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] =?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();