import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; import 'dart:io'; import 'dart:math' as math; import 'package:ffi/ffi.dart'; import 'package:path/path.dart' as p; import '../../core/playlist.dart'; import '../../l10n/tr.dart'; /// 이 프로세스를 바로 끝낸다. Dart 의 exit() 는 엔진 · 창 · 플러그인 DLL 을 정리하는 중에 Windows 의 /// coremessaging.dll 등에서 접근 위반 (0xc0000005) 으로 꺼지기도 한다 - 특히 막 뜬 두 번째 실행이 바로 끝날 때 /// (재현: 앱이 켜져 있을 때 한 번 더 실행 → APPCRASH). 저장할 것이 없는 경우에만 쓴다. Never endProcessNow() { if (Platform.isWindows) Process.killPid(pid); exit(0); } /// 첫 창에 넘겨 본 결과: 받음 · 아직 준비 안 됨 (파일 없음 · 연결 안 됨) · 연결은 됐는데 답이 없음 enum _Forward { ok, notReady, silent } /// [SingleInstance.claim] 의 결과 enum ClaimResult { /// 첫 실행 (요청을 받을 준비가 됨) first, /// 이미 실행 중인 창에 넘겼다 (이 실행은 끝낸다) forwarded, /// 이미 실행 중인데 넘기지 않음 ([SingleInstance.claim] 의 forward 가 false - 새 재생 창 등) running, /// 이미 실행 중인 창이 있는데 응답하지 않는다 (사용자에게 물어 그래도 열지 정한다) unresponsive, } /// 프로그램을 데이터 폴더마다 하나만 실행하고, 나중에 실행된 것은 인수를 첫 번째 창에 넘기고 끝낸다 (228). /// /// - 첫 실행 판단: 데이터 폴더의 instance.lock 을 잠가 쥔다 (파일 핸들 단위 잠금) - 포트가 아니라 Windows 가 막아 둔 포트 범위 /// (Hyper-V · WSL) 나 다른 프로그램과 부딪히지 않고, 데이터 폴더가 다르면 (시험판 JJ_MKVMAKER_DATA) 따로 뜬다. /// 프로세스가 끝나면 (죽어도) Windows 가 잠금을 푼다. /// - 인수 넘기기: 첫 실행이 127.0.0.1 의 포트 0 (OS 가 고름) 에 열고, 포트 · 무작위 비밀값을 데이터 폴더의 instance.json 에 쓴다. /// 나중 실행은 그 비밀값과 함께 보낸다 (같은 PC 의 다른 프로그램이 요청을 넣지 못하게). 비밀값은 작업 기록에 쓰지 않는다. /// /// 탐색기에서 파일 여러 개를 골라 "재생" 하면 파일마다 프로그램이 실행되므로, 짧은 시간([gather]) 안에 들어온 요청을 하나로 모아 전달한다. class SingleInstance { static const _magic = 'JJMKVMAKER2'; final String dataDir; final Duration gather; ServerSocket? _server; RandomAccessFile? _lock; String? _token; final _pending = >{}; Timer? _timer; void Function(LaunchRequest)? _onRequest; SingleInstance({required this.dataDir, this.gather = const Duration(milliseconds: 700)}); /// 이 데이터 폴더의 잠금 파일 (첫 실행이 앱이 끝날 때까지 잠가 쥔다) String get lockPath => p.join(dataDir, 'instance.lock'); File get _file => File(p.join(dataDir, 'instance.json')); /// 첫 실행인지 정하고, 아니면 [initial] 을 첫 창에 넘긴다. /// [waitForExit]: 재시작 - 기존 창이 닫힐 때까지 (최대 15초) 기다렸다가 첫 번째가 된다. /// [forward] 가 false 면 이미 실행 중이어도 넘기지 않는다 (새 재생 창으로 따로 뜰 때). Future claim(LaunchRequest initial, {bool waitForExit = false, bool forward = true}) async { for (var i = 0;; i++) { if (_takeLock()) break; if (waitForExit && i < 60) { await Future.delayed(const Duration(milliseconds: 250)); continue; } if (!forward) return ClaimResult.running; // 첫 창이 막 뜨는 중이면 instance.json 이 아직 없을 수 있다 - 잠깐 기다리며 몇 번. // 연결은 되는데 답이 없으면 (멈춘 첫 창) [silentWait] 씩 [silentTries] 번이면 묻는다 (관리자 10/24: 예전에는 약 33초 동안 반응 없음) var silent = 0; for (var k = 0; k < 10; k++) { switch (await _forward(initial)) { case _Forward.ok: return ClaimResult.forwarded; case _Forward.silent: if (++silent >= silentTries) return ClaimResult.unresponsive; case _Forward.notReady: await Future.delayed(const Duration(milliseconds: 300)); } } return ClaimResult.unresponsive; } await _listen(); _add(initial); return ClaimResult.first; } /// 응답하지 않는 첫 창이 있어도 이 실행이 그대로 뜰 때 (사용자가 [그래도 열기]): 요청은 이 창이 받는다 Future takeOverAnyway(LaunchRequest initial) async { await _listen(); _add(initial); } Future _listen() async { _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); _token = _randomToken(); try { Directory(dataDir).createSync(recursive: true); _file.writeAsStringSync(jsonEncode({'port': _server!.port, 'token': _token, 'pid': pid})); } catch (_) {} _server!.listen(_accept); } static String _randomToken() { final r = math.Random.secure(); return base64Url.encode(List.generate(24, (_) => r.nextInt(256))).replaceAll('=', ''); } /// 모인 요청을 처리할 함수 (앱 준비가 끝난 뒤 등록) void listen(void Function(LaunchRequest) onRequest) { _onRequest = onRequest; if (_pending.isNotEmpty) _schedule(); } /// 연결된 첫 창이 답하기를 기다리는 시간과 횟수 (넘으면 응답 없음 - 사용자에게 묻는다) static const silentWait = Duration(seconds: 1); static const silentTries = 3; Future<_Forward> _forward(LaunchRequest r) async { Socket? s; try { final j = jsonDecode(_file.readAsStringSync()) as Map; final port = j['port'] as int, token = j['token'] as String; s = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: const Duration(seconds: 2)); s.write('$_magic $token ${jsonEncode(r.toArgs())}\n'); await s.flush(); final reply = await utf8.decoder.bind(s).join().timeout(silentWait); await s.close(); return reply.startsWith('OK') ? _Forward.ok : _Forward.notReady; } on TimeoutException { s?.destroy(); return s == null ? _Forward.notReady : _Forward.silent; } catch (_) { s?.destroy(); return _Forward.notReady; } } void _accept(Socket s) { utf8.decoder.bind(s).transform(const LineSplitter()).first.then((line) { final head = '$_magic $_token '; // 비밀값이 맞을 때만 (같은 PC 의 다른 프로그램이 파일 열기 등을 넣지 못하게) if (_token != null && line.startsWith(head)) { final args = (jsonDecode(line.substring(head.length)) as List).cast(); _add(LaunchRequest.parse(args)); s.write('OK'); } return s.close(); }).catchError((_) => s.destroy()); } void _add(LaunchRequest r) { // 파일 없이 실행된 경우도 "창 보이기" 요청으로 전달 _pending.putIfAbsent(r.action, () => []).addAll(r.files); _schedule(); } void _schedule() { if (_onRequest == null) return; _timer?.cancel(); _timer = Timer(gather, () { final items = Map.of(_pending); _pending.clear(); for (final e in items.entries) { _onRequest!(LaunchRequest(e.key, e.value.toSet().toList())); } }); } /// 끝낼 때: instance.json (이 실행이 쓴 것일 때만) 을 지우고 잠금 파일을 놓는다 Future close() async { _timer?.cancel(); await _server?.close(); try { final j = jsonDecode(_file.readAsStringSync()) as Map; if (j['token'] == _token) _file.deleteSync(); } catch (_) {} _releaseLock(); } // ───────── 잠금 파일 ───────── /// 이 데이터 폴더의 첫 실행이면 instance.lock 을 잠가 쥔다 (true). 다른 실행이 쥐고 있으면 false. /// 잠금은 파일 핸들에 묶인다 (Windows LockFileEx) - 스레드와 상관없이 이 프로세스가 끝날 때까지 쥐고, /// 프로세스가 죽으면 Windows 가 푼다. 이름 있는 뮤텍스는 쥔 OS 스레드가 끝나면 "버려진" 상태가 되어 /// 다음 실행이 첫 실행으로 판단할 수 있어 쓰지 않는다 (관리자 검토 10/12). GetLastError 도 쓰지 않는다. bool _takeLock() { if (_lock != null) return true; RandomAccessFile? raf; try { Directory(dataDir).createSync(recursive: true); raf = File(p.join(dataDir, 'instance.lock')).openSync(mode: FileMode.append); raf.lockSync(FileLock.exclusive); _lock = raf; return true; } on FileSystemException { try { raf?.closeSync(); } catch (_) {} // 열지조차 못하면 (쓸 수 없는 폴더 등) 막지 않는다. 잠금만 실패면 다른 실행이 쥐고 있다 return raf == null; } } void _releaseLock() { final raf = _lock; _lock = null; if (raf == null) return; try { raf.unlockSync(); } catch (_) {} try { raf.closeSync(); } catch (_) {} } } /// 228: 같은 데이터 폴더의 창이 이미 있는데 응답하지 않을 때 묻는다 (앱 화면이 뜨기 전이라 Windows 메시지 상자). /// [예] = 그래도 열기, [아니요] = 닫기 (처음 고른 것 - 같은 데이터 위에 창이 두 개 뜨는 것을 막는 쪽, 관리자 결정 10/12) bool askOpenAnyway(String title) { if (!Platform.isWindows) return true; const mbYesNo = 0x4, mbIconWarning = 0x30, mbDefButton2 = 0x100, idYes = 6; final text = tr('이미 실행 중인 창이 응답하지 않습니다.\n\n' '[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n' '[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.') .toNativeUtf16(); final caption = title.toNativeUtf16(); try { final box = DynamicLibrary.open('user32.dll').lookupFunction, Pointer, Uint32), int Function(int, Pointer, Pointer, int)>('MessageBoxW'); return box(0, text, caption, mbYesNo | mbIconWarning | mbDefButton2) == idYes; } catch (_) { return false; } finally { calloc.free(text); calloc.free(caption); } }