diff --git a/lib/ui/update/update_install.dart b/lib/ui/update/update_install.dart new file mode 100644 index 0000000..7280f95 --- /dev/null +++ b/lib/ui/update/update_install.dart @@ -0,0 +1,361 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +import '../../app/ai_local.dart' show AiStore; +import '../../app/app_controller.dart'; +import '../../app/copy_center.dart' show diskSpace; +import '../../app/model_backup.dart'; +import '../../app/version_snapshot.dart'; +import '../../core/app_update.dart'; +import '../../l10n/tr.dart'; +import '../../services/updater.dart' show InstallPermissionNeeded, Updater; +import '../theme.dart'; + +/// 약속 (업데이트 나누기): 설치 · 되돌리기가 함께 쓰는 단계. +/// - [downloadRelease]: 진행 창을 띄우고 받는다 → 받은 경로, 실패하면 알림 뒤 null (던지지 않음) +/// - [saveVersionSnapshot]: 지금 버전의 설정을 보관 (실패는 작업 기록에만, 던지지 않음) +/// - [rollbackModelRoots]: 되돌리기 때 옮겨 둘 AI 모델 폴더 · [gbText]: 크기 글 +/// - [withProgressDialog]: 끌 수 없는 진행 창에서 일 하나 → 실패하면 그 글, 성공은 null + +/// 받기 (진행 창) → 받은 파일 경로. 실패하면 null. +Future downloadRelease(BuildContext context, Updater up, ReleaseInfo r) async { + final progress = ValueNotifier(0); + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + scrollable: true, + title: Text(trf('v{0} 받는 중', [r.version])), + content: ValueListenableBuilder( + valueListenable: progress, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [ + Text(v >= 1 ? (up.installsInPlace ? tr('파일 확인 중…') : tr('확인 · 압축 푸는 중…')) : '${(v * 100).round()}%'), + const SizedBox(height: 12), + LinearProgressIndicator(value: v >= 1 ? null : v), + ]), + ), + ), + ); + try { + final dir = await up.download(r, (x) => progress.value = x); + if (context.mounted) Navigator.of(context).pop(); + return dir; + } catch (e) { + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('업데이트 실패: {0}', [e])))); + } + return null; + } +} + +/// 다른 버전을 설치하기 전에 지금 버전의 설정을 보관 (그 버전으로 돌아오면 되살린다) +Future saveVersionSnapshot(AppController c, String current) async { + final snap = VersionSnapshot.instance; + if (snap == null) return; + try { + await c.updateSettings((x) => x.lastRunVersion = current); // 보관본이 "이 버전이 쓴 설정" 이 되게 + await snap.save(current); + c.note(trf('지금 버전 (v{0}) 의 설정을 보관했습니다: {1}', [current, snap.dirOf(current)])); + } catch (e) { + c.note(trf('설정을 보관하지 못했습니다: {0}', [e])); + } +} + +/// P0: 되돌리기 때 옮겨 둘 받은 AI 모델 폴더 (그림 · 해상도: 앱 데이터/ai, 자막: 모델 폴더) +Future> rollbackModelRoots(AppController c) async => { + if (AiStore.instance != null) 'ai': AiStore.instance!.root, + 'models': await c.services.models.directory(), + }; + +/// 크기를 GB · MB 로 +String gbText(int bytes) => bytes >= 1 << 30 + ? '${(bytes / (1 << 30)).toStringAsFixed(1)}GB' + : '${(bytes / (1 << 20)).toStringAsFixed(0)}MB'; + +/// 진행 창을 띄우고 [work] 를 한다 (끌 수 없음). 실패하면 그 글 +Future withProgressDialog( + BuildContext context, String title, Future Function(void Function(int done, int total) onProgress) work) async { + final progress = ValueNotifier(null); + final nav = Navigator.of(context); + // ignore: unawaited_futures + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: Text(title), + content: ValueListenableBuilder( + valueListenable: progress, + builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [ + LinearProgressIndicator(value: v), + const SizedBox(height: 6), + Text(v == null ? tr('준비하는 중') : '${(v * 100).floor()}%'), + ]), + ), + ), + ); + String? error; + try { + await work((done, total) => progress.value = total == 0 ? null : done / total); + } catch (e) { + error = '$e'; + } + nav.pop(); + return error; +} + +/// 약속 (업데이트 나누기): 고른 버전 설치 (새 버전 · 예전 버전 · 같은 버전). +/// - 입력: 화면 [context] · [c] (c.services.updater 가 있어야 함) · 버전 [r] · 지금 버전 [current] +/// - 출력: 없음. 받기 · 검증 → 확인 창 → 지금 설정 보관 → Android 는 설치 화면, PC 는 종료 후 설치 · 다시 시작. +/// Android 에서 낮은 버전이면 [androidRollback] 으로 +/// - 오류: 작업 중 · 설치할 수 없는 위치 · 받기 실패 · 설치 화면 열기 실패는 알림 (PC 의 설치 예약 오류는 그대로 던짐) +/// [r] 버전 설치 (새 버전 · 예전 버전 · 같은 버전 모두). 설치 전에 지금 버전의 설정을 보관한다. +Future installRelease(BuildContext context, AppController c, ReleaseInfo r, String current) async { + final up = c.services.updater!; + void snack(String t) { + if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t))); + } + + if (c.busy) { + snack(tr('MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.')); + return; + } + // 프로그램 폴더에 쓸 수 없으면 (예: Program Files) 페이지에서 직접 받도록 + if (r.zipUrl == null || !await up.canInstall()) { + snack(tr('자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.')); + await up.openPage(r); + return; + } + final down = compareVersions(r.version, current) < 0; + // Android 는 낮은 버전을 위에 설치할 수 없다 (앱을 지우고 설치해야 함) → 따로 안내 + if (down && up.installsInPlace) { + if (context.mounted) await androidRollback(context, c, up, r, current); + return; + } + if (!context.mounted) return; + final dir = await downloadRelease(context, up, r); + if (dir == null || !context.mounted) return; + + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('설치 준비 완료')), + content: Text(trf('v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' '{1}', [ + r.version, + up.installsInPlace + ? tr('Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n' '(처음이면 "이 출처의 앱 설치 허용" 을 켜야 할 수 있습니다)') + : down + ? trf('프로그램을 종료하고 예전 버전 (v{0}) 으로 되돌린 뒤 다시 시작합니다. 프로그램 폴더는 그 버전과 똑같이 맞추고, ' + '받은 파일 · AI 모델은 그대로 둡니다. 지금 버전 (v{1}) 의 설정은 보관해 둡니다.', [r.version, current]) + : tr('프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'), + ])), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지금 설치'))), + ], + ), + ); + if (go != true) return; + await saveVersionSnapshot(c, current); + // Android: 설치 화면만 연다 (앱을 끝내지 않음, 설치되면 Android 가 앱을 다시 띄움) + if (up.installsInPlace) { + while (true) { + try { + await up.scheduleInstall(dir); + return; + } on InstallPermissionNeeded { + // 설치 허용 설정 화면이 열렸다: 켜고 돌아와 [설치 계속] → 받은 파일로 다시 (처음부터 다시 받지 않게) + if (!context.mounted) return; + final again = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('설치 허용 필요')), + content: Text(tr('설정 화면에서 "이 출처의 앱 설치 허용" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. ' + '받은 파일을 그대로 씁니다 (다시 받지 않음).')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설치 계속'))), + ], + ), + ); + if (again != true) return; + } catch (e) { + snack(trf('설치 화면을 열 수 없습니다: {0}', [e])); + return; + } + } + } + // 다운로드 중이면 기존 종료 확인 절차 + final ok = await (c.confirmQuit?.call() ?? Future.value(true)); + if (!ok) return; + await up.scheduleInstall(dir); + await c.services.shell.quit(); +} + +/// 약속 (업데이트 나누기): Android 에서 예전 버전 [r] 로 되돌리기 (앱을 지운 뒤 설치). +/// - 입력: 화면 [context] · [c] · [up] · 고른 버전 [r] · 지금 버전 [current] +/// - 출력: 없음. 남는 것 / 사라지는 것을 보여 주고 두 번 확인한 뒤, APK 와 설정 보관본 (원하면 AI 모델) 을 +/// Download/JJ_MKVMaker 에 두고 앱 지우기를 부른다 +/// - 오류: 저장 · 모델 복사에 실패하면 알림만 하고 앱은 지우지 않는다 (던지지 않음) +/// Android 에서 예전 버전으로: Android 는 버전이 낮은 앱을 위에 설치하지 못하므로 앱을 지운 뒤 설치해야 한다. +/// APK 와 설정 보관본은 앱을 지워도 남는 Download/JJ_MKVMaker 에 두고, 다시 설치한 앱이 시작할 때 설정을 되살린다. +Future androidRollback(BuildContext context, AppController c, Updater up, ReleaseInfo r, String current) async { + final snap = VersionSnapshot.instance; + final shared = snap?.sharedDir; + if (shared == null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.')))); + return; + } + // P0: 남는 것 / 사라지는 것을 사실대로. 받은 AI 모델은 공용 폴더에 옮겨 둘 수 있다 + final roots = await rollbackModelRoots(c); + final modelSize = ModelBackup.sizeOf(roots); + // 공용 폴더는 처음엔 없을 수 있다 - 있는 가장 가까운 상위 폴더로 남은 공간을 잰다 + var probe = shared; + while (!Directory(probe).existsSync() && p.dirname(probe) != probe) { + probe = p.dirname(probe); + } + final free = (await diskSpace(probe))?.$1; + final setting = c.settings.rollbackKeepModels; + var keep = modelSize > 0 && (setting == 'on' || (setting == '' && free != null && free >= modelSize * 2)); + var keepTouched = false; + // 옮겨 둔 모델을 되살리는 기능은 이 판 다음부터 들어 있다 (설정 되살리기는 v2026.10.08_001 부터) + final targetRestoresModels = compareVersions(r.version, '2026.10.09_001') > 0; + final targetRestoresSettings = compareVersions(r.version, '2026.10.08_001') >= 0; + if (!context.mounted) return; + final ok = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setInner) { + final size = gbText(modelSize); + final kept = [ + tr('설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관'), + tr('공용 폴더에 받은 파일 (동영상 · 그림 등)'), + if (keep) trf('받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관', [size]), + ]; + final lost = [ + if (modelSize > 0 && !keep) trf('받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})', [size]), + tr('비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)'), + tr('마스터 · 최상 비밀번호'), + tr('앱 안 브라우저의 로그인 · 쿠키'), + tr('작업 기록 · 받다 만 파일'), + ]; + return AlertDialog( + scrollable: true, + title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])), + content: SizedBox( + width: 560, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(trf( + 'Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 "남는 것" 을 ' + 'Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.', + [r.version])), + const SizedBox(height: 10), + Text(tr('남는 것'), style: const TextStyle(fontWeight: FontWeight.w600)), + for (final x in kept) Text('· $x'), + const SizedBox(height: 8), + Text(tr('사라지는 것'), style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)), + for (final x in lost) Text('· $x', style: TextStyle(color: JjColors.warning)), + if (modelSize > 0) ...[ + const SizedBox(height: 8), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + value: keep, + // 고른 것은 [저장하고 계속] 을 누를 때만 설정에 남긴다 ([취소] 하면 그대로) + onChanged: (v) => setInner(() { + keep = v ?? false; + keepTouched = true; + }), + title: Text(trf('받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)', [size])), + subtitle: Text([ + if (free != null) trf('남은 공간 {0}', [gbText(free)]), + if (!targetRestoresModels) + trf('v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.', [r.version]), + ].join(' · ')), + ), + ], + if (!targetRestoresSettings) + Text(trf('v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.', [r.version]), + style: TextStyle(color: JjColors.warning)), + ]), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장하고 계속'))), + ], + ); + }, + ), + ); + if (ok != true || !context.mounted) return; + // 체크를 바꿨으면 이제 설정에 (자동이던 것은 건드리지 않으면 자동 그대로) + if (keepTouched) await c.updateSettings((x) => x.rollbackKeepModels = keep ? 'on' : 'off'); + if (!context.mounted) return; + // 사라지는 것이 있으니 한 번 더 (비밀번호 · 로그인은 늘 사라진다) + final sure = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('정말 되돌릴까요?')), + content: Text([ + tr('앱을 지우면 다음은 되살릴 수 없습니다:'), + if (modelSize > 0 && !keep) trf('· 받은 AI 모델 (약 {0}, 다시 받아야 함)', [gbText(modelSize)]), + tr('· 비밀번호 · API 키 · 마스터 · 최상 비밀번호'), + tr('· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록'), + ].join('\n')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.alert), + onPressed: () => Navigator.pop(ctx, true), + child: Text(tr('되돌리기 계속')), + ), + ], + ), + ); + if (sure != true || !context.mounted) return; + final apk = await downloadRelease(context, up, r); + if (apk == null || !context.mounted) return; + final outDir = p.dirname(shared); + final saved = p.join(outDir, r.zipName ?? 'JJ_MKVMaker_v${r.version}.apk'); + try { + await Directory(outDir).create(recursive: true); + await File(apk).copy(saved); + await saveVersionSnapshot(c, current); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e])))); + } + return; + } + // P0: 받은 AI 모델을 공용 폴더로 (실패하면 앱을 지우지 않는다) + if (keep && modelSize > 0) { + final backup = ModelBackup.shared; + if (backup == null || !context.mounted) return; + final err = await withProgressDialog(context, tr('AI 모델을 복사해 두는 중'), (onProgress) => backup.save(roots, onProgress: onProgress)); + if (err != null) { + if (context.mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(trf('AI 모델을 복사해 두지 못해 멈췄습니다 (앱은 지우지 않음): {0}', [err])))); + } + return; + } + c.note(trf('되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}', [gbText(modelSize), backup.dir])); + } + if (!context.mounted) return; + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('저장했습니다')), + content: Text(trf('설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.', [saved])), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('앱 지우기'))), + ], + ), + ); + if (go == true) await up.uninstallSelf(); +} diff --git a/lib/ui/update_dialog.dart b/lib/ui/update_dialog.dart index ad495fa..59cf2f8 100644 --- a/lib/ui/update_dialog.dart +++ b/lib/ui/update_dialog.dart @@ -1,20 +1,20 @@ -import 'dart:math' as math; import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:path/path.dart' as p; -import '../app/ai_local.dart' show AiStore; import '../app/app_controller.dart'; -import '../app/copy_center.dart' show diskSpace; -import '../app/model_backup.dart'; import '../app/net_guard.dart'; -import '../app/version_snapshot.dart'; import '../core/app_update.dart'; -import '../services/updater.dart' show InstallPermissionNeeded, Updater; +import '../services/updater.dart' show Updater; import 'theme.dart'; +import 'update/update_install.dart'; import '../l10n/tr.dart'; +// 약속 (업데이트 나누기): 이 파일은 새 버전 확인 ([checkForUpdate] · [UpdateCheckButton]) 과 버전 고르기를 맡는다. +// 버전 고르기 ([chooseVersion] · [VersionPickerDialog]) 도 여기, 설치 · 되돌리기 · 공용 단계는 update/update_install.dart (부르는 곳이 그대로 쓰게 아래에서 내보냄) +export 'update/update_install.dart' show gbText, installRelease, rollbackModelRoots, withProgressDialog; + /// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작 (하루 한 번 자동 확인 · 최신 버전만) /// /// [manual]: "지금 확인" (최신이어도 알려 주고, 건너뛴 버전도 보여 줌) @@ -79,7 +79,7 @@ Future checkForUpdate(BuildContext context, AppController c, {bool manual const SizedBox(height: 12), Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), - Expanded(child: _Notes(r.notes)), + Expanded(child: UpdateNotes(r.notes)), ]), ), actions: [ @@ -105,30 +105,6 @@ Future checkForUpdate(BuildContext context, AppController c, {bool manual } } -/// 환경 설정 > [업데이트]: GitHub 에 올려 둔 모든 버전을 보여 주고 골라 설치한다. -/// 최신으로 올리기뿐 아니라 예전 (정상이던) 버전으로 되돌리기 · 같은 버전 다시 설치도 된다. -Future chooseVersion(BuildContext context, AppController c) async { - final up = c.services.updater; - if (up == null) return; - final String current; - try { - current = await up.currentVersion(); - } catch (_) { - return; - } - if (!context.mounted) return; - final picked = await showDialog( - context: context, - builder: (_) => _VersionPicker(up: up, current: current), - ); - if (picked == null || !context.mounted) return; - await installRelease(context, c, picked, current); -} - -/// 169: 버전 고르기 창에서 처음 고를 버전: 이 기기용 파일이 있는 것 중 가장 새것, 없으면 지금 버전 (목록은 새것이 앞) -ReleaseInfo initialVersionPick(List all, String current) => - all.firstWhere((r) => r.zipUrl != null, orElse: () => all.firstWhere((r) => r.version == current, orElse: () => all.first)); - /// 164: [최신 버전 확인] 과 그 결과 (버튼 옆에 - 최신입니다 · 새 버전이 있습니다 · 확인할 수 없습니다) class UpdateCheckButton extends StatefulWidget { final AppController c; @@ -164,17 +140,46 @@ class _UpdateCheckButtonState extends State { ]); } -/// 버전 고르기 창 -class _VersionPicker extends StatefulWidget { - final Updater up; +/// 약속 (업데이트 나누기): 버전 고르기. +/// - [chooseVersion]: 올려 둔 모든 버전을 보여 주고 고른 것을 [installRelease] 로 (닫으면 아무것도 하지 않음) +/// - [initialVersionPick]: 처음 고를 버전 (순수 함수) +/// - [VersionPickerDialog]: 고르기 창 → 고른 [ReleaseInfo], 닫으면 null. 목록을 읽지 못하면 창 안에 그 글 +/// - [UpdateNotes] · [UpdateBadge]: 바뀐 내용 칸 · 작은 표 (지금 · 최신 · 시험판) +/// 환경 설정 > [업데이트]: GitHub 에 올려 둔 모든 버전을 보여 주고 골라 설치한다. +/// 최신으로 올리기뿐 아니라 예전 (정상이던) 버전으로 되돌리기 · 같은 버전 다시 설치도 된다. +Future chooseVersion(BuildContext context, AppController c) async { + final up = c.services.updater; + if (up == null) return; final String current; - const _VersionPicker({required this.up, required this.current}); - - @override - State<_VersionPicker> createState() => _VersionPickerState(); + try { + current = await up.currentVersion(); + } catch (_) { + return; + } + if (!context.mounted) return; + final picked = await showDialog( + context: context, + builder: (_) => VersionPickerDialog(up: up, current: current), + ); + if (picked == null || !context.mounted) return; + await installRelease(context, c, picked, current); } -class _VersionPickerState extends State<_VersionPicker> { +/// 169: 버전 고르기 창에서 처음 고를 버전: 이 기기용 파일이 있는 것 중 가장 새것, 없으면 지금 버전 (목록은 새것이 앞) +ReleaseInfo initialVersionPick(List all, String current) => + all.firstWhere((r) => r.zipUrl != null, orElse: () => all.firstWhere((r) => r.version == current, orElse: () => all.first)); + +/// 버전 고르기 창 +class VersionPickerDialog extends StatefulWidget { + final Updater up; + final String current; + const VersionPickerDialog({super.key, required this.up, required this.current}); + + @override + State createState() => _VersionPickerDialogState(); +} + +class _VersionPickerDialogState extends State { late final Future> _list = widget.up.releases(); ReleaseInfo? _sel; @@ -229,9 +234,9 @@ class _VersionPickerState extends State<_VersionPicker> { selectedTileColor: JjColors.accent.withValues(alpha: 0.15), title: Row(children: [ Text('v${r.version}', style: const TextStyle(fontWeight: FontWeight.w600)), - if (r.version == widget.current) _Badge(tr('지금'), JjColors.accent), - if (r.version == newest) _Badge(tr('최신'), JjColors.positive), - if (r.prerelease) _Badge(tr('시험판'), JjColors.warning), + if (r.version == widget.current) UpdateBadge(tr('지금'), JjColors.accent), + if (r.version == newest) UpdateBadge(tr('최신'), JjColors.positive), + if (r.prerelease) UpdateBadge(tr('시험판'), JjColors.warning), ]), subtitle: Text(r.zipUrl == null ? '${_date(r.published)} · ${tr('이 기기용 파일 없음')}' : _date(r.published), style: const TextStyle(fontSize: 11)), @@ -260,7 +265,7 @@ class _VersionPickerState extends State<_VersionPicker> { const SizedBox(height: 8), Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 4), - Expanded(child: _Notes(_sel!.notes)), + Expanded(child: UpdateNotes(_sel!.notes)), ]); // 168: 좁은 화면 (폰) 에서는 목록 위 · 고른 버전의 바뀐 내용 아래 (나란히 두면 바뀐 내용 칸이 너무 좁았다) return LayoutBuilder( @@ -290,10 +295,10 @@ class _VersionPickerState extends State<_VersionPicker> { ); } -class _Badge extends StatelessWidget { +class UpdateBadge extends StatelessWidget { final String text; final Color color; - const _Badge(this.text, this.color); + const UpdateBadge(this.text, this.color, {super.key}); @override Widget build(BuildContext context) => Container( @@ -304,9 +309,9 @@ class _Badge extends StatelessWidget { ); } -class _Notes extends StatelessWidget { +class UpdateNotes extends StatelessWidget { final String notes; - const _Notes(this.notes); + const UpdateNotes(this.notes, {super.key}); @override Widget build(BuildContext context) => Container( @@ -318,334 +323,3 @@ class _Notes extends StatelessWidget { ), ); } - -/// 받기 (진행 창) → 받은 파일 경로. 실패하면 null. -Future _download(BuildContext context, Updater up, ReleaseInfo r) async { - final progress = ValueNotifier(0); - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => AlertDialog( - scrollable: true, - title: Text(trf('v{0} 받는 중', [r.version])), - content: ValueListenableBuilder( - valueListenable: progress, - builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [ - Text(v >= 1 ? (up.installsInPlace ? tr('파일 확인 중…') : tr('확인 · 압축 푸는 중…')) : '${(v * 100).round()}%'), - const SizedBox(height: 12), - LinearProgressIndicator(value: v >= 1 ? null : v), - ]), - ), - ), - ); - try { - final dir = await up.download(r, (x) => progress.value = x); - if (context.mounted) Navigator.of(context).pop(); - return dir; - } catch (e) { - if (context.mounted) { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('업데이트 실패: {0}', [e])))); - } - return null; - } -} - -/// [r] 버전 설치 (새 버전 · 예전 버전 · 같은 버전 모두). 설치 전에 지금 버전의 설정을 보관한다. -Future installRelease(BuildContext context, AppController c, ReleaseInfo r, String current) async { - final up = c.services.updater!; - void snack(String t) { - if (context.mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t))); - } - - if (c.busy) { - snack(tr('MKV 만들기 · AI 자막 작업이 끝난 뒤 업데이트하세요.')); - return; - } - // 프로그램 폴더에 쓸 수 없으면 (예: Program Files) 페이지에서 직접 받도록 - if (r.zipUrl == null || !await up.canInstall()) { - snack(tr('자동 설치를 할 수 없는 위치입니다. 페이지에서 직접 받아 주세요.')); - await up.openPage(r); - return; - } - final down = compareVersions(r.version, current) < 0; - // Android 는 낮은 버전을 위에 설치할 수 없다 (앱을 지우고 설치해야 함) → 따로 안내 - if (down && up.installsInPlace) { - if (context.mounted) await _androidRollback(context, c, up, r, current); - return; - } - if (!context.mounted) return; - final dir = await _download(context, up, r); - if (dir == null || !context.mounted) return; - - final go = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(tr('설치 준비 완료')), - content: Text(trf('v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n' '{1}', [ - r.version, - up.installsInPlace - ? tr('Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n' '(처음이면 "이 출처의 앱 설치 허용" 을 켜야 할 수 있습니다)') - : down - ? trf('프로그램을 종료하고 예전 버전 (v{0}) 으로 되돌린 뒤 다시 시작합니다. 프로그램 폴더는 그 버전과 똑같이 맞추고, ' - '받은 파일 · AI 모델은 그대로 둡니다. 지금 버전 (v{1}) 의 설정은 보관해 둡니다.', [r.version, current]) - : tr('프로그램을 종료하고 설치한 뒤 자동으로 다시 시작합니다.'), - ])), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지금 설치'))), - ], - ), - ); - if (go != true) return; - await _saveSnapshot(c, current); - // Android: 설치 화면만 연다 (앱을 끝내지 않음, 설치되면 Android 가 앱을 다시 띄움) - if (up.installsInPlace) { - while (true) { - try { - await up.scheduleInstall(dir); - return; - } on InstallPermissionNeeded { - // 설치 허용 설정 화면이 열렸다: 켜고 돌아와 [설치 계속] → 받은 파일로 다시 (처음부터 다시 받지 않게) - if (!context.mounted) return; - final again = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(tr('설치 허용 필요')), - content: Text(tr('설정 화면에서 "이 출처의 앱 설치 허용" 을 켠 뒤 돌아와 [설치 계속] 을 누르세요. ' - '받은 파일을 그대로 씁니다 (다시 받지 않음).')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('설치 계속'))), - ], - ), - ); - if (again != true) return; - } catch (e) { - snack(trf('설치 화면을 열 수 없습니다: {0}', [e])); - return; - } - } - } - // 다운로드 중이면 기존 종료 확인 절차 - final ok = await (c.confirmQuit?.call() ?? Future.value(true)); - if (!ok) return; - await up.scheduleInstall(dir); - await c.services.shell.quit(); -} - -/// 다른 버전을 설치하기 전에 지금 버전의 설정을 보관 (그 버전으로 돌아오면 되살린다) -Future _saveSnapshot(AppController c, String current) async { - final snap = VersionSnapshot.instance; - if (snap == null) return; - try { - await c.updateSettings((x) => x.lastRunVersion = current); // 보관본이 "이 버전이 쓴 설정" 이 되게 - await snap.save(current); - c.note(trf('지금 버전 (v{0}) 의 설정을 보관했습니다: {1}', [current, snap.dirOf(current)])); - } catch (e) { - c.note(trf('설정을 보관하지 못했습니다: {0}', [e])); - } -} - -/// P0: 되돌리기 때 옮겨 둘 받은 AI 모델 폴더 (그림 · 해상도: 앱 데이터/ai, 자막: 모델 폴더) -Future> rollbackModelRoots(AppController c) async => { - if (AiStore.instance != null) 'ai': AiStore.instance!.root, - 'models': await c.services.models.directory(), - }; - -/// 크기를 GB · MB 로 -String gbText(int bytes) => bytes >= 1 << 30 - ? '${(bytes / (1 << 30)).toStringAsFixed(1)}GB' - : '${(bytes / (1 << 20)).toStringAsFixed(0)}MB'; - -/// 진행 창을 띄우고 [work] 를 한다 (끌 수 없음). 실패하면 그 글 -Future withProgressDialog( - BuildContext context, String title, Future Function(void Function(int done, int total) onProgress) work) async { - final progress = ValueNotifier(null); - final nav = Navigator.of(context); - // ignore: unawaited_futures - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => AlertDialog( - title: Text(title), - content: ValueListenableBuilder( - valueListenable: progress, - builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, children: [ - LinearProgressIndicator(value: v), - const SizedBox(height: 6), - Text(v == null ? tr('준비하는 중') : '${(v * 100).floor()}%'), - ]), - ), - ), - ); - String? error; - try { - await work((done, total) => progress.value = total == 0 ? null : done / total); - } catch (e) { - error = '$e'; - } - nav.pop(); - return error; -} - -/// Android 에서 예전 버전으로: Android 는 버전이 낮은 앱을 위에 설치하지 못하므로 앱을 지운 뒤 설치해야 한다. -/// APK 와 설정 보관본은 앱을 지워도 남는 Download/JJ_MKVMaker 에 두고, 다시 설치한 앱이 시작할 때 설정을 되살린다. -Future _androidRollback(BuildContext context, AppController c, Updater up, ReleaseInfo r, String current) async { - final snap = VersionSnapshot.instance; - final shared = snap?.sharedDir; - if (shared == null) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.')))); - return; - } - // P0: 남는 것 / 사라지는 것을 사실대로. 받은 AI 모델은 공용 폴더에 옮겨 둘 수 있다 - final roots = await rollbackModelRoots(c); - final modelSize = ModelBackup.sizeOf(roots); - // 공용 폴더는 처음엔 없을 수 있다 - 있는 가장 가까운 상위 폴더로 남은 공간을 잰다 - var probe = shared; - while (!Directory(probe).existsSync() && p.dirname(probe) != probe) { - probe = p.dirname(probe); - } - final free = (await diskSpace(probe))?.$1; - final setting = c.settings.rollbackKeepModels; - var keep = modelSize > 0 && (setting == 'on' || (setting == '' && free != null && free >= modelSize * 2)); - var keepTouched = false; - // 옮겨 둔 모델을 되살리는 기능은 이 판 다음부터 들어 있다 (설정 되살리기는 v2026.10.08_001 부터) - final targetRestoresModels = compareVersions(r.version, '2026.10.09_001') > 0; - final targetRestoresSettings = compareVersions(r.version, '2026.10.08_001') >= 0; - if (!context.mounted) return; - final ok = await showDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (ctx, setInner) { - final size = gbText(modelSize); - final kept = [ - tr('설정 (WebDAV 서버 목록 포함, 비밀번호는 빼고) · 동영상 목록 · 즐겨찾기 - Download/JJ_MKVMaker/설정 보관'), - tr('공용 폴더에 받은 파일 (동영상 · 그림 등)'), - if (keep) trf('받은 AI 모델 ({0}) - Download/JJ_MKVMaker/AI 모델 보관', [size]), - ]; - final lost = [ - if (modelSize > 0 && !keep) trf('받은 AI 모델 (그림 · 자막) - 다시 받아야 합니다 (약 {0})', [size]), - tr('비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)'), - tr('마스터 · 최상 비밀번호'), - tr('앱 안 브라우저의 로그인 · 쿠키'), - tr('작업 기록 · 받다 만 파일'), - ]; - return AlertDialog( - scrollable: true, - title: Text(trf('예전 버전 v{0} 으로 되돌리기', [r.version])), - content: SizedBox( - width: 560, - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(trf( - 'Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 "남는 것" 을 ' - 'Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.', - [r.version])), - const SizedBox(height: 10), - Text(tr('남는 것'), style: const TextStyle(fontWeight: FontWeight.w600)), - for (final x in kept) Text('· $x'), - const SizedBox(height: 8), - Text(tr('사라지는 것'), style: TextStyle(fontWeight: FontWeight.w600, color: JjColors.warning)), - for (final x in lost) Text('· $x', style: TextStyle(color: JjColors.warning)), - if (modelSize > 0) ...[ - const SizedBox(height: 8), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - value: keep, - // 고른 것은 [저장하고 계속] 을 누를 때만 설정에 남긴다 ([취소] 하면 그대로) - onChanged: (v) => setInner(() { - keep = v ?? false; - keepTouched = true; - }), - title: Text(trf('받은 AI 모델 ({0}) 을 Download/JJ_MKVMaker 에 복사해 두었다가 다시 설치한 뒤 되살리기 (잠시 {0} 더 필요)', [size])), - subtitle: Text([ - if (free != null) trf('남은 공간 {0}', [gbText(free)]), - if (!targetRestoresModels) - trf('v{0} 은 복사해 둔 모델을 되살리지 못합니다. 이 기능이 있는 판으로 다시 올리면 그때 되살립니다.', [r.version]), - ].join(' · ')), - ), - ], - if (!targetRestoresSettings) - Text(trf('v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.', [r.version]), - style: TextStyle(color: JjColors.warning)), - ]), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장하고 계속'))), - ], - ); - }, - ), - ); - if (ok != true || !context.mounted) return; - // 체크를 바꿨으면 이제 설정에 (자동이던 것은 건드리지 않으면 자동 그대로) - if (keepTouched) await c.updateSettings((x) => x.rollbackKeepModels = keep ? 'on' : 'off'); - if (!context.mounted) return; - // 사라지는 것이 있으니 한 번 더 (비밀번호 · 로그인은 늘 사라진다) - final sure = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(tr('정말 되돌릴까요?')), - content: Text([ - tr('앱을 지우면 다음은 되살릴 수 없습니다:'), - if (modelSize > 0 && !keep) trf('· 받은 AI 모델 (약 {0}, 다시 받아야 함)', [gbText(modelSize)]), - tr('· 비밀번호 · API 키 · 마스터 · 최상 비밀번호'), - tr('· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록'), - ].join('\n')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: JjColors.alert), - onPressed: () => Navigator.pop(ctx, true), - child: Text(tr('되돌리기 계속')), - ), - ], - ), - ); - if (sure != true || !context.mounted) return; - final apk = await _download(context, up, r); - if (apk == null || !context.mounted) return; - final outDir = p.dirname(shared); - final saved = p.join(outDir, r.zipName ?? 'JJ_MKVMaker_v${r.version}.apk'); - try { - await Directory(outDir).create(recursive: true); - await File(apk).copy(saved); - await _saveSnapshot(c, current); - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('저장하지 못했습니다: {0}', [e])))); - } - return; - } - // P0: 받은 AI 모델을 공용 폴더로 (실패하면 앱을 지우지 않는다) - if (keep && modelSize > 0) { - final backup = ModelBackup.shared; - if (backup == null || !context.mounted) return; - final err = await withProgressDialog(context, tr('AI 모델을 복사해 두는 중'), (onProgress) => backup.save(roots, onProgress: onProgress)); - if (err != null) { - if (context.mounted) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(trf('AI 모델을 복사해 두지 못해 멈췄습니다 (앱은 지우지 않음): {0}', [err])))); - } - return; - } - c.note(trf('되돌리기: AI 모델 {0} 을 복사해 두었습니다: {1}', [gbText(modelSize), backup.dir])); - } - if (!context.mounted) return; - final go = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(tr('저장했습니다')), - content: Text(trf('설치 파일: {0}\n이제 앱을 지운 뒤, 파일 앱에서 이 파일을 눌러 설치하세요.', [saved])), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('앱 지우기'))), - ], - ), - ); - if (go == true) await up.uninstallSelf(); -} diff --git a/test/update_modules_test.dart b/test/update_modules_test.dart new file mode 100644 index 0000000..baa7f79 --- /dev/null +++ b/test/update_modules_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/version_snapshot.dart'; +import 'package:jj_mkvmaker/core/app_update.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/update/update_install.dart'; +import 'package:jj_mkvmaker/ui/update_dialog.dart'; + +/// 업데이트 나누기 (update_dialog 651줄 → lib/ui/update/) 의 모듈 약속 시험. +/// 확인 · 고르기 · 설치 · Android 되돌리기 흐름 전체는 update_ui_test 가 본다 +void main() { + ReleaseInfo rel(String v, {bool zip = true}) => ReleaseInfo( + version: v, tag: 'v$v', name: v, notes: '', pageUrl: 'https://example.com/$v', zipUrl: zip ? 'https://example.com/$v.zip' : null); + + test('update_install (단계): gbText (1GB 이상은 소수 한 자리 GB, 아래는 MB)', () { + expect(gbText(1 << 30), '1.0GB'); + expect(gbText(3 * (1 << 29)), '1.5GB'); + expect(gbText(5 << 20), '5MB'); + expect(gbText(0), '0MB'); + }); + + test('update_dialog (버전 고르기): 처음 고를 버전 = 이 기기용 파일이 있는 가장 새것, 없으면 지금, 그것도 없으면 맨 앞', () { + expect(initialVersionPick([rel('3', zip: false), rel('2'), rel('1')], '1').version, '2'); + expect(initialVersionPick([rel('3', zip: false), rel('2', zip: false)], '2').version, '2'); + expect(initialVersionPick([rel('3', zip: false), rel('2', zip: false)], '9').version, '3'); + }); + + testWidgets('update_install (단계): withProgressDialog - 끝나면 창을 닫고 null, 실패하면 그 글 · 진행은 % 로', (t) async { + late BuildContext ctx; + await t.pumpWidget(MaterialApp(home: Builder(builder: (c) { + ctx = c; + return const Scaffold(); + }))); + void Function(int, int)? report; + var release = false; + final ok = withProgressDialog(ctx, '복사 중', (onProgress) async { + report = onProgress; + while (!release) { + await Future.delayed(const Duration(milliseconds: 10)); + } + return null; + }); + await t.pump(); + expect(find.text('복사 중'), findsOneWidget); + expect(find.text('준비하는 중'), findsOneWidget); + report!(1, 4); + await t.pump(); + expect(find.text('25%'), findsOneWidget); + release = true; + await t.pump(const Duration(milliseconds: 20)); + await t.pumpAndSettle(); + expect(await ok, isNull); + expect(find.text('복사 중'), findsNothing); + + final bad = withProgressDialog(ctx, '되살리는 중', (_) async => throw StateError('자리 없음')); + await t.pumpAndSettle(); + expect(await bad, contains('자리 없음')); + expect(find.text('되살리는 중'), findsNothing); + }); + + test('update_install (단계): 설정 보관 저장소가 없으면 (VersionSnapshot 없음) 아무것도 하지 않음 · 던지지 않음', () async { + final was = VersionSnapshot.instance; + VersionSnapshot.instance = null; + addTearDown(() => VersionSnapshot.instance = was); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final before = c.settings.lastRunVersion; + await saveVersionSnapshot(c, '9.9.9'); + expect(c.settings.lastRunVersion, before); + }); + + testWidgets('update_dialog (버전 고르기): 바뀐 내용이 비면 "(설명 없음)" · 표는 글자 그대로', (t) async { + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: Column(children: [ + const SizedBox(height: 100, child: UpdateNotes('')), + UpdateBadge('최신', Colors.green), + ])))); + expect(find.text('(설명 없음)'), findsOneWidget); + expect(find.text('최신'), findsOneWidget); + }); +}