JJ_MKVMaker/lib/platform/windows/single_instance.dart

235 lines
9.6 KiB
Dart

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) 으로 꺼지기도 한다 - 특히 막 뜬 두 번째 실행이 바로 끝날 때
/// (재현: 앱이 켜져 있을 때 한 번 더 실행 → APPCRASH). 저장할 것이 없는 경우에만 쓴다.
Never endProcessNow() {
if (Platform.isWindows) Process.killPid(pid);
exit(0);
}
/// [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 = <LaunchAction, List<String>>{};
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<ClaimResult> claim(LaunchRequest initial, {bool waitForExit = false, bool forward = true}) async {
for (var i = 0;; i++) {
if (_takeLock()) break;
if (waitForExit && i < 60) {
await Future<void>.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<void>.delayed(const Duration(milliseconds: 300));
}
return ClaimResult.unresponsive;
}
await _listen();
_add(initial);
return ClaimResult.first;
}
/// 응답하지 않는 첫 창이 있어도 이 실행이 그대로 뜰 때 (사용자가 [그래도 열기]): 요청은 이 창이 받는다
Future<void> takeOverAnyway(LaunchRequest initial) async {
await _listen();
_add(initial);
}
Future<void> _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();
}
Future<bool> _forward(LaunchRequest r) async {
try {
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();
return reply.startsWith('OK');
} catch (_) {
return false;
}
}
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<String>();
_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 은 그대로 - 응답하지 않는 창)
@visibleForTesting
Future<void> debugStopListening() async => _server?.close();
/// 끝낼 때: instance.json (이 실행이 쓴 것일 때만) 을 지우고 잠금 파일을 놓는다
Future<void> 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<Int32 Function(IntPtr, Pointer<Utf16>, Pointer<Utf16>, Uint32),
int Function(int, Pointer<Utf16>, Pointer<Utf16>, int)>('MessageBoxW');
return box(0, text, caption, mbYesNo | mbIconWarning | mbDefButton2) == idYes;
} catch (_) {
return false;
} finally {
calloc.free(text);
calloc.free(caption);
}
}