From 121dd4350bcdaee1f26f67b971bcec8683a69c18 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:03:12 +0900 Subject: [PATCH 01/20] =?UTF-8?q?=EC=8B=9C=ED=97=98=ED=8C=90=20=C2=B7=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20(=EB=94=B0=EB=A1=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0)=20=EC=9D=B4=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EB=A9=94=EB=89=B4=20=EB=B0=94=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=C2=B7=20jjmkvmaker://=20=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B1=B0=EB=82=98=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20(=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A7=80=EC=8B=9C=2010/20,=2015=20=EC=B0=BE=EC=9D=8C,=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인: 앱은 켤 때마다 StartMenu.ensure() (설정을 끄면 remove()) 로 사용자 시작 메뉴의 JJ_MKVMaker.lnk 와 HKCU\Software\Classes\jjmkvmaker 를 지금 실행 파일로 바꾼다. JJ_MKVMAKER_DATA (시험판 · 시험의 따로 데이터) 로 켜도 그랬다 - 시험 폴더의 판을 켜면 사용자 바로 가기 · 알림 누르기가 그 판을 가리키고, 시험 폴더를 지우면 깨졌다 (10.18_001 도 같음) - "따로 데이터" 는 코드에서 JJ_MKVMAKER_DATA 하나뿐 (시험판 표시 파일 없음) - 그때 StartMenu 가 사용자 영역을 건드리는 ensure (바로 가기 · 주소 연결) · remove 를 막음. 시험이 따로 준 바로 가기 파일만 쓰는 부름은 그대로. 켤 때 작업 기록에 한 줄, 환경 설정의 [시작 메뉴에 등록] 아래 글도 그 사실로 (en · ja · zh) - 사용자 판 (JJ_MKVMAKER_DATA 없음) 동작은 그대로 - 시험: 명령 실행을 가짜로 바꿔 (StartMenu.run) 막힌 동안 아무것도 부르지 않음 · 바로 가기 파일도 시험 폴더로 (shortcutPathOverride - 시험이 잘못돼도 사용자 것을 지우지 않게). 시험 전후 사용자 바로 가기 · 주소 연결이 그대로인지 확인함 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 1 + assets/l10n/ja.json | 1 + assets/l10n/zh-Hans.json | 1 + lib/main.dart | 5 +++- lib/platform/windows/start_menu.dart | 23 ++++++++++++++--- lib/ui/settings_page.dart | 4 ++- test/start_menu_test.dart | 37 ++++++++++++++++++++++++++++ 7 files changed, 66 insertions(+), 6 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index b84387f..e685d2f 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1691,6 +1691,7 @@ "시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)", "시작할 때마다 설정 창 보기": "Show the setup window at every start", "시즌": "Season", + "시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "Started with a test data folder (JJ_MKVMAKER_DATA), so the Start menu shortcut and the jjmkvmaker:// link are left unchanged", "시험판": "Pre-release", "실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)", "실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 68d1cff..56e525f 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1691,6 +1691,7 @@ "시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)", "시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示", "시즌": "シーズン", + "시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "テスト用データフォルダー (JJ_MKVMAKER_DATA) で起動したため、スタートメニューのショートカットと jjmkvmaker:// の関連付けは変更しません", "시험판": "プレリリース", "실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)", "실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 299f513..c027a46 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1691,6 +1691,7 @@ "시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)", "시작할 때마다 설정 창 보기": "每次启动时显示设置窗口", "시즌": "季", + "시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "以测试数据文件夹 (JJ_MKVMAKER_DATA) 启动,因此不更改开始菜单快捷方式和 jjmkvmaker:// 关联", "시험판": "预发布", "실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)", "실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}", diff --git a/lib/main.dart b/lib/main.dart index 14812a1..311219f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -262,7 +262,10 @@ Future main(List args) async { // 99: 시작 메뉴 바로 가기 (앱 ID) · jjmkvmaker:// 연결 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다 (설정에서 끔) StartMenu.setProcessAppId(); final shell0 = services.shell; - if (controller.settings.startMenuShortcut) { + if (StartMenu.userAreaLocked()) { + // 관리자 지시 (10/20): 시험판 · 시험 (따로 데이터) - 사용자 바로 가기 · 주소 연결은 그대로 (StartMenu 가 막음), 작업 기록에 한 줄 + controller.note(tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다')); + } else if (controller.settings.startMenuShortcut) { unawaited(StartMenu.ensure().then((ok) { if (shell0 is DesktopShell) shell0.toastAppReady = ok; })); diff --git a/lib/platform/windows/start_menu.dart b/lib/platform/windows/start_menu.dart index 4927c8c..9d0cfae 100644 --- a/lib/platform/windows/start_menu.dart +++ b/lib/platform/windows/start_menu.dart @@ -5,6 +5,8 @@ import 'dart:io'; import 'package:ffi/ffi.dart'; import 'package:path/path.dart' as p; +import 'data_dir_override.dart'; + /// 99 (사용자 결정): 지금 사용자의 시작 메뉴에 JJ_MKVMaker 바로 가기를 앱 ID (AUMID) 와 함께 만들고, /// jjmkvmaker:// 주소를 이 앱으로 연결한다 (관리자 권한 없이 - 사용자 영역만). /// 그래야 Windows 알림의 출처가 "JJ_MKVMaker" 로 보이고, 알림을 누르면 앱이 열린다 (jjmkvmaker://lsync). @@ -12,8 +14,19 @@ class StartMenu { static const aumid = 'JJ.MKVMaker'; static const protocol = 'jjmkvmaker'; + /// 관리자 지시 (10/20, 15 찾음): 시험판 · 시험 (따로 데이터 JJ_MKVMAKER_DATA) 이면 사용자의 시작 메뉴 바로 가기 · + /// jjmkvmaker:// 를 자기 쪽으로 바꾸거나 지우지 않는다. 예전에는 시험 폴더의 판을 켜면 사용자 바로 가기가 그 판을 가리키고, + /// 시험 폴더를 지우면 시작 메뉴 · 알림 누르기가 깨졌다. 시험이 바꿀 수 있게 함수로 둔다 + static bool Function() userAreaLocked = () => dataDirOverride != null; + + /// 바로 가기 · 레지스트리를 바꾸는 명령 실행 (시험이 가짜로 바꿔 무엇을 부르는지 본다 - 사용자 영역을 건드리지 않고) + static Future Function(String exe, List args) run = Process.run; + + /// 시험용: 바로 가기 파일 자리 (null = 사용자의 시작 메뉴). 시험이 잘못돼도 사용자 바로 가기를 지우지 않게 + static String? shortcutPathOverride; + /// 바로 가기 파일 (사용자의 시작 메뉴) - static String get shortcutPath => p.join(Platform.environment['APPDATA'] ?? '', 'Microsoft', 'Windows', 'Start Menu', + static String get shortcutPath => shortcutPathOverride ?? p.join(Platform.environment['APPDATA'] ?? '', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'JJ_MKVMaker.lnk'); /// 이 프로세스의 앱 ID (작업 표시줄 · 알림이 바로 가기와 같은 앱으로 묶이게) @@ -35,10 +48,12 @@ class StartMenu { /// [lnk] · [registerProtocol]: 시험용 (사용자의 시작 메뉴 · 레지스트리를 건드리지 않고) static Future ensure({String? exe, String? lnk, bool registerProtocol = true}) async { if (!Platform.isWindows) return false; + // 사용자 영역 (시작 메뉴 바로 가기 · HKCU 주소 연결) 을 건드리는 부름만 막는다 - 시험이 따로 준 바로 가기 파일만 쓰면 그대로 + if ((lnk == null || registerProtocol) && userAreaLocked()) return false; final target = exe ?? appLauncher(); final script = _script(lnk ?? shortcutPath, target, registerProtocol: registerProtocol); try { - final r = await Process.run( + final r = await run( 'powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', _encode(script)], ).timeout(const Duration(seconds: 60)); @@ -61,11 +76,11 @@ class StartMenu { /// 설정에서 끄면: 바로 가기 · 주소 연결을 지운다 (이 앱이 만든 것만) static Future remove() async { - if (!Platform.isWindows) return; + if (!Platform.isWindows || userAreaLocked()) return; try { final f = File(shortcutPath); if (await f.exists()) await f.delete(); - await Process.run('reg.exe', ['delete', r'HKCU\Software\Classes\' + protocol, '/f']); + await run('reg.exe', ['delete', r'HKCU\Software\Classes\' + protocol, '/f']); } catch (_) {} } diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 676b9d5..88cbe31 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -1141,7 +1141,9 @@ class _SettingsPageState extends State { } }, title: Text(tr('시작 메뉴에 등록')), - subtitle: Text(tr('알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)')), + subtitle: Text(StartMenu.userAreaLocked() + ? tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다') + : tr('알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)')), ), SettingTile( title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')), diff --git a/test/start_menu_test.dart b/test/start_menu_test.dart index 0c3cafe..dc7d0a3 100644 --- a/test/start_menu_test.dart +++ b/test/start_menu_test.dart @@ -32,6 +32,43 @@ void main() { expect(await read('id'), StartMenu.aumid); }, skip: !Platform.isWindows, timeout: const Timeout(Duration(minutes: 2))); + 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); + }); + // 바로 가기 파일도 시험 폴더로 (시험이 잘못돼도 사용자 시작 메뉴의 것을 지우지 않게) + 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(); + expect(calls, isEmpty, reason: '막힌 동안은 아무 명령도 부르지 않음'); + expect(File(lnk).existsSync(), isTrue, reason: '바로 가기를 지우지 않음'); + // 시험이 따로 준 바로 가기 파일만 쓰는 것은 그대로 + expect(await StartMenu.ensure(exe: Platform.resolvedExecutable, lnk: lnk, registerProtocol: false), isTrue); + expect(calls, ['powershell.exe']); + // 막지 않으면 (사용자 판) 예전처럼 부른다 + StartMenu.userAreaLocked = () => false; + calls.clear(); + expect(await StartMenu.ensure(), isTrue); + await StartMenu.remove(); + expect(calls, ['powershell.exe', 'reg.exe']); + expect(File(lnk).existsSync(), isFalse, reason: '사용자 판에서 끄면 지움 (여기서는 시험 폴더의 것)'); + }, skip: !Platform.isWindows); + test('알림을 누르면 오는 주소 (jjmkvmaker://lsync) 는 실시간 동기화 화면 요청', () { expect(LaunchRequest.parse(['jjmkvmaker://lsync/']).action, LaunchAction.lsync); expect(LaunchRequest.parse(['jjmkvmaker://lsync/']).files, isEmpty); From 50572674942e85178a2dd61d3838ef3bcb850824 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:06:03 +0900 Subject: [PATCH 02/20] =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EA=B8=B8=20JJ=5FUPDATE=5FLOCAL=5FZIP:=20G?= =?UTF-8?q?itHub=20=EB=8C=80=EC=8B=A0=20=EC=9D=B4=20PC=20=EC=9D=98=20zip?= =?UTF-8?q?=20=EC=9C=BC=EB=A1=9C=20=EC=95=B1=20=EC=95=88=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=A0=84=EC=B2=B4=EB=A5=BC=20(?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JJ_UPDATE_LOCAL_ZIP=; - 시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켰을 때만 받음 (사용자 판에서는 무시) - 최신 확인 · 버전 목록 · 받기가 그 zip 을 씀 (GitHub 를 부르지 않음). 판은 zip 이름 (JJ_MKVMaker_v<판>_win64.zip) 에서. 그 뒤 SHA256 검사 · 풀기 · 교체 스크립트 · 다시 켜기는 정식 길 그대로 - 화면: 이름 "[시험 길] v<판>", 바뀐 내용 첫 줄에 시험 길 설명과 zip 경로 (en · ja · zh) - 거절: sha256 이 없거나 꼴이 틀림 · zip 과 다름 · 시험 길이 아닌데 file: 주소 - 시험: update_test (따로 데이터 없음 = 무시, sha256 없음 · 틀림 거절, 닿지 않는 apiBase 로 확인 · 목록 · 받기, 원래 zip 그대로) Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 5 ++ assets/l10n/ja.json | 5 ++ assets/l10n/zh-Hans.json | 5 ++ lib/platform/windows/github_updater.dart | 85 ++++++++++++++++++++---- test/update_test.dart | 32 +++++++++ 5 files changed, 119 insertions(+), 13 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e685d2f..fa675dd 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -433,6 +433,10 @@ "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [C] counter · extension kept e.g. Show S01E[C]", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "Enter the password in [Edit server settings]. It is kept in this device's secure storage, so you won't need to enter it again.", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].", + "[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[Test path] Updates from a zip on this PC instead of GitHub (JJ_UPDATE_LOCAL_ZIP). Used only when started with a test data folder.", + "[시험 길] JJ_UPDATE_LOCAL_ZIP 에 sha256 이 없습니다 (zip 경로;sha256)": "[Test path] JJ_UPDATE_LOCAL_ZIP has no sha256 (zip path;sha256)", + "[시험 길] zip 을 읽을 수 없습니다: {0}": "[Test path] Cannot read the zip: {0}", + "[시험 길] 시험용 데이터 폴더 · sha256 없이는 이 PC 의 zip 을 쓰지 않습니다": "[Test path] A zip on this PC is used only with a test data folder and a sha256", "[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "Tap [Allow] to open Android settings. After you allow JJ_MKVMaker and come back, videos that couldn't be read are analyzed again automatically.", "aria2 RPC 에 연결할 수 없습니다.": "Cannot connect to aria2 RPC.", "aria2 오류: {0}": "aria2 error: {0}", @@ -1691,6 +1695,7 @@ "시작할 때 새 버전 확인 (하루 한 번)": "Check for a new version at startup (once a day)", "시작할 때마다 설정 창 보기": "Show the setup window at every start", "시즌": "Season", + "시험 길": "Test path", "시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "Started with a test data folder (JJ_MKVMAKER_DATA), so the Start menu shortcut and the jjmkvmaker:// link are left unchanged", "시험판": "Pre-release", "실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 56e525f..d61f0cc 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -433,6 +433,10 @@ "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前 · [C] 番号 · 拡張子はそのまま 例: Show S01E[C]", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。", + "[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[テスト経路] GitHub ではなくこの PC の zip で更新します (JJ_UPDATE_LOCAL_ZIP)。テスト用データフォルダーで起動したときだけ使われます。", + "[시험 길] JJ_UPDATE_LOCAL_ZIP 에 sha256 이 없습니다 (zip 경로;sha256)": "[テスト経路] JJ_UPDATE_LOCAL_ZIP に sha256 がありません (zip のパス;sha256)", + "[시험 길] zip 을 읽을 수 없습니다: {0}": "[テスト経路] zip を読めません: {0}", + "[시험 길] 시험용 데이터 폴더 · sha256 없이는 이 PC 의 zip 을 쓰지 않습니다": "[テスト経路] テスト用データフォルダーと sha256 がなければこの PC の zip は使いません", "[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "[許可] を押すと Android の設定が開きます。JJ_MKVMaker を許可して戻ると、読み取れなかった動画を自動的に再分析します。", "aria2 RPC 에 연결할 수 없습니다.": "aria2 RPC に接続できません。", "aria2 오류: {0}": "aria2 エラー: {0}", @@ -1691,6 +1695,7 @@ "시작할 때 새 버전 확인 (하루 한 번)": "起動時に新しいバージョンを確認(1日1回)", "시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示", "시즌": "シーズン", + "시험 길": "テスト経路", "시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "テスト用データフォルダー (JJ_MKVMAKER_DATA) で起動したため、スタートメニューのショートカットと jjmkvmaker:// の関連付けは変更しません", "시험판": "プレリリース", "실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index c027a46..4819b3c 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -433,6 +433,10 @@ "[N] 원래 이름 · [C] 번호 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名称 · [C] 序号 · 保留扩展名 例如: Show S01E[C]", "[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。", "[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。", + "[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[测试路径] 不从 GitHub,而是用本机的 zip 更新 (JJ_UPDATE_LOCAL_ZIP)。仅在以测试数据文件夹启动时使用。", + "[시험 길] JJ_UPDATE_LOCAL_ZIP 에 sha256 이 없습니다 (zip 경로;sha256)": "[测试路径] JJ_UPDATE_LOCAL_ZIP 中没有 sha256 (zip 路径;sha256)", + "[시험 길] zip 을 읽을 수 없습니다: {0}": "[测试路径] 无法读取 zip: {0}", + "[시험 길] 시험용 데이터 폴더 · sha256 없이는 이 PC 의 zip 을 쓰지 않습니다": "[测试路径] 没有测试数据文件夹和 sha256 时不使用本机的 zip", "[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "点按 [允许] 将打开 Android 设置。允许 JJ_MKVMaker 后返回,无法读取的视频会自动重新分析。", "aria2 RPC 에 연결할 수 없습니다.": "无法连接到 aria2 RPC。", "aria2 오류: {0}": "aria2 错误: {0}", @@ -1691,6 +1695,7 @@ "시작할 때 새 버전 확인 (하루 한 번)": "启动时检查新版本(每天一次)", "시작할 때마다 설정 창 보기": "每次启动时显示设置窗口", "시즌": "季", + "시험 길": "测试路径", "시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "以测试数据文件夹 (JJ_MKVMAKER_DATA) 启动,因此不更改开始菜单快捷方式和 jjmkvmaker:// 关联", "시험판": "预发布", "실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)", diff --git a/lib/platform/windows/github_updater.dart b/lib/platform/windows/github_updater.dart index b27d669..7154d1e 100644 --- a/lib/platform/windows/github_updater.dart +++ b/lib/platform/windows/github_updater.dart @@ -6,11 +6,53 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; import 'app_paths.dart'; +import 'data_dir_override.dart'; import '../../core/app_update.dart'; import '../../services/updater.dart'; import '../../l10n/tr.dart'; +/// 관리자 지시 (10/20): 업데이트 시험 길 - GitHub 대신 이 PC 의 zip (`JJ_UPDATE_LOCAL_ZIP=zip 경로;sha256`). +/// 시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켰을 때만 받는다 (사용자 판에서는 무시). sha256 이 없거나 zip 과 다르면 거절 +class LocalUpdateZip { + final String path; + final String sha256; + const LocalUpdateZip(this.path, this.sha256); + + /// 환경 변수에서. 시험 길이 아니면 null, 값이 틀리면 [UpdateException] + static LocalUpdateZip? fromEnvironment([Map? env, String? dataDir]) { + final e = env ?? Platform.environment; + final raw = e['JJ_UPDATE_LOCAL_ZIP']?.trim() ?? ''; + if (raw.isEmpty || (env == null ? dataDirOverride : dataDir) == null) return null; + final i = raw.lastIndexOf(';'); + final hash = i < 0 ? '' : raw.substring(i + 1).trim().toLowerCase(); + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(hash)) { + throw UpdateException(tr('[시험 길] JJ_UPDATE_LOCAL_ZIP 에 sha256 이 없습니다 (zip 경로;sha256)')); + } + return LocalUpdateZip(raw.substring(0, i).trim(), hash); + } + + /// zip 이름 (`JJ_MKVMaker_v판_win64.zip`) 의 판 + String? get version => RegExp(r'_v(\d{4}\.\d{2}\.\d{2}_\d{3})_win64\.zip$', caseSensitive: false).firstMatch(p.basename(path))?.group(1); + + ReleaseInfo release() { + final v = version; + final f = File(path); + if (v == null || !f.existsSync()) throw UpdateException(trf('[시험 길] zip 을 읽을 수 없습니다: {0}', [path])); + return ReleaseInfo( + version: v, + tag: 'v$v', + name: '[${tr('시험 길')}] v$v', + notes: '${tr('[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.')}\n$path', + pageUrl: '', + zipUrl: Uri.file(path).toString(), + zipName: p.basename(path), + zipSize: f.lengthSync(), + sha256: sha256, + ); + } +} + /// GitHub Release 로 업데이트 (Windows) /// /// 1. releases/latest 조회 → 2. zip 받기 · SHA256 확인 · 압축 풀기 @@ -27,12 +69,17 @@ class GitHubUpdater implements Updater { /// (예전에는 시험이 %TEMP% 에 jj_mkvmaker_update_1.9.0 · .ps1 · .log 를 남겼다, 관리자 지시 10/20) final String? tempRoot; + /// 시험 길 (null = GitHub). 기본은 환경 변수 ([LocalUpdateZip.fromEnvironment]) - 값이 틀리면 확인 · 받기 때 그 까닭으로 실패 + final LocalUpdateZip? Function() _local; + GitHubUpdater({ this.apiBase = 'https://api.github.com', String? appDir, this.versionOverride, this.tempRoot, - }) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위 + LocalUpdateZip? Function()? local, + }) : appDir = appDir ?? AppPaths.root, // Lib 구조면 배포 폴더 맨 위 + _local = local ?? LocalUpdateZip.fromEnvironment; String get _temp => tempRoot ?? Directory.systemTemp.path; @@ -49,6 +96,8 @@ class GitHubUpdater implements Updater { @override Future latest() async { + final local = _local(); + if (local != null) return local.release(); final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases/latest')); req.headers.set('Accept', 'application/vnd.github+json'); final res = await req.close(); @@ -62,6 +111,8 @@ class GitHubUpdater implements Updater { @override Future> releases() async { + final local = _local(); + if (local != null) return [local.release()]; final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases?per_page=100')); req.headers.set('Accept', 'application/vnd.github+json'); final res = await req.close(); @@ -93,19 +144,27 @@ class GitHubUpdater implements Updater { await work.create(recursive: true); final zip = File(p.join(work.path, r.zipName ?? 'update.zip')); - // 받기 - final req = await _http.getUrl(Uri.parse(url)); - final res = await req.close(); - if (res.statusCode != 200) throw UpdateException(trf('내려받기 실패 ({0})', [res.statusCode])); - final total = res.contentLength > 0 ? res.contentLength : r.zipSize; - final sink = zip.openWrite(); - var got = 0; - await for (final chunk in res) { - sink.add(chunk); - got += chunk.length; - if (total > 0) onProgress((got / total).clamp(0.0, 0.99)); + final uri = Uri.parse(url); + if (uri.isScheme('file')) { + // 시험 길: 이 PC 의 zip (sha256 은 꼭 있어야 하고 아래에서 견준다) + if (_local() == null || r.sha256 == null) throw UpdateException(tr('[시험 길] 시험용 데이터 폴더 · sha256 없이는 이 PC 의 zip 을 쓰지 않습니다')); + await File(uri.toFilePath()).copy(zip.path); + onProgress(0.99); + } else { + // 받기 + final req = await _http.getUrl(uri); + final res = await req.close(); + if (res.statusCode != 200) throw UpdateException(trf('내려받기 실패 ({0})', [res.statusCode])); + final total = res.contentLength > 0 ? res.contentLength : r.zipSize; + final sink = zip.openWrite(); + var got = 0; + await for (final chunk in res) { + sink.add(chunk); + got += chunk.length; + if (total > 0) onProgress((got / total).clamp(0.0, 0.99)); + } + await sink.close(); } - await sink.close(); // 검증: GitHub 가 알려 준 SHA256 과 비교 if (r.sha256 != null) { diff --git a/test/update_test.dart b/test/update_test.dart index ee7c1d5..d5953bd 100644 --- a/test/update_test.dart +++ b/test/update_test.dart @@ -168,6 +168,38 @@ void main() { await expectLater(up.download(bad, (_) {}), throwsA(predicate((e) => '$e'.contains('SHA256')))); }); + test('관리자 지시 (10/20): 시험 길 JJ_UPDATE_LOCAL_ZIP - 시험용 데이터 폴더일 때만 · sha256 이 꼭 있고 다르면 거절', () async { + final zip = p.join(tmp.path, 'JJ_MKVMaker_v2026.10.20_001_win64.zip'); + File(zip).writeAsBytesSync(zipBytes); + final env = {'JJ_UPDATE_LOCAL_ZIP': '$zip;${digest.toUpperCase()}'}; + // 사용자 판 (따로 데이터 없음) 은 무시 - GitHub 그대로 + expect(LocalUpdateZip.fromEnvironment(env, null), isNull); + expect(LocalUpdateZip.fromEnvironment(const {}, 'D:/data'), isNull); + // sha256 이 없거나 꼴이 틀리면 거절 + for (final v in [zip, '$zip;', '$zip;abc']) { + expect(() => LocalUpdateZip.fromEnvironment({'JJ_UPDATE_LOCAL_ZIP': v}, 'D:/data'), + throwsA(predicate((e) => '$e'.contains('sha256'))), reason: v); + } + final local = LocalUpdateZip.fromEnvironment(env, 'D:/data')!; + expect((local.path, local.sha256, local.version), (zip, digest, '2026.10.20_001')); + // GitHub 를 부르지 않음 (닿지 않는 주소) - 확인 · 목록 · 받기 모두 이 PC 의 zip + final up = GitHubUpdater(apiBase: 'http://127.0.0.1:1', appDir: tmp.path, versionOverride: '2026.10.18_001', tempRoot: tmp.path, local: () => local); + final r = (await up.latest())!; + expect(r.name, contains('시험 길'), reason: '화면에 시험 길이라고'); + expect(r.notes, contains('시험 길')); + expect(r.isNewerThan(await up.currentVersion()), isTrue); + expect((await up.releases()).single.version, '2026.10.20_001'); + final dir = await up.download(r, (_) {}); + expect(File(p.join(dir, 'jj_mkvmaker.exe')).readAsStringSync(), 'NEW EXE'); + expect(File(zip).existsSync(), isTrue, reason: '원래 zip 은 그대로 (복사해서 씀)'); + // sha256 이 다르면 거절 + final bad = GitHubUpdater(apiBase: 'http://127.0.0.1:1', appDir: tmp.path, tempRoot: tmp.path, local: () => LocalUpdateZip(zip, '0' * 64)); + await expectLater(bad.download((await bad.latest())!, (_) {}), throwsA(predicate((e) => '$e'.contains('SHA256')))); + // 시험 길이 아닌데 file: 주소가 오면 쓰지 않음 + final none = GitHubUpdater(apiBase: 'http://127.0.0.1:1', appDir: tmp.path, tempRoot: tmp.path, local: () => null); + await expectLater(none.download(r, (_) {}), throwsA(predicate((e) => '$e'.contains('시험 길')))); + }); + test('업데이트 스크립트: 프로그램 파일만 교체, 받은 파일 · 모델 보존', () async { if (!Platform.isWindows) return markTestSkipped('Windows 전용'); final app = Directory(p.join(tmp.path, 'installed'))..createSync(); From 024d6e04b064c699508029cce86ef2f68b2a0038 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:32:06 +0900 Subject: [PATCH 03/20] =?UTF-8?q?=EC=8B=9C=ED=97=98:=20=EC=A0=95=EC=8B=9D?= =?UTF-8?q?=20=ED=8C=90=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=84=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=86=EC=9D=B4=20=EB=81=9D=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?(tool/update=5Fpath=5Fcheck.ps1=20=C2=B7=20update=5Fpath=5Freal?= =?UTF-8?q?=5Ftest,=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=201?= =?UTF-8?q?0/20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - %TEMP%\jj_updtest_<표> 에 예전 판 (기본 release\stage_v2026.10.18_001) 배포 폴더와 그 판이 남겼을 데이터 (settings.json · 이 판이 모르는 키 · WebDAV 가짜 서버 jj_updtest_dav · videos.json · DPAPI secrets.dat 의 가짜 비밀번호) 를 만든다 - 통합 시험이 앱 안 업데이트 길 (시험 길 JJ_UPDATE_LOCAL_ZIP) 로 최신 확인 → [지금 설치] (가짜 누르기) → 받기 · SHA256 · 풀기 → 설정 보관 → 교체 예약 - flutter test 는 끝날 때 시험 앱이 띄운 교체 스크립트까지 끝낸다 (기록 없음 · 도는 것 0 으로 확인 - 사용자 판은 앱이 스스로 끝나 그런 일이 없음) - 그래서 앱이 써 둔 교체 스크립트를 앱이 넘긴 것과 같은 인수 · 환경으로 바깥에서 띄움 - 확인: 프로그램 교체 · 새 판이 저절로 켜짐 (따로 데이터라 시작 메뉴를 건드리지 않음) · lastRunVersion · 모르는 키 · WebDAV 서버 · 동영상 목록 · 비밀번호 (안전 저장소) · 평문 비밀번호 없음 (설정 · .bak · 보관본 · 기록) · 예전 판 보관본 · 사용자 바로 가기 그대로 · 시험 폴더 · 가짜 자격 증명 0개 - 결과 (10/21, 2026.10.18_001 → 2026.10.20_990 시험 빌드 = after_1020 5057267): 모두 통과 - 실제 GitHub · 사용자 %APPDATA% · 시작 메뉴 · 실행 중인 앱은 건드리지 않음. 환경 변수가 없으면 통합 시험은 건너뜀 Co-Authored-By: Claude Opus 5.5 --- integration_test/update_path_real_test.dart | 70 +++++++++ tool/update_path_check.ps1 | 157 ++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 integration_test/update_path_real_test.dart create mode 100644 tool/update_path_check.ps1 diff --git a/integration_test/update_path_real_test.dart b/integration_test/update_path_real_test.dart new file mode 100644 index 0000000..6b47b5e --- /dev/null +++ b/integration_test/update_path_real_test.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/app/version_snapshot.dart'; +import 'package:jj_mkvmaker/platform/windows/data_dir_override.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/github_updater.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:jj_mkvmaker/ui/update_dialog.dart'; +import 'package:path/path.dart' as p; + +/// 관리자 지시 (10/20): 정식 판 업데이트 길을 사용자 데이터 없이 끝까지 - 앱 안의 [지금 설치] 를 가짜 누르기로. +/// 바깥 스크립트 (tool/update_path_check.ps1) 가 준비 · 확인 · 정리를 하고, 이 시험은 그 사이의 앱 쪽만 한다: +/// 예전 판 배포 폴더 (JJ_UPD_APP) · 예전 판 데이터 (JJ_MKVMAKER_DATA) 에서 시험 길 (JJ_UPDATE_LOCAL_ZIP) 로 최신 확인 → 받기 · +/// SHA256 · 풀기 → 설정 보관 → 교체 예약 (이 시험 프로세스가 끝나면 교체하고 새 판을 띄움). +/// 실제 GitHub · 사용자 %APPDATA% · 시작 메뉴는 건드리지 않는다. 그 환경 변수가 없으면 건너뜀. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final env = Platform.environment; + final app = env['JJ_UPD_APP'] ?? ''; + final from = env['JJ_UPD_FROM'] ?? ''; + final skip = !Platform.isWindows || app.isEmpty || from.isEmpty || dataDirOverride == null || (env['JJ_UPDATE_LOCAL_ZIP'] ?? '').isEmpty + ? 'JJ_UPD_APP · JJ_UPD_FROM · JJ_MKVMAKER_DATA · JJ_UPDATE_LOCAL_ZIP 이 없음 (tool/update_path_check.ps1 이 준다)' + : null; + + testWidgets('업데이트 길: 시험 길로 [지금 설치] → 설정 보관 → 교체 예약', (t) async { + applyDataDirOverride(); + final data = dataDirOverride!; + VersionSnapshot.instance = VersionSnapshot(data); + final work = Directory(p.join(p.dirname(app), 'upd_tmp'))..createSync(recursive: true); + final up = GitHubUpdater(appDir: app, versionOverride: from, tempRoot: work.path); + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), updater: up), + settingsStore: SettingsStore()); + await t.runAsync(c.init); + expect(c.settings.lastRunVersion, from, reason: '예전 판이 쓴 데이터를 읽음'); + final r = (await t.runAsync(up.latest))!; + expect(r.name, contains('시험 길')); + t.view.physicalSize = const Size(1200, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + theme: buildTheme(), + home: Builder(builder: (ctx) => Scaffold(body: Center(child: FilledButton(onPressed: () => installRelease(ctx, c, r, from), child: const Text('go'))))), + )); + await t.tap(find.text('go')); + Future until(bool Function() done, {int ms = 60000}) async { + for (var i = 0; i < ms ~/ 100 && !done(); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await t.pump(); + } + } + + // 받기 · SHA256 · 풀기 (실제 시간) → "설치 준비 완료" + await until(() => find.text('지금 설치').evaluate().isNotEmpty); + expect(find.text('지금 설치'), findsOneWidget, reason: '받기 · 검증 끝'); + await t.tap(find.text('지금 설치')); + await until(() => File(p.join(work.path, 'jj_mkvmaker_update.ps1')).existsSync(), ms: 20000); + expect(File(p.join(work.path, 'jj_mkvmaker_update.ps1')).existsSync(), isTrue, reason: '교체 스크립트 예약'); + expect(VersionSnapshot.instance!.has(from), isTrue, reason: '지금 판 ($from) 설정 보관'); + // ignore: avoid_print + print('UPD_SCHEDULED pid=$pid'); + }, skip: skip != null, timeout: const Timeout(Duration(minutes: 5))); +} diff --git a/tool/update_path_check.ps1 b/tool/update_path_check.ps1 new file mode 100644 index 0000000..e09ba15 --- /dev/null +++ b/tool/update_path_check.ps1 @@ -0,0 +1,157 @@ +# 정식 판 업데이트 길 시험 (관리자 지시 10/20): 사용자 데이터 · 시작 메뉴 · 실제 GitHub 를 건드리지 않고 끝까지. +# 1. %TEMP%\jj_updtest_<표> 에 예전 판 배포 폴더 (-OldStage) 와 그 판이 남겼을 데이터 (설정 · 동영상 목록 · 비밀번호 (DPAPI secrets.dat, +# 가짜 서버 jj_updtest_dav) · 이 판이 모르는 설정 키) 를 만든다. +# 2. 통합 시험 integration_test\update_path_real_test.dart 가 앱 안 업데이트 길로 [지금 설치] (시험 길 JJ_UPDATE_LOCAL_ZIP = -NewZip). +# 3. 시험이 끝나면 앱의 교체 스크립트가 배포 폴더를 바꾸고 새 판을 띄운다 (따로 데이터라 시작 메뉴를 건드리지 않음 - 121dd43). +# 4. 새 판이 설정을 쓴 뒤 끄고 확인: 판 · 설정 · 모르는 키 · 동영상 목록 · 비밀번호 · 보관본 (비밀번호 없음) · 시작 메뉴 바로 가기 그대로. +# 5. 시험 폴더를 지우고 jj_updtest_* 가 0개인지 본다. +# -NewZip 은 시작 메뉴 막기 (121dd43) 가 든 판이어야 한다 (아니면 새 판이 켜질 때 사용자 바로 가기를 바꾼다 - 그래서 먼저 판을 본다). +# 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\update_path_check.ps1 -NewZip [-OldStage <폴더>] [-From 2026.10.18_001] +param( + [Parameter(Mandatory = $true)][string]$NewZip, + [string]$OldStage = '', + [string]$From = '2026.10.18_001', + [int]$WaitSeconds = 60 +) +$ErrorActionPreference = 'Stop' +$appSrc = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$root = Split-Path $appSrc # 앱 폴더 (app · wt_*) 의 위 = 루트 +if (-not $OldStage) { $OldStage = Join-Path $root "release\stage_v$From\JJ_MKVMaker" } +$NewZip = (Resolve-Path $NewZip).Path +if ((Split-Path $NewZip -Leaf) -notmatch '_v(\d{4}\.\d{2}\.\d{2}_\d{3})_win64\.zip$') { throw "zip 이름에서 판을 읽지 못함: $NewZip" } +$newVer = $Matches[1] +$sha = (Get-FileHash $NewZip -Algorithm SHA256).Hash.ToLower() +$fails = New-Object System.Collections.Generic.List[string] +function Check([string]$what, [bool]$ok, [string]$detail = '') { + if ($ok) { Write-Host "통과: $what $detail" } else { Write-Host "실패: $what $detail"; $fails.Add($what) } +} + +# 사용자 쪽 (읽기만): 시작 메뉴 바로 가기 · 주소 연결 · %TEMP% 의 jj 이름 +$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\JJ_MKVMaker.lnk' +function UserArea { + $t = if (Test-Path $lnk) { (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath + ' @' + (Get-Item $lnk).LastWriteTime.Ticks } else { '(없음)' } + $k = 'HKCU:\Software\Classes\jjmkvmaker\shell\open\command' + $r = if (Test-Path $k) { (Get-ItemProperty $k).'(default)' } else { '(없음)' } + "$t | $r" +} +$userBefore = UserArea +$sysTemp = [IO.Path]::GetTempPath() +$tempBefore = @(Get-ChildItem $sysTemp -Force -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) + +$tag = -join ((1..6) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) +$T = Join-Path $sysTemp "jj_updtest_$tag" +$app = "$T\app"; $data = "$T\data"; $media = "$T\media"; $tmp = "$T\temp" +New-Item -ItemType Directory $T, $data, $media, $tmp | Out-Null +Write-Host "시험 폴더: $T ($From → $newVer)" +$appProcs = { @(Get-Process jj_mkvmaker -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($T, [StringComparison]::OrdinalIgnoreCase) }) } +try { + # 1. 예전 판 배포 폴더 · 데이터 + robocopy $OldStage $app /E /NFL /NDL /NJH /NJS /NP | Out-Null + if ($LASTEXITCODE -ge 8) { throw "예전 판 복사 실패 $LASTEXITCODE" } + $oldVer = (Get-Item "$app\Lib\jj_mkvmaker.exe").VersionInfo.ProductVersion + $videos = @("$media\a.mp4", "$media\b.mkv") + foreach ($v in $videos) { [IO.File]::WriteAllBytes($v, [byte[]](1..64)) } + $pw = "jj_updtest-Pw-$tag" + $utf8 = New-Object Text.UTF8Encoding $false + [IO.File]::WriteAllText("$data\videos.json", (ConvertTo-Json -InputObject $videos -Compress), $utf8) + $settings = [ordered]@{ + lastRunVersion = $From + autoCheckUpdates = $false + startMenuShortcut = $true + webdavServers = @(@{ id = 'jj_updtest_dav'; name = 'jj_updtest_dav'; url = 'https://127.0.0.1:9/dav'; user = 'jj_updtest_user' }) + zz_jj_updtest_future = @{ keep = $true; n = 7 } + } + [IO.File]::WriteAllText("$data\settings.json", (ConvertTo-Json -InputObject $settings -Depth 5), $utf8) + Add-Type -AssemblyName System.Security + $plain = $utf8.GetBytes((ConvertTo-Json -InputObject @{ 'dav.pw.jj_updtest_dav' = $pw } -Compress)) + [IO.File]::WriteAllBytes("$data\secrets.dat", [Security.Cryptography.ProtectedData]::Protect($plain, $null, 'CurrentUser')) + + # 2. 앱 안 업데이트 길 (통합 시험) - 환경 변수는 이 시험 · 새 판에만 + $saved = @{} + $vars = @{ JJ_MKVMAKER_DATA = $data; JJ_UPDATE_LOCAL_ZIP = "$NewZip;$sha"; JJ_UPD_APP = $app; JJ_UPD_FROM = $From; TEMP = $tmp; TMP = $tmp } + foreach ($k in $vars.Keys) { $saved[$k] = [Environment]::GetEnvironmentVariable($k); [Environment]::SetEnvironmentVariable($k, $vars[$k]) } + Push-Location $appSrc + try { + # flutter 의 경고 (stderr) 를 오류로 보지 않게 (PowerShell 5.1 의 2>&1 + Stop) + $ErrorActionPreference = 'Continue' + $out = & flutter test integration_test\update_path_real_test.dart -d windows 2>&1 | ForEach-Object { "$_" } + $code = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + } finally { + Pop-Location + foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } + } + $out | Select-String 'UPD_SCHEDULED|All tests|Some tests|\[E\]|Expected|Actual' | ForEach-Object { Write-Host " $_" } + Check '앱 안 업데이트 길 ([지금 설치] · 설정 보관 · 교체 예약)' ($code -eq 0 -and ($out -match 'UPD_SCHEDULED')) "(exit $code)" + + # 3. 교체 → 새 판이 켜짐 → 설정을 씀 + # flutter test 는 시험이 끝나면 시험 앱과 그 앱이 띄운 프로세스 (교체 스크립트 포함) 를 함께 끝낸다 (15 확인 10/21: 기록 없음 · 도는 것 0). + # 사용자 판은 앱이 스스로 끝나 그런 일이 없다. 그래서 앱이 써 둔 교체 스크립트를 앱이 넘긴 것과 같은 인수 · 환경으로 여기서 띄운다 + $exe = "$app\Lib\jj_mkvmaker.exe" + $work = "$T\upd_tmp" + $ps1 = "$work\jj_mkvmaker_update.ps1" + $src = Get-ChildItem "$work\jj_mkvmaker_update_$newVer\extract" -Recurse -Filter jj_mkvmaker.exe -ErrorAction SilentlyContinue | + Sort-Object { $_.FullName.Split('\').Count } | Select-Object -First 1 + $testPid = if (($out -join "`n") -match 'UPD_SCHEDULED pid=(\d+)') { [int]$Matches[1] } else { 0 } + Check '앱이 교체 스크립트 · 풀어 둔 새 판을 남김' ((Test-Path $ps1) -and $src) "($ps1)" + if ((Test-Path $ps1) -and $src) { + foreach ($k in $vars.Keys) { [Environment]::SetEnvironmentVariable($k, $vars[$k]) } + try { + Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $ps1, + '-ParentPid', $testPid, '-Source', $src.DirectoryName, '-Target', $app, '-Exe', "$app\jj_mkvmaker.exe" | Out-Null + } finally { + foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) } + } + } + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt 120 -and -not ((Get-Item $exe).VersionInfo.ProductVersion -ne $oldVer -and (& $appProcs).Count -gt 0)) { Start-Sleep -Milliseconds 500 } + $newPv = (Get-Item $exe).VersionInfo.ProductVersion + # 교체 스크립트의 흔적 (안 됐을 때 까닭을 보려고): 기록 · 아직 도는 스크립트 + $ulog = "$app\..\upd_tmp\jj_mkvmaker_update.log" + Write-Host (" 교체 기록: " + $(if (Test-Path $ulog) { ((Get-Content $ulog | Select-String 'Copied|Extras|Failed|ERROR' | Select-Object -First 4) -join ' | ') } else { '(없음 - 스크립트가 robocopy 까지 가지 않음)' })) + $ps = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -like '*jj_mkvmaker_update.ps1*' -and $_.CommandLine -like "*$T*" }) + Write-Host " 아직 도는 교체 스크립트: $($ps.Count)개" + Check '프로그램 교체' ($newPv -ne $oldVer) "($oldVer → $newPv)" + Check '새 판이 저절로 켜짐 (교체 스크립트)' ((& $appProcs).Count -gt 0) + $sw.Restart() + $s = $null + while ($sw.Elapsed.TotalSeconds -lt $WaitSeconds) { + try { $s = Get-Content -Raw -Encoding UTF8 "$data\settings.json" | ConvertFrom-Json } catch { $s = $null } + if ($s -and $s.lastRunVersion -eq $newVer) { break } + Start-Sleep -Milliseconds 500 + } + Start-Sleep -Seconds 3 + & $appProcs | ForEach-Object { $_.CloseMainWindow() | Out-Null } + Start-Sleep -Seconds 5 + & $appProcs | Stop-Process -Force -ErrorAction SilentlyContinue + + # 4. 확인 + $raw = Get-Content -Raw -Encoding UTF8 "$data\settings.json" + $s = $raw | ConvertFrom-Json + Check '판 기록 (lastRunVersion)' ($s.lastRunVersion -eq $newVer) "($($s.lastRunVersion))" + Check '이 판이 모르는 설정 키 그대로' ($s.zz_jj_updtest_future.keep -eq $true -and $s.zz_jj_updtest_future.n -eq 7) + Check 'WebDAV 서버 그대로' (@($s.webdavServers | Where-Object { $_.id -eq 'jj_updtest_dav' -and $_.user -eq 'jj_updtest_user' }).Count -eq 1) + $v2 = Get-Content -Raw -Encoding UTF8 "$data\videos.json" | ConvertFrom-Json + Check '동영상 목록 그대로' ((@($v2) -join '|') -eq ($videos -join '|')) "($(@($v2).Count)개)" + $dec = $utf8.GetString([Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes("$data\secrets.dat"), $null, 'CurrentUser')) | ConvertFrom-Json + Check '비밀번호 (안전 저장소) 그대로' ($dec.'dav.pw.jj_updtest_dav' -eq $pw) + $plainHits = @(Get-ChildItem $data -Recurse -File | Where-Object { $_.Name -ne 'secrets.dat' -and (Select-String -LiteralPath $_.FullName -SimpleMatch $pw -Quiet) } | ForEach-Object { $_.FullName.Substring($data.Length + 1) }) + Check '평문 비밀번호 없음 (설정 · .bak · 보관본 · 기록)' ($plainHits.Count -eq 0) ($plainHits -join ', ') + Check "예전 판 ($From) 설정 보관본" (Test-Path "$data\version_snapshots\$From\settings.json") + $userAfter = UserArea + Check '사용자 시작 메뉴 바로 가기 · jjmkvmaker:// 그대로' ($userAfter -eq $userBefore) "($userAfter)" +} finally { + & $appProcs | Stop-Process -Force -ErrorAction SilentlyContinue + for ($i = 0; $i -lt 30 -and (Test-Path -LiteralPath $T); $i++) { + if ($i -gt 0) { Start-Sleep -Seconds 2 } + cmd /c "rd /s /q `"\\?\$T`" 2>nul" + } + $left = @(Get-ChildItem $sysTemp -Force -Filter 'jj_updtest_*' -ErrorAction SilentlyContinue) + Check '시험 폴더 · 가짜 자격 증명 (jj_updtest_*) 0개' ($left.Count -eq 0) "($($left.Count)개)" + $tempNew = @(Get-ChildItem $sysTemp -Force -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name } | Where-Object { $tempBefore -notcontains $_ -and $_ -notlike 'jj_updtest_*' }) + # 참고만: 시험 · 새 판의 TEMP 는 시험 폴더로 돌렸으므로, 여기 새로 생긴 jj 이름은 다른 세션의 시험일 수 있다 + if ($tempNew.Count -gt 0) { Write-Host "참고 - %TEMP% 에 새로 생긴 jj 이름 (다른 세션일 수 있음): $($tempNew -join ', ')" } +} +if ($fails.Count -gt 0) { Write-Host "실패 $($fails.Count)개"; exit 1 } +Write-Host '업데이트 길: 모두 통과' +exit 0 From 5d8c79260e8a35f0c1ba8f1a83ceefdb144b65f8 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:41:35 +0900 Subject: [PATCH 04/20] =?UTF-8?q?145=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=B0=BD=20=EC=BA=A1=EC=B2=98:=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EC=B0=BD=EC=97=90=20=EC=B5=9C=EC=83=81?= =?UTF-8?q?=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=E2=86=92=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=B0=BD=20=EA=B8=B8=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EC=B2=98=EC=9D=8C=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=8A=94=20=EB=A7=88=EC=8A=A4=ED=84=B0=EB=A7=8C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C?= =?UTF-8?q?=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windows_reset_shot_test: 마스터 · 최상 비밀번호를 가짜 저장소 (MemorySecretStore) 에 정하고 askMaster 창에 최상 비밀번호를 넣어 초기화 창으로 (예전에는 showSuperReset 을 바로 불렀다). 체크 3개 · 처음 값 [마스터만] 을 확인하고 마스터 입력 창도 찍음 (0_master) - [초기화] 는 끝까지 누르지 않음 (정말? 창에서 닫음) - 끝에 마스터 · 최상이 그대로인지 확인 - 결과 (after_1020 024d6e0 + 이것): ko · en · ja × 412 · 960 의 6개 모두 통과, 넘침 없음, 30장 (release\test\shots_145) - 문구 = 실제로 지우는 것: "저장된 실제 비밀번호 · API 키" = clearStoredSecrets (WebDAV · SFTP 비밀번호 · 키 · 키 암호 · SMB · FTP · AI API 키 · OpenSubtitles 키 · 비밀번호, 아이디 · 서버 목록 남김), "앱 설정 전체" = AppSettings() 저장 (서버 목록과 그 비밀번호 - 저장할 때 지운 서버의 열쇠를 안전 저장소에서도 지움 - 파일 탐색기 즐겨찾기 포함, videos.json · bookmarks.json 은 따로라 그대로) Co-Authored-By: Claude Opus 5.5 --- integration_test/windows_reset_shot_test.dart | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/integration_test/windows_reset_shot_test.dart b/integration_test/windows_reset_shot_test.dart index afd553b..e0c92a8 100644 --- a/integration_test/windows_reset_shot_test.dart +++ b/integration_test/windows_reset_shot_test.dart @@ -36,7 +36,9 @@ void main() { await t.runAsync(() async { await lock.load(); await lock.setMaster('시험-마스터'); + await lock.setSuper('시험-최상'); }); + lock.unlocked = false; final navKey = GlobalKey(); final shotKey = GlobalKey(); await t.pumpWidget(RepaintBoundary( @@ -66,10 +68,21 @@ void main() { await settle(); } - // ① 최상 비밀번호 - 초기화 (무엇을 초기화할지) - 셋 다 체크해 긴 설명까지 보이게 - unawaited(showSuperReset(navKey.currentContext!, c, lock)); + // ⓪ 관리자 지시 (10/21): 사용자가 가는 길 그대로 - 마스터 입력 창에 최상 비밀번호를 넣으면 초기화 창 (가짜 저장소 · 가짜 비밀번호) + unawaited(askMaster(navKey.currentContext!, c, lock)); await settle(); + await shot('0_master'); + await t.enterText(find.byType(TextField), '시험-최상'); + await t.tap(find.byType(FilledButton).last); + for (var i = 0; i < 100 && find.byType(CheckboxListTile).evaluate().isEmpty; i++) { + await settle(1); + } + // ① 최상 비밀번호 - 초기화 (무엇을 초기화할지): 체크 3개, 처음 체크는 마스터만 + expect(find.byType(CheckboxListTile), findsNWidgets(3), reason: '마스터 창에 최상 비밀번호 → 초기화 창'); + expect([for (final e in find.byType(CheckboxListTile).evaluate()) (e.widget as CheckboxListTile).value], [true, false, false], + reason: '처음 체크는 마스터만'); await shot('1_super'); + // 셋 다 체크해 긴 설명까지 보이게 for (final cb in find.byType(CheckboxListTile).evaluate().toList()) { final tile = cb.widget as CheckboxListTile; if (tile.value != true && tile.onChanged != null) tile.onChanged!(true); @@ -88,6 +101,7 @@ void main() { await shot('4_forgot'); await closeAll(); expect(lock.hasMaster, isTrue, reason: '찍기만 하고 지우지 않음'); + expect(lock.hasSuper, isTrue); }); } } From 27259af83c58dc6f115f40d520193cc83efa51d8 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:00:47 +0900 Subject: [PATCH 05/20] =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20[=EC=95=B1?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=A0=84=EC=B2=B4]=20=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B4=20=ED=8C=90=EC=9D=B4=20=EB=AA=A8=EB=A5=B4=EB=8A=94=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=82=A4=EB=8F=84=20=EC=A7=80=EC=9B=80=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A4=EB=A5=B8=20=ED=8C=90=EC=9D=98=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=20=EA=B0=92=EC=9D=80=20[=EC=A0=80=EC=9E=A5=EB=90=9C?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=C2=B7=20API=20=ED=82=A4]=20=EB=A5=BC=20=EA=B3=A8=EB=9E=90?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EB=A7=8C=20(=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=ED=8C=90=EB=8B=A8=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 까닭 (3a): 사용자가 직접 "전체" 를 골랐고, 새 판으로 돌아갔을 때 예전 값이 되살아나면 더 헷갈린다. 모르는 키를 남기는 규칙 (40) 은 보통 저장 때만 - resetAllSettings 는 AppSettings()..extra = {} 로 (뜻을 드러냄) - 안전 저장소: 이 판이 맡지 않는 열쇠 (os. · dav. · ai. · sftp. · smb. · ftp. · sec. 밖 = 다른 판이 쓴 것) 는 [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 지움 (SettingsStore.clearForeignSecrets, clearStoredSecrets 끝에서). 마스터 · 최상 (sec.) 은 남김 - 초기화 창 설명에 한 줄씩: "다른 판에서 넣은 비밀번호 · 키도 지웁니다." · "다른 판에서 만든 설정도 지웁니다." (en · ja · zh, 새 줄로) - 시험: master_lock_test - 모르는 키 · 다른 판 열쇠가 있는 설정 → 보통 저장은 남김 → 전체 초기화는 키 없음 · 열쇠는 남음 → 저장된 비밀번호 지우기는 열쇠도 지움 · sec. 남김. 초기화 창 캡처 다시 (ko · en · ja × 412 · 960, 넘침 없음) Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 ++ assets/l10n/ja.json | 2 ++ assets/l10n/zh-Hans.json | 2 ++ lib/app/settings.dart | 21 ++++++++++++++++++++- lib/ui/master_prompt.dart | 17 +++++++++++++---- test/master_lock_test.dart | 28 ++++++++++++++++++++++++++++ 6 files changed, 67 insertions(+), 5 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index fa675dd..4c9115e 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -974,6 +974,8 @@ "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "When you drop onto the other pane. Either way, the copy/move confirmation (same names etc.) still appears", "다른 창으로 복사": "Copy to other pane", "다른 창으로 이동": "Move to other pane", + "다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "Passwords and keys saved by other versions are deleted too.", + "다른 판에서 만든 설정도 지웁니다.": "Settings made by other versions are deleted too.", "다른 폴더": "Other folder", "다른 폴더로 이동": "Move to another folder", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "Another program may be using them, or you may lack permission. The rest were deleted.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index d61f0cc..1fec2a4 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -974,6 +974,8 @@ "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "もう一方のウィンドウにドロップしたとき。どちらでもコピー・移動の確認 (同じ名前など) は表示されます", "다른 창으로 복사": "もう一方のペインへコピー", "다른 창으로 이동": "もう一方のペインへ移動", + "다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "他のバージョンで保存したパスワード・キーも消します。", + "다른 판에서 만든 설정도 지웁니다.": "他のバージョンで作った設定も消します。", "다른 폴더": "別のフォルダー", "다른 폴더로 이동": "他のフォルダーに移動", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "他のプログラムが使用中か、権限がない可能性があります。残りは削除しました。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 4819b3c..f426ed2 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -974,6 +974,8 @@ "다른 창으로 끌어 놓았을 때. 어느 쪽이든 복사 · 이동 확인 창 (같은 이름 등) 은 그대로 뜹니다": "拖放到另一个窗格时。无论哪种,都会显示复制/移动确认 (同名等)", "다른 창으로 복사": "复制到另一窗格", "다른 창으로 이동": "移动到另一窗格", + "다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "其他版本保存的密码和密钥也会删除。", + "다른 판에서 만든 설정도 지웁니다.": "其他版本创建的设置也会删除。", "다른 폴더": "其他文件夹", "다른 폴더로 이동": "移动到其他文件夹", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "可能有其他程序正在使用或没有权限。其余已删除。", diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 5ccbb90..aed7a6c 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -1682,12 +1682,31 @@ class SettingsStore { }); } + /// 이 판의 설정이 맡는 안전 저장소 열쇠 (그 밖은 마스터 · 최상 (sec.) 이나 이 판이 모르는 것 - 새 판이 쓴 열쇠) + static bool ownsSecret(String k) => + k.startsWith('os.') || k.startsWith('dav.') || k.startsWith('ai.') || k.startsWith('sftp.') || k.startsWith('smb.') || k.startsWith('ftp.'); + + /// 관리자 판단 (10/21): 이 판이 모르는 비밀 값 (다른 판이 쓴 열쇠) 을 지운다 - 사용자가 초기화 창에서 + /// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다. + /// 지운 열쇠 수 + Future clearForeignSecrets() async { + final all = await secrets.readAll(); + var n = 0; + for (final k in all.keys) { + if (k.startsWith('sec.') || ownsSecret(k)) continue; + await secrets.delete(k); + _known.remove(k); + n++; + } + return n; + } + /// 바뀐 비밀 값만 안전 저장소에 쓴다 (빈 값 · 지운 서버는 저장소에서도 지움). 저장소에 넣지 못한 열쇠를 돌려준다. Future> _writeSecrets(Map values) async { if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])}; final unsaved = {}; for (final k in {..._known.keys, ...values.keys}) { - if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.') && !k.startsWith('smb.') && !k.startsWith('ftp.')) continue; + if (!ownsSecret(k)) continue; final v = values[k]; if (v == _known[k]) continue; try { diff --git a/lib/ui/master_prompt.dart b/lib/ui/master_prompt.dart index 2da04d7..9a0f3be 100644 --- a/lib/ui/master_prompt.dart +++ b/lib/ui/master_prompt.dart @@ -147,11 +147,17 @@ Future clearStoredSecrets(AppController c) => c.updateSettings((s) => s // AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17) ..aiServices = [for (final x in s.aiServices) x.copyWith(apiKey: '')] ..openSubtitlesKey = '' - ..openSubtitlesPassword = ''); + ..openSubtitlesPassword = '').then((_) async { + // 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김) + await c.settingsStore?.clearForeignSecrets(); +}); /// 앱 설정 전체를 처음 상태로 (다음에 켤 때부터 모두 적용되도록 앱을 다시 켜 달라고 한다) +/// 관리자 판단 (10/21): 이 판이 모르는 설정 키 (다른 판이 쓴 것) 도 지운다 - 사용자가 "전체" 를 골랐고, 새 판으로 돌아갔을 때 +/// 예전 값이 되살아나면 더 헷갈린다. 모르는 키를 남기는 규칙 (40) 은 보통 저장 때만 (새 AppSettings 는 [AppSettings.extra] 가 비어 있다). +/// 안전 저장소의 이 판이 모르는 열쇠는 [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 지운다 ([clearStoredSecrets]) Future resetAllSettings(AppController c) async { - c.settings = AppSettings(); + c.settings = AppSettings()..extra = {}; await c.updateSettings((_) {}); } @@ -198,14 +204,17 @@ Future showSuperReset(BuildContext context, AppController c, MasterLock lo onChanged: (v) => set(() => secrets = v!), title: Text(tr('저장된 실제 비밀번호 · API 키')), // 145: 10/18 에 생긴 SFTP · SMB · FTP 비밀번호 · 키도 지운다 (clearStoredSecrets) - subtitle: Text(tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다')), + subtitle: Text('${tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다')}\n' + '${tr('다른 판에서 넣은 비밀번호 · 키도 지웁니다.')}'), ), CheckboxListTile( value: settings, onChanged: (v) => set(() => settings = v!), title: Text(tr('앱 설정 전체 (처음 상태로)')), // 145: 파일 탐색기 즐겨찾기 (73) 는 설정 안에 있어 함께 지워진다 - 예전 글은 "즐겨찾기는 그대로" - subtitle: Text(tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다')), + subtitle: Text('${tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다')}\n' + // 관리자 판단 (10/21): 이 판이 모르는 설정 키도 지운다 (resetAllSettings) + '${tr('다른 판에서 만든 설정도 지웁니다.')}'), ), ]), ), diff --git a/test/master_lock_test.dart b/test/master_lock_test.dart index b1dd73a..7137f98 100644 --- a/test/master_lock_test.dart +++ b/test/master_lock_test.dart @@ -298,6 +298,34 @@ void main() { expect((c.settings.webdavServers.single.user, c.settings.sftpServers.single.user, c.settings.aiServices.single.url), ('u', 'u', 'https://ai')); }); + test('관리자 판단 (10/21): "앱 설정 전체" 는 이 판이 모르는 설정 키도 지우고, 다른 판의 비밀 값은 "저장된 비밀번호" 를 골랐을 때만', () async { + final tmp = Directory.systemTemp.createTempSync('jj_master_full_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final file = File(p.join(tmp.path, 'settings.json')) + ..writeAsStringSync(jsonEncode({'explorerLayout': 'single', 'zz_new_version': {'keep': 1}})); + final secrets = MemorySecretStore() + ..values['nfs.pw.n1'] = 'new-version-pw' // 다른 (새) 판이 쓴 열쇠 + ..values['sec.master'] = 'hash'; + final store = SettingsStore(file.path, secrets); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()), + settingsStore: store); + await c.init(); + // 보통 저장: 모르는 키 · 다른 판의 열쇠 남음 (40) + await c.updateSettings((s) => s.explorerLayout = 'dual'); + Map saved() => jsonDecode(file.readAsStringSync()) as Map; + expect(saved()['zz_new_version'], {'keep': 1}); + expect(secrets.values['nfs.pw.n1'], 'new-version-pw'); + // 앱 설정 전체: 모르는 키 없음 · 다른 판의 열쇠는 아직 남음 (비밀번호 체크를 안 함) + await resetAllSettings(c); + expect(saved().containsKey('zz_new_version'), isFalse); + expect(saved()['explorerLayout'], AppSettings().explorerLayout); + expect(secrets.values['nfs.pw.n1'], 'new-version-pw', reason: '[저장된 실제 비밀번호] 를 고르지 않으면 남김'); + // 저장된 실제 비밀번호 · API 키: 다른 판의 열쇠도 지움 · 마스터 (sec.) 는 남김 + await clearStoredSecrets(c); + expect(secrets.values.containsKey('nfs.pw.n1'), isFalse); + expect(secrets.values['sec.master'], 'hash'); + }); + test('최상으로 "앱 설정 전체" 를 지우면 처음 상태', () async { final c = controller()..settings.explorerLayout = 'single'; await resetAllSettings(c); From 889a92e919c1d1e610aeb2c3aeed879034714c83 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:04:46 +0900 Subject: [PATCH 06/20] =?UTF-8?q?update=5Fpath=5Fcheck:=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=8B=9C=EC=9E=91=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A7=80=ED=82=A4=EB=8A=94=20=EB=91=90=20=EA=B2=B9?= =?UTF-8?q?=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/21,?= =?UTF-8?q?=2028=20=EC=A7=80=EC=A0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ① 시작 전에 -NewZip 안의 en.json 에 시작 메뉴 막기 (121dd43) 의 글이 있는지 본다. 없으면 (10.20_001 과 그 전 판) 아무것도 만들지 않고 멈춤 (exit 3) - 예전에는 머리 주석만 "먼저 판을 본다" 였고 코드에 없었다 - ② 시작 전에 사용자 바로 가기 (.lnk 사본) 와 HKCU\Software\Classes\jjmkvmaker (reg export) 를 시험 폴더 밖 %TEMP%\jj_updtest_backup_<표> 에 보관. 끝에 (시험 폴더를 지우기 전) 다르면 되돌리고 같은지 다시 보며 크게 알림 (실패로 셈). 같으면 보관본을 지우고, 되돌리지 못하면 남김 (그러면 jj_updtest_* 0개 검사도 실패) - 머리 주석을 코드와 같게 - 확인 (10/21): 10.20_001 zip → 멈춤 (시험 폴더 · 보관본 없음). 2026.10.20_990 시험 빌드 zip → 16개 모두 통과, 바꾸지 않아 되돌리기 없음, 보관본 지움, jj_updtest_* 0개. 되돌리기 길 자체는 사용자 영역을 바꿔야 해서 일부러 돌리지 않음 Co-Authored-By: Claude Opus 5.5 --- tool/update_path_check.ps1 | 50 +++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/tool/update_path_check.ps1 b/tool/update_path_check.ps1 index e09ba15..7d5bd9f 100644 --- a/tool/update_path_check.ps1 +++ b/tool/update_path_check.ps1 @@ -5,7 +5,11 @@ # 3. 시험이 끝나면 앱의 교체 스크립트가 배포 폴더를 바꾸고 새 판을 띄운다 (따로 데이터라 시작 메뉴를 건드리지 않음 - 121dd43). # 4. 새 판이 설정을 쓴 뒤 끄고 확인: 판 · 설정 · 모르는 키 · 동영상 목록 · 비밀번호 · 보관본 (비밀번호 없음) · 시작 메뉴 바로 가기 그대로. # 5. 시험 폴더를 지우고 jj_updtest_* 가 0개인지 본다. -# -NewZip 은 시작 메뉴 막기 (121dd43) 가 든 판이어야 한다 (아니면 새 판이 켜질 때 사용자 바로 가기를 바꾼다 - 그래서 먼저 판을 본다). +# 사용자 시작 메뉴를 지키는 두 겹 (관리자 지시 10/21, 28 지적): +# ① 시작 전에 -NewZip 안의 en.json 에 시작 메뉴 막기 (121dd43) 의 글이 있는지 본다 - 없으면 (10.20_001 과 그 전 판) 아무것도 하지 않고 멈춘다. +# 예전 판 (-OldStage) 은 켜지 않으므로 보지 않는다. +# ② 시작 전에 사용자 바로 가기 (.lnk 사본) 와 HKCU\Software\Classes\jjmkvmaker (reg export) 를 시험 폴더 밖 %TEMP%\jj_updtest_backup_<표> 에 +# 보관하고, 끝에 (시험 폴더를 지우기 전) 다르면 되돌린 뒤 같은지 다시 보고 크게 알린다. 같으면 보관본을 지운다 (되돌리지 못하면 남김). # 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\update_path_check.ps1 -NewZip [-OldStage <폴더>] [-From 2026.10.18_001] param( [Parameter(Mandatory = $true)][string]$NewZip, @@ -38,7 +42,34 @@ $userBefore = UserArea $sysTemp = [IO.Path]::GetTempPath() $tempBefore = @(Get-ChildItem $sysTemp -Force -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) +# ① 새 판에 시작 메뉴 막기가 있는지 (번역 글로 본다 - 121dd43 에서 생긴 글) +Add-Type -AssemblyName System.IO.Compression.FileSystem +$marker = 'Started with a test data folder (JJ_MKVMAKER_DATA)' +$zf = [IO.Compression.ZipFile]::OpenRead($NewZip) +try { + $en = $zf.Entries | Where-Object { $_.FullName -like '*/data/flutter_assets/assets/l10n/en.json' } | Select-Object -First 1 + $hasGuard = $false + if ($en) { $sr = New-Object IO.StreamReader($en.Open()); try { $hasGuard = $sr.ReadToEnd().Contains($marker) } finally { $sr.Dispose() } } +} finally { $zf.Dispose() } +if (-not $hasGuard) { + Write-Host "멈춤: $NewZip 에는 시작 메뉴 막기 (121dd43) 가 없습니다 - 이 판을 따로 데이터로 켜면 사용자 시작 메뉴 바로 가기 · jjmkvmaker:// 가 바뀝니다. 아무것도 하지 않았습니다." + exit 3 +} +Write-Host '통과: 새 판에 시작 메뉴 막기가 있음 (en.json)' + $tag = -join ((1..6) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) +# ② 사용자 바로 가기 · 주소 연결 보관 (시험 폴더 밖) +$B = Join-Path $sysTemp "jj_updtest_backup_$tag" +$protoKey = 'HKCU\Software\Classes\jjmkvmaker' +New-Item -ItemType Directory $B | Out-Null +$hadLnk = Test-Path $lnk +if ($hadLnk) { Copy-Item -LiteralPath $lnk "$B\JJ_MKVMaker.lnk" } +$hadProto = Test-Path 'HKCU:\Software\Classes\jjmkvmaker' +if ($hadProto) { + cmd /c "reg export `"$protoKey`" `"$B\jjmkvmaker.reg`" /y >nul 2>&1" + if ($LASTEXITCODE -ne 0) { throw "주소 연결을 보관하지 못함 (reg export $LASTEXITCODE) - 시작하지 않음" } +} +Write-Host "사용자 바로 가기 · 주소 연결 보관: $B (바로 가기 $hadLnk · 주소 연결 $hadProto)" $T = Join-Path $sysTemp "jj_updtest_$tag" $app = "$T\app"; $data = "$T\data"; $media = "$T\media"; $tmp = "$T\temp" New-Item -ItemType Directory $T, $data, $media, $tmp | Out-Null @@ -142,6 +173,23 @@ try { Check '사용자 시작 메뉴 바로 가기 · jjmkvmaker:// 그대로' ($userAfter -eq $userBefore) "($userAfter)" } finally { & $appProcs | Stop-Process -Force -ErrorAction SilentlyContinue + # ② 바뀌었으면 되돌림 (시험 폴더를 지우기 전) - 같은지 다시 보고, 같을 때만 보관본을 지운다 + $now = UserArea + if ($now -ne $userBefore) { + Write-Host '!!!!!!!! 사용자 시작 메뉴 바로 가기 · jjmkvmaker:// 가 바뀌었습니다 - 보관본으로 되돌립니다 !!!!!!!!' + Write-Host " 시험 전: $userBefore" + Write-Host " 시험 뒤: $now" + if ($hadLnk) { Copy-Item -LiteralPath "$B\JJ_MKVMaker.lnk" $lnk -Force } elseif (Test-Path $lnk) { Remove-Item -LiteralPath $lnk -Force } + # reg 의 오류 글은 cmd 안에서 버린다 (PowerShell 의 2>$null 은 Stop 에서 예외가 됨) + cmd /c "reg delete `"$protoKey`" /f >nul 2>&1" + if ($hadProto) { cmd /c "reg import `"$B\jjmkvmaker.reg`" >nul 2>&1" } + $restored = UserArea + # 되돌린 바로 가기는 고친 시각이 보관본 것과 같아야 한다 (Copy-Item 은 고친 시각을 그대로 둔다) + Check '되돌린 뒤 사용자 바로 가기 · 주소 연결이 시험 전과 같음' ($restored -eq $userBefore) "($restored)" + if ($restored -ne $userBefore) { Write-Host " 보관본을 남깁니다: $B" } + $fails.Add('사용자 시작 메뉴 · 주소 연결이 시험 중 바뀜 (되돌림)') + } + if ((UserArea) -eq $userBefore) { cmd /c "rd /s /q `"$B`" 2>nul" } for ($i = 0; $i -lt 30 -and (Test-Path -LiteralPath $T); $i++) { if ($i -gt 0) { Start-Sleep -Seconds 2 } cmd /c "rd /s /q `"\\?\$T`" 2>nul" From 54eb692001a5561b15cb9f620afa3746d5fe2fdf Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:09:18 +0900 Subject: [PATCH 07/20] =?UTF-8?q?=EB=B3=B4=ED=86=B5=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=9D=80=20=EC=9D=B4=20=ED=8C=90=EC=9D=B4=20=EB=A7=8C=EB=93=9C?= =?UTF-8?q?=EB=8A=94=20=EA=BC=B4=EC=9D=98=20=EB=B9=84=EB=B0=80=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0=EB=A7=8C=20=EC=A7=80=EC=9A=B4=EB=8B=A4=20-=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=A8=B8=EB=A6=AC=EA=B8=80=EC=9D=98=20?= =?UTF-8?q?=EB=AA=A8=EB=A5=B4=EB=8A=94=20=EA=BC=B4=20(=EC=83=88=20?= =?UTF-8?q?=ED=8C=90=EC=9D=98=20dav.token.x=20=EB=93=B1)=20=EC=9D=80=20?= =?UTF-8?q?=EB=82=A8=EA=B9=80=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=2010/21,=2015=20=EC=95=8C=EB=A6=BC,=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 틈: _writeSecrets 가 머리글 (os. · dav. · ai. · sftp. · smb. · ftp.) 만 보고 지금 설정에 없는 열쇠를 지웠다. 새 판이 같은 머리글로 새 종류의 열쇠를 쓰면 예전 판으로 되돌려 저장할 때 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽) - SettingsStore.ownsSecret 을 정확한 꼴로: os.key · os.user · os.password · dav.servers · dav.pw. · sftp.pw|key|kp. · smb.pw. · ftp.pw. · ai.key. ([_secretsOf] 가 만드는 것과 같음). 지우는 것은 이 꼴 중 그 서버 · 항목이 설정에서 없어지거나 비운 것만 - 초기화의 [저장된 실제 비밀번호 · API 키] (clearForeignSecrets) 는 그대로 sec. 밖을 모두 지움 - 시험 settings_store_test: 모르는 꼴 (dav.token.d1 · sftp.cert.s1 · nfs.pw.n1) 은 보통 저장 · 다시 켜 저장 뒤에도 남음, 서버 s1 을 지우면 s1 의 pw · key · kp 만 없어지고 s2 · 모르는 꼴 · sec. 은 그대로, clearForeignSecrets 는 3개 지우고 sec. 만 남김. 관련 9파일 81개 통과 Co-Authored-By: Claude Opus 5.5 --- lib/app/settings.dart | 11 +++++++---- test/settings_store_test.dart | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/app/settings.dart b/lib/app/settings.dart index aed7a6c..48c0514 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -1682,11 +1682,14 @@ class SettingsStore { }); } - /// 이 판의 설정이 맡는 안전 저장소 열쇠 (그 밖은 마스터 · 최상 (sec.) 이나 이 판이 모르는 것 - 새 판이 쓴 열쇠) - static bool ownsSecret(String k) => - k.startsWith('os.') || k.startsWith('dav.') || k.startsWith('ai.') || k.startsWith('sftp.') || k.startsWith('smb.') || k.startsWith('ftp.'); + /// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴). + /// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를 + /// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽). + /// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다 + static bool ownsSecret(String k) => _osKeys.containsValue(k) || k == _davServers || _ownForm.hasMatch(k); + static final _ownForm = RegExp(r'^(dav\.pw|sftp\.pw|sftp\.key|sftp\.kp|smb\.pw|ftp\.pw|ai\.key)\..+$'); - /// 관리자 판단 (10/21): 이 판이 모르는 비밀 값 (다른 판이 쓴 열쇠) 을 지운다 - 사용자가 초기화 창에서 + /// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서 /// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다. /// 지운 열쇠 수 Future clearForeignSecrets() async { diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart index de2c96a..13038bc 100644 --- a/test/settings_store_test.dart +++ b/test/settings_store_test.dart @@ -2,6 +2,9 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/sftp.dart'; +import 'package:jj_mkvmaker/core/webdav.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; void main() { @@ -25,6 +28,40 @@ void main() { expect((await SettingsStore(file).load()).homeUrl, 'https://4.example'); }); + test('관리자 지시 (10/21): 보통 저장은 이 판이 만드는 꼴의 열쇠만 지운다 - 모르는 꼴 (새 판의 dav.token.x 등) 은 남김 · 지운 서버의 아는 열쇠만', () async { + final tmp = Directory.systemTemp.createTempSync('jj_secret_form_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final secrets = MemorySecretStore() + ..values['dav.token.d1'] = 'new-kind' // 새 판이 같은 머리글로 쓴 새 종류 + ..values['sftp.cert.s1'] = 'new-kind-2' + ..values['nfs.pw.n1'] = 'new-server-kind' + ..values['sec.master'] = 'hash'; + final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + final s = await store.load() + ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] + ..sftpServers = [ + const SftpServer(id: 's1', name: 'a', host: 'h', user: 'u', password: 'sp1', privateKey: 'KEY1', keyPassphrase: 'kp1'), + const SftpServer(id: 's2', name: 'b', host: 'h', user: 'u', password: 'sp2'), + ]; + await store.save(s); + expect( + {for (final k in ['dav.pw.d1', 'sftp.pw.s1', 'sftp.key.s1', 'sftp.kp.s1', 'sftp.pw.s2']) k: secrets.values[k]}, + {'dav.pw.d1': 'dp', 'sftp.pw.s1': 'sp1', 'sftp.key.s1': 'KEY1', 'sftp.kp.s1': 'kp1', 'sftp.pw.s2': 'sp2'}); + // 서버 s1 을 지움: s1 의 아는 열쇠 (pw · key · kp) 만 없어지고, s2 · 모르는 꼴 · 마스터는 그대로 + s.sftpServers = [s.sftpServers.last]; + await store.save(s); + expect(secrets.values.keys.where((k) => k.endsWith('.s1')), ['sftp.cert.s1']); + expect(secrets.values['sftp.pw.s2'], 'sp2'); + expect((secrets.values['dav.token.d1'], secrets.values['nfs.pw.n1'], secrets.values['sec.master']), ('new-kind', 'new-server-kind', 'hash')); + // 다시 읽어 저장해도 (새 SettingsStore = 앱을 다시 켬) 모르는 꼴은 남음 + final again = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + await again.save(await again.load()); + expect((secrets.values['dav.token.d1'], secrets.values['sftp.cert.s1']), ('new-kind', 'new-kind-2')); + // 초기화의 [저장된 실제 비밀번호 · API 키] 쪽 (clearForeignSecrets) 은 모르는 꼴도 지우고 sec. 만 남김 + expect(await again.clearForeignSecrets(), 3); + expect(secrets.values.keys.where((k) => !SettingsStore.ownsSecret(k)), ['sec.master']); + }); + test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async { final store = SettingsStore(file); await store.save(AppSettings()..homeUrl = 'https://good.example'); From 91af2b53794d860232b80634e6e57095db99da21 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:14:52 +0900 Subject: [PATCH 08/20] =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EC=B0=BD?= =?UTF-8?q?=20=EA=B8=80=20=EB=8B=A4=EB=93=AC=EA=B8=B0:=20[=EC=95=B1=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=84=EC=B2=B4]=20=EB=91=98=EC=A7=B8?= =?UTF-8?q?=20=EC=A4=84=EC=97=90=20=EB=8B=A4=EB=A5=B8=20=ED=8C=90=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=EB=8A=94=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EA=B3=A8=EB=9D=BC=EC=95=BC=20=EC=A7=80=EC=9B=8C?= =?UTF-8?q?=EC=A7=84=EB=8B=A4=EB=8A=94=20=EA=B2=83=20=C2=B7=20=EC=A4=84=20?= =?UTF-8?q?=EB=81=9D=20=EB=A7=88=EC=B9=A8=ED=91=9C=EB=A5=BC=20=EB=AA=A8?= =?UTF-8?q?=EB=91=90=20=EA=B0=99=EA=B2=8C=20(=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A7=80=EC=8B=9C=2010/21,=2028=20=EA=B2=80=ED=86=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [앱 설정 전체] 둘째 줄: "다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다)." (en · ja · zh 같은 뜻, 체크 이름은 각 언어의 실제 이름으로) - 두 설명 첫 줄 끝에 마침표 (ko · en ".", ja · zh "。") - 창 안 설명 줄이 모두 마침표로 끝남. 체크 · 창 제목은 이름이라 그대로 - 캡처 shots_145 다시 (ko · en · ja × 412 · 960 의 6개 통과, ja 412 넘침 없음). l10n 빠진 것 0 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 6 +++--- assets/l10n/ja.json | 6 +++--- assets/l10n/zh-Hans.json | 6 +++--- lib/ui/master_prompt.dart | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 4c9115e..d9e6b66 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -359,7 +359,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts", "WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "If nothing is sent or received for this long while downloading or uploading over WebDAV · SFTP · FTP · SMB, it fails with a notice (so it does not wait forever when the server stops)", - "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept", + "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept.", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete, --remove-source-files and the filters --exclude, --include, --filter, --delete-excluded from the options).", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV / SFTP reads everything inside from the server, which can take a long time. Analyze?", @@ -975,7 +975,7 @@ "다른 창으로 복사": "Copy to other pane", "다른 창으로 이동": "Move to other pane", "다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "Passwords and keys saved by other versions are deleted too.", - "다른 판에서 만든 설정도 지웁니다.": "Settings made by other versions are deleted too.", + "다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).": "Settings made by other versions are deleted too (passwords saved by other versions are deleted only if you also choose \"Saved actual passwords and API keys\").", "다른 폴더": "Other folder", "다른 폴더로 이동": "Move to another folder", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "Another program may be using them, or you may lack permission. The rest were deleted.", @@ -1559,7 +1559,7 @@ "서버 ({0}) 에 연결할 수 없습니다: {1}": "Can't connect to the server ({0}): {1}", "서버 ({0}) 와의 연결이 끊겼습니다": "The connection to the server ({0}) was lost", "서버 · 서비스 (기기 밖)": "Servers · services (off device)", - "서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "Server lists (WebDAV, SFTP, SMB, FTP) with their passwords and API keys, and file explorer favorites, are deleted too. The video list, web bookmarks and files are kept", + "서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.": "Server lists (WebDAV, SFTP, SMB, FTP) with their passwords and API keys, and file explorer favorites, are deleted too. The video list, web bookmarks and files are kept.", "서버 설정 고치기": "Edit server settings", "서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "Deletes the server settings and the password, private key and trusted server key saved on this device. Files on the server stay as they are. Syncs and copies that use this server won't connect until you add it again.", "서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "Deletes the server settings and the password and trusted certificate saved on this device. Files on the server are not touched.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 1fec2a4..bff83cd 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -359,7 +359,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します", "WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "WebDAV · SFTP · FTP · SMB でダウンロード・アップロード中に、この時間何もやり取りがなければ失敗として知らせます (サーバーが止まってもずっと待たないように)", - "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します", + "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files・フィルター (--exclude・--include・--filter・--delete-excluded) に従う)。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV・SFTP はサーバーから中身をすべて読むため、時間がかかることがあります。分析しますか?", @@ -975,7 +975,7 @@ "다른 창으로 복사": "もう一方のペインへコピー", "다른 창으로 이동": "もう一方のペインへ移動", "다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "他のバージョンで保存したパスワード・キーも消します。", - "다른 판에서 만든 설정도 지웁니다.": "他のバージョンで作った設定も消します。", + "다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).": "他のバージョンで作った設定も消します (他のバージョンが保存したパスワードは「保存した実際のパスワード・API キー」も選んだときだけ消えます)。", "다른 폴더": "別のフォルダー", "다른 폴더로 이동": "他のフォルダーに移動", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "他のプログラムが使用中か、権限がない可能性があります。残りは削除しました。", @@ -1559,7 +1559,7 @@ "서버 ({0}) 에 연결할 수 없습니다: {1}": "サーバー ({0}) に接続できません: {1}", "서버 ({0}) 와의 연결이 끊겼습니다": "サーバー ({0}) との接続が切れました", "서버 · 서비스 (기기 밖)": "サーバー・サービス (端末外)", - "서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "サーバー一覧 (WebDAV・SFTP・SMB・FTP) とそのパスワード・API キー、ファイルエクスプローラーのお気に入りも消えます。動画リスト・ウェブのお気に入り・ファイルはそのままです", + "서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.": "サーバー一覧 (WebDAV・SFTP・SMB・FTP) とそのパスワード・API キー、ファイルエクスプローラーのお気に入りも消えます。動画リスト・ウェブのお気に入り・ファイルはそのままです。", "서버 설정 고치기": "サーバー設定を修正", "서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "サーバー設定と、この端末に保存されたパスワード・秘密鍵・信頼したサーバー鍵を削除します。サーバーのファイルはそのままです。このサーバーを使う同期・コピーは、再登録するまで接続されません。", "서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "サーバー設定と、この端末に保存したパスワード · 信頼した証明書を削除します。サーバーのファイルはそのままです。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index f426ed2..c112a03 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -359,7 +359,7 @@ "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}", "WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入", "WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "通过 WebDAV · SFTP · FTP · SMB 下载或上传时,若这段时间内没有任何数据往来,将提示失败 (服务器停止时不会一直等待)", - "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名", + "WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files · 过滤 (--exclude · --include · --filter · --delete-excluded))。", "WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。", "WebDAV · SFTP 는 서버에서 안의 것을 모두 읽어 오래 걸릴 수 있습니다. 분석할까요?": "WebDAV · SFTP 需要从服务器读取全部内容,可能需要较长时间。要分析吗?", @@ -975,7 +975,7 @@ "다른 창으로 복사": "复制到另一窗格", "다른 창으로 이동": "移动到另一窗格", "다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "其他版本保存的密码和密钥也会删除。", - "다른 판에서 만든 설정도 지웁니다.": "其他版本创建的设置也会删除。", + "다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).": "其他版本创建的设置也会删除 (其他版本保存的密码只有同时选择“已保存的实际密码 · API 密钥”时才会删除)。", "다른 폴더": "其他文件夹", "다른 폴더로 이동": "移动到其他文件夹", "다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "可能有其他程序正在使用或没有权限。其余已删除。", @@ -1559,7 +1559,7 @@ "서버 ({0}) 에 연결할 수 없습니다: {1}": "无法连接服务器 ({0}):{1}", "서버 ({0}) 와의 연결이 끊겼습니다": "与服务器 ({0}) 的连接已断开", "서버 · 서비스 (기기 밖)": "服务器 · 服务 (设备外)", - "서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "服务器列表 (WebDAV · SFTP · SMB · FTP) 及其密码 · API 密钥、文件管理器收藏也会删除。视频列表 · 网页书签 · 文件保持不变", + "서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.": "服务器列表 (WebDAV · SFTP · SMB · FTP) 及其密码 · API 密钥、文件管理器收藏也会删除。视频列表 · 网页书签 · 文件保持不变。", "서버 설정 고치기": "修改服务器设置", "서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "删除服务器设置以及本设备保存的密码、私钥和已信任的服务器密钥。服务器上的文件保持不变。使用此服务器的同步 · 复制在重新添加前无法连接。", "서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "删除服务器设置以及本设备上保存的密码和已信任的证书。服务器上的文件不受影响。", diff --git a/lib/ui/master_prompt.dart b/lib/ui/master_prompt.dart index 9a0f3be..bd2a5f8 100644 --- a/lib/ui/master_prompt.dart +++ b/lib/ui/master_prompt.dart @@ -204,7 +204,7 @@ Future showSuperReset(BuildContext context, AppController c, MasterLock lo onChanged: (v) => set(() => secrets = v!), title: Text(tr('저장된 실제 비밀번호 · API 키')), // 145: 10/18 에 생긴 SFTP · SMB · FTP 비밀번호 · 키도 지운다 (clearStoredSecrets) - subtitle: Text('${tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다')}\n' + subtitle: Text('${tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.')}\n' '${tr('다른 판에서 넣은 비밀번호 · 키도 지웁니다.')}'), ), CheckboxListTile( @@ -212,9 +212,9 @@ Future showSuperReset(BuildContext context, AppController c, MasterLock lo onChanged: (v) => set(() => settings = v!), title: Text(tr('앱 설정 전체 (처음 상태로)')), // 145: 파일 탐색기 즐겨찾기 (73) 는 설정 안에 있어 함께 지워진다 - 예전 글은 "즐겨찾기는 그대로" - subtitle: Text('${tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다')}\n' + subtitle: Text('${tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.')}\n' // 관리자 판단 (10/21): 이 판이 모르는 설정 키도 지운다 (resetAllSettings) - '${tr('다른 판에서 만든 설정도 지웁니다.')}'), + '${tr('다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).')}'), ), ]), ), From d745324ca2f6ab3116cc05909859aac3434ee237 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:48:57 +0900 Subject: [PATCH 09/20] =?UTF-8?q?131=20=EC=BF=A0=ED=82=A4=20=EC=B9=B8=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=8B=9C=ED=97=98:=20ko=20=C2=B7=20en=20?= =?UTF-8?q?=C2=B7=20ja=20=C3=97=20412=20=C2=B7=20960=20+=20[=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EC=95=88=20=ED=95=A8]=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EB=A9=B4=20=ED=8F=89=EB=AC=B8=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EB=8A=94=EC=A7=80=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windows_cookie_shot_test: 시험 폴더의 가짜 cookies_youtube.txt 로 앱 안 브라우저 로그인 안내 · 넘길 사이트 칩 · [쿠키 파일 지우기] 를 찍고, 고르기를 [사용 안 함] 으로 바꾸면 파일이 지워지고 안내가 숨는지 확인 (설정은 메모리만) - 정식 판 후보 코드 1b31609 로 6개 통과, 캡처 12장 release\test\shots_v20261020_001 (cookie_*) Co-Authored-By: Claude Opus 5.5 --- .../windows_cookie_shot_test.dart | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 integration_test/windows_cookie_shot_test.dart diff --git a/integration_test/windows_cookie_shot_test.dart b/integration_test/windows_cookie_shot_test.dart new file mode 100644 index 0000000..10ba3d9 --- /dev/null +++ b/integration_test/windows_cookie_shot_test.dart @@ -0,0 +1,85 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/settings_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 131 (관리자 지시 10/21): 환경 설정의 YouTube 쿠키 칸 (앱 안 브라우저 로그인 · 평문 쿠키 파일 안내 · 넘길 사이트 · [쿠키 파일 지우기]) 을 +/// ko · en · ja × 412 · 960 으로 찍고, [사용 안 함] 으로 바꾸면 내보낸 쿠키 파일 (평문) 이 지워지는지 본다. +/// 사용자 데이터는 쓰지 않는다 (시험 폴더의 가짜 쿠키 파일, 설정은 메모리만). 실행: … -d windows (JJ_SHOT_DIR 에 PNG) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('쿠키 칸 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_cookieshot_'); + final web = Directory(p.join(tmp.path, 'webview'))..createSync(); + final cookie = File(p.join(web.path, 'cookies_youtube.txt'))..writeAsStringSync('# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tSID\tfake\n'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.webViewDataDir = web.path; + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: SettingsPage(c: c)), + )); + Future settle([int n = 10]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'cookie_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(20); + // 세로로 넘기는 설정 목록에서 [쿠키 파일 지우기] (칸의 맨 아래) 가 보일 때까지 + final list = find.byWidgetPredicate((x) => x is Scrollable && x.axisDirection == AxisDirection.down).first; + await t.scrollUntilVisible(find.text(tr('쿠키 파일 지우기')), 200, scrollable: list, maxScrolls: 200); + // 칸 제목이 위 묶음 바로가기 막대 아래 (화면 1/4 쯤) 에 오게 - 그래야 고르기 · 안내 · 칩 · 버튼이 함께 보인다 + await t.runAsync(() => Scrollable.ensureVisible(t.element(find.text(tr('YouTube 쿠키 (로봇 확인이 나올 때)'))), alignment: 0.25)); + await settle(); + expect(find.text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')), findsOneWidget, reason: '칸 제목도 화면 안'); + expect(find.text(tr('쿠키 파일 지우기')), findsOneWidget, reason: '앱 안 브라우저 로그인 (처음 값) 이면 안내 · 지우기 버튼'); + await shot('1_internal'); + // [사용 안 함] 으로 바꾸면 내보낸 평문 쿠키 파일을 지운다 (131) + await t.tap(find.text(tr('앱 안 브라우저 (권장)')).first); + await settle(); + await t.tap(find.text(tr('사용 안 함')).last); + await settle(15); + expect(c.settings.ytCookiesBrowser, ''); + expect(cookie.existsSync(), isFalse, reason: '쓰지 않게 바꾸면 내보낸 쿠키 파일을 지움'); + expect(find.text(tr('쿠키 파일 지우기')), findsNothing, reason: '파일이 없고 쓰지 않으면 안내도 숨김'); + await shot('2_off'); + }); + } + } +} From b011cecfe8be981db0c77612e13a36013d61ee3e Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:55:21 +0900 Subject: [PATCH 10/20] =?UTF-8?q?ftp=5Ftest=20FTP=20=3D=20rsync=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90:=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=A9=B4=20(?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=B4=88=EA=B3=BC=20=ED=8F=AC=ED=95=A8)?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20=ED=8F=B4=EB=8D=94=20=EB=B0=96=20%LOCAL?= =?UTF-8?q?APPDATA%\Temp\jj=5Fftp28=5Ffail=5F<=EC=8B=9C=EA=B0=81>.txt=20?= =?UTF-8?q?=EC=97=90=20=EC=A0=84=EB=B6=80=20(=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A7=80=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전체 시험 부하에서만 가끔 실패하고 콘솔 끝줄만 남아 원문을 못 잡았다 - 시험 이름 · 까닭 · 걸린 시간 · 포트 · 옵션마다 시작 · 걸린 시간 · 같음/다름 · 다른 점 · 목록 (크기 · 바뀐 시각) 을 파일로. 몸통이 시간 초과로 끊겨도 tearDown 에서 어디까지 갔는지 씀. 성공하면 쓰지 않음 - 확인: 혼자 돌려 통과 · 파일 0개 Co-Authored-By: Claude Opus 5.5 --- test/ftp_test.dart | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/ftp_test.dart b/test/ftp_test.dart index 9cf6ac2..0dd8ab8 100644 --- a/test/ftp_test.dart +++ b/test/ftp_test.dart @@ -270,8 +270,33 @@ void main() { } final sw = Stopwatch()..start(); + // 관리자 지시 (10/21): 전체 시험 부하에서만 가끔 실패 - 콘솔이 잘려도 남게, 실패하면 (시간 초과 포함) 시험 폴더 밖 + // %LOCALAPPDATA%\Temp\jj_ftp28_fail_<시각>.txt 에 시험 이름 · 옵션마다 걸린 시간 · 다른 점 · 목록을 쓴다. 성공하면 쓰지 않는다 + final progress = []; + var finished = false; + void writeFail(String why) { + final base = Platform.environment['LOCALAPPDATA']; + if (base == null || base.isEmpty) return; + final stamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.').first; + try { + File(p.join(base, 'Temp', 'jj_ftp28_fail_$stamp.txt')).writeAsStringSync([ + 'ftp_test: FTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)', + '까닭: $why · 걸린 시간 ${sw.elapsedMilliseconds}ms · 포트 $port · 끝 ${DateTime.now().toIso8601String()}', + '옵션마다:', + ...progress.map((x) => ' $x'), + '', + ...bad, + ].join('\n')); + } catch (_) {} + } + + // 시간 초과로 몸통이 끊겨도 (tearDown 은 돈다) 어디까지 갔는지 남긴다 + addTearDown(() { + if (!finished) writeFail('끝나지 않음 (시간 초과 · 예외)'); + }); for (final opts in sceneOptions) { final t0 = sw.elapsedMilliseconds; + progress.add('$opts - 시작 ${t0}ms'); writeScene(src, want); final w = await realRsyncTree(rsync, src, want, opts); final wantList = listing(want); @@ -300,7 +325,10 @@ void main() { listing(got), ].join('\n')); } + progress[progress.length - 1] += ' · ${sw.elapsedMilliseconds - t0}ms · ${d.isEmpty && error == null ? '같음' : '다름'}'; } + finished = true; + if (bad.isNotEmpty) writeFail('결과가 rsync 와 다름 ${bad.length}개'); expect(bad, isEmpty, reason: bad.join('\n')); }, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5))); From 53389baa64288d7d29ba0020d60218b0ac95e9e6 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:52:22 +0900 Subject: [PATCH 11/20] =?UTF-8?q?F1=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8?= =?UTF-8?q?=20=C2=B7=20=ED=82=A4=20=EC=B9=B8=20=EC=9D=B4=EB=A6=84=EC=9D=80?= =?UTF-8?q?=20=EC=A7=A7=EA=B2=8C,=20=EA=B4=84=ED=98=B8=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=EC=9D=80=20=EC=B9=B8=20=EC=95=84=EB=9E=98=20=EB=8F=84?= =?UTF-8?q?=EC=9B=80=20=EA=B8=80=EB=A1=9C=20=C2=B7=20131=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EC=95=88=EB=82=B4=20=EA=B8=80=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EC=84=A4=EB=AA=85=20=EA=B8=80=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=ED=81=AC=EA=B8=B0=20=C2=B7=20=EC=83=89=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C?= =?UTF-8?q?=2010/21,=2015=20=EC=B0=BE=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: 412 폭 en · ja 에서 👁 자리 때문에 칸 이름이 "…" 로 잘렸다 (SFTP 추가 창 "Password (leave empty if usin…") · SFTP 비밀번호 "비밀번호" + 도움 글 "개인 키만 쓰면 비웁니다", 개인 키 암호 "개인 키 암호" + "없으면 비웁니다" · AI 인터넷 서비스 "API 키" / "API 키 또는 아이디:비밀번호" + "넣지 않아도 됩니다" · ZIP 으로 묶기 "비밀번호" (도움 글 "넣으면 AES-256 … 비우면 보통 ZIP" 이 선택임을 알림) · WebDAV · SMB · FTP · 마스터 · OpenSubtitles 는 칸 이름이 이미 "비밀번호" 뿐이라 그대로. 옛 열쇠 4개 지움 (en · ja · zh) - 131: 쿠키 안내 글 12px 흐린 회색 → ListTile 부제목과 같은 bodyMedium · onSurfaceVariant - 시험: windows_f1_shot_test (ZIP · AI 서비스 창, ko · en · ja × 412 · 960) 추가. f1 · trunc · cookie 캡처 각 6개 통과, 72장 release\test\shots_f1 - 직접 봄 (en 412 SFTP · ja 412 AI · en 412 ZIP · ja 960 쿠키): 칸 이름 잘림 없음 - 이 고침은 다음 판 (10.20_001 노트에는 넣지 않음, 관리자 결정) Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 9 +-- assets/l10n/ja.json | 9 +-- assets/l10n/zh-Hans.json | 9 +-- integration_test/windows_f1_shot_test.dart | 80 ++++++++++++++++++++++ lib/ui/ai_settings.dart | 5 +- lib/ui/archive_ui.dart | 4 +- lib/ui/settings_page.dart | 3 +- lib/ui/sftp_settings.dart | 7 +- 8 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 integration_test/windows_f1_shot_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index d9e6b66..79fe092 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -137,9 +137,8 @@ "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "When a long task such as AI upscaling finishes", "AI 해상도 올리기: {0}": "AI upscaling: {0}", "API 키": "API key", - "API 키 (선택)": "API key (optional)", "API 키 (필수, 무료)": "API key (required, free)", - "API 키 또는 아이디:비밀번호 (선택)": "API key or user:password (optional)", + "API 키 또는 아이디:비밀번호": "API key or user:password", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 video cannot be re-encoded on this device. Change the codec to \"Keep original\" to make the MKV. (The PC version can.)", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Since Android 11, no file app can open other apps' data folders (Android/data · Android/obb). Use export or share inside that app to get the files.", "Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView lacks a required feature ({0}). Update Android System WebView / Chrome in the Play Store and open this screen again. Because pages could open without going through Tor, this screen loads nothing.", @@ -697,13 +696,14 @@ "개인 키": "Private key", "개인 키 바꾸기": "Change private key", "개인 키 빼기": "Remove private key", - "개인 키 암호 (없으면 비움)": "Private key passphrase (leave empty if none)", + "개인 키 암호": "Private key passphrase", "개인 키 있음": "Private key set", "개인 키 파일 고르기": "Choose private key file", "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "This isn't a private key file (choose an OpenSSH or PEM private key, not .pub)", "개인 키 파일이 아닙니다 (너무 큼)": "This isn't a private key file (too large)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "Private key added (the original file path isn't saved)", "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "Can't read the private key (OpenSSH or PEM format)", + "개인 키만 쓰면 비웁니다": "Leave empty if you use only a private key", "개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect", "거꾸로": "Reverse", "거꾸로 된 범위: {0}": "Reversed range: {0}", @@ -924,6 +924,7 @@ "넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "If set, encrypts with AES-256. Leave empty for a normal ZIP", "넣지 못했습니다: {0}": "Could not add: {0}", + "넣지 않아도 됩니다": "Optional", "네덜란드어": "Dutch", "네트워크 · DNS": "Network · DNS", "네트워크 경로 (UNC)": "Network path (UNC)", @@ -1472,7 +1473,6 @@ "비교할 값 붙여넣기 (받은 곳의 해시)": "Paste a value to compare (hash from the source)", "비교할 해시": "Hash to compare", "비밀번호": "Password", - "비밀번호 (개인 키만 쓰면 비움)": "Password (leave empty if using only a private key)", "비밀번호 (선택)": "Password (optional)", "비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "This version has no 7-Zip to make password ZIPs. Make the ZIP without a password or reinstall the app.", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)", @@ -1830,6 +1830,7 @@ "없는 경로: {0}": "path not found: {0}", "없는 경로입니다: {0}": "The path doesn't exist: {0}", "없는 항목입니다": "No such item", + "없으면 비웁니다": "Leave empty if there is none", "없음": "None", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details", "엔진": "Engine", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index bff83cd..2b1da07 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -137,9 +137,8 @@ "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "AI 高解像度化など時間のかかる作業が終わったとき", "AI 해상도 올리기: {0}": "AI 高解像度化: {0}", "API 키": "API キー", - "API 키 (선택)": "API キー (任意)", "API 키 (필수, 무료)": "API キー(必須・無料)", - "API 키 또는 아이디:비밀번호 (선택)": "API キーまたは ID:パスワード (任意)", + "API 키 또는 아이디:비밀번호": "API キーまたは ID:パスワード", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Android 11 以降、他のアプリのデータフォルダー (Android/data・Android/obb) はどのファイルアプリでも開けません。そのアプリ内のエクスポート・共有でファイルを取り出してください。", "Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView に必要な機能がありません ({0})。Play ストアで Android System WebView・Chrome を更新してから開き直してください。Tor を通らずに開く恐れがあるため、この画面はページを読み込みません。", @@ -697,13 +696,14 @@ "개인 키": "秘密鍵", "개인 키 바꾸기": "秘密鍵を変更", "개인 키 빼기": "秘密鍵を外す", - "개인 키 암호 (없으면 비움)": "秘密鍵のパスフレーズ (なければ空欄)", + "개인 키 암호": "秘密鍵のパスフレーズ", "개인 키 있음": "秘密鍵あり", "개인 키 파일 고르기": "秘密鍵ファイルを選択", "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "秘密鍵ファイルではありません (OpenSSH・PEM 形式の秘密鍵を選んでください。.pub ではありません)", "개인 키 파일이 아닙니다 (너무 큼)": "秘密鍵ファイルではありません (大きすぎます)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を登録しました (元のファイルのパスは保存しません)", "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "秘密鍵を読み込めません (OpenSSH・PEM 形式)", + "개인 키만 쓰면 비웁니다": "秘密鍵だけを使うなら空にします", "개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません", "거꾸로": "逆順", "거꾸로 된 범위: {0}": "逆順の範囲: {0}", @@ -924,6 +924,7 @@ "넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "入力すると AES-256 で暗号化します。空欄なら通常の ZIP", "넣지 못했습니다: {0}": "追加できませんでした: {0}", + "넣지 않아도 됩니다": "入れなくてもかまいません", "네덜란드어": "オランダ語", "네트워크 · DNS": "ネットワーク・DNS", "네트워크 경로 (UNC)": "ネットワークパス (UNC)", @@ -1472,7 +1473,6 @@ "비교할 값 붙여넣기 (받은 곳의 해시)": "比較する値を貼り付け (入手元のハッシュ)", "비교할 해시": "比較するハッシュ", "비밀번호": "パスワード", - "비밀번호 (개인 키만 쓰면 비움)": "パスワード (秘密鍵だけを使う場合は空欄)", "비밀번호 (선택)": "パスワード(任意)", "비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "このバージョンにはパスワード付き ZIP を作る 7-Zip がありません。パスワードなしでまとめるか、アプリを再インストールしてください。", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)", @@ -1830,6 +1830,7 @@ "없는 경로: {0}": "存在しないパス: {0}", "없는 경로입니다: {0}": "存在しないパスです: {0}", "없는 항목입니다": "存在しない項目です", + "없으면 비웁니다": "なければ空にします", "없음": "なし", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します", "엔진": "エンジン", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index c112a03..69b10cb 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -137,9 +137,8 @@ "AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "AI 提升分辨率等耗时任务完成时", "AI 해상도 올리기: {0}": "AI 提升分辨率: {0}", "API 키": "API 密钥", - "API 키 (선택)": "API 密钥 (可选)", "API 키 (필수, 무료)": "API 密钥(必填,免费)", - "API 키 또는 아이디:비밀번호 (선택)": "API 密钥或 用户名:密码 (可选)", + "API 키 또는 아이디:비밀번호": "API 密钥或 用户名:密码", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "从 Android 11 起,任何文件应用都无法打开其他应用的数据文件夹 (Android/data · Android/obb)。请在该应用内使用导出或分享取出文件。", "Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView 缺少所需功能 ({0})。请在 Play 商店更新 Android System WebView · Chrome 后重新打开。由于页面可能不经过 Tor 打开,此画面不会加载任何页面。", @@ -697,13 +696,14 @@ "개인 키": "私钥", "개인 키 바꾸기": "更换私钥", "개인 키 빼기": "移除私钥", - "개인 키 암호 (없으면 비움)": "私钥密码 (没有则留空)", + "개인 키 암호": "私钥密码", "개인 키 있음": "已设置私钥", "개인 키 파일 고르기": "选择私钥文件", "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "这不是私钥文件 (请选择 OpenSSH · PEM 格式的私钥,而不是 .pub)", "개인 키 파일이 아닙니다 (너무 큼)": "这不是私钥文件 (文件过大)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥 (不保存原文件路径)", "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "无法读取私钥 (OpenSSH · PEM 格式)", + "개인 키만 쓰면 비웁니다": "仅使用私钥时留空", "개인 키의 암호가 맞지 않습니다": "私钥密码不正确", "거꾸로": "倒序", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", @@ -924,6 +924,7 @@ "넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右", "넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "填写后使用 AES-256 加密。留空则为普通 ZIP", "넣지 못했습니다: {0}": "无法加入: {0}", + "넣지 않아도 됩니다": "可以不填", "네덜란드어": "荷兰语", "네트워크 · DNS": "网络 · DNS", "네트워크 경로 (UNC)": "网络路径 (UNC)", @@ -1472,7 +1473,6 @@ "비교할 값 붙여넣기 (받은 곳의 해시)": "粘贴要比较的值 (来源处的哈希)", "비교할 해시": "要比较的哈希", "비밀번호": "密码", - "비밀번호 (개인 키만 쓰면 비움)": "密码 (仅使用私钥时留空)", "비밀번호 (선택)": "密码(可选)", "비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "此版本没有用于创建加密 ZIP 的 7-Zip。请不设密码压缩,或重新安装应用。", "비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)", @@ -1830,6 +1830,7 @@ "없는 경로: {0}": "路径不存在: {0}", "없는 경로입니다: {0}": "路径不存在: {0}", "없는 항목입니다": "项目不存在", + "없으면 비웁니다": "没有则留空", "없음": "无", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建", "엔진": "引擎", diff --git a/integration_test/windows_f1_shot_test.dart b/integration_test/windows_f1_shot_test.dart new file mode 100644 index 0000000..2e05c91 --- /dev/null +++ b/integration_test/windows_f1_shot_test.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/ai_settings.dart'; +import 'package:jj_mkvmaker/ui/archive_ui.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// F1 (관리자 지시 10/21, 15 찾음): 비밀번호 · 키 칸 이름은 짧게, 설명은 칸 아래 - ZIP 으로 묶기 창 · AI 인터넷 서비스 추가 창을 +/// ko · en · ja × 412 · 960 으로 찍는다 (SFTP 추가 창은 windows_trunc_shot_test). 아무것도 만들지 않는다 (창만 열고 닫음). +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('F1 칸 이름 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_f1shot_'); + final file = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('a'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final navKey = GlobalKey(); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())), + )); + Future settle([int n = 8]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'f1_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + Future closeAll() async { + navKey.currentState!.popUntil((r) => r.isFirst); + await settle(); + } + + unawaited(createZipFlow(navKey.currentContext!, c, [file.path], dir: tmp.path)); + await settle(12); + await shot('zip'); + await closeAll(); + unawaited(editAiService(navKey.currentContext!, c)); + await settle(12); + await shot('ai'); + await closeAll(); + expect(Directory(tmp.path).listSync().map((e) => p.basename(e.path)), ['a.txt'], reason: '창만 열고 닫음 - 아무것도 만들지 않음'); + }); + } + } +} diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index fd6f78b..5014253 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -518,7 +518,10 @@ Future editAiService(BuildContext context, AppController c, {AiService? ol controller: key, obscureText: hide, decoration: InputDecoration( - labelText: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호 (선택)') : tr('API 키 (선택)'), + // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 + labelText: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호') : tr('API 키'), + helperText: tr('넣지 않아도 됩니다'), + helperMaxLines: 3, suffixIcon: IconButton(icon: Icon(hide ? Icons.visibility : Icons.visibility_off), onPressed: () => set(() => hide = !hide)), ), ), diff --git a/lib/ui/archive_ui.dart b/lib/ui/archive_ui.dart index 1a14a63..d3a56b8 100644 --- a/lib/ui/archive_ui.dart +++ b/lib/ui/archive_ui.dart @@ -360,8 +360,10 @@ Future createZipFlow(BuildContext context, AppController c, List { Text( tr('로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). ' '이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:'), - style: const TextStyle(fontSize: 12, color: JjColors.textDim)), + // 관리자 지시 (10/21, 131 확인): 12px 흐린 회색은 읽기 힘들었다 - 위아래 설명 글 (ListTile 부제목) 과 같은 크기 · 색으로 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant)), const SizedBox(height: 6), Wrap(spacing: 6, runSpacing: 6, children: [ for (final d in loginCookieDomains) diff --git a/lib/ui/sftp_settings.dart b/lib/ui/sftp_settings.dart index 7d042f4..ffcb44d 100644 --- a/lib/ui/sftp_settings.dart +++ b/lib/ui/sftp_settings.dart @@ -274,7 +274,10 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS controller: pass, obscureText: hide, decoration: InputDecoration( - labelText: tr('비밀번호 (개인 키만 쓰면 비움)'), + // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 + labelText: tr('비밀번호'), + helperText: tr('개인 키만 쓰면 비웁니다'), + helperMaxLines: 3, suffixIcon: IconButton( tooltip: hide ? tr('보이기') : tr('가리기'), icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), @@ -298,7 +301,7 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS TextField( controller: passphrase, obscureText: hide, - decoration: InputDecoration(labelText: tr('개인 키 암호 (없으면 비움)')), + decoration: InputDecoration(labelText: tr('개인 키 암호'), helperText: tr('없으면 비웁니다'), helperMaxLines: 3), ), TextField( controller: root, From 6c5aef75a2eed0f72b27c0b81e349fc61d8e8e4d Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:49:07 +0900 Subject: [PATCH 12/20] =?UTF-8?q?F2=20AI=20=EC=9D=B8=ED=84=B0=EB=84=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B0=BD:=20=EC=A3=BC=EC=86=8C?= =?UTF-8?q?=20=EC=98=88=EC=8B=9C=20=C2=B7=20=EB=AA=A8=EB=8D=B8=20=EB=8F=84?= =?UTF-8?q?=EC=9B=80=20=EA=B8=80=EC=9D=84=203=EC=A4=84=EA=B9=8C=EC=A7=80?= =?UTF-8?q?=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=8A=B9=EC=9D=B8=2010/21,?= =?UTF-8?q?=2015=20=EC=B0=BE=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 412 ja 에서 주소 예시가 한 줄로 잘려 "--api 로 켠 …" (어떤 옵션으로 켜야 하는지) 가 보이지 않았다 - helperMaxLines 3 (다른 창의 도움 글과 같게) - 같은 창의 모델 도움 글 (ComfyUI 체크포인트 이름) 도 3줄까지 - 캡처: windows_f1_shot_test 에 ComfyUI 로 바꾼 창 (ai_comfy) 을 더함. ko · en · ja × 412 · 960 의 6개 통과, ja 412 · en 412 ComfyUI 직접 봄 - 잘림 없음 Co-Authored-By: Claude Opus 5.5 --- integration_test/windows_f1_shot_test.dart | 6 ++++++ lib/ui/ai_settings.dart | 3 +++ 2 files changed, 9 insertions(+) diff --git a/integration_test/windows_f1_shot_test.dart b/integration_test/windows_f1_shot_test.dart index 2e05c91..56f713b 100644 --- a/integration_test/windows_f1_shot_test.dart +++ b/integration_test/windows_f1_shot_test.dart @@ -72,6 +72,12 @@ void main() { unawaited(editAiService(navKey.currentContext!, c)); await settle(12); await shot('ai'); + // F2 (10/21): ComfyUI 로 바꾸면 주소 예시 · 모델 도움 글 - 3줄까지 (잘리지 않게) + await t.tap(find.text('Automatic1111 · Forge').first); + await settle(); + await t.tap(find.text('ComfyUI').last); + await settle(); + await shot('ai_comfy'); await closeAll(); expect(Directory(tmp.path).listSync().map((e) => p.basename(e.path)), ['a.txt'], reason: '창만 열고 닫음 - 아무것도 만들지 않음'); }); diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 5014253..66be74a 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -505,6 +505,8 @@ Future editAiService(BuildContext context, AppController c, {AiService? ol 'openai' => tr('예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)'), _ => tr('예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)'), }, + // 관리자 지시 (10/21, 15 찾음 F2): 412 ja 에서 한 줄로 잘려 어떤 옵션으로 켜야 하는지가 안 보였다 - 다른 창의 도움 글처럼 3줄까지 + helperMaxLines: 3, ), ), TextField( @@ -512,6 +514,7 @@ Future editAiService(BuildContext context, AppController c, {AiService? ol decoration: InputDecoration( labelText: tr('모델 (선택)'), helperText: kind == 'comfy' ? tr('체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)') : null, + helperMaxLines: 3, ), ), TextField( From 9f6c5c0bd53cbf30c10a5a3fd1518201d69ec3c9 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:53:44 +0900 Subject: [PATCH 13/20] =?UTF-8?q?SFTP=20SSH=20=ED=82=A4=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8:=20=ED=82=A4=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=20=C2=B7=20=ED=82=A4=20=EC=95=94=ED=98=B8=20?= =?UTF-8?q?=C2=B7=20=EC=84=9C=EB=B2=84=20=EA=B1=B0=EB=B6=80=EB=A5=BC=20?= =?UTF-8?q?=EB=94=B0=EB=A1=9C=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=BC=20=EB=95=8C=20=EB=B0=94=EB=A1=9C=20=EA=BC=B4=EC=9D=84?= =?UTF-8?q?=20=EB=B4=84=20=C2=B7=20=ED=82=A4=20=EC=A2=85=EB=A5=98=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EC=8B=A4=EC=A0=9C=20=EC=84=9C=EB=B2=84=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20(=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=2010/21,=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미 있던 것 (그대로): 서버 설정 [개인 키 파일 고르기] · 키 암호 칸, 키 내용은 안전 저장소 (sftp.key. · sftp.kp.) 에 두고 파일 경로는 저장하지 않음, 설정 파일 · .bak · 보관본에는 평문 없음 (redactSecrets). "개인 키 붙여넣기" 는 없어 합칠 것 없음. 셸은 넣지 않음 - 더한 것: SftpFailure 에 keyFormat · keyPassphrase · keyRejected · checkPrivateKey: OpenSSH · PEM RSA · PEM EC (dartssh2 4.1.0 이 읽는 꼴) 인지, 암호가 걸렸는지. PuTTY (.ppk) 는 PuTTYgen 으로 바꾸는 법, PKCS#8 · 공개 키 (.pub) · 다른 파일은 고를 꼴을 알림 - [개인 키 파일 고르기] 에서 바로 (암호가 걸린 키면 "아래에 키 암호를 넣으세요") · parsePrivateKey: 암호가 걸렸는데 비었으면 "키 암호를 넣으세요", 못 풀면 "암호가 맞지 않습니다" (PEM 의 다른 오류도), 그 밖은 꼴 · 키만으로 들어가다 SSHAuthError 면 "서버가 이 키를 받지 않습니다 (authorized_keys · 아이디)", 비밀번호도 있으면 "모두 거부" - 시험 (실제 paramiko 서버 · Windows ssh-keygen): ed25519 OpenSSH (암호 · 없음) · rsa OpenSSH 암호 · rsa PEM · ecdsa OpenSSH 암호 · ecdsa PEM 6종으로 로그인, 짝이 없는 키 = keyRejected, 암호 없음 = keyPassphrase, PuTTY · PKCS#8 · 공개 키 · 다른 파일 = keyFormat. 틀린 키 암호 시험은 kind 를 keyPassphrase 로. sftp · sftp_stream 14개 통과, l10n 빠진 것 0 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 8 +++-- assets/l10n/ja.json | 8 +++-- assets/l10n/zh-Hans.json | 8 +++-- lib/core/sftp.dart | 41 +++++++++++++++++++---- lib/ui/sftp_settings.dart | 7 ++-- test/sftp_test.dart | 70 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 125 insertions(+), 17 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 79fe092..a7bbe72 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -274,6 +274,7 @@ "PDF 를 만들지 못했습니다: {0}": "Could not make the PDF: {0}", "PDF 를 열 수 없습니다: {0}": "Cannot open the PDF: {0}", "PDF 에 넣을 그림 선택": "Choose images to add to the PDF", + "PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "PuTTY keys (.ppk) can't be used. Choose a file converted with PuTTYgen [Conversions] > [Export OpenSSH key]", "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (photos)", "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (comics · art, light and fast)", "Rsync (지금 여기)": "Rsync (you are here)", @@ -699,11 +700,12 @@ "개인 키 암호": "Private key passphrase", "개인 키 있음": "Private key set", "개인 키 파일 고르기": "Choose private key file", - "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "This isn't a private key file (choose an OpenSSH or PEM private key, not .pub)", "개인 키 파일이 아닙니다 (너무 큼)": "This isn't a private key file (too large)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "Private key added (the original file path isn't saved)", - "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "Can't read the private key (OpenSSH or PEM format)", + "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "Private key added. It is protected by a passphrase, so enter the key passphrase below (the original file path is not saved)", + "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "Can't read the private key. Choose an OpenSSH or PEM (RSA · EC) private key (ed25519 · rsa · ecdsa) (.pub is the public key)", "개인 키만 쓰면 비웁니다": "Leave empty if you use only a private key", + "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "The private key is protected by a passphrase. Enter the key passphrase", "개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect", "거꾸로": "Reverse", "거꾸로 된 범위: {0}": "Reversed range: {0}", @@ -1581,6 +1583,7 @@ "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "The server sent an unexpected reply before starting encryption (FTPS). The connection was closed", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "The server doesn't support encryption (FTPS) ({0} {1}). Turn on FTPS on the server, or choose \"Plain FTP\" knowing the risk", "서버가 이 기능을 허용하지 않음": "the server does not allow this", + "서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "The server doesn't accept this key. Check that the matching public key (.pub) is in the server's ~/.ssh/authorized_keys and that the user name is right", "서버에 닿을 수 없음": "host unreachable", "서버에 닿지 않습니다": "Cannot reach the server", "서버에서 이 계정의 권한을 확인하세요.": "Check this account's permissions on the server.", @@ -1740,6 +1743,7 @@ "아이디 (비우면 게스트)": "User name (guest if empty)", "아이디 (비우면 익명)": "User name (empty = anonymous)", "아이디 (선택)": "Username (optional)", + "아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "The user name, password and key were all rejected by the server", "아이디 · 비밀번호를 확인하세요": "check the user name and password", "아이디 또는 비밀번호가 맞지 않습니다": "The user name or password is wrong", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 2b1da07..36d5fe9 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -274,6 +274,7 @@ "PDF 를 만들지 못했습니다: {0}": "PDF を作成できませんでした: {0}", "PDF 를 열 수 없습니다: {0}": "PDF を開けません: {0}", "PDF 에 넣을 그림 선택": "PDF に追加する画像を選択", + "PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "PuTTY のキー (.ppk) は使えません。PuTTYgen の [Conversions] > [Export OpenSSH key] で変換したファイルを選んでください", "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (写真)", "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (マンガ・イラスト、軽くて速い)", "Rsync (지금 여기)": "Rsync (現在地)", @@ -699,11 +700,12 @@ "개인 키 암호": "秘密鍵のパスフレーズ", "개인 키 있음": "秘密鍵あり", "개인 키 파일 고르기": "秘密鍵ファイルを選択", - "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "秘密鍵ファイルではありません (OpenSSH・PEM 形式の秘密鍵を選んでください。.pub ではありません)", "개인 키 파일이 아닙니다 (너무 큼)": "秘密鍵ファイルではありません (大きすぎます)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を登録しました (元のファイルのパスは保存しません)", - "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "秘密鍵を読み込めません (OpenSSH・PEM 形式)", + "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を入れました。パスフレーズ付きのキーなので下にキーのパスフレーズを入れてください (元のファイルの場所は保存しません)", + "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "秘密鍵を読めません。OpenSSH 形式か PEM (RSA・EC) 形式の秘密鍵 (ed25519・rsa・ecdsa) を選んでください (.pub は公開鍵)", "개인 키만 쓰면 비웁니다": "秘密鍵だけを使うなら空にします", + "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "秘密鍵にパスフレーズがかかっています。キーのパスフレーズを入れてください", "개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません", "거꾸로": "逆順", "거꾸로 된 범위: {0}": "逆順の範囲: {0}", @@ -1581,6 +1583,7 @@ "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "サーバーが暗号化 (FTPS) の開始前に予期しない応答を送りました。接続を切りました", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください", "서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません", + "서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "サーバーがこのキーを受け付けません。サーバーの ~/.ssh/authorized_keys に対になる公開鍵 (.pub) があるか、ユーザー名が合っているか確認してください", "서버에 닿을 수 없음": "ホストに到達できません", "서버에 닿지 않습니다": "サーバーに接続できません", "서버에서 이 계정의 권한을 확인하세요.": "サーバーでこのアカウントの権限を確認してください。", @@ -1740,6 +1743,7 @@ "아이디 (비우면 게스트)": "ユーザー名 (空欄ならゲスト)", "아이디 (비우면 익명)": "ユーザー名 (空欄なら匿名)", "아이디 (선택)": "ユーザー名(任意)", + "아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "ユーザー名・パスワード・キーのすべてがサーバーに拒否されました", "아이디 · 비밀번호를 확인하세요": "ID とパスワードを確認してください", "아이디 또는 비밀번호가 맞지 않습니다": "ユーザー名またはパスワードが正しくありません", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 69b10cb..6c132c9 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -274,6 +274,7 @@ "PDF 를 만들지 못했습니다: {0}": "无法制作 PDF: {0}", "PDF 를 열 수 없습니다: {0}": "无法打开 PDF: {0}", "PDF 에 넣을 그림 선택": "选择要加入 PDF 的图片", + "PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "无法使用 PuTTY 密钥 (.ppk)。请选择用 PuTTYgen 的 [Conversions] > [Export OpenSSH key] 转换的文件", "Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (照片)", "Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (漫画 · 插画,轻量快速)", "Rsync (지금 여기)": "Rsync (当前位置)", @@ -699,11 +700,12 @@ "개인 키 암호": "私钥密码", "개인 키 있음": "已设置私钥", "개인 키 파일 고르기": "选择私钥文件", - "개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "这不是私钥文件 (请选择 OpenSSH · PEM 格式的私钥,而不是 .pub)", "개인 키 파일이 아닙니다 (너무 큼)": "这不是私钥文件 (文件过大)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥 (不保存原文件路径)", - "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "无法读取私钥 (OpenSSH · PEM 格式)", + "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥。此密钥设有密码,请在下方输入私钥密码 (不保存原文件路径)", + "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "无法读取私钥。请选择 OpenSSH 或 PEM (RSA · EC) 格式的私钥 (ed25519 · rsa · ecdsa) (.pub 是公钥)", "개인 키만 쓰면 비웁니다": "仅使用私钥时留空", + "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "私钥设有密码。请输入私钥密码", "개인 키의 암호가 맞지 않습니다": "私钥密码不正确", "거꾸로": "倒序", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", @@ -1581,6 +1583,7 @@ "서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "服务器在开始加密 (FTPS) 前发送了意外的回复。已断开连接", "서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"", "서버가 이 기능을 허용하지 않음": "服务器不允许此操作", + "서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "服务器不接受此密钥。请检查服务器的 ~/.ssh/authorized_keys 中是否有对应的公钥 (.pub),以及用户名是否正确", "서버에 닿을 수 없음": "主机不可达", "서버에 닿지 않습니다": "无法连接服务器", "서버에서 이 계정의 권한을 확인하세요.": "请在服务器上检查此账户的权限。", @@ -1740,6 +1743,7 @@ "아이디 (비우면 게스트)": "用户名 (留空则为访客)", "아이디 (비우면 익명)": "用户名 (留空则匿名)", "아이디 (선택)": "用户名(可选)", + "아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "用户名、密码和密钥都被服务器拒绝", "아이디 · 비밀번호를 확인하세요": "请检查用户名和密码", "아이디 또는 비밀번호가 맞지 않습니다": "用户名或密码错误", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。", diff --git a/lib/core/sftp.dart b/lib/core/sftp.dart index ca5aeaa..d6b0ab7 100644 --- a/lib/core/sftp.dart +++ b/lib/core/sftp.dart @@ -113,7 +113,32 @@ class SftpServer { } /// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) -enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, other } +/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림) +enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other } + +/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true. +/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다 +bool checkPrivateKey(String text) { + if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat); + try { + return SSHKeyPair.isEncryptedPem(text); + } catch (_) { + throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); + } +} + +/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다 +List parsePrivateKey(String text, String passphrase) { + final encrypted = checkPrivateKey(text); + if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase); + try { + return SSHKeyPair.fromPem(text, encrypted ? passphrase : null); + } catch (_) { + // 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다) + if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase); + throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); + } +} /// SFTP 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다 class SftpException extends DavException { @@ -187,13 +212,10 @@ class SftpRemote implements RemoteClient { List? keys; if (s.privateKey.isNotEmpty) { try { - keys = SSHKeyPair.fromPem(s.privateKey, s.keyPassphrase.isEmpty ? null : s.keyPassphrase); - } on SSHKeyDecryptError { - sock.destroy(); - throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.auth); + keys = parsePrivateKey(s.privateKey, s.keyPassphrase); } catch (_) { sock.destroy(); - throw SftpException(tr('개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)'), SftpFailure.auth); + rethrow; } } final ssh = SSHClient( @@ -250,7 +272,12 @@ class SftpRemote implements RemoteClient { : tr('서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 "오래된 ssh-rsa 허용" 을 켜 보세요'), SftpFailure.other); } - if (e is SSHAuthError) return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth); + if (e is SSHAuthError) { + // 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다 + if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected); + if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth); + return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth); + } return SftpException('$e', SftpFailure.other); } diff --git a/lib/ui/sftp_settings.dart b/lib/ui/sftp_settings.dart index ffcb44d..a7084bd 100644 --- a/lib/ui/sftp_settings.dart +++ b/lib/ui/sftp_settings.dart @@ -227,15 +227,16 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS // 개인 키는 작다 - 큰 파일은 키가 아니다 (실수로 다른 파일을 고름) if (await f.length() > 64 * 1024) throw FormatException(tr('개인 키 파일이 아닙니다 (너무 큼)')); final text = await f.readAsString(); - if (!text.contains('PRIVATE KEY')) throw FormatException(tr('개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)')); + // 관리자 지시 (10/21): 고를 때 바로 꼴을 본다 (OpenSSH · PEM RSA · EC - PuTTY · PKCS#8 · 공개 키는 바꾸는 법을 알림) + final encrypted = checkPrivateKey(text); set(() { key = text; - result = tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)'); + result = encrypted ? tr('개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)') : tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)'); passed = true; }); } catch (e) { set(() { - result = e is FormatException ? e.message : '$e'; + result = e is FormatException ? e.message : e is SftpException ? e.message : '$e'; passed = false; }); } diff --git a/test/sftp_test.dart b/test/sftp_test.dart index 637be7f..0beaa54 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -299,10 +299,78 @@ void main() { addTearDown(c.close); expect(await c.list('/'), isEmpty); final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); - expect((bad! as SftpException).kind, SftpFailure.auth); + expect((bad! as SftpException).kind, SftpFailure.keyPassphrase); expect('$bad', contains('개인 키의 암호')); }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); + test('관리자 지시 (10/21): SSH 키 파일 로그인 - ed25519 · rsa · ecdsa × OpenSSH · PEM (암호 있음 · 없음) 이 실제 서버에 붙고, 서버 거부 · 암호 없음은 따로', () async { + final dir = Directory(p.join(tmp.path, 'keys'))..createSync(); + Future gen(String name, List args) async { + final f = p.join(dir.path, name); + final r = await Process.run(keygen, ['-q', ...args, '-f', f, '-C', 'jj-test']); + return r.exitCode == 0 ? f : null; + } + + final cases = [ + ('ed25519 OpenSSH 암호', ['-t', 'ed25519', '-N', 'kp'], 'kp'), + ('ed25519 OpenSSH 암호 없음', ['-t', 'ed25519', '-N', ''], ''), + ('rsa OpenSSH 암호', ['-t', 'rsa', '-b', '2048', '-N', 'kp'], 'kp'), + ('rsa PEM 암호 없음', ['-t', 'rsa', '-b', '2048', '-m', 'PEM', '-N', ''], ''), + ('ecdsa OpenSSH 암호', ['-t', 'ecdsa', '-b', '256', '-N', 'kp'], 'kp'), + ('ecdsa PEM 암호 없음', ['-t', 'ecdsa', '-b', '256', '-m', 'PEM', '-N', ''], ''), + ]; + final failed = []; + for (final (i, (name, args, kp)) in cases.indexed) { + final key = await gen('k$i', args); + if (key == null) return markTestSkipped('ssh-keygen 이 $name 을 만들지 못함'); + await server?.stop(); + await start(password: null, pubkey: '$key.pub'); + final pem = File(key).readAsStringSync(); + expect(checkPrivateKey(pem), kp.isNotEmpty, reason: '$name: 암호가 걸렸는지'); + final c = SftpRemote(srv(password: '', key: pem, kp: kp, fp: fingerprint)); + try { + await c.list('/'); + } catch (e) { + failed.add('$name: $e'); + } finally { + c.close(); + } + } + expect(failed, isEmpty, reason: failed.join('\n')); + // 서버가 모르는 키 (짝 공개 키가 서버에 없음) → 키 거부 (비밀번호 틀림과 따로) + final other = await gen('other', ['-t', 'ed25519', '-N', '']); + final rejected = await err(() => SftpRemote(srv(password: '', key: File(other!).readAsStringSync(), fp: fingerprint)).list('/')); + expect((rejected! as SftpException).kind, SftpFailure.keyRejected, reason: '$rejected'); + expect('$rejected', contains('authorized_keys')); + // 암호가 걸린 키인데 키 암호를 넣지 않음 → 그렇다고 (서버에 묻기 전에) + final locked = File(p.join(dir.path, 'k0')).readAsStringSync(); + final noPass = await err(() => SftpRemote(srv(password: '', key: locked, fp: fingerprint)).list('/')); + expect((noPass! as SftpException).kind, SftpFailure.keyPassphrase); + expect('$noPass', contains('키 암호를 넣으세요')); + }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음', timeout: const Timeout(Duration(minutes: 3))); + + test('관리자 지시 (10/21): 쓸 수 없는 키 꼴은 고를 때 바로 - PuTTY (.ppk) · PKCS#8 · 공개 키 · 다른 파일', () async { + final dir = Directory(p.join(tmp.path, 'keys'))..createSync(); + final f = p.join(dir.path, 'p8'); + final r = await Process.run(keygen, ['-q', '-t', 'rsa', '-b', '2048', '-m', 'PKCS8', '-N', '', '-f', f]); + final samples = { + 'PuTTY': 'PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: x\nPublic-Lines: 1\nAAAA\n', + if (r.exitCode == 0) 'PKCS#8': File(f).readAsStringSync(), + if (r.exitCode == 0) '공개 키': File('$f.pub').readAsStringSync(), + '다른 파일': 'hello', + }; + for (final MapEntry(key: name, value: text) in samples.entries) { + Object? e; + try { + checkPrivateKey(text); + } catch (x) { + e = x; + } + expect(e, isA().having((x) => x.kind, 'kind', SftpFailure.keyFormat), reason: name); + if (name == 'PuTTY') expect('$e', contains('Export OpenSSH key')); + } + }, skip: File(keygen).existsSync() ? false : 'ssh-keygen 없음'); + test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { await start(keytype: 'rsa', legacyOnly: true); final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/')); From 51f16d554864a9d59f31a41a8c4b27dc026d2bc2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:02:55 +0900 Subject: [PATCH 14/20] =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=8B=9C=ED=97=98:?= =?UTF-8?q?=20264=20=C2=B7=20265=20=EC=9D=B4=EB=8F=99=20=EB=92=A4=20(?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=EC=97=90=20=EB=82=A8=EC=9D=8C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20=EC=9B=90=EB=B3=B8=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0)=20=C2=B7=20SFTP=20=ED=82=A4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=80=205=EA=B0=80=EC=A7=80=20(?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windows_move_left_shot_test: 폴더를 합쳐 옮긴 뒤 알림 "n개는 원본에 남음 (건너뜀)" · 펼쳐 둔 원본 D 를 다시 읽어 남은 것만, ko · en · ja × 960 · 1400. 정식 판 후보 1b31609 로 6개 통과 (캡처 release\test\shots_v20261020_001 moveleft_*) - windows_sshkey_shot_test: [개인 키 파일 고르기] 를 가짜 고르기로, 실제 시험 서버 (paramiko) · ssh-keygen 키로 .ppk 안내 · 고른 뒤 안내 · 암호 필요 · 암호 틀림 · 서버 거부를 ko · en · ja × 412 로 (9f6c5c0 의 글, after_1020 으로 3개 통과, 캡처 release\test\shots_f1 sshkey_*). [저장] 은 누르지 않음 (설정 메모리만) Co-Authored-By: Claude Opus 5.5 --- .../windows_move_left_shot_test.dart | 96 ++++++++++++ .../windows_sshkey_shot_test.dart | 138 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 integration_test/windows_move_left_shot_test.dart create mode 100644 integration_test/windows_sshkey_shot_test.dart diff --git a/integration_test/windows_move_left_shot_test.dart b/integration_test/windows_move_left_shot_test.dart new file mode 100644 index 0000000..766a463 --- /dev/null +++ b/integration_test/windows_move_left_shot_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 264 · 265 (관리자 지시 10/21): 폴더를 합쳐 옮긴 뒤 - 알림에 "n개는 원본에 남음 (건너뜀)" · 펼쳐 둔 원본 폴더를 다시 읽어 +/// 옮겨진 파일은 보이지 않고 남은 것만 보인다. ko · en · ja × 960 · 1400 (두 창). 시험 폴더 안에서만 (실제 이동, 휴지통 쓰지 않음) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(960, 600), (1400, 800)]) { + testWidgets('264 · 265 이동 뒤 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_moveleft_'); + final left = p.join(tmp.path, 'left'), right = p.join(tmp.path, 'right'); + final d = p.join(left, 'D'); + File(p.join(d, 'same.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('old') + ..setLastModifiedSync(DateTime(2026, 1, 1)); + File(p.join(d, 'add.txt')).writeAsStringSync('a'); + File(p.join(right, 'D', 'same.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('NEWER'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerPaths = [left, right] + ..copyConflict = 'overwrite'; + final root = p.rootPrefix(tmp.path); + ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))]; + addTearDown(() => ExplorerPage.debugVolumes = null); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)), + )); + Future settle(bool Function() done, {int rounds = 150}) async { + for (var i = 0; i < rounds && !done(); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 40))); + await t.pump(); + } + await t.pump(const Duration(milliseconds: 300)); + } + + Future shot(String name) async { + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'moveleft_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(() => find.text('D').evaluate().length >= 2); + // 왼쪽 D 를 눌러 펼치고 (고른 것) 안의 add.txt 가 보이게 + await t.tap(find.text('D').first); + await settle(() => find.text('add.txt').evaluate().isNotEmpty); + await shot('1_before'); + await t.tap(find.text(tr('이동')).first); + await settle(() => find.widgetWithText(FilledButton, tr('이동')).evaluate().isNotEmpty); + await t.tap(find.widgetWithText(FilledButton, tr('이동'))); + await settle(() => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.byType(SnackBar).evaluate().isNotEmpty); + await settle(() => false, rounds: 10); // 알림이 다 올라오게 + expect(File(p.join(right, 'D', 'add.txt')).existsSync(), isTrue); + expect(File(p.join(d, 'same.txt')).readAsStringSync(), 'old', reason: '받는 쪽이 더 새것이라 원본에 남음'); + expect(find.textContaining(trf('{0}개는 원본에 남음 (건너뜀)', [1])), findsOneWidget, reason: '264: 알림에'); + expect(find.text('add.txt'), findsNothing, reason: '265: 원본 D 를 다시 읽음'); + expect(find.text('same.txt'), findsOneWidget); + await shot('2_after'); + ScaffoldMessenger.of(t.element(find.byType(ExplorerPage))).clearSnackBars(); + await settle(() => false, rounds: 20); + }); + } + } +} diff --git a/integration_test/windows_sshkey_shot_test.dart b/integration_test/windows_sshkey_shot_test.dart new file mode 100644 index 0000000..72cf803 --- /dev/null +++ b/integration_test/windows_sshkey_shot_test.dart @@ -0,0 +1,138 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/core/sftp.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/sftp_settings.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import '../test/support/sftp_server.dart'; +import 'support/temp_dir.dart'; + +/// [개인 키 파일 고르기] 가 고르는 파일을 시험이 정한다 (실제 파일 창은 열지 않음) +class _PickStorage extends DesktopStorageService { + String? next; + @override + Future> pickFiles({required String title, required List extensions, String? initialDirectory}) async => + next == null ? const [] : [next!]; +} + +/// 관리자 지시 (10/21): SFTP 키 로그인의 사용자 글 - 키 고른 뒤 안내 · 암호 필요 · 암호 틀림 · 서버 거부 · .ppk 안내 를 +/// ko · en · ja × 412 로 찍는다. 실제 시험 서버 (paramiko) · ssh-keygen 으로 만든 시험 키만 쓴다 (시험 폴더 안, 설정은 메모리만) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + final keygen = r'C:\Windows\System32\OpenSSH\ssh-keygen.exe'; + final skip = !sftpTestServerAvailable || !File(keygen).existsSync(); + + for (final lang in ['ko', 'en', 'ja']) { + testWidgets('SSH 키 글 $lang 412x900', (t) async { + t.view.physicalSize = const Size(412, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_sshkeyshot_'); + final root = Directory(p.join(tmp.path, 'root'))..createSync(); + Future gen(String name, String pass) async { + final f = p.join(tmp.path, name); + final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', pass, '-f', f, '-C', 'jj-test']); + expect(r.exitCode, 0, reason: '${r.stderr}'); + return f; + } + + late SftpTestServer server; + late String keyA, keyB; + await t.runAsync(() async { + keyA = await gen('a', 'kp'); + keyB = await gen('b', ''); + server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: null, pubkey: '$keyA.pub'); + }); + addTearDown(() => t.runAsync(server.stop)); + final ppk = File(p.join(tmp.path, 'key.ppk')) + ..writeAsStringSync('PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: x\nPublic-Lines: 1\nAAAA\n'); + final storage = _PickStorage(); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: storage)); + final old = SftpServer(id: 's1', name: 'NAS', host: '127.0.0.1', port: server.port, user: 'jj', hostKeyFingerprint: server.fingerprint); + final navKey = GlobalKey(); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())), + )); + Future settle(bool Function() done, {int rounds = 150}) async { + for (var i = 0; i < rounds && !done(); i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 40))); + await t.pump(); + } + await t.pump(const Duration(milliseconds: 200)); + } + + Future shot(String name, String expectText) async { + final f = find.textContaining(expectText); + await settle(() => f.evaluate().isNotEmpty); + expect(f, findsOneWidget, reason: name); + await t.ensureVisible(f); + await settle(() => false, rounds: 5); + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'sshkey_${name}_${lang}_412.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + Future pick(String file) async { + storage.next = file; + final b = find.byIcon(Icons.key_outlined); + await t.ensureVisible(b); + await t.tap(b); + } + + Future testConnection() async { + final b = find.text(tr('연결 확인')); + await t.ensureVisible(b); + await t.tap(b); + await settle(() => find.byType(LinearProgressIndicator).evaluate().isNotEmpty, rounds: 20); + await settle(() => find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 400); + } + + unawaited(editSftpServer(navKey.currentContext!, c, old: old)); + await settle(() => find.text(tr('연결 확인')).evaluate().isNotEmpty); + // ① PuTTY 키를 고르면 바꾸는 법 + await pick(ppk.path); + await shot('1_ppk', 'Export OpenSSH key'); + // ② 암호가 걸린 키를 고르면 "아래에 키 암호를" + await pick(keyA); + await shot('2_picked', tr('개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)')); + // ③ 키 암호 없이 [연결 확인] + await testConnection(); + await shot('3_needpass', tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요')); + // ④ 틀린 키 암호 + await t.enterText(find.widgetWithText(TextField, tr('개인 키 암호')), 'nope'); + await testConnection(); + await shot('4_badpass', tr('개인 키의 암호가 맞지 않습니다')); + // ⑤ 서버가 모르는 키 (짝 공개 키가 서버에 없음) + await pick(keyB); + await settle(() => false, rounds: 5); + await testConnection(); + await shot('5_rejected', 'authorized_keys'); + navKey.currentState!.popUntil((r) => r.isFirst); + await settle(() => false, rounds: 10); + expect(c.settings.sftpServers, isEmpty, reason: '[저장] 은 누르지 않음'); + }, skip: skip, timeout: const Timeout(Duration(minutes: 3))); + } +} From 616878d8858ecab54e0ab29d7d6efb0e75ea2f62 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:25:43 +0900 Subject: [PATCH 15/20] =?UTF-8?q?F3=20=EB=B3=B5=EC=82=AC=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=81=9D=20=EC=95=8C=EB=A6=BC=EC=9D=84=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=A5=EC=9C=BC=EB=A1=9C=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20F4=20=ED=82=A4=20=EC=95=94=ED=98=B8=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20"=EC=9C=84=EC=9D=98=20[=EA=B0=9C=EC=9D=B8=20?= =?UTF-8?q?=ED=82=A4=20=EC=95=94=ED=98=B8]=20=EC=B9=B8"=20=C2=B7=20185=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=8B=9C=ED=97=98=20(=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/21,=2015=20=EC=B0=BE?= =?UTF-8?q?=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F3: "1개 항목을 옮겼습니다. · 1개는 원본에 남음 (건너뜀)" 처럼 마침표 뒤에 " · " 가 붙었다 → 뒤 조각도 문장 ("1개는 원본에 남았습니다 (건너뜀).", "받는 쪽이 더 새것이라 n개를 건너뛰었습니다." 등 5개) 으로 쓰고 빈칸으로 잇기 (en · ja · zh). 같은 꼴을 찾아보니 다른 끝 알림 (지우기 · 되돌리기) 은 이미 빈칸으로 이어 그대로. 옛 조각 글은 작업 기록 · 모니터링 · 확인 창에서도 써 열쇠는 남김 - F4: 키를 고른 뒤 안내가 창 맨 아래인데 "아래에 키 암호를" 이었다 → "위의 [개인 키 암호] 칸에 키 암호를 넣으세요" (칸 이름은 실제 칸 이름) - 185: windows_dlcancel_shot_test - [다운로드 취소] 묻는 창 → [계속 받기] 면 그대로 받음 (가짜 백엔드), ko · en · ja × 412 · 960. 정식 판 후보 1b31609 로 6개 통과 (release\test\shots_v20261020_001 dlcancel_*), after_1020 으로도 6개 통과 - 확인: analyze · l10n 빠진 것 0, explorer_conflict · move_settled · sftp 48개, moveleft · sshkey 캡처 통과 (release\test\shots_f1), ja 960 이동 알림 · en 412 키 안내 · ja 412 취소 창 직접 봄 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 7 +- assets/l10n/ja.json | 7 +- assets/l10n/zh-Hans.json | 7 +- .../windows_dlcancel_shot_test.dart | 104 ++++++++++++++++++ .../windows_move_left_shot_test.dart | 2 +- .../windows_sshkey_shot_test.dart | 2 +- lib/ui/explorer_page.dart | 13 ++- lib/ui/sftp_settings.dart | 5 +- test/explorer_conflict_test.dart | 8 +- 9 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 integration_test/windows_dlcancel_shot_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index a7bbe72..c271ad2 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -583,6 +583,7 @@ "{0}개 항목을 휴지통으로 보낼까요?": "Move {0} items to the Recycle Bin?", "{0}개 항목을 휴지통으로 보냈습니다.": "Moved {0} items to the Recycle Bin.", "{0}개는 더 새 파일이라 그대로 둠": "{0} kept (existing file is newer)", + "{0}개는 원본에 남았습니다 (건너뜀).": "{0} left in the source (skipped).", "{0}개는 원본에 남음 (건너뜀)": "{0} left in the source (skipped)", "{0}개는 지우지 않았습니다 (취소).": "{0} were not deleted (cancelled).", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} were too large for the Recycle Bin and were deleted permanently (as Windows asked).", @@ -673,6 +674,7 @@ "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "Can't create the folder because a file with the same name exists: {0}", + "같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.": "Skipped {0} because a folder with the same name exists: {1}.", "같은 이름의 폴더가 있어 건너뜀 {0}": "Skipped {0} (a folder with the same name exists)", "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "Skipped {0} because a folder with the same name exists: {1}", "같은 이름이 이미 있습니다": "An item with the same name already exists", @@ -681,6 +683,7 @@ "같은 이름이 있는지 확인하는 중 …": "Checking for items with the same name …", "같은 이름이 있으면": "If the name exists", "같은 이름이 있을 때": "When a file with the same name exists", + "같은 이름이라 건너뛰었습니다: {0}.": "Skipped (same name): {0}.", "같은 이름이라 건너뜀 {0}개": "{0} skipped (same name)", "같은 이름이라 건너뜀: {0}": "Skipped (same name): {0}", "같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)", @@ -702,7 +705,7 @@ "개인 키 파일 고르기": "Choose private key file", "개인 키 파일이 아닙니다 (너무 큼)": "This isn't a private key file (too large)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "Private key added (the original file path isn't saved)", - "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "Private key added. It is protected by a passphrase, so enter the key passphrase below (the original file path is not saved)", + "개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "Private key added. It is protected by a passphrase, so enter it in the [{0}] field above (the original file path is not saved)", "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "Can't read the private key. Choose an OpenSSH or PEM (RSA · EC) private key (ed25519 · rsa · ecdsa) (.pub is the public key)", "개인 키만 쓰면 비웁니다": "Leave empty if you use only a private key", "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "The private key is protected by a passphrase. Enter the key passphrase", @@ -1340,6 +1343,7 @@ "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "{0} files on the receiving side will be overwritten (same name, different content - the original is lost)", "받는 쪽이 더 새것 {0} (건너뜀)": "Newer on receiving side {0} (skipped)", "받는 쪽이 더 새것 {0}개 건너뜀": "{0} skipped (newer on the receiving side)", + "받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.": "Skipped {0} because the receiving side is newer.", "받는 쪽이 더 새것이면 건너뛰기": "Skip when the receiving side is newer", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept", "받다 만 AI 모델": "Unfinished AI models", @@ -2157,6 +2161,7 @@ "이미 실행 중인 창이 응답하지 않습니다.\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}.", "이미 이 폴더에 있어 건너뜀: {0}": "Skipped, already in this folder: {0}", "이미 있는 폴더입니다: {0}": "The folder already exists: {0}", "이미 있어 건너뜀": "Skipped - already exists", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 36d5fe9..c1bf554 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -583,6 +583,7 @@ "{0}개 항목을 휴지통으로 보낼까요?": "{0} 個の項目をごみ箱に移動しますか?", "{0}개 항목을 휴지통으로 보냈습니다.": "{0} 個の項目をごみ箱に移動しました。", "{0}개는 더 새 파일이라 그대로 둠": "{0} 件は新しいファイルなのでそのまま", + "{0}개는 원본에 남았습니다 (건너뜀).": "{0} 件は元の場所に残りました (スキップ)。", "{0}개는 원본에 남음 (건너뜀)": "{0} 件は元の場所に残っています (スキップ)", "{0}개는 지우지 않았습니다 (취소).": "{0} 個は削除しませんでした (キャンセル)。", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。", @@ -673,6 +674,7 @@ "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}", + "같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.": "同名のフォルダーがあるため {0} 件をスキップしました: {1}。", "같은 이름의 폴더가 있어 건너뜀 {0}": "同名のフォルダーがあるためスキップ {0}", "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "同名のフォルダーがあるため {0} 件スキップ: {1}", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", @@ -681,6 +683,7 @@ "같은 이름이 있는지 확인하는 중 …": "同じ名前があるか確認中 …", "같은 이름이 있으면": "同じ名前がある場合", "같은 이름이 있을 때": "同じ名前があるとき", + "같은 이름이라 건너뛰었습니다: {0}.": "同じ名前のためスキップしました: {0}。", "같은 이름이라 건너뜀 {0}개": "同じ名前のためスキップ {0} 件", "같은 이름이라 건너뜀: {0}": "同じ名前のためスキップ: {0}", "같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)", @@ -702,7 +705,7 @@ "개인 키 파일 고르기": "秘密鍵ファイルを選択", "개인 키 파일이 아닙니다 (너무 큼)": "秘密鍵ファイルではありません (大きすぎます)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を登録しました (元のファイルのパスは保存しません)", - "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を入れました。パスフレーズ付きのキーなので下にキーのパスフレーズを入れてください (元のファイルの場所は保存しません)", + "개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を入れました。パスフレーズ付きのキーなので、上の [{0}] 欄にキーのパスフレーズを入れてください (元のファイルの場所は保存しません)", "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "秘密鍵を読めません。OpenSSH 形式か PEM (RSA・EC) 形式の秘密鍵 (ed25519・rsa・ecdsa) を選んでください (.pub は公開鍵)", "개인 키만 쓰면 비웁니다": "秘密鍵だけを使うなら空にします", "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "秘密鍵にパスフレーズがかかっています。キーのパスフレーズを入れてください", @@ -1340,6 +1343,7 @@ "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "受け取る側の {0} 件を上書きします (同じ名前で内容が違うもの - 元のものはなくなります)", "받는 쪽이 더 새것 {0} (건너뜀)": "受け取る側が新しい {0} (スキップ)", "받는 쪽이 더 새것 {0}개 건너뜀": "受け取る側が新しい {0} 件をスキップ", + "받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.": "受け取る側が新しいため {0} 件をスキップしました。", "받는 쪽이 더 새것이면 건너뛰기": "受け取る側が新しければスキップ", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま", "받다 만 AI 모델": "ダウンロード途中の AI モデル", @@ -2157,6 +2161,7 @@ "이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "すでに実行中のウィンドウが応答しません。\n\n[はい] それでも開く - 同じデータの上にウィンドウが 2 つになり、設定 · 一覧が互いに上書きされることがあります。\n[いいえ] 閉じる - 応答しないウィンドウをタスク マネージャーで終了してから、もう一度実行してください。", "이미 이 폴더에 있습니다": "既にこのフォルダーにあります", "이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}", + "이미 이 폴더에 있어 건너뛰었습니다: {0}.": "既にこのフォルダーにあるためスキップしました: {0}。", "이미 이 폴더에 있어 건너뜀: {0}": "既にこのフォルダーにあるためスキップ: {0}", "이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}", "이미 있어 건너뜀": "すでにあるためスキップ", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 6c132c9..ddffda0 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -583,6 +583,7 @@ "{0}개 항목을 휴지통으로 보낼까요?": "将 {0} 个项目移到回收站吗?", "{0}개 항목을 휴지통으로 보냈습니다.": "已将 {0} 个项目移到回收站。", "{0}개는 더 새 파일이라 그대로 둠": "{0} 个因现有文件更新而保留", + "{0}개는 원본에 남았습니다 (건너뜀).": "{0} 个留在源中(已跳过)。", "{0}개는 원본에 남음 (건너뜀)": "{0} 个留在源中(已跳过)", "{0}개는 지우지 않았습니다 (취소).": "{0} 个未删除 (已取消)。", "{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 个超出回收站容量,已永久删除 (按 Windows 的提示)。", @@ -673,6 +674,7 @@ "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}", + "같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.": "因存在同名文件夹,已跳过 {0} 项: {1}。", "같은 이름의 폴더가 있어 건너뜀 {0}": "因存在同名文件夹而跳过 {0}", "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "因存在同名文件夹跳过 {0} 项: {1}", "같은 이름이 이미 있습니다": "已存在同名项目", @@ -681,6 +683,7 @@ "같은 이름이 있는지 확인하는 중 …": "正在检查是否有同名项目 …", "같은 이름이 있으면": "如果同名", "같은 이름이 있을 때": "存在同名项目时", + "같은 이름이라 건너뛰었습니다: {0}.": "因同名已跳过: {0}。", "같은 이름이라 건너뜀 {0}개": "因同名跳过 {0} 个", "같은 이름이라 건너뜀: {0}": "因同名已跳过: {0}", "같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)", @@ -702,7 +705,7 @@ "개인 키 파일 고르기": "选择私钥文件", "개인 키 파일이 아닙니다 (너무 큼)": "这不是私钥文件 (文件过大)", "개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥 (不保存原文件路径)", - "개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥。此密钥设有密码,请在下方输入私钥密码 (不保存原文件路径)", + "개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥。此密钥设有密码,请在上方的 [{0}] 栏输入私钥密码 (不保存原文件路径)", "개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "无法读取私钥。请选择 OpenSSH 或 PEM (RSA · EC) 格式的私钥 (ed25519 · rsa · ecdsa) (.pub 是公钥)", "개인 키만 쓰면 비웁니다": "仅使用私钥时留空", "개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "私钥设有密码。请输入私钥密码", @@ -1340,6 +1343,7 @@ "받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "将覆盖接收端的 {0} 个文件(同名但内容不同 - 原文件将丢失)", "받는 쪽이 더 새것 {0} (건너뜀)": "接收端较新 {0}(跳过)", "받는 쪽이 더 새것 {0}개 건너뜀": "已跳过接收端较新的 {0} 个", + "받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.": "因接收端较新,已跳过 {0} 个。", "받는 쪽이 더 새것이면 건너뛰기": "接收端较新时跳过", "받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留", "받다 만 AI 모델": "未下载完的 AI 模型", @@ -2157,6 +2161,7 @@ "이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "已在运行的窗口没有响应。\n\n[是] 仍然打开 - 同一数据上将有两个窗口,设置和列表可能互相覆盖。\n[否] 关闭 - 请在任务管理器中结束无响应的窗口后重新运行。", "이미 이 폴더에 있습니다": "已在此文件夹中", "이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}", + "이미 이 폴더에 있어 건너뛰었습니다: {0}.": "已在此文件夹中,已跳过: {0}。", "이미 이 폴더에 있어 건너뜀: {0}": "已在此文件夹中,已跳过: {0}", "이미 있는 폴더입니다: {0}": "文件夹已存在: {0}", "이미 있어 건너뜀": "已存在,已跳过", diff --git a/integration_test/windows_dlcancel_shot_test.dart b/integration_test/windows_dlcancel_shot_test.dart new file mode 100644 index 0000000..bfaf2fc --- /dev/null +++ b/integration_test/windows_dlcancel_shot_test.dart @@ -0,0 +1,104 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/download_detect.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/services/downloader.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 받는 척만 하는 백엔드 (네트워크 없음) +class _Backend implements DownloadBackend { + @override + final DownloadKind kind; + _Backend(this.kind); + @override + Future<(String, List)?> expandPlaylist(String url) async => null; + @override + Future start(DownloadTask t, void Function() changed) async { + t + ..state = DownloadState.downloading + ..progress = 0.4 + ..speed = '1.2MB/s' + ..title = kind == DownloadKind.video ? 'Test video' : 'ubuntu-24.04.1-desktop-amd64.iso'; + changed(); + } + + @override + Future pause(DownloadTask t) async => t.state = DownloadState.paused; + @override + Future cancel(DownloadTask t) async => t.state = DownloadState.cancelled; + @override + Future shutdown() async {} +} + +/// 185 (관리자 지시 10/21): [다운로드 취소] 를 누르면 묻는 창 - [계속 받기] 면 그대로 받는다. ko · en · ja × 412 · 960 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('185 계속 받기 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_dlcancel_'); + final d = DownloadManager( + backends: [_Backend(DownloadKind.video), _Backend(DownloadKind.torrent)], + settings: () => AppSettings()..downloadRoot = tmp.path, + readClipboard: () async => null, + ); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: DownloadsPage(d: d)), + )); + Future settle([int n = 8]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(3); + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'dlcancel_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + d.add('https://youtu.be/AAA111'); + d.add('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'); + await settle(); + await t.tap(find.text(tr('전체 선택'))); + await settle(); + await t.tap(find.text(tr('다운로드 취소'))); + await settle(); + expect(find.text(trf('선택한 다운로드 {0}개를 취소할까요? 잠깐 멈추려면 [일시정지] 를 쓰세요.', [2])), findsOneWidget); + await shot('1_ask'); + await t.tap(find.text(tr('계속 받기'))); + await settle(); + expect(d.tasks.every((x) => x.state == DownloadState.downloading), isTrue, reason: '[계속 받기] 면 그대로'); + await shot('2_kept'); + expect(p.basename(tmp.path), startsWith('jj_dlcancel_')); + }); + } + } +} diff --git a/integration_test/windows_move_left_shot_test.dart b/integration_test/windows_move_left_shot_test.dart index 766a463..75ebf01 100644 --- a/integration_test/windows_move_left_shot_test.dart +++ b/integration_test/windows_move_left_shot_test.dart @@ -84,7 +84,7 @@ void main() { await settle(() => false, rounds: 10); // 알림이 다 올라오게 expect(File(p.join(right, 'D', 'add.txt')).existsSync(), isTrue); expect(File(p.join(d, 'same.txt')).readAsStringSync(), 'old', reason: '받는 쪽이 더 새것이라 원본에 남음'); - expect(find.textContaining(trf('{0}개는 원본에 남음 (건너뜀)', [1])), findsOneWidget, reason: '264: 알림에'); + expect(find.textContaining(trf('{0}개는 원본에 남았습니다 (건너뜀).', [1])), findsOneWidget, reason: '264: 알림에'); expect(find.text('add.txt'), findsNothing, reason: '265: 원본 D 를 다시 읽음'); expect(find.text('same.txt'), findsOneWidget); await shot('2_after'); diff --git a/integration_test/windows_sshkey_shot_test.dart b/integration_test/windows_sshkey_shot_test.dart index 72cf803..019ec32 100644 --- a/integration_test/windows_sshkey_shot_test.dart +++ b/integration_test/windows_sshkey_shot_test.dart @@ -117,7 +117,7 @@ void main() { await shot('1_ppk', 'Export OpenSSH key'); // ② 암호가 걸린 키를 고르면 "아래에 키 암호를" await pick(keyA); - await shot('2_picked', tr('개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)')); + await shot('2_picked', trf('개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)', [tr('개인 키 암호')])); // ③ 키 암호 없이 [연결 확인] await testConnection(); await shot('3_needpass', tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요')); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 1376e42..dbcea2a 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3317,14 +3317,15 @@ class _ExplorerPageState extends State { ? trf('끝나지 못했습니다: {0}', [error]) : [ trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]), + // 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ") // 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등) - if (move && job.leftInSource > 0) trf('{0}개는 원본에 남음 (건너뜀)', [job.leftInSource]), - if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것 {0}개 건너뜀', [job.keptNewer]), - if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]), + if (move && job.leftInSource > 0) trf('{0}개는 원본에 남았습니다 (건너뜀).', [job.leftInSource]), + if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.', [job.keptNewer]), + if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뛰었습니다: {0}.', [_namesOf(job.skipped)]), // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 - if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 건너뜀 {0}개: {1}', [job.dirConflicts.length, _namesOf(job.dirConflicts)]), - if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]), - ].join(' · '); + if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, _namesOf(job.dirConflicts)]), + if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [_namesOf(skippedHere)]), + ].join(' '); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) if (error == null && mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( diff --git a/lib/ui/sftp_settings.dart b/lib/ui/sftp_settings.dart index a7084bd..97bd53b 100644 --- a/lib/ui/sftp_settings.dart +++ b/lib/ui/sftp_settings.dart @@ -231,7 +231,10 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS final encrypted = checkPrivateKey(text); set(() { key = text; - result = encrypted ? tr('개인 키를 넣었습니다. 암호가 걸린 키라 아래에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)') : tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)'); + // 관리자 지시 (10/21, 15 찾음 F4): 안내는 창 맨 아래 - 키 암호 칸은 그 위 (칸 이름은 실제 칸 이름 그대로) + result = encrypted + ? trf('개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)', [tr('개인 키 암호')]) + : tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)'); passed = true; }); } catch (e) { diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index f70edd7..5963214 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -134,7 +134,7 @@ void main() { await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.byType(SnackBar).evaluate().isNotEmpty); expect(File(p.join(right, 'D', 'add.txt')).existsSync(), isTrue); expect(File(p.join(d, 'same.txt')).existsSync(), isTrue, reason: '받는 쪽이 더 새것이라 원본에 남음'); - expect(find.textContaining('1개는 원본에 남음 (건너뜀)'), findsOneWidget, reason: '264: 알림에도'); + expect(find.textContaining('1개는 원본에 남았습니다 (건너뜀).'), findsOneWidget, reason: '264: 알림에도'); expect(find.text('add.txt'), findsNothing, reason: '265: 원본 D 를 다시 읽음 (오른쪽 D 는 펼치지 않음)'); expect(find.text('same.txt'), findsOneWidget); await calm(tester); @@ -359,13 +359,13 @@ void main() { expect(find.textContaining('2개 표시함'), findsOneWidget); await tester.runAsync(() => tester.tap(find.text('이동').first)); await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30); - expect(find.textContaining('이미 이 폴더에 있어 건너뜀: b.txt'), findsOneWidget); + expect(find.textContaining('이미 이 폴더에 있어 건너뜀: b.txt'), findsOneWidget, reason: '확인 창 (설명 목록 꼴 그대로)'); await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '이동'))); await settle(tester, () => File(p.join(right, 'a.txt')).existsSync() && find.byType(SnackBar).evaluate().isNotEmpty); expect(File(p.join(right, 'a.txt')).readAsStringSync(), 'a'); expect(File(p.join(left, 'a.txt')).existsSync(), isFalse); expect(File(p.join(right, 'b.txt')).readAsStringSync(), 'b'); - expect(find.textContaining('이미 이 폴더에 있어 건너뜀: b.txt'), findsOneWidget, reason: '끝난 알림에도'); + expect(find.textContaining('이미 이 폴더에 있어 건너뛰었습니다: b.txt.'), findsOneWidget, reason: '끝난 알림에도'); }); testWidgets('147: 버튼 줄이 다 들어가지 않으면 (낮은 화면) 나머지는 [더 보기] 메뉴로 - "숨은 항목 표시" 도 누를 수 있다', (tester) async { @@ -522,6 +522,6 @@ void main() { await confirm(tester); expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'old'); expect(File(p.join(right, 'doc (2).txt')).existsSync(), isFalse); - expect(find.textContaining('같은 이름이라 건너뜀: doc.txt'), findsOneWidget); + expect(find.textContaining('같은 이름이라 건너뛰었습니다: doc.txt.'), findsOneWidget); }); } From cb9e4433d33b4a9077198cf56ee1ba87c84ce34a Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:37:53 +0900 Subject: [PATCH 16/20] =?UTF-8?q?=EB=81=9D=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=A5=20=EC=9E=87=EA=B8=B0:=20=EC=9D=BC=EB=B3=B8?= =?UTF-8?q?=EC=96=B4=20=C2=B7=20=EC=A4=91=EA=B5=AD=EC=96=B4=EB=8A=94=20?= =?UTF-8?q?=E3=80=82=20=EB=92=A4=EC=97=90=20=EB=B9=88=EC=B9=B8=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20(joinSentences)=20=C2=B7=20262=20=EC=BA=A1=EC=B2=98?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=A7=80=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - l10n/tr.dart joinSentences: 한국어 · 영어는 빈칸, ja · zh 는 붙여서 (「移動しました。1 件は…」). 빈 조각은 뺌 - 파일 탐색기의 끝 알림 5곳 (복사 · 이동 끝, 긴 경로 영구 삭제, 휴지통 보내기, 휴지통 되돌리기, 끌어 놓아 옮긴 뒤) 이 이것으로 잇는다 - 시험: i18n_test (ko · en · ja · zh-Hans), moveleft 캡처 ja 960 직접 봄 - 빈칸 없음 - 262: windows_mkvend_shot_test - 가짜 FFmpeg 로 2개 묶음, 첫 동영상이 끝나는 순간 그 줄의 ■ 자리를 눌러도 ⟳ · ✕ 가 먹지 않고 둘 다 성공. ko · en · ja × 412 · 960, 정식 판 후보 1b31609 로 6개 통과 (release\test\shots_v20261020_001 mkvend_*), after_1020 으로도 6개. i18n · explorer_conflict · batch 55개 통과 Co-Authored-By: Claude Opus 5.5 --- .../windows_mkvend_shot_test.dart | 137 ++++++++++++++++++ lib/l10n/tr.dart | 4 + lib/ui/explorer_page.dart | 20 +-- test/i18n_test.dart | 12 ++ 4 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 integration_test/windows_mkvend_shot_test.dart diff --git a/integration_test/windows_mkvend_shot_test.dart b/integration_test/windows_mkvend_shot_test.dart new file mode 100644 index 0000000..0d5caae --- /dev/null +++ b/integration_test/windows_mkvend_shot_test.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/services/media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 시험이 끝내라고 할 때까지 FFmpeg 이 도는 척 (끝나면 임시 출력 파일을 씀) - test/batch_test.dart 의 _EndTool 과 같음 +class _EndTool implements MediaTool { + final runs = >{}; + @override + Future version() async => 'ffmpeg test'; + @override + Future> encoders() async => {'libx264'}; + @override + Future probe(String path) async => const MediaInfo(duration: Duration(seconds: 1)); + @override + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async { + final c = runs[tag] = Completer(); + try { + await c.future; + } finally { + runs.remove(tag); + } + File(args.last).writeAsStringSync('NEW'); + } + + void _stop(Completer? c) { + if (c != null && !c.isCompleted) c.completeError(MediaToolException('사용자가 취소했습니다.')); + } + + void finish(Object tag) => runs[tag]!.complete(); + @override + void cancel() => runs.values.toList().forEach(_stop); + @override + void cancelTagged(Object tag) => _stop(runs[tag]); +} + +/// 262 (관리자 지시 10/21): MKV 묶음에서 한 동영상이 끝나는 바로 그때 - 그 줄의 ■ 자리에 오는 ⟳ 는 묶음이 끝날 때까지 눌리지 않고 +/// (✕ 도 밀려오지 않음), 끝나면 둘 다 성공. ko · en · ja × 412 · 960 (가짜 FFmpeg, 시험 폴더 안) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'en', 'ja']) { + for (final (w, h) in [(412, 900), (960, 600)]) { + testWidgets('262 MKV 끝나는 순간 $lang ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_mkvend_'); + appTempInto(tmp); + final files = [for (final n in ['episode_01', 'episode_02']) (File(p.join(tmp.path, '$n.mp4'))..writeAsBytesSync([0])).path]; + final tool = _EndTool(); + final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService())); + late Future building; + await t.runAsync(() async { + await c.init(); + c.settings.maxParallelJobs = 1; + await c.addVideos(files); + building = c.buildVideos([...c.videos]); + for (var i = 0; i < 200 && tool.runs.isEmpty; i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + final [a, b] = c.videos; + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: HomePage(c: c)), + )); + Future settle([int n = 6]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(2); + expect(t.takeException(), isNull, reason: '$name 넘침'); + if (out.isEmpty) return; + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'mkvend_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(); + final stop = find.byTooltip(tr('이 동영상만 취소')); + expect(stop, findsWidgets, reason: '도는 줄의 ■'); + final stopAt = t.getCenter(stop.first); + await shot('1_running'); + // a 가 끝나고 b 가 시작 - a 의 ■ 자리를 누른다 (손가락이 ■ 를 누르려던 것) + await t.runAsync(() async { + tool.finish(a); + for (var i = 0; i < 200 && !tool.runs.containsKey(b); i++) { + await Future.delayed(const Duration(milliseconds: 10)); + } + }); + await settle(); + expect(a.status, JobStatus.done); + await t.tapAt(stopAt); + await settle(); + expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음'); + expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음'); + await shot('2_a_done_b_running'); + await t.runAsync(() async { + tool.finish(b); + await building; + }); + await settle(); + expect((a.status, b.status), (JobStatus.done, JobStatus.done)); + await shot('3_all_done'); + await t.pumpWidget(const SizedBox()); + await t.pump(const Duration(seconds: 2)); + }); + } + } +} diff --git a/lib/l10n/tr.dart b/lib/l10n/tr.dart index a6d789c..c2b0c17 100644 --- a/lib/l10n/tr.dart +++ b/lib/l10n/tr.dart @@ -12,6 +12,10 @@ Map _fallback = const {}; /// 지금 화면 언어 코드 (ko · en · ja · zh-Hans …) String get uiLanguage => _lang; +/// 문장 여럿을 한 줄로 잇는다 - 한국어 · 영어는 빈칸, 일본어 · 중국어는 붙여서 (。 뒤에 빈칸을 두지 않음, 관리자 지시 10/21) +String joinSentences(Iterable parts) => + parts.where((x) => x.isNotEmpty).join(_lang.startsWith('ja') || _lang.startsWith('zh') ? '' : ' '); + /// 화면 언어와 사전을 바꾼다. [fallback] 은 사전에 없는 글자를 찾을 사전 (보통 영어). void setTranslations(String lang, Map dict, [Map fallback = const {}]) { _lang = lang; diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index dbcea2a..19672de 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -2881,18 +2881,18 @@ class _ExplorerPageState extends State { notDeleted.add('${vBasename(x)}: ${e is FileSystemException ? (e.osError?.message ?? e.message) : e}'); } } - _snack([ + _snack(joinSentences([ if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]), if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]), - ].join(' ')); + ])); } } else if (!permanent && mounted) { - final text = [ + final text = joinSentences([ if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]), // 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다 if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]), if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]), - ].join(' '); + ]); // 148: 휴지통으로 보낸 것은 바로 [되돌리기] ScaffoldMessenger.of(context).showSnackBar(SnackBar( duration: const Duration(seconds: 10), @@ -2973,10 +2973,10 @@ class _ExplorerPageState extends State { for (final x in _panes) { x.changed(); } - _snack([ + _snack(joinSentences([ if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]), if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]), - ].join(' ')); + ])); } /// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소) @@ -3315,7 +3315,7 @@ class _ExplorerPageState extends State { ? tr('취소했습니다.') : error != null ? trf('끝나지 못했습니다: {0}', [error]) - : [ + : joinSentences([ trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]), // 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ") // 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등) @@ -3325,7 +3325,7 @@ class _ExplorerPageState extends State { // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, _namesOf(job.dirConflicts)]), if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [_namesOf(skippedHere)]), - ].join(' '); + ]); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) if (error == null && mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( @@ -4646,11 +4646,11 @@ class _ExplorerPageState extends State { ..hideCurrentSnackBar() ..showSnackBar(SnackBar( duration: const Duration(seconds: 10), - content: Text([ + content: Text(joinSentences([ trf('{0}개를 옮겼습니다.', [moved]), if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]), if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]), - ].join(' ')), + ])), action: SnackBarAction(label: tr('되돌리기'), onPressed: () => unawaited(_undoMove(_panes[load.from], back))), )); } diff --git a/test/i18n_test.dart b/test/i18n_test.dart index 146ea9f..8b8c684 100644 --- a/test/i18n_test.dart +++ b/test/i18n_test.dart @@ -61,6 +61,18 @@ void main() { dir.deleteSync(recursive: true); }); + test('관리자 지시 (10/21): 문장 잇기 - 한국어 · 영어는 빈칸, 일본어 · 중국어는 。 뒤에 빈칸 없이', () { + for (final (lang, want) in [ + ('ko', '옮겼습니다. 남았습니다.'), + ('en', '옮겼습니다. 남았습니다.'), + ('ja', '옮겼습니다.남았습니다.'), + ('zh-Hans', '옮겼습니다.남았습니다.'), + ]) { + setTranslations(lang, const {}); + expect(joinSentences(['옮겼습니다.', '', '남았습니다.']), want, reason: lang); + } + }); + test('기본 사전: 영어 · 일본어 · 중국어가 같은 열쇠를 모두 가진다', () { final en = _asset('en'), ja = _asset('ja'), zh = _asset('zh-Hans'); expect(en.length, greaterThan(700)); From ebaa054922d000212aec5dffba53063889300db2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:11:22 +0900 Subject: [PATCH 17/20] =?UTF-8?q?F6=20MKV=20=EC=9E=90=EB=A7=89=20=EC=A4=84?= =?UTF-8?q?:=20=EC=A2=81=EC=95=84=20=EB=84=98=EC=B9=A0=20=EB=95=8C?= =?UTF-8?q?=EB=A7=8C=20=EB=91=90=20=EC=A4=84=20(=EC=9C=84=20=3D=20?= =?UTF-8?q?=EB=82=B4=EC=9E=A5=20=C2=B7=20=ED=8A=B8=EB=9E=99=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84,=20=EC=95=84=EB=9E=98=20=3D=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=C2=B7=20=EB=AC=B8=EC=9E=90=EC=85=8B=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EB=8B=A8=EC=B6=94)=20=C2=B7=20F5=20=C2=B7?= =?UTF-8?q?=20206=20=EC=BA=A1=EC=B2=98=20=EC=8B=9C=ED=97=98=20(=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=8A=B9=EC=9D=B8=2010/21,=2015=20?= =?UTF-8?q?=EC=B0=BE=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인: 자막 줄은 한 줄 Row 라 좁은 폭 · 큰 글자 · 일본어에서 오른쪽으로 넘쳐 (1b31609: ja 412 글자 1.0 에서 65px, 외부 자막 + 1.6 에서 219px) 끝의 편집 · 지우기 단추가 잘려 누를 수 없었다 - 고침: 한 줄에 드는 폭 (내장 표 · 이름 최소 60 · 언어 고르기 · 문자셋 · 단추) 을 지금 글꼴 · 글자 배율로 잰다 (고르기는 가장 긴 항목 글의 폭 - DropdownButton 이 그 폭을 씀). 모자랄 때만 두 줄, 아래 줄은 Wrap. 언어 이름은 "…" 로 줄이지 않음 (무엇을 골랐는지 보이게, 3a 결정). 넓을 때는 예전 한 줄 그대로. 오디오 트랙은 줄이 없고 칩 (음성 n개) 뿐이라 해당 없음 - windows_mkvdetail_shot_test: 412 · 960×600 · 1280×720 × 글자 1.0 · 1.6 × ko · ja (내장 + 외부 자막). 넘침 없음 · 처음 자리에서 칩과 [이동] 단추가 겹치지 않음 (206) · 끝까지 내리면 출력 줄이 단추 위 (F5 - 이미 있던 목록 끝 여백으로) 를 자리로 잰다. 1b31609 에서는 6개 실패 (넘침), 이 고침으로 12개 모두 통과. HomePage 를 쓰는 단위 11파일 111개 통과 Co-Authored-By: Claude Opus 5.5 --- .../windows_mkvdetail_shot_test.dart | 140 +++++++++++++++ lib/ui/home_page.dart | 161 +++++++++++------- 2 files changed, 238 insertions(+), 63 deletions(-) create mode 100644 integration_test/windows_mkvdetail_shot_test.dart diff --git a/integration_test/windows_mkvdetail_shot_test.dart b/integration_test/windows_mkvdetail_shot_test.dart new file mode 100644 index 0000000..f88de29 --- /dev/null +++ b/integration_test/windows_mkvdetail_shot_test.dart @@ -0,0 +1,140 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/core/output_paths.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/services/media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 동영상 하나 (음성 2 · 자막 1) 로 보이는 가짜 FFmpeg (실제로 만들지 않음) +class _Tool implements MediaTool { + @override + Future version() async => 'ffmpeg test'; + @override + Future> encoders() async => {'libx264'}; + @override + Future probe(String path) async => MediaInfo(duration: const Duration(minutes: 24), streams: [ + const StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080), + const StreamInfo(index: 1, type: 'audio', codec: 'aac', language: 'jpn'), + const StreamInfo(index: 2, type: 'audio', codec: 'aac', language: 'kor'), + const StreamInfo(index: 3, type: 'subtitle', codec: 'subrip', language: 'kor'), + ]); + @override + Future runFfmpeg(List args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {} + @override + void cancel() {} + @override + void cancelTagged(Object tag) {} +} + +/// F5 · 206 (관리자 지시 10/21): MKV 자세히 칸 - 오른쪽 아래 떠 있는 [이동] 단추가 마지막 줄 ("출력: …") 이나 칩을 가리지 않는다. +/// 넓은 화면은 끝까지 내리면 마지막 줄이 단추 위로 (목록 끝 여백), 좁은 화면 (412) 은 단추가 칩 줄 아래에 놓인다. +/// 412 · 960×600 · 1280×720 × 글자 1.0 · 1.6 × ko · ja. 단추와 출력 줄 · 칩이 겹치는지 자리로 잰다 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'ja']) { + for (final (w, h, scale) in [(412, 900, 1.0), (412, 900, 1.6), (960, 600, 1.0), (960, 600, 1.6), (1280, 720, 1.0), (1280, 720, 1.6)]) { + testWidgets('MKV 자세히 칸 이동 단추 $lang ${w}x$h 글자 $scale', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_mkvdetail_'); + appTempInto(tmp); + final file = (File(p.join(tmp.path, '[시험] 아주 긴 이름의 동영상 - 1화 고화질.mp4'))..writeAsBytesSync([0])).path; + // 같은 폴더의 외부 자막 (문자셋 고르기까지 든 줄 - F6) + File(p.join(tmp.path, '[시험] 아주 긴 이름의 동영상 - 1화 고화질.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); + final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService())); + await t.runAsync(() async { + await c.init(); + await c.addVideos([file]); + }); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), + debugShowCheckedModeBanner: false, + builder: (ctx, child) => MediaQuery( + data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!), + home: HomePage(c: c), + ), + )); + Future settle([int n = 8]) async { + for (var i = 0; i < n; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await t.pump(); + } + } + + Future shot(String name) async { + await settle(2); + if (out.isEmpty) return; + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'mkvdetail_${name}_${lang}_${w}x${h}_s$scale.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + + await settle(); + // 좁은 화면은 목록 · 자세히가 위아래 - 동영상 줄을 눌러 자세히를 연다 (넓은 화면은 처음부터 보임) + final outputLine = find.text(trf('출력: {0}', [outputMkvPath(file)])); + if (outputLine.evaluate().isEmpty && find.text(tr('자막')).evaluate().isEmpty) { + await t.tap(find.textContaining('아주 긴 이름의 동영상').first); + await settle(); + } + expect(t.takeException(), isNull, reason: '넘침 (처음 자리)'); + final fab = find.byType(FloatingActionButton); + // 206: 처음 자리에서 칩 (음성 n개 등) 이 [이동] 단추에 가리지 않음 (좁은 화면은 단추가 칩 줄 아래에 놓임) + if (fab.evaluate().isNotEmpty) { + final b = t.getRect(fab.first); + for (final text in [trf('음성 {0}개', [2]), trf('영상 {0}', ['h264']), '1920×1080']) { + if (find.text(text).evaluate().isEmpty) continue; + final r = t.getRect(find.text(text)); + expect(r.overlaps(b), isFalse, reason: '칩 "$text" 이 단추에 가리지 않음 ($r · 단추 $b)'); + } + } + // 글자가 크면 출력 줄이 아래에 있어 아직 만들어지지 않았을 수 있다 - 자세히 칸을 (칩 자리에서) 끌어 내려 찾는다 + if (outputLine.evaluate().isEmpty) { + final down = find.byWidgetPredicate((x) => x is Scrollable && x.axisDirection == AxisDirection.down); + final pane = t.state(find.ancestor(of: find.text('1920×1080'), matching: down).first); + for (var i = 0; i < 10 && outputLine.evaluate().isEmpty; i++) { + pane.position.jumpTo(pane.position.maxScrollExtent); + await settle(3); + } + } + expect(outputLine, findsOneWidget, reason: '자세히 칸의 출력 줄'); + await shot('1_top'); + expect(t.takeException(), isNull, reason: '넘침 (출력 줄까지)'); + // F5: 자세히 칸을 끝까지 내리면 출력 줄이 단추 위로 (목록 끝 여백) + final scroll = t.state(find.ancestor(of: outputLine, matching: find.byType(Scrollable)).first); + scroll.position.jumpTo(scroll.position.maxScrollExtent); + await settle(); + await shot('2_end'); + expect(t.takeException(), isNull, reason: '넘침 (끝)'); + if (fab.evaluate().isNotEmpty && outputLine.evaluate().isNotEmpty) { + final b = t.getRect(fab.first), line = t.getRect(outputLine); + expect(line.overlaps(b), isFalse, reason: '끝까지 내리면 출력 줄이 단추에 가리지 않음 (줄 $line · 단추 $b)'); + } + expect(c.videos.single.status, isNot(JobStatus.running)); + }); + } + } +} diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 6bdba63..ae7504a 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -1288,10 +1288,85 @@ class _SubtitleRow extends StatelessWidget { const _SubtitleRow( {required this.c, required this.v, required this.s, required this.locked}); + /// 관리자 지시 (10/21, 15 찾음 F6): 한 줄에 들어가는 폭 - 내장 표 · 이름 (최소) · 언어 고르기 · 문자셋 · 단추. + /// 고르기는 가장 긴 항목 글의 폭을 쓰므로 (DropdownButton) 그 글을 지금 글꼴 · 글자 배율로 잰다 + static double _oneLineWidth(BuildContext context, {required bool embedded, required int buttons}) { + final scaler = MediaQuery.textScalerOf(context); + final base = DefaultTextStyle.of(context).style; + double textW(String t, double size) => + (TextPainter(text: TextSpan(text: t, style: base.copyWith(fontSize: size)), textDirection: TextDirection.ltr, textScaler: scaler, maxLines: 1) + ..layout()) + .width; + double widest(Iterable xs, double size) => xs.fold(0.0, (m, t) => math.max(m, textW(t, size))); + final lang = widest([tr('언어: 미지정'), for (final l in languages) '${l.name} (${l.code})'], 12) + 24; + final charset = embedded ? 0.0 : widest(supportedCharsets.values, 12) + 24 + 8; + final badge = textW(embedded ? tr('내장') : tr('외부'), 11) + 12; + const nameMin = 60.0; + return badge + 10 + nameMin + 8 + lang + charset + 4 + buttons * 48; + } + @override Widget build(BuildContext context) { final embedded = s.kind == SubtitleKind.embedded; final off = !s.enabled; + final badge = Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: (embedded ? JjColors.textDim : JjColors.accent) + .withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(4), + ), + child: Text(embedded ? tr('내장') : tr('외부'), + style: TextStyle( + fontSize: 11, + color: embedded ? JjColors.textDim : JjColors.accent)), + ); + final name = Text( + s.displayName, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: off ? JjColors.textDim : JjColors.text, + decoration: off ? TextDecoration.lineThrough : null, + ), + ); + final pickers = [ + _LanguageDropdown( + value: s.language, + onChanged: locked || off ? null : (l) => c.setLanguage(s, l), + ), + if (!embedded) + _CharsetDropdown( + value: s.charset ?? 'UTF-8', + onChanged: locked ? null : (cs) => c.setCharset(s, cs), + ), + ]; + final buttons = [ + IconButton( + tooltip: c.canEdit(s) ? tr('내용 편집 · 문자셋 변환') : tr('이미지 자막은 편집할 수 없습니다'), + iconSize: 18, + onPressed: locked || off || !c.canEdit(s) + ? null + : () => openSubtitleEditor(context, c, v, s), + icon: const Icon(Icons.edit_note, color: JjColors.accent), + ), + if (c.services.createTranslator != null) + IconButton( + tooltip: c.canEdit(s) ? trf('다른 언어로 번역 (AI · 이 {0}에서)', [Platform.isAndroid ? tr('기기') : 'PC']) : tr('이미지 자막은 번역할 수 없습니다'), + iconSize: 18, + onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s), + icon: const Icon(Icons.translate, color: JjColors.accent), + ), + IconButton( + tooltip: embedded ? (off ? tr('되살리기') : tr('삭제 (출력에서 제외)')) : tr('목록에서 제거'), + iconSize: 18, + onPressed: locked ? null : () => c.removeSubtitle(v, s), + icon: Icon( + embedded && off ? Icons.undo : Icons.delete_outline, + color: JjColors.textDim, + ), + ), + ]; return Container( margin: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), @@ -1300,71 +1375,31 @@ class _SubtitleRow extends StatelessWidget { borderRadius: BorderRadius.circular(8), border: Border.all(color: JjColors.border), ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: (embedded ? JjColors.textDim : JjColors.accent) - .withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(4), - ), - child: Text(embedded ? tr('내장') : tr('외부'), - style: TextStyle( - fontSize: 11, - color: embedded ? JjColors.textDim : JjColors.accent)), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - s.displayName, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 13, - color: off ? JjColors.textDim : JjColors.text, - decoration: off ? TextDecoration.lineThrough : null, - ), - ), - ), - const SizedBox(width: 8), - _LanguageDropdown( - value: s.language, - onChanged: locked || off ? null : (l) => c.setLanguage(s, l), - ), - if (!embedded) ...[ + child: LayoutBuilder(builder: (context, box) { + final fits = box.maxWidth >= _oneLineWidth(context, embedded: embedded, buttons: buttons.length); + if (fits) { + // 넓을 때: 지금처럼 한 줄 + return Row(children: [ + badge, + const SizedBox(width: 10), + Expanded(child: name), const SizedBox(width: 8), - _CharsetDropdown( - value: s.charset ?? 'UTF-8', - onChanged: locked ? null : (cs) => c.setCharset(s, cs), - ), - ], - const SizedBox(width: 4), - IconButton( - tooltip: c.canEdit(s) ? tr('내용 편집 · 문자셋 변환') : tr('이미지 자막은 편집할 수 없습니다'), - iconSize: 18, - onPressed: locked || off || !c.canEdit(s) - ? null - : () => openSubtitleEditor(context, c, v, s), - icon: const Icon(Icons.edit_note, color: JjColors.accent), + for (final (i, x) in pickers.indexed) ...[if (i > 0) const SizedBox(width: 8), x], + const SizedBox(width: 4), + ...buttons, + ]); + } + // 좁아 넘칠 때만 두 줄: 위 = 내장 표 · 트랙 이름, 아래 = 언어 고르기 · 단추 (언어 이름은 줄이지 않는다 - 무엇을 골랐는지 보이게) + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [badge, const SizedBox(width: 10), Expanded(child: name)]), + const SizedBox(height: 2), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [...pickers, Row(mainAxisSize: MainAxisSize.min, children: buttons)], ), - if (c.services.createTranslator != null) - IconButton( - tooltip: c.canEdit(s) ? trf('다른 언어로 번역 (AI · 이 {0}에서)', [Platform.isAndroid ? tr('기기') : 'PC']) : tr('이미지 자막은 번역할 수 없습니다'), - iconSize: 18, - onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s), - icon: const Icon(Icons.translate, color: JjColors.accent), - ), - IconButton( - tooltip: embedded ? (off ? tr('되살리기') : tr('삭제 (출력에서 제외)')) : tr('목록에서 제거'), - iconSize: 18, - onPressed: locked ? null : () => c.removeSubtitle(v, s), - icon: Icon( - embedded && off ? Icons.undo : Icons.delete_outline, - color: JjColors.textDim, - ), - ), - ], - ), + ]); + }), ); } } From af77dc56d2cc6e5b91b5b1574a040204b5f287fb Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:21:45 +0900 Subject: [PATCH 18/20] =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=8B=9C=ED=97=98:?= =?UTF-8?q?=20253=20=C2=B7=20255=20=C2=B7=20256=20-=20=ED=81=B0=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=20=C2=B7=20=ED=8F=B0=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=206=EA=B0=9C=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EA=B8=80=EA=BC=B4=EB=A1=9C=20(=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windows_bigtext_shot_test: test/large_text_test 의 화면 6개 (MKV · 파일 탐색기 · Rsync · 모니터링 · 다운로드 · 환경 설정) 를 실제 글꼴로, 글자 1.6배 (412×900 · 900×412 · 1280×800) · 글자 1.0 (412×900 · 900×412) × ko · ja = 60개. 넘침 없음 · 누르는 것이 화면 폭 안 (가로로 넘기는 막대 안은 뺌). MKV 는 동영상 5개 · 작업 기록 3줄 (256: 폰 가로에서 기록을 켜도 목록이 3줄 보임) - 정식 판 후보 1b31609 로 60개 통과 (release\test\shots_v20261020_001 bigtext_*) Co-Authored-By: Claude Opus 5.5 --- .../windows_bigtext_shot_test.dart | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 integration_test/windows_bigtext_shot_test.dart diff --git a/integration_test/windows_bigtext_shot_test.dart b/integration_test/windows_bigtext_shot_test.dart new file mode 100644 index 0000000..b3d50c1 --- /dev/null +++ b/integration_test/windows_bigtext_shot_test.dart @@ -0,0 +1,117 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/downloads_page.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:jj_mkvmaker/ui/monitor_page.dart'; +import 'package:jj_mkvmaker/ui/settings_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 253 · 255 · 256 (관리자 지시 10/21): test/large_text_test 의 화면들을 실제 글꼴로 - 글자 1.6배 (412×900 · 900×412 · 1280×800) 와 +/// 폰 크기 글자 1.0 (412×900 세로 · 900×412 가로) 에서 넘치지 않고, 누르는 것 (단추 · 고르기 · 입력 칸) 이 화면 폭 안에 있다. ko · ja +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + final pages = { + 'mkv': (c, d) => HomePage(c: c), + 'explorer': (c, d) => ExplorerPage(c: c), + 'rsync': (c, d) => ExplorerPage(c: c, rsync: true), + 'monitor': (c, d) => MonitorPage(c: c), + 'downloads': (c, d) => DownloadsPage(d: d), + 'settings': (c, d) => SettingsPage(c: c), + }; + final cases = [ + for (final s in [const Size(412, 900), const Size(900, 412), const Size(1280, 800)]) (s, 1.6), + for (final s in [const Size(412, 900), const Size(900, 412)]) (s, 1.0), + ]; + for (final lang in ['ko', 'ja']) { + for (final MapEntry(key: name, value: page) in pages.entries) { + for (final (size, scale) in cases) { + final tag = '${name}_${lang}_${size.width.round()}x${size.height.round()}_s$scale'; + testWidgets('253 · 255 · 256 $tag', (t) async { + t.view.physicalSize = size; + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_bigtext_'); + appTempInto(tmp); + final video = p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4'); + File(video).writeAsStringSync('v'); + Directory(p.join(tmp.path, 'sub')).createSync(); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..explorerPaths = [tmp.path, tmp.path] + ..rsyncPaths = [tmp.path, tmp.path] + ..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')] + ..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))]; + c.videos.add(VideoItem(video)); + // 256: 폰 가로에서 작업 기록을 켜도 목록이 여러 줄 보이는지 - 동영상 5개 · 기록 몇 줄 + for (var i = 2; i <= 5; i++) { + final f = p.join(tmp.path, '에피소드 0$i.mp4'); + File(f).writeAsStringSync('v'); + c.videos.add(VideoItem(f)); + } + for (var i = 1; i <= 3; i++) { + c.note('시험 기록 $i'); + } + c.selected = c.videos.first; + final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null); + addTearDown(d.dispose); + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), + debugShowCheckedModeBanner: false, + builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!), + home: page(c, d), + ), + )); + for (var i = 0; i < 15; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 40))); + await t.pump(); + } + if (out.isNotEmpty) { + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'bigtext_$tag.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + expect(t.takeException(), isNull, reason: '$tag 넘침'); + // 넘침 오류 없이 옆으로 밀려나는 것도 본다: 누르는 것은 화면 폭 안 (가로로 넘기는 막대 안의 것은 빼고) + final hit = find.byWidgetPredicate((w) => + w is ButtonStyleButton || w is IconButton || w is Checkbox || w is DropdownButton || w is TextField || w is ChoiceChip || w is Radio); + final off = []; + for (final e in hit.evaluate()) { + final inRow = e.findAncestorWidgetOfExactType()?.scrollDirection == Axis.horizontal || + e.findAncestorWidgetOfExactType()?.scrollDirection == Axis.horizontal; + if (inRow) continue; + final r = t.getRect(find.byElementPredicate((x) => x == e)); + if (r.left < -0.5 || r.right > size.width + 0.5) off.add('${e.widget.runtimeType} $r'); + } + expect(off, isEmpty, reason: '$tag: 화면 밖 ${off.join(' · ')}'); + await t.pumpWidget(const SizedBox()); + await t.pump(const Duration(seconds: 1)); + }); + } + } + } +} From 4554b4b98d8b3287df82ae4bac6cad38ba47484c Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:24:37 +0900 Subject: [PATCH 19/20] =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EC=8B=9C=ED=97=98:?= =?UTF-8?q?=20261=20=C2=B7=20266=20=ED=8F=B0=20=EC=84=B8=EB=A1=9C=20MKV=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EC=A0=95=EB=B3=B4=20=C2=B7=20DNS=20?= =?UTF-8?q?=EC=A4=84=20(=EC=8B=A4=EC=A0=9C=20=EA=B8=80=EA=BC=B4,=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windows_mkvportrait_shot_test: test/mkv_portrait_detail_test 를 실제 글꼴로 - 412×869 · 작업 기록 켬 · 감지한 언어 줄 · DNS 닿지 않음 줄에서 [AI 자막 만들기] 가 스크롤 없이 보이고 DNS 줄은 한 줄 · [시스템 DNS 로 바꾸기] 도 같은 줄. ko · ja × 글자 1.0 · 1.6 × 3가지 = 12개 - 정식 판 후보 1b31609 로 12개 통과 (release\test\shots_v20261020_001 mkvportrait_*) Co-Authored-By: Claude Opus 5.5 --- .../windows_mkvportrait_shot_test.dart | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 integration_test/windows_mkvportrait_shot_test.dart diff --git a/integration_test/windows_mkvportrait_shot_test.dart b/integration_test/windows_mkvportrait_shot_test.dart new file mode 100644 index 0000000..afa0b62 --- /dev/null +++ b/integration_test/windows_mkvportrait_shot_test.dart @@ -0,0 +1,95 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/i18n_controller.dart'; +import 'package:jj_mkvmaker/app/net_guard.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/models.dart'; +import 'package:jj_mkvmaker/l10n/tr.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/home_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 261 · 266 (관리자 지시 10/21): test/mkv_portrait_detail_test 를 실제 글꼴로 - 폰 세로 (노트 10+ 412×869) · 작업 기록 켬 · +/// 감지한 언어 줄 · DNS 닿지 않음 줄이 있어도 [AI 자막 만들기] 가 스크롤 없이 보이고, DNS 줄은 한 줄 · [시스템 DNS 로 바꾸기] 도 그 줄에. +/// ko · ja × 글자 1.0 · 1.6 +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; + + for (final lang in ['ko', 'ja']) { + for (final scale in [1.0, 1.6]) { + for (final (detected, dns) in [(false, false), (true, false), (true, true)]) { + final tag = '${lang}_s${scale}_${detected ? 'detected' : 'plain'}${dns ? '_dns' : ''}'; + testWidgets('261 · 266 $tag', (t) async { + t.view.physicalSize = const Size(412, 869); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.runAsync(() => i18n.apply(lang, save: false)); + addTearDown(() => i18n.apply('ko', save: false)); + final tmp = testTempDir('jj_mkvportrait_'); + appTempInto(tmp); + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), + storage: DesktopStorageService(), + createRecognizer: () => throw UnimplementedError(), + createTranslator: () => throw UnimplementedError(), + )); + c.settings.showLog = true; + c.settings.moveTargets = [MoveTarget('보관', p.join(tmp.path, '보관'))]; + for (var i = 1; i <= 4; i++) { + c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 $i.mp4'))); + } + c.selected = c.videos.first..aiDetected = detected ? 'en' : null; + if (dns) { + NetGuard.instance.failure.value = DnsFailure('192.0.2.1', '답이 없습니다', DateTime(2026, 10, 12, 8, 42)); + addTearDown(() => NetGuard.instance.failure.value = null); + } + final shotKey = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: shotKey, + child: MaterialApp( + theme: buildTheme(), + debugShowCheckedModeBanner: false, + builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!), + home: HomePage(c: c), + ), + )); + for (var i = 0; i < 10; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 40))); + await t.pump(); + } + if (out.isNotEmpty) { + final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => r.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'mkvportrait_$tag.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + } + expect(t.takeException(), isNull, reason: '$tag 넘침'); + final ai = find.text(tr('AI 자막 만들기')); + expect(ai, findsOneWidget, reason: '$tag: 세부 정보 칸 아래로 밀림 (화면 밖이면 목록이 만들지 않는다)'); + final view = t.getRect(find.ancestor(of: ai, matching: find.byType(Scrollable)).first); + final button = t.getRect(ai); + expect(button.bottom, lessThanOrEqualTo(view.bottom), reason: '$tag: 버튼 $button · 세부 정보 칸 $view'); + if (dns) { + final line = t.getRect(find.textContaining('192.0.2.1')); + final sys = t.getRect(find.text(tr('시스템 DNS 로 바꾸기'))); + expect((sys.center.dy - line.center.dy).abs(), lessThan(4), reason: '$tag: 버튼이 같은 줄 (줄 $line · 버튼 $sys)'); + expect(line.height, lessThan(40 * scale), reason: '$tag: 한 줄'); + } + }); + } + } + } +} From f67e1e3a8575588e7802789ef70d8c512ba80fa2 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:13:09 +0900 Subject: [PATCH 20/20] =?UTF-8?q?F7=20=EB=82=AE=EC=9D=80=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=EC=84=9C=EB=8F=84=20[=EC=9D=B4=EB=8F=99]=20?= =?UTF-8?q?=EC=9D=84=20=EC=B9=A9=20=EC=A4=84=20=EC=95=84=EB=9E=98=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20F8=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EC=B9=B8=EC=9D=84=20=EC=A2=81=EA=B3=A0=20?= =?UTF-8?q?=EA=B8=80=EC=9E=90=EA=B0=80=20=ED=81=B4=20=EB=95=8C=EB=A7=8C=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=20=EC=95=84=EB=9E=98=20=EC=A4=84=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20214=20c=20=EC=8B=9C=ED=97=98=EC=9D=84=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=20=EC=8B=9C=EC=9E=91=20=EB=92=A4=EB=A1=9C=20?= =?UTF-8?q?(=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=8A=B9=EC=9D=B8=2010/21,=2015?= =?UTF-8?q?=20=EC=B0=BE=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F7: 폰 가로 (900×412) 에서 떠 있는 [이동] 이 [자막 파일 추가] 를 반쯤 가렸다 (잘못 누를 수 있음) → 높이 < 500 × 글자 배율 이면 206 의 배치 (칩 줄 아래) 를 씀. 처음에 높이 < 500 만 봤더니 960×600 · 글자 1.6 (ja) 에서도 가려 글자 배율을 곱함. 높이가 넉넉하면 그대로 - F8: 다운로드 화면 ja 412 글자 1.6 에서 제목이 넓어 주소 칸이 「動画...」 만큼으로 눌렸다 → 주소 칸이 200 보다 좁아질 때만 주소 칸 · [추가] 를 제목 아래 한 줄로 (글 폭은 지금 글꼴 · 배율로 잼). 이를 위해 AppTopBar 의 좁은 배치 아래 줄을 "높이 48 고정" 에서 "48 이상" 으로 (한 줄이면 예전처럼 48 가운데, 폭은 그대로 다 씀 - app_top_bar_test 201). 한 줄 막대 (넓은 화면) 에서는 늘 한 줄 - 214 c (move_settled_test): 전체 시험에서 한 번 실패 - 250ms 안에 원본 목록을 아직 읽지 않으면 새 파일까지 옮겨져 거짓 실패였다. 보내기가 실제로 시작된 뒤 새 파일을 만들고, "새 파일을 잃지 않음 (원본 또는 받는 쪽)" 을 따로 확인 - 시험: windows_mkvdetail_shot_test 에 900×412 (1.0 · 1.6) · 1280×480 과 [자막 파일 추가] 겹침 확인을 더해 18개 통과, windows_bigtext_shot_test 60개 통과 (release\test\shots_f1), 전체 단위 1150개 통과, 214 c 혼자 5번 통과 Co-Authored-By: Claude Opus 5.5 --- .../windows_mkvdetail_shot_test.dart | 10 +++-- lib/ui/app_actions.dart | 16 +++++--- lib/ui/downloads_page.dart | 39 +++++++++++++------ lib/ui/home_page.dart | 4 +- test/move_settled_test.dart | 9 ++++- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/integration_test/windows_mkvdetail_shot_test.dart b/integration_test/windows_mkvdetail_shot_test.dart index f88de29..7139ee4 100644 --- a/integration_test/windows_mkvdetail_shot_test.dart +++ b/integration_test/windows_mkvdetail_shot_test.dart @@ -42,13 +42,17 @@ class _Tool implements MediaTool { /// F5 · 206 (관리자 지시 10/21): MKV 자세히 칸 - 오른쪽 아래 떠 있는 [이동] 단추가 마지막 줄 ("출력: …") 이나 칩을 가리지 않는다. /// 넓은 화면은 끝까지 내리면 마지막 줄이 단추 위로 (목록 끝 여백), 좁은 화면 (412) 은 단추가 칩 줄 아래에 놓인다. -/// 412 · 960×600 · 1280×720 × 글자 1.0 · 1.6 × ko · ja. 단추와 출력 줄 · 칩이 겹치는지 자리로 잰다 +/// 412 · 960×600 · 1280×720 × 글자 1.0 · 1.6 · 낮은 화면 900×412 · 1280×480 (F7) × ko · ja. [자막 파일 추가] 도 단추에 가리지 않는다. 단추와 출력 줄 · 칩이 겹치는지 자리로 잰다 void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; for (final lang in ['ko', 'ja']) { - for (final (w, h, scale) in [(412, 900, 1.0), (412, 900, 1.6), (960, 600, 1.0), (960, 600, 1.6), (1280, 720, 1.0), (1280, 720, 1.6)]) { + for (final (w, h, scale) in [ + (412, 900, 1.0), (412, 900, 1.6), (960, 600, 1.0), (960, 600, 1.6), (1280, 720, 1.0), (1280, 720, 1.6), + // F7 (10/21): 높이가 낮은 화면 (폰 가로 · 낮은 창) - [이동] 이 [자막 파일 추가] 를 가렸다 + (900, 412, 1.0), (900, 412, 1.6), (1280, 480, 1.0), + ]) { testWidgets('MKV 자세히 칸 이동 단추 $lang ${w}x$h 글자 $scale', (t) async { t.view.physicalSize = Size(w.toDouble(), h.toDouble()); t.view.devicePixelRatio = 1; @@ -105,7 +109,7 @@ void main() { // 206: 처음 자리에서 칩 (음성 n개 등) 이 [이동] 단추에 가리지 않음 (좁은 화면은 단추가 칩 줄 아래에 놓임) if (fab.evaluate().isNotEmpty) { final b = t.getRect(fab.first); - for (final text in [trf('음성 {0}개', [2]), trf('영상 {0}', ['h264']), '1920×1080']) { + for (final text in [trf('음성 {0}개', [2]), trf('영상 {0}', ['h264']), '1920×1080', tr('자막 파일 추가')]) { if (find.text(text).evaluate().isEmpty) continue; final r = t.getRect(find.text(text)); expect(r.overlaps(b), isFalse, reason: '칩 "$text" 이 단추에 가리지 않음 ($r · 단추 $b)'); diff --git a/lib/ui/app_actions.dart b/lib/ui/app_actions.dart index 866463d..a752feb 100644 --- a/lib/ui/app_actions.dart +++ b/lib/ui/app_actions.dart @@ -103,11 +103,17 @@ class _AppTopBarState extends State { actions, ]), ), - SizedBox( - height: 48, - child: Padding( - padding: const EdgeInsets.only(left: 6), - child: widget.trailing == null ? middle : Row(children: [Expanded(child: middle), widget.trailing!]))), + // F8 (10/21): 아래 줄은 48 이상 - middle 이 좁아 두 줄로 바꾸면 (다운로드 화면의 주소 칸) 그만큼 늘어난다. 한 줄이면 예전처럼 48 가운데 + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Align( + alignment: Alignment.centerLeft, + heightFactor: 1, + child: SizedBox( + width: double.infinity, // 아래 줄은 가운데가 폭을 다 쓴다 (201) + child: Padding( + padding: const EdgeInsets.only(left: 6), + child: widget.trailing == null ? middle : Row(children: [Expanded(child: middle), widget.trailing!]))))), ]), ); } diff --git a/lib/ui/downloads_page.dart b/lib/ui/downloads_page.dart index b25bd4e..834c185 100644 --- a/lib/ui/downloads_page.dart +++ b/lib/ui/downloads_page.dart @@ -93,12 +93,10 @@ class _DownloadsPageState extends State { minMiddleWidth: 420, nav: const AppNavButtons(onDownloadsPage: true), actions: const AppActions(), - middle: Row(children: [ - Text(trf('다운로드 ({0})', [d.tasks.length]), - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - SizedBox(width: isCompact(context) ? 8 : 20), - Expanded( - child: TextField( + middle: LayoutBuilder(builder: (context, box) { + final title = Text(trf('다운로드 ({0})', [d.tasks.length]), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)); + final field = TextField( controller: _url, style: const TextStyle(fontSize: 13), decoration: InputDecoration( @@ -110,11 +108,30 @@ class _DownloadsPageState extends State { border: OutlineInputBorder(), ), onSubmitted: (_) => _add(), - ), - ), - const SizedBox(width: 8), - FilledButton(onPressed: _add, child: Text(tr('추가'))), - ]), + ); + final add = FilledButton(onPressed: _add, child: Text(tr('추가'))); + // F8 (관리자 승인 10/21, 15 찾음): 좁고 글자가 크면 제목이 넓어 주소 칸이 「動画...」 만큼으로 눌렸다 - + // 주소 칸이 200 보다 좁아질 때만 주소 칸 · [추가] 를 제목 아래 한 줄로 (넓으면 예전 한 줄 그대로) + final scaler = MediaQuery.textScalerOf(context); + double textW(String t, double size, [FontWeight? w]) => (TextPainter( + text: TextSpan(text: t, style: DefaultTextStyle.of(context).style.copyWith(fontSize: size, fontWeight: w)), + textDirection: TextDirection.ltr, + textScaler: scaler, + maxLines: 1) + ..layout()) + .width; + final gap = isCompact(context) ? 8.0 : 20.0; + final need = textW(trf('다운로드 ({0})', [d.tasks.length]), 16, FontWeight.w600) + gap + 200 + 8 + textW(tr('추가'), 14) + 48; + // 한 줄 막대 (높이가 정해짐) 에서는 늘 한 줄 - 모자라면 막대가 스스로 두 줄 배치로 바뀐다 (AppTopBar) + if (box.maxWidth >= need || box.maxHeight.isFinite) { + return Row(children: [title, SizedBox(width: gap), Expanded(child: field), const SizedBox(width: 8), add]); + } + return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + title, + const SizedBox(height: 6), + Row(children: [Expanded(child: field), const SizedBox(width: 8), add]), + ]); + }), ), Container( color: JjColors.bg, diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index ae7504a..f311f51 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -1103,7 +1103,9 @@ class _VideoDetail extends StatelessWidget { ), ); // 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다 - if (isCompact(context)) { + // F7 (관리자 승인 10/21, 15 찾음): 높이가 낮은 화면 (폰 가로 등) 도 - 떠 있는 버튼이 [자막 파일 추가] 를 반쯤 가렸다. + // 높이는 글자 배율로 나눠 본다 (글자가 크면 같은 높이에 들어가는 줄이 적다 - 960×600 · 글자 1.6 에서도 가렸다) + if (isCompact(context) || MediaQuery.sizeOf(context).height < 500 * MediaQuery.textScalerOf(context).scale(1)) { final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [ if (buttons.isEmpty) button(null), for (final t in buttons) button(t), diff --git a/test/move_settled_test.dart b/test/move_settled_test.dart index 80d635c..334231a 100644 --- a/test/move_settled_test.dart +++ b/test/move_settled_test.dart @@ -55,12 +55,19 @@ void main() { File(p.join(src, 'big.bin')).writeAsBytesSync(List.filled(300 * 1024, 7)); final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, bandwidthKBps: 400); final running = job.run(); - await Future.delayed(const Duration(milliseconds: 250)); + // 정해진 시간 (예전 250ms) 이 아니라 보내기가 실제로 시작된 뒤 (받는 쪽에 무엇이 생긴 뒤) 새 파일을 만든다 - + // 전체 시험 부하에서는 250ms 안에 원본 목록을 아직 읽지 않아 새 파일까지 옮겨져 거짓 실패 (10/21, 15) + for (var i = 0; i < 200; i++) { + if (Directory(dst).existsSync() && Directory(dst).listSync(recursive: true).isNotEmpty) break; + await Future.delayed(const Duration(milliseconds: 25)); + } File(p.join(src, 'arrived.txt')).writeAsStringSync('new'); await running; expect(job.error, isNull); expect(File(p.join(dst, 'big.bin')).lengthSync(), 300 * 1024); expect(File(p.join(src, 'big.bin')).existsSync(), isFalse); + // 데이터: 새 파일은 어디에도 없어지면 안 된다 (원본에 남았거나 옮겨졌거나) + expect(File(p.join(src, 'arrived.txt')).existsSync() || File(p.join(dst, 'arrived.txt')).existsSync(), isTrue, reason: '새 파일을 잃지 않음'); expect(File(p.join(src, 'arrived.txt')).existsSync(), isTrue, reason: '복사하지 않은 것은 남김'); });