import 'dart:async'; import 'dart:io'; import 'dart:math' show Random; import 'package:flutter/foundation.dart'; import '../data/mock_data.dart'; import '../models/catia_document.dart'; import '../models/catia_instance.dart'; import '../models/catia_status.dart'; import '../models/history_entry.dart'; import 'catia_file_types.dart'; import 'desktop_groups.dart'; import 'everything_search_service.dart'; import 'macro_library.dart'; import 'native_bridge.dart'; import 'open_ghosts.dart'; import 'palette_model.dart'; import 'palette_service.dart'; import 'palette_shell_state.dart'; import 'palette_state.dart'; import 'restore_plan.dart'; import 'script_run.dart'; import 'session_picker_service.dart'; import 'session_service.dart'; import 'settings_window_service.dart'; import 'virtual_desktop_service.dart'; import 'vk_shortcut.dart'; import 'window_documents.dart'; import 'window_tray_service.dart'; import '../theme/app_theme.dart'; enum HomeTab { active, history, search } /// [CatiaProvider._probeDesktopInfo] sonucu — 🚀 PERF (29. oturum, paralel /// masaüstü taraması). `_instances` mutasyonu İÇERMEZ, sadece ham veriyi /// taşır; bu sayede birden çok id için aynı anda (Future.wait) üretilebilir. /// `psFallbackHwnd` doluysa native GUID okunamamış demektir — çağıran taraf /// bunu PowerShell yedek okumasına (`VirtualDesktopService.desktopsOfWindows`) /// TEK toplu çağrıda gönderir. class _DesktopProbeResult { final String id; final ({int hwnd, int pid})? window; final ({ bool valid, String guid, int? number, String name, String title, })? info; final int? psFallbackHwnd; const _DesktopProbeResult({ required this.id, this.window, this.info, this.psFallbackHwnd, }); } /// Uygulamanın tek state kaynağı. Şu an mock veriyle besleniyor; /// Phase 7'de bu sınıfın içi gerçek server bağlantısına (stream/polling) /// bağlanacak, dışarıya bakan API (instances/history/search) aynı kalacak. class CatiaProvider extends ChangeNotifier { /// Gerçek uygulama mı (Windows exe), yoksa `flutter test` mi? Testte /// `FLUTTER_TEST` ortam değişkeni tanımlıdır. static bool get _isRealApp => Platform.isWindows && !Platform.environment.containsKey('FLUTTER_TEST'); /// [useMockData]: MockData örnek kartlarıyla başla. Varsayılan: yalnızca /// testte true. Gerçek uygulamada örnek kartlar (VOLBER_245, KAPAK_55, /// GOVDE_220…) HİÇ yüklenmez — aksi halde macro'suz/bağlı olmayan CATIA'lar /// varken sahte kartlar listede görünür (ve oturuma yazılabilirdi). CatiaProvider({bool? useMockData}) { final mock = useMockData ?? !_isRealApp; _instances = mock ? MockData.instances() : []; _mockActive = mock; _history = MockData.history(); // Mock: her instance'a stabil sahte masaüstü GUID'i ver (M1-M3 dağılımı) // ve 1-2 örnek açık döküman ekle. for (var i = 0; i < _instances.length; i++) { final cur = _instances[i]; final bucket = i % 3 + 1; final docs = [ CatiaDocument( id: '${cur.id}-doc1', name: cur.fileName, path: r'D:\Projeler\VOLBER\', saved: true, ), if (i.isEven) CatiaDocument( id: '${cur.id}-doc2', name: 'MONTAJ_${cur.id.substring(6)}.CATProduct', path: r'D:\Projeler\VOLBER\Montaj\', saved: false, ), ]; _instances[i] = cur.copyWith( desktopGuid: 'mock-desk-$bucket', documents: docs, ); } // CATIA COM bridge artık bu process'in kendi native (Windows runner) // tarafında barındırılıyor (bkz. windows/runner/com_bridge.h) — ayrı bir // server process/JSON dosyası/polling yok. RegisterInstance/UpdateStatus/ // Unregister her çağrıldığında native taraf anlık push eder; hiç // instance kaydolmadıysa mock liste öylece kalır. NativeBridge.listenInstancesSnapshot(_onNativeSnapshot); // 🔗 Everything arama penceresi açık döküman listesini "bağlı" rozeti // için ister (bkz. everything_search_service.dart pushOpenPaths) — her // notifyListeners()'da (kart/döküman değişince) tetiklenir, değişmediyse // kanal çağrısı atlanır (bkz. _pushOpenDocsToEverything). addListener(_pushOpenDocsToEverything); // Pencere kapalıyken oluşan durum değişikliği hiç gönderilmemiş olabilir // (bkz. EverythingSearchWindowService.onShown dokümantasyonu) — pencere // her gösterildiğinde GÜNCEL anlık görüntüyü zorla gönder. EverythingSearchWindowService.onShown = () => _pushOpenDocsToEverything(force: true); // 🧰 Palet köprüsü (D9 (4)): pencere servisi KARAR ÜRETMEZ; kararlar // yalnız burada (tek state kaynağı) alınır ve `updatePaletteSettings` ile // servise bildirilir. Palet düğmesine tıklama da buraya döner. PaletteService.onRun = (entryId) => runPaletteEntry(entryId); PaletteService.onMoved = (pos) => updatePaletteSettings(_palette.copyWith(pos: pos)); PaletteService.onViewMode = (m) => updatePaletteSettings(_palette.copyWith(viewMode: m)); PaletteService.onLocked = (v) => updatePaletteSettings(_palette.copyWith(locked: v)); PaletteService.onAnchor = () => updatePaletteSettings(_palette.copyWith(clearPos: true)); PaletteService.onHidden = () async { // Anında gizle (gözle görülür gecikme olmasın), sonra kalıcı ayarı yaz. await PaletteService.hideNow(); await updatePaletteSettings(_palette.copyWith(hidden: true)); }; // Tray menüsündeki "🧰 Palet" öğesi (window_tray_service). WindowTrayService.onTogglePalette = togglePalette; // 🧰 Palet modu menü öğesi (her zaman ↔ yalnız CATIA öndeyken; O2/D2). WindowTrayService.onTogglePaletteMode = togglePaletteShowMode; // I9: palet penceresi native'e kaydedilince BİR KEZ Pin (tüm masaüstleri); // Pin başarısızsa yedek: hedef CATIA masaüstü takibi (R3). PaletteService.onRegistered = _pinPaletteWindow; // O2/D2: showMode == whenCatia iken görünürlük kararı (ön planda CATIA/biz). PaletteService.wantAutoVisible = _paletteWantsAutoVisible; // 👁/🌐 palet başlığındaki showMode düğmesi. PaletteService.onShowModeToggle = togglePaletteShowMode; // 🎯 palet başlığındaki hedef alanı (sıradaki bağlı CATIA / otomatik). PaletteService.onTargetCycle = cyclePaletteTarget; _loadInitialSnapshot(); // Masaüstü değişimi COM push'undan bağımsız bir olaydır (kullanıcı // CATIA penceresini bir sonraki UpdateStatus'tan önce başka bir sanal // masaüstüne sürükleyebilir) — bu yüzden ayrıca periyodik olarak da // (native push'a bakılmaksızın) yokluyoruz. _scheduleDesktopPoll(); // Windows'ta o an AKTİF (ön plandaki) pencerenin hangi CATIA'ya ait // olduğunu göstermek için hafif/hızlı ayrı bir poll — 4 sn'lik masaüstü // turundan bağımsız, kullanıcı pencere değiştirdiğinde vurgu gecikmesin // diye 400ms. Salt okunur Win32 çağrısı (I5: getShellState, eskisi // getForegroundWindowPid), COM'a dokunmaz. _activeWindowPollTimer = Timer.periodic(const Duration(milliseconds: 400), (_) async { // I5 (tek poll): getShellState TEK çağrıda ön plan PID + popup // görünürlüğü + kendi pid'imizi verir (palet yerleşimi de aynı kaynağı // okur). Eski exe'de null → eski getForegroundWindowPid'e düşer. final shell = await NativeBridge.getShellState(); int fg = 0; String fgTitle = ''; if (shell != null) { fg = shell.fgPid; fgTitle = shell.fgTitle; _selfPid = shell.selfPid; _shellPopupVisible = shell.popupVisible; } else { fg = await NativeBridge.getForegroundWindowPid(); } final normalized = fg > 0 ? fg : null; if (normalized != _activePid) { _activePid = normalized; _safeNotify(); } // 🧰 AYNI PID'de birden fazla kayıtlı kart olabilir (tek CATIA // sürecinde çoklu döküman penceresi — bkz. _resolveInstanceForWindow). _activeFgTitle = fgTitle; // 🧰 Yapışkan hedef HER turda tazelensin: pid → id eşleşmesi (CATIA // kapanıp yeniden kaydolursa id başka sürece geçebilir) yalnız pid // DEĞİŞİNCE güncelleniyorsa bayat kalıp YANLIŞ CATIA'ya gidebiliyordu. _updatePaletteTarget(); // Teşhis: ~10 saniyede bir ön plan/hedef durumu (yanlış CATIA // şüphesinde fikatia_debug.log'dan izlenir). if (++_pollLogTick % 25 == 0) { debugPrint('PALET-POLL: fg=${_activePid ?? 0} self=$_selfPid ' 'hedef=${_paletteTargetId ?? '-'} pin=$_paletteTargetPinned ' 'kayıtlı=[${_instances.where((i) => i.pid > 0).map((i) => '${i.id}:${i.pid}${i.connected ? '' : '!'}').join(',')}]'); } }); // Kayıtlı oturum + ayarlar (diskten) — açılışta geri yükleme teklifi / // otomatik geri yükleme için. unawaited(_initSession()); } Timer? _desktopPollTimer; Timer? _activeWindowPollTimer; /// O AN Windows'ta aktif (ön plandaki) pencerenin sahibi sürecin PID'i; /// hiçbir CATIA aktif değilse (ya da bilinmiyorsa) null. UI, listedeki /// `instance.pid == activePid` olan kartı vurgulamak için kullanır. int? _activePid; int? get activePid => _activePid; /// Ön plandaki pencerenin başlık metni (bkz. [PaletteShellState.fgTitle]). /// AYNI PID'de birden fazla kayıtlı kart varsa (tek CATIA sürecinde çoklu /// döküman) hedef eşleştirmede pid'e ek ikinci sinyal olarak kullanılır. String _activeFgTitle = ''; /// I5 (tek poll): `getShellState`'ten gelen yardımcılar. [_selfPid] = bu /// sürecin pid'i (paleti başka masaüstüne taşırken `moveWindowToDesktop`'e /// pid + hwnd verilir); [_shellPopupVisible] = popup şu an görünür mü → /// masaüstü poll aralığı buna göre uzar (popup VE palet gizliyken 4 → 10 sn; /// 24. oturum perf notu). Eski exe'de getShellState yoksa varsayılanlar ESKİ /// davranışı korur (popup görünür say, selfPid 0 → hwnd ile taşınır). int _selfPid = 0; bool _shellPopupVisible = true; /// PALET-POLL teşhis satırı sayacı (25 turda bir = ~10 sn). int _pollLogTick = 0; /// Masaüstü/ghost tarama turunu PLANLAR (Timer.periodic değil — aralık /// popup/palet görünürlüğüne göre değişir). void _scheduleDesktopPoll() { _desktopPollTimer?.cancel(); _desktopPollTimer = Timer(_desktopPollInterval, _runDesktopPollTick); } /// Popup ve paletin İKİSİ de gizliyken aralık 4 sn → 10 sn. Duration get _desktopPollInterval => (_shellPopupVisible || paletteVisible) ? const Duration(seconds: 4) : const Duration(seconds: 10); void _runDesktopPollTick() { refreshDesktops(); // 👻 Çalışan ama bağlanmamış CATIA süreçlerini de aynı turda tara // (salt okunur, COM'a dokunmaz; kendi meşgul kilidi var). unawaited(_scanCatiaProcesses()); // Gruplu görünümde masaüstü listesi (ad/sıra/aktif) de tazelenir — // registry okuması olduğu için ucuz. if (_settings.listMode == ListMode.byDesktop) refreshDesktopList(); // 🧰 Pin başarısızsa palet hedef CATIA'nın masaüstünü takip eder (yedek). unawaited(_maybeFollowTargetDesktop()); if (!_disposed) _scheduleDesktopPoll(); } /// Ayarlardaki [SessionSettings.autoSaveIntervalMs] periyoduyla sessizce /// "son oturum" kopyasına kaydeden zamanlayıcı (bkz. [_scheduleAutoSave]). Timer? _autoSaveTimer; bool _disposed = false; @override void dispose() { _disposed = true; _desktopPollTimer?.cancel(); _activeWindowPollTimer?.cancel(); _autoSaveTimer?.cancel(); // 🧰 Palet yerleşim döngüsü + sürükleme push'u kapatılır (pencere kalırsa // sonraki provider `ensure()` ile yeniden başlatır). PaletteService.stop(); super.dispose(); } /// Uzun süren async işler (oturum geri yükleme) provider dispose /// edildikten sonra da bitebilir; o durumda notify etme. void _safeNotify() { if (_disposed) return; notifyListeners(); // 🧰 Palet içeriği her durum değişiminde tazelenir; pencere servisi JSON // imzasını karşılaştırdığı için aynı veri kanaldan TEKRAR gitmez. _pushPaletteState(); } late List _instances; /// `_instances` hâlâ MockData örnek kartlarını mı tutuyor? (Mock kartların /// pid'i gerçek görünümlüdür — pid'den ayırt EDİLEMEZ.) İlk gerçek native /// snapshot listeyi baştan kurunca ([_onNativeSnapshot]) ya da test listeyi /// elle kurunca ([debugSetInstances]) false olur. bool _mockActive = true; late List _history; bool _serverConnected = false; bool get serverConnected => _serverConnected; /// Açılışta (ilk push gelmeden önce) native tarafın o an elinde tuttuğu /// anlık görüntüyü bir kerelik sorgular (örn. macro, UI açılmadan önce /// zaten RegisterInstance çağırmışsa). Future _loadInitialSnapshot() async { final raw = await NativeBridge.getInstancesSnapshot(); if (raw.isNotEmpty) _onNativeSnapshot(raw); } /// Son itilen (Everything'e) açık döküman anlık görüntüsü — değişmediyse /// [_pushOpenDocsToEverything] gereksiz kanal çağrısı atlar. Map? _lastPushedOpenDocs; /// Tüm kartların açık dökümanlarını (yer tutucular hariç) normalize /// edilmiş (küçük harf) tam yol → kart başlığı eşlemesine çevirir. Ayrık /// pencereye itişte ([_pushOpenDocsToEverything]) VE ana pencereye gömülü /// arama panelinde ([openDocPaths], bkz. widgets/everything_search_panel.dart) /// ORTAK kullanılır. Map _computeOpenDocsRefMap() { final map = {}; for (final inst in _instances) { if (inst.placeholder) continue; for (final doc in inst.documents) { if (doc.path.isEmpty && doc.name.isEmpty) continue; map['${doc.path}${doc.name}'.toLowerCase()] = (id: inst.id, title: inst.title); } } return map; } Map _computeOpenDocsMap() => _computeOpenDocsRefMap().map((k, v) => MapEntry(k, v.title)); /// Gömülü Everything arama paneli (HomeTab.search — aynı pencere, aynı /// process) "🔗 bağlı" rozeti için bunu DOĞRUDAN okur: ayrık pencere /// deseninin aksine (bkz. EverythingSearchWindowService.pushOpenPaths) /// kanal/köprüye gerek yoktur, provider zaten widget ağacında. Map get openDocPaths => _computeOpenDocsMap(); /// [openDocPaths] ile aynı eşleme ama başlığın yanında kart `id`'si de /// taşır — panelde 🔗 rozetine tıklayınca [focusInstance] çağırabilmek /// için (bkz. widgets/everything_search_panel.dart _ResultRow). Map get openDocRefs => _computeOpenDocsRefMap(); /// Hesaplanan haritayı `addListener` ile HER `notifyListeners()` sonrası /// (bkz. constructor) ayrık Everything penceresine iter; harita önceki /// itişle aynıysa (çoğu durum — ilerleme/yüzde güncellemeleri dökümanları /// etkilemez) kanal çağrısı yapılmaz — [force] true ise (pencere yeni /// gösterildi, bkz. `onShown`) bu kısayol atlanır: pencere kapalıyken /// oluşmuş ama HİÇ gönderilmemiş bir durum olabilir. void _pushOpenDocsToEverything({bool force = false}) { final map = _computeOpenDocsMap(); final last = _lastPushedOpenDocs; if (!force && last != null && last.length == map.length && last.entries.every((e) => map[e.key] == e.value)) { return; } _lastPushedOpenDocs = map; unawaited(EverythingSearchWindowService.pushOpenPaths(map)); } /// Native COM bridge'den gelen push/sorgu sonucunu işler. Kullanıcının /// çift tıklayarak verdiği özel başlık (displayName) — id eşleşmesiyle /// korunur. Döküman listesi artık native'den (CATIA'nın gerçek /// Application.Documents koleksiyonundan) geliyor, bu yüzden burada /// eskisi gibi üstüne yazılmıyor. void _onNativeSnapshot(List raw) { if (raw.isEmpty) return; final real = raw.map(_instanceFromNative).toList(); final existingById = {for (final i in _instances) i.id: i}; // Dökümanların KAPANMADAN ÖNCEKİ hâli — snapshot uygulandıktan sonra // "hangi döküman artık listede yok" diye kıyaslamak için (bkz. // _trackClosedDocuments). final prevDocsById = {for (final i in _instances) i.id: i.documents}; _mockActive = false; // liste artık gerçek native verisinden kuruluyor _instances = real.map((r) { final prev = existingById[r.id]; // İlk kez görülen (ya da mock'tan gerçeğe geçen) CATIA: başlığı // CATIA'nın kendi pencere başlığından türet. if (prev == null || prev.pid <= 0) return _applyInitialTitle(r); // Masaüstü bilgisi timer ile (gerçek HWND'den) güncelleniyor; her // snapshot push'unda sıfırlanıp rozet "M?"ye dönmesin diye taşınır. return r.copyWith( displayName: prev.displayName, desktopGuid: r.desktopGuid.isEmpty ? prev.desktopGuid : r.desktopGuid, desktopNumber: prev.desktopNumber, desktopName: prev.desktopName, ); }).toList(); // Kart listesinin kaynağı `catia.Windows` ise snapshot'taki pencere // listesini sakla ve dökümanları yol/kayıt bilgisiyle birleştir. _applyWindowsFromSnapshot(raw); _applyWindowDocuments(); // Artık listede olmayan (kapanmış) dökümanları "🕘 daha önce açıktı" // geçmişine taşır — hem ❌ ile bizim kapattığımızda hem CATIA'nın // kendisinde/dışarıdan kapandığında (kaynak farketmez, tek gerçek // kaynak native snapshot'tır). _trackClosedDocuments(prevDocsById); // 🔎 Yeni bağlanan (ya da bu app run'ında ilk kez görülen — TAM OLARAK // "yeniden açılış" durumu) CATIA'ların son kayıtlı oturumdaki hangi // kayda ait olduğunu ortak açık döküman yoluna göre çözmeye çalışır. _recognizeFromSavedSession(); // 👻 Kayıtlı oturumda olup hâlâ hiçbir canlı CATIA'yla eşleşmeyen kayıtlar // "bağlı değil" yer tutucu kart olarak listede kalır; eşleşenler düşer. _rebuildGhosts(); // Artık listede olmayan CATIA'ların pencere önbelleğini temizle. final liveIds = {for (final i in _instances) i.id}; _windowCache.removeWhere((k, _) => !liveIds.contains(k)); _probeRetryAt.removeWhere((k, _) => !liveIds.contains(k)); _winDocs.removeWhere((k, _) => !liveIds.contains(k)); _winSig.removeWhere((k, _) => !liveIds.contains(k)); _recognitionDone.removeWhere((k) => !liveIds.contains(k)); _serverConnected = true; // 🧰 Hedef CATIA/bağlam değişmiş olabilir (yeni kart, kopma, workbench). _updatePaletteTarget(); notifyListeners(); // Yeni bağlanan CATIA'ları bul (ilk snapshot hariç: uygulama açılırken // zaten bağlı olanlar için bildirim basmayalım). if (_firstSnapshotDone) { for (final r in real) { if (!existingById.containsKey(r.id) && r.pid > 0 && r.connected) { _showConnectToast(r); } } } _firstSnapshotDone = true; // Kart widget'ı zaten mount olmuşsa (mock'tan gerçek instance'a // aynı id ile geçildiğinde State yeniden kurulmaz, initState tekrar // çalışmaz) CatiaListTile'ın initState'i bir daha tetiklenmeyebilir. // Bu yüzden gerçek pid geldiği her anda masaüstü sorgusunu burada da // (widget'tan bağımsız olarak) tetikliyoruz — fire-and-forget. unawaited(refreshDesktops()); } /// [_recognizeFromSavedSession] her id için en fazla bir kez denesin diye /// (dökümanlar henüz gelmediyse boş bırakılır, sonraki snapshot'ta id /// burada olmadığı için tekrar denenir). final Set _recognitionDone = {}; /// Bir kayıtlı oturum kaydına (SavedInstance.id) zaten atanmış CATIA'lar — /// aynı kayıt iki canlı CATIA'ya birden "sahiplenilmiş" sayılmasın. final Set _claimedSavedIds = {}; // ---- 👻 Kayıtlı ama henüz bağlanmamış CATIA'lar (yer tutucu kartlar) ---- // // Uygulama açıldığında kayıtlı oturumdaki (💾 / oto-kayıt) CATIA'lar // "hâlâ açılmadı" diye listede "🔌 bağlı değil" kartlarıyla görünür; o // CATIA (ya da aynı dökümanları taşıyan bir CATIA) bağlanınca yer tutucu // düşer, yerini gerçek kart alır. Yer tutucular `_instances`'a KONMAZ // (native snapshot o listeyi baştan kurar); yalnızca gösterim listelerine // ([_queryFiltered]) eklenir. Böylece kaydet/geri yükle/masaüstü // işlemleri onları hiç görmez. /// Yer tutucu kart id öneki ("ghost:CATIA-02"). static const _ghostPrefix = 'ghost:'; /// Bu app run'ında kullanıcının ❌ ile gizlediği kayıtlar (SavedInstance.id). /// Kayıt dosyasına DOKUNMAZ; yalnızca listede gizler. final Set _dismissedGhostIds = {}; List _ghosts = const []; // ---- 🔎 Açık ama bağlanmamış CATIA süreçleri (süreç taraması) ---- // // Macro `RegisterInstance` çağırmayan bir CATIA native snapshot'ta hiç // yoktur. Bu yüzden çalışan CATIA SÜREÇLERİ (exe adına göre) periyodik // taranır; pid'i hiçbir bağlı CATIA'ya ait olmayan süreç, kısa bir bekleme // süresinden sonra "🔌 Açık ama bağlanmadı" hayaleti olur (bkz. // open_ghosts.dart). Tarama salt okunur: COM/CATIA'ya dokunmaz. /// Son taramanın sonucu. List _scanned = const []; /// pid → süreci İLK gördüğümüz an (yeni açılan CATIA'nın macro'su birkaç /// sn sonra kayıt olur; bekleme süresi dolmadan hayalet basılmaz). final Map _procFirstSeen = {}; /// Bu app run'ında ❌ ile gizlenen süreçler (pid). final Set _dismissedProcPids = {}; /// pid → o sürece eşlenmiş kayıtlı oturum kaydı (SavedInstance.id). /// Süreç yaşadığı sürece eşleşme korunur; pencere başlığı değişse de kart /// "Açık ama bağlanmadı"ya dönüp gri kayıtlı kartı geri getirmez. final Map _procSavedClaims = {}; /// pid → üst üste kaç taramada görünmedi (bkz. [_scanCatiaProcesses]). final Map _procMiss = {}; bool _scanBusy = false; /// Aranacak süreç (exe) adları: `CNEXT.exe` + (ayarlardaki başlatıcı bir /// .exe ise) onun dosya adı. Gerçek ad makineye göre değişebilir — /// `com_bridge.log`'taki `listCatiaProcesses: N surec bulundu` satırından /// doğrulanır. List get _catiaExeNames { final names = {'cnext.exe'}; final exe = _settings.catiaExe.trim(); if (exe.toLowerCase().endsWith('.exe')) { names.add(exe.split(RegExp(r'[\\/]')).last.toLowerCase()); } return names.toList(); } /// hwnd → son PowerShell okumasındaki (numara, ad). Yalnızca native /// okuyamayan süreçler için; çağrılar arasında rozetin titremesini önler. final Map _procDeskCache = {}; DateTime _psScanNextAt = DateTime.fromMillisecondsSinceEpoch(0); /// Native masaüstü okuyamadığı (numara yok) süreçlerin masaüstünü /// `VirtualDesktop` PowerShell modülüyle okur (tek çağrı; ≥5 sn arayla, /// hata olursa 30 sn geri çekilme). Okunamayan süreç numarasız kalır. Future> _fillDesktopsViaPs( List list) async { final missing = [ for (final p in list) if (p.desktopNumber == null && p.hwnd != 0) p ]; _procDeskCache.removeWhere((h, _) => !list.any((p) => p.hwnd == h)); if (missing.isEmpty) return list; if (DateTime.now().isAfter(_psScanNextAt)) { final res = await VirtualDesktopService.desktopsOfWindows( [for (final p in missing) p.hwnd]); _psScanNextAt = DateTime.now().add( res == null ? const Duration(seconds: 30) : const Duration(seconds: 5)); if (res != null) { for (final p in missing) { final r = res[p.hwnd]; if (r == null || r.index < 0) continue; final isDefault = RegExp(r'^(Masaüstü|Desktop)\s+\d+$', caseSensitive: false) .hasMatch(r.name.trim()); _procDeskCache[p.hwnd] = (number: r.index + 1, name: isDefault ? '' : r.name.trim()); } } } return [ for (final p in list) if (p.desktopNumber == null && _procDeskCache.containsKey(p.hwnd)) ScannedProcess( pid: p.pid, hwnd: p.hwnd, title: p.title, exe: p.exe, guid: p.guid, desktopNumber: _procDeskCache[p.hwnd]!.number, desktopName: _procDeskCache[p.hwnd]!.name, ) else p, ]; } String _ghostSig() => _ghosts .map((g) => '${g.id}|${g.title}|${g.fileName}|${g.desktopNumber}|' '${g.documents.length}') .join(';'); /// Çalışan CATIA süreçlerini native'den okur, hayalet listesini yeniler; /// görünür bir değişiklik olduysa bildirir. Kendi meşgul kilidi vardır. Future _scanCatiaProcesses() async { if (_disposed || _scanBusy) return; _scanBusy = true; try { final rawFound = await NativeBridge.listCatiaProcesses(_catiaExeNames); if (_disposed) return; // Native masaüstü NUMARASINI çözemediyse ama GUID'i okuduysa, numarayı // masaüstü listesinden (GUID eşlemesiyle) tamamla → doğru grupta görünür. String ng(String g) => g.replaceAll(RegExp(r'[{}\s]'), '').toLowerCase(); final withGuid = [ for (final p in rawFound) if (p.desktopNumber == null && p.guid.isNotEmpty) (() { final d = _desktopList .where((x) => x.guid.isNotEmpty && ng(x.guid) == ng(p.guid)) .firstOrNull; if (d == null || d.index < 0) return p; return ScannedProcess( pid: p.pid, hwnd: p.hwnd, title: p.title, exe: p.exe, guid: p.guid, desktopNumber: d.index + 1, desktopName: p.desktopName.isNotEmpty ? p.desktopName : d.name, ); })() else p, ]; // Native IVirtualDesktopManager bu makinede oluşturulamıyorsa // (CoCreateInstance 0x80040154) numara PowerShell modülüyle okunur. final found = await _fillDesktopsViaPs(withGuid); if (_disposed) return; final now = DateTime.now(); // Oturmuş (≥ bekleme süresi görülmüş) bir süreç taramada BİR-İKİ tur // görünmezse (pencere gizlendi, diyalog açıldı, başlık boş döndü) // hemen silinmez: aksi halde firstSeen sıfırlanır, hayalet 12 sn kaybolur // ve kayıtlı gri kart geri gelirdi ("gidip geliyor"). Gerçekten kapanan // süreç en fazla ~3 tur (≈12 sn) sonra düşer. final foundPids = {for (final p in found) p.pid}; final list = [...found]; for (final old in _scanned) { if (foundPids.contains(old.pid)) { _procMiss.remove(old.pid); continue; } final seen = _procFirstSeen[old.pid]; final established = seen != null && now.difference(seen) >= const Duration(seconds: 12); final miss = (_procMiss[old.pid] ?? 0) + 1; if (established && miss <= 3) { _procMiss[old.pid] = miss; list.add(old); } else { _procMiss.remove(old.pid); } } final pids = {for (final p in list) p.pid}; _procFirstSeen.removeWhere((k, _) => !pids.contains(k)); _dismissedProcPids.removeWhere((k) => !pids.contains(k)); _procSavedClaims.removeWhere((k, _) => !pids.contains(k)); for (final p in list) { _procFirstSeen.putIfAbsent(p.pid, () => now); } _scanned = list; final before = _ghostSig(); _rebuildGhosts(); if (_ghostSig() != before) { for (final g in _ghosts.where((g) => g.isOpenUnconnected)) { _dlog('ACIK-BAGLANMAMIS ${g.id} baslik="${g.title}" ' 'masaustu=${g.desktopNumber ?? '?'}'); } _safeNotify(); } } finally { _scanBusy = false; } } /// 👁 (açık ama bağlanmamış hayalet) sürecin penceresini öne getirir; /// başka masaüstündeyse önce o masaüstüne geçer (bkz. /// [bringInstanceToFront]). Future focusOpenGhost(String ghostId) async { if (!ghostId.startsWith(openGhostPrefix)) return; final pid = int.tryParse(ghostId.substring(openGhostPrefix.length)); final p = _scanned.where((x) => x.pid == pid).firstOrNull; if (p == null || p.hwnd == 0) return; await _focusHwnd(p.hwnd, 'Açık CATIA (pid ${p.pid})'); } /// 🔌 (kehribar / açık-ama-bağlanmamış hayalet): pencereyi öne getirip /// Ayarlar'daki bağlantı kısayolunu (ör. "Ctrl+Alt+F9") gönderir. CATIA /// tarafında bu kısayola Tools>Customize>Commands'tan bağlantı makrosu /// atanmış olmalı — bkz. PROJECT_PROGRESS.md 26. oturum (ROT üzerinden /// doğrudan COM bağlantısı denendi, IDispatch olmayan tek kayıt bulundu, /// bu yüzden SendKeys'e geçildi). Kısayol tanımlı değilse, pencere /// bulunamazsa ya da öne gelmezse tuş GÖNDERİLMEZ, uyarı mesajı verilir. Future triggerConnectShortcut(String ghostId) async { if (!ghostId.startsWith(openGhostPrefix)) return; final keys = parseShortcutKeys(_settings.connectShortcut); if (keys == null) { _setSessionMessage( 'Ayarlarda geçerli bir bağlantı kısayolu tanımlı değil', warn: true); return; } final pid = int.tryParse(ghostId.substring(openGhostPrefix.length)); final p = _scanned.where((x) => x.pid == pid).firstOrNull; if (p == null || p.hwnd == 0) { _setSessionMessage('CATIA penceresi bulunamadı', warn: true); return; } final ok = await NativeBridge.sendShortcutToWindow(p.hwnd, keys); if (_disposed) return; if (!ok) { _setSessionMessage( 'Kısayol gönderilemedi (pencere öne gelmedi)', warn: true); } else { _setSessionMessage( 'Bağlantı kısayolu gönderildi — CATIA\'nın makroyu çalıştırması bekleniyor'); } } /// Listede şu an görünen "açık ama bağlanmamış" (kehribar) kart sayısı. int get openUnconnectedCount => _ghosts.where((g) => g.isOpenUnconnected).length; bool _connectAllBusy = false; bool get connectAllBusy => _connectAllBusy; /// Kehribar kartlar arasında beklenen süre: CATIA'nın kısayola bağlı /// makroyu çalıştırıp bağlanması için zaman tanır; ayrıca bir sonraki /// pencereye geçerken tuşlar birbirine karışmasın. static const Duration _connectAllGap = Duration(milliseconds: 1500); /// 🔌 (ana toolbar): bağlanmamış TÜM açık CATIA'lara sırayla /// [triggerConnectShortcut] ile aynı yöntemle bağlantı kısayolunu gönderir. /// Her pencere öne getirilip tuş basıldıktan sonra [_connectAllGap] kadar /// beklenir. Öne gelmeyen pencereye tuş GÖNDERİLMEZ (atlanır, sayılır). /// Bitince "N/M gönderildi" özeti verilir; bağlantının kurulup kurulmadığı /// kartların yeşile dönmesinden anlaşılır. Future triggerConnectShortcutAll() async { if (_connectAllBusy) return; final keys = parseShortcutKeys(_settings.connectShortcut); if (keys == null) { _setSessionMessage( 'Ayarlarda geçerli bir bağlantı kısayolu tanımlı değil', warn: true); return; } // Döngü sırasında liste değişebilir → pid'leri baştan sabitle. final pids = [ for (final g in _ghosts.where((g) => g.isOpenUnconnected)) ?int.tryParse(g.id.substring(openGhostPrefix.length)), ]; if (pids.isEmpty) { _setSessionMessage('Bağlanmamış açık CATIA yok'); return; } _connectAllBusy = true; _safeNotify(); var sent = 0; var skipped = 0; try { for (var i = 0; i < pids.length && !_disposed; i++) { _setSessionMessage( 'Bağlantı kısayolu gönderiliyor (${i + 1}/${pids.length})…'); final p = _scanned.where((x) => x.pid == pids[i]).firstOrNull; // Bu arada bağlanmış ya da kapanmış olabilir → atla. if (p == null || p.hwnd == 0) { skipped++; continue; } final ok = await NativeBridge.sendShortcutToWindow(p.hwnd, keys); if (_disposed) return; if (ok) { sent++; } else { skipped++; } if (i < pids.length - 1) await Future.delayed(_connectAllGap); } } finally { _connectAllBusy = false; _safeNotify(); } if (_disposed) return; if (skipped == 0) { _setSessionMessage( 'Bağlantı kısayolu $sent CATIA\'ya gönderildi — kartların yeşile ' 'dönmesi bekleniyor'); } else { _setSessionMessage( 'Bağlantı kısayolu $sent/${pids.length} CATIA\'ya gönderildi; ' '$skipped tanesi atlandı (pencere bulunamadı ya da öne gelmedi)', warn: true); } } /// 👁 (kayıtlı-kapalı gri hayalet): kayıtla eşleşen ÇALIŞAN bir CATIA süreci /// (macro kaydı yok ya da başlığı kayıtla örtüşmediği için gri kalmış olabilir) /// aranır. Eşleşme: pencere başlığı kaydın başlığına eşit ya da kaydın bir /// dökümanının adını içeriyor; bağlı CATIA'ların süreçleri hariç. Aday TAM /// BİR tane ise odaklanır (yanlış CATIA'ya gitmemek için); yoksa "açık /// görünmüyor", birden çoksa "belirsiz" uyarısı verir. Future focusSavedGhost(String ghostId) async { final g = _ghosts.where((x) => x.id == ghostId).firstOrNull; if (g == null) return; final found = await NativeBridge.listCatiaProcesses(_catiaExeNames); if (_disposed) return; final livePids = { for (final i in (_mockActive ? const [] : _instances)) if (i.pid > 0) ...[ i.pid, if (_windowCache[i.id] != null) _windowCache[i.id]!.pid, ], }; String n(String s) => s.trim().toLowerCase(); final def = n(_settings.defaultCaption); final title = n(g.title); final docNames = { for (final d in g.documents) if (n(d.name).isNotEmpty) n(d.name), }; final cands = found.where((p) { if (p.hwnd == 0 || livePids.contains(p.pid)) return false; final t = n(p.title); if (t.isEmpty) return false; if (title.isNotEmpty && title != def && t == title) return true; return docNames.any(t.contains); }).toList(); if (cands.isEmpty) { _setSessionMessage( '${g.title}: çalışan bir CATIA bulunamadı (kapalı görünüyor)', warn: true); return; } if (cands.length > 1) { _setSessionMessage( '${g.title}: ${cands.length} CATIA eşleşti, hangisi belirsiz', warn: true); return; } await _focusHwnd(cands.first.hwnd, g.title); } /// HWND'yi öne getirir; başka masaüstündeyse önce o masaüstüne geçer. Future _focusHwnd(int hwnd, String label) async { var ok = await NativeBridge.bringWindowToFront(hwnd); if (!ok) { await VirtualDesktopService.switchToDesktopOfWindow(hwnd); for (var k = 0; k < 4 && !ok && !_disposed; k++) { await Future.delayed(const Duration(milliseconds: 250)); ok = await NativeBridge.bringWindowToFront(hwnd); } } if (!ok) { _setSessionMessage('$label: pencere öne getirilemedi', warn: true); } } /// Kayıtlı oturumda olup HİÇBİR canlı CATIA'yla eşleşmeyen kayıtlar. /// /// Eşleşme = ortak açık döküman yolu (restoreSession'daki [_matchLive] ile /// aynı ölçüt). Canlı × kayıt çiftleri ortak döküman sayısına göre /// büyükten küçüğe atanır (1'e 1). [_recognizeFromSavedSession]'ın daha /// önce sahiplendiği kayıtlar da eşleşmiş sayılır (döküman sonradan /// kapansa bile). Hiç yolu olan dökümanı olmayan kayıt (boş CATIA) /// tanınamayacağı için yer tutucu OLMAZ. List _unmatchedSavedInstances() { final session = _savedSession; if (session == null) return const []; // Mock kartlar (pid'leri gerçek görünümlü) canlı CATIA SAYILMAZ. final live = _mockActive ? const [] : _instances.where((i) => i.pid > 0).toList(); final livePaths = [ for (final r in live) { for (final d in r.documents) if (d.path.isNotEmpty) '${d.path}${d.name}'.toLowerCase(), }, ]; final pairs = <({int score, int li, int si})>[]; for (var li = 0; li < live.length; li++) { for (var si = 0; si < session.instances.length; si++) { final score = session.instances[si].documents .where((d) => livePaths[li].contains(d.fullPath.toLowerCase())) .length; if (score > 0) pairs.add((score: score, li: li, si: si)); } } pairs.sort((a, b) => b.score.compareTo(a.score)); final usedLive = {}; final usedSaved = {}; for (final p in pairs) { if (usedLive.contains(p.li) || usedSaved.contains(p.si)) continue; usedLive.add(p.li); usedSaved.add(p.si); } return [ for (var si = 0; si < session.instances.length; si++) if (!usedSaved.contains(si) && !_claimedSavedIds.contains(session.instances[si].id) && session.instances[si].documents.any((d) => d.path.isNotEmpty)) session.instances[si], ]; } /// [_ghosts]'u kayıtlı oturum + canlı CATIA'lardan yeniden üretir /// (bildirim YAPMAZ; çağıran notify eder). void _rebuildGhosts() { final unmatched = _unmatchedSavedInstances(); final live = _mockActive ? const [] : _instances.where((i) => i.pid > 0).toList(); // 🔎 Çalışan ama bağlanmamış süreçler (+ temkinli kayıt eşlemesi). Bir // sürece güvenle eşlenen kayıt ayrıca "kapalı" hayalet OLMAZ. final open = resolveOpenGhosts( scanned: _scanned, // Makronun bildirdiği pid GÜVENİLMEZ (birkaç CATIA aynı pid'i // bildirebiliyor); bağlı CATIA'nın GERÇEK pencere pid'i de (başlık // probe'uyla bulunan `_windowCache`) "bağlı" sayılır. Aksi halde bağlanan // CATIA "açık ama bağlanmadı" hayaleti olarak kalıyordu. livePids: { for (final i in live) i.pid, for (final i in live) if (_windowCache[i.id] != null) _windowCache[i.id]!.pid, }, liveTitles: { for (final i in live) ...[i.title, i.caption], }, unmatchedSaved: unmatched, firstSeen: _procFirstSeen, now: DateTime.now(), dismissedPids: _dismissedProcPids, defaultCaption: _settings.defaultCaption, stickyClaims: _procSavedClaims, ); // Yeni/korunan eşleşmeleri sakla (süreç düşünce _scanCatiaProcesses temizler). _procSavedClaims.addAll(open.claims); final next = [...open.ghosts]; for (final s in unmatched) { if (_dismissedGhostIds.contains(s.id)) continue; if (open.claimedSavedIds.contains(s.id)) continue; final docs = s.documents.where((d) => d.path.isNotEmpty).toList(); final gid = '$_ghostPrefix${s.id}'; final active = docs.where((d) => d.isActive).firstOrNull ?? docs.first; next.add(CatiaInstance( id: gid, fileName: active.name, status: CatiaStatus.disconnected, pid: 0, worker: '', connected: false, displayName: s.title, desktopName: s.desktopName, desktopNumber: s.desktopNumber ?? (s.desktopOrder > 0 ? s.desktopOrder : null), placeholder: true, documents: [ for (var k = 0; k < docs.length; k++) CatiaDocument( id: '$gid-doc$k', name: docs[k].name, path: docs[k].path, // Kayıt anındaki "kaydedilmemiş" bilgisi burada anlamsız // (CATIA kapalı) — turuncu uyarı çıkmasın. saved: true, isActive: docs[k].isActive, ), ], )); } _ghosts = next; } /// ❌ (yer tutucu kart): bu app run'ında listeden gizler. Kayıt dosyası /// DEĞİŞMEZ (bir sonraki açılışta yine görünür); ancak oto-kayıt sonraki /// turda yalnızca bağlı CATIA'ları yazacağı için gizlenen kayıt dosyadan /// da düşer. void dismissGhost(String ghostId) { if (ghostId.startsWith(openGhostPrefix)) { // Açık ama bağlanmamış süreç: pid'e göre bu app run'ında gizle. final pid = int.tryParse(ghostId.substring(openGhostPrefix.length)); if (pid == null) return; _dismissedProcPids.add(pid); _rebuildGhosts(); _safeNotify(); return; } if (!ghostId.startsWith(_ghostPrefix)) return; _dismissedGhostIds.add(ghostId.substring(_ghostPrefix.length)); _rebuildGhosts(); _safeNotify(); } /// Oto-kayıt yeni "son oturum"u yazarken, henüz bağlanmamış (ve /// gizlenmemiş) kayıtları da yeni kayda taşır. Aksi halde bir CATIA /// bağlanıp oto-kayıt tetiklenince diğer kayıtlar dosyadan silinir ve /// "bağlı değil" kartları kaybolurdu. SavedSession _withGhostRecords(SavedSession s) { final usedIds = {for (final i in s.instances) i.id}; final carried = []; for (final g in _unmatchedSavedInstances()) { if (_dismissedGhostIds.contains(g.id)) continue; // Kayıt id'leri ("CATIA-02") her app run'ında yeniden dağıtılır; eski // kaydın id'si yeni canlı bir CATIA'nınkiyle çakışabilir. var id = g.id; while (usedIds.contains(id)) { id = '$id~'; } usedIds.add(id); carried.add(id == g.id ? g : SavedInstance( id: id, title: g.title, desktopGuid: g.desktopGuid, desktopOrder: g.desktopOrder, desktopName: g.desktopName, desktopNumber: g.desktopNumber, documents: g.documents, )); } if (carried.isEmpty) return s; final all = [...s.instances, ...carried]; var desktops = deriveDesktops(all); // Canlı kayıttan gelen adlı BOŞ masaüstlerini (instances: []) koru. final extra = []; for (final d in s.effectiveDesktops) { if (d.instances.isNotEmpty) continue; final known = desktops.any((x) => (d.number != null && x.number == d.number) || (d.hasName && x.hasName && x.name.toLowerCase() == d.name.toLowerCase())); if (!known) extra.add(d); } if (extra.isNotEmpty) { final merged = [...desktops, ...extra]..sort((a, b) => (a.number ?? (1 << 30)).compareTo(b.number ?? (1 << 30))); desktops = [ for (var k = 0; k < merged.length; k++) SavedDesktop( key: 'd$k', name: merged[k].name, number: merged[k].number, instances: merged[k].instances, ), ]; } return SavedSession( savedAt: s.savedAt, instances: all, scope: s.scope, desktops: desktops, ); } /// Yeni bağlanan (ya da bu app run'ında ilk kez görülen) CATIA'nın son /// kayıtlı oturumdaki (💾 elle ya da [autoSaveIntervalMs] ile otomatik) /// hangi kayda ait olduğunu ORTAK AÇIK DÖKÜMAN yoluna göre çözmeye /// çalışır (bkz. [_matchLive] — restoreSession'daki aynı mantık, burada /// TEK YÖNLÜ ve TAŞIMASIZ). Eşleşirse kayıtlı masaüstü adı/numarası hemen /// karta uygulanır; CATIA'yı TAŞIMAZ, yalnızca rozeti doğru gösterir — /// gerçek taşıma yalnızca kullanıcının 📂 Oturumu geri yükle'siyle olur. void _recognizeFromSavedSession() { final session = _savedSession; if (session == null) return; for (var i = 0; i < _instances.length; i++) { final r = _instances[i]; if (r.pid <= 0 || _recognitionDone.contains(r.id)) continue; if (r.desktopName != null || (r.desktopNumber ?? 0) > 0) { // Bu app run'ında zaten bir masaüstü bilgisi almış (ör. HWND'den // gerçek okuma geldi) — kayıtlı kaydı üstüne yazma. _recognitionDone.add(r.id); continue; } final rPaths = r.documents .where((d) => d.path.isNotEmpty) .map((d) => '${d.path}${d.name}'.toLowerCase()) .toSet(); if (rPaths.isEmpty) continue; // dökümanlar henüz gelmedi, sonra dene SavedInstance? best; var bestScore = 0; for (final saved in session.instances) { if (_claimedSavedIds.contains(saved.id)) continue; final score = saved.documents .where((d) => rPaths.contains(d.fullPath.toLowerCase())) .length; if (score > bestScore) { best = saved; bestScore = score; } } _recognitionDone.add(r.id); if (best == null) continue; _claimedSavedIds.add(best.id); _dlog('OTO-TANI ${r.id} -> kayitli "${best.title}" ' '(${best.desktopLabel}) ortak dokuman=$bestScore'); _instances[i] = r.copyWith( desktopName: best.desktopName, desktopNumber: best.desktopNumber, ); } } /// 🔔 Yeni CATIA bağlandı → masaüstünde kısa toast (ayarlardan açılıp /// kapanır; süre ayarlanabilir). void _showConnectToast(CatiaInstance i) { if (!_settings.toastEnabled) return; // Başlık CATIA'nın kendi başlığından/otomatik adından türetilmiş olabilir. final shown = _instances.where((x) => x.id == i.id).firstOrNull ?? i; unawaited(NativeBridge.showToast( title: '🟢 ${shown.title} bağlandı', subtitle: i.fileName, durationMs: _settings.toastDurationMs, )); } /// İlk native snapshot işlendi mi? (Açılış toast'larını bastırmak için.) bool _firstSnapshotDone = false; // ---- Açık döküman listesi: kaynak `catia.Windows` (COM) ---- // // Snapshot her geldiğinde native taraf `Application.Windows` + // `Application.ActiveWindow`u okur (hafif: pencere başına Name+Caption). // Dart bu listeyi saklar ve kartın dökümanlarını kurarken YOL (FullName) ve // KAYIT (Saved) bilgisini `catia.Documents`ten ad eşleşmesiyle ekler // (bkz. lib/services/window_documents.dart — saf, test edilebilir). // Pencere listesi boş gelirse (eski native / beklenmedik pencere yapısı) // kart listesi Documents'ten gelir → bugünkü davranış bozulmaz. /// id → pencere listesi (parse edilmiş) + aktiflik bilgisinin güvenilirliği. final Map docs, bool activeKnown})> _winDocs = {}; /// Teşhis: pencere listesinin son imzası (yalnız DEĞİŞİMDE log yaz). final Map _winSig = {}; /// Snapshot'taki `catia.Windows` satırlarını [_winDocs]'a işler. void _applyWindowsFromSnapshot(List raw) { if (_settings.documentSource == DocumentSource.documents) { if (_winDocs.isNotEmpty || _winSig.isNotEmpty) { // Kaynağa geri dönüldü: pencere listesi bir sonraki snapshot'ta // doğal olarak Documents listesiyle değişecek. _winDocs.clear(); _winSig.clear(); } return; } for (final j in raw) { if (j is! Map) continue; final map = Map.from(j); final id = map['id'] as String?; if (id == null) continue; final rows = windowRowsFromNative(map['windows']); if (rows == null) continue; // eski native → pencere bilgisi yok final parsed = parseWindowDocuments( rows, activeName: (map['activeWindow'] as String?) ?? '', activeCaption: (map['activeWindowCaption'] as String?) ?? '', ); final sig = parsed.docs.map((d) => d.name).join('|'); if (_winSig[id] != sig) { _winSig[id] = sig; if (sig.isNotEmpty) { final active = parsed.docs .where((d) => d.active) .map((d) => d.name) .firstOrNull; _dlog('DOKUMAN-PENCERE $id kaynak=${parsed.source} ' 'sayi=${parsed.docs.length} aktif=${active ?? '-'} ' 'aktifBilindi=${parsed.activeKnown}'); } } if (parsed.docs.isEmpty) { _winDocs.remove(id); // pencere listesi okunamadı → Documents kalsın } else { _winDocs[id] = (docs: parsed.docs, activeKnown: parsed.activeKnown); } } } /// Pencere listesini kartların döküman alanına uygular (Documents bilgisiyle /// birleştirerek). Kaynak `catia.Documents` ise hiçbir şey yapmaz. void _applyWindowDocuments() { if (_settings.documentSource == DocumentSource.documents) return; for (var i = 0; i < _instances.length; i++) { final r = _instances[i]; final win = _winDocs[r.id]; if (win == null || win.docs.isEmpty) continue; final merged = mergeWindowAndComDocuments( instanceId: r.id, com: r.documents, win: win.docs, activeKnown: win.activeKnown, ); if (sameDocuments(r.documents, merged)) continue; _instances[i] = r.copyWith(documents: merged); } } // ---- "🕘 Daha önce açıktı" geçmişi ---- // // Native taraf hangi dökümanın nasıl kapandığını (❌ ile bizim mi, // CATIA'nın kendisinde mi, dışarıdan mı) ayırt etmiyor — tek gerçek kaynak // her snapshot'taki güncel döküman listesi. Bu yüzden geçmiş TAMAMEN // Dart tarafında, ardışık iki snapshot'ın döküman listesi FARKINDAN // türetilir: önceki listede olup yeni listede olmayan (ve yolu bilinen) // her döküman geçmişe eklenir; yeniden açılan (CATIA'nın kendisinde de // olsa) bir döküman geçmişten otomatik düşer. /// id → geçmiş (en yeni başta), CatiaProvider dispose olana kadar bellekte /// tutulur (diske yazılmaz — anlık oturuma özgü, CATIA yeniden bağlanınca /// zaten yeni bir instance id'si alır). final Map> _closedDocs = {}; static const int _closedDocsCap = 8; /// Kart UI'ı: instance'ın "daha önce açıktı" listesi (en yeni başta). List closedDocumentsFor(String instanceId) => List.unmodifiable(_closedDocs[instanceId] ?? const []); static String _docKey(String path, String name) => '${path.trim().toLowerCase()}|${name.trim().toLowerCase()}'; void _trackClosedDocuments(Map> prevDocsById) { for (final inst in _instances) { final prev = prevDocsById[inst.id]; final curKeys = { for (final d in inst.documents) _docKey(d.path, d.name), }; // Yeniden açılmış (CATIA'nın kendisinde de olsa) dökümanları geçmişten // düşür — artık "daha önce açıktı" değil, şu an açık. final list = _closedDocs[inst.id]; if (list != null && list.isNotEmpty) { list.removeWhere((c) => curKeys.contains(_docKey(c.path, c.name))); } if (prev == null || prev.isEmpty) continue; for (final d in prev) { if (d.path.isEmpty) continue; // yolu yoksa yeniden açılamaz final key = _docKey(d.path, d.name); if (curKeys.contains(key)) continue; // hâlâ açık final target = _closedDocs.putIfAbsent(inst.id, () => []); final idx = target.indexWhere((c) => _docKey(c.path, c.name) == key); final entry = ClosedCatiaDocument(name: d.name, path: d.path, closedAt: DateTime.now()); if (idx >= 0) { target[idx] = entry; } else { target.insert(0, entry); if (target.length > _closedDocsCap) target.removeLast(); _dlog('GECMIS-DOKUMAN ${inst.id} "${d.name}" -> geçmişe eklendi ' '(${target.length}/$_closedDocsCap)'); } } } final liveIds = {for (final i in _instances) i.id}; _closedDocs.removeWhere((k, _) => !liveIds.contains(k)); } CatiaInstance _instanceFromNative(dynamic j) { final map = Map.from(j as Map); final registeredAt = DateTime.parse(map['registeredAtUtc'] as String); CatiaStatus status; try { status = CatiaStatus.values.byName(map['status'] as String); } catch (_) { status = CatiaStatus.idle; } // CATIA dışarıdan kapandığında (process öldüğünde) native `responsive`u // false'a çeker (bkz. com_bridge.cpp DocPoller) — iş durumu ne olursa // olsun kart "Bağlantı Kesildi" gösterir, 🔄 ile yeniden açma sunulur // (bkz. reopenInstance). Eski native sürümde alan yoksa varsayılan true. final responsive = (map['responsive'] as bool?) ?? true; if (!responsive) status = CatiaStatus.disconnected; final id = map['id'] as String; final activeDocument = map['activeDocument'] as String?; final rawDocs = (map['documents'] as List?) ?? const []; final documents = [ for (var i = 0; i < rawDocs.length; i++) () { final d = Map.from(rawDocs[i] as Map); final name = d['name'] as String? ?? ''; return CatiaDocument( id: '$id-doc${i + 1}', name: name, path: (d['path'] as String?) ?? '', saved: (d['saved'] as bool?) ?? true, isActive: activeDocument != null && activeDocument == name, ); }(), ]; return CatiaInstance( id: id, fileName: map['fileName'] as String, status: status, progress: map['progress'] as int?, uptime: DateTime.now().toUtc().difference(registeredAt), pid: map['pid'] as int, worker: map['worker'] as String, connected: (map['connected'] as bool) && responsive, lastError: map['lastError'] as String?, desktopGuid: (map['desktopGuid'] as String?) ?? '', caption: ((map['caption'] as String?) ?? '').trim(), workbenchId: ((map['workbenchId'] as String?) ?? '').trim(), documents: documents, ); } /// CATIA'nın başlığı hiç değiştirilmemiş (varsayılan) mi? ("CATIA V5") bool _isDefaultCaption(String caption) => caption.trim().toLowerCase() == _settings.defaultCaption.trim().toLowerCase(); /// Otomatik ad: önek + kayıt numarası ("CATIA-01" id'sinden "01" alınır). /// [prefix] verilmezse ayardaki önek kullanılır (ayar değişiminde ESKİ /// öneki de denemek gerekir). String _autoTitleFor(String id, {String? prefix}) { final m = RegExp(r'(\d+)\s*$').firstMatch(id); final n = m?.group(1) ?? ''; if (n.isEmpty) return id; return '${prefix ?? _settings.autoTitlePrefix}$n'; } /// [caption] uygulamanın KENDİ yazdığı otomatik ad mı ("CATIA-01")? /// /// Uygulama adsız bir CATIA'ya otomatik ad verince CATIA'nın pencere başlığı /// artık varsayılan ("CATIA V5") değil, bizim yazdığımız ad olur; sonraki /// kayıtta bu başlık CATIA'nın "kendi başlığı" gibi okunur. Böyle bir adı da /// "adsız" saymazsak, önek değiştirildiğinde ya da kural kapatıldığında /// CATIA kilitli kalır (ad artık "özel başlık" görünür). Bu yüzden hem /// ayardaki hem de fabrika önekiyle üretilen adı "bizim adımız" sayıyoruz. bool _isOwnAutoCaption(String caption, String id) { final c = caption.trim().toLowerCase(); for (final p in { _settings.autoTitlePrefix.toLowerCase(), 'catia-', }) { if (p.isEmpty) continue; if (c == _autoTitleFor(id, prefix: p).toLowerCase()) return true; } return false; } /// Kart başlığını CATIA'nın KENDİ pencere başlığından türetir (uygulama /// kendiliğinden ad uydurmaz): /// * Başlık okunamadıysa (boş) hiçbir şey yapılmaz → kart kendi id'sini /// ("CATIA-01") gösterir. /// * CATIA'nın özel bir başlığı varsa (ör. kullanıcı CATIA penceresini /// adlandırmışsa) kart başlığı AYNEN o olur. /// * Başlık ayardaki varsayılanla ("CATIA V5") aynıysa (ya da daha önce /// uygulamanın yazdığı otomatik ad ise) CATIA "adsız" sayılır: ayar /// AÇIKKEN önek + sıra numarası ("CATIA-01") verilir ve aynı ad CATIA /// penceresine de yazılır (görev çubuğunda hangi CATIA olduğu görünsün); /// ayar KAPALIYKEN kart var olan başlığı gösterir ve CATIA penceresi /// varsayılan başlığa döndürülür (uygulamanın yazdığı ad geri alınır). CatiaInstance _applyInitialTitle(CatiaInstance r) { final cap = r.caption.trim(); if (cap.isEmpty) return r; final nameless = _isDefaultCaption(cap) || _isOwnAutoCaption(cap, r.id); if (!nameless) return r.copyWith(displayName: cap); if (!_settings.autoTitleDefault) { _writeWindowTitle(r, _settings.defaultCaption); return r.copyWith( displayName: _isDefaultCaption(cap) ? cap : _settings.defaultCaption); } final title = _autoTitleFor(r.id); _writeWindowTitle(r, title); return r.copyWith(displayName: title); } /// CATIA penceresinin başlığını yazar (yalnız gerçek/bağlı CATIA'lar). /// Native taraf sürmekte olan başlık probe'uyla çakışmayı kendisi çözer /// (probe bitince bu başlık kalır). void _writeWindowTitle(CatiaInstance r, String title) { final t = title.trim(); if (r.pid > 0 && r.connected && t.isNotEmpty) { unawaited(NativeBridge.setInstanceWindowTitle(r.id, t)); } } /// Başlık kuralları (ayarlar) değiştiğinde BAĞLI CATIA'ların kart /// başlıklarını yeni kurala göre yeniden türetir. Kullanıcının ELLE verdiği /// başlıklar korunur: yalnızca başlığı kuralın ürettiği bir değerle (CATIA /// başlığı, eski otomatik ad ya da varsayılan başlık) aynı olan kartlar /// değişir. void _reapplyTitles(SessionSettings old) { var changed = false; for (var i = 0; i < _instances.length; i++) { final inst = _instances[i]; if (inst.pid <= 0 || !inst.connected || inst.caption.trim().isEmpty) { continue; } final cur = inst.displayName?.trim().toLowerCase() ?? ''; final derived = cur.isEmpty || cur == inst.caption.trim().toLowerCase() || cur == _settings.defaultCaption.trim().toLowerCase() || cur == old.defaultCaption.trim().toLowerCase() || cur == _autoTitleFor(inst.id).toLowerCase() || cur == _autoTitleFor(inst.id, prefix: old.autoTitlePrefix) .toLowerCase() || _isOwnAutoCaption(inst.displayName ?? '', inst.id); if (!derived) continue; // elle verilmiş başlık → dokunma final next = _applyInitialTitle(inst); if (next.displayName != inst.displayName) { _instances[i] = next; changed = true; } } if (changed) _safeNotify(); } HomeTab _tab = HomeTab.active; String _query = ''; String? _selectedInstanceId; HomeTab get tab => _tab; String get query => _query; List get instances => _instances; List get history => _history; CatiaInstance? get selectedInstance { if (_selectedInstanceId == null) return null; try { return _instances.firstWhere((i) => i.id == _selectedInstanceId); } catch (_) { return null; } } /// Aktif liste: arama süzgeci + seçili SIRALAMA uygulanır. İç liste /// (`_instances`, native push sırası) BOZULMAZ — sıralama yalnız gösterim /// için üretilen kopyada yapılır ki snapshot/timer akışı etkilenmesin. List get filteredInstances { final list = _queryFiltered(); _sortInstances(list); return list; } /// Arama süzgecinden geçmiş (sıralanmamış) CATIA kopyası. /// /// Kayıtlı oturumdan gelen "bağlı değil" yer tutucu kartlar ([_ghosts]) /// burada listeye katılır. Yer tutucu varken MockData örnek kartları /// ([_mockActive]) gizlenir: gerçek bir oturum bilgisi varken sahte /// kartların yanında görünmeleri kafa karıştırırdı. List _queryFiltered() { final base = _ghosts.isEmpty ? _instances : [if (!_mockActive) ..._instances, ..._ghosts]; if (_query.isEmpty) return List.of(base); final q = _query.toLowerCase(); return base.where((i) => i.searchText.contains(q)).toList(); } // ---- Masaüstüne göre gruplu görünüm (bkz. masaustu_gruplu_liste_plani.md) ---- /// Aktif listenin görünümü (düz / masaüstüne göre gruplu). ListMode get listMode => _settings.listMode; /// Windows'taki tüm sanal masaüstleri (son okuma). List get desktopList => _desktopList; List _desktopList = const []; bool _desktopListBusy = false; DateTime _psListNextAt = DateTime.fromMillisecondsSinceEpoch(0); /// Gruplu görünüm: masaüstü başına CATIA grupları. Arama süzgeci uygulanır; /// arama varken boş gruplar gizlenir. Grup içinde seçili sıralama geçerli. List get desktopGroups => buildDesktopGroups( desktops: _desktopList, instances: _queryFiltered(), showEmpty: _settings.showEmptyDesktops && _query.isEmpty, sortWithin: _sortInstances, ); bool isDesktopCollapsed(String key) => _settings.collapsedDesktops.contains(key); // ── 🎨 Tema (koyu / açık) ─────────────────────────────────────────────── AppThemeMode get themeMode => _settings.themeMode; /// Paleti uygular (kök MaterialApp `AppColors.mode`u dinleyip baştan /// kurulur) ve tray popup'ının cam efektini temaya göre boyar. void _applyTheme(AppThemeMode m) { AppColors.apply(m); unawaited(WindowTrayService.applyThemeEffect(m)); } Future setThemeMode(AppThemeMode m) async { if (_settings.themeMode == m) return; await updateSessionSettings(_settings.copyWith(themeMode: m)); } Future toggleThemeMode() => setThemeMode( _settings.themeMode == AppThemeMode.dark ? AppThemeMode.light : AppThemeMode.dark); /// Görünümü değiştirir (ayara yazılır); gruplu moda geçince listeyi tazeler. Future setListMode(ListMode m) async { if (_settings.listMode == m) return; await updateSessionSettings(_settings.copyWith(listMode: m)); if (m == ListMode.byDesktop) { unawaited(refreshDesktopList(force: true)); unawaited(refreshDesktops()); } } Future toggleListMode() => setListMode(_settings.listMode.next); /// Bir masaüstü grubunu daraltır/açar (ayara yazılır). Future toggleDesktopCollapsed(String key) { final cur = [..._settings.collapsedDesktops]; if (!cur.remove(key)) cur.add(key); return updateSessionSettings(_settings.copyWith(collapsedDesktops: cur)); } Future setShowEmptyDesktops(bool v) { if (_settings.showEmptyDesktops == v) return Future.value(); return updateSessionSettings(_settings.copyWith(showEmptyDesktops: v)); } /// Windows masaüstü listesini tazeler. Birincil kaynak native registry /// okuması (süreç açmaz, ucuz); boş/başarısızsa ve PowerShell motoru /// seçiliyse PowerShell modülü (≥5 sn aralık, hata olursa 30 sn geri /// çekilme). Yalnızca liste GERÇEKTEN değiştiyse dinleyicileri uyarır. Future refreshDesktopList({bool force = false}) async { if (_disposed || _desktopListBusy) return; _desktopListBusy = true; try { List? list; final raw = await NativeBridge.listVirtualDesktops(); if (raw != null && raw.isNotEmpty) { list = [ for (final m in raw) DesktopInfo( index: (m['index'] as num?)?.toInt() ?? -1, name: (m['name'] as String?) ?? '', visible: m['current'] == true, guid: (m['guid'] as String?) ?? '', ), ]; } else if (_psEngine && (force || DateTime.now().isAfter(_psListNextAt))) { final ps = await VirtualDesktopService.listDesktops(); _psListNextAt = DateTime.now() .add(ps.isEmpty ? const Duration(seconds: 30) : const Duration(seconds: 5)); if (ps.isNotEmpty) { list = [ for (final d in ps) DesktopInfo( index: d.index, // Modül isimsiz masaüstü için de varsayılan adı döndürür. name: isDefaultDesktopName(d.name) ? '' : d.name, visible: d.visible, ), ]; } } if (list == null || _disposed) return; if (!sameDesktopList(_desktopList, list)) { _desktopList = list; _safeNotify(); } } finally { _desktopListBusy = false; } } /// Test kancası: masaüstü listesini doğrudan kurar. @visibleForTesting void debugSetDesktopList(List list) { _desktopList = list; notifyListeners(); } List get filteredHistory { if (_query.isEmpty) return _history; final q = _query.toLowerCase(); return _history.where((h) => h.searchText.contains(q)).toList(); } int get problemCount => _instances.where((i) => i.status.isProblem).length; // ---- Sıralama (Görünüm ▸ ↕ Sırala / toolbar ↕) ---- /// Aktif listenin sıralaması (ayarlardan gelir; diske yazılır). InstanceSort get instanceSort => _settings.instanceSort; /// Sıralamayı değiştirir (ayar dosyasına da yazılır) ve kısa bir bildirim /// gösterir. Bildirim BİLEREK oturum bandına değil masaüstü toast'ına gider: /// bant, süren bir oturum geri yükleme mesajını EZERDİ. Toast "Bildirimler" /// ayarına uyar (kapalıysa sessiz geçer). Future setInstanceSort(InstanceSort s) async { if (_settings.instanceSort == s) return; await updateSessionSettings(_settings.copyWith(instanceSort: s)); if (_settings.toastEnabled) { unawaited(NativeBridge.showToast( title: '↕ Sıralama: ${s.label}', subtitle: 'CATIA listesi', durationMs: _settings.toastDurationMs, )); } } /// Toolbar ↕ çipi: sıradaki sıralamaya geçer (sırayla döner). Future cycleInstanceSort() => setInstanceSort(_settings.instanceSort.next); /// [list]'i seçili sıralamaya göre sıralar. void _sortInstances(List list) { // `List.sort` kararlı DEĞİL: birincil anahtarlar eşitse sıra her seferinde // değişebilir (kartlar zıplar). Bu yüzden son kırıcı HER ZAMAN id'dir. int tie(int primary, CatiaInstance a, CatiaInstance b) => primary != 0 ? primary : a.id.compareTo(b.id); switch (_settings.instanceSort) { case InstanceSort.registered: list.sort((a, b) => tie(_registrationNo(a.id) - _registrationNo(b.id), a, b)); case InstanceSort.title: list.sort((a, b) => tie(_collate(a.title, b.title), a, b)); case InstanceSort.desktop: // Bilinmeyen masaüstü ("M?") en sonda; aynı masaüstünde ada göre. list.sort((a, b) { final c = _desktopRank(a).compareTo(_desktopRank(b)); if (c != 0) return c; return tie(_collate(a.title, b.title), a, b); }); case InstanceSort.status: // Sorunlular (hata / bağlantı kesildi / COM zaman aşımı) en üstte, // sonra çalışanlar, en altta boştakiler. list.sort((a, b) => tie(_statusRank(a.status) - _statusRank(b.status), a, b)); case InstanceSort.fileName: list.sort((a, b) => tie(_collate(a.fileName, b.fileName), a, b)); } // "Bağlı değil" yer tutucu kartlar hangi sıralama olursa olsun listenin // (gruplu görünümde: masaüstü grubunun) SONUNDA durur; kendi aralarındaki // sıra yukarıdaki sıralamadan korunur. if (list.any((i) => i.placeholder)) { final live = list.where((i) => !i.placeholder).toList(); final ghosts = list.where((i) => i.placeholder).toList(); list ..clear() ..addAll(live) ..addAll(ghosts); } } /// Masaüstü numarası; bilinmiyorsa ("M?") sona düşecek büyük değer. static int _desktopRank(CatiaInstance i) => i.desktopNumber ?? (1 << 20); /// Durum önceliği: 0 sorunlu, 1 çalışıyor, 2 boşta. static int _statusRank(CatiaStatus s) { if (s.isProblem) return 0; if (s.isBusy || s == CatiaStatus.busy) return 1; return 2; } /// Bağlanma sırası: id'nin sonundaki kayıt numarası ("CATIA-02" → 2). /// Numarasız id'ler sona düşer. static int _registrationNo(String id) { final m = RegExp(r'(\d+)\s*$').firstMatch(id); return (m == null ? null : int.tryParse(m.group(1)!)) ?? (1 << 20); } /// Ada göre karşılaştırma (büyük/küçük harf duyarsız, boşluklar kırpılır). static int _collate(String a, String b) => a.trim().toLowerCase().compareTo(b.trim().toLowerCase()); int _indexOf(String id) => _instances.indexWhere((i) => i.id == id); /// Kart üzerindeki 👁 butonu: seç + (gerçek CATIA ise) penceresini öne getir. void focusInstance(String id) { _selectedInstanceId = id; notifyListeners(); unawaited(bringInstanceToFront(id)); } final Set _focusing = {}; /// COM'a dokunmadan, çalışan CATIA süreçlerinin taramasından [inst]'in /// penceresini bulur: önce kayıtlı pid ile; olmazsa (pid güvenilmez) pencere /// başlığı kartın başlığı/CATIA başlığıyla TEK bir süreçte eşleşirse o. Future<({int hwnd, int pid})?> _windowFromProcessScan( CatiaInstance inst) async { final found = await NativeBridge.listCatiaProcesses(_catiaExeNames); if (found.isEmpty) return null; final byPid = found.where((p) => p.pid == inst.pid && p.hwnd != 0); if (byPid.isNotEmpty) return (hwnd: byPid.first.hwnd, pid: byPid.first.pid); String n(String s) => s.trim().toLowerCase(); final wanted = {n(inst.title), n(inst.caption)}..remove(''); final byTitle = found .where((p) => p.hwnd != 0 && wanted.contains(n(p.title))) .toList(); if (byTitle.length == 1) { return (hwnd: byTitle.first.hwnd, pid: byTitle.first.pid); } return null; } /// 👁 CATIA'nın penceresini öne getirir ve etkinleştirir. Pencere başka bir /// sanal masaüstündeyse önce o masaüstüne geçilir (Windows başka /// masaüstündeki pencereyi öne getiremez), sonra pencere öne alınır. /// Küçültülmüşse geri yüklenir. Kesin HWND kullanılır (pid'den tahmin YOK). Future bringInstanceToFront(String id) async { final inst = _instances.where((i) => i.id == id).firstOrNull; if (inst == null || inst.pid <= 0 || !_focusing.add(id)) return; try { final cached = _windowCache[id]; // COM bağlantısı yoksa başlık-probe'u yapılamaz → doğrudan süreç // taramasından (pid / başlık) pencere bulunur. var w = cached ?? (inst.connected ? await _findRealWindow(inst) : null); w ??= await _windowFromProcessScan(inst); if (w == null) { _setSessionMessage('${inst.title}: $_noWindowMsg', warn: true); return; } // 1) Aynı masaüstündeyse doğrudan öne gelir (hızlı yol). var ok = await NativeBridge.bringWindowToFront(w.hwnd); var switched = false; if (!ok) { // 2) Başka masaüstünde (ya da öne alınamadı) → pencerenin masaüstüne // geç, sonra birkaç kez öne almayı dene (geçiş animasyonu sürer). final r = await VirtualDesktopService.switchToDesktopOfWindow(w.hwnd); switched = r.ok; if (!r.ok) { _dlog('ODAK-GECIS-HATA $id hwnd=0x${w.hwnd.toRadixString(16)} ' 'err=${r.error}'); } for (var k = 0; k < 4 && !ok && !_disposed; k++) { await Future.delayed(const Duration(milliseconds: 250)); ok = await NativeBridge.bringWindowToFront(w.hwnd); } } _dlog('ODAK $id hwnd=0x${w.hwnd.toRadixString(16)} ' 'masaustuGecildi=$switched onde=$ok'); if (!ok) { // Pencere kapanmış/HWND geçersiz olabilir → sonraki tıkta yeniden bul. _windowCache.remove(id); _setSessionMessage( '${inst.title}: pencere öne getirilemedi' '${switched ? '' : ' (masaüstüne geçilemedi — VirtualDesktop modülü kurulu mu?)'}', warn: true); } } finally { _focusing.remove(id); } } /// ▶️ Hesap başlat: boşta/hata durumundaki instance'ı hesaplamaya alır. void startJob(String id) { final i = _indexOf(id); if (i < 0) return; final cur = _instances[i]; if (cur.status == CatiaStatus.calculating || cur.status == CatiaStatus.exporting) return; _instances[i] = cur.copyWith(status: CatiaStatus.calculating, progress: 0); notifyListeners(); } /// 📤 Export iste: boşta instance'ı exporta alır. void exportInstance(String id) { final i = _indexOf(id); if (i < 0) return; final cur = _instances[i]; if (cur.status.isBusy) return; _instances[i] = cur.copyWith(status: CatiaStatus.exporting, progress: 0); notifyListeners(); } /// ⏹ Durdur: süren işi boşta durumuna çeker. void stopInstance(String id) { final i = _indexOf(id); if (i < 0) return; final cur = _instances[i]; if (!cur.status.isBusy) return; _instances[i] = cur.copyWith(status: CatiaStatus.idle, clearProgress: true); notifyListeners(); } /// 🔄 Yeniden başlat: hatalı/kopuk instance'ı boşa alır. void restartInstance(String id) { final i = _indexOf(id); if (i < 0) return; final cur = _instances[i]; _instances[i] = cur.copyWith(status: CatiaStatus.idle, clearProgress: true); notifyListeners(); } /// 🔄 Yeniden Aç: CATIA dışarıdan kapanmış (CatiaStatus.disconnected) bir /// instance'ı ayarlardaki başlatıcıyla tekrar açar ve kapanmadan ÖNCEKİ /// döküman listesini (yol+ad) yeni CATIA'da açtırır. Oturum geri yükleme /// ile aynı alt yapıyı (_launchCatia/_waitForNewInstance/openDocuments/ /// _awaitOpenJob) kullanır, tek fark tek bir instance'a odaklanması. Future reopenInstance(String id) async { if (_sessionBusy) { _setSessionMessage('Başka bir oturum işlemi sürüyor, bitmesini bekleyin.', warn: true); return; } final idx = _indexOf(id); if (idx < 0) return; final dead = _instances[idx]; if (dead.status != CatiaStatus.disconnected) return; final docs = dead.documents.where((d) => d.path.isNotEmpty).toList(); final activeDoc = dead.documents.where((d) => d.isActive).firstOrNull; final label = dead.title; _sessionBusy = true; unawaited(WindowTrayService.current?.showWindow()); try { _setSessionMessage('⏳ $label: CATIA yeniden başlatılıyor…'); final before = _liveInstances.map((i) => i.id).toSet(); final err = await _launchCatia(); if (err != null) { _setSessionMessage('⚠ $label: $err', warn: true); return; } _setSessionMessage('⏳ $label: CATIA açılıyor, macro kaydı bekleniyor…'); final live = await _waitForNewInstance(before); if (live == null) { _setSessionMessage( '⚠ $label: CATIA açıldı ama macro kayıt olmadı (zaman aşımı)', warn: true); return; } // Eski (kopuk) kaydı native'den ve listeden kalıcı olarak temizle — // artık ayrı bir "CATIA-0N" olarak yaşamaya devam eden yeni kayıt var. unawaited(NativeBridge.removeInstance(id)); _instances.removeWhere((i) => i.id == id); if (_selectedInstanceId == id) _selectedInstanceId = null; notifyListeners(); if (dead.title != dead.id) renameInstance(live.id, dead.title); if (docs.isEmpty) { _setSessionMessage('✅ $label: CATIA yeniden açıldı (kayıtlı döküman yok).'); return; } _setSessionMessage('⏳ $label: ${docs.length} döküman açtırılıyor…'); final jobId = await NativeBridge.openDocuments( live.id, [for (final d in docs) '${d.path}${d.name}'], activate: activeDoc?.name, ); if (jobId <= 0) { _setSessionMessage('⚠ $label: döküman açma başlatılamadı (kod $jobId)', warn: true); return; } final tally = _RestoreTally(); await _awaitOpenJob(jobId, label, tally); final ok = tally.failed == 0 && tally.errors.isEmpty; final parts = [ '${tally.opened} döküman açıldı', if (tally.alreadyOpen > 0) '${tally.alreadyOpen} zaten açıktı', if (tally.failed > 0) '${tally.failed} döküman açılamadı', ]; _setSessionMessage( '${ok ? '✅' : '⚠'} $label: CATIA yeniden açıldı — ${parts.join(' · ')}', warn: !ok, ); } catch (e, st) { debugPrint('reopenInstance EXCEPTION: $e\n$st'); _setSessionMessage('⚠ $label: beklenmeyen hata: $e', warn: true); } finally { _sessionBusy = false; } } /// 🔄 Aç (gri "kayıtlı-kapalı" hayalet kartı): hiç çalışan süreci olmayan /// kayıtlı bir CATIA'yı sıfırdan başlatır ve kaydedildiği andaki döküman /// listesini yeni CATIA'da açtırır. [reopenInstance] ile aynı alt yapıyı /// (_launchCatia/_waitForNewInstance/openDocuments/_awaitOpenJob) kullanır; /// tek fark kaynağın `_instances`'taki "kopuk" bir kayıt değil, `_ghosts` /// listesindeki yer tutucu kart olması. Kehribar ("açık ama bağlanmadı") /// hayaletlerde bu buton YOK — orada zaten çalışan bir süreç var ve macro /// kaydı olmadan COM ile döküman açtırılamıyor. Future openSavedGhost(String ghostId) async { if (!ghostId.startsWith(_ghostPrefix)) return; if (_sessionBusy) { _setSessionMessage('Başka bir oturum işlemi sürüyor, bitmesini bekleyin.', warn: true); return; } final g = _ghosts.where((x) => x.id == ghostId).firstOrNull; if (g == null) return; final docs = g.documents.where((d) => d.path.isNotEmpty).toList(); final activeDoc = docs.where((d) => d.isActive).firstOrNull; final label = g.title; _sessionBusy = true; unawaited(WindowTrayService.current?.showWindow()); try { _setSessionMessage('⏳ $label: CATIA başlatılıyor…'); final before = _liveInstances.map((i) => i.id).toSet(); final err = await _launchCatia(); if (err != null) { _setSessionMessage('⚠ $label: $err', warn: true); return; } _setSessionMessage('⏳ $label: CATIA açılıyor, macro kaydı bekleniyor…'); final live = await _waitForNewInstance(before); if (live == null) { _setSessionMessage( '⚠ $label: CATIA açıldı ama macro kayıt olmadı (zaman aşımı)', warn: true); return; } if (g.hasCustomTitle) renameInstance(live.id, g.title); if (docs.isEmpty) { _setSessionMessage('✅ $label: CATIA açıldı (kayıtlı döküman yok).'); return; } _setSessionMessage('⏳ $label: ${docs.length} döküman açtırılıyor…'); final jobId = await NativeBridge.openDocuments( live.id, [for (final d in docs) '${d.path}${d.name}'], activate: activeDoc?.name, ); if (jobId <= 0) { _setSessionMessage('⚠ $label: döküman açma başlatılamadı (kod $jobId)', warn: true); return; } final tally = _RestoreTally(); await _awaitOpenJob(jobId, label, tally); final ok = tally.failed == 0 && tally.errors.isEmpty; final parts = [ '${tally.opened} döküman açıldı', if (tally.alreadyOpen > 0) '${tally.alreadyOpen} zaten açıktı', if (tally.failed > 0) '${tally.failed} döküman açılamadı', ]; _setSessionMessage( '${ok ? '✅' : '⚠'} $label: CATIA açıldı — ${parts.join(' · ')}', warn: !ok, ); } catch (e, st) { debugPrint('openSavedGhost EXCEPTION: $e\n$st'); _setSessionMessage('⚠ $label: beklenmeyen hata: $e', warn: true); } finally { _sessionBusy = false; } } /// ❌ Kapat: instance'ı listeden düşürür. Gerçek (pid>0) bir CATIA'ysa /// native taraftaki kaydı da KALICI olarak siler — aksi halde native /// g_map'te kalmaya devam eder ve bir sonraki push'ta (yeni bağlanan bir /// CATIA ya da döküman poller'ı) tekrar listeye düşer. void closeInstance(String id) { // 👻 Yer tutucu kart: kapatılacak bir CATIA yok, yalnızca listeden gizle. if (id.startsWith(_ghostPrefix) || id.startsWith(openGhostPrefix)) { dismissGhost(id); return; } final cur = _instances.where((i) => i.id == id).firstOrNull; _instances.removeWhere((i) => i.id == id); if (_selectedInstanceId == id) _selectedInstanceId = null; _closedDocs.remove(id); notifyListeners(); if (cur != null && cur.pid > 0) { unawaited(NativeBridge.removeInstance(id)); } } /// 🚪 CATIA'yı kapat: çalışan CATIA'nın ana penceresine kapatma isteği /// (WM_CLOSE) gönderir — pencerenin ✕ düğmesiyle aynı. CATIA kaydedilmemiş /// dökümanlar için kendi kaydet/kaydetme diyaloğunu gösterir; süreç ZORLA /// sonlandırılmaz, dolayısıyla veri kaybı riski CATIA'nın kendi sorusuna /// bırakılır. COM gerekmez → hem bağlı kartta hem kehribar (açık ama /// bağlanmamış) kartta çalışır. Kart ❌'sinden farkı: ❌ yalnız listeden /// kaldırır, CATIA'yı kapatmaz. Kapanış bir sonraki taramada/snapshot'ta /// yansır (kart düşer / "bağlı değil" olur). Future quitCatia(String id) async { final int pid; final int hwnd; final String label; if (id.startsWith(openGhostPrefix)) { final p = int.tryParse(id.substring(openGhostPrefix.length)); final proc = _scanned.where((x) => x.pid == p).firstOrNull; if (proc == null || proc.hwnd == 0) { _setSessionMessage('CATIA penceresi bulunamadı', warn: true); return; } pid = proc.pid; hwnd = proc.hwnd; label = _ghosts.where((g) => g.id == id).firstOrNull?.title ?? 'Açık CATIA (pid ${proc.pid})'; } else { final inst = _instances.where((i) => i.id == id).firstOrNull; if (inst == null || inst.pid <= 0) { // Yer tutucu / mock / süreci olmayan kart: kapatılacak CATIA yok. _setSessionMessage('Kapatılacak çalışan bir CATIA yok', warn: true); return; } final w = _windowCache[id] ?? await _windowFromProcessScan(inst); if (_disposed) return; if (w == null) { _setSessionMessage('${inst.title}: $_noWindowMsg', warn: true); return; } pid = w.pid; hwnd = w.hwnd; label = inst.title; } final ok = await NativeBridge.closeCatiaWindow(hwnd, pid); if (_disposed) return; _dlog('CATIA-KAPAT $id hwnd=0x${hwnd.toRadixString(16)} pid=$pid ok=$ok'); if (ok) { _setSessionMessage('$label: kapatma isteği gönderildi — kaydedilmemiş ' 'döküman varsa CATIA sorabilir (CATIA penceresine bak)'); } else { _windowCache.remove(id); _setSessionMessage('$label: kapatma isteği gönderilemedi (pencere ' 'bulunamadı ya da uygulama güncel değil)', warn: true); } } /// Bağlı bir CATIA'nın İÇİNDE kod çalıştırır (`SystemService.Evaluate`, /// 27. oturum). Yalnız bağlantısı KURULMUŞ (yeşil) kartlarda çalışır; /// kehribar/gri hayalet ya da bağlantısı kopuk kartta başlatılmaz. /// /// GÜVENLİK (B7): [text] YALNIZ kullanıcının o an elle yazdığı/seçtiği /// kaynaktan gelmelidir — `.fikoses`, sürükle-bırak ya da dış girdiden /// ASLA otomatik çağrılmaz. Kod CATIA süresince donduruyor; iptal API'si /// yok. [timeout] dolarsa `stillRunning=true` döner (script CATIA'da /// hâlâ sürüyor olabilir). [onTick] her yoklamada geçen ms ile çağrılır. Future runScriptOn( String id, String text, { ScriptLanguage language = ScriptLanguage.vbScript, bool passApp = false, String func = kScriptDefaultFunc, Duration timeout = const Duration(minutes: 10), void Function(int elapsedMs)? onTick, }) async { if (!_isRealApp) { return const ScriptRunResult.failure( 'Kod çalıştırma yalnız gerçek uygulamada (Windows) kullanılabilir'); } if (id.startsWith(_ghostPrefix) || id.startsWith(openGhostPrefix)) { return const ScriptRunResult.failure( 'Bu kart CATIA\'ya bağlı değil — önce bağla (🔌)'); } final inst = _instances.where((i) => i.id == id).firstOrNull; if (inst == null || inst.pid <= 0 || !inst.connected) { return const ScriptRunResult.failure( 'Bu CATIA\'ya bağlantı yok (kart yeşil/bağlı olmalı)'); } if (!_beginScript(id)) { return const ScriptRunResult.failure( 'Bu CATIA\'da zaten bir kod/makro çalışıyor — bitmesini bekle'); } try { final jobId = await NativeBridge.runScript(id, text, func: func.trim().isEmpty ? kScriptDefaultFunc : func.trim(), language: language.code, passApp: passApp); if (jobId <= 0) { return ScriptRunResult.failure(scriptStartErrorText(jobId)); } _dlog('KOD-CALISTIR $id job=$jobId lang=${language.code} ' 'passApp=$passApp len=${text.length}'); return await _awaitScriptJob(id, jobId, timeout, onTick); } finally { _endScript(id); } } /// 🧰 Bağlı bir CATIA'da `Application.StartCommand(name)` ile bir CATIA /// komutunu ikonuna tıklanmış gibi başlatır (31. oturum, F1/F2). Yalnız /// bağlantısı KURULMUŞ (yeşil) kartlarda; hayalet/kopuk kartta çalışmaz. /// Kullanıcı girdisi gerektirmeyen komutlar işi bitene kadar bekletir; /// diyalog/seçim açan komutlarda diyaloğu açıp hemen döner (CATIA'da /// diyalog açık kalabilir — [timeout] dolarsa `stillRunning=true`). Future startCommandOn( String id, String name, { Duration timeout = const Duration(minutes: 10), void Function(int elapsedMs)? onTick, }) async { if (!_isRealApp) { return const ScriptRunResult.failure( 'Komut çalıştırma yalnız gerçek uygulamada (Windows) kullanılabilir'); } if (id.startsWith(_ghostPrefix) || id.startsWith(openGhostPrefix)) { return const ScriptRunResult.failure( 'Bu kart CATIA\'ya bağlı değil — önce bağla (🔌)'); } final inst = _instances.where((i) => i.id == id).firstOrNull; if (inst == null || inst.pid <= 0 || !inst.connected) { return const ScriptRunResult.failure( 'Bu CATIA\'ya bağlantı yok (kart yeşil/bağlı olmalı)'); } if (!_beginScript(id)) { return const ScriptRunResult.failure( 'Bu CATIA\'da zaten bir kod/makro çalışıyor — bitmesini bekle'); } try { final jobId = await NativeBridge.startCommand(id, name); if (jobId <= 0) { return ScriptRunResult.failure(scriptStartErrorText(jobId)); } _dlog('KOMUT-CALISTIR $id job=$jobId "$name"'); return await _awaitScriptJob(id, jobId, timeout, onTick); } finally { _endScript(id); } } // ---- 🧰 Komut paleti (D9 (4)) ---- // // Palet, hedef CATIA'nın (I1: YAPIŞKAN hedef — en son ön planda görülen BAĞLI // CATIA) workbench'ine ve aktif döküman türüne göre hangi kayıtlı öğeleri // göstereceğine karar verir (O4: öğe başına chip'ler, bkz. palette_model.dart). // Pencere/stil/konum işi `PaletteService`'te; karar YALNIZ burada (tek durum // kaynağı) alınır. PaletteSettings _palette = const PaletteSettings(); PaletteSettings get paletteSettings => _palette; /// I9: Pin başarılı mı? (`false` = yedek yolda: masaüstü takibi devrede.) bool _palettePinOk = false; /// 🎯 Palet hedefi elle mi seçildi? (başlıktaki hedef adına tıklayıp döngü) /// true iken ön plan takibi hedefi EZMEZ. Oturumluk — palette.json'a /// YAZILMAZ; uygulama yeniden açılınca otomatikten başlar. bool _paletteTargetPinned = false; bool get paletteTargetPinned => _paletteTargetPinned; /// Paletin şu an olduğu sanal masaüstü GUID'i (yalnız Pin başarısızsa /// takip için kullanılır; null = henüz bilinmiyor). String? _paletteDesktopGuid; /// Yapışkan hedefin kart id'si (null = henüz hedef yok). String? _paletteTargetId; String? get paletteTargetId => _paletteTargetId; /// Hedef CATIA kartı — kopmuş/kapanmışsa null (palet düğmeleri soluk çizilir). CatiaInstance? get paletteTarget { final id = _paletteTargetId; if (id == null) return null; final inst = _instances.where((i) => i.id == id).firstOrNull; if (inst == null || !inst.connected || inst.pid <= 0) return null; return inst; } /// Palet bağlamı (workbench + aktif döküman türü) — hedef yoksa boş. PaletteContext get paletteContext { final inst = paletteTarget; if (inst == null) return PaletteContext.empty; final active = inst.documents.where((d) => d.isActive).firstOrNull; return PaletteContext.fromFields( workbenchId: inst.workbenchId, activeDocumentName: active?.name, ); } /// Paletin göstereceği öğeler (kayıt sırası korunur). List get paletteEntries => paletteVisibleEntries(_macros, paletteContext); /// Chip seçenekleri: bilinen workbench kimlikleri (F3) + canlıdan /// öğrenilenler ([PaletteSettings.seenWorkbenches]). List get paletteWorkbenchChoices => paletteWorkbenchOptions(_palette.seenWorkbenches); /// Palet görünür mü (etkin + gizlenmemiş)? Popup 🧰 düğmesi bunu izler. bool get paletteVisible => _palette.enabled && !_palette.hidden; /// Paleti gösterir (ilk kez: pencereyi oluşturur — I4, kapalıyken engine yok). Future showPalette() => updatePaletteSettings(_palette.copyWith(enabled: true, hidden: false)); Future hidePalette() => updatePaletteSettings(_palette.copyWith(enabled: true, hidden: true)); /// 🧰 düğmesi / tray menüsü: aç-kapa. Future togglePalette() => paletteVisible ? hidePalette() : showPalette(); /// Palet ayarını uygular: disk yazımı + pencere/konum + içerik tek elden /// (pencere servisi tek yazıcı). Future updatePaletteSettings(PaletteSettings s) async { _palette = s; // Tray menüsünün 🧰 modu etiketi de tazelensin (syncMenu ayrıca çağrılır). WindowTrayService.paletteWhenCatia = s.showMode == PaletteShowMode.whenCatia; _safeNotify(); await PaletteService.applySettings(s); _pushPaletteState(); } /// Palet düğmesine tıklandı (alt pencereden `run`): öğe, o anki CATIA'da /// çalıştırılır (O7: yalnız hedef; "hepsine gönder" yok). /// /// 🧰 HEDEF SEÇİMİ: tıklama anında ön plan TAZE okunur — palet odağı /// ÇALMADIĞI için ön plandaki pencere, kullanıcının o an çalıştığı CATIA'dır /// ve öğe ORAYA gitmeli. Eski davranış (yalnız pid DEĞİŞİNCE güncellenen /// yapışkan id) CATIA kapanıp yeniden kaydolunca bayat kalıp YANLIŞ CATIA'ya /// gidebiliyordu. Yapışkan hedef yalnızca ön plan BİZİM penceremizken (popup /// öndeyken tıklama) ya da ön plandaki CATIA kayıtlı değilken devreye girer. Future runPaletteEntry(String entryId) async { // 🎯 Elle seçim (pin) varsa taze çözüm YOK — hedef neyse ORAYA gider. final fresh = _paletteTargetPinned ? null : await _resolvePaletteTargetFresh(); final inst = fresh ?? paletteTarget; if (inst == null) { _setSessionMessage('🧰 Palet: bağlı CATIA yok', warn: true); return; } debugPrint('PALET-RUN: $entryId -> ${inst.id} (pid=${inst.pid}, ' 'taze=${fresh != null}, pin=$_paletteTargetPinned)'); final m = _macros.where((e) => e.id == entryId).firstOrNull; if (m == null) return; if (m.isCommand) { unawaited(startCommandOn(inst.id, m.command!).then((r) { // Hangi CATIA'ya gittiği ilerleme bandında görünsün. _setSessionMessage( r.ok ? '✔ ${m.name} (${inst.title}): komut gönderildi' : '✖ ${m.name} (${inst.title}): ${scriptResultSummary(r)}', warn: !r.ok, ); })); return; } // announce: bantta "⏳ … (CATIA-0x) içinde çalışıyor…" + sonuç görünsün → // yanlış CATIA hemen fark edilir. unawaited(runMacroOn(inst.id, m, announce: true)); } /// 🧰 Palet modu (O2/D2): her zaman ↔ yalnız CATIA/biz öndeyken. Palet /// başlığındaki 👁/🌐 düğmesinden ya da tray menüsünden gelir; kalıcıdır /// (palette.json'a yazılır) ve tray menüsünün etiketi de tazelenir. Future togglePaletteShowMode() async { final next = _palette.showMode == PaletteShowMode.always ? PaletteShowMode.whenCatia : PaletteShowMode.always; await updatePaletteSettings(_palette.copyWith(showMode: next)); await WindowTrayService.syncMenu(); _setSessionMessage( next == PaletteShowMode.whenCatia ? '🧰 Palet: yalnız CATIA/biz ön plandayken görünür' : '🧰 Palet: her zaman görünür', ); } /// showMode == whenCatia iken palet ŞU AN görünür olmalı mı? Ön planda /// CATIA ya da bizim pencerelerden biri (popup) var mı? [shell] = /// `getShellState` anlık görüntüsü (PaletteService'in yerleşim turundan). bool _paletteWantsAutoVisible(PaletteShellState shell) => shell.foregroundIsSelf || _instances.any((i) => i.connected && _effectivePid(i) > 0 && _effectivePid(i) == shell.fgPid); /// Bir kartın ön-plan eşleştirmesinde kullanılacak pid'i: önce native /// probe'un bulduğu GERÇEK pencere pid'i ([realWindowPid]), henüz yoksa /// makronun bildirdiği `instance.pid`. Kart vurgusu (catia_list_tile) ve /// palet hedef çözümü AYNI kuralı kullanır → "renk değişen kart" ile /// "makronun gittiği CATIA" her zaman aynı olur. int _effectivePid(CatiaInstance i) => _windowCache[i.id]?.pid ?? i.pid; /// I9: palet penceresi yeni kaydedildi → BİR KEZ tüm masaüstlerine sabitle /// (PowerShell `Pin-Window`). Başarısızsa (R3: modül yok/eski sürüm) yedek /// devreye girer: paletin o anki masaüstü öğrenilir ve hedef CATIA başka /// masaüstüne geçtiğinde palet de oraya taşınır ([_maybeFollowTargetDesktop]). Future _pinPaletteWindow(int hwnd) async { _palettePinOk = false; final r = await VirtualDesktopService.pinWindow(hwnd); _palettePinOk = r.ok; debugPrint('PALET-PIN: hwnd=$hwnd ok=${r.ok} ${r.error ?? ''}'); if (!r.ok) { final info = await NativeBridge.getWindowDesktopInfo(hwnd); _paletteDesktopGuid = (info != null && info.valid) ? info.guid : null; } } /// Pin başarısız olduysa palet hedef CATIA'nın masaüstünü takip eder: /// hedefin masaüstü değiştiğinde palet penceresi de oraya taşınır /// (IVirtualDesktopManager kendi penceremizi taşımaya izin verir). /// Pin OK ise gereksiz — palet zaten her masaüstünde görünür. Future _maybeFollowTargetDesktop() async { if (_palettePinOk || !_palette.enabled || _disposed) return; final hwnd = PaletteService.hwnd; if (hwnd == null) return; final t = paletteTarget; if (t == null || t.desktopGuid.isEmpty) return; if (t.desktopGuid == _paletteDesktopGuid) return; final previous = _paletteDesktopGuid; _paletteDesktopGuid = t.desktopGuid; final hr = await NativeBridge.moveWindowToDesktop( _selfPid, t.desktopGuid, hwnd: hwnd); debugPrint('PALET-TAKIP: palet -> ${t.desktopGuid} hr=$hr ' '(önceki=$previous)'); } /// 🧰 AYNI PID'ye kayıtlı BİRDEN FAZLA kart olabilir (tek CATIA sürecinde /// çoklu döküman penceresi — kullanıcı loglarında doğrulandı: 19 kart, /// hepsi pid=9412). Bu durumda pid TEK BAŞINA ayırt edemez; CATIA'nın /// pencere başlığı ([fgTitle]) aktif dökümanın adını içerdiği için ikinci /// sinyal olarak kullanılır: `fgTitle` içinde kartın aktif döküman adı /// GEÇİYORSA o kart seçilir. Hiçbiri eşleşmezse (başlık boş/tanınamadı) /// önceki hedef adaylar arasındaysa ONA sadık kalınır (rastgele sıçramasın); /// o da yoksa ilk aday döner (eski davranış — tek adaylık pid'lerde zaten /// tek sonuç verir). CatiaInstance? _resolveInstanceForWindow( int pid, String fgTitle, String? preferId) { // KÖK NEDEN DÜZELTMESİ: `i.pid` (makronun RegisterInstance'ta bildirdiği // pid) launcher/bat ile başlatılan CATIA'larda GERÇEK pencere pid'inden // farklı olabilir. Kart vurgusu zaten gerçek pid'e bakıyordu (doğru // çalışıyordu); palet ise ham i.pid'e bakıp yanlış/hiç eşleşmiyordu. // Artık ikisi de aynı kaynağı ([_effectivePid]) kullanır. final candidates = _instances.where((i) => _effectivePid(i) == pid).toList(); if (candidates.isEmpty) return null; if (candidates.length == 1) return candidates.first; if (fgTitle.trim().isNotEmpty) { final t = fgTitle.toLowerCase(); for (final c in candidates) { final active = c.documents.where((d) => d.isActive).firstOrNull; final docName = active?.name.toLowerCase() ?? ''; if (docName.isNotEmpty && t.contains(docName)) return c; } } final sticky = candidates.where((c) => c.id == preferId).firstOrNull; if (sticky != null) return sticky; return candidates.first; } /// I1 (yapışkan hedef): ön planda kayıtlı bir CATIA varsa hedef o olur; ön /// planda CATIA yoksa (odak bizde / başka uygulamada) hedef DEĞİŞMEZ. HER /// 400 ms turunda ve her snapshot'ta çağrılır — pid→id eşleşmesi (CATIA /// kapanıp yeniden kaydolunca id başka sürece geçebilir) böylece bayat /// kalmaz. 🎯 Elle seçim varsa HİÇ çalışmaz (hedef sabit). Tıklama anındaki /// TAZE çözüm için bkz. [_resolvePaletteTargetFresh]. void _updatePaletteTarget() { if (_paletteTargetPinned) return; final pid = _activePid; if (pid == null) return; // NOT: `_resolvePaletteTargetFresh` ile TUTARLI — `connected` şartı // burada da ARANMAZ. Eskiden yalnız bağlı kartlar hedef olabiliyordu; // bağlantısı anlık titreyen (poller'ın henüz "bağlı değil" işaretlediği) // bir CATIA ön plandayken hedef GÜNCELLENMİYOR, bir önceki (BAŞKA) // CATIA'da yapışık kalıyordu — "hep aynı CATIA'da çalışıyor" // şikayetinin olası bir kaynağı buydu. final inst = _resolveInstanceForWindow(pid, _activeFgTitle, _paletteTargetId); if (inst == null) return; if (_paletteTargetId != inst.id) { _paletteTargetId = inst.id; debugPrint('PALET-HEDEF-OTO: fg=$pid "$_activeFgTitle" -> ${inst.id} ' '(bağlı=${inst.connected})'); // Başlıktaki 🎯 metni ve palet içeriği hedef değişir değişmez // tazelensin — önceden yalnız ~10 sn'lik teşhis turunda görünürdü. _safeNotify(); } _noteSeenWorkbench(inst.workbenchId); } /// 🧰 Tıklama anında TAZE hedef çözümü: ön plan pid'i ŞİMDİ okunur ve /// `_instances` içinde pid eşleşmesi aranır. `connected` şartı YOK — /// bağlantısı kopmuş kart da döner ki `runMacroOn`/`startCommandOn` /// "bağlantı yok" hatasını DOĞRU kartta göstersin (sessizce yanlış CATIA'ya /// düşmesin). Başarısızlık nedeni `PALET-HEDEF` satırına yazılır (fg=0: ön /// plan yok; fg==self: odak popup/palet'te; eşleşme yok: fg kayıtlı değil). Future _resolvePaletteTargetFresh() async { if (_paletteTargetPinned) return null; // 🎯 elle seçim → dokunma final shell = await NativeBridge.getShellState(); int fg = 0; String fgTitle = ''; if (shell != null) { fg = shell.fgPid; fgTitle = shell.fgTitle; _selfPid = shell.selfPid; } else { fg = await NativeBridge.getForegroundWindowPid(); } if (fg <= 0) { debugPrint('PALET-HEDEF: fg=0 (ön plan yok) self=$_selfPid'); return null; } if (fg == _selfPid) { debugPrint('PALET-HEDEF: fg=self=$_selfPid (odak popup/palet\'te ' '— yapışkan hedef kullanılacak)'); return null; } final inst = _resolveInstanceForWindow(fg, fgTitle, _paletteTargetId); if (inst == null) { final known = _instances .where((i) => i.pid > 0) .map((i) => '${i.id}:${i.pid}${i.connected ? '' : '!bağlı'}') .join(', '); debugPrint('PALET-HEDEF: fg=$fg pid eşleşmesi YOK ' '(kayıtlı: [$known]) — yapışkan hedef kullanılacak'); return null; } debugPrint('PALET-HEDEF: fg=$fg "$fgTitle" → ${inst.id}'); _paletteTargetId = inst.id; _noteSeenWorkbench(inst.workbenchId); return inst; } /// 🎯 Palet başlığındaki hedef alanı: hedefi sıradaki bağlı CATIA'ya çevirir /// ve SABİTLER (ön plan takibi artık hedefi ezmez). Sonuncudan sonra tekrar /// otomatik (ön plan takibi) moduna döner. Oturumluk — kalıcı yazılmaz. Future cyclePaletteTarget() async { final connected = _instances.where((i) => i.connected && i.pid > 0).toList(); if (connected.isEmpty) { _setSessionMessage('🧰 Palet: bağlı CATIA yok', warn: true); return; } final idx = connected.indexWhere((i) => i.id == _paletteTargetId); final nextIdx = (idx + 1) % connected.length; _paletteTargetId = connected[nextIdx].id; // Son öğeden başa sarmada otomatik moduna dön; aksi halde sabit kal. _paletteTargetPinned = !(idx >= 0 && nextIdx == 0); debugPrint('PALET-HEDEF-EL: hedef=$_paletteTargetId ' 'pin=$_paletteTargetPinned'); _setSessionMessage(_paletteTargetPinned ? '🧰 Palet hedefi: ${connected[nextIdx].title} (🎯 sabitlendi)' : '🧰 Palet hedefi: otomatik (ön plandaki CATIA)'); _safeNotify(); // → _pushPaletteState (başlık tazelenir) } /// R6: canlıdan görülen workbench kimlikleri (Sketcher/FreeStyle gibi /// tabloda olmayanlar) chip seçeneklerine eklensin. Diske YALNIZ yeni /// kimlikte ya da 10 dakikadan sonra yazılır — 400 ms'lik turda sürekli /// dosya yazımı olmasın. void _noteSeenWorkbench(String rawId) { final id = rawId.trim(); if (id.isEmpty) return; final cur = _palette.seenWorkbenches[id.toLowerCase()]; final fresh = cur == null || DateTime.now().difference(cur.lastSeen) > const Duration(minutes: 10); if (!fresh) return; _palette = _palette.copyWith( seenWorkbenches: recordSeenWorkbench(_palette.seenWorkbenches, id)); unawaited(PaletteService.saveSettings(_palette)); } /// Palet penceresine içeriği gönderir (etkin değilse hiç iş yapmaz; pencere /// servisi aynı imzayı tekrar göndermez). void _pushPaletteState() { if (!_palette.enabled) return; final ctx = paletteContext; final t = paletteTarget; unawaited(PaletteService.pushState(PaletteStatePayload( entries: [ for (final m in paletteVisibleEntries(_macros, ctx)) PaletteEntryView( id: m.id, emoji: m.emoji, label: m.label.isEmpty ? m.name : m.label, tooltip: m.isCommand ? 'Komut: ${m.command}' : (m.description.isEmpty ? m.name : m.description), enabled: t != null, ), ], workbenchId: ctx.workbenchId, docKind: ctx.docKind, targetTitle: t?.title ?? '', note: t == null ? 'bağlı CATIA yok' : '', viewMode: _palette.viewMode, showMode: _palette.showMode, targetPinned: _paletteTargetPinned, locked: _palette.locked, hidden: _palette.hidden, ))); } // ---- 📜 Kayıtlı makrolar (27. oturum) ---- /// Kayıtlı makro kütüphanesi (`macros.json`); açılışta [_initSession] yükler. List _macros = []; List get macros => List.unmodifiable(_macros); /// Şu an kod/makro çalıştıran CATIA kartları (aynı CATIA'da eşzamanlı /// ikinci çalıştırma engellenir; CATIA zaten donuk olur). final Set _scriptBusy = {}; bool isScriptBusy(String id) => _scriptBusy.contains(id); bool _beginScript(String id) { if (!_scriptBusy.add(id)) return false; _safeNotify(); return true; } void _endScript(String id) { if (_scriptBusy.remove(id)) _safeNotify(); } Future _saveMacros() async { try { await MacroLibraryService.save(_macros); } catch (e) { debugPrint('makrolar kaydedilemedi: $e'); _setSessionMessage('Makro listesi diske yazılamadı: $e', warn: true); } } /// Seçilen dosyayı makro olarak kaydeder. Aynı yol zaten kayıtlıysa /// (büyük/küçük harf duyarsız) yenisini eklemez, mevcut olanı döner. Future addMacro(String path, {String? name, String func = kScriptDefaultFunc, bool passApp = false}) async { final existing = _macros .where((m) => m.path.toLowerCase() == path.toLowerCase()) .firstOrNull; if (existing != null) return existing; final macro = SavedMacro( id: MacroLibraryService.newId(), name: (name == null || name.trim().isEmpty) ? MacroLibraryService.nameFromPath(path) : name.trim(), path: path, func: func.trim().isEmpty ? kScriptDefaultFunc : func.trim(), passApp: passApp, ); _macros = [..._macros, macro]; _safeNotify(); await _saveMacros(); return macro; } /// 📂 Sistem "Aç" diyaloğundan bir makro dosyası seçtirip kaydeder. /// Tray popup'ı diyalog açıkken gizlenmesin diye modal işaretlenir /// (bkz. [WindowTrayService.beginModal]). Dönüş: `macro` (eklenen ya da /// zaten kayıtlı olan), `note` (bilgi/uyarı), `error` (diyalog açılamadı). /// Kullanıcı vazgeçtiyse üçü de null. Future<({SavedMacro? macro, String? note, String? error})> pickAndAddMacro() async { String? path; WindowTrayService.beginModal(); try { path = await NativeBridge.pickOpenFile( title: 'Makro dosyası seç', filterName: 'CATIA makroları (*.CATScript, *.catvbs)', extension: MacroLibraryService.fileFilterExt, allFilesLabel: 'Tüm dosyalar (*.*)', ); } catch (e) { return (macro: null, note: null, error: '$e'); } finally { await WindowTrayService.endModal(); } if (path == null) return (macro: null, note: null, error: null); final already = _macros.any((m) => m.path.toLowerCase() == path!.toLowerCase()); final macro = await addMacro(path); String? note; if (already) { note = 'Bu dosya zaten kayıtlı: ${macro.name}'; } else if (!MacroLibraryService.isKnownMacroPath(path)) { note = 'Uyarı: dosya .CATScript/.catvbs değil — yine de eklendi'; } return (macro: macro, note: note, error: null); } /// 💾 "Kod" sekmesindeki metni adıyla KAYDEDER (kayıtlı kod öğesi). /// Aynı adda (büyük/küçük harf duyarsız) bir KOD öğesi varsa yenisini /// eklemez, onu günceller (`updated=true`). Dosya makrolarına dokunmaz. /// GÜVENLİK (B7): yalnız kullanıcının elle yazdığı metin için çağrılmalı. Future<({SavedMacro macro, bool updated})> saveCode( String name, String code, { String func = kScriptDefaultFunc, ScriptLanguage language = ScriptLanguage.vbScript, bool passApp = false, String description = '', }) async { final n = name.trim().isEmpty ? 'Kod' : name.trim(); final f = func.trim().isEmpty ? kScriptDefaultFunc : func.trim(); final d = description.trim(); final i = _macros.indexWhere( (m) => m.isCode && m.name.toLowerCase() == n.toLowerCase()); late final SavedMacro saved; if (i >= 0) { // Açıklama boş bırakıldıysa eskisi korunur. saved = _macros[i].copyWith( name: n, func: f, passApp: passApp, code: code, language: language.code, description: d.isEmpty ? null : d); _macros = [..._macros.sublist(0, i), saved, ..._macros.sublist(i + 1)]; } else { saved = SavedMacro( id: MacroLibraryService.newId(), name: n, path: '', func: f, passApp: passApp, code: code, language: language.code, description: d, ); _macros = [..._macros, saved]; } _safeNotify(); await _saveMacros(); return (macro: saved, updated: i >= 0); } /// 🧰 Bir StartCommand komutunu adıyla KAYDEDER (kayıtlı komut öğesi, /// 31. oturum O6): ayrı kütüphane yok, `SavedMacro`'nun `command` türü /// kullanılır. Aynı adda (büyük/küçük harf duyarsız) bir KOMUT öğesi /// varsa yenisini eklemez, onu günceller (`updated=true`). /// GÜVENLİK (B7): yalnız kullanıcının elle yazdığı komut adı içindir. Future<({SavedMacro macro, bool updated})> saveCommand( String name, String command, { String description = '', bool inPalette = false, String emoji = '', String label = '', List paletteWorkbenches = const [], List paletteDocKinds = const [], }) async { final n = name.trim().isEmpty ? command.trim() : name.trim(); final c = command.trim(); final d = description.trim(); final i = _macros.indexWhere( (m) => m.isCommand && m.name.toLowerCase() == n.toLowerCase()); late final SavedMacro saved; if (i >= 0) { saved = _macros[i].copyWith( name: n, command: c, description: d.isEmpty ? null : d, inPalette: inPalette, emoji: emoji.trim(), label: label.trim(), paletteWorkbenches: _lowerIds(paletteWorkbenches), paletteDocKinds: _lowerIds(paletteDocKinds), ); _macros = [..._macros.sublist(0, i), saved, ..._macros.sublist(i + 1)]; } else { saved = SavedMacro( id: MacroLibraryService.newId(), name: n, path: '', command: c, description: d, inPalette: inPalette, emoji: emoji.trim(), label: label.trim(), paletteWorkbenches: _lowerIds(paletteWorkbenches), paletteDocKinds: _lowerIds(paletteDocKinds), ); _macros = [..._macros, saved]; } _safeNotify(); await _saveMacros(); return (macro: saved, updated: i >= 0); } /// Ad / açıklama / fonksiyon / parametre ayarını günceller (dosya yolu /// değişmez). [description] boş metin verilirse açıklama SİLİNİR. Future updateMacro(String id, {String? name, String? func, bool? passApp, String? description}) async { final i = _macros.indexWhere((m) => m.id == id); if (i < 0) return; final cur = _macros[i]; final n = name?.trim(); final f = func?.trim(); _macros = [ ..._macros.sublist(0, i), cur.copyWith( name: (n == null || n.isEmpty) ? cur.name : n, func: (f == null || f.isEmpty) ? cur.func : f, description: description?.trim(), passApp: passApp, ), ..._macros.sublist(i + 1), ]; _safeNotify(); await _saveMacros(); } /// 🧰 Palet alanlarını günceller (O3/O4/O5): görünen emoji + kısa etiket ve /// "nerede görünsün" chip'leri (workbench'ler / döküman türleri). Diğer /// alanlara DOKUNMAZ (onlar için [updateMacro]). Boş liste = o grupta şart /// yok; hiç chip yoksa öğe HER bağlamda görünür (bkz. paletteEntryMatches). Future updateMacroPalette( String id, { required bool inPalette, String emoji = '', String label = '', List paletteWorkbenches = const [], List paletteDocKinds = const [], }) async { final i = _macros.indexWhere((m) => m.id == id); if (i < 0) return; final cur = _macros[i]; _macros = [ ..._macros.sublist(0, i), cur.copyWith( inPalette: inPalette, emoji: emoji.trim(), label: label.trim(), paletteWorkbenches: _lowerIds(paletteWorkbenches), paletteDocKinds: _lowerIds(paletteDocKinds), ), ..._macros.sublist(i + 1), ]; _safeNotify(); await _saveMacros(); } /// Chip kimlikleri KÜÇÜK HARF saklanır → `PaletteContext` ile karşılaştırma /// duyarsız olur (bkz. SavedMacro._strList). static List _lowerIds(List v) => [ for (final e in v) if (e.trim().isNotEmpty) e.trim().toLowerCase(), ]; /// Makroyu listeden kaldırır (DOSYA SİLİNMEZ). Future removeMacro(String id) async { _macros = _macros.where((m) => m.id != id).toList(); _safeNotify(); await _saveMacros(); } /// 📜 Kayıtlı makroyu bağlı bir CATIA'nın İÇİNDE çalıştırır /// (`SystemService.ExecuteScript`). Yalnız bağlantısı kurulmuş (yeşil) /// kartlarda; makro her seferinde DİSKTEKİ güncel dosyadan çalışır. /// [announce] true ise ilerleme bandına "çalışıyor…" ve sonuç/hata mesajı /// yazılır (kart ▶ menüsü); pencere kendi sonuç panelini gösterdiği için /// orada false verilir. Future runMacroOn( String id, SavedMacro macro, { bool announce = false, Duration timeout = const Duration(minutes: 10), void Function(int elapsedMs)? onTick, }) async { ScriptRunResult finish(ScriptRunResult r, String label) { if (announce && !_disposed) { final secs = (r.elapsedMs / 1000).toStringAsFixed(1); _setSessionMessage( r.ok ? '✔ ${macro.name} ($label): ${scriptResultSummary(r)} · $secs sn' : '✖ ${macro.name} ($label): ${scriptResultSummary(r)}', warn: !r.ok, ); } return r; } if (macro.isCode) { // 🧪 Kayıtlı KOD: dosya değil, `Evaluate` ile çalışır (runScriptOn kendi // bağlantı/meşgul kontrollerini yapar). final lbl = _instances.where((i) => i.id == id).firstOrNull?.title ?? id; if (announce) { _setSessionMessage('⏳ ${macro.name}: $lbl içinde çalışıyor… ' '(CATIA arayüzü bitene kadar donabilir)'); } final r = await runScriptOn( id, macro.code!, language: macro.language == 1 ? ScriptLanguage.vba : ScriptLanguage.vbScript, passApp: macro.passApp, func: macro.func, timeout: timeout, onTick: onTick, ); return finish(r, lbl); } if (macro.isCommand) { // 🧰 Kayıtlı KOMUT (StartCommand, 31. oturum): kendi bağlantı/meşgul // kontrollerini burada yapar (runScriptOn ile aynı kural, ayrı native // çağrı). final r = await startCommandOn(id, macro.command!, timeout: timeout, onTick: onTick); final lbl = _instances.where((i) => i.id == id).firstOrNull?.title ?? id; return finish(r, lbl); } if (!_isRealApp) { return const ScriptRunResult.failure( 'Makro çalıştırma yalnız gerçek uygulamada (Windows) kullanılabilir'); } if (id.startsWith(_ghostPrefix) || id.startsWith(openGhostPrefix)) { return finish( const ScriptRunResult.failure( 'Bu kart CATIA\'ya bağlı değil — önce bağla (🔌)'), 'bağlı değil'); } final inst = _instances.where((i) => i.id == id).firstOrNull; if (inst == null || inst.pid <= 0 || !inst.connected) { return finish( const ScriptRunResult.failure( 'Bu CATIA\'ya bağlantı yok (kart yeşil/bağlı olmalı)'), inst?.title ?? id); } final label = inst.title; if (macro.folder.isEmpty || !File(macro.path).existsSync()) { return finish( ScriptRunResult.failure('Makro dosyası bulunamadı: ${macro.path}'), label); } if (!_beginScript(id)) { return finish( const ScriptRunResult.failure( 'Bu CATIA\'da zaten bir kod/makro çalışıyor — bitmesini bekle'), label); } try { if (announce) { _setSessionMessage('⏳ ${macro.name}: $label içinde çalışıyor… ' '(CATIA arayüzü bitene kadar donabilir)'); } final jobId = await NativeBridge.runScriptFile( id, macro.folder, macro.fileName, func: macro.func, passApp: macro.passApp); if (jobId <= 0) { return finish( ScriptRunResult.failure(scriptStartErrorText(jobId)), label); } _dlog('MAKRO-CALISTIR $id job=$jobId "${macro.name}" ' 'file=${macro.path} func=${macro.func} passApp=${macro.passApp}'); final r = await _awaitScriptJob(id, jobId, timeout, onTick); return finish(r, label); } finally { _endScript(id); } } /// `getScriptJob`'u iş bitene / zaman aşımına kadar yoklar. Future _awaitScriptJob(String id, int jobId, Duration timeout, void Function(int elapsedMs)? onTick) async { final deadline = DateTime.now().add(timeout); var lastElapsed = 0; while (!_disposed) { final r = await NativeBridge.getScriptJob(jobId); if (r != null) { lastElapsed = (r['elapsedMs'] as int?) ?? lastElapsed; if (r['found'] == false) { return const ScriptRunResult.failure('İş kaydı bulunamadı'); } if (r['done'] == true) { final ok = r['ok'] == true; _dlog('KOD-CALISTIR $id job=$jobId ${ok ? "OK" : "HATA"} ' '${lastElapsed}ms'); return ScriptRunResult( ok: ok, result: (r['result'] as String?) ?? '', error: (r['error'] as String?) ?? '', elapsedMs: lastElapsed, ); } onTick?.call(lastElapsed); } if (DateTime.now().isAfter(deadline)) { return ScriptRunResult( ok: false, error: 'Zaman aşımı — script CATIA\'da hâlâ sürüyor olabilir ' '(CATIA\'da açık bir diyalog/MsgBox var mı bak)', elapsedMs: lastElapsed, stillRunning: true, ); } await Future.delayed(const Duration(milliseconds: 300)); } return ScriptRunResult( ok: false, error: 'İptal edildi', elapsedMs: lastElapsed); } /// 🔁 Geçmiş kaydı yeniden kuyruğa al (mock: bilgi amaçlı, liste sabit). void retryHistory(String id) { notifyListeners(); } /// 🗑 Geçmiş kaydını sil. void deleteHistory(String id) { _history.removeWhere((h) => h.id == id); notifyListeners(); } /// ✏️ Kart başlığını yeniden adlandır (çift tıklama ile düzenleme). /// Boş başlık verilirse özel başlık temizlenip varsayılana dönülür. /// Bağlı bir CATIA'ysa (pid>0) GERÇEK pencere başlığını da değiştirir — /// böylece CATIA'nın görev çubuğu/pencere başlıkları da aynı ismi taşır. void renameInstance(String id, String newTitle) { final i = _indexOf(id); if (i < 0) return; final t = newTitle.trim(); _instances[i] = t.isEmpty ? _instances[i].copyWith(clearDisplayName: true) : _instances[i].copyWith(displayName: t); notifyListeners(); // CATIA pencere başlığını eşitle (boş bırakılırsa "CATIA V5"e dön). final inst = _instances[i]; if (inst.pid > 0 && inst.connected) { final label = t.isEmpty ? (inst.documents.where((d) => d.isActive).isNotEmpty ? inst.documents.firstWhere((d) => d.isActive).name : '') : t; unawaited(NativeBridge.setInstanceWindowTitle(inst.id, label)); } } /// ▶ Kartta döküman satırındaki "Aktif et": CATIA'da o dökümanın penceresini /// öne getirir (`Application.Windows.Item(name).Activate()`; yedek /// `Documents.Item(name).Activate()`) ve CATIA penceresini de öne alır /// (küçültülmüşse geri yükler, başka masaüstündeyse o masaüstüne geçer). Future activateDocument(String instanceId, String docId) async { final i = _indexOf(instanceId); if (i < 0) return; final inst = _instances[i]; final doc = inst.documents.where((d) => d.id == docId).firstOrNull; if (doc == null || doc.name.isEmpty) return; if (inst.pid <= 0 || !inst.connected) { _setSessionMessage('${inst.title}: CATIA bağlı değil', warn: true); return; } if (!_sessionBusy) { _setSessionMessage('▶ ${inst.title}: "${doc.name}" aktif ediliyor…'); } final ok = await NativeBridge.activateDocumentWindow(inst.id, doc.name); if (_sessionBusy) return; // geri yükleme sürüyorsa mesajı ezme if (!ok) { _setSessionMessage( '${inst.title}: "${doc.name}" aktif edilemedi (CATIA meşgul olabilir)', warn: true); return; } _setSessionMessage('▶ ${inst.title}: "${doc.name}" öne getirildi'); // CATIA çerçeve penceresini de öne al (👉 Window.Activate CATIA içindeki // dökümanı öne getirir; çerçeve başka masaüstünde/küçültülmüş olabilir). await bringInstanceToFront(instanceId); } /// ✏️ Geçmiş kaydının başlığını yeniden adlandır. void renameHistory(String id, String newTitle) { final i = _history.indexWhere((h) => h.id == id); if (i < 0) return; final t = newTitle.trim(); _history[i] = t.isEmpty ? _history[i].copyWith(clearDisplayName: true) : _history[i].copyWith(displayName: t); notifyListeners(); } // ---- Genel toolbar aksiyonları (üst menü altı) ---- /// ➕ Yeni CATIA: gerçek uygulamada [launchNewCatia] ile GERÇEK bir CATIA /// başlatıp bağlar; testte (Platform/FLUTTER_TEST) eski sahte davranış. void addInstance() { if (_isRealApp) { unawaited(launchNewCatia()); return; } _addMockInstance(); } /// İlk bağlantı kısayolu denemesine kadar beklenen süre: CATIA'nın /// başlangıç makrosu varsa kendiliğinden bağlanır; yoksa CATIA yüklenip /// pencere hazır olunca kısayol gönderilir. static const Duration _newCatiaFirstShortcutDelay = Duration(seconds: 30); /// Kısayol denemeleri arası (makro çalışıp kayıt olsun diye). static const Duration _newCatiaShortcutGap = Duration(seconds: 15); static const int _newCatiaMaxShortcutAttempts = 6; static const Duration _newCatiaTimeout = Duration(minutes: 4); /// ➕ Yeni CATIA başlat ve GERÇEKTEN bağla. Ayarlardaki başlatıcıyla /// CATIA'yı açar; makro kendiliğinden kayıt olursa tamam, olmazsa (ve /// Ayarlar'da bağlantı kısayolu tanımlıysa) yeni sürecin penceresine /// kısayolu [_newCatiaShortcutGap] aralıkla, en çok /// [_newCatiaMaxShortcutAttempts] kez gönderir; kayıt gelince biter. /// [group] verilirse (masaüstü başlığındaki ➕) bağlanan CATIA o masaüstüne /// taşınır (geçiş YAPILMAZ). NOT: CATIA açılırken masaüstünü değiştirme — /// başka masaüstündeki pencere öne getirilemez, kısayol gönderilemez. Future launchNewCatia({DesktopGroup? group}) async { if (_sessionBusy) { _setSessionMessage('Başka bir oturum işlemi sürüyor, bitmesini bekleyin.', warn: true); return; } final target = (group != null && !group.isUnknown) ? group : null; final label = target == null ? 'Yeni CATIA' : 'Yeni CATIA → ${target.label} ${target.displayName}'; _sessionBusy = true; _safeNotify(); try { _setSessionMessage('⏳ $label: CATIA başlatılıyor…'); final before = _liveInstances.map((i) => i.id).toSet(); final beforePids = { for (final p in await NativeBridge.listCatiaProcesses(_catiaExeNames)) p.pid, }; final err = await _launchCatia(); if (err != null) { _setSessionMessage('⚠ $label: $err', warn: true); return; } final keys = parseShortcutKeys(_settings.connectShortcut); _setSessionMessage(keys == null ? '⏳ $label: CATIA açılıyor, macro kaydı bekleniyor… (Ayarlar\'da ' 'bağlantı kısayolu yok: macro kendiliğinden bağlanmıyorsa ' 'CATIA\'da elle çalıştır)' : '⏳ $label: CATIA açılıyor, bağlantı bekleniyor…'); final res = await _waitNewCatiaConnected(before, beforePids, keys, label); if (_disposed) return; final live = res.instance; if (live == null) { _setSessionMessage( '⚠ $label: CATIA açıldı ama bağlanmadı' '${res.attempts > 0 ? ' (kısayol ${res.attempts} kez gönderildi)' : ''}' ' — kehribar kartta 🔌 ile dene; kısayolun CATIA\'da bağlantı ' 'makrosuna atandığından emin ol', warn: true); return; } if (target == null) { _setSessionMessage('✅ $label: ${live.title} bağlandı'); return; } _setSessionMessage( '⏳ $label: ${live.title} bağlandı, masaüstüne taşınıyor…'); final moveErr = await _moveLiveToGroup(live, target); if (_disposed) return; if (moveErr == null) { _setSessionMessage( '✅ $label: ${live.title} bağlandı ve masaüstüne taşındı'); } else { _setSessionMessage( '⚠ $label: ${live.title} bağlandı ama masaüstüne taşınamadı: ' '$moveErr', warn: true); } } catch (e, st) { debugPrint('launchNewCatia EXCEPTION: $e\n$st'); _setSessionMessage('⚠ Yeni CATIA: beklenmeyen hata: $e', warn: true); } finally { _sessionBusy = false; _safeNotify(); } } /// Yeni başlatılan CATIA'nın kayıt olmasını bekler; gerekirse yeni sürecin /// penceresine bağlantı kısayolunu gönderir. Kısayol yalnızca pencere gerçekten /// öne gelirse gönderilir (native tarafın kuralı); öne gelmeyen deneme /// sayılmaz, [_newCatiaShortcutGap] sonra tekrar denenir. Future<({CatiaInstance? instance, int attempts})> _waitNewCatiaConnected( Set before, Set beforePids, List? keys, String label, ) async { final started = DateTime.now(); final deadline = started.add(_newCatiaTimeout); var nextTry = started.add(_newCatiaFirstShortcutDelay); var attempts = 0; while (DateTime.now().isBefore(deadline) && !_disposed) { for (final i in _liveInstances) { if (!before.contains(i.id)) return (instance: i, attempts: attempts); } if (keys != null && attempts < _newCatiaMaxShortcutAttempts && !DateTime.now().isBefore(nextTry)) { nextTry = DateTime.now().add(_newCatiaShortcutGap); final procs = await NativeBridge.listCatiaProcesses(_catiaExeNames); final fresh = procs .where((p) => !beforePids.contains(p.pid) && p.hwnd != 0) .firstOrNull; if (fresh != null && !_disposed) { final ok = await NativeBridge.sendShortcutToWindow(fresh.hwnd, keys); _dlog('YENI-CATIA-KISAYOL pid=${fresh.pid} ' 'hwnd=0x${fresh.hwnd.toRadixString(16)} ok=$ok'); if (ok) { attempts++; _setSessionMessage('⏳ $label: bağlantı kısayolu gönderildi ' '($attempts/$_newCatiaMaxShortcutAttempts), bağlantı bekleniyor…'); } } } await Future.delayed(const Duration(seconds: 1)); } return (instance: null, attempts: attempts); } /// Bağlanan yeni CATIA'nın penceresini [g] masaüstüne taşır (geçiş yok). /// Hata metni ya da null (başarılı). Motor seçimi kart 🖥 rozetiyle aynı: /// PowerShell → indeks; native → GUID (yalnız bilinen masaüstü). Future _moveLiveToGroup(CatiaInstance live, DesktopGroup g) async { ({int hwnd, int pid})? w; for (var a = 0; a < 3 && w == null && !_disposed; a++) { w = await _findRealWindow(live); if (w == null) await Future.delayed(const Duration(milliseconds: 1500)); } if (w == null) return 'CATIA penceresi bulunamadı'; String? err; if (_psEngine) { final r = await VirtualDesktopService.moveWindowToDesktopIndex( w.hwnd, g.index, switchTo: false); if (!r.ok) err = '${r.error}'; } else if (g.guid.isEmpty) { err = 'bu masaüstünün kimliği bilinmiyor (Ayarlar ▸ Sanal Masaüstü ▸ ' '"PowerShell modülü" motorunu seçin)'; } else { final hr = await NativeBridge.moveWindowToDesktop(w.pid, g.guid, hwnd: w.hwnd); if (hr != 0) { err = hr == null ? 'yanıt yok' : '0x${(hr & 0xFFFFFFFF).toRadixString(16).toUpperCase()}'; } } _dlog('YENI-CATIA-TASI ${live.id} hedef=${g.label} hata=${err ?? '-'}'); if (err == null) { _psReadNextAt = DateTime.fromMillisecondsSinceEpoch(0); unawaited(refreshDesktops()); } return err; } /// Sahte kart (yalnız `flutter test` / gerçek olmayan çalışma). void _addMockInstance() { final n = _instances.length + 1; final id = 'CATIA-${n.toString().padLeft(2, '0')}'; if (_instances.any((i) => i.id == id)) { _instances.add(CatiaInstance( id: '$id-${DateTime.now().millisecond}', fileName: '—', status: CatiaStatus.idle, uptime: Duration.zero, pid: 0, worker: 'Worker-$n', connected: false, )); } else { _instances.add(CatiaInstance( id: id, fileName: '—', status: CatiaStatus.idle, uptime: Duration.zero, pid: 0, worker: 'Worker-$n', connected: false, )); } notifyListeners(); } /// ▶️ Tüm boşta instance'ları hesaplamaya al. void startAll() { var changed = false; for (var i = 0; i < _instances.length; i++) { final cur = _instances[i]; if (!cur.status.isBusy) { _instances[i] = cur.copyWith(status: CatiaStatus.calculating, progress: 0); changed = true; } } if (changed) notifyListeners(); } /// ⏹ Tüm süren işleri durdur. void stopAll() { var changed = false; for (var i = 0; i < _instances.length; i++) { final cur = _instances[i]; if (cur.status.isBusy) { _instances[i] = cur.copyWith(status: CatiaStatus.idle, clearProgress: true); changed = true; } } if (changed) notifyListeners(); } /// 🔄 Tüm listeyi yenile (mock: seçim + aramayı sıfırlar). void refreshAll() { _selectedInstanceId = null; _query = ''; notifyListeners(); } /// 🧹 Sorunlu olmayan boş kayıtları temizle (mock: bağlantısızları at). void clearIdle() { _instances.removeWhere((i) => !i.connected && !i.status.isBusy); notifyListeners(); } void setTab(HomeTab t) { _tab = t; notifyListeners(); } // ---- Sanal masaüstü etiketi ---- /// [desktopNumber] varsa doğrudan onu ("M" + numara) gösterir — bu artık /// Görev Görünümü'ndeki GERÇEK sıra numarasıdır (native registry /// okumasıyla çözülür). Numara henüz gelmemişse (native yanıt /// vermediyse/desteklenmiyorsa) eski GUID→ilk-görülme-sırası davranışına /// düşer (yalnızca geçici bir yer tutucu; kalıcı olarak KAYDEDİLMEZ). final Map _desktopIndexes = {}; String desktopLabel(String guid, {int? desktopNumber}) { if (desktopNumber != null) return '🖥 M$desktopNumber'; if (guid.isEmpty) return '🖥 M?'; final idx = _desktopIndexes.putIfAbsent(guid, () => _desktopIndexes.length + 1); return '🖥 M$idx'; } /// Her CATIA'nın GERÇEK pencere tutamacı önbelleği (id → hwnd+pid). /// Başlık-belirteci probe'u (CATIA başlığına kısa süre yazma) yalnızca /// önbellek boşken / HWND geçersizleşince çalışır; sonraki tüm timer /// tikleri sadece ucuz bir `getWindowDesktopInfo(hwnd)` çağrısıdır. final Map _windowCache = {}; /// [id]'nin GERÇEK pencere pid'i (native caption-probe ile bulunmuş — /// `_windowCache`). CATScript makronun `RegisterInstance`'a bildirdiği /// `CatiaInstance.pid` GÜVENİLMEZ olabilir (launcher/bat üzerinden /// başlatılan CATIA'larda gerçek pencere sürecinden FARKLI çıkabiliyor — /// bkz. PROBE-BULUNDU logu: regPid ≠ winPid). Henüz probe edilmemişse /// null (çağıran bu durumda `instance.pid`'e düşebilir). int? realWindowPid(String id) => _windowCache[id]?.pid; /// Probe başarısız olan (CATIA meşgul/COM yok) örnekler için bir sonraki /// deneme zamanı — timer her 4 sn'de CATIA başlığını kurcalamasın. final Map _probeRetryAt = {}; bool _desktopRefreshing = false; bool _desktopRefreshQueued = false; /// Teşhis: her CATIA için son loglanan masaüstü imzası (yalnızca değişimde yaz). final Map _lastDeskSig = {}; /// Teşhis günlüğü: exe'nin yanındaki `desktop_move.log` (native /// `com_bridge.log` ile yan yana okunur). Hangi CATIA id'si hangi HWND / /// pid / pencere başlığıyla, hangi masaüstüne taşındı → yanlış pencere /// varsa burada görünür. void _dlog(String msg) { try { final dir = File(Platform.resolvedExecutable).parent.path; File('$dir\\desktop_move.log').writeAsStringSync( '${DateTime.now().toIso8601String()} $msg\r\n', mode: FileMode.append, flush: true); } catch (_) {} } /// Bir taşıma/adlandırma/geçiş öncesi-sonrası durum satırı: CATIA id'si, /// kayıtlı pid (makro bildirir; GÜVENİLMEZ), gerçek HWND+pid, pencerenin /// o anki başlığı ve bulunduğu masaüstü. Future _logWindow( String tag, CatiaInstance inst, ({int hwnd, int pid}) w, String extra) async { final info = await NativeBridge.getWindowDesktopInfo(w.hwnd, pid: w.pid); _dlog('$tag ${inst.id} regPid=${inst.pid} ' 'hwnd=0x${w.hwnd.toRadixString(16)} winPid=${w.pid} ' 'valid=${info?.valid} title="${info?.title}" ' 'desk=#${info?.number}/"${info?.name}" $extra'); } /// 🔁 Timer ile (4 sn) ve kart açılışında çağrılır: her gerçek CATIA'nın /// GERÇEK penceresinin (pid'den tahmin YOK) sanal masaüstü GUID'i, Görev /// Görünümü numarası ve adını okuyup instance'a yazar. Masaüstü değişince /// (kullanıcı pencereyi sürükledi ya da uygulama taşıdı) rozet kendiliğinden /// güncellenir. Pencere bulunamıyorsa rozet yanlış bir değer göstermek /// yerine "M?" kalır. Future refreshDesktops() async { if (_disposed) return; if (_desktopRefreshing) { // Devam eden yoklama bittiğinde bir kez daha çalış (ör. az önce bir // taşıma yapıldı; sonucu 4 sn beklemeden görmek istiyoruz). _desktopRefreshQueued = true; return; } _desktopRefreshing = true; try { do { _desktopRefreshQueued = false; await _refreshDesktopsOnce(); } while (_desktopRefreshQueued && !_disposed); // Bağlı CATIA'ların gerçek pencere pid'leri (probe) belli olmuş olabilir: // "açık ama bağlanmadı" hayaletlerini hemen (bir sonraki taramayı // beklemeden) yeniden hesapla; bağlanan CATIA'nın hayaleti düşsün. if (!_disposed && _scanned.isNotEmpty) { final before = _ghostSig(); _rebuildGhosts(); if (_ghostSig() != before) _safeNotify(); } } finally { _desktopRefreshing = false; } } /// [_refreshDesktopsOnce] TEK bir instance'ın pencere + masaüstü bilgisini /// okur. 🚀 PERF (29. oturum): bu metod artık ÇAĞIRAN tarafta `Future.wait` /// ile PARALEL çalıştırılıyor (önceden id'ler üzerinde sıralı bir /// `for (... await ...)` döngüsü vardı — N bağlı CATIA'da bir poll turu /// N × ~900ms'ye kadar uzayabiliyordu, çünkü her `getWindowDesktopInfo` /// çağrısı 900ms timeout'lu). Salt-okunur (COM'a dokunmaz), bu yüzden /// paralel çalışması güvenli. `_instances` listesine YAZMAZ — sadece ham /// sonucu döner; mutasyon çağıran tarafta, tüm sonuçlar toplandıktan /// SONRA, senkron tek turda yapılır (çakışma riski olmasın diye). Future<_DesktopProbeResult?> _probeDesktopInfo(String id) async { final idx = _indexOf(id); if (idx < 0) return null; var w = _windowCache[id]; if (w == null) { final inst = _instances[idx]; if (!inst.connected) return null; // COM bağlı değil → başlık yazılamaz final retry = _probeRetryAt[id]; if (retry != null && DateTime.now().isBefore(retry)) return null; w = await _findRealWindow(inst, timeout: const Duration(seconds: 6)); if (w == null) { _probeRetryAt[id] = DateTime.now().add(const Duration(seconds: 20)); return null; } _probeRetryAt.remove(id); } final info = await NativeBridge.getWindowDesktopInfo(w.hwnd, pid: w.pid); if (info == null) return null; // native yanıt vermedi → dokunma if (!info.valid) { // Pencere yok/HWND başka pencereye geçmiş → sonraki tikte yeniden bul. _windowCache.remove(id); return null; } if (info.guid.isEmpty) { // Native okuma başarısız (ör. bazı makinelerde IVirtualDesktopManager // oluşturulamıyor: CoCreateInstance 0x80040154) → çağıran taraf // PowerShell modülüyle TEK toplu çağrıda yedek okuma yapacak. return _DesktopProbeResult(id: id, window: w, psFallbackHwnd: w.hwnd); } return _DesktopProbeResult(id: id, window: w, info: info); } Future _refreshDesktopsOnce() async { var changed = false; final psFallback = {}; // id → hwnd (native okuyamadıklar) // Await'ler sırasında _instances yeniden kurulabilir → indeks yerine id. final ids = [for (final i in _instances) if (i.pid > 0) i.id]; // 🚀 PERF: her id'nin pencere/masaüstü sorgusu PARALEL çalışır (bkz. // _probeDesktopInfo dokümantasyonu). Sonuçlar toplandıktan sonra // _instances mutasyonu aşağıda SENKRON (await'siz) yapılır. final probes = await Future.wait([for (final id in ids) _probeDesktopInfo(id)]); if (_disposed) return; for (final r in probes) { if (r == null) continue; if (r.psFallbackHwnd != null) { psFallback[r.id] = r.psFallbackHwnd!; continue; } final idx = _indexOf(r.id); if (idx < 0) continue; final cur = _instances[idx]; final info = r.info!; final w = r.window!; final sig = '${info.guid}|${info.number}|${info.name}'; if (_lastDeskSig[r.id] != sig) { _lastDeskSig[r.id] = sig; unawaited(_logWindow('MASAUSTU', cur, w, 'guidBos=${info.guid.isEmpty} sayi=${info.number}')); } final guidOk = info.guid.isNotEmpty; final hasName = info.name.isNotEmpty; final guidChanged = guidOk && info.guid != cur.desktopGuid; final numberChanged = info.number != null && info.number != cur.desktopNumber; final nameChanged = hasName && info.name != cur.desktopName; // GUID okundu ama masaüstünün adı yok → masaüstü isimsiz (ya da adı // silindi): eski adı tutma. GUID okunamadıysa dokunma. final nameCleared = !hasName && guidOk && (cur.desktopName ?? '').isNotEmpty; if (guidChanged || numberChanged || nameChanged || nameCleared) { _instances[idx] = cur.copyWith( desktopGuid: guidOk ? info.guid : null, desktopNumber: info.number, desktopName: hasName ? info.name : null, clearDesktopName: nameCleared, ); changed = true; } } // Yedek okuma: native IVirtualDesktopManager kullanılamıyorsa PowerShell // `VirtualDesktop` modülünden (taşıma zaten bunu kullanıyor) numara+ad al. // Her çağrı bir powershell.exe başlattığı için hepsi TEK çağrıda ve // sıklığı sınırlı (5 sn; hata olursa 30 sn geri çekilme). if (psFallback.isNotEmpty && !_disposed && DateTime.now().isAfter(_psReadNextAt)) { final res = await VirtualDesktopService.desktopsOfWindows(psFallback.values.toList()); _psReadNextAt = DateTime.now() .add(res == null ? const Duration(seconds: 30) : const Duration(seconds: 5)); if (res != null) { for (final e in psFallback.entries) { final r = res[e.value]; if (r == null) continue; final idx = _indexOf(e.key); if (idx < 0) continue; final cur = _instances[idx]; final number = r.index + 1; // Adı olmayan masaüstünde modül varsayılan adı döndürür → ad yok say. final isDefault = RegExp(r'^(Masaüstü|Desktop)\s+\d+$', caseSensitive: false) .hasMatch(r.name.trim()); final name = isDefault ? '' : r.name.trim(); final hadName = (cur.desktopName ?? '').isNotEmpty; if (cur.desktopNumber != number || (cur.desktopName ?? '') != name) { _instances[idx] = cur.copyWith( desktopNumber: number, desktopName: name.isEmpty ? null : name, clearDesktopName: name.isEmpty && hadName, ); changed = true; _dlog('MASAUSTU-PS ${e.key} hwnd=0x${e.value.toRadixString(16)} ' 'desk=#$number/"$name"'); } } } } if (changed) _safeNotify(); } DateTime _psReadNextAt = DateTime.fromMillisecondsSinceEpoch(0); // ---- Sanal masaüstü kontrol (🖥️➡️ Taşı aksiyonu, kart üzerinden) ---- /// Aktif motor PowerShell mi? bool get _psEngine => _settings.desktopEngine == DesktopEngine.powershell; /// Masaüstü oluşturma/adlandırma/silme/geçiş yapılabilir mi? (PowerShell motoru) bool get canManageDesktops => _psEngine; /// Diyalogda gösterilecek masaüstleri. /// * PowerShell motoru: Windows'taki TÜM masaüstleri (modülden). /// * Native motor: yalnızca CATIA'ların şu an bulunduğu (GUID'i bilinen) /// masaüstleri — native yalnızca bunlara taşıyabilir. Future> listDesktops() async { if (_psEngine) return VirtualDesktopService.listDesktops(); final seen = {}; final out = []; for (final i in _instances) { if (i.desktopGuid.isEmpty || !seen.add(i.desktopGuid)) continue; final n = i.desktopNumber; out.add(DesktopInfo( index: n == null ? -1 : n - 1, name: (i.desktopName ?? '').isNotEmpty ? i.desktopName! : (n == null ? 'Masaüstü ?' : 'Masaüstü $n'), guid: i.desktopGuid, )); } out.sort((a, b) => a.index.compareTo(b.index)); return out; } /// Bağlı (pid>0) CATIA'yı döndürür; değilse kullanıcıya uyarı gösterip null. CatiaInstance? _realInstanceOrWarn(String id) { final inst = _instances.where((i) => i.id == id).firstOrNull; if (inst == null) return null; if (inst.pid <= 0) { _setSessionMessage( '${inst.title}: bağlı (gerçek) bir CATIA değil, işlem yapılamaz.', warn: true); return null; } return inst; } bool _requirePowerShell(String action) { if (_psEngine) return true; _setSessionMessage( '$action için Ayarlar ▸ 🖥️ Sanal Masaüstü ▸ "PowerShell modülü" ' 'motorunu seçin (native motor yalnızca bilinen bir masaüstüne taşır).', warn: true); return false; } final _rng = Random(); final Set _probing = {}; /// 🔎 [inst]'in GERÇEK pencere tutamacını (HWND) bulur. /// /// pid'den "ana pencere"yi tahmin etmek (Process.MainWindowHandle / ilk /// görünür pencere) birden çok CATIA/pencere varken YANLIŞ pencereyi /// verebiliyor → yanlış CATIA taşınıyordu. Bunun yerine CATIA'nın kendi /// COM `Application.Caption`'ına benzersiz bir belirteç ("FIKO-xxxxxxxx") /// yazılır, pencere başlıktan bulunur, sonra ÖZGÜN başlık aynen geri /// yazılır. Dönen pid, pencerenin gerçek süreç kimliğidir. /// Bulunamazsa null (CATIA meşgul/COM bağlı değil olabilir) — bu durumda /// tahminle taşımak yerine işlem iptal edilir. Future<({int hwnd, int pid})?> _findRealWindow(CatiaInstance inst, {Duration timeout = const Duration(seconds: 15)}) async { // Önbellekteki HWND hâlâ aynı sürece ait geçerli bir pencereyse tekrar // başlık yazmaya gerek yok (CATIA başlığı kurcalanmaz, anında döner). final cached = _windowCache[inst.id]; if (cached != null) { final info = await NativeBridge.getWindowDesktopInfo(cached.hwnd, pid: cached.pid); if (info == null || info.valid) return cached; _windowCache.remove(inst.id); // HWND geçersiz → yeniden bul } if (!_probing.add(inst.id)) return null; // aynı CATIA için probe sürüyor try { final token = 'FIKO-${_rng.nextInt(1 << 32).toRadixString(16).padLeft(8, '0')}'; if (!await NativeBridge.beginCaptionProbe(inst.id, token)) return null; ({int hwnd, int pid})? w; final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline) && !_disposed) { w = await NativeBridge.findWindowByTitle(token); if (w != null) break; await Future.delayed(const Duration(milliseconds: 250)); } // Özgün başlığı geri yaz. Pencere bulunamadıysa CATIA meşgul olabilir // (Begin hâlâ kuyrukta) → gecikmeli ikinci bir geri yazma da dene. await NativeBridge.endCaptionProbe(inst.id); if (w == null) { unawaited(Future.delayed(const Duration(seconds: 8), () => NativeBridge.endCaptionProbe(inst.id))); } else if (inst.pid > 0 && w.pid != inst.pid) { debugPrint('desktop: ${inst.id} kayıtlı pid=${inst.pid} ama gerçek ' 'pencere pid=${w.pid} (hwnd=0x${w.hwnd.toRadixString(16)}) — ' 'gerçek pencere kullanıldı.'); } if (w != null) { _windowCache[inst.id] = w; unawaited(_logWindow('PROBE-BULUNDU', inst, w, 'token=$token')); } else { _dlog('PROBE-BULUNAMADI ${inst.id} regPid=${inst.pid} token=$token'); } return w; } finally { _probing.remove(inst.id); } } static const _noWindowMsg = 'CATIA penceresi bulunamadı (CATIA meşgul ya da ' 'başlık değiştirilemedi) — kimse taşınmadı, biraz sonra tekrar deneyin.'; /// 🖥️➡️ Bir CATIA'yı [desktopName] adlı masaüstüne taşır — masaüstü yoksa /// o isimle oluşturulur (yalnızca PowerShell motoru). Future moveInstanceToNamedDesktop(String id, String desktopName) async { final name = desktopName.trim(); if (name.isEmpty) return; final inst = _realInstanceOrWarn(id); if (inst == null || !_requirePowerShell('İsimle masaüstüne taşıma')) return; _setSessionMessage('⏳ ${inst.title}: pencere aranıyor…'); final w = await _findRealWindow(inst); if (w == null) { _setSessionMessage('${inst.title}: $_noWindowMsg', warn: true); return; } _setSessionMessage('⏳ ${inst.title}: "$name" masaüstüne taşınıyor…'); await _logWindow('TASI-ONCE', inst, w, 'hedef="$name"'); final r = await VirtualDesktopService.moveWindowToNamedDesktop(w.hwnd, name); _dlog('TASI-PS ${inst.id} hwnd=0x${w.hwnd.toRadixString(16)} ' 'hedef="$name" ok=${r.ok} err=${r.error}'); await _logWindow('TASI-SONRA', inst, w, 'hedef="$name"'); if (!r.ok) { _setSessionMessage('"$name" masaüstüne taşınamadı: ${r.error}', warn: true); return; } final i = _indexOf(id); if (i >= 0) { _instances[i] = _instances[i].copyWith(desktopName: name); notifyListeners(); } _setSessionMessage('🖥️ ${inst.title} → "$name" masaüstüne taşındı'); unawaited(refreshDesktops()); } /// 🖥 Rozete tıklayınca: CATIA'yı 0-bazlı [index]'teki masaüstüne taşır ve /// (varsayılan) Windows'u o masaüstüne geçirir. İndeks tabanlı: isimsiz /// masaüstlerinde de çalışır. [label] yalnızca mesajlar içindir. /// Hedef zaten CATIA'nın bulunduğu masaüstüyse taşıma no-op olur, geçiş yine yapılır. Future moveInstanceToDesktopIndex(String id, int index, String label, {bool switchTo = true}) async { final inst = _realInstanceOrWarn(id); if (inst == null || !_requirePowerShell('Masaüstüne taşıma')) return; _setSessionMessage('⏳ ${inst.title}: pencere aranıyor…'); final w = await _findRealWindow(inst); if (w == null) { _setSessionMessage('${inst.title}: $_noWindowMsg', warn: true); return; } _setSessionMessage('⏳ ${inst.title}: "$label" masaüstüne taşınıyor…'); await _logWindow('TASI-IDX-ONCE', inst, w, 'hedef=#${index + 1}/"$label"'); final r = await VirtualDesktopService.moveWindowToDesktopIndex( w.hwnd, index, switchTo: switchTo); _dlog('TASI-IDX ${inst.id} hwnd=0x${w.hwnd.toRadixString(16)} ' 'hedef=#${index + 1}/"$label" switch=$switchTo ok=${r.ok} err=${r.error}'); if (!r.ok) { _setSessionMessage('"$label" masaüstüne taşınamadı: ${r.error}', warn: true); return; } // Rozet 4-5 sn'lik yoklamayı beklemeden güncellensin. final i = _indexOf(id); if (i >= 0) { _instances[i] = _instances[i].copyWith(desktopNumber: index + 1); notifyListeners(); } _psReadNextAt = DateTime.fromMillisecondsSinceEpoch(0); _setSessionMessage( '🖥️ ${inst.title} → "$label" masaüstüne taşındı${switchTo ? ' ve geçildi' : ''}'); unawaited(refreshDesktops()); } /// 🖥️➡️ (native motor) Bir CATIA'yı GUID'i bilinen bir masaüstüne taşır. Future moveInstanceToKnownDesktop( String id, String guid, String label) async { final inst = _realInstanceOrWarn(id); if (inst == null || guid.isEmpty) return; _setSessionMessage('⏳ ${inst.title}: pencere aranıyor…'); final w = await _findRealWindow(inst); if (w == null) { _setSessionMessage('${inst.title}: $_noWindowMsg', warn: true); return; } _setSessionMessage('⏳ ${inst.title}: "$label" masaüstüne taşınıyor…'); await _logWindow('TASI-ONCE(native)', inst, w, 'hedef="$label" guid=$guid'); final hr = await NativeBridge.moveWindowToDesktop(w.pid, guid, hwnd: w.hwnd); _dlog('TASI-NATIVE ${inst.id} hwnd=0x${w.hwnd.toRadixString(16)} hr=$hr'); await _logWindow('TASI-SONRA(native)', inst, w, 'hedef="$label"'); if (hr == 0) { _setSessionMessage('🖥️ ${inst.title} → "$label" masaüstüne taşındı'); unawaited(refreshDesktops()); } else { _setSessionMessage( '"$label" masaüstüne taşınamadı (${hr == null ? 'yanıt yok' : '0x${(hr & 0xFFFFFFFF).toRadixString(16).toUpperCase()}'}).', warn: true); } } /// ✏️ CATIA'nın BULUNDUĞU masaüstünü [newName] olarak yeniden adlandırır /// (aynı masaüstündeki diğer CATIA'ların rozeti de bir sonraki taramada /// güncellenir). Aynı adda başka bir masaüstü varsa reddedilir. Future renameInstanceDesktop(String id, String newName) async { final name = newName.trim(); if (name.isEmpty) return; final inst = _realInstanceOrWarn(id); if (inst == null || !_requirePowerShell('Masaüstü adı değiştirme')) return; _setSessionMessage('⏳ ${inst.title}: pencere aranıyor…'); final w = await _findRealWindow(inst); if (w == null) { _setSessionMessage('${inst.title}: $_noWindowMsg', warn: true); return; } _setSessionMessage('⏳ ${inst.title}: masaüstü "$name" olarak adlandırılıyor…'); final r = await VirtualDesktopService.renameDesktopOfWindow(w.hwnd, name); if (!r.ok) { _setSessionMessage('Masaüstü adı değiştirilemedi: ${r.error}', warn: true); return; } _setSessionMessage('✏️ Masaüstü "$name" olarak adlandırıldı'); await refreshDesktops(); } /// 🗑 [d] masaüstünü siler (yalnızca PowerShell motoru). Üzerindeki /// pencereler (CATIA'lar dahil) Windows tarafından soldaki masaüstüne /// taşınır; rozetler bir sonraki taramada güncellenir. Future removeDesktop(DesktopInfo d) async { if (!_requirePowerShell('Masaüstü silme')) return; if (d.index < 0) return; _setSessionMessage('⏳ "${d.name}" masaüstü siliniyor…'); final r = await VirtualDesktopService.removeDesktopByIndex(d.index, d.name); if (!r.ok) { _setSessionMessage('Masaüstü silinemedi: ${r.error}', warn: true); return; } _setSessionMessage('🗑 "${d.name}" masaüstü silindi'); await refreshDesktops(); } /// ↪ Windows'u [desktopName] adlı masaüstüne geçirir (boşsa CATIA'nın /// kendi masaüstüne geçer). Future switchDesktop(String id, String desktopName) async { final inst = _realInstanceOrWarn(id); if (inst == null || !_requirePowerShell('Masaüstüne geçiş')) return; final name = desktopName.trim(); VdResult r; if (name.isEmpty) { final w = await _findRealWindow(inst); if (w == null) { _setSessionMessage('${inst.title}: $_noWindowMsg', warn: true); return; } r = await VirtualDesktopService.switchToDesktopOfWindow(w.hwnd); } else { r = await VirtualDesktopService.switchToNamedDesktop(name); } if (!r.ok) { _setSessionMessage('Masaüstüne geçilemedi: ${r.error}', warn: true); } } // ---- Gruplu görünüm: masaüstü yönetimi (başlık aksiyonları) ---- /// Aksiyondan hemen önce: listeyi tazeleyip [g]'nin hâlâ aynı sırada ve /// aynı GUID'de olduğunu doğrular (liste bu arada kaymış olabilir — yanlış /// masaüstünü adlandırma/silme riski). Future _desktopStillThere(DesktopGroup g) async { if (g.isUnknown) return false; await refreshDesktopList(force: true); final cur = _desktopList.where((d) => d.index == g.index).firstOrNull; if (cur == null) return false; return g.guid.isEmpty || cur.guid.isEmpty || cur.guid == g.guid; } /// Bir masaüstü aksiyonundan sonra listeyi/rozetleri hemen tazeler. Future _afterDesktopChange() async { _psReadNextAt = DateTime.fromMillisecondsSinceEpoch(0); await refreshDesktopList(force: true); unawaited(refreshDesktops()); } static const _desktopListChanged = 'Masaüstü listesi değişmiş — liste yenilendi, tekrar deneyin.'; /// ✏️ Başlığa tıklayınca: [g] masaüstünü [newName] olarak yeniden adlandırır. Future renameDesktopAt(DesktopGroup g, String newName) async { if (g.isUnknown) return; final name = newName.trim(); if (name == g.name.trim()) return; if (name.isEmpty) { _setSessionMessage( 'Masaüstü adı boş olamaz (varsayılana dönmek için Görev ' 'Görünümü\'nden adı silin).', warn: true); return; } if (!_requirePowerShell('Masaüstü adı değiştirme')) return; if (!await _desktopStillThere(g)) { _setSessionMessage(_desktopListChanged, warn: true); return; } final r = await VirtualDesktopService.renameDesktopByIndex(g.index, name); if (!r.ok) { _setSessionMessage('Masaüstü adı değiştirilemedi: ${r.error}', warn: true); return; } // Başlık, registry okuması yetişmeden eski adına dönmesin. _desktopList = [ for (final d in _desktopList) d.index == g.index ? DesktopInfo( index: d.index, name: name, visible: d.visible, guid: d.guid) : d, ]; _setSessionMessage('✏️ ${g.label} masaüstü "$name" olarak adlandırıldı'); await _afterDesktopChange(); } /// + Yeni masaüstü oluşturur (boş ad → Windows varsayılan adı). Geçiş yapmaz. Future createDesktop(String name) async { if (!_requirePowerShell('Yeni masaüstü oluşturma')) return; final n = name.trim(); _setSessionMessage('⏳ Yeni masaüstü oluşturuluyor…'); final r = await VirtualDesktopService.createDesktop(n); if (!r.ok) { _setSessionMessage('Masaüstü oluşturulamadı: ${r.error}', warn: true); return; } final idx = int.tryParse(r.data ?? ''); if (idx != null && !_desktopList.any((d) => d.index == idx)) { _desktopList = [..._desktopList, DesktopInfo(index: idx, name: n)]; } _setSessionMessage( '🖥️ ${n.isEmpty ? 'Yeni masaüstü' : '"$n"'} oluşturuldu' '${idx == null ? '' : ' (M${idx + 1})'}'); await _afterDesktopChange(); } /// 🗑 [g] masaüstünü siler; üzerindeki pencereler (CATIA'lar dahil) Windows /// tarafından soldaki masaüstüne taşınır, kapanmaz. Future removeDesktopAt(DesktopGroup g) async { if (g.isUnknown) return; if (!_requirePowerShell('Masaüstü silme')) return; if (!await _desktopStillThere(g)) { _setSessionMessage(_desktopListChanged, warn: true); return; } final r = await VirtualDesktopService.removeDesktopAtIndex(g.index); if (!r.ok) { _setSessionMessage('Masaüstü silinemedi: ${r.error}', warn: true); return; } _setSessionMessage('🗑 ${g.label} masaüstü silindi'); await _afterDesktopChange(); } /// ↪ Windows'u [g] masaüstüne geçirir. Future switchToDesktopAt(DesktopGroup g) async { if (g.isUnknown) return; if (!_requirePowerShell('Masaüstüne geçiş')) return; if (!await _desktopStillThere(g)) { _setSessionMessage(_desktopListChanged, warn: true); return; } final r = await VirtualDesktopService.switchToDesktopIndex(g.index); if (!r.ok) { _setSessionMessage('Masaüstüne geçilemedi: ${r.error}', warn: true); return; } unawaited(refreshDesktopList(force: true)); } // ---- Oturum kaydet / geri yükle (tüm bağlı CATIA'lar) ---- // // 💾 Kaydet: bağlı (gerçek) tüm CATIA'ların açık dökümanlarını, hangi // CATIA'da ve hangi sanal masaüstünde olduklarıyla birlikte // `%LOCALAPPDATA%\FikoCatia\session.json`'a yazar. Dökümanların KENDİSİNİ // kaydetmez (Document.Save çağırmaz) — kaydedilmemiş değişiklik varsa // yalnızca uyarır; yanlışlıkla üzerine yazma riski olmasın. // 📂 Geri yükle: her kayıtlı CATIA için (aynı masaüstündeki ya da aynı // dökümanlara sahip) çalışan bir CATIA bulur, yoksa ayarlardaki // başlatıcıyla yenisini açar; macro kaydolunca dökümanları native worker // thread'de Documents.Open ile açar. SessionSettings _settings = const SessionSettings(); SavedSession? _savedSessionValue; /// "Son oturum" kaydı. Atandığında (diskten yükleme, oto-kayıt, 💾) o kayda /// ait tanıma durumu sıfırlanır ve "bağlı değil" yer tutucu kartlar yeniden /// hesaplanır (bildirim çağıranın işidir). Kayıt id'leri ("CATIA-02") her /// app run'ında yeniden dağıtıldığı için eski kayda ait `_claimedSavedIds` /// yeni kayıtta yanlış kayda işaret ederdi. SavedSession? get _savedSession => _savedSessionValue; set _savedSession(SavedSession? s) { _savedSessionValue = s; _claimedSavedIds.clear(); _rebuildGhosts(); } bool _restoreOfferDismissed = false; bool _sessionBusy = false; String? _sessionMessage; bool _sessionMessageIsWarning = false; SessionSettings get sessionSettings => _settings; SavedSession? get savedSession => _savedSession; bool get sessionBusy => _sessionBusy; String? get sessionMessage => _sessionMessage; bool get sessionMessageIsWarning => _sessionMessageIsWarning; // ---- Birim testi kancaları (uygulama akışında kullanılmaz) ---- /// Ayarları diske yazmadan/native'e bildirmeden uygular. @visibleForTesting void debugSetSettings(SessionSettings s) => _settings = s; /// 🧰 Palet ayarını diske/native'e dokunmadan uygular (test). @visibleForTesting void debugSetPalette(PaletteSettings s) => _palette = s; /// Testte listeyi doğrudan kurar (native push/snapshot beklemeden) — /// sıralama ve arama davranışını sınamak için. @visibleForTesting void debugSetInstances(List list) { _instances = list; _mockActive = false; _rebuildGhosts(); notifyListeners(); } /// Testte kayıtlı "son oturum"u doğrudan verir (diske yazmadan/okumadan); /// "bağlı değil" yer tutucu kartlar yeniden hesaplanır. @visibleForTesting void debugSetSavedSession(SavedSession? s) { _savedSession = s; notifyListeners(); } /// Testte süreç taramasının sonucunu doğrudan verir (native çağrısı /// yapmadan). [firstSeen] verilmezse her süreç "yeterince önce" görülmüş /// sayılır (bekleme süresi dolmuş). @visibleForTesting void debugSetScanned(List procs, {Map? firstSeen}) { _scanned = procs; _procFirstSeen ..clear() ..addAll(firstSeen ?? { for (final p in procs) p.pid: DateTime.now().subtract(const Duration(minutes: 5)), }); _rebuildGhosts(); notifyListeners(); } /// Testte oto-kayıtın taşıyacağı kayıtlı-ama-bağlı-olmayan kayıtları /// (diske yazmadan) [s]'e ekleyip döndürür. @visibleForTesting SavedSession debugWithGhostRecords(SavedSession s) => _withGhostRecords(s); /// Kart başlığı kuralını (CATIA'nın kendi başlığı → gerekiyorsa otomatik ad) /// tek bir örnek üzerinde çalıştırır; ağ/native/masaüstü çağrısı yapmaz. @visibleForTesting CatiaInstance debugApplyTitleRule({ required String id, required String caption, int pid = 4242, String fileName = 'TEST.CATPart', }) => _applyInitialTitle(CatiaInstance( id: id, fileName: fileName, status: CatiaStatus.idle, pid: pid, worker: 'Worker-01', connected: true, caption: caption, )); /// Native COM bridge'e gerçekten bağlı (mock olmayan) CATIA'lar. MockData /// örnek kartları (pid'leri gerçek görünümlü) HİÇBİR ZAMAN sayılmaz — /// aksi halde oto-kayıt/💾 sahte kartları oturum dosyasına yazıp sonraki /// açılışta "kayıtlı" hayalet olarak geri getirirdi. List get _liveInstances => _mockActive ? const [] : _instances.where((i) => i.pid > 0 && i.connected).toList(); /// Bilgisayar yeni açıldığında (hiç bağlı CATIA yokken) kayıtlı oturum /// varsa üstte "geri yükle?" bandı gösterilir. bool get showRestoreOffer => _savedSession != null && !_restoreOfferDismissed && !_sessionBusy && _liveInstances.isEmpty; void dismissRestoreOffer() { _restoreOfferDismissed = true; _safeNotify(); } /// Dışarıdan (ör. sürükle-bırak) oturum bandına uyarı yazar. void notifyWarning(String msg) => _setSessionMessage(msg, warn: true); /// Aynı CATIA'ya aynı anda iki "dosya bırakma" işi başlamasın. final Set _dropOpenBusy = {}; /// Bir CATIA kartının üzerine bırakılan dosyaları O CATIA'da açar /// (`Application.Documents.Open`, izole native worker'da). Seçici/masaüstü /// hedefi yoktur: hedef, dosyanın bırakıldığı kartın CATIA'sıdır. /// CATIA dosyası olmayanlar (klasör, txt…) atlanır ve özette belirtilir; /// zaten açık olanlar atlanır; STEP/IGES gibi biçimlerin açılıp açılmayacağına /// CATIA karar verir (hata metni banda düşer). Future openFilesInInstance( String instanceId, List paths) async { final i = _indexOf(instanceId); if (i < 0) return; final inst = _instances[i]; final label = inst.title; if (inst.pid <= 0 || !inst.connected) { _setSessionMessage('$label: CATIA bağlı değil — dosya açılamaz', warn: true); return; } if (_sessionBusy) { _setSessionMessage('Bir oturum işlemi sürüyor, bitmesini bekleyin.', warn: true); return; } if (_dropOpenBusy.contains(instanceId)) { _setSessionMessage('$label: önceki dosya açma işlemi sürüyor.', warn: true); return; } final parts = partitionCatiaFiles(paths); final skipped = parts.rejected.isEmpty ? '' : '${parts.rejected.length} dosya CATIA dosyası değil, atlandı ' '(${parts.rejected.take(2).map(fileNameOf).join(', ')}' '${parts.rejected.length > 2 ? '…' : ''})'; if (parts.accepted.isEmpty) { _setSessionMessage( '$label: açılabilir CATIA dosyası yok' '${skipped.isEmpty ? '' : ' — $skipped'}', warn: true); return; } _dropOpenBusy.add(instanceId); final tally = _RestoreTally(); try { _setSessionMessage( '⏳ $label: ${parts.accepted.length} dosya açtırılıyor…'); final jobId = await NativeBridge.openDocuments( instanceId, parts.accepted, activate: fileNameOf(parts.accepted.first), ); if (jobId <= 0) { _setSessionMessage( '⚠ $label: dosya açma başlatılamadı (kod $jobId)', warn: true); return; } await _awaitOpenJob(jobId, label, tally); } catch (e) { tally.errors.add('$label: beklenmeyen hata: $e'); } finally { _dropOpenBusy.remove(instanceId); } final done = [ if (tally.opened > 0) '${tally.opened} dosya açıldı', if (tally.alreadyOpen > 0) '${tally.alreadyOpen} zaten açıktı', ]; final problems = [ if (tally.failed > 0) '${tally.failed} dosya açılamadı', ...tally.errors.take(3), if (skipped.isNotEmpty) skipped, ]; final ok = problems.isEmpty; _setSessionMessage( '${ok ? '✅' : '⚠'} $label: ' '${done.isEmpty ? 'dosya açılmadı' : done.join(' · ')}' '${ok ? '' : '\n${problems.join('\n')}'}', warn: !ok, ); // Dosya açıldıysa CATIA çerçevesini de öne al (başka masaüstünde/küçültülmüş // olabilir) — 🎯 "Aktif et" ile aynı yol. if (!_disposed && (tally.opened > 0 || tally.alreadyOpen > 0)) { unawaited(bringInstanceToFront(instanceId)); } } void dismissSessionMessage() { _sessionMessage = null; _safeNotify(); } void _setSessionMessage(String? msg, {bool warn = false}) { _sessionMessage = msg; _sessionMessageIsWarning = warn; _safeNotify(); } Future _initSession() async { _settings = await SessionService.loadSettings(); _applyTheme(_settings.themeMode); // Kayıtlı izleme aralığını + canlılık eşiklerini native poller'a uygula. unawaited(NativeBridge.setPollInterval(_settings.pollIntervalMs)); unawaited(NativeBridge.setComFailLimit(_settings.comFailLimit)); unawaited(NativeBridge.setDocsRefreshMs(_settings.docsRefreshMs)); _savedSession = await SessionService.loadSession(); _macros = await MacroLibraryService.load(); // 🧰 Palet ayarları AYRI dosyadan (`palette.json`; gerekçe: macros.json // gibi bayat bir ayar penceresinin makro listesini ezmemesi). _palette = await PaletteService.loadSettings(); // Tray menüsündeki 🧰 modu etiketini kayıtlı ayara göre kur. WindowTrayService.paletteWhenCatia = _palette.showMode == PaletteShowMode.whenCatia; unawaited(WindowTrayService.syncMenu()); unawaited(PaletteService.applySettings(_palette, persist: false)); _scheduleAutoSave(); unawaited(_scanCatiaProcesses()); // ilk süreç taraması (ayarlar yüklendi) _safeNotify(); if (selfTestSession) unawaited(_selfTestLoop()); if (_savedSession != null && _settings.autoRestore) { // Zaten çalışan CATIA'ların (macro'ları) ilk push'larının gelmesi için // kısa bir süre bekle; sonra eşleşmeyenler için CATIA başlatılır. await Future.delayed(const Duration(seconds: 6)); if (!_disposed && !_sessionBusy) unawaited(restoreSession(auto: true)); } } /// Teşhis bayrağı (`fikocatia.exe --selftest-session`): UI'a dokunmadan /// oturum kaydet/geri yüklemeyi dosya komutlarıyla sınamaya yarar /// (`%LOCALAPPDATA%\FikoCatia\selftest\cmd_save|cmd_restore` dosyası /// oluşturulunca ilgili işlem çalışır, sonuç `result_*.txt`'e yazılır). /// Bayrak verilmedikçe hiçbir şey çalışmaz. static bool selfTestSession = false; Future _selfTestLoop() async { final base = Platform.environment['LOCALAPPDATA'] ?? Directory.systemTemp.path; final sep = Platform.pathSeparator; final dir = Directory('$base${sep}FikoCatia${sep}selftest') ..createSync(recursive: true); debugPrint('SELFTEST: döngü başladı dir=${dir.path}'); while (!_disposed) { for (final cmd in const ['save', 'restore']) { final f = File('${dir.path}${sep}cmd_$cmd'); if (!f.existsSync()) continue; try { f.deleteSync(); } catch (_) {} debugPrint('SELFTEST: $cmd başlıyor'); if (cmd == 'save') { await saveSession(); } else { await restoreSession(); } File('${dir.path}${sep}result_$cmd.txt').writeAsStringSync( '${_sessionMessageIsWarning ? 'WARN' : 'OK'}\n' '${_sessionMessage ?? ''}\n'); debugPrint('SELFTEST: $cmd bitti: $_sessionMessage'); } await Future.delayed(const Duration(milliseconds: 500)); } } /// ⚙ Merkezi ayarlar penceresini (ayrı pencere) açar. Kaydet → /// [updateSessionSettings] (diske yazar + native tarafa uygular). Future openSettingsWindow() async { try { await SettingsWindowService.open( current: _settings, onSave: updateSessionSettings, ); } catch (e) { _setSessionMessage('Ayarlar penceresi açılamadı: $e', warn: true); } } Future updateSessionSettings(SessionSettings s) async { final old = _settings; final intervalChanged = s.pollIntervalMs != _settings.pollIntervalMs; final failLimitChanged = s.comFailLimit != _settings.comFailLimit; final docsRefreshChanged = s.docsRefreshMs != _settings.docsRefreshMs; final autoSaveChanged = s.autoSaveIntervalMs != _settings.autoSaveIntervalMs; // Başlık kuralı değişti mi? (Değiştiyse bağlı CATIA'ların kart // başlıklarını hemen yeni kurala göre tazeleriz.) final titleChanged = s.autoTitleDefault != old.autoTitleDefault || s.autoTitlePrefix != old.autoTitlePrefix || s.defaultCaption != old.defaultCaption; _settings = s; if (s.themeMode != old.themeMode) _applyTheme(s.themeMode); _safeNotify(); if (autoSaveChanged) _scheduleAutoSave(); if (intervalChanged) { unawaited(NativeBridge.setPollInterval(s.pollIntervalMs)); } if (failLimitChanged) { unawaited(NativeBridge.setComFailLimit(s.comFailLimit)); } if (docsRefreshChanged) { unawaited(NativeBridge.setDocsRefreshMs(s.docsRefreshMs)); } if (titleChanged) _reapplyTitles(old); try { await SessionService.saveSettings(s); } catch (e) { _setSessionMessage('Ayarlar kaydedilemedi: $e', warn: true); } } /// GUID → "M1/M2…" numarası (desktopLabel ile aynı sıralama). int _desktopOrderOf(String guid) => guid.isEmpty ? 0 : _desktopIndexes.putIfAbsent(guid, () => _desktopIndexes.length + 1); /// Bağlı (gerçek) tüm CATIA'ların anlık oturum görüntüsünü üretir; hiçbir /// şey yazmaz. `notes`: kullanıcıya gösterilecek uyarılar (kaydedilmemiş /// değişiklik, yolu olmayan yeni dökümanlar). /// /// [onlyIds] verilirse yalnızca o CATIA'lar alınır (tek masaüstü kaydı); /// null = tümü. Tümü kaydedilirken (scope `all`) kayıtlı ADLI ama boş /// masaüstleri de `desktops[]`'a `instances: []` ile yazılır (düzeni geri /// kurmak için). Future<({SavedSession session, List notes})> _snapshotLiveSession( {Set? onlyIds}) async { await refreshDesktops(); // masaüstü bilgisi en güncel haliyle alınsın if (onlyIds == null) await refreshDesktopList(); // boş adlı masaüstleri için var skippedNoPath = 0; var unsaved = 0; final saved = []; for (final i in _liveInstances) { if (onlyIds != null && !onlyIds.contains(i.id)) continue; final docs = []; for (final d in i.documents) { if (d.path.isEmpty) { // Henüz diske hiç kaydedilmemiş (yeni) döküman — yeniden açılamaz. skippedNoPath++; continue; } if (!d.saved) unsaved++; docs.add(SavedDocument( name: d.name, path: d.path, saved: d.saved, isActive: d.isActive, )); } saved.add(SavedInstance( id: i.id, title: i.title, desktopGuid: i.desktopGuid, // Gerçek numara varsa onu yaz (GUID okunamayan makinelerde de doğru). desktopOrder: i.desktopNumber ?? _desktopOrderOf(i.desktopGuid), desktopName: i.desktopName, desktopNumber: i.desktopNumber, documents: docs, )); } saved.sort((a, b) { // Masaüstü sırasına göre (M1, M2, …; bilinmeyenler sona), sonra id. int key(SavedInstance s) => s.desktopOrder == 0 ? 1 << 30 : s.desktopOrder; final c = key(a).compareTo(key(b)); return c != 0 ? c : a.id.compareTo(b.id); }); final notes = []; if (unsaved > 0) { notes.add('⚠ $unsaved dökümanda kaydedilmemiş değişiklik var — dosyaların ' 'kendisi kaydedilmedi'); } if (skippedNoPath > 0) { notes.add('$skippedNoPath yeni (diske hiç kaydedilmemiş) döküman atlandı'); } // v2 masaüstü bölümleri: CATIA'lardan türet; tümü kaydediliyorsa kayıtlı // ADLI boş masaüstlerini de (instances: []) ekle. var desktops = deriveDesktops(saved); if (onlyIds == null) { final extra = []; for (final d in _desktopList) { final nm = d.name.trim(); if (nm.isEmpty || d.index < 0) continue; final n = d.index + 1; final known = desktops.any((x) => x.number == n || (x.hasName && x.name.toLowerCase() == nm.toLowerCase())); if (known) continue; extra.add(SavedDesktop(key: 'x$n', name: nm, number: n)); } if (extra.isNotEmpty) { final all = [...desktops, ...extra]..sort((a, b) => (a.number ?? (1 << 30)).compareTo(b.number ?? (1 << 30))); desktops = [ for (var k = 0; k < all.length; k++) SavedDesktop( key: 'd$k', name: all[k].name, number: all[k].number, instances: all[k].instances, ), ]; } } return ( session: SavedSession( savedAt: DateTime.now(), instances: saved, scope: onlyIds == null ? SavedSession.scopeAll : SavedSession.scopeDesktop, desktops: desktops, ), notes: notes, ); } /// "Son oturum" kopyası (`%LOCALAPPDATA%\FikoCatia\session.json`): açılışta /// "kayıtlı oturum var, geri yükle?" teklifi ve otomatik geri yükleme bunu /// kullanır. Kullanıcının seçtiği dosyadan bağımsızdır. /// /// Yalnızca `scope: all` kayıtlar güncellenir: tek masaüstü kaydı, tüm /// düzeni tutan "son oturum" kopyasını EZMEZ. Future _rememberLastSession(SavedSession session) async { if (session.isDesktopScope) return; try { await SessionService.saveSession(session); _savedSession = session; _restoreOfferDismissed = true; } catch (e) { debugPrint('son oturum kopyası yazılamadı: $e'); } } static String _fileNameOf(String path) => path.split(RegExp(r'[\\/]')).last; /// [_settings.autoSaveIntervalMs] değişince (ya da açılışta) çağrılır: /// eski zamanlayıcıyı iptal edip, süre > 0 ise yenisini kurar. Süre 0 ise /// (kapalı) yalnızca iptal eder. void _scheduleAutoSave() { _autoSaveTimer?.cancel(); _autoSaveTimer = null; final ms = _settings.autoSaveIntervalMs; if (ms <= 0) return; _autoSaveTimer = Timer.periodic( Duration(milliseconds: ms), (_) => unawaited(_autoSaveTick()), ); } /// Ana toolbardaki 💾'nın (bkz. [saveSessionToFile]) dosya diyaloğu /// AÇMAYAN, sessiz eşdeğeri — [_scheduleAutoSave] tarafından periyodik /// çağrılır. Bağlı CATIA yoksa, kaydedilebilir döküman yoksa ya da başka /// bir oturum işlemi (kaydet/geri yükle) sürüyorsa o tur SESSİZCE atlanır /// (kullanıcıyı meşgul etmeden arka planda çalışması gereken bir özellik). Future _autoSaveTick() async { if (_disposed || _sessionBusy) return; if (_liveInstances.isEmpty) return; final (:session, :notes) = await _snapshotLiveSession(); if (session.documentCount == 0) return; // Henüz bağlanmamış (👻) kayıtlar yeni "son oturum"a taşınır; aksi halde // bir CATIA bağlanıp oto-kayıt tetiklenince diğerleri dosyadan silinirdi. final live = session.instances.length; final merged = _withGhostRecords(session); try { await SessionService.saveSession(merged); _savedSession = merged; _safeNotify(); _dlog('OTO-KAYIT $live CATIA · ' '${session.documentCount} döküman' '${merged.instances.length > live ? ' + ${merged.instances.length - live} bagli-olmayan kayit korundu' : ''}' '${notes.isEmpty ? '' : ' (${notes.join(' / ')})'}'); } catch (e) { debugPrint('oto-kayıt yazılamadı: $e'); } } /// 💾 (teşhis / eski akış) Oturumu sabit konuma (`session.json`) kaydeder. /// Arayüzdeki 💾 artık [saveSessionToFile] kullanıyor. Future saveSession() async { if (_sessionBusy) return; if (_liveInstances.isEmpty) { _setSessionMessage('Bağlı CATIA yok — kaydedilecek oturum bulunamadı.', warn: true); return; } final (:session, :notes) = await _snapshotLiveSession(); if (session.documentCount == 0) { _setSessionMessage( 'Bağlı CATIA\'larda kaydedilebilir (diskte yolu olan) döküman yok — ' 'mevcut kayıt korundu.', warn: true); return; } try { await SessionService.saveSession(session); } catch (e) { _setSessionMessage('Oturum kaydedilemedi: $e', warn: true); return; } _savedSession = session; _restoreOfferDismissed = true; _setSessionMessage( '💾 ${session.instances.length} CATIA · ${session.desktopCount} masaüstü · ' '${session.documentCount} döküman kaydedildi' '${notes.isEmpty ? '' : '\n${notes.join('\n')}'}', warn: notes.isNotEmpty, ); } /// 💾 (gruplu görünüm, masaüstü başlığı) Yalnızca [g] masaüstündeki bağlı /// CATIA'ların oturumunu kullanıcının seçeceği bir dosyaya kaydeder /// (`scope: desktop`, tek `desktops[]` elemanı). Tüm düzeni tutan "son /// oturum" kopyasına DOKUNMAZ. Future saveDesktopToFile(DesktopGroup g) async { if (_sessionBusy) return; if (g.isUnknown) { _setSessionMessage( 'Bilinmeyen masaüstü tek başına kaydedilemez — 💾 (tümü) kullanın.', warn: true); return; } final ids = { for (final i in g.instances) if (i.pid > 0 && i.connected) i.id, }; if (ids.isEmpty) { _setSessionMessage( '${g.label}: bu masaüstünde bağlı CATIA yok — kaydedilecek oturum ' 'bulunamadı.', warn: true); return; } final (:session, :notes) = await _snapshotLiveSession(onlyIds: ids); if (session.documentCount == 0) { _setSessionMessage( '${g.label}: CATIA\'larda kaydedilebilir (diskte yolu olan) döküman ' 'yok.', warn: true); return; } // Masaüstünün adı/numarası GRUBUN bildiğiyle yazılır (CATIA'nın önbelleği // az önce yapılan bir adlandırmadan bayat kalmış olabilir). final deskName = g.hasName ? g.name.trim() : ''; final instances = [ for (final i in session.instances) SavedInstance( id: i.id, title: i.title, desktopGuid: g.guid.isNotEmpty ? g.guid : i.desktopGuid, desktopOrder: g.number ?? i.desktopOrder, desktopName: deskName.isEmpty ? null : deskName, desktopNumber: g.number, documents: i.documents, ), ]; final single = SavedSession( savedAt: session.savedAt, instances: instances, scope: SavedSession.scopeDesktop, desktops: [ SavedDesktop( key: 'd0', name: deskName, number: g.number, instances: [for (var k = 0; k < instances.length; k++) k], ), ], ); WindowTrayService.beginModal(); String? path; try { path = await NativeBridge.pickSaveFile( title: 'Masaüstünü oturum olarak kaydet', defaultName: SessionService.desktopFileName( deskName.isEmpty ? g.label : deskName), filterName: 'FikoCATIA oturumu (*.${SessionService.fileExtension})', extension: SessionService.fileExtension, allFilesLabel: 'Tüm dosyalar (*.*)', ); } catch (e) { _setSessionMessage('$e', warn: true); return; } finally { await WindowTrayService.endModal(); } if (path == null) return; // kullanıcı vazgeçti try { await SessionService.saveToFile(single, path); } catch (e) { _setSessionMessage('Oturum dosyası yazılamadı: $e', warn: true); return; } // NOT: _rememberLastSession ÇAĞRILMAZ (tek masaüstü son oturumu ezmesin). _setSessionMessage( '💾 ${_fileNameOf(path)} kaydedildi\n' '${g.label}${deskName.isEmpty ? '' : ' · $deskName'} · ' '${single.instances.length} CATIA · ${single.documentCount} döküman' '${notes.isEmpty ? '' : '\n${notes.join('\n')}'}', warn: notes.isNotEmpty, ); } /// 💾 Tüm bağlı CATIA'ların oturumunu, kullanıcının seçeceği bir dosyaya /// kaydeder (sistem "Farklı kaydet" diyaloğu açılır). Future saveSessionToFile() async { if (_sessionBusy) return; if (_liveInstances.isEmpty) { _setSessionMessage('Bağlı CATIA yok — kaydedilecek oturum bulunamadı.', warn: true); return; } final (:session, :notes) = await _snapshotLiveSession(); if (session.documentCount == 0) { _setSessionMessage( 'Bağlı CATIA\'larda kaydedilebilir (diskte yolu olan) döküman yok.', warn: true); return; } // Diyalog açıkken popup odak kaybında kapanmasın. WindowTrayService.beginModal(); String? path; try { path = await NativeBridge.pickSaveFile( title: 'Oturumu kaydet', defaultName: SessionService.defaultFileName(), filterName: 'FikoCATIA oturumu (*.${SessionService.fileExtension})', extension: SessionService.fileExtension, allFilesLabel: 'Tüm dosyalar (*.*)', ); } catch (e) { _setSessionMessage('$e', warn: true); return; } finally { await WindowTrayService.endModal(); } if (path == null) return; // kullanıcı vazgeçti try { await SessionService.saveToFile(session, path); } catch (e) { _setSessionMessage('Oturum dosyası yazılamadı: $e', warn: true); return; } await _rememberLastSession(session); _setSessionMessage( '💾 ${_fileNameOf(path)} kaydedildi\n' '${session.instances.length} CATIA · ${session.desktopCount} masaüstü · ' '${session.documentCount} döküman' '${notes.isEmpty ? '' : '\n${notes.join('\n')}'}', warn: notes.isNotEmpty, ); } /// 📂 Oturum dosyası seç → ortada seçim penceresi aç → kullanıcının verdiği /// plana göre (yeni CATIA / açık bir CATIA) dökümanları aç. Future openSessionFromFile() async { if (_sessionBusy) return; WindowTrayService.beginModal(); String? path; try { path = await NativeBridge.pickOpenFile( title: 'Oturum dosyası aç', filterName: 'FikoCATIA oturumu (*.${SessionService.fileExtension})', extension: SessionService.fileExtension, allFilesLabel: 'Tüm dosyalar (*.*)', ); } catch (e) { _setSessionMessage('$e', warn: true); return; } finally { await WindowTrayService.endModal(); } if (path == null) return; // kullanıcı vazgeçti await openSessionFromPath(path); } /// 📂 Verilen oturum dosyasını yükler → "nereye açılsın?" penceresi → geri /// yükleme. Dosya seçiciden de, sürükle-bırakla da bu yol kullanılır. Future openSessionFromPath(String path) async { if (_sessionBusy) { _setSessionMessage('Başka bir oturum işlemi sürüyor, bitmesini bekleyin.', warn: true); return; } final SavedSession session; try { session = await SessionService.loadFromFile(path); } on FormatException catch (e) { _setSessionMessage('${_fileNameOf(path)}: ${e.message}', warn: true); return; } catch (e) { _setSessionMessage('${_fileNameOf(path)} okunamadı: $e', warn: true); return; } if (session.documentCount == 0) { _setSessionMessage( '${_fileNameOf(path)}: açılacak döküman bulunamadı.', warn: true); return; } await refreshDesktops(); // hedef listesinde güncel masaüstü etiketleri await refreshDesktopList(force: true); // hedef masaüstü seçenekleri final PickerResult? res; try { res = await SessionPickerService.pick(_pickerPayload(session, path)); } catch (e) { _setSessionMessage('Seçim penceresi açılamadı: $e', warn: true); return; } if (res == null || res.items.isEmpty) return; // vazgeçildi await restorePlan(session, res.items, desktops: res.desktops, switchAfter: res.switchAfter); } /// Seçim penceresine gönderilecek veri: oturumdaki CATIA grupları (önerilen /// hedefle) + şu an açık (gerçek) CATIA'lar. Map _pickerPayload(SavedSession s, String path) { final assigned = {}; final groups = >[]; final sections = s.effectiveDesktops; final deskOfInstance = { for (final d in sections) for (final k in d.instances) k: d.key, }; final canManage = canManageDesktops; for (var i = 0; i < s.instances.length; i++) { final g = s.instances[i]; // Aynı dökümanları / aynı masaüstünü taşıyan açık bir CATIA varsa // varsayılan hedef odur; yoksa yeni CATIA. final match = _matchLive(g, assigned); if (match != null) assigned.add(match.id); groups.add({ 'index': i, 'title': g.title, 'desktop': g.desktopLabel, 'suggested': match?.id ?? 'new', 'desktopKey': deskOfInstance[i] ?? '', 'docs': [ for (final d in g.documents) if (d.path.isNotEmpty) { 'name': d.name, 'fullPath': d.fullPath, 'isActive': d.isActive, 'saved': d.saved, }, ], }); } return { 'fileName': _fileNameOf(path), 'savedAt': s.savedAt.toIso8601String(), 'canLaunch': _settings.catiaExe.trim().isNotEmpty, 'canManage': canManage, 'desktops': [ for (final d in _desktopList) if (d.index >= 0) {'index': d.index, 'name': d.name.trim()}, ], 'savedDesktops': [ for (final d in sections) if (d.instances.isNotEmpty) { 'key': d.key, 'label': d.label, 'name': d.name.trim(), 'number': d.number, 'catiaCount': d.instances.length, 'suggested': suggestDesktopTarget( saved: d, desktops: _desktopList, canManage: canManage) .toJson(), }, // Hiçbir bölüme ait olmayan CATIA'lar (bozuk/eksik dosya). if (groups.any((g) => g['desktopKey'] == '')) { 'key': '', 'label': 'M?', 'name': '', 'number': null, 'catiaCount': groups.where((g) => g['desktopKey'] == '').length, 'suggested': DesktopTarget.keep.toJson(), }, ], 'groups': groups, 'targets': [ for (final t in _liveInstances) { 'id': t.id, 'title': t.title, 'desktop': desktopLabel(t.desktopGuid, desktopNumber: t.desktopNumber).replaceFirst('🖥 ', ''), 'docCount': t.documents.length, 'open': [ for (final d in t.documents) '${d.path}${d.name}'.toLowerCase(), ], }, ], }; } /// Seçim penceresinden gelen planı uygular: `new` hedefli her grup için yeni /// bir CATIA başlatılır (ayarlardaki başlatıcıyla), diğerleri seçilen açık /// CATIA'ya açtırılır. Aynı açık CATIA'yı hedefleyen gruplar tek işte /// birleştirilir. Future restorePlan(SavedSession session, List plan, {Map desktops = const {}, bool switchAfter = false}) async { if (_sessionBusy) { _setSessionMessage('Başka bir oturum işlemi sürüyor, bitmesini bekleyin.', warn: true); return; } // Aynı açık CATIA'yı hedefleyen grupları birleştir (tek worker işi). final entries = <_PlanEntry>[]; for (final item in plan) { if (item.group < 0 || item.group >= session.instances.length) continue; final saved = session.instances[item.group]; final byPath = { for (final d in saved.documents) d.fullPath.toLowerCase(): d, }; final docs = [ for (final p in item.paths) if (byPath[p.toLowerCase()] != null) byPath[p.toLowerCase()]!, ]; if (docs.isEmpty) continue; if (item.target != 'new') { final same = entries.where((e) => e.target == item.target).firstOrNull; if (same != null) { same.docs.addAll(docs); continue; } } entries.add(_PlanEntry(item.target, saved, docs, item.desktopKey)); } if (entries.isEmpty) return; _sessionBusy = true; _restoreOfferDismissed = true; // İlerleme bandı popup'ta görünüyor → popup'ı öne getir. unawaited(WindowTrayService.current?.showWindow()); final tally = _RestoreTally(); final jobs = >[]; final total = entries.length; // Masaüstü hedeflerini çöz: create → oluştur (aynı adlı varsa onu kullan), // existing → hâlâ var mı doğrula. null = çözülemedi (taşıma yapılmaz). final resolved = {}; int? switchIdx; try { if (desktops.values.any((d) => !d.isKeep)) { await refreshDesktopList(force: true); for (final key in {for (final e in entries) e.desktopKey}) { final t = desktops[key]; if (t == null || t.isKeep) continue; if (!_psEngine) { tally.errors.add( 'Masaüstü oluşturma/taşıma için PowerShell motoru gerekli'); resolved[key] = null; continue; } if (t.isCreate) { final existing = findDesktopByName(_desktopList, t.name); if (existing != null) { resolved[key] = existing; } else { _setSessionMessage('⏳ Yeni masaüstü oluşturuluyor…'); final r = await VirtualDesktopService.createDesktop(t.name); final idx = int.tryParse(r.data ?? ''); if (!r.ok || idx == null) { tally.errors.add( 'Masaüstü oluşturulamadı: ${r.error ?? 'sıra okunamadı'}'); resolved[key] = null; continue; } tally.desktopsCreated++; resolved[key] = idx; _desktopList = [ ..._desktopList, DesktopInfo(index: idx, name: t.name) ]; } } else { final idx = t.index ?? -1; if (idx < 0 || !_desktopList.any((d) => d.index == idx)) { tally.errors.add( 'Hedef masaüstü (M${idx + 1}) artık yok — liste değişmiş'); resolved[key] = null; continue; } resolved[key] = idx; } switchIdx ??= resolved[key]; } _dlog('PLAN-MASAUSTU cozuldu=$resolved gec=$switchAfter'); } var n = 0; for (final e in entries) { n++; final label = '${e.saved.title} (${e.saved.desktopLabel})'; final step = '[$n/$total] $label'; CatiaInstance? live; var launched = false; if (e.target == 'new') { final before = _liveInstances.map((i) => i.id).toSet(); _setSessionMessage('⏳ $step: yeni CATIA başlatılıyor…'); final err = await _launchCatia(); if (err != null) { tally.errors.add('$label: $err'); tally.failed += e.docs.length; debugPrint('session plan: $label: $err'); continue; } tally.launched++; launched = true; _setSessionMessage( '⏳ $step: CATIA açılıyor, macro kaydı bekleniyor…'); live = await _waitForNewInstance(before); if (live == null) { const msg = 'CATIA açıldı ama macro kayıt olmadı (zaman aşımı)'; tally.errors.add('$label: $msg'); tally.failed += e.docs.length; debugPrint('session plan: $label: $msg'); continue; } } else { live = _liveInstances.where((i) => i.id == e.target).firstOrNull; if (live == null) { final msg = '${e.target} artık bağlı değil'; tally.errors.add('$label: $msg'); tally.failed += e.docs.length; debugPrint('session plan: $label: $msg'); continue; } } // Yeni açılan CATIA'ya kayıtlı başlık + masaüstü uygulanır; kullanıcının // kendi seçtiği açık bir CATIA'nın adına/masaüstüne dokunulmaz. if (launched) { if (e.saved.title != e.saved.id) renameInstance(live.id, e.saved.title); final dt = desktops[e.desktopKey]; if (dt == null || dt.isKeep) { await _tryMoveToDesktop(live, e.saved, tally); } else if (resolved[e.desktopKey] != null) { await _tryMoveToDesktop(live, e.saved, tally, overrideIndex: resolved[e.desktopKey]); } } // Aynı döküman iki kez seçilmişse teke indir; ürün → çizim → parça. final seen = {}; final docs = [ for (final d in e.docs) if (seen.add(d.fullPath.toLowerCase())) d, ]; final order = {for (var k = 0; k < docs.length; k++) docs[k]: k}; docs.sort((a, b) { final c = a.openPriority.compareTo(b.openPriority); return c != 0 ? c : order[a]!.compareTo(order[b]!); }); final active = docs.where((d) => d.isActive).firstOrNull; _setSessionMessage('⏳ $step: ${docs.length} döküman açtırılıyor…'); final jobId = await NativeBridge.openDocuments( live.id, [for (final d in docs) d.fullPath], activate: active?.name, ); if (jobId <= 0) { final msg = 'döküman açma başlatılamadı (kod $jobId)'; tally.errors.add('$label: $msg'); tally.failed += docs.length; debugPrint('session plan: $label: $msg'); continue; } jobs.add(_awaitOpenJob(jobId, label, tally)); } if (jobs.isNotEmpty) { _setSessionMessage('⏳ Dökümanlar CATIA\'larda açılıyor… ' '(büyük montajlar dakikalar sürebilir)'); await Future.wait(jobs); } if (switchAfter && switchIdx != null) { final r = await VirtualDesktopService.switchToDesktopIndex(switchIdx); if (!r.ok) tally.errors.add('Masaüstüne geçilemedi: ${r.error}'); unawaited(refreshDesktopList(force: true)); } } catch (e, st) { tally.errors.add('Beklenmeyen hata: $e'); debugPrint('session plan EXCEPTION: $e\n$st'); } finally { _sessionBusy = false; } _reportRestore(tally); } /// Geri yükleme sonuç özeti (banner mesajı). void _reportRestore(_RestoreTally tally) { final parts = [ if (tally.launched > 0) '${tally.launched} CATIA başlatıldı', '${tally.opened} döküman açıldı', if (tally.alreadyOpen > 0) '${tally.alreadyOpen} zaten açıktı', if (tally.desktopsCreated > 0) '${tally.desktopsCreated} masaüstü oluşturuldu', if (tally.moved > 0) '${tally.moved} CATIA masaüstüne taşındı', ]; final problems = [ if (tally.failed > 0) '${tally.failed} döküman açılamadı', if (tally.desktopFailed > 0) '${tally.desktopFailed} CATIA kayıtlı masaüstüne taşınamadı ' '(${tally.desktopHr ?? '?'}) — pencereyi elle sürükleyin', ...tally.errors.take(3), ]; final ok = problems.isEmpty; _setSessionMessage( '${ok ? '✅' : '⚠'} Geri yükleme bitti: ${parts.join(' · ')}' '${ok ? '' : '\n${problems.join('\n')}'}', warn: !ok, ); } /// 📂 Kayıtlı oturumu geri yükle. Future restoreSession({bool auto = false}) async { if (_sessionBusy) return; final session = _savedSession ?? await SessionService.loadSession(); if (session == null) { _setSessionMessage('Kayıtlı oturum bulunamadı — önce 💾 ile kaydedin.', warn: true); return; } _sessionBusy = true; _restoreOfferDismissed = true; final tally = _RestoreTally(); final jobs = >[]; final total = session.instances.length; try { final assigned = {}; var n = 0; for (final saved in session.instances) { n++; final label = '${saved.title} (${saved.desktopLabel})'; final step = '[$n/$total] $label'; _setSessionMessage('⏳ $step: CATIA aranıyor…'); var live = _matchLive(saved, assigned); if (live == null) { final before = _liveInstances.map((i) => i.id).toSet(); _setSessionMessage('⏳ $step: CATIA başlatılıyor…'); final err = await _launchCatia(); if (err != null) { tally.errors.add('$label: $err'); debugPrint('session restore: $label: $err'); continue; } tally.launched++; _setSessionMessage( '⏳ $step: CATIA açılıyor, macro kaydı bekleniyor…'); live = await _waitForNewInstance(before); if (live == null) { const msg = 'CATIA açıldı ama macro kayıt olmadı (zaman aşımı)'; tally.errors.add('$label: $msg'); debugPrint('session restore: $label: $msg'); continue; } } assigned.add(live.id); if (saved.title != saved.id) renameInstance(live.id, saved.title); await _tryMoveToDesktop(live, saved, tally); // Zaten açık olanları atla, kalanları ürün → çizim → parça sırasıyla aç. final openNow = live.documents .map((d) => '${d.path}${d.name}'.toLowerCase()) .toSet(); final restorable = saved.documents.where((d) => d.path.isNotEmpty).toList(); final todoIdx = [ for (var k = 0; k < restorable.length; k++) if (!openNow.contains(restorable[k].fullPath.toLowerCase())) k, ]..sort((a, b) { final c = restorable[a] .openPriority .compareTo(restorable[b].openPriority); return c != 0 ? c : a.compareTo(b); }); tally.alreadyOpen += restorable.length - todoIdx.length; if (todoIdx.isEmpty) continue; final todo = [for (final k in todoIdx) restorable[k]]; final active = todo.where((d) => d.isActive).firstOrNull ?? restorable.where((d) => d.isActive).firstOrNull; _setSessionMessage( '⏳ $step: ${todo.length} döküman açtırılıyor…'); final jobId = await NativeBridge.openDocuments( live.id, [for (final d in todo) d.fullPath], activate: active?.name, ); if (jobId <= 0) { final msg = 'döküman açma başlatılamadı (kod $jobId)'; tally.errors.add('$label: $msg'); tally.failed += todo.length; debugPrint('session restore: $label: $msg'); continue; } jobs.add(_awaitOpenJob(jobId, label, tally)); } if (jobs.isNotEmpty) { _setSessionMessage('⏳ Dökümanlar CATIA\'larda açılıyor… ' '(büyük montajlar dakikalar sürebilir)'); await Future.wait(jobs); } } catch (e, st) { tally.errors.add('Beklenmeyen hata: $e'); debugPrint('session restore EXCEPTION: $e\n$st'); } finally { _sessionBusy = false; } _reportRestore(tally); } /// Kayıtlı CATIA'ya karşılık gelen çalışan (henüz atanmamış) CATIA'yı bul: /// önce ortak dökümanı en çok olan, yoksa aynı sanal masaüstündeki. CatiaInstance? _matchLive(SavedInstance saved, Set assigned) { final live = _liveInstances.where((i) => !assigned.contains(i.id)).toList(); final savedPaths = saved.documents.map((d) => d.fullPath.toLowerCase()).toSet(); CatiaInstance? best; var bestScore = 0; for (final i in live) { final score = i.documents .where((d) => savedPaths.contains('${d.path}${d.name}'.toLowerCase())) .length; if (score > bestScore) { best = i; bestScore = score; } } if (best != null) return best; if (saved.desktopGuid.isNotEmpty) { for (final i in live) { if (i.desktopGuid == saved.desktopGuid) return i; } } return null; } /// Ayarlardaki başlatıcıyla yeni bir CATIA açar. Hata metni ya da null. Future _launchCatia() async { final exe = _settings.catiaExe.trim(); if (exe.isEmpty) { return 'CATIA başlatıcı yolu ayarlı değil (Dosya ▸ Oturum ayarları)'; } final file = File(exe); if (!file.existsSync()) return 'Başlatıcı bulunamadı: $exe'; try { final args = [ for (final m in RegExp(r'"([^"]*)"|(\S+)').allMatches(_settings.catiaArgs)) m.group(1) ?? m.group(2)!, ]; await Process.start(exe, args, mode: ProcessStartMode.detached, workingDirectory: file.parent.path); return null; } catch (e) { return 'CATIA başlatılamadı: $e'; } } /// Yeni başlatılan CATIA'nın macro'sunun COM'a kayıt olmasını bekler. Future _waitForNewInstance(Set before, {Duration timeout = const Duration(minutes: 4)}) async { final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline) && !_disposed) { for (final i in _liveInstances) { if (!before.contains(i.id)) return i; } await Future.delayed(const Duration(seconds: 1)); } return null; } /// CATIA penceresini kayıtlı masaüstüne taşımaya dener. /// /// Öncelik ADA göre (PowerShell motoru seçiliyse): Windows, bilgisayar /// yeniden açıldığında/oturum kapanıp açıldığında tüm sanal masaüstülerini /// YENİ GUID'lerle yeniden kurar — kaydedilen `desktopGuid` bu durumda /// artık hiçbir masaüstüne karşılık gelmez. Ad ise (kullanıcı verdiyse) /// kalıcıdır; bu yüzden ad kaydedilmişse önce o denenir (yoksa PowerShell /// modülü masaüstünü AYNI isimle yeniden oluşturup taşır). Ad yoksa/motor /// native ise eski GUID eşleştirmesine (`IVirtualDesktopManager`) düşülür — /// bu yalnızca GUID'in hala geçerli olduğu (reboot olmamış) durumlarda işe yarar. Future _tryMoveToDesktop( CatiaInstance live, SavedInstance saved, _RestoreTally tally, {int? overrideIndex}) async { if (live.pid <= 0) return; // Gerçek pencere tutamacı: pid'den tahmin YOK (yanlış CATIA taşınıyordu). // Pencere yeni açıldıysa henüz görünmemiş olabilir → birkaç deneme. ({int hwnd, int pid})? w; for (var attempt = 0; attempt < 3 && w == null && !_disposed; attempt++) { w = await _findRealWindow(live); if (w == null) await Future.delayed(const Duration(seconds: 2)); } if (w == null) { tally.desktopFailed++; tally.desktopHr ??= 'pencere bulunamadı'; debugPrint('session restore: ${live.id} penceresi bulunamadı — taşınmadı.'); return; } final hwnd = w.hwnd; // Kullanıcının seçtiği (ya da yeni oluşturulan) masaüstü: indekse göre taşı. if (overrideIndex != null) { final r = await VirtualDesktopService.moveWindowToDesktopIndex( hwnd, overrideIndex); _dlog('TASI-PLAN ${live.id} hwnd=0x${hwnd.toRadixString(16)} ' 'hedef=#${overrideIndex + 1} ok=${r.ok} err=${r.error}'); if (r.ok) { tally.moved++; final i = _indexOf(live.id); if (i >= 0) { _instances[i] = _instances[i].copyWith(desktopNumber: overrideIndex + 1); } unawaited(refreshDesktops()); } else { tally.desktopFailed++; tally.desktopHr ??= r.error ?? 'taşıma hatası'; } return; } final name = saved.desktopName?.trim() ?? ''; String? psError; if (name.isNotEmpty && _settings.desktopEngine == DesktopEngine.powershell) { final r = await VirtualDesktopService.moveWindowToNamedDesktop(hwnd, name); if (r.ok) { tally.moved++; final i = _indexOf(live.id); if (i >= 0) { _instances[i] = _instances[i].copyWith(desktopName: name); } unawaited(refreshDesktops()); return; } psError = r.error; debugPrint('session restore: PowerShell ile isimli masaüstüne taşıma ' 'başarısız (${live.id} -> "$name": $psError) — GUID yöntemine düşülüyor.'); } // Adı olmayan (isimsiz) masaüstü: kayıttaki GERÇEK numaraya göre taşı // (yalnızca PowerShell motoru; masaüstü sayısı yetmiyorsa hata döner). final savedNo = saved.desktopNumber; if (name.isEmpty && savedNo != null && savedNo > 0 && _settings.desktopEngine == DesktopEngine.powershell) { final r = await VirtualDesktopService.moveWindowToDesktopIndex(hwnd, savedNo - 1); _dlog('TASI-NUM ${live.id} hwnd=0x${hwnd.toRadixString(16)} ' 'hedef=#$savedNo ok=${r.ok} err=${r.error}'); if (r.ok) { tally.moved++; unawaited(refreshDesktops()); return; } psError = r.error; } if (saved.desktopGuid.isEmpty) { if (psError != null) { // Yedek (GUID) yok: sessizce geçme, özet mesajda göster. tally.desktopFailed++; tally.desktopHr ??= '"$name": $psError'; } return; } await refreshDesktops(); final cur = _instances.firstWhere((i) => i.id == live.id, orElse: () => live); if (cur.desktopGuid == saved.desktopGuid) return; // zaten doğru masaüstü int? hr; for (var attempt = 0; attempt < 5; attempt++) { hr = await NativeBridge.moveWindowToDesktop(w.pid, saved.desktopGuid, hwnd: hwnd); // 0x80070490 (ERROR_NOT_FOUND) = pencere henüz oluşmadı → biraz bekle. if (hr != -2147023728) break; await Future.delayed(const Duration(seconds: 2)); } if (hr == 0) { tally.moved++; } else { tally.desktopFailed++; tally.desktopHr ??= hr == null ? 'yanıt yok' : '0x${(hr & 0xFFFFFFFF).toRadixString(16).toUpperCase().padLeft(8, '0')}'; debugPrint('session restore: masaüstü taşıma ${live.id} -> ' '${saved.desktopGuid} hr=$hr'); } } /// Native worker'daki döküman açma işini bitene kadar yoklar. Future _awaitOpenJob( int jobId, String label, _RestoreTally tally) async { final deadline = DateTime.now().add(const Duration(minutes: 45)); while (!_disposed) { final r = await NativeBridge.getOpenJob(jobId); if (r != null && r['done'] == true) { for (final e in (r['results'] as List? ?? const [])) { final m = Map.from(e as Map); final path = (m['path'] as String?) ?? ''; final err = (m['error'] as String?) ?? ''; if (m['ok'] == true) { if (err == 'zaten açık') { tally.alreadyOpen++; } else { tally.opened++; } } else { tally.failed++; final file = path.split(RegExp(r'[\\/]')).last; tally.errors.add('$label: $file — $err'); debugPrint('session restore: $label: $path -> $err'); } } return; } if (DateTime.now().isAfter(deadline)) { tally.errors.add('$label: döküman açma zaman aşımına uğradı'); return; } await Future.delayed(const Duration(seconds: 1)); } } // ---- Döküman aksiyonları ---- /// 💾 Dökümanı kaydet (mock: saved=true yapar). void saveDocument(String instanceId, String docId) { _mutateDocument(instanceId, docId, (d) => d.copyWith(saved: true)); } bool _saveAllBusy = false; /// Toolbar 💽 "tüm dökümanları kaydet" çalışırken true (düğme pasifleşir). bool get saveAllBusy => _saveAllBusy; /// 💽 Bağlı TÜM CATIA'lardaki kaydedilmemiş (ve diskte yolu olan) /// dökümanları kaydeder. Her CATIA için native'de worker-thread işi /// başlatılır (`saveAllDocuments`), sonra hepsi paralel yoklanır /// (`getSaveAllResult`); özet oturum bandına yazılır. Hiç kaydedilmemiş /// (yolsuz) yeni dökümanlar atlanır — "Farklı kaydet" diyaloğu açmayız. Future saveAllDocuments() async { if (_saveAllBusy) return; final live = _liveInstances; if (live.isEmpty) { _setSessionMessage('Kaydedilecek bağlı CATIA yok', warn: true); return; } _saveAllBusy = true; _safeNotify(); try { _setSessionMessage('💽 ${live.length} CATIA\'da dökümanlar kaydediliyor…'); final pending = {}; // jobId -> CATIA başlığı final notStarted = []; for (final inst in live) { final jobId = await NativeBridge.saveAllDocuments(inst.id); if (jobId > 0) { pending[jobId] = inst.title; } else { notStarted.add(inst.title); } } var saved = 0, skipped = 0, failed = 0; final errors = []; final deadline = DateTime.now().add(const Duration(seconds: 60)); while (pending.isNotEmpty && DateTime.now().isBefore(deadline)) { for (final e in pending.entries.toList()) { final r = await NativeBridge.getSaveAllResult(e.key); if (r == null || r['done'] != true) continue; pending.remove(e.key); saved += (r['saved'] as int?) ?? 0; skipped += (r['skipped'] as int?) ?? 0; final f = (r['failed'] as int?) ?? 0; failed += f; final err = '${r['errors'] ?? ''}'.trim(); if (f > 0) errors.add('${e.value}: ${err.isEmpty ? 'hata' : err}'); } if (pending.isNotEmpty) { await Future.delayed(const Duration(milliseconds: 400)); } } for (final t in pending.values) { errors.add('$t: zaman aşımı (CATIA meşgul/diyalogda olabilir)'); } failed += pending.length; for (final t in notStarted) { errors.add('$t: başlatılamadı'); } failed += notStarted.length; if (failed == 0) { _setSessionMessage(saved == 0 ? '💽 Kaydedilecek değişiklik yok ($skipped döküman zaten kayıtlı/yeni)' : '✅ $saved döküman kaydedildi' '${skipped > 0 ? ' · $skipped atlandı' : ''}'); } else { _setSessionMessage( '⚠ $saved kaydedildi · $failed hata — ${errors.join(' | ')}', warn: true); } } finally { _saveAllBusy = false; _safeNotify(); } } /// ❌ Dökümanı kapat. Bağlı gerçek bir CATIA'ysa native'e `Window.Close`/ /// `Document.Close` komutu gönderilir (worker thread'de, fire-and-forget); /// CATIA'nın kaydetme diyaloğu çıkabilir, biz bastırmıyoruz. Gerçek /// kapanma bir sonraki snapshot'ta yansır ve otomatik olarak "🕘 daha /// önce açıktı" geçmişine düşer (bkz. _trackClosedDocuments) — burada /// listeye dokunulmaz. Mock/bağlı olmayan instance'da eskisi gibi yalnız /// yerel listeden düşürülür. Future closeDocument(String instanceId, String docId) async { final i = _indexOf(instanceId); if (i < 0) return; final inst = _instances[i]; final doc = inst.documents.where((d) => d.id == docId).firstOrNull; if (doc == null) return; if (inst.pid <= 0 || !inst.connected || doc.name.isEmpty) { _instances[i] = inst.copyWith( documents: inst.documents.where((d) => d.id != docId).toList(), ); notifyListeners(); return; } _setSessionMessage('${inst.title}: "${doc.name}" kapatılıyor…'); final ok = await NativeBridge.closeDocumentWindow(inst.id, doc.name); if (!ok) { _setSessionMessage( '${inst.title}: "${doc.name}" kapatılamadı (CATIA meşgul olabilir)', warn: true); } } /// 🕘 Geçmişteki ("daha önce açıktı") bir dökümanı AYNI CATIA'da yeniden /// açar — oturum geri yüklemede kullanılan `openDocuments` iş kuyruğunun /// aynısını (yol+ad) kullanır. Başarıyla açılırsa geçmişten düşer (bir /// sonraki snapshot zaten aynısını yapardı, burada anında yapılır). Future reopenClosedDocument( String instanceId, ClosedCatiaDocument doc) async { final i = _indexOf(instanceId); if (i < 0) return; final inst = _instances[i]; if (inst.pid <= 0 || !inst.connected) { _setSessionMessage('${inst.title}: CATIA bağlı değil', warn: true); return; } _setSessionMessage('🕘 ${inst.title}: "${doc.name}" yeniden açılıyor…'); final jobId = await NativeBridge.openDocuments(inst.id, ['${doc.path}${doc.name}']); if (jobId <= 0) { _setSessionMessage( '${inst.title}: "${doc.name}" açılamadı (kod $jobId)', warn: true); return; } final tally = _RestoreTally(); await _awaitOpenJob(jobId, inst.title, tally); if (tally.failed == 0) { _closedDocs[instanceId] ?.removeWhere((c) => _docKey(c.path, c.name) == _docKey(doc.path, doc.name)); _setSessionMessage('✅ ${inst.title}: "${doc.name}" yeniden açıldı'); notifyListeners(); } else { _setSessionMessage( '⚠ ${inst.title}: "${doc.name}" açılamadı — ${tally.errors.join(' · ')}', warn: true); } } /// 📂 Dökümanın klasörünü Explorer'da aç (gerçek yol, mock'ta yoksa no-op). Future openDocumentFolder( String instanceId, String docId) async { final i = _indexOf(instanceId); if (i < 0) return; for (final d in _instances[i].documents) { if (d.id == docId && d.path.isNotEmpty && Directory(d.path).existsSync()) { try { await Process.run('explorer.exe', [d.path]); } catch (_) {} return; } } } void _mutateDocument( String instanceId, String docId, CatiaDocument Function(CatiaDocument) f) { final i = _indexOf(instanceId); if (i < 0) return; final cur = _instances[i]; _instances[i] = cur.copyWith( documents: cur.documents .map((d) => d.id == docId ? f(d) : d) .toList(), ); notifyListeners(); } void setQuery(String q) { _query = q; notifyListeners(); } void selectInstance(String id) { _selectedInstanceId = id; notifyListeners(); } void clearSelection() { _selectedInstanceId = null; notifyListeners(); } } /// Seçim penceresi planının uygulanabilir tek bir adımı: bir hedef (yeni CATIA /// ya da açık bir CATIA) ve ona açılacak dökümanlar. class _PlanEntry { final String target; // 'new' ya da açık CATIA id'si final SavedInstance saved; // etiket/başlık/masaüstü bilgisi için final List docs; final String desktopKey; // kayıtlı masaüstü bölümü ('' = yok) _PlanEntry(this.target, this.saved, this.docs, [this.desktopKey = '']); } /// Bir geri yükleme çalışmasının sayaçları (özet mesajı için). class _RestoreTally { int launched = 0; // yeni başlatılan CATIA int opened = 0; // açılan döküman int alreadyOpen = 0; // zaten açık olduğu için atlanan döküman int failed = 0; // açılamayan döküman int moved = 0; // kayıtlı masaüstüne taşınan CATIA int desktopsCreated = 0; // yeni oluşturulan masaüstü int desktopFailed = 0; // taşınamayan CATIA String? desktopHr; // ilk taşıma hatasının HRESULT'ı final List errors = []; }