설정 A 분야 9: 실행 · 종료 · 업데이트 (RunSettings) - 키 11 · backgroundRun 함께

- 도구 고침: setter 안의 '      closeAction = v' 줄을 선언으로 잡던 것 (타입은 글자로 시작 · 첫 선언만)
- 문서 주석 [AppSettings.run.x] → [RunSettings.x]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 23:28:09 +09:00
parent 7c6320165a
commit 056ca186d9
20 changed files with 159 additions and 127 deletions

View File

@ -39,7 +39,7 @@ void main() {
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), updater: up),
settingsStore: SettingsStore());
await t.runAsync(c.init);
expect(c.settings.lastRunVersion, from, reason: '예전 판이 쓴 데이터를 읽음');
expect(c.settings.run.lastRunVersion, from, reason: '예전 판이 쓴 데이터를 읽음');
final r = (await t.runAsync(up.latest))!;
expect(r.name, contains('시험 길'));
t.view.physicalSize = const Size(1200, 900);

View File

@ -15,6 +15,7 @@ import '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart';
import 'settings/records.dart';
import 'settings/settings_group.dart';
import 'settings/run_settings.dart';
import 'settings/browser_settings.dart';
import 'settings/download_settings.dart';
import 'settings/reader_settings.dart';
@ -27,6 +28,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart';
export 'settings/settings_group.dart';
export 'settings/settings_store.dart';
export 'settings/run_settings.dart';
export 'settings/browser_settings.dart';
export 'settings/download_settings.dart';
export 'settings/reader_settings.dart';
@ -53,15 +55,8 @@ class AppSettings {
/// 최소화하면 작업 표시줄 대신 트레이로
bool minimizeToTray = true;
/// 종료 (창 X · 종료 버튼) 를 눌렀을 때:
/// 'background' 창만 숨기고 다운로드 · 변환 계속 / 'quit' 묻지 않고 모두 종료 / 'ask' 매번 고르기
String closeAction = 'background';
/// 창 보이기 단축키 (예: Ctrl+Shift+X)
String showHotkey = 'Ctrl+Shift+X';
/// AI 자막: 매번 설정 창을 띄울지 (끄면 마지막 설정으로 바로 시작)
bool askAiOptions = true;
@ -229,8 +224,6 @@ class AppSettings {
/// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다
bool startMenuShortcut = true;
// ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ──
@ -312,42 +305,18 @@ class AppSettings {
/// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시).
/// 끄면 지금처럼 닫을 때 끝난다. Windows 는 [closeAction] 'background' 가 같은 뜻 ([backgroundRun]).
bool runInBackground = true;
/// 백그라운드로 실행 (설정 화면의 체크 하나). Windows: 창 ✕ 를 누르면 트레이로 (끄면 종료), Android: [runInBackground]
bool get backgroundRun => Platform.isWindows ? closeAction == 'background' : runInBackground;
set backgroundRun(bool v) {
if (Platform.isWindows) {
closeAction = v ? 'background' : 'quit';
} else {
runInBackground = v;
}
}
/// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음
String webViewDataDir = '';
/// 시작할 때 새 버전 확인 (하루 한 번)
bool autoCheckUpdates = true;
String lastUpdateCheck = '';
/// [lastUpdateCheck] 를 한 버전 (같은 설정 파일을 쓰는 다른 버전이 확인했으면 다시 확인)
String lastUpdateCheckVersion = '';
/// 마지막으로 이 설정을 쓴 (실행한) 버전. 예전 버전은 이 항목을 몰라 저장하면서 지운다 →
/// 돌아온 버전이 자기 것이 아니면 보관해 둔 그 버전의 설정을 되살릴지 묻는다 (VersionSnapshot)
String lastRunVersion = '';
/// P0 (Android 되돌리기): 받은 AI 모델을 공용 폴더에 옮겨 두었다가 되살릴지. '' = 남은 공간이 모델의 2배 이상이면 켬 · 'on' · 'off'
String rollbackKeepModels = '';
/// "이 버전 건너뛰기" 한 버전
String skippedVersion = '';
/// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈)
PlaylistMode playlistMode = PlaylistMode.series;
@ -389,8 +358,11 @@ class AppSettings {
/// 웹 브라우저 (앱 안 · 외부)
final browser = BrowserSettings();
/// 실행 · 종료 · 업데이트
final run = RunSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser];
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -400,9 +372,6 @@ class AppSettings {
'mkvOutputRoot': mkvOutputRoot,
'moveTargets': [for (final t in moveTargets) t.toJson()],
'folderBookmarks': [for (final b in folderBookmarks) b.toJson()],
'minimizeToTray': minimizeToTray,
'closeAction': closeAction,
'showHotkey': showHotkey,
'askAiOptions': askAiOptions,
// 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만
'openSubtitlesSaved': {
@ -447,7 +416,6 @@ class AppSettings {
'navOrder': navOrder,
'swipeNav': swipeNav,
'imageExts': imageExts,
'startMenuShortcut': startMenuShortcut,
'swipeWrap': swipeWrap,
'cleanupPrechecked': cleanupPrechecked,
'migrationNotice': migrationNotice,
@ -469,14 +437,6 @@ class AppSettings {
'smbRequireSecure': smbRequireSecure,
'remoteIdleSeconds': remoteIdleSeconds,
'ftpServers': [for (final x in ftpServers) x.toJson()],
'runInBackground': runInBackground,
'backgroundV2': true,
'autoCheckUpdates': autoCheckUpdates,
'lastUpdateCheck': lastUpdateCheck,
'lastUpdateCheckVersion': lastUpdateCheckVersion,
'lastRunVersion': lastRunVersion,
'rollbackKeepModels': rollbackKeepModels,
'skippedVersion': skippedVersion,
'playlistMode': playlistMode.name,
'externalPlayers': externalPlayers,
'encode': {
@ -518,9 +478,6 @@ class AppSettings {
// 예전 판의 이동 폴더 하나 → "이동" 버튼으로
if (j['moveTargets'] == null && j['moveTargetDir'] is String) MoveTarget('이동', j['moveTargetDir'] as String),
]
..minimizeToTray = j['minimizeToTray'] as bool? ?? true
..closeAction = const ['background', 'quit', 'ask'].contains(j['closeAction']) ? j['closeAction'] as String : 'background'
..showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X'
..askAiOptions = j['askAiOptions'] as bool? ?? true
..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
@ -567,7 +524,6 @@ class AppSettings {
..imageExts = j['imageExts'] is List
? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()]
: [...defaultImageExtensions]
..startMenuShortcut = j['startMenuShortcut'] != false
..swipeWrap = j['swipeWrap'] != false
..cleanupPrechecked = j['cleanupPrechecked'] is List
? [for (final x in j['cleanupPrechecked'] as List) '$x']
@ -599,14 +555,6 @@ class AppSettings {
for (final x in (j['ftpServers'] as List?) ?? const [])
if (x is Map) FtpServer.fromJson(x),
]
// 사용자 결정 (10/8): 기본 켜짐. 예전 기본 (꺼짐) 은 한 번 켜짐으로
..runInBackground = j['backgroundV2'] == true ? j['runInBackground'] != false : true
..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true
..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? ''
..lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? ''
..lastRunVersion = j['lastRunVersion'] as String? ?? ''
..rollbackKeepModels = const ['on', 'off'].contains(j['rollbackKeepModels']) ? j['rollbackKeepModels'] as String : ''
..skippedVersion = j['skippedVersion'] as String? ?? ''
..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series)
..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>()
..encode = EncodeSettings(
@ -693,7 +641,7 @@ class AppSettings {
if (j['cookieScopeV2'] != true && j.isNotEmpty && (j['ytCookiesBrowser'] ?? internalBrowserCookies) == internalBrowserCookies)
'cookieScope',
if (j['zipComicV2'] != true && j['zipComic'] != false && j.containsKey('imageExts') && !s.reader.zipComic) 'zipComic',
if (j['backgroundV2'] != true && j['runInBackground'] == false && s.runInBackground && Platform.isAndroid)
if (j['backgroundV2'] != true && j['runInBackground'] == false && s.run.inBackground && Platform.isAndroid)
'background',
// 193: 그 설정이 생기기 전 판 (설정 파일은 있는데 그 항목이 없음 - 예: 10.03) 에서 올라온 사람이 바로 겪는 동작 변화.
// 예전 판에는 키가 없어 위의 "예전 값 → 새 값" 조건에 걸리지 않았다 (P11: 탐색기에서 한 번 눌렀는데 바로 열려 놀람)
@ -706,7 +654,7 @@ class AppSettings {
if (j.isNotEmpty && j['explorerRulesV1'] != true && (j['explorerLayout'] ?? 'auto') == 'auto') 'explorerLayoutRule',
if (j.isNotEmpty && !j.containsKey('screenOrientation') && s.screenOrientation == 'auto' && Platform.isAndroid) 'orientation',
if (j.isNotEmpty && !j.containsKey('swipeNav') && s.swipeNav) 'swipeNav',
if (j.isNotEmpty && !j.containsKey('runInBackground') && s.runInBackground && Platform.isAndroid) 'background',
if (j.isNotEmpty && !j.containsKey('runInBackground') && s.run.inBackground && Platform.isAndroid) 'background',
// DNS 강제 (사용자 요청 10/10, 처음 값 직접 지정 8.8.8.8 · 8.8.4.4) 가 없던 판에서 올라오면 이름을 묻는 서버가 바뀐다 - 193 과 같이 한 번 알림
if (j.isNotEmpty && !j.containsKey('dnsMode') && s.network.mode == 'custom') 'dnsForced',
// 193 · 229: 예전 판을 쓰던 사람 (설정 파일이 있는데 이 항목이 없음) 에게 복사 · 이동이 받는 쪽의 더 새 파일을 건너뛰게 바뀐 것을 한 번

View File

@ -0,0 +1,84 @@
import 'dart:io';
import 'settings_group.dart';
/// 약속 (설정 A): 환경 설정 > 실행 · 종료 · 업데이트 - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키)
class RunSettings implements SettingsGroup {
/// 최소화하면 작업 표시줄 대신 트레이로
bool minimizeToTray = true;
/// 종료 (창 X · 종료 버튼) 를 눌렀을 때:
/// 'background' 창만 숨기고 다운로드 · 변환 계속 / 'quit' 묻지 않고 모두 종료 / 'ask' 매번 고르기
String closeAction = 'background';
/// 창 보이기 단축키 (예: Ctrl+Shift+X)
String showHotkey = 'Ctrl+Shift+X';
/// 99 (Windows): 시작 메뉴에 등록 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다
bool startMenuShortcut = true;
/// 백그라운드로 실행 (Android): 켜면 ← 로 닫거나 최근 앱에서 밀어도 동기화 · MKV 만들기 · 다운로드를 계속한다 (알림에 표시).
/// 끄면 지금처럼 닫을 때 끝난다. Windows 는 [closeAction] 'background' 가 같은 뜻 ([backgroundRun]).
bool inBackground = true;
/// 시작할 때 새 버전 확인 (하루 한 번)
bool autoCheckUpdates = true;
String lastUpdateCheck = '';
/// [lastUpdateCheck] 를 한 버전 (같은 설정 파일을 쓰는 다른 버전이 확인했으면 다시 확인)
String lastUpdateCheckVersion = '';
/// 마지막으로 이 설정을 쓴 (실행한) 버전. 예전 버전은 이 항목을 몰라 저장하면서 지운다 →
/// 돌아온 버전이 자기 것이 아니면 보관해 둔 그 버전의 설정을 되살릴지 묻는다 (VersionSnapshot)
String lastRunVersion = '';
/// P0 (Android 되돌리기): 받은 AI 모델을 공용 폴더에 옮겨 두었다가 되살릴지. '' = 남은 공간이 모델의 2배 이상이면 켬 · 'on' · 'off'
String rollbackKeepModels = '';
/// "이 버전 건너뛰기" 한 버전
String skippedVersion = '';
/// 백그라운드로 실행 (설정 화면의 체크 하나). Windows: 창 ✕ 를 누르면 트레이로 (끄면 종료), Android: [inBackground]
bool get backgroundRun => Platform.isWindows ? closeAction == 'background' : inBackground;
set backgroundRun(bool v) {
if (Platform.isWindows) {
closeAction = v ? 'background' : 'quit';
} else {
inBackground = v;
}
}
@override
void write(Map<String, Object?> j, {required bool plainSecrets}) {
j['minimizeToTray'] = minimizeToTray;
j['closeAction'] = closeAction;
j['showHotkey'] = showHotkey;
j['runInBackground'] = inBackground;
j['startMenuShortcut'] = startMenuShortcut;
j['autoCheckUpdates'] = autoCheckUpdates;
j['lastUpdateCheck'] = lastUpdateCheck;
j['lastUpdateCheckVersion'] = lastUpdateCheckVersion;
j['lastRunVersion'] = lastRunVersion;
j['rollbackKeepModels'] = rollbackKeepModels;
j['skippedVersion'] = skippedVersion;
j['backgroundV2'] = true;
}
@override
void read(Map<String, dynamic> j) {
minimizeToTray = j['minimizeToTray'] as bool? ?? true;
closeAction = const ['background', 'quit', 'ask'].contains(j['closeAction']) ? j['closeAction'] as String : 'background';
showHotkey = j['showHotkey'] as String? ?? 'Ctrl+Shift+X';
startMenuShortcut = j['startMenuShortcut'] != false;
// 사용자 결정 (10/8): 기본 켜짐. 예전 기본 (꺼짐) 은 한 번 켜짐으로
inBackground = j['backgroundV2'] == true ? j['runInBackground'] != false : true;
autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true;
lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '';
lastUpdateCheckVersion = j['lastUpdateCheckVersion'] as String? ?? '';
lastRunVersion = j['lastRunVersion'] as String? ?? '';
rollbackKeepModels = const ['on', 'off'].contains(j['rollbackKeepModels']) ? j['rollbackKeepModels'] as String : '';
skippedVersion = j['skippedVersion'] as String? ?? '';
}
}

View File

@ -172,7 +172,7 @@ Future<void> main(List<String> args) async {
Future<bool> confirmQuit({bool force = false}) async {
final shell = services.shell;
final desktop = shell is DesktopShell ? shell : null;
var mode = force || (desktop?.closeFromTray ?? false) ? 'force' : controller.settings.closeAction;
var mode = force || (desktop?.closeFromTray ?? false) ? 'force' : controller.settings.run.closeAction;
// 트레이 아이콘이 없으면 숨긴 창을 끝낼 방법이 없다 → 고르게 한다
if (mode == 'background' && !(desktop?.hasTray ?? false)) mode = 'ask';
if (mode == 'ask') {
@ -186,9 +186,9 @@ Future<void> main(List<String> args) async {
if ((CopyCenter.peekOf(controller)?.activeCount ?? 0) > 0)
trf('복사 · 이동 {0}개', [CopyCenter.peekOf(controller)!.activeCount]),
].join(' · ').ifEmptyText(tr('진행 중인 작업 없음')),
hotkey: controller.settings.showHotkey);
hotkey: controller.settings.run.showHotkey);
if (pick == null) return false;
if (pick.$2) controller.updateSettings((x) => x.closeAction = pick.$1);
if (pick.$2) controller.updateSettings((x) => x.run.closeAction = pick.$1);
mode = pick.$1;
}
if (mode == 'background') {
@ -251,8 +251,8 @@ Future<void> main(List<String> args) async {
}
await services.shell.init(
hotkey: controller.settings.showHotkey,
minimizeToTray: () => controller.settings.minimizeToTray,
hotkey: controller.settings.run.showHotkey,
minimizeToTray: () => controller.settings.run.minimizeToTray,
onCloseRequested: confirmQuit,
onDownloadsRequested: () {
final nav = navigatorKey.currentState;
@ -266,7 +266,7 @@ Future<void> main(List<String> args) async {
if (StartMenu.userAreaLocked()) {
// 관리자 지시 (10/20): 시험판 · 시험 (따로 데이터) - 사용자 바로 가기 · 주소 연결은 그대로 (StartMenu 가 막음), 작업 기록에 한 줄
controller.note(tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다'));
} else if (controller.settings.startMenuShortcut) {
} else if (controller.settings.run.startMenuShortcut) {
unawaited(StartMenu.ensure().then((ok) {
if (shell0 is DesktopShell) shell0.toastAppReady = ok;
}));

View File

@ -15,7 +15,7 @@ import '../../l10n/tr.dart';
/// Android: 다운로드 · MKV 만들기 · AI 자막이 진행 중이면 포그라운드 서비스 (MainActivity.kt · KeepAliveService.kt)
/// 를 띄워 앱이 화면에서 내려가도 계속하게 하고, 알림에 진행 상황을 보여 준다. 모두 끝나면 내린다.
/// [AppSettings.runInBackground] 를 켜면 실시간 동기화를 지켜보는 동안에도 띄워 두고, 앱을 닫아도 (← · 최근 앱에서 밀기)
/// [RunSettings.inBackground] 를 켜면 실시간 동기화를 지켜보는 동안에도 띄워 두고, 앱을 닫아도 (← · 최근 앱에서 밀기)
/// Dart 엔진을 살려 둔다 (MainActivity.setBackground).
class AndroidKeepAlive {
static const _ch = MethodChannel('jj_mkvmaker/android');
@ -72,7 +72,7 @@ class AndroidKeepAlive {
_textsKey = textsKey;
} catch (_) {}
}
final bg = c.settings.runInBackground;
final bg = c.settings.run.inBackground;
if (bg != _background) {
_background = bg;
try {

View File

@ -510,15 +510,15 @@ class LiveSyncRunOptions extends StatelessWidget {
],
onChanged: (v) => c.updateSettings((x) => x.copy.liveSyncOnStart = v!),
);
void setBackground(bool? v) => c.updateSettings((x) => x.backgroundRun = v ?? false);
void setBackground(bool? v) => c.updateSettings((x) => x.run.backgroundRun = v ?? false);
if (dense) {
return Wrap(spacing: 12, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
Tooltip(
message: backgroundHelp(),
child: InkWell(
onTap: () => setBackground(!s.backgroundRun),
onTap: () => setBackground(!s.run.backgroundRun),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Checkbox(value: s.backgroundRun, onChanged: setBackground),
Checkbox(value: s.run.backgroundRun, onChanged: setBackground),
Text(tr('백그라운드로 실행'), style: const TextStyle(fontSize: 13)),
]),
),
@ -533,7 +533,7 @@ class LiveSyncRunOptions extends StatelessWidget {
return Column(children: [
CheckboxListTile(
dense: true,
value: s.backgroundRun,
value: s.run.backgroundRun,
onChanged: setBackground,
title: Text(tr('백그라운드로 실행')),
subtitle: Text(backgroundHelp()),

View File

@ -61,7 +61,7 @@ class ExitGuard extends StatelessWidget {
];
}
bool get _guard => (Platform.isAndroid || forceAndroid) && !c.settings.runInBackground && _running().isNotEmpty;
bool get _guard => (Platform.isAndroid || forceAndroid) && !c.settings.run.inBackground && _running().isNotEmpty;
@override
Widget build(BuildContext context) => ListenableBuilder(

View File

@ -32,7 +32,7 @@ class _RunSettingsSectionState extends State<RunSettingsSection> {
AppController get c => widget.c;
bool get desk => widget.desk;
late final _hotkey = TextEditingController(text: c.settings.showHotkey);
late final _hotkey = TextEditingController(text: c.settings.run.showHotkey);
String? _hotkeyError;
@override
@ -49,7 +49,7 @@ class _RunSettingsSectionState extends State<RunSettingsSection> {
}
final ok = await c.services.shell.setHotkey(text);
setState(() => _hotkeyError = ok ? null : tr('이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)'));
if (ok) await c.updateSettings((s) => s.showHotkey = text);
if (ok) await c.updateSettings((s) => s.run.showHotkey = text);
}
@override
@ -58,9 +58,9 @@ class _RunSettingsSectionState extends State<RunSettingsSection> {
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [
if (desk)
SwitchListTile(
value: s.startMenuShortcut,
value: s.run.startMenuShortcut,
onChanged: (v) async {
await c.updateSettings((x) => x.startMenuShortcut = v);
await c.updateSettings((x) => x.run.startMenuShortcut = v);
if (v) {
final ok = await StartMenu.ensure();
final sh = c.services.shell;
@ -78,21 +78,21 @@ class _RunSettingsSectionState extends State<RunSettingsSection> {
),
SettingTile(
title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')),
subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.showHotkey])),
subtitle: Text(trf('백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n' '완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)', [s.run.showHotkey])),
isThreeLine: true,
trailing: DropdownButton<String>(
value: s.closeAction,
value: s.run.closeAction,
items: [
DropdownMenuItem(value: 'background', child: Text(tr('백그라운드로 계속 (기본)'))),
DropdownMenuItem(value: 'quit', child: Text(tr('다운로드 · 변환도 모두 종료 (묻지 않음)'))),
DropdownMenuItem(value: 'ask', child: Text(tr('매번 고르기'))),
],
onChanged: (v) => c.updateSettings((x) => x.closeAction = v!),
onChanged: (v) => c.updateSettings((x) => x.run.closeAction = v!),
),
),
SwitchListTile(
value: s.minimizeToTray,
onChanged: (v) => c.updateSettings((x) => x.minimizeToTray = v),
value: s.run.minimizeToTray,
onChanged: (v) => c.updateSettings((x) => x.run.minimizeToTray = v),
title: Text(tr('최소화하면 트레이로 (백그라운드 실행)')),
subtitle: Text(tr('트레이 아이콘을 누르거나 아래 단축키로 다시 엽니다')),
),

View File

@ -68,8 +68,8 @@ class _GeneralSettingsSectionState extends State<GeneralSettingsSection> {
]),
),
SwitchListTile(
value: s.autoCheckUpdates,
onChanged: (v) => c.updateSettings((x) => x.autoCheckUpdates = v),
value: s.run.autoCheckUpdates,
onChanged: (v) => c.updateSettings((x) => x.run.autoCheckUpdates = v),
title: Text(tr('시작할 때 새 버전 확인 (하루 한 번)')),
),
],

View File

@ -56,7 +56,7 @@ 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 c.updateSettings((x) => x.run.lastRunVersion = current); // 보관본이 "이 버전이 쓴 설정" 이 되게
await snap.save(current);
c.note(trf('지금 버전 (v{0}) 의 설정을 보관했습니다: {1}', [current, snap.dirOf(current)]));
} catch (e) {
@ -219,7 +219,7 @@ Future<void> androidRollback(BuildContext context, AppController c, Updater up,
probe = p.dirname(probe);
}
final free = (await diskSpace(probe))?.$1;
final setting = c.settings.rollbackKeepModels;
final setting = c.settings.run.rollbackKeepModels;
var keep = modelSize > 0 && (setting == 'on' || (setting == '' && free != null && free >= modelSize * 2));
var keepTouched = false;
// 옮겨 둔 모델을 되살리는 기능은 이 판 다음부터 들어 있다 (설정 되살리기는 v2026.10.08_001 부터)
@ -292,7 +292,7 @@ Future<void> androidRollback(BuildContext context, AppController c, Updater up,
);
if (ok != true || !context.mounted) return;
// 체크를 바꿨으면 이제 설정에 (자동이던 것은 건드리지 않으면 자동 그대로)
if (keepTouched) await c.updateSettings((x) => x.rollbackKeepModels = keep ? 'on' : 'off');
if (keepTouched) await c.updateSettings((x) => x.run.rollbackKeepModels = keep ? 'on' : 'off');
if (!context.mounted) return;
// 사라지는 것이 있으니 한 번 더 (비밀번호 · 로그인은 늘 사라진다)
final sure = await showDialog<bool>(

View File

@ -30,8 +30,8 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
return;
}
if (!manual &&
(!s.autoCheckUpdates ||
!updateCheckDue(s.lastUpdateCheck, DateTime.now(), checkedBy: s.lastUpdateCheckVersion, current: current))) {
(!s.run.autoCheckUpdates ||
!updateCheckDue(s.run.lastUpdateCheck, DateTime.now(), checkedBy: s.run.lastUpdateCheckVersion, current: current))) {
return;
}
@ -52,15 +52,15 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
return;
}
await c.updateSettings((x) => x
..lastUpdateCheck = DateTime.now().toIso8601String()
..lastUpdateCheckVersion = current);
..run.lastUpdateCheck = DateTime.now().toIso8601String()
..run.lastUpdateCheckVersion = current);
// 작업 기록에 남긴다 (업데이트가 안 될 때 원인을 찾을 수 있게)
c.note(trf('새 버전 확인: 지금 v{0} · 최신 v{1}', [current, r?.version ?? '-']));
if (r == null || !r.isNewerThan(current)) {
if (manual) snack(trf('최신 버전입니다 (v{0})', [current]));
return;
}
if (!manual && s.skippedVersion == r.version) return;
if (!manual && s.run.skippedVersion == r.version) return;
onResult?.call(trf('새 버전 v{0} 이 있습니다', [r.version]));
if (!context.mounted) return;
@ -97,7 +97,7 @@ Future<void> checkForUpdate(BuildContext context, AppController c, {bool manual
if (!context.mounted) return;
switch (choice) {
case 'skip':
await c.updateSettings((x) => x.skippedVersion = r!.version);
await c.updateSettings((x) => x.run.skippedVersion = r!.version);
case 'versions':
await chooseVersion(context, c);
case 'update':

View File

@ -50,11 +50,11 @@ Future<void> checkVersionRestore(
message = trf('앱을 새로 설치해 설정이 없습니다. 저장해 둔 v{0} 의 설정 (동영상 목록 · 즐겨찾기 포함) 을 되살릴까요?\n'
'(자막 API 키 · 비밀번호는 저장하지 않았으니 다시 넣어 주세요)', [p.basename(from)]);
}
} else if (snap.shouldOffer(current, c.settings.lastRunVersion)) {
} else if (snap.shouldOffer(current, c.settings.run.lastRunVersion)) {
from = snap.dirOf(current);
message = trf('다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\n'
'v{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?',
[c.settings.lastRunVersion.isEmpty ? tr('예전 버전') : 'v${c.settings.lastRunVersion}', current]);
[c.settings.run.lastRunVersion.isEmpty ? tr('예전 버전') : 'v${c.settings.run.lastRunVersion}', current]);
}
if (from != null && message != null && context.mounted) {
final ok = await showDialog<bool>(
@ -80,7 +80,7 @@ Future<void> checkVersionRestore(
}
}
}
if (c.settings.lastRunVersion != current) await c.updateSettings((x) => x.lastRunVersion = current);
if (c.settings.run.lastRunVersion != current) await c.updateSettings((x) => x.run.lastRunVersion = current);
}
typedef SettingsSummary = ({int videos, List<String> servers, DateTime? saved});

View File

@ -27,21 +27,21 @@ void main() {
test('기본값 = 정한 답 · 저장 · 읽기', () {
final s = AppSettings();
expect([
s.startMenuShortcut, s.copy.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter,
s.run.startMenuShortcut, s.copy.syncStopToast, s.swipeWrap, s.migrationNotice, s.allowInnerToOuter,
s.rememberPasswords, s.recycleOnDelete, s.reader.systemBars,
], everyElement(isTrue));
expect(s.cleanupPrechecked, ['work', 'download']);
expect(s.tripleTapAction, 'move');
expect(s.davExternalOpen, 'ask');
// 이미 있던 설정의 기본값도 결정대로
expect([s.browser.pauseOnLeave, s.autoCheckUpdates, s.browser.adSkip, s.browser.adHide, s.runInBackground, s.swipeNav],
expect([s.browser.pauseOnLeave, s.run.autoCheckUpdates, s.browser.adSkip, s.browser.adHide, s.run.inBackground, s.swipeNav],
everyElement(isTrue));
// 탐색기 처음 배치 = 두 창 (규칙 7 · 관리자 결정 3, 10/22)
expect([s.copy.liveSyncOnStart, s.copy.liveSyncIntervalSec, s.explorer.layout, s.reader.zipComic], ['auto', 30, 'dual', false]);
expect([AppSettings.uiScaleMin, AppSettings.uiScaleMax], [0.5, 2.5]);
// 바꾼 값은 남는다
final b = AppSettings.fromJson((AppSettings()
..startMenuShortcut = false
..run.startMenuShortcut = false
..copy.syncStopToast = false
..swipeWrap = false
..migrationNotice = false
@ -52,7 +52,7 @@ void main() {
..tripleTapAction = 'none'
..davExternalOpen = 'url')
.toJson());
expect([b.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
b.recycleOnDelete], everyElement(isFalse));
expect([b.cleanupPrechecked, b.tripleTapAction, b.davExternalOpen], [['logs'], 'none', 'url']);
});

View File

@ -142,13 +142,13 @@ https://example.com/page.html
..mkvOutputRoot = r'D:\out'
..download.root = r'D:\dl'
..download.clipboardWatch = false
..showHotkey = 'Alt+F9'
..run.showHotkey = 'Alt+F9'
..askAiOptions = false
..encode = const EncodeSettings(codec: VideoCodecChoice.vp9, resolution: ResolutionChoice.p720)
..aiTargets = ['ko', 'fr'];
await store.save(s);
final l = await store.load();
expect([l.mkvOutputRoot, l.download.root, l.download.clipboardWatch, l.showHotkey, l.askAiOptions],
expect([l.mkvOutputRoot, l.download.root, l.download.clipboardWatch, l.run.showHotkey, l.askAiOptions],
[r'D:\out', r'D:\dl', false, 'Alt+F9', false]);
expect(l.encode.codec, VideoCodecChoice.vp9);
expect(l.encode.resolution, ResolutionChoice.p720);

View File

@ -12,7 +12,7 @@ void main() {
Future<AppController> pump(WidgetTester tester, {required bool background, required bool busy}) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.runInBackground = background;
c.settings.run.inBackground = background;
c.busy = busy;
c.currentJob = busy ? 'MKV 만들기 3/10' : null;
final nav = GlobalKey<NavigatorState>();

View File

@ -75,9 +75,9 @@ void main() {
});
test('설정: 기본은 자동 (남은 공간으로), 고른 값은 남는다', () {
expect(AppSettings().rollbackKeepModels, '');
final b = AppSettings.fromJson((AppSettings()..rollbackKeepModels = 'off').toJson());
expect(b.rollbackKeepModels, 'off');
expect(AppSettings.fromJson({'rollbackKeepModels': 'weird'}).rollbackKeepModels, '');
expect(AppSettings().run.rollbackKeepModels, '');
final b = AppSettings.fromJson((AppSettings()..run.rollbackKeepModels = 'off').toJson());
expect(b.run.rollbackKeepModels, 'off');
expect(AppSettings.fromJson({'rollbackKeepModels': 'weird'}).run.rollbackKeepModels, '');
});
}

View File

@ -190,21 +190,21 @@ void main() {
group('백그라운드 · 다시 켤 때 동기화', () {
test('설정 저장 · 읽기, Windows 는 종료 동작과 같은 값', () {
final s = AppSettings()
..runInBackground = true
..run.inBackground = true
..copy.liveSyncOnStart = 'ask';
final r = AppSettings.fromJson(s.toJson());
expect([r.runInBackground, r.copy.liveSyncOnStart], [true, 'ask']);
expect([r.run.inBackground, r.copy.liveSyncOnStart], [true, 'ask']);
expect(AppSettings.fromJson({'liveSyncOnStart': 'x'}).copy.liveSyncOnStart, 'auto');
// 사용자 결정 (10/8): Android 백그라운드로 실행은 기본 켜짐. 사용자가 끈 값 (새 형식) 은 그대로
expect(AppSettings.fromJson({}).runInBackground, isTrue);
expect(AppSettings.fromJson({'runInBackground': false, 'backgroundV2': true}).runInBackground, isFalse);
final w = AppSettings()..backgroundRun = false;
expect(AppSettings.fromJson({}).run.inBackground, isTrue);
expect(AppSettings.fromJson({'runInBackground': false, 'backgroundV2': true}).run.inBackground, isFalse);
final w = AppSettings()..run.backgroundRun = false;
if (Platform.isWindows) {
expect(w.closeAction, 'quit');
w.backgroundRun = true;
expect([w.closeAction, w.backgroundRun], ['background', true]);
expect(w.run.closeAction, 'quit');
w.run.backgroundRun = true;
expect([w.run.closeAction, w.run.backgroundRun], ['background', true]);
} else {
expect(w.runInBackground, isFalse);
expect(w.run.inBackground, isFalse);
}
});

View File

@ -441,9 +441,9 @@ void main() {
});
testWidgets('종료를 누르면: 설정 기본값 · 고르기 창 (백그라운드로 / 모두 종료 / 묻지 않기)', (tester) async {
expect(AppSettings().closeAction, 'background');
expect(AppSettings.fromJson((AppSettings()..closeAction = 'quit').toJson()).closeAction, 'quit');
expect(AppSettings.fromJson({'closeAction': 'x'}).closeAction, 'background');
expect(AppSettings().run.closeAction, 'background');
expect(AppSettings.fromJson((AppSettings()..run.closeAction = 'quit').toJson()).run.closeAction, 'quit');
expect(AppSettings.fromJson({'closeAction': 'x'}).run.closeAction, 'background');
late BuildContext ctx;
await tester.pumpWidget(MaterialApp(home: Builder(builder: (x) {

View File

@ -66,9 +66,9 @@ void main() {
VersionSnapshot.instance = null;
addTearDown(() => VersionSnapshot.instance = was);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final before = c.settings.lastRunVersion;
final before = c.settings.run.lastRunVersion;
await saveVersionSnapshot(c, '9.9.9');
expect(c.settings.lastRunVersion, before);
expect(c.settings.run.lastRunVersion, before);
});
testWidgets('update_dialog (버전 고르기): 바뀐 내용이 비면 "(설명 없음)" · 표는 글자 그대로', (t) async {

View File

@ -182,8 +182,8 @@ void main() {
await open(tester);
await tester.tap(find.text('이 버전 건너뛰기'));
await tester.pumpAndSettle();
expect(c.settings.skippedVersion, '1.1.0');
c.settings.lastUpdateCheck = '';
expect(c.settings.run.skippedVersion, '1.1.0');
c.settings.run.lastUpdateCheck = '';
await open(tester, manual: false);
expect(find.text('새 버전 v1.1.0'), findsNothing);
});
@ -337,7 +337,7 @@ void main() {
// 체크를 바꿔도 [취소] 하면 설정은 그대로 (자동)
await tester.tap(find.text('취소'));
await tester.pumpAndSettle();
expect(c.settings.rollbackKeepModels, '');
expect(c.settings.run.rollbackKeepModels, '');
await openPicker(tester);
await tester.tap(find.text('v1.0.0').first);
await tester.pumpAndSettle();
@ -348,10 +348,10 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byType(Checkbox));
await tester.pumpAndSettle();
expect(c.settings.rollbackKeepModels, '');
expect(c.settings.run.rollbackKeepModels, '');
await tester.tap(find.text('저장하고 계속'));
await tester.pumpAndSettle();
expect(c.settings.rollbackKeepModels, 'on');
expect(c.settings.run.rollbackKeepModels, 'on');
await tester.tap(find.text('되돌리기 계속'));
await settleIo(tester, until: () => find.text('저장했습니다').evaluate().isNotEmpty);
expect(find.text('저장했습니다'), findsOneWidget);