업데이트 나누기: update_dialog (651) 를 확인 · 고르기 (325) 와 설치 · 되돌리기 모듈 (361) 로
- lib/ui/update_dialog.dart: 새 버전 확인 · [최신 버전 확인] 단추 · 버전 고르기 창 (VersionPickerDialog · UpdateNotes · UpdateBadge) - lib/ui/update/update_install.dart: 공용 단계 (받기 · 설정 보관 · 진행 창 · gbText · rollbackModelRoots) · installRelease · Android 되돌리기 - 부르는 곳은 그대로 (update_dialog 가 installRelease · gbText · rollbackModelRoots · withProgressDialog 를 내보냄). 동작은 그대로 - 약속 시험 test/update_modules_test.dart Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>split_vault
parent
e0928ea5a6
commit
88aadc7d3e
|
|
@ -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<String?> downloadRelease(BuildContext context, Updater up, ReleaseInfo r) async {
|
||||||
|
final progress = ValueNotifier<double>(0);
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
scrollable: true,
|
||||||
|
title: Text(trf('v{0} 받는 중', [r.version])),
|
||||||
|
content: ValueListenableBuilder<double>(
|
||||||
|
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<void> 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<Map<String, String>> 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<String?> withProgressDialog(
|
||||||
|
BuildContext context, String title, Future<Object?> Function(void Function(int done, int total) onProgress) work) async {
|
||||||
|
final progress = ValueNotifier<double?>(null);
|
||||||
|
final nav = Navigator.of(context);
|
||||||
|
// ignore: unawaited_futures
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: ValueListenableBuilder<double?>(
|
||||||
|
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<void> 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<bool>(
|
||||||
|
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<bool>(
|
||||||
|
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<void> 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<bool>(
|
||||||
|
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<bool>(
|
||||||
|
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<bool>(
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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/app_controller.dart';
|
||||||
import '../app/copy_center.dart' show diskSpace;
|
|
||||||
import '../app/model_backup.dart';
|
|
||||||
import '../app/net_guard.dart';
|
import '../app/net_guard.dart';
|
||||||
import '../app/version_snapshot.dart';
|
|
||||||
import '../core/app_update.dart';
|
import '../core/app_update.dart';
|
||||||
import '../services/updater.dart' show InstallPermissionNeeded, Updater;
|
import '../services/updater.dart' show Updater;
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
import 'update/update_install.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
|
|
||||||
|
// 약속 (업데이트 나누기): 이 파일은 새 버전 확인 ([checkForUpdate] · [UpdateCheckButton]) 과 버전 고르기를 맡는다.
|
||||||
|
// 버전 고르기 ([chooseVersion] · [VersionPickerDialog]) 도 여기, 설치 · 되돌리기 · 공용 단계는 update/update_install.dart (부르는 곳이 그대로 쓰게 아래에서 내보냄)
|
||||||
|
export 'update/update_install.dart' show gbText, installRelease, rollbackModelRoots, withProgressDialog;
|
||||||
|
|
||||||
/// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작 (하루 한 번 자동 확인 · 최신 버전만)
|
/// 새 버전 확인 → 알림 → 받기 · 검증 → 종료 후 설치 · 다시 시작 (하루 한 번 자동 확인 · 최신 버전만)
|
||||||
///
|
///
|
||||||
/// [manual]: "지금 확인" (최신이어도 알려 주고, 건너뛴 버전도 보여 줌)
|
/// [manual]: "지금 확인" (최신이어도 알려 주고, 건너뛴 버전도 보여 줌)
|
||||||
|
|
@ -79,7 +79,7 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Expanded(child: _Notes(r.notes)),
|
Expanded(child: UpdateNotes(r.notes)),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|
@ -105,30 +105,6 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 환경 설정 > [업데이트]: GitHub 에 올려 둔 모든 버전을 보여 주고 골라 설치한다.
|
|
||||||
/// 최신으로 올리기뿐 아니라 예전 (정상이던) 버전으로 되돌리기 · 같은 버전 다시 설치도 된다.
|
|
||||||
Future<void> 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<ReleaseInfo>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => _VersionPicker(up: up, current: current),
|
|
||||||
);
|
|
||||||
if (picked == null || !context.mounted) return;
|
|
||||||
await installRelease(context, c, picked, current);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 169: 버전 고르기 창에서 처음 고를 버전: 이 기기용 파일이 있는 것 중 가장 새것, 없으면 지금 버전 (목록은 새것이 앞)
|
|
||||||
ReleaseInfo initialVersionPick(List<ReleaseInfo> all, String current) =>
|
|
||||||
all.firstWhere((r) => r.zipUrl != null, orElse: () => all.firstWhere((r) => r.version == current, orElse: () => all.first));
|
|
||||||
|
|
||||||
/// 164: [최신 버전 확인] 과 그 결과 (버튼 옆에 - 최신입니다 · 새 버전이 있습니다 · 확인할 수 없습니다)
|
/// 164: [최신 버전 확인] 과 그 결과 (버튼 옆에 - 최신입니다 · 새 버전이 있습니다 · 확인할 수 없습니다)
|
||||||
class UpdateCheckButton extends StatefulWidget {
|
class UpdateCheckButton extends StatefulWidget {
|
||||||
final AppController c;
|
final AppController c;
|
||||||
|
|
@ -164,17 +140,46 @@ class _UpdateCheckButtonState extends State<UpdateCheckButton> {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 버전 고르기 창
|
/// 약속 (업데이트 나누기): 버전 고르기.
|
||||||
class _VersionPicker extends StatefulWidget {
|
/// - [chooseVersion]: 올려 둔 모든 버전을 보여 주고 고른 것을 [installRelease] 로 (닫으면 아무것도 하지 않음)
|
||||||
final Updater up;
|
/// - [initialVersionPick]: 처음 고를 버전 (순수 함수)
|
||||||
|
/// - [VersionPickerDialog]: 고르기 창 → 고른 [ReleaseInfo], 닫으면 null. 목록을 읽지 못하면 창 안에 그 글
|
||||||
|
/// - [UpdateNotes] · [UpdateBadge]: 바뀐 내용 칸 · 작은 표 (지금 · 최신 · 시험판)
|
||||||
|
/// 환경 설정 > [업데이트]: GitHub 에 올려 둔 모든 버전을 보여 주고 골라 설치한다.
|
||||||
|
/// 최신으로 올리기뿐 아니라 예전 (정상이던) 버전으로 되돌리기 · 같은 버전 다시 설치도 된다.
|
||||||
|
Future<void> chooseVersion(BuildContext context, AppController c) async {
|
||||||
|
final up = c.services.updater;
|
||||||
|
if (up == null) return;
|
||||||
final String current;
|
final String current;
|
||||||
const _VersionPicker({required this.up, required this.current});
|
try {
|
||||||
|
current = await up.currentVersion();
|
||||||
@override
|
} catch (_) {
|
||||||
State<_VersionPicker> createState() => _VersionPickerState();
|
return;
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final picked = await showDialog<ReleaseInfo>(
|
||||||
|
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<ReleaseInfo> 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<VersionPickerDialog> createState() => _VersionPickerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VersionPickerDialogState extends State<VersionPickerDialog> {
|
||||||
late final Future<List<ReleaseInfo>> _list = widget.up.releases();
|
late final Future<List<ReleaseInfo>> _list = widget.up.releases();
|
||||||
ReleaseInfo? _sel;
|
ReleaseInfo? _sel;
|
||||||
|
|
||||||
|
|
@ -229,9 +234,9 @@ class _VersionPickerState extends State<_VersionPicker> {
|
||||||
selectedTileColor: JjColors.accent.withValues(alpha: 0.15),
|
selectedTileColor: JjColors.accent.withValues(alpha: 0.15),
|
||||||
title: Row(children: [
|
title: Row(children: [
|
||||||
Text('v${r.version}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
Text('v${r.version}', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
if (r.version == widget.current) _Badge(tr('지금'), JjColors.accent),
|
if (r.version == widget.current) UpdateBadge(tr('지금'), JjColors.accent),
|
||||||
if (r.version == newest) _Badge(tr('최신'), JjColors.positive),
|
if (r.version == newest) UpdateBadge(tr('최신'), JjColors.positive),
|
||||||
if (r.prerelease) _Badge(tr('시험판'), JjColors.warning),
|
if (r.prerelease) UpdateBadge(tr('시험판'), JjColors.warning),
|
||||||
]),
|
]),
|
||||||
subtitle: Text(r.zipUrl == null ? '${_date(r.published)} · ${tr('이 기기용 파일 없음')}' : _date(r.published),
|
subtitle: Text(r.zipUrl == null ? '${_date(r.published)} · ${tr('이 기기용 파일 없음')}' : _date(r.published),
|
||||||
style: const TextStyle(fontSize: 11)),
|
style: const TextStyle(fontSize: 11)),
|
||||||
|
|
@ -260,7 +265,7 @@ class _VersionPickerState extends State<_VersionPicker> {
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
Text(tr('바뀐 내용'), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Expanded(child: _Notes(_sel!.notes)),
|
Expanded(child: UpdateNotes(_sel!.notes)),
|
||||||
]);
|
]);
|
||||||
// 168: 좁은 화면 (폰) 에서는 목록 위 · 고른 버전의 바뀐 내용 아래 (나란히 두면 바뀐 내용 칸이 너무 좁았다)
|
// 168: 좁은 화면 (폰) 에서는 목록 위 · 고른 버전의 바뀐 내용 아래 (나란히 두면 바뀐 내용 칸이 너무 좁았다)
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
|
|
@ -290,10 +295,10 @@ class _VersionPickerState extends State<_VersionPicker> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Badge extends StatelessWidget {
|
class UpdateBadge extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
final Color color;
|
final Color color;
|
||||||
const _Badge(this.text, this.color);
|
const UpdateBadge(this.text, this.color, {super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => Container(
|
||||||
|
|
@ -304,9 +309,9 @@ class _Badge extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Notes extends StatelessWidget {
|
class UpdateNotes extends StatelessWidget {
|
||||||
final String notes;
|
final String notes;
|
||||||
const _Notes(this.notes);
|
const UpdateNotes(this.notes, {super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => Container(
|
||||||
|
|
@ -318,334 +323,3 @@ class _Notes extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 받기 (진행 창) → 받은 파일 경로. 실패하면 null.
|
|
||||||
Future<String?> _download(BuildContext context, Updater up, ReleaseInfo r) async {
|
|
||||||
final progress = ValueNotifier<double>(0);
|
|
||||||
showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (_) => AlertDialog(
|
|
||||||
scrollable: true,
|
|
||||||
title: Text(trf('v{0} 받는 중', [r.version])),
|
|
||||||
content: ValueListenableBuilder<double>(
|
|
||||||
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<void> 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<bool>(
|
|
||||||
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<bool>(
|
|
||||||
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<void> _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<Map<String, String>> 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<String?> withProgressDialog(
|
|
||||||
BuildContext context, String title, Future<Object?> Function(void Function(int done, int total) onProgress) work) async {
|
|
||||||
final progress = ValueNotifier<double?>(null);
|
|
||||||
final nav = Navigator.of(context);
|
|
||||||
// ignore: unawaited_futures
|
|
||||||
showDialog<void>(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (_) => AlertDialog(
|
|
||||||
title: Text(title),
|
|
||||||
content: ValueListenableBuilder<double?>(
|
|
||||||
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<void> _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<bool>(
|
|
||||||
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<bool>(
|
|
||||||
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<bool>(
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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<void>.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue