업데이트 시험 길 JJ_UPDATE_LOCAL_ZIP: GitHub 대신 이 PC 의 zip 으로 앱 안 업데이트 전체를 (관리자 지시 10/20)

- JJ_UPDATE_LOCAL_ZIP=<zip 경로>;<sha256> - 시험용 데이터 폴더 (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 <noreply@anthropic.com>
rc2
RyuJongJin 2026-10-21 00:06:03 +09:00
parent 121dd4350b
commit 5057267494
5 changed files with 119 additions and 13 deletions

View File

@ -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)",

View File

@ -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 のように)",

View File

@ -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)",

View File

@ -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<String, String>? 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<ReleaseInfo?> 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<List<ReleaseInfo>> 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) {

View File

@ -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();