From e82c34a0b83dabcae2abdd224420f143220acb08 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:27:57 +0900 Subject: [PATCH] =?UTF-8?q?M4=203=EB=AC=B6=EC=9D=8C:=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20(StartMenu)=20=EC=9D=98=20static=20?= =?UTF-8?q?=EC=85=8B=EC=9D=84=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=20?= =?UTF-8?q?=C2=B7=20PlatformServices.startMenu=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20-=20=EC=8B=9C=ED=97=98=EC=9D=B4=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=8B=9C=EC=9E=91=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=EB=A5=BC=20=EA=B1=B4=EB=93=9C=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StartMenu 는 run · userAreaLocked · shortcutPath 를 가진 인스턴스. StartMenu.user = 사용자 시작 메뉴 · HKCU (앱 조립 create 가 넘김), StartMenu.locked(dir) = 늘 막힘 · 바로 가기 자리는 dir 아래 (PlatformServices 를 손으로 조립할 때의 처음 값). 예전 static run · userAreaLocked · shortcutPathOverride 를 바꾸던 것을 대신 - main · 설정 [시작 메뉴에 등록] 은 c.services.startMenu 를 부른다 - 시험: start_menu_test 는 가짜 run · 시험 폴더 바로 가기를 넘긴 인스턴스로 (사용자 영역 0), 손 조립 처음 값이 막혀 있고 사용자 시작 메뉴가 아님을 새로 봄 (2회 통과). start_menu_real_test (실제 사용자 시작 메뉴) 는 JJ_TEST_LAUNCHER 가 있을 때만 - 예전과 같음 - 사용자가 보게 될 동작: 없음 Co-Authored-By: Claude Opus 5.5 --- integration_test/start_menu_real_test.dart | 8 +-- integration_test/support/shot_services.dart | 1 + lib/main.dart | 7 +-- lib/platform/windows/start_menu.dart | 34 ++++++++----- lib/services/platform_services.dart | 8 +++ lib/ui/settings/app_sections.dart | 7 ++- test/start_menu_test.dart | 55 ++++++++++++--------- 7 files changed, 74 insertions(+), 46 deletions(-) diff --git a/integration_test/start_menu_real_test.dart b/integration_test/start_menu_real_test.dart index b164303..365629f 100644 --- a/integration_test/start_menu_real_test.dart +++ b/integration_test/start_menu_real_test.dart @@ -16,10 +16,10 @@ void main() { Future ps(String cmd) async => '${(await Process.run('powershell.exe', ['-NoProfile', '-Command', cmd])).stdout}'.trim(); testWidgets('시작 메뉴 바로 가기 (AUMID) · jjmkvmaker:// · JJ_MKVMaker 이름의 알림 · 폴더를 옮기면 고침 · 정리', (t) async { - final lnk = StartMenu.shortcutPath; + final lnk = StartMenu.user.shortcutPath; expect(File(lnk).existsSync(), isFalse, reason: '시험 전에는 없어야 한다'); try { - expect(await StartMenu.ensure(exe: launcher), isTrue); + expect(await StartMenu.user.ensure(exe: launcher), isTrue); expect(File(lnk).existsSync(), isTrue); final dir = p.dirname(lnk), name = p.basename(lnk); expect(await ps("(New-Object -ComObject Shell.Application).Namespace('$dir').ParseName('$name').ExtendedProperty('System.AppUserModel.ID')"), @@ -39,10 +39,10 @@ void main() { } // 앱 폴더를 옮긴 경우: 다음 실행 때 새 위치로 고친다 final moved = p.join(p.dirname(launcher), 'Lib', p.basename(launcher)); - expect(await StartMenu.ensure(exe: moved), isTrue); + expect(await StartMenu.user.ensure(exe: moved), isTrue); expect((await ps("(New-Object -ComObject WScript.Shell).CreateShortcut('$lnk').TargetPath")).toLowerCase(), moved.toLowerCase()); } finally { - await StartMenu.remove(); + await StartMenu.user.remove(); } expect(File(lnk).existsSync(), isFalse); expect(await ps(r"Test-Path 'HKCU:\Software\Classes\jjmkvmaker'"), 'False'); diff --git a/integration_test/support/shot_services.dart b/integration_test/support/shot_services.dart index c42f1b9..6938f90 100644 --- a/integration_test/support/shot_services.dart +++ b/integration_test/support/shot_services.dart @@ -33,6 +33,7 @@ PlatformServices shotServices() { privileged: r.privileged, isMetered: r.isMetered, keepScreenOn: r.keepScreenOn, + startMenu: r.startMenu, updater: r.updater, usage: r.usage == null ? null : const FixedUsage(), models: r.models, diff --git a/lib/main.dart b/lib/main.dart index 946c115..5158b6a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -141,15 +141,16 @@ Future main(List args) async { // 99: 시작 메뉴 바로 가기 (앱 ID) · jjmkvmaker:// 연결 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다 (설정에서 끔) StartMenu.setProcessAppId(); final shell0 = services.shell; - if (StartMenu.userAreaLocked()) { + final startMenu = services.startMenu; + if (startMenu.userAreaLocked()) { // 관리자 지시 (10/20): 시험판 · 시험 (따로 데이터) - 사용자 바로 가기 · 주소 연결은 그대로 (StartMenu 가 막음), 작업 기록에 한 줄 controller.note(tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다')); } else if (controller.settings.run.startMenuShortcut) { - unawaited(StartMenu.ensure().then((ok) { + unawaited(startMenu.ensure().then((ok) { if (shell0 is DesktopShell) shell0.toastAppReady = ok; })); } else { - unawaited(StartMenu.remove()); + unawaited(startMenu.remove()); } // 제목 표시줄에 버전 · 지난번 창 위치에서 열기 · 동영상 목록 불러오기 (창끼리 공유) await windowManager.setTitle(appTitle); diff --git a/lib/platform/windows/start_menu.dart b/lib/platform/windows/start_menu.dart index 9d0cfae..906ff24 100644 --- a/lib/platform/windows/start_menu.dart +++ b/lib/platform/windows/start_menu.dart @@ -14,20 +14,30 @@ class StartMenu { static const aumid = 'JJ.MKVMaker'; static const protocol = 'jjmkvmaker'; + /// 설계 M4: 바로 가기 · 주소 연결을 바꾸는 쪽은 인스턴스 ([user] = 이 기기의 사용자 영역). 시험은 가짜 [run] · 시험 폴더의 [shortcutPath] 를 + /// 넘긴 인스턴스를 만든다 (예전 static run · userAreaLocked · shortcutPathOverride 를 바꾸던 것 - 사용자 영역을 건드리지 않고) + StartMenu({Future Function(String exe, List args)? run, bool Function()? userAreaLocked, String? shortcutPath}) + : run = run ?? Process.run, + userAreaLocked = userAreaLocked ?? (() => dataDirOverride != null), + shortcutPath = shortcutPath ?? + p.join(Platform.environment['APPDATA'] ?? '', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'JJ_MKVMaker.lnk'); + + /// 이 기기의 사용자 시작 메뉴 · HKCU (앱 조립 PlatformServices.create 가 넘김) + static final user = StartMenu(); + + /// 사용자 영역 (시작 메뉴 · HKCU) 을 건드리지 않는 것 - 바로 가기 자리는 [dir] 아래, 늘 막힘. PlatformServices 를 손으로 조립할 때의 처음 값 + factory StartMenu.locked(String dir) => StartMenu(userAreaLocked: () => true, shortcutPath: p.join(dir, 'JJ_MKVMaker.lnk')); + /// 관리자 지시 (10/20, 15 찾음): 시험판 · 시험 (따로 데이터 JJ_MKVMAKER_DATA) 이면 사용자의 시작 메뉴 바로 가기 · /// jjmkvmaker:// 를 자기 쪽으로 바꾸거나 지우지 않는다. 예전에는 시험 폴더의 판을 켜면 사용자 바로 가기가 그 판을 가리키고, - /// 시험 폴더를 지우면 시작 메뉴 · 알림 누르기가 깨졌다. 시험이 바꿀 수 있게 함수로 둔다 - static bool Function() userAreaLocked = () => dataDirOverride != null; + /// 시험 폴더를 지우면 시작 메뉴 · 알림 누르기가 깨졌다 + final bool Function() userAreaLocked; - /// 바로 가기 · 레지스트리를 바꾸는 명령 실행 (시험이 가짜로 바꿔 무엇을 부르는지 본다 - 사용자 영역을 건드리지 않고) - static Future Function(String exe, List args) run = Process.run; + /// 바로 가기 · 레지스트리를 바꾸는 명령 실행 + final Future Function(String exe, List args) run; - /// 시험용: 바로 가기 파일 자리 (null = 사용자의 시작 메뉴). 시험이 잘못돼도 사용자 바로 가기를 지우지 않게 - static String? shortcutPathOverride; - - /// 바로 가기 파일 (사용자의 시작 메뉴) - static String get shortcutPath => shortcutPathOverride ?? p.join(Platform.environment['APPDATA'] ?? '', 'Microsoft', 'Windows', 'Start Menu', - 'Programs', 'JJ_MKVMaker.lnk'); + /// 바로 가기 파일 (처음 값 = 사용자의 시작 메뉴) + final String shortcutPath; /// 이 프로세스의 앱 ID (작업 표시줄 · 알림이 바로 가기와 같은 앱으로 묶이게) static void setProcessAppId() { @@ -46,7 +56,7 @@ class StartMenu { /// 바로 가기 · 주소 연결을 만든다 (있고 이 실행 파일을 가리키면 그대로, 앱 폴더를 옮겼으면 고친다). 됐으면 true /// [lnk] · [registerProtocol]: 시험용 (사용자의 시작 메뉴 · 레지스트리를 건드리지 않고) - static Future ensure({String? exe, String? lnk, bool registerProtocol = true}) async { + Future ensure({String? exe, String? lnk, bool registerProtocol = true}) async { if (!Platform.isWindows) return false; // 사용자 영역 (시작 메뉴 바로 가기 · HKCU 주소 연결) 을 건드리는 부름만 막는다 - 시험이 따로 준 바로 가기 파일만 쓰면 그대로 if ((lnk == null || registerProtocol) && userAreaLocked()) return false; @@ -75,7 +85,7 @@ class StartMenu { } /// 설정에서 끄면: 바로 가기 · 주소 연결을 지운다 (이 앱이 만든 것만) - static Future remove() async { + Future remove() async { if (!Platform.isWindows || userAreaLocked()) return; try { final f = File(shortcutPath); diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index 5230649..c015d24 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -21,6 +21,7 @@ import '../platform/android/ffmpeg_kit_media_tool.dart'; import '../platform/windows/aria2_backend.dart'; import '../platform/windows/desktop_shell.dart'; import '../platform/windows/desktop_storage_service.dart'; +import '../platform/windows/start_menu.dart'; import '../platform/windows/github_updater.dart'; import '../platform/windows/process_media_tool.dart'; import '../platform/windows/windows_usage.dart'; @@ -95,6 +96,10 @@ class PlatformServices { /// 화면을 켜 둔다 / 놓는다 (보기 화면의 자동 넘기기 - 설계 M4 WakeLock). 처음 값 = WakelockPlus final Future Function(bool on) keepScreenOn; + /// 시작 메뉴 바로 가기 · jjmkvmaker:// 연결 (Windows, 설계 M4). 앱 조립은 [StartMenu.user] (사용자 영역), + /// 손으로 조립하면 처음 값은 [StartMenu.locked] (사용자 시작 메뉴 · HKCU 를 건드리지 않음 - 시험이 실제 바로 가기를 만들거나 지우지 않게) + final StartMenu startMenu; + /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기) final PlatformInfo platform; @@ -123,10 +128,12 @@ class PlatformServices { this.privileged, Future Function()? isMetered, Future Function(bool on)? keepScreenOn, + StartMenu? startMenu, }) : models = models ?? ModelStore(), vault = vault ?? VaultLocation.under(storage.tempDirectory), isMetered = isMetered ?? deviceMetered, keepScreenOn = keepScreenOn ?? _wakeLock, + startMenu = startMenu ?? StartMenu.locked(Directory.systemTemp.path), platform = platform ?? PlatformInfo.host, shell = shell ?? NoopShell(); @@ -137,6 +144,7 @@ class PlatformServices { final ffmpegDir = File(tool.ffmpeg).existsSync() ? p.dirname(tool.ffmpeg) : null; return PlatformServices( vault: VaultLocation.device, + startMenu: StartMenu.user, mediaTool: tool, storage: DesktopStorageService(), createPlayer: MediaKitPreviewPlayer.new, diff --git a/lib/ui/settings/app_sections.dart b/lib/ui/settings/app_sections.dart index fa5159b..23e434d 100644 --- a/lib/ui/settings/app_sections.dart +++ b/lib/ui/settings/app_sections.dart @@ -5,7 +5,6 @@ import '../../services/app_shell.dart'; import '../setup_dialog.dart'; import '../update_dialog.dart'; import '../../platform/windows/desktop_shell.dart' show DesktopShell; -import '../../platform/windows/start_menu.dart'; import '../app_actions.dart'; import '../cleanup_dialog.dart'; import '../theme.dart'; @@ -64,17 +63,17 @@ class _RunSettingsSectionState extends State { onChanged: (v) async { await c.updateSettings((x) => x.run.startMenuShortcut = v); if (v) { - final ok = await StartMenu.ensure(); + final ok = await c.services.startMenu.ensure(); final sh = c.services.shell; if (sh is DesktopShell) sh.toastAppReady = ok; } else { - await StartMenu.remove(); + await c.services.startMenu.remove(); final sh = c.services.shell; if (sh is DesktopShell) sh.toastAppReady = false; } }, title: Text(tr('시작 메뉴에 등록')), - subtitle: Text(StartMenu.userAreaLocked() + subtitle: Text(c.services.startMenu.userAreaLocked() ? tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다') : tr('알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)')), ), diff --git a/test/start_menu_test.dart b/test/start_menu_test.dart index dc7d0a3..74db726 100644 --- a/test/start_menu_test.dart +++ b/test/start_menu_test.dart @@ -4,6 +4,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/playlist.dart'; import 'package:jj_mkvmaker/platform/windows/start_menu.dart'; import 'package:path/path.dart' as p; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; /// 99: 시작 메뉴 바로 가기 (앱 ID 포함) · 알림을 누르면 jjmkvmaker://lsync 로 실시간 동기화 화면 /// (시험은 임시 폴더에 만든다 - 사용자의 시작 메뉴 · 레지스트리는 건드리지 않음) @@ -13,7 +16,7 @@ void main() { addTearDown(() => tmp.deleteSync(recursive: true)); final lnk = p.join(tmp.path, 'JJ_MKVMaker.lnk'); final exe1 = Platform.resolvedExecutable; - expect(await StartMenu.ensure(exe: exe1, lnk: lnk, registerProtocol: false), isTrue); + expect(await StartMenu.user.ensure(exe: exe1, lnk: lnk, registerProtocol: false), isTrue); expect(File(lnk).existsSync(), isTrue); Future read(String what) async => '${(await Process.run('powershell.exe', [ '-NoProfile', @@ -27,7 +30,7 @@ void main() { expect((await read('target')).toLowerCase(), exe1.toLowerCase()); // 앱 폴더를 옮긴 경우: 다음 실행 때 새 위치로 final exe2 = p.join(Platform.environment['SystemRoot'] ?? r'C:\Windows', 'notepad.exe'); - expect(await StartMenu.ensure(exe: exe2, lnk: lnk, registerProtocol: false), isTrue); + expect(await StartMenu.user.ensure(exe: exe2, lnk: lnk, registerProtocol: false), isTrue); expect((await read('target')).toLowerCase(), exe2.toLowerCase()); expect(await read('id'), StartMenu.aumid); }, skip: !Platform.isWindows, timeout: const Timeout(Duration(minutes: 2))); @@ -35,36 +38,33 @@ void main() { test('관리자 지시 (10/20): 시험판 · 시험 (따로 데이터) 이면 사용자 시작 메뉴 · jjmkvmaker:// 를 바꾸거나 지우지 않는다', () async { // 명령은 가짜로만 (사용자 영역을 건드리지 않음) - 무엇을 부르는지만 본다 final calls = []; - final runBefore = StartMenu.run, lockedBefore = StartMenu.userAreaLocked; final tmp = Directory.systemTemp.createTempSync('jj_lnk_'); - addTearDown(() { - StartMenu.run = runBefore; - StartMenu.userAreaLocked = lockedBefore; - StartMenu.shortcutPathOverride = null; - tmp.deleteSync(recursive: true); - }); - // 바로 가기 파일도 시험 폴더로 (시험이 잘못돼도 사용자 시작 메뉴의 것을 지우지 않게) + addTearDown(() => tmp.deleteSync(recursive: true)); + // 바로 가기 파일도 시험 폴더로 (시험이 잘못돼도 사용자 시작 메뉴의 것을 지우지 않게) - 설계 M4: 가짜를 넘긴 인스턴스 final lnk = p.join(tmp.path, 'JJ_MKVMaker.lnk'); - StartMenu.shortcutPathOverride = lnk; File(lnk).writeAsStringSync('x'); - StartMenu.run = (exe, args) async { - calls.add(exe); - return ProcessResult(0, 0, 'JJ_OK', ''); - }; - StartMenu.userAreaLocked = () => true; - expect(await StartMenu.ensure(), isFalse, reason: '사용자 바로 가기 · 주소 연결'); - expect(await StartMenu.ensure(lnk: lnk), isFalse, reason: '주소 연결 (HKCU) 은 사용자 영역'); - await StartMenu.remove(); + var locked = true; + final menu = StartMenu( + shortcutPath: lnk, + userAreaLocked: () => locked, + run: (exe, args) async { + calls.add(exe); + return ProcessResult(0, 0, 'JJ_OK', ''); + }, + ); + expect(await menu.ensure(), isFalse, reason: '사용자 바로 가기 · 주소 연결'); + expect(await menu.ensure(lnk: lnk), isFalse, reason: '주소 연결 (HKCU) 은 사용자 영역'); + await menu.remove(); expect(calls, isEmpty, reason: '막힌 동안은 아무 명령도 부르지 않음'); expect(File(lnk).existsSync(), isTrue, reason: '바로 가기를 지우지 않음'); // 시험이 따로 준 바로 가기 파일만 쓰는 것은 그대로 - expect(await StartMenu.ensure(exe: Platform.resolvedExecutable, lnk: lnk, registerProtocol: false), isTrue); + expect(await menu.ensure(exe: Platform.resolvedExecutable, lnk: lnk, registerProtocol: false), isTrue); expect(calls, ['powershell.exe']); // 막지 않으면 (사용자 판) 예전처럼 부른다 - StartMenu.userAreaLocked = () => false; + locked = false; calls.clear(); - expect(await StartMenu.ensure(), isTrue); - await StartMenu.remove(); + expect(await menu.ensure(), isTrue); + await menu.remove(); expect(calls, ['powershell.exe', 'reg.exe']); expect(File(lnk).existsSync(), isFalse, reason: '사용자 판에서 끄면 지움 (여기서는 시험 폴더의 것)'); }, skip: !Platform.isWindows); @@ -76,4 +76,13 @@ void main() { expect(LaunchRequest.parse(LaunchRequest(LaunchAction.lsync, const []).toArgs()).action, LaunchAction.lsync); expect(LaunchRequest.parse(['jjmkvmaker://other']).files, isEmpty); // 모르는 주소를 파일로 열지 않음 }); + + test('설계 M4: PlatformServices 를 손으로 조립하면 시작 메뉴는 사용자 영역을 건드리지 않는 것 (늘 막힘 · 바로 가기 자리는 임시 폴더)', () async { + final s = PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()); + final userLnk = p.join(Platform.environment['APPDATA'] ?? '', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'JJ_MKVMaker.lnk'); + expect(s.startMenu.userAreaLocked(), isTrue); + expect(p.equals(s.startMenu.shortcutPath, userLnk), isFalse, reason: '사용자 시작 메뉴가 아님'); + expect(p.isWithin(Directory.systemTemp.path, s.startMenu.shortcutPath), isTrue); + expect(await s.startMenu.ensure(), isFalse, reason: '막힘 - 아무것도 만들지 않음'); + }); }