JJ_MKVMaker/lib/platform/windows/desktop_shell.dart

334 lines
12 KiB
Dart

import 'dart:io';
import 'package:flutter/services.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:tray_manager/tray_manager.dart' as tray;
import 'package:screen_retriever/screen_retriever.dart';
import 'package:window_manager/window_manager.dart';
import '../../services/app_shell.dart';
import 'exit_trace.dart';
import 'shell_integration.dart';
import 'tool_installer.dart';
/// Windows 데스크톱: 트레이 아이콘 · 전역 단축키 · 창 닫기 가로채기
class DesktopShell with WindowListener implements AppShell {
tray.TrayIcon? _tray;
HotKey? _hotkey;
late bool Function() _minimizeToTray;
late Future<bool> Function() _onClose;
void Function()? _onDownloads;
bool _closing = false;
@override
Future<void> init({
required String hotkey,
required bool Function() minimizeToTray,
required Future<bool> Function() onCloseRequested,
void Function()? onDownloadsRequested,
}) async {
_minimizeToTray = minimizeToTray;
_onClose = onCloseRequested;
_onDownloads = onDownloadsRequested;
await windowManager.ensureInitialized();
await windowManager.setPreventClose(true);
windowManager.addListener(this);
_initTray();
await hotKeyManager.unregisterAll();
await setHotkey(hotkey);
}
void _initTray() {
final t = tray.TrayIcon.create();
if (t == null) return;
t.icon = tray.ImageAsset.fromAsset('assets/tray_icon.ico') ??
tray.Image.fromFile(Platform.resolvedExecutable);
t.setTooltip('JJ_MKVMaker');
final menu = tray.Menu.create()!;
tray.MenuItem item(String label, void Function() onClick) {
final i = tray.MenuItem.createWithLabelAndType(label, tray.MenuItemType.normal)!;
i.addListener((e) {
if (e is tray.MenuItemClickedEvent) onClick();
});
menu.addItem(i);
return i;
}
item('열기', show);
item('다운로드 목록', () async {
await show();
_onDownloads?.call();
});
menu.addSeparator();
item('종료', () => _requestClose(fromTray: true));
t.setContextMenu(menu);
t.setContextMenuTrigger(tray.ContextMenuTrigger.rightClicked);
t.addListener((e) {
if (e is tray.TrayIconClickedEvent || e is tray.TrayIconDoubleClickedEvent) show();
});
t.setVisible(true);
_tray = t;
}
@override
Future<bool> setHotkey(String text) async {
final parsed = parseHotkey(text);
if (parsed == null) return false;
final (mods, key) = parsed;
final physical = _keyOf(key);
if (physical == null) return false;
final hk = HotKey(
key: physical,
modifiers: [
for (final m in mods)
switch (m) {
'ctrl' => HotKeyModifier.control,
'shift' => HotKeyModifier.shift,
'alt' => HotKeyModifier.alt,
_ => HotKeyModifier.meta,
},
],
scope: HotKeyScope.system,
);
if (_hotkey != null) await hotKeyManager.unregister(_hotkey!);
await hotKeyManager.register(hk, keyDownHandler: (_) => toggle());
_hotkey = hk;
return true;
}
static PhysicalKeyboardKey? _keyOf(String k) {
const letters = [
PhysicalKeyboardKey.keyA, PhysicalKeyboardKey.keyB, PhysicalKeyboardKey.keyC,
PhysicalKeyboardKey.keyD, PhysicalKeyboardKey.keyE, PhysicalKeyboardKey.keyF,
PhysicalKeyboardKey.keyG, PhysicalKeyboardKey.keyH, PhysicalKeyboardKey.keyI,
PhysicalKeyboardKey.keyJ, PhysicalKeyboardKey.keyK, PhysicalKeyboardKey.keyL,
PhysicalKeyboardKey.keyM, PhysicalKeyboardKey.keyN, PhysicalKeyboardKey.keyO,
PhysicalKeyboardKey.keyP, PhysicalKeyboardKey.keyQ, PhysicalKeyboardKey.keyR,
PhysicalKeyboardKey.keyS, PhysicalKeyboardKey.keyT, PhysicalKeyboardKey.keyU,
PhysicalKeyboardKey.keyV, PhysicalKeyboardKey.keyW, PhysicalKeyboardKey.keyX,
PhysicalKeyboardKey.keyY, PhysicalKeyboardKey.keyZ,
];
const digits = [
PhysicalKeyboardKey.digit0, PhysicalKeyboardKey.digit1, PhysicalKeyboardKey.digit2,
PhysicalKeyboardKey.digit3, PhysicalKeyboardKey.digit4, PhysicalKeyboardKey.digit5,
PhysicalKeyboardKey.digit6, PhysicalKeyboardKey.digit7, PhysicalKeyboardKey.digit8,
PhysicalKeyboardKey.digit9,
];
const fkeys = [
PhysicalKeyboardKey.f1, PhysicalKeyboardKey.f2, PhysicalKeyboardKey.f3,
PhysicalKeyboardKey.f4, PhysicalKeyboardKey.f5, PhysicalKeyboardKey.f6,
PhysicalKeyboardKey.f7, PhysicalKeyboardKey.f8, PhysicalKeyboardKey.f9,
PhysicalKeyboardKey.f10, PhysicalKeyboardKey.f11, PhysicalKeyboardKey.f12,
];
if (k.length == 1) {
final c = k.codeUnitAt(0);
if (c >= 65 && c <= 90) return letters[c - 65];
if (c >= 48 && c <= 57) return digits[c - 48];
}
if (k.startsWith('F')) {
final n = int.tryParse(k.substring(1));
if (n != null && n >= 1 && n <= 12) return fkeys[n - 1];
}
return null;
}
@override
Future<void> show() async {
if (await windowManager.isMinimized()) await windowManager.restore();
await windowManager.show();
await windowManager.focus();
}
/// 단축키: 창이 앞에 보이면 트레이로 숨기고, 숨겨져 있거나 다른 창 뒤에 있으면 앞으로 가져온다
Future<void> toggle() async {
final front = await windowManager.isVisible() &&
!await windowManager.isMinimized() &&
await windowManager.isFocused();
if (front && !_closing) {
await hide();
} else {
await show();
}
}
/// 숨긴 창은 작업 표시줄에도 나타나지 않는다.
/// (window_manager 0.5.2 의 setSkipTaskbar 는 Windows 에서 충돌하므로 쓰지 않음)
@override
Future<void> hide() => windowManager.hide();
@override
Future<void> setTooltip(String text) async => _tray?.setTooltip(text);
/// 트레이 아이콘이 있는지 (없으면 창을 숨긴 뒤 끝낼 방법이 없으므로 백그라운드로 보내지 않는다)
bool get hasTray => _tray != null;
/// 지금 처리 중인 종료 요청이 트레이 메뉴 "종료" 에서 왔는지 (항상 완전히 종료)
bool closeFromTray = false;
Future<void> _requestClose({bool fromTray = false}) async {
if (_closing) return;
_closing = true;
closeFromTray = fromTray;
try {
// 트레이에서 종료: 진행 창을 보여 주려고 창을 연다. 창의 ✕ 는 이미 보이는 상태.
if (fromTray) await show();
if (await _onClose()) await quit();
} finally {
_closing = false;
closeFromTray = false;
}
}
@override
void onWindowClose() => _requestClose();
@override
void onWindowMinimize() {
if (_minimizeToTray()) hide();
}
// ───────── 플레이어 창 ─────────
Rect? _beforePopup;
bool _popup = false;
/// 팝업 보기 중 (이때의 작은 창 크기는 기억하지 않는다)
bool get isPopup => _popup;
@override
Future<void> setFullScreen(bool on) async {
if (on && _popup) await setPopup(false);
await windowManager.setFullScreen(on);
}
@override
Future<bool> isFullScreen() => windowManager.isFullScreen();
@override
Future<void> setPopup(bool on) async {
if (on == _popup) return;
if (on) {
if (await windowManager.isFullScreen()) await windowManager.setFullScreen(false);
_beforePopup = await windowManager.getBounds();
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
await windowManager.setAlwaysOnTop(true);
final area = await _workArea();
const size = Size(480, 270);
await windowManager.setBounds(Rect.fromLTWH(
area.right - size.width - 24, area.bottom - size.height - 24, size.width, size.height));
} else {
await windowManager.setAlwaysOnTop(false);
await windowManager.setTitleBarStyle(TitleBarStyle.normal);
if (_beforePopup != null) await windowManager.setBounds(_beforePopup!);
}
_popup = on;
}
Future<Rect> _workArea() async {
final d = await screenRetriever.getPrimaryDisplay();
final pos = d.visiblePosition ?? Offset.zero;
final size = d.visibleSize ?? d.size;
return pos & size;
}
@override
Future<void> fitVideo(int width, int height, double factor) async {
if (width <= 0 || height <= 0) return;
if (await windowManager.isFullScreen()) await windowManager.setFullScreen(false);
if (_popup) await setPopup(false);
final area = await _workArea();
// 조작 막대 · 제목 표시줄 여유
const extraH = 120.0, extraW = 16.0;
var w = width * factor, h = height * factor;
final scale = [1.0, (area.width - extraW) / w, (area.height - extraH) / h].reduce((a, b) => a < b ? a : b);
w *= scale;
h *= scale;
await windowManager.setSize(Size(w + extraW, h + extraH));
await windowManager.center();
}
// ───────── 탐색기 · 외부 프로그램 ─────────
final _shellMenu = ShellIntegration();
@override
Future<bool> isContextMenuRegistered() => _shellMenu.isRegistered();
@override
Future<bool> registerContextMenu() => _shellMenu.register();
@override
Future<void> unregisterContextMenu() => _shellMenu.unregister();
@override
Future<void> openExternal(String program, List<String> files) => openExternally(program, files);
@override
String? get vlcPath => findVlc();
@override
Future<void> openUrl(String url, {String browser = 'system'}) => openInBrowser(url, browser);
/// 탐색기로 폴더를 열고 파일을 선택. 파일이 없으면 폴더만 (그것도 없으면 가장 가까운 위 폴더).
@override
Future<void> revealFile(String path) async {
if (File(path).existsSync()) {
await Process.start('explorer.exe', ['/select,', path], mode: ProcessStartMode.detached);
return;
}
var dir = Directory(path).existsSync() ? Directory(path) : File(path).parent;
while (!dir.existsSync() && dir.parent.path != dir.path) {
dir = dir.parent;
}
await Process.start('explorer.exe', [dir.path], mode: ProcessStartMode.detached);
}
@override
List<String> installedBrowsers() => [
for (final b in browserExecutables.keys)
if (findBrowser(b) != null) b,
];
// ───────── 필수 프로그램 ─────────
final _installer = ToolInstaller();
@override
Future<List<String>> missingTools() async => [
for (final t in await _installer.missing()) '${t.name} - ${t.purpose} (약 ${t.sizeMb}MB)',
];
@override
Future<void> installMissingTools(void Function(String name, double progress) onProgress) async =>
_installer.install(await _installer.missing(), onProgress);
/// 새 프로세스는 --restart 로 시작해 이 창이 닫힐 때까지 기다린다
@override
Future<void> restart() async {
await Process.start(Platform.resolvedExecutable, ['--restart'], mode: ProcessStartMode.detached);
await quit();
}
@override
Future<void> quit() async {
ExitTrace.mark('quit 시작');
// 단축키 해제가 늦어도 종료를 붙잡지 않게 (프로세스가 끝나면 어차피 풀린다)
try {
await hotKeyManager.unregisterAll().timeout(const Duration(milliseconds: 500));
} catch (_) {}
ExitTrace.mark('단축키 해제');
_tray?.setVisible(false);
_tray?.dispose();
ExitTrace.mark('트레이 정리');
windowManager.removeListener(this);
// 창을 숨기고 바로 끝낸다. 엔진 · 웹뷰 · 플레이어를 차례로 닫는 정상 종료는 몇 초씩 걸리는데,
// 저장할 것은 이미 모두 저장했고 다운로드 · 변환 프로세스도 앞 단계에서 끝냈다.
try {
await windowManager.hide().timeout(const Duration(milliseconds: 500));
} catch (_) {}
ExitTrace.mark('창 숨김 → 프로세스 종료');
// exit() 는 Dart VM · 엔진 · 플러그인 DLL 을 차례로 정리하느라 1~2초 걸린다 → 프로세스를 바로 끝낸다
Process.killPid(pid);
exit(0);
}
}