From 893f6759ff1afe5fd9ada7d2d3ec8c4e5653df82 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:26:59 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EB=A7=89=EA=B8=B0=20(228):=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=EC=9D=98=20=EC=9E=A0=EA=B8=88=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20(instance.lock,=20=ED=95=B8=EB=93=A4=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84)=20=C2=B7=20=ED=8F=AC=ED=8A=B8=200=20+=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EA=B0=92=20=C2=B7=20=EC=9D=91=EB=8B=B5=20=EC=97=86?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EB=AC=BB=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 검토 (10/12): 이름 있는 뮤텍스는 쥔 OS 스레드가 끝나면 버려진 상태가 되어 다음 실행이 첫 실행으로 판단할 수 있다 → 데이터 폴더의 instance.lock 을 파일 핸들 잠금 (LockFileEx) 으로 앱이 끝날 때까지 쥔다. 스레드와 상관없고 프로세스가 죽으면 Windows 가 푼다. GetLastError · FFI 를 쓰지 않는다. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 + assets/l10n/ja.json | 2 + assets/l10n/zh-Hans.json | 2 + lib/main.dart | 20 ++- lib/platform/windows/single_instance.dart | 185 ++++++++++++++++++---- test/player_test.dart | 115 ++++++++++++-- 6 files changed, 277 insertions(+), 49 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b4320dc..6ead742 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1603,6 +1603,7 @@ "이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).", "이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).", "이미 받는 중이거나 받을 수 없는 주소입니다.": "Already downloading or a URL that cannot be downloaded.", + "이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "A window that is already running is not responding.\n\n[Yes] Open anyway - two windows will share the same data, and settings or lists may overwrite each other.\n[No] Close - end the unresponsive window in Task Manager, then run the app again.", "이미 이 폴더에 있습니다": "Already in this folder", "이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}", "이미 이 폴더에 있어 건너뜀: {0}": "Skipped, already in this folder: {0}", @@ -1817,6 +1818,7 @@ "중국어": "Chinese", "중국어 간체": "Chinese (Simplified)", "중국어 번체": "Chinese (Traditional)", + "중복 실행 막기 정리": "Clean up duplicate-launch guard", "중지": "Stop", "즐겨찾기": "Bookmarks", "즐겨찾기 HTML 가져오기": "Import bookmarks HTML", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 507f866..3c9ac15 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1603,6 +1603,7 @@ "이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。", "이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "既にダウンロード中か、ダウンロードできない URL です。", + "이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "すでに実行中のウィンドウが応答しません。\n\n[はい] それでも開く - 同じデータの上にウィンドウが 2 つになり、設定 · 一覧が互いに上書きされることがあります。\n[いいえ] 閉じる - 応答しないウィンドウをタスク マネージャーで終了してから、もう一度実行してください。", "이미 이 폴더에 있습니다": "既にこのフォルダーにあります", "이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}", "이미 이 폴더에 있어 건너뜀: {0}": "既にこのフォルダーにあるためスキップ: {0}", @@ -1817,6 +1818,7 @@ "중국어": "中国語", "중국어 간체": "中国語(簡体字)", "중국어 번체": "中国語(繁体字)", + "중복 실행 막기 정리": "重複起動防止の後片付け", "중지": "中止", "즐겨찾기": "ブックマーク", "즐겨찾기 HTML 가져오기": "ブックマーク HTML を取り込む", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 20edd17..f4186a9 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1603,6 +1603,7 @@ "이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。", "이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。", "이미 받는 중이거나 받을 수 없는 주소입니다.": "已在下载中,或该地址无法下载。", + "이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "已在运行的窗口没有响应。\n\n[是] 仍然打开 - 同一数据上将有两个窗口,设置和列表可能互相覆盖。\n[否] 关闭 - 请在任务管理器中结束无响应的窗口后重新运行。", "이미 이 폴더에 있습니다": "已在此文件夹中", "이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}", "이미 이 폴더에 있어 건너뜀: {0}": "已在此文件夹中,已跳过: {0}", @@ -1817,6 +1818,7 @@ "중국어": "中文", "중국어 간체": "简体中文", "중국어 번체": "繁体中文", + "중복 실행 막기 정리": "清理防止重复运行", "중지": "中止", "즐겨찾기": "书签", "즐겨찾기 HTML 가져오기": "导入书签 HTML", diff --git a/lib/main.dart b/lib/main.dart index dca8984..cac4bf0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -92,10 +92,20 @@ Future main(List args) async { // 이미 실행 중이면 인수(탐색기에서 고른 파일)를 넘기고 끝낸다 final request = LaunchRequest.parse(args); - final newWindow = wantsNewPlayerWindow(request, await SettingsStore().load()); - // 시험판은 사용자가 켜 둔 앱에 넘기지 않고 따로 뜬다 - final instance = SingleInstance(port: dataDirOverride == null ? 47821 : 47822); - if (!await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow)) { + final startSettings = await SettingsStore().load(); + final newWindow = wantsNewPlayerWindow(request, startSettings); + // 228: 데이터 폴더마다 하나 (데이터 폴더의 잠금 파일) - 시험판 (따로 데이터) 은 사용자가 켜 둔 앱 · 다른 시험판에 넘기지 않고 따로 뜬다 + final instance = SingleInstance(dataDir: dataDir); + final claimed = await instance.claim(request, waitForExit: args.contains('--restart'), forward: !newWindow); + if (claimed == ClaimResult.unresponsive) { + // 같은 데이터 위에 창이 두 개 뜨지 않게 묻는다 (처음 고른 것은 [닫기] - 관리자 결정 10/12) + await i18n.apply(startSettings.uiLanguage, save: false); + if (askOpenAnyway(appTitle)) { + await instance.takeOverAnyway(request); + } else { + endProcessNow(); + } + } else if (claimed != ClaimResult.first) { // 설정이 "새 창에서" 이고 이미 켜져 있으면: 재생만 하는 창으로 따로 뜬다 if (newWindow) return runSecondWindow(request.files.where(isVideoFile).toList(), dataDir); endProcessNow(); // exit(0) 은 막 뜬 엔진을 정리하다 coremessaging.dll 에서 꺼진다 @@ -195,6 +205,8 @@ Future main(List args) async { () async => controller.cancel()), ExitStep(tr('다운로드 정리 (yt-dlp · aria2 종료)'), downloads.shutdown), ExitStep(tr('Tor 끄기'), () => TorService.instance.stop()), + // 228: 다음 실행이 이 창에 넘기려 하지 않게 (instance.json 지우기 · 잠금 파일 놓기) + ExitStep(tr('중복 실행 막기 정리'), instance.close), ExitStep(tr('쓰레드 확인 (AI · 변환 작업이 멈출 때까지)'), () async { while (controller.busy) { await Future.delayed(const Duration(milliseconds: 100)); diff --git a/lib/platform/windows/single_instance.dart b/lib/platform/windows/single_instance.dart index 3d86d2d..a392a92 100644 --- a/lib/platform/windows/single_instance.dart +++ b/lib/platform/windows/single_instance.dart @@ -1,8 +1,15 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:ffi'; import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart' show visibleForTesting; +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) 으로 꺼지기도 한다 - 특히 막 뜬 두 번째 실행이 바로 끝날 때 @@ -12,43 +19,91 @@ Never endProcessNow() { exit(0); } -/// 프로그램을 하나만 실행하고, 나중에 실행된 것은 인수를 첫 번째 창에 넘기고 끝낸다. -/// -/// 탐색기에서 파일 여러 개를 골라 "재생" 하면 파일마다 프로그램이 실행되므로, -/// 짧은 시간([gather]) 안에 들어온 요청을 하나로 모아 전달한다. -class SingleInstance { - static const _magic = 'JJMKVMAKER1'; +/// [SingleInstance.claim] 의 결과 +enum ClaimResult { + /// 첫 실행 (요청을 받을 준비가 됨) + first, - final int port; + /// 이미 실행 중인 창에 넘겼다 (이 실행은 끝낸다) + 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({this.port = 47821, this.gather = const Duration(milliseconds: 700)}); + SingleInstance({required this.dataDir, this.gather = const Duration(milliseconds: 700)}); - /// 첫 번째 실행이면 true (요청을 받을 준비). 이미 실행 중이면 인수를 넘기고 false. + /// 이 데이터 폴더의 잠금 파일 (첫 실행이 앱이 끝날 때까지 잠가 쥔다) + 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 { + /// [forward] 가 false 면 이미 실행 중이어도 넘기지 않는다 (새 재생 창으로 따로 뜰 때). + Future claim(LaunchRequest initial, {bool waitForExit = false, bool forward = true}) async { for (var i = 0;; i++) { - try { - _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, port); - break; - } on SocketException { - if (waitForExit && i < 60) { - await Future.delayed(const Duration(milliseconds: 250)); - continue; - } - if (!forward) return false; - if (await _forward(initial)) return false; - return true; // 다른 프로그램이 포트를 쓰는 경우: 넘기지 못했으므로 그냥 실행 + if (_takeLock()) break; + if (waitForExit && i < 60) { + await Future.delayed(const Duration(milliseconds: 250)); + continue; } + if (!forward) return ClaimResult.running; + // 첫 창이 막 뜨는 중이면 instance.json 이 아직 없을 수 있다 - 잠깐 기다리며 몇 번 + for (var k = 0; k < 10; k++) { + if (await _forward(initial)) return ClaimResult.forwarded; + await Future.delayed(const Duration(milliseconds: 300)); + } + return ClaimResult.unresponsive; } - _server!.listen(_accept); + await _listen(); _add(initial); - return true; + 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('=', ''); } /// 모인 요청을 처리할 함수 (앱 준비가 끝난 뒤 등록) @@ -59,9 +114,10 @@ class SingleInstance { Future _forward(LaunchRequest r) async { try { - final s = await Socket.connect(InternetAddress.loopbackIPv4, port, - timeout: const Duration(seconds: 2)); - s.write('$_magic ${jsonEncode(r.toArgs())}\n'); + final j = jsonDecode(_file.readAsStringSync()) as Map; + final port = j['port'] as int, token = j['token'] as String; + final 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(const Duration(seconds: 3)); await s.close(); @@ -73,8 +129,10 @@ class SingleInstance { void _accept(Socket s) { utf8.decoder.bind(s).transform(const LineSplitter()).first.then((line) { - if (line.startsWith('$_magic ')) { - final args = (jsonDecode(line.substring(_magic.length + 1)) as List).cast(); + 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'); } @@ -100,8 +158,77 @@ class SingleInstance { }); } + /// 시험용: 받는 곳만 닫는다 (잠금 · instance.json 은 그대로 - 응답하지 않는 창) + @visibleForTesting + Future debugStopListening() async => _server?.close(); + + /// 끝낼 때: 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); + } +} + diff --git a/test/player_test.dart b/test/player_test.dart index 4fad970..bfffaf2 100644 --- a/test/player_test.dart +++ b/test/player_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/gestures.dart' show kSecondaryButton; @@ -173,24 +174,106 @@ void main() { }); }); - test('중복 실행 방지: 나중 실행은 넘기고, 짧은 시간의 요청은 하나로', () async { - final first = SingleInstance(port: 47999, gather: const Duration(milliseconds: 300)); - expect(await first.claim(const LaunchRequest(LaunchAction.play, ['a.mp4'])), isTrue); - final got = []; - first.listen(got.add); + group('중복 실행 방지 (228: 데이터 폴더마다 잠금 파일 · 포트 0 · 비밀값)', () { + late Directory d1, d2; + setUp(() { + d1 = Directory.systemTemp.createTempSync('jj_inst1_'); + d2 = Directory.systemTemp.createTempSync('jj_inst2_'); + }); + tearDown(() { + d1.deleteSync(recursive: true); + d2.deleteSync(recursive: true); + }); - // 탐색기가 파일마다 프로그램을 실행한 상황 - for (final f in ['b.mp4', 'c.mp4']) { - expect(await SingleInstance(port: 47999).claim(LaunchRequest(LaunchAction.play, [f])), isFalse); - } - expect(await SingleInstance(port: 47999).claim(const LaunchRequest(LaunchAction.subtitle, ['d.mp4'])), isFalse); - await Future.delayed(const Duration(milliseconds: 600)); - await first.close(); + test('나중 실행은 넘기고, 짧은 시간의 요청은 하나로 · 끝내면 instance.json 을 지운다', () async { + final first = SingleInstance(dataDir: d1.path, gather: const Duration(milliseconds: 300)); + expect(await first.claim(const LaunchRequest(LaunchAction.play, ['a.mp4'])), ClaimResult.first); + final got = []; + first.listen(got.add); + expect(File(p.join(d1.path, 'instance.json')).existsSync(), isTrue); - expect(got, hasLength(2)); - final play = got.firstWhere((r) => r.action == LaunchAction.play); - expect(play.files, ['a.mp4', 'b.mp4', 'c.mp4']); - expect(got.firstWhere((r) => r.action == LaunchAction.subtitle).files, ['d.mp4']); + // 탐색기가 파일마다 프로그램을 실행한 상황 + for (final f in ['b.mp4', 'c.mp4']) { + expect(await SingleInstance(dataDir: d1.path).claim(LaunchRequest(LaunchAction.play, [f])), ClaimResult.forwarded); + } + expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.subtitle, ['d.mp4'])), + ClaimResult.forwarded); + // 새 재생 창으로 따로 뜰 때는 넘기지 않는다 + expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, ['e.mp4']), forward: false), + ClaimResult.running); + await Future.delayed(const Duration(milliseconds: 600)); + await first.close(); + + expect(got, hasLength(2)); + final play = got.firstWhere((r) => r.action == LaunchAction.play); + expect(play.files, ['a.mp4', 'b.mp4', 'c.mp4']); + expect(got.firstWhere((r) => r.action == LaunchAction.subtitle).files, ['d.mp4']); + expect(File(p.join(d1.path, 'instance.json')).existsSync(), isFalse); + // 끝낸 뒤에는 새 실행이 첫 실행 + final again = SingleInstance(dataDir: d1.path); + expect(await again.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); + await again.close(); + }, skip: !Platform.isWindows); + + test('데이터 폴더가 다르면 따로 뜬다 (시험판 · 다른 데이터끼리 넘기지 않음)', () async { + final a = SingleInstance(dataDir: d1.path); + final b = SingleInstance(dataDir: d2.path); + expect(a.lockPath, isNot(b.lockPath)); + expect(await a.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); + expect(await b.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); + await a.close(); + await b.close(); + }, skip: !Platform.isWindows); + + test('비밀값이 틀린 요청은 받지 않는다 (같은 PC 의 다른 프로그램)', () async { + final first = SingleInstance(dataDir: d1.path, gather: const Duration(milliseconds: 100)); + expect(await first.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); + final got = []; + first.listen(got.add); + await Future.delayed(const Duration(milliseconds: 300)); + got.clear(); + final port = (jsonDecode(File(p.join(d1.path, 'instance.json')).readAsStringSync()) as Map)['port'] as int; + for (final line in ['JJMKVMAKER2 wrong ["--play","x.mp4"]', 'JJMKVMAKER1 ["--play","x.mp4"]']) { + final s = await Socket.connect(InternetAddress.loopbackIPv4, port); + s.write('$line\n'); + await s.flush(); + final reply = await utf8.decoder.bind(s).join(); + expect(reply, isNot(startsWith('OK'))); + s.destroy(); + } + await Future.delayed(const Duration(milliseconds: 300)); + expect(got, isEmpty); + await first.close(); + }, skip: !Platform.isWindows); + + test('첫 창이 응답하지 않으면 unresponsive (묻기 - 같은 데이터 위에 창 두 개를 막는다)', () async { + final holder = SingleInstance(dataDir: d1.path); + expect(await holder.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); + // 응답하지 않는 창: 받는 곳을 닫았다 + await holder.debugStopListening(); + expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, [])), + ClaimResult.unresponsive); + await holder.close(); + }, skip: !Platform.isWindows); + + test('잠금을 쥔 다른 프로세스가 죽으면 풀려서 다음 실행이 첫 실행', () async { + // 다른 프로세스가 instance.lock 을 잠근 채 응답하지 않는다 (instance.json 없음) + final lock = p.join(d1.path, 'instance.lock'); + final holder = await Process.start('powershell', [ + '-NoProfile', + '-Command', + "\$f = [IO.File]::Open('$lock', 'OpenOrCreate', 'ReadWrite', 'ReadWrite'); \$f.Lock(0, 1); " + "[Console]::Out.WriteLine('locked'); [Console]::Out.Flush(); Start-Sleep 60", + ]); + await holder.stdout.transform(utf8.decoder).firstWhere((l) => l.contains('locked')); + expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, [])), + ClaimResult.unresponsive); + holder.kill(); + await holder.exitCode; + final next = SingleInstance(dataDir: d1.path); + expect(await next.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); + await next.close(); + }, skip: !Platform.isWindows, timeout: const Timeout(Duration(minutes: 1))); }); test('탐색기 메뉴 등록 · 해제 (테스트 전용 확장자)', () async {