250 lines
10 KiB
Dart
250 lines
10 KiB
Dart
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 = <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 이 아직 없을 수 있다 - 잠깐 기다리며 몇 번.
|
|
// 연결은 되는데 답이 없으면 (멈춘 첫 창) [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<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();
|
|
}
|
|
|
|
/// 연결된 첫 창이 답하기를 기다리는 시간과 횟수 (넘으면 응답 없음 - 사용자에게 묻는다)
|
|
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<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 (이 실행이 쓴 것일 때만) 을 지우고 잠금 파일을 놓는다
|
|
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);
|
|
}
|
|
}
|
|
|