설정 A 바탕: 기록 클래스 · settings_store 옮김 · 분야 틀 (SettingsGroup) · JSON 약속 시험
- lib/app/settings/records.dart: MoveTarget · FolderBookmark · LiveSyncPair · CopyTask - lib/app/settings/settings_store.dart: SettingsStore · SecretIssue · SettingsLoadProblem (동작 그대로, M0 자리에 TODO) - lib/app/settings/settings_group.dart: 분야 약속 - 자기 키를 읽고 쓴다 - AppSettings.toJson · fromJson 은 분야 목록 (groups) 을 돌며 합침 (지금은 빈 목록) - settings.dart 는 옮긴 것을 그대로 내보냄 (부르는 쪽 그대로) - 약속 시험: test/settings_json_contract_test.dart + fixtures (나누기 전 판의 출력, 합성 값 - 개인 자료 없음). 사용자 설정 파일은 시험 폴더에 복사해 읽기만 (JJ_USER_SETTINGS_EXPECT 가 있을 때, 기준은 커밋하지 않음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>modular
parent
d537d1c3e1
commit
74da2e9a3e
|
|
@ -1,9 +1,6 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
import 'components.dart';
|
import 'components.dart';
|
||||||
import '../core/reader_sources.dart' show defaultImageExtensions;
|
import '../core/reader_sources.dart' show defaultImageExtensions;
|
||||||
|
|
@ -17,241 +14,13 @@ import '../core/smb.dart';
|
||||||
import '../core/webdav.dart';
|
import '../core/webdav.dart';
|
||||||
import '../services/image_ai.dart' show AiService;
|
import '../services/image_ai.dart' show AiService;
|
||||||
import '../services/app_shell.dart' show appIconOf;
|
import '../services/app_shell.dart' show appIconOf;
|
||||||
import '../services/secret_store.dart';
|
|
||||||
import '../l10n/tr.dart';
|
|
||||||
import '../core/remote_idle.dart';
|
import '../core/remote_idle.dart';
|
||||||
|
import 'settings/records.dart';
|
||||||
|
import 'settings/settings_group.dart';
|
||||||
|
|
||||||
/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더
|
export 'settings/records.dart';
|
||||||
class MoveTarget {
|
export 'settings/settings_group.dart';
|
||||||
final String name;
|
export 'settings/settings_store.dart';
|
||||||
final String dir;
|
|
||||||
const MoveTarget(this.name, this.dir);
|
|
||||||
|
|
||||||
MoveTarget copyWith({String? name, String? dir}) => MoveTarget(name ?? this.name, dir ?? this.dir);
|
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {'name': name, 'dir': dir};
|
|
||||||
|
|
||||||
factory MoveTarget.fromJson(Map<Object?, Object?> j) =>
|
|
||||||
MoveTarget((j['name'] as String?)?.trim().isNotEmpty == true ? j['name'] as String : '이동', j['dir'] as String);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => other is MoveTarget && other.name == name && other.dir == dir;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(name, dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 73: 파일 탐색기 즐겨찾기 (폴더). [right] 가 있으면 Rsync 화면의 "왼쪽 + 오른쪽" 짝
|
|
||||||
class FolderBookmark {
|
|
||||||
final String name;
|
|
||||||
final String path;
|
|
||||||
final String? right;
|
|
||||||
const FolderBookmark({required this.name, required this.path, this.right});
|
|
||||||
|
|
||||||
bool get isPair => right != null;
|
|
||||||
|
|
||||||
FolderBookmark copyWith({String? name}) => FolderBookmark(name: name ?? this.name, path: path, right: right);
|
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {'name': name, 'path': path, if (right != null) 'right': right};
|
|
||||||
|
|
||||||
static FolderBookmark? fromJson(Object? j) => j is Map && j['path'] is String
|
|
||||||
? FolderBookmark(
|
|
||||||
name: j['name'] is String && (j['name'] as String).trim().isNotEmpty ? j['name'] as String : j['path'] as String,
|
|
||||||
path: j['path'] as String,
|
|
||||||
right: j['right'] is String ? j['right'] as String : null)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 실시간 동기화 한 쌍 (lsyncd 처럼: 원본 폴더를 지켜보다가 바뀌면 대상 폴더에 맞춘다)
|
|
||||||
class LiveSyncPair {
|
|
||||||
final String source;
|
|
||||||
final String target;
|
|
||||||
|
|
||||||
/// 'builtin' · 'rsync' · 'robocopy' (CopyMethod 이름)
|
|
||||||
final String method;
|
|
||||||
|
|
||||||
/// 원본에 없는 것을 대상에서 지우기 (rsync --delete, robocopy /PURGE)
|
|
||||||
final bool delete;
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
/// 동작 시간 (cron 줄들, core/cron_window.dart). 비어 있으면 계속 (앱이 켜져 있는 동안 늘)
|
|
||||||
final List<String> schedule;
|
|
||||||
|
|
||||||
/// "지우기" 를 사용자가 지울 목록을 보고 확인했는지. 확인 전에는 지울 것이 있으면 지우지 않고 맞추며 확인을 기다린다 (42).
|
|
||||||
/// 이 값이 생기기 전부터 돌던 쌍은 확인한 것으로 본다.
|
|
||||||
final bool deleteConfirmed;
|
|
||||||
|
|
||||||
/// 지우기를 확인할 때의 rsync 옵션 (rsync 쌍만, 그 밖은 ''). 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다
|
|
||||||
/// (관리자 3a 판단 10/20 - --delete-excluded · --exclude · --filter 등). null = 이 값이 생기기 전에 확인한 쌍
|
|
||||||
final String? confirmedOptions;
|
|
||||||
const LiveSyncPair(this.source, this.target,
|
|
||||||
{this.method = 'builtin',
|
|
||||||
this.delete = false,
|
|
||||||
this.enabled = true,
|
|
||||||
this.schedule = const [],
|
|
||||||
this.deleteConfirmed = false,
|
|
||||||
this.confirmedOptions});
|
|
||||||
|
|
||||||
/// [delete] 를 새로 켜면 확인은 다시 받는다
|
|
||||||
LiveSyncPair copyWith(
|
|
||||||
{String? method, bool? delete, bool? enabled, List<String>? schedule, bool? deleteConfirmed, String? confirmedOptions}) =>
|
|
||||||
LiveSyncPair(source, target,
|
|
||||||
method: method ?? this.method,
|
|
||||||
delete: delete ?? this.delete,
|
|
||||||
enabled: enabled ?? this.enabled,
|
|
||||||
schedule: schedule ?? this.schedule,
|
|
||||||
deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed),
|
|
||||||
confirmedOptions: confirmedOptions ?? this.confirmedOptions);
|
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
|
||||||
'source': source,
|
|
||||||
'target': target,
|
|
||||||
'method': method,
|
|
||||||
'delete': delete,
|
|
||||||
'enabled': enabled,
|
|
||||||
'schedule': schedule,
|
|
||||||
'deleteConfirmed': deleteConfirmed,
|
|
||||||
if (confirmedOptions != null) 'confirmedOptions': confirmedOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
factory LiveSyncPair.fromJson(Map<Object?, Object?> j) => LiveSyncPair(
|
|
||||||
j['source'] as String,
|
|
||||||
j['target'] as String,
|
|
||||||
method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin',
|
|
||||||
delete: j['delete'] == true,
|
|
||||||
enabled: j['enabled'] != false,
|
|
||||||
schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'],
|
|
||||||
// 이 값이 없는 예전 쌍은 이미 지우기 포함으로 돌던 것 → 확인한 것으로
|
|
||||||
deleteConfirmed: j['deleteConfirmed'] as bool? ?? true,
|
|
||||||
confirmedOptions: j['confirmedOptions'] is String ? j['confirmedOptions'] as String : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 복사 · 이동 기억 (모니터링 > 복사 · rsync): 같은 원본 → 대상을 다시 복사하면 이 옵션을 쓴다. 언제든 [실행] 으로 다시.
|
|
||||||
class CopyTask {
|
|
||||||
final String id;
|
|
||||||
final List<String> sources;
|
|
||||||
final String dest;
|
|
||||||
final bool move;
|
|
||||||
|
|
||||||
/// 폴더 "안의 것" 을 대상에 맞추기 (lsync 에서 옮겨 온 것: 원본/ → 대상/)
|
|
||||||
final bool contents;
|
|
||||||
|
|
||||||
/// CopyMethod 이름 · 그 방법의 옵션 · 한 번에 · 속도 제한 (KB/s)
|
|
||||||
final String method;
|
|
||||||
final String options;
|
|
||||||
final bool once;
|
|
||||||
final int bandwidthKBps;
|
|
||||||
|
|
||||||
/// 마지막 실행: 시각 (ISO) · 결과 ('done' · 'failed' · 'cancelled' · '') · 글 · 파일 수
|
|
||||||
final String lastRun;
|
|
||||||
final String lastResult;
|
|
||||||
final String lastMessage;
|
|
||||||
final int lastFiles;
|
|
||||||
|
|
||||||
/// 235: 이동 뒤 원본에 남은 파일 수 (건너뜀 - 216). 카드에 "n개는 원본에 남음" 으로
|
|
||||||
final int lastLeft;
|
|
||||||
|
|
||||||
/// 237: 실패 원문 (예외 이름 · 명령 줄 포함) - 카드의 [자세히] 에서 보고 복사한다. 화면 글은 [lastMessage]
|
|
||||||
final String lastDetail;
|
|
||||||
|
|
||||||
/// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 ·
|
|
||||||
/// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete)
|
|
||||||
final String prune;
|
|
||||||
|
|
||||||
const CopyTask({
|
|
||||||
required this.id,
|
|
||||||
required this.sources,
|
|
||||||
required this.dest,
|
|
||||||
this.move = false,
|
|
||||||
this.contents = false,
|
|
||||||
this.method = 'builtin',
|
|
||||||
this.options = '',
|
|
||||||
this.once = false,
|
|
||||||
this.bandwidthKBps = 0,
|
|
||||||
this.lastRun = '',
|
|
||||||
this.lastResult = '',
|
|
||||||
this.lastMessage = '',
|
|
||||||
this.lastFiles = 0,
|
|
||||||
this.lastLeft = 0,
|
|
||||||
this.lastDetail = '',
|
|
||||||
this.prune = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
/// 같은 복사인지 (원본들 · 대상 · 이동)
|
|
||||||
String get key => '${move ? 'M' : contents ? 'S' : 'C'}|${[...sources]..sort()}|$dest';
|
|
||||||
|
|
||||||
CopyTask copyWith({
|
|
||||||
String? method,
|
|
||||||
String? options,
|
|
||||||
bool? once,
|
|
||||||
int? bandwidthKBps,
|
|
||||||
bool? move,
|
|
||||||
String? lastRun,
|
|
||||||
String? lastResult,
|
|
||||||
String? lastMessage,
|
|
||||||
int? lastFiles,
|
|
||||||
int? lastLeft,
|
|
||||||
String? lastDetail,
|
|
||||||
String? prune,
|
|
||||||
}) =>
|
|
||||||
CopyTask(
|
|
||||||
id: id,
|
|
||||||
sources: sources,
|
|
||||||
dest: dest,
|
|
||||||
move: move ?? this.move,
|
|
||||||
contents: contents,
|
|
||||||
method: method ?? this.method,
|
|
||||||
options: options ?? this.options,
|
|
||||||
once: once ?? this.once,
|
|
||||||
bandwidthKBps: bandwidthKBps ?? this.bandwidthKBps,
|
|
||||||
lastRun: lastRun ?? this.lastRun,
|
|
||||||
lastResult: lastResult ?? this.lastResult,
|
|
||||||
lastMessage: lastMessage ?? this.lastMessage,
|
|
||||||
lastFiles: lastFiles ?? this.lastFiles,
|
|
||||||
lastLeft: lastLeft ?? this.lastLeft,
|
|
||||||
lastDetail: lastDetail ?? this.lastDetail,
|
|
||||||
prune: prune ?? this.prune,
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'sources': sources,
|
|
||||||
'dest': dest,
|
|
||||||
'move': move,
|
|
||||||
'contents': contents,
|
|
||||||
'method': method,
|
|
||||||
'options': options,
|
|
||||||
'once': once,
|
|
||||||
'bandwidthKBps': bandwidthKBps,
|
|
||||||
'lastRun': lastRun,
|
|
||||||
'lastResult': lastResult,
|
|
||||||
'lastMessage': lastMessage,
|
|
||||||
'lastFiles': lastFiles,
|
|
||||||
'lastLeft': lastLeft,
|
|
||||||
'lastDetail': lastDetail,
|
|
||||||
'prune': prune,
|
|
||||||
};
|
|
||||||
|
|
||||||
factory CopyTask.fromJson(Map<Object?, Object?> j) => CopyTask(
|
|
||||||
id: '${j['id']}',
|
|
||||||
sources: [for (final x in (j['sources'] as List?) ?? const []) '$x'],
|
|
||||||
dest: '${j['dest']}',
|
|
||||||
move: j['move'] == true,
|
|
||||||
contents: j['contents'] == true,
|
|
||||||
method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin',
|
|
||||||
options: j['options'] as String? ?? '',
|
|
||||||
once: j['once'] == true,
|
|
||||||
bandwidthKBps: (j['bandwidthKBps'] as num?)?.toInt() ?? 0,
|
|
||||||
lastRun: j['lastRun'] as String? ?? '',
|
|
||||||
lastResult: j['lastResult'] as String? ?? '',
|
|
||||||
lastMessage: j['lastMessage'] as String? ?? '',
|
|
||||||
lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0,
|
|
||||||
lastLeft: (j['lastLeft'] as num?)?.toInt() ?? 0,
|
|
||||||
lastDetail: j['lastDetail'] as String? ?? '',
|
|
||||||
prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 환경 설정 (settings.json 에 저장)
|
/// 환경 설정 (settings.json 에 저장)
|
||||||
class AppSettings {
|
class AppSettings {
|
||||||
|
|
@ -806,8 +575,12 @@ class AppSettings {
|
||||||
|
|
||||||
AppSettings();
|
AppSettings();
|
||||||
|
|
||||||
|
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
|
||||||
|
List<SettingsGroup> get groups => const [];
|
||||||
|
|
||||||
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
|
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
|
||||||
Map<String, Object?> toJson({bool plainSecrets = false}) => {
|
Map<String, Object?> toJson({bool plainSecrets = false}) {
|
||||||
|
final j = <String, Object?>{
|
||||||
// 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40)
|
// 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40)
|
||||||
...stripSecrets(extra),
|
...stripSecrets(extra),
|
||||||
'mkvOutputRoot': mkvOutputRoot,
|
'mkvOutputRoot': mkvOutputRoot,
|
||||||
|
|
@ -1031,6 +804,11 @@ class AppSettings {
|
||||||
'aiTargets': aiTargets,
|
'aiTargets': aiTargets,
|
||||||
'aiWhisper': aiWhisper,
|
'aiWhisper': aiWhisper,
|
||||||
};
|
};
|
||||||
|
for (final g in groups) {
|
||||||
|
g.write(j, plainSecrets: plainSecrets);
|
||||||
|
}
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
|
||||||
/// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로)
|
/// 파일 탐색기의 복사 방법 (rsync 는 Rsync 화면으로 옮겨 예전 값 rsync 도 현재 방식으로)
|
||||||
static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin';
|
static String _method(Object? v) => v == 'robocopy' ? 'robocopy' : 'builtin';
|
||||||
|
|
@ -1326,6 +1104,9 @@ class AppSettings {
|
||||||
..aiTargets = (j['aiTargets'] as List?)?.cast<String>() ?? ['ko', 'en', 'ja']
|
..aiTargets = (j['aiTargets'] as List?)?.cast<String>() ?? ['ko', 'en', 'ja']
|
||||||
..aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base';
|
..aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base';
|
||||||
s.migrated = _migrated(j, s);
|
s.migrated = _migrated(j, s);
|
||||||
|
for (final g in s.groups) {
|
||||||
|
g.read(j);
|
||||||
|
}
|
||||||
s.extra = Map.of(j);
|
s.extra = Map.of(j);
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
@ -1457,444 +1238,3 @@ class AppSettings {
|
||||||
}
|
}
|
||||||
String get aria2Dir => p.join(resolvedDownloadRoot(), 'jj_aria2');
|
String get aria2Dir => p.join(resolvedDownloadRoot(), 'jj_aria2');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 설정 파일 읽기·쓰기
|
|
||||||
/// 설정 파일 읽기 · 쓰기.
|
|
||||||
/// - 쓰기: 임시 파일에 다 쓴 뒤 바꿔치기 (쓰는 도중 앱이 끝나도 settings.json 이 깨지지 않게).
|
|
||||||
/// 바꿔치기 전의 정상 파일은 settings.json.bak 으로 남긴다.
|
|
||||||
/// - 읽기: settings.json 을 읽지 못하면 settings.json.bak 으로 되살린다. 읽지 못한 파일은 지우지 않고
|
|
||||||
/// settings.broken-(시각).json 으로 보관한다 (다음 저장에 덮여 사라지지 않게). 무슨 일이 있었는지는 [problem].
|
|
||||||
class SettingsStore {
|
|
||||||
final String? _path;
|
|
||||||
|
|
||||||
/// 비밀 값 (WebDAV 비밀번호 · OpenSubtitles) 을 두는 곳. 경로를 직접 주면 (시험) 메모리
|
|
||||||
final SecretStore secrets;
|
|
||||||
|
|
||||||
SettingsStore([this._path, SecretStore? secrets])
|
|
||||||
: secrets = secrets ?? (_path == null ? SecretStore.platform() : MemorySecretStore());
|
|
||||||
|
|
||||||
/// 안전 저장소를 읽을 수 있었는지 (못 읽었으면 저장소를 건드리지 않는다 - 지워 버리지 않게)
|
|
||||||
bool _secretsOk = true;
|
|
||||||
|
|
||||||
/// 안전 저장소에 마지막으로 쓴 (읽은) 값 - 바뀐 것만 쓴다
|
|
||||||
Map<String, String> _known = {};
|
|
||||||
|
|
||||||
/// 설정 파일에 예전부터 평문으로 있던 값 (저장소 열쇠 → 값). 안전 저장소에 들어가기 전까지는 파일에서 지우지 않는다 (40-1).
|
|
||||||
/// 새로 넣은 값은 저장소에 못 쓰더라도 평문으로 쓰지 않는다 (사용자 결정) - 대신 [secretIssue] 로 알린다.
|
|
||||||
final Map<String, String> _filePlain = {};
|
|
||||||
|
|
||||||
/// 안전 저장소 문제 (화면 위 알림 · [다시 시도]). null = 문제 없음
|
|
||||||
final secretIssue = ValueNotifier<SecretIssue?>(null);
|
|
||||||
|
|
||||||
/// 안전 저장소의 열쇠
|
|
||||||
static const _osKeys = {
|
|
||||||
'openSubtitlesKey': 'os.key',
|
|
||||||
'openSubtitlesUser': 'os.user',
|
|
||||||
'openSubtitlesPassword': 'os.password',
|
|
||||||
};
|
|
||||||
static String _davKey(String id) => 'dav.pw.$id';
|
|
||||||
static String _sftpPw(String id) => 'sftp.pw.$id';
|
|
||||||
static String _sftpKey(String id) => 'sftp.key.$id';
|
|
||||||
static String _sftpKp(String id) => 'sftp.kp.$id';
|
|
||||||
static String _smbPw(String id) => 'smb.pw.$id';
|
|
||||||
static String _ftpPw(String id) => 'ftp.pw.$id';
|
|
||||||
static String _aiKey(String id) => 'ai.key.$id';
|
|
||||||
static const _davServers = 'dav.servers';
|
|
||||||
|
|
||||||
static String _os(AppSettings s, String k) => switch (k) {
|
|
||||||
'openSubtitlesKey' => s.openSubtitlesKey,
|
|
||||||
'openSubtitlesUser' => s.openSubtitlesUser,
|
|
||||||
_ => s.openSubtitlesPassword,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움)
|
|
||||||
/// 규칙 (관리자 10/21): 새 종류의 비밀 값은 지금 꼴 ([secretForms]) 과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 로 만든다 -
|
|
||||||
/// 겹치면 그 꼴을 아는 예전 판이 저장할 때 "없어진 항목" 으로 보고 지운다. 새 꼴은 [secretForms] 에도 더한다
|
|
||||||
static Map<String, String> _secretsOf(AppSettings s) => !s.rememberPasswords ? const {} : {
|
|
||||||
for (final e in _osKeys.entries)
|
|
||||||
if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key),
|
|
||||||
for (final x in s.webdavServers)
|
|
||||||
if (x.password.isNotEmpty) _davKey(x.id): x.password,
|
|
||||||
for (final x in s.aiServices)
|
|
||||||
if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey,
|
|
||||||
for (final x in s.sftpServers) ...{
|
|
||||||
if (x.password.isNotEmpty) _sftpPw(x.id): x.password,
|
|
||||||
if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey,
|
|
||||||
if (x.keyPassphrase.isNotEmpty) _sftpKp(x.id): x.keyPassphrase,
|
|
||||||
},
|
|
||||||
for (final x in s.smbServers)
|
|
||||||
if (x.password.isNotEmpty) _smbPw(x.id): x.password,
|
|
||||||
for (final x in s.ftpServers)
|
|
||||||
if (x.password.isNotEmpty) _ftpPw(x.id): x.password,
|
|
||||||
// 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40)
|
|
||||||
if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]),
|
|
||||||
};
|
|
||||||
|
|
||||||
/// 저장소에 못 넣은 예전 평문 값을 설정 JSON 에 그대로 둔다
|
|
||||||
static void _putPlain(Map<String, Object?> j, String key, String value) {
|
|
||||||
for (final e in _osKeys.entries) {
|
|
||||||
if (e.value == key) j[e.key] = value;
|
|
||||||
}
|
|
||||||
if (key.startsWith('ai.key.')) {
|
|
||||||
final id = key.substring('ai.key.'.length);
|
|
||||||
for (final x in (j['aiServices'] as List?) ?? const []) {
|
|
||||||
if (x is Map && '${x['id']}' == id) x['apiKey'] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (key.startsWith('dav.pw.')) {
|
|
||||||
final id = key.substring('dav.pw.'.length);
|
|
||||||
for (final x in (j['webdavServers'] as List?) ?? const []) {
|
|
||||||
if (x is Map && '${x['id']}' == id) x['password'] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 비어 있는 비밀 값을 안전 저장소의 것으로 채운다 (파일에 평문이 있으면 그것을 쓰고 [_filePlain] 에 기억)
|
|
||||||
void _fill(AppSettings s, Map<String, String> stored) {
|
|
||||||
final os = <String, String>{};
|
|
||||||
for (final e in _osKeys.entries) {
|
|
||||||
final v = _os(s, e.key);
|
|
||||||
os[e.key] = v.isNotEmpty ? v : stored[e.value] ?? '';
|
|
||||||
}
|
|
||||||
s
|
|
||||||
..openSubtitlesKey = os['openSubtitlesKey']!
|
|
||||||
..openSubtitlesUser = os['openSubtitlesUser']!
|
|
||||||
..openSubtitlesPassword = os['openSubtitlesPassword']!;
|
|
||||||
s.webdavServers = [
|
|
||||||
for (final x in s.webdavServers)
|
|
||||||
x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''),
|
|
||||||
];
|
|
||||||
s.aiServices = [
|
|
||||||
for (final x in s.aiServices) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''),
|
|
||||||
];
|
|
||||||
s.sftpServers = [
|
|
||||||
for (final x in s.sftpServers)
|
|
||||||
x.copyWith(
|
|
||||||
password: x.password.isNotEmpty ? x.password : stored[_sftpPw(x.id)] ?? '',
|
|
||||||
privateKey: x.privateKey.isNotEmpty ? x.privateKey : stored[_sftpKey(x.id)] ?? '',
|
|
||||||
keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
s.smbServers = [
|
|
||||||
for (final x in s.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''),
|
|
||||||
];
|
|
||||||
s.ftpServers = [
|
|
||||||
for (final x in s.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54).
|
|
||||||
Future<void> _loadSecrets(AppSettings s) async {
|
|
||||||
Map<String, String> stored;
|
|
||||||
try {
|
|
||||||
stored = await secrets.readAll();
|
|
||||||
_secretsOk = true;
|
|
||||||
} catch (_) {
|
|
||||||
stored = {};
|
|
||||||
_secretsOk = false;
|
|
||||||
}
|
|
||||||
_known = Map.of(stored);
|
|
||||||
_filePlain.clear();
|
|
||||||
// 예전 판이 서버 목록을 지운 설정 파일 (항목 자체가 없음) → 저장소의 목록으로
|
|
||||||
if (!s.extra.containsKey('webdavServers') && stored[_davServers] != null) {
|
|
||||||
try {
|
|
||||||
s.webdavServers = [
|
|
||||||
for (final x in jsonDecode(stored[_davServers]!) as List)
|
|
||||||
if (x is Map) DavServer.fromJson(x),
|
|
||||||
];
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
// 파일의 평문 값 (예전 판 · 저장소에 못 넣었던 것) 기억
|
|
||||||
for (final e in _osKeys.entries) {
|
|
||||||
final v = _os(s, e.key);
|
|
||||||
if (v.isNotEmpty) _filePlain[e.value] = v;
|
|
||||||
}
|
|
||||||
for (final x in s.webdavServers) {
|
|
||||||
if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password;
|
|
||||||
}
|
|
||||||
for (final x in s.aiServices) {
|
|
||||||
if (x.apiKey.isNotEmpty) _filePlain[_aiKey(x.id)] = x.apiKey;
|
|
||||||
}
|
|
||||||
_fill(s, stored);
|
|
||||||
secretIssue.value = !_secretsOk
|
|
||||||
? const SecretIssue(SecretIssueKind.readFailed)
|
|
||||||
: secrets.lostCopy != null
|
|
||||||
? SecretIssue(SecretIssueKind.lost, kept: secrets.lostCopy)
|
|
||||||
: null;
|
|
||||||
// 옮기기: 저장소에 쓰고 파일에서 지운다 (못 쓴 것은 파일에 그대로)
|
|
||||||
if (_filePlain.isNotEmpty && _secretsOk) await save(s);
|
|
||||||
// 백업으로 되살렸거나 예전 이름의 파일을 옮겨 왔으면, 저장소에 다 들어간 뒤 그 파일들의 평문도 지운다 (40-4)
|
|
||||||
if (_secretsOk && _filePlain.isEmpty) {
|
|
||||||
for (final f in _scrub) {
|
|
||||||
await _scrubFile(f);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_scrub.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 평문 비밀 값이 남아 있을 수 있는 파일 (.bak 으로 되살림 · jj_capcut 에서 옮김)
|
|
||||||
final List<File> _scrub = [];
|
|
||||||
|
|
||||||
static Future<void> _scrubFile(File f) async {
|
|
||||||
try {
|
|
||||||
if (!await f.exists()) return;
|
|
||||||
final j = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
|
||||||
final clean = AppSettings.stripSecrets(j);
|
|
||||||
if (jsonEncode(clean) == jsonEncode(j)) return;
|
|
||||||
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(clean), flush: true);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 깨진 설정 파일의 보관본에서 비밀 값을 지운다 (JSON 으로 읽을 수 없으니 글자로)
|
|
||||||
static String redactSecrets(String text) => text.replaceAllMapped(
|
|
||||||
RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password|apiKey|privateKey|keyPassphrase)"\s*:\s*"(?:[^"\\]|\\.)*"'),
|
|
||||||
(m) => '"${m[1]}": ""');
|
|
||||||
|
|
||||||
/// 읽기 오류를 알릴 글 (파일 내용은 넣지 않음 - JSON 오류 메시지에는 원문 일부가 들어간다)
|
|
||||||
static String _describe(Object e) =>
|
|
||||||
e is FormatException ? 'FormatException: ${e.message}${e.offset != null ? ' (offset ${e.offset})' : ''}' : '$e';
|
|
||||||
|
|
||||||
/// [다시 시도]: 안전 저장소를 다시 읽고 (못 읽었으면) 저장하지 못한 값을 다시 쓴다. 문제가 없어지면 true
|
|
||||||
Future<bool> retrySecrets(AppSettings s) async {
|
|
||||||
if (!_secretsOk) {
|
|
||||||
try {
|
|
||||||
final stored = await secrets.readAll();
|
|
||||||
_secretsOk = true;
|
|
||||||
_known = Map.of(stored);
|
|
||||||
_fill(s, stored);
|
|
||||||
} catch (_) {
|
|
||||||
secretIssue.value = const SecretIssue(SecretIssueKind.readFailed);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await save(s);
|
|
||||||
final i = secretIssue.value;
|
|
||||||
if (i?.kind == SecretIssueKind.readFailed) secretIssue.value = null;
|
|
||||||
return secretIssue.value == null || secretIssue.value!.kind == SecretIssueKind.lost;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 마지막 [load] 에서 생긴 일 (null = 문제 없음). 앱이 켜질 때 사용자에게 알린다.
|
|
||||||
SettingsLoadProblem? problem;
|
|
||||||
|
|
||||||
Future<String> _file() async =>
|
|
||||||
_path ?? p.join((await getApplicationSupportDirectory()).path, 'settings.json');
|
|
||||||
|
|
||||||
static AppSettings _parse(String text) => AppSettings.fromJson(jsonDecode(text) as Map<String, dynamic>);
|
|
||||||
|
|
||||||
Future<AppSettings> load() async {
|
|
||||||
final s = await _loadFile();
|
|
||||||
await _loadSecrets(s);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<AppSettings> _loadFile() async {
|
|
||||||
problem = null;
|
|
||||||
final f = File(await _file());
|
|
||||||
try {
|
|
||||||
// 이전 이름(jj_capcut) 의 설정 파일이 있으면 옮겨 온다
|
|
||||||
final legacy = File(p.join(p.dirname(f.parent.path), 'jj_capcut', 'settings.json'));
|
|
||||||
if (_path == null && !await f.exists() && await legacy.exists()) {
|
|
||||||
await f.parent.create(recursive: true);
|
|
||||||
await legacy.copy(f.path);
|
|
||||||
_scrub.add(legacy);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
final bak = File('${f.path}.bak');
|
|
||||||
if (!await f.exists()) {
|
|
||||||
// 바꿔치기 도중 끝나 settings.json 만 없는 경우: 백업이 있으면 그것으로
|
|
||||||
if (await bak.exists()) {
|
|
||||||
try {
|
|
||||||
final s = _parse(await bak.readAsString());
|
|
||||||
problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: null, error: tr('settings.json 없음'));
|
|
||||||
_scrub.add(bak);
|
|
||||||
return s;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
return AppSettings();
|
|
||||||
}
|
|
||||||
String? text;
|
|
||||||
Object? error;
|
|
||||||
try {
|
|
||||||
text = await f.readAsString();
|
|
||||||
return _parse(text);
|
|
||||||
} catch (e) {
|
|
||||||
error = e;
|
|
||||||
}
|
|
||||||
// 읽지 못함: 깨진 파일을 보관하고 백업으로
|
|
||||||
String? kept;
|
|
||||||
try {
|
|
||||||
final stamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.').first;
|
|
||||||
kept = p.join(f.parent.path, 'settings.broken-$stamp.json');
|
|
||||||
// 비밀 값은 지우고 보관 (40-4)
|
|
||||||
await File(kept).writeAsString(redactSecrets(utf8.decode(await f.readAsBytes(), allowMalformed: true)));
|
|
||||||
} catch (_) {
|
|
||||||
kept = null;
|
|
||||||
}
|
|
||||||
if (await bak.exists()) {
|
|
||||||
try {
|
|
||||||
final s = _parse(await bak.readAsString());
|
|
||||||
problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: kept, error: _describe(error));
|
|
||||||
_scrub.add(bak);
|
|
||||||
return s;
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
problem = SettingsLoadProblem(restoredFromBackup: false, brokenCopy: kept, error: _describe(error));
|
|
||||||
return AppSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 한 번에 하나씩 쓴다 (빠르게 여러 번 바꿔도 파일이 섞이지 않게)
|
|
||||||
Future<void> _last = Future.value();
|
|
||||||
|
|
||||||
Future<void> save(AppSettings s) {
|
|
||||||
final j = s.toJson();
|
|
||||||
final values = _secretsOf(s);
|
|
||||||
return _last = _last.catchError((_) {}).then((_) async {
|
|
||||||
final unsaved = await _writeSecrets(values);
|
|
||||||
// 40-1: 저장소에 들어가지 못한 값 중 파일에 원래 평문으로 있던 것은 지우지 않고 그대로 둔다
|
|
||||||
for (final k in _filePlain.keys.toList()) {
|
|
||||||
if (!values.containsKey(k) || !unsaved.contains(k)) {
|
|
||||||
_filePlain.remove(k); // 사용자가 지웠거나 저장소에 들어감
|
|
||||||
} else {
|
|
||||||
_putPlain(j, k, _filePlain[k]!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final missing = unsaved.where((k) => k != _davServers).toList();
|
|
||||||
if (missing.isNotEmpty) {
|
|
||||||
// 91: 새로 넣은 값이 저장되지 않았으면, 앱을 끈 뒤 어떻게 되는지 (예전 값으로 돌아감 / 사라짐)
|
|
||||||
var reverts = false, lost = false;
|
|
||||||
for (final k in missing) {
|
|
||||||
final old = _filePlain[k] ?? _known[k];
|
|
||||||
if (old == values[k]) continue; // 예전 평문 그대로 (파일에 남아 있음)
|
|
||||||
if (old != null) {
|
|
||||||
reverts = true;
|
|
||||||
} else {
|
|
||||||
lost = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
secretIssue.value = SecretIssue(_secretsOk ? SecretIssueKind.writeFailed : SecretIssueKind.readFailed,
|
|
||||||
revertsToOld: reverts, lostOnExit: lost);
|
|
||||||
} else if (secretIssue.value?.kind == SecretIssueKind.writeFailed) {
|
|
||||||
secretIssue.value = null;
|
|
||||||
}
|
|
||||||
await _write(const JsonEncoder.withIndent(' ').convert(j));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴).
|
|
||||||
/// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를
|
|
||||||
/// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽).
|
|
||||||
/// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다
|
|
||||||
/// 규칙 (관리자 10/21): 새 종류의 비밀 값은 아래 꼴들과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 을 쓴다 (겹치면 예전 판이 저장할 때 지움)
|
|
||||||
static bool ownsSecret(String k) => secretForms.any((f) => f.hasMatch(k));
|
|
||||||
|
|
||||||
/// 이 판이 만드는 비밀 열쇠의 꼴 ([_secretsOf] 와 같아야 한다 - 시험이 모든 열쇠가 정확히 한 꼴에만 맞는지 본다)
|
|
||||||
static final secretForms = [
|
|
||||||
for (final k in _osKeys.values) RegExp('^${RegExp.escape(k)}\$'),
|
|
||||||
RegExp('^${RegExp.escape(_davServers)}\$'),
|
|
||||||
for (final pre in const ['dav.pw', 'sftp.pw', 'sftp.key', 'sftp.kp', 'smb.pw', 'ftp.pw', 'ai.key']) RegExp('^${RegExp.escape(pre)}' r'\..+$'),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서
|
|
||||||
/// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다.
|
|
||||||
/// 지운 열쇠 수
|
|
||||||
Future<int> clearForeignSecrets() async {
|
|
||||||
final all = await secrets.readAll();
|
|
||||||
var n = 0;
|
|
||||||
for (final k in all.keys) {
|
|
||||||
if (k.startsWith('sec.') || ownsSecret(k)) continue;
|
|
||||||
await secrets.delete(k);
|
|
||||||
_known.remove(k);
|
|
||||||
n++;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 바뀐 비밀 값만 안전 저장소에 쓴다 (빈 값 · 지운 서버는 저장소에서도 지움). 저장소에 넣지 못한 열쇠를 돌려준다.
|
|
||||||
Future<Set<String>> _writeSecrets(Map<String, String> values) async {
|
|
||||||
if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])};
|
|
||||||
final unsaved = <String>{};
|
|
||||||
for (final k in {..._known.keys, ...values.keys}) {
|
|
||||||
if (!ownsSecret(k)) continue;
|
|
||||||
final v = values[k];
|
|
||||||
if (v == _known[k]) continue;
|
|
||||||
try {
|
|
||||||
if (v == null) {
|
|
||||||
await secrets.delete(k);
|
|
||||||
_known.remove(k);
|
|
||||||
} else {
|
|
||||||
await secrets.write(k, v);
|
|
||||||
_known[k] = v;
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
if (v != null) unsaved.add(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return unsaved;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _write(String text) async {
|
|
||||||
final f = File(await _file());
|
|
||||||
await f.parent.create(recursive: true);
|
|
||||||
final tmp = File('${f.path}.tmp');
|
|
||||||
await tmp.writeAsString(text, flush: true);
|
|
||||||
if (await f.exists()) {
|
|
||||||
// 지금 파일이 정상이면 백업으로 남긴다 (깨진 파일로 좋은 백업을 덮지 않게). 비밀 값은 빼고 (54)
|
|
||||||
try {
|
|
||||||
final j = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
|
||||||
AppSettings.fromJson(j);
|
|
||||||
// 저장소에 못 넣어 파일에 남겨 둔 평문이 있으면 백업에도 그대로 (40-1)
|
|
||||||
await File('${f.path}.bak').writeAsString(
|
|
||||||
const JsonEncoder.withIndent(' ').convert(_filePlain.isNotEmpty ? j : AppSettings.stripSecrets(j)),
|
|
||||||
flush: true);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
await tmp.rename(f.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 안전 저장소 문제의 종류
|
|
||||||
enum SecretIssueKind {
|
|
||||||
/// 안전 저장소를 읽을 수 없음 (이번 실행에서 넣은 비밀번호는 저장되지 않음)
|
|
||||||
readFailed,
|
|
||||||
|
|
||||||
/// 안전 저장소에 쓰지 못함 (넣은 비밀번호가 저장되지 않음)
|
|
||||||
writeFailed,
|
|
||||||
|
|
||||||
/// 안전 저장소를 풀 수 없어 저장된 비밀번호를 되살리지 못함 (풀지 못한 파일은 [SecretIssue.kept] 에 보관)
|
|
||||||
lost,
|
|
||||||
}
|
|
||||||
|
|
||||||
class SecretIssue {
|
|
||||||
final SecretIssueKind kind;
|
|
||||||
final String? kept;
|
|
||||||
|
|
||||||
/// 91: 저장하지 못한 새 값 중 예전 값이 있는 것이 있다 (앱을 끄면 예전 비밀번호로 돌아감)
|
|
||||||
final bool revertsToOld;
|
|
||||||
|
|
||||||
/// 저장하지 못한 새 값 중 예전 값이 없는 것이 있다 (앱을 끄면 사라짐)
|
|
||||||
final bool lostOnExit;
|
|
||||||
|
|
||||||
const SecretIssue(this.kind, {this.kept, this.revertsToOld = false, this.lostOnExit = false});
|
|
||||||
|
|
||||||
// 같은 상황이면 알림을 다시 띄우지 않게 (90)
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
other is SecretIssue &&
|
|
||||||
other.kind == kind &&
|
|
||||||
other.kept == kept &&
|
|
||||||
other.revertsToOld == revertsToOld &&
|
|
||||||
other.lostOnExit == lostOnExit;
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(kind, kept, revertsToOld, lostOnExit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 설정 파일을 읽다 생긴 일 (앱이 켜질 때 알림)
|
|
||||||
class SettingsLoadProblem {
|
|
||||||
/// 백업 (직전 정상 저장) 으로 되살렸는지. false 면 처음 설정으로 켰다.
|
|
||||||
final bool restoredFromBackup;
|
|
||||||
|
|
||||||
/// 읽지 못한 원래 파일을 보관한 곳
|
|
||||||
final String? brokenCopy;
|
|
||||||
final String error;
|
|
||||||
const SettingsLoadProblem({required this.restoredFromBackup, required this.brokenCopy, required this.error});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 약속 (설정 A): 설정 안의 기록 - 이동 버튼 · 즐겨찾기 · 실시간 동기화 쌍 · 복사 작업. 저마다 toJson · fromJson
|
||||||
|
|
||||||
|
/// MKV 세부 정보의 이동 버튼 하나: 표시 이름 · 옮길 폴더
|
||||||
|
class MoveTarget {
|
||||||
|
final String name;
|
||||||
|
final String dir;
|
||||||
|
const MoveTarget(this.name, this.dir);
|
||||||
|
|
||||||
|
MoveTarget copyWith({String? name, String? dir}) => MoveTarget(name ?? this.name, dir ?? this.dir);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'name': name, 'dir': dir};
|
||||||
|
|
||||||
|
factory MoveTarget.fromJson(Map<Object?, Object?> j) =>
|
||||||
|
MoveTarget((j['name'] as String?)?.trim().isNotEmpty == true ? j['name'] as String : '이동', j['dir'] as String);
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is MoveTarget && other.name == name && other.dir == dir;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(name, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 73: 파일 탐색기 즐겨찾기 (폴더). [right] 가 있으면 Rsync 화면의 "왼쪽 + 오른쪽" 짝
|
||||||
|
class FolderBookmark {
|
||||||
|
final String name;
|
||||||
|
final String path;
|
||||||
|
final String? right;
|
||||||
|
const FolderBookmark({required this.name, required this.path, this.right});
|
||||||
|
|
||||||
|
bool get isPair => right != null;
|
||||||
|
|
||||||
|
FolderBookmark copyWith({String? name}) => FolderBookmark(name: name ?? this.name, path: path, right: right);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {'name': name, 'path': path, if (right != null) 'right': right};
|
||||||
|
|
||||||
|
static FolderBookmark? fromJson(Object? j) => j is Map && j['path'] is String
|
||||||
|
? FolderBookmark(
|
||||||
|
name: j['name'] is String && (j['name'] as String).trim().isNotEmpty ? j['name'] as String : j['path'] as String,
|
||||||
|
path: j['path'] as String,
|
||||||
|
right: j['right'] is String ? j['right'] as String : null)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 실시간 동기화 한 쌍 (lsyncd 처럼: 원본 폴더를 지켜보다가 바뀌면 대상 폴더에 맞춘다)
|
||||||
|
class LiveSyncPair {
|
||||||
|
final String source;
|
||||||
|
final String target;
|
||||||
|
|
||||||
|
/// 'builtin' · 'rsync' · 'robocopy' (CopyMethod 이름)
|
||||||
|
final String method;
|
||||||
|
|
||||||
|
/// 원본에 없는 것을 대상에서 지우기 (rsync --delete, robocopy /PURGE)
|
||||||
|
final bool delete;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// 동작 시간 (cron 줄들, core/cron_window.dart). 비어 있으면 계속 (앱이 켜져 있는 동안 늘)
|
||||||
|
final List<String> schedule;
|
||||||
|
|
||||||
|
/// "지우기" 를 사용자가 지울 목록을 보고 확인했는지. 확인 전에는 지울 것이 있으면 지우지 않고 맞추며 확인을 기다린다 (42).
|
||||||
|
/// 이 값이 생기기 전부터 돌던 쌍은 확인한 것으로 본다.
|
||||||
|
final bool deleteConfirmed;
|
||||||
|
|
||||||
|
/// 지우기를 확인할 때의 rsync 옵션 (rsync 쌍만, 그 밖은 ''). 확인한 뒤 옵션이 바뀌어 지울 것이 늘면 다시 묻는다
|
||||||
|
/// (관리자 3a 판단 10/20 - --delete-excluded · --exclude · --filter 등). null = 이 값이 생기기 전에 확인한 쌍
|
||||||
|
final String? confirmedOptions;
|
||||||
|
const LiveSyncPair(this.source, this.target,
|
||||||
|
{this.method = 'builtin',
|
||||||
|
this.delete = false,
|
||||||
|
this.enabled = true,
|
||||||
|
this.schedule = const [],
|
||||||
|
this.deleteConfirmed = false,
|
||||||
|
this.confirmedOptions});
|
||||||
|
|
||||||
|
/// [delete] 를 새로 켜면 확인은 다시 받는다
|
||||||
|
LiveSyncPair copyWith(
|
||||||
|
{String? method, bool? delete, bool? enabled, List<String>? schedule, bool? deleteConfirmed, String? confirmedOptions}) =>
|
||||||
|
LiveSyncPair(source, target,
|
||||||
|
method: method ?? this.method,
|
||||||
|
delete: delete ?? this.delete,
|
||||||
|
enabled: enabled ?? this.enabled,
|
||||||
|
schedule: schedule ?? this.schedule,
|
||||||
|
deleteConfirmed: deleteConfirmed ?? (delete == true && !this.delete ? false : this.deleteConfirmed),
|
||||||
|
confirmedOptions: confirmedOptions ?? this.confirmedOptions);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'source': source,
|
||||||
|
'target': target,
|
||||||
|
'method': method,
|
||||||
|
'delete': delete,
|
||||||
|
'enabled': enabled,
|
||||||
|
'schedule': schedule,
|
||||||
|
'deleteConfirmed': deleteConfirmed,
|
||||||
|
if (confirmedOptions != null) 'confirmedOptions': confirmedOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory LiveSyncPair.fromJson(Map<Object?, Object?> j) => LiveSyncPair(
|
||||||
|
j['source'] as String,
|
||||||
|
j['target'] as String,
|
||||||
|
method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin',
|
||||||
|
delete: j['delete'] == true,
|
||||||
|
enabled: j['enabled'] != false,
|
||||||
|
schedule: [for (final x in (j['schedule'] as List?) ?? const []) '$x'],
|
||||||
|
// 이 값이 없는 예전 쌍은 이미 지우기 포함으로 돌던 것 → 확인한 것으로
|
||||||
|
deleteConfirmed: j['deleteConfirmed'] as bool? ?? true,
|
||||||
|
confirmedOptions: j['confirmedOptions'] is String ? j['confirmedOptions'] as String : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 복사 · 이동 기억 (모니터링 > 복사 · rsync): 같은 원본 → 대상을 다시 복사하면 이 옵션을 쓴다. 언제든 [실행] 으로 다시.
|
||||||
|
class CopyTask {
|
||||||
|
final String id;
|
||||||
|
final List<String> sources;
|
||||||
|
final String dest;
|
||||||
|
final bool move;
|
||||||
|
|
||||||
|
/// 폴더 "안의 것" 을 대상에 맞추기 (lsync 에서 옮겨 온 것: 원본/ → 대상/)
|
||||||
|
final bool contents;
|
||||||
|
|
||||||
|
/// CopyMethod 이름 · 그 방법의 옵션 · 한 번에 · 속도 제한 (KB/s)
|
||||||
|
final String method;
|
||||||
|
final String options;
|
||||||
|
final bool once;
|
||||||
|
final int bandwidthKBps;
|
||||||
|
|
||||||
|
/// 마지막 실행: 시각 (ISO) · 결과 ('done' · 'failed' · 'cancelled' · '') · 글 · 파일 수
|
||||||
|
final String lastRun;
|
||||||
|
final String lastResult;
|
||||||
|
final String lastMessage;
|
||||||
|
final int lastFiles;
|
||||||
|
|
||||||
|
/// 235: 이동 뒤 원본에 남은 파일 수 (건너뜀 - 216). 카드에 "n개는 원본에 남음" 으로
|
||||||
|
final int lastLeft;
|
||||||
|
|
||||||
|
/// 237: 실패 원문 (예외 이름 · 명령 줄 포함) - 카드의 [자세히] 에서 보고 복사한다. 화면 글은 [lastMessage]
|
||||||
|
final String lastDetail;
|
||||||
|
|
||||||
|
/// 이동 ([move], rsync --remove-source-files) 뒤 원본 정리: '' 그대로 · 'keep' 빈 폴더를 지우고 원본 폴더는 남김 ·
|
||||||
|
/// 'all' 원본 폴더까지 (비었으면) 지움 (find 원본/ -type d -empty -delete)
|
||||||
|
final String prune;
|
||||||
|
|
||||||
|
const CopyTask({
|
||||||
|
required this.id,
|
||||||
|
required this.sources,
|
||||||
|
required this.dest,
|
||||||
|
this.move = false,
|
||||||
|
this.contents = false,
|
||||||
|
this.method = 'builtin',
|
||||||
|
this.options = '',
|
||||||
|
this.once = false,
|
||||||
|
this.bandwidthKBps = 0,
|
||||||
|
this.lastRun = '',
|
||||||
|
this.lastResult = '',
|
||||||
|
this.lastMessage = '',
|
||||||
|
this.lastFiles = 0,
|
||||||
|
this.lastLeft = 0,
|
||||||
|
this.lastDetail = '',
|
||||||
|
this.prune = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 같은 복사인지 (원본들 · 대상 · 이동)
|
||||||
|
String get key => '${move ? 'M' : contents ? 'S' : 'C'}|${[...sources]..sort()}|$dest';
|
||||||
|
|
||||||
|
CopyTask copyWith({
|
||||||
|
String? method,
|
||||||
|
String? options,
|
||||||
|
bool? once,
|
||||||
|
int? bandwidthKBps,
|
||||||
|
bool? move,
|
||||||
|
String? lastRun,
|
||||||
|
String? lastResult,
|
||||||
|
String? lastMessage,
|
||||||
|
int? lastFiles,
|
||||||
|
int? lastLeft,
|
||||||
|
String? lastDetail,
|
||||||
|
String? prune,
|
||||||
|
}) =>
|
||||||
|
CopyTask(
|
||||||
|
id: id,
|
||||||
|
sources: sources,
|
||||||
|
dest: dest,
|
||||||
|
move: move ?? this.move,
|
||||||
|
contents: contents,
|
||||||
|
method: method ?? this.method,
|
||||||
|
options: options ?? this.options,
|
||||||
|
once: once ?? this.once,
|
||||||
|
bandwidthKBps: bandwidthKBps ?? this.bandwidthKBps,
|
||||||
|
lastRun: lastRun ?? this.lastRun,
|
||||||
|
lastResult: lastResult ?? this.lastResult,
|
||||||
|
lastMessage: lastMessage ?? this.lastMessage,
|
||||||
|
lastFiles: lastFiles ?? this.lastFiles,
|
||||||
|
lastLeft: lastLeft ?? this.lastLeft,
|
||||||
|
lastDetail: lastDetail ?? this.lastDetail,
|
||||||
|
prune: prune ?? this.prune,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'sources': sources,
|
||||||
|
'dest': dest,
|
||||||
|
'move': move,
|
||||||
|
'contents': contents,
|
||||||
|
'method': method,
|
||||||
|
'options': options,
|
||||||
|
'once': once,
|
||||||
|
'bandwidthKBps': bandwidthKBps,
|
||||||
|
'lastRun': lastRun,
|
||||||
|
'lastResult': lastResult,
|
||||||
|
'lastMessage': lastMessage,
|
||||||
|
'lastFiles': lastFiles,
|
||||||
|
'lastLeft': lastLeft,
|
||||||
|
'lastDetail': lastDetail,
|
||||||
|
'prune': prune,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory CopyTask.fromJson(Map<Object?, Object?> j) => CopyTask(
|
||||||
|
id: '${j['id']}',
|
||||||
|
sources: [for (final x in (j['sources'] as List?) ?? const []) '$x'],
|
||||||
|
dest: '${j['dest']}',
|
||||||
|
move: j['move'] == true,
|
||||||
|
contents: j['contents'] == true,
|
||||||
|
method: const ['rsync', 'robocopy'].contains(j['method']) ? j['method'] as String : 'builtin',
|
||||||
|
options: j['options'] as String? ?? '',
|
||||||
|
once: j['once'] == true,
|
||||||
|
bandwidthKBps: (j['bandwidthKBps'] as num?)?.toInt() ?? 0,
|
||||||
|
lastRun: j['lastRun'] as String? ?? '',
|
||||||
|
lastResult: j['lastResult'] as String? ?? '',
|
||||||
|
lastMessage: j['lastMessage'] as String? ?? '',
|
||||||
|
lastFiles: (j['lastFiles'] as num?)?.toInt() ?? 0,
|
||||||
|
lastLeft: (j['lastLeft'] as num?)?.toInt() ?? 0,
|
||||||
|
lastDetail: j['lastDetail'] as String? ?? '',
|
||||||
|
prune: const ['keep', 'all'].contains(j['prune']) ? j['prune'] as String : '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/// 약속 (설정 A): 설정 분야 하나 - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다.
|
||||||
|
/// 설정 하나를 더할 때는 그 분야 파일만 고친다 (settings.dart 의 toJson · fromJson 은 고치지 않음 - 분야 목록을 돌며 합치기만).
|
||||||
|
/// 저장 파일 모양 (평평한 키) 은 그대로 - 키 이름을 바꾸지 않는다
|
||||||
|
abstract class SettingsGroup {
|
||||||
|
/// 저장할 키와 값을 [j] 에 넣는다. [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 비밀 값도 파일에
|
||||||
|
void write(Map<String, Object?> j, {required bool plainSecrets});
|
||||||
|
|
||||||
|
/// 설정 파일 [j] 에서 읽는다 - 없거나 틀린 값은 처음 값 (또는 고친 값)
|
||||||
|
void read(Map<String, dynamic> j);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,454 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../core/webdav.dart';
|
||||||
|
import '../../services/secret_store.dart';
|
||||||
|
import '../../l10n/tr.dart';
|
||||||
|
import '../settings.dart';
|
||||||
|
|
||||||
|
/// 설정 파일 읽기·쓰기
|
||||||
|
/// 설정 파일 읽기 · 쓰기.
|
||||||
|
/// - 쓰기: 임시 파일에 다 쓴 뒤 바꿔치기 (쓰는 도중 앱이 끝나도 settings.json 이 깨지지 않게).
|
||||||
|
/// 바꿔치기 전의 정상 파일은 settings.json.bak 으로 남긴다.
|
||||||
|
/// - 읽기: settings.json 을 읽지 못하면 settings.json.bak 으로 되살린다. 읽지 못한 파일은 지우지 않고
|
||||||
|
/// settings.broken-(시각).json 으로 보관한다 (다음 저장에 덮여 사라지지 않게). 무슨 일이 있었는지는 [problem].
|
||||||
|
class SettingsStore {
|
||||||
|
final String? _path;
|
||||||
|
|
||||||
|
/// 비밀 값 (WebDAV 비밀번호 · OpenSubtitles) 을 두는 곳. 경로를 직접 주면 (시험) 메모리
|
||||||
|
final SecretStore secrets;
|
||||||
|
|
||||||
|
SettingsStore([this._path, SecretStore? secrets])
|
||||||
|
: secrets = secrets ?? (_path == null ? SecretStore.platform() : MemorySecretStore());
|
||||||
|
|
||||||
|
/// 안전 저장소를 읽을 수 있었는지 (못 읽었으면 저장소를 건드리지 않는다 - 지워 버리지 않게)
|
||||||
|
bool _secretsOk = true;
|
||||||
|
|
||||||
|
/// 안전 저장소에 마지막으로 쓴 (읽은) 값 - 바뀐 것만 쓴다
|
||||||
|
Map<String, String> _known = {};
|
||||||
|
|
||||||
|
/// 설정 파일에 예전부터 평문으로 있던 값 (저장소 열쇠 → 값). 안전 저장소에 들어가기 전까지는 파일에서 지우지 않는다 (40-1).
|
||||||
|
/// 새로 넣은 값은 저장소에 못 쓰더라도 평문으로 쓰지 않는다 (사용자 결정) - 대신 [secretIssue] 로 알린다.
|
||||||
|
final Map<String, String> _filePlain = {};
|
||||||
|
|
||||||
|
/// 안전 저장소 문제 (화면 위 알림 · [다시 시도]). null = 문제 없음
|
||||||
|
final secretIssue = ValueNotifier<SecretIssue?>(null);
|
||||||
|
|
||||||
|
/// 안전 저장소의 열쇠
|
||||||
|
static const _osKeys = {
|
||||||
|
'openSubtitlesKey': 'os.key',
|
||||||
|
'openSubtitlesUser': 'os.user',
|
||||||
|
'openSubtitlesPassword': 'os.password',
|
||||||
|
};
|
||||||
|
static String _davKey(String id) => 'dav.pw.$id';
|
||||||
|
static String _sftpPw(String id) => 'sftp.pw.$id';
|
||||||
|
static String _sftpKey(String id) => 'sftp.key.$id';
|
||||||
|
static String _sftpKp(String id) => 'sftp.kp.$id';
|
||||||
|
static String _smbPw(String id) => 'smb.pw.$id';
|
||||||
|
static String _ftpPw(String id) => 'ftp.pw.$id';
|
||||||
|
static String _aiKey(String id) => 'ai.key.$id';
|
||||||
|
static const _davServers = 'dav.servers';
|
||||||
|
|
||||||
|
static String _os(AppSettings s, String k) => switch (k) {
|
||||||
|
'openSubtitlesKey' => s.openSubtitlesKey,
|
||||||
|
'openSubtitlesUser' => s.openSubtitlesUser,
|
||||||
|
_ => s.openSubtitlesPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 지금 설정의 비밀 값 (빈 값은 넣지 않음 = 저장소에서 지움)
|
||||||
|
/// 규칙 (관리자 10/21): 새 종류의 비밀 값은 지금 꼴 ([secretForms]) 과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 로 만든다 -
|
||||||
|
/// 겹치면 그 꼴을 아는 예전 판이 저장할 때 "없어진 항목" 으로 보고 지운다. 새 꼴은 [secretForms] 에도 더한다
|
||||||
|
static Map<String, String> _secretsOf(AppSettings s) => !s.rememberPasswords ? const {} : {
|
||||||
|
for (final e in _osKeys.entries)
|
||||||
|
if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key),
|
||||||
|
for (final x in s.webdavServers)
|
||||||
|
if (x.password.isNotEmpty) _davKey(x.id): x.password,
|
||||||
|
for (final x in s.aiServices)
|
||||||
|
if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey,
|
||||||
|
for (final x in s.sftpServers) ...{
|
||||||
|
if (x.password.isNotEmpty) _sftpPw(x.id): x.password,
|
||||||
|
if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey,
|
||||||
|
if (x.keyPassphrase.isNotEmpty) _sftpKp(x.id): x.keyPassphrase,
|
||||||
|
},
|
||||||
|
for (final x in s.smbServers)
|
||||||
|
if (x.password.isNotEmpty) _smbPw(x.id): x.password,
|
||||||
|
for (final x in s.ftpServers)
|
||||||
|
if (x.password.isNotEmpty) _ftpPw(x.id): x.password,
|
||||||
|
// 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40)
|
||||||
|
if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 저장소에 못 넣은 예전 평문 값을 설정 JSON 에 그대로 둔다
|
||||||
|
static void _putPlain(Map<String, Object?> j, String key, String value) {
|
||||||
|
for (final e in _osKeys.entries) {
|
||||||
|
if (e.value == key) j[e.key] = value;
|
||||||
|
}
|
||||||
|
if (key.startsWith('ai.key.')) {
|
||||||
|
final id = key.substring('ai.key.'.length);
|
||||||
|
for (final x in (j['aiServices'] as List?) ?? const []) {
|
||||||
|
if (x is Map && '${x['id']}' == id) x['apiKey'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key.startsWith('dav.pw.')) {
|
||||||
|
final id = key.substring('dav.pw.'.length);
|
||||||
|
for (final x in (j['webdavServers'] as List?) ?? const []) {
|
||||||
|
if (x is Map && '${x['id']}' == id) x['password'] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 비어 있는 비밀 값을 안전 저장소의 것으로 채운다 (파일에 평문이 있으면 그것을 쓰고 [_filePlain] 에 기억)
|
||||||
|
void _fill(AppSettings s, Map<String, String> stored) {
|
||||||
|
final os = <String, String>{};
|
||||||
|
for (final e in _osKeys.entries) {
|
||||||
|
final v = _os(s, e.key);
|
||||||
|
os[e.key] = v.isNotEmpty ? v : stored[e.value] ?? '';
|
||||||
|
}
|
||||||
|
s
|
||||||
|
..openSubtitlesKey = os['openSubtitlesKey']!
|
||||||
|
..openSubtitlesUser = os['openSubtitlesUser']!
|
||||||
|
..openSubtitlesPassword = os['openSubtitlesPassword']!;
|
||||||
|
s.webdavServers = [
|
||||||
|
for (final x in s.webdavServers)
|
||||||
|
x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''),
|
||||||
|
];
|
||||||
|
s.aiServices = [
|
||||||
|
for (final x in s.aiServices) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''),
|
||||||
|
];
|
||||||
|
s.sftpServers = [
|
||||||
|
for (final x in s.sftpServers)
|
||||||
|
x.copyWith(
|
||||||
|
password: x.password.isNotEmpty ? x.password : stored[_sftpPw(x.id)] ?? '',
|
||||||
|
privateKey: x.privateKey.isNotEmpty ? x.privateKey : stored[_sftpKey(x.id)] ?? '',
|
||||||
|
keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
s.smbServers = [
|
||||||
|
for (final x in s.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''),
|
||||||
|
];
|
||||||
|
s.ftpServers = [
|
||||||
|
for (final x in s.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54).
|
||||||
|
Future<void> _loadSecrets(AppSettings s) async {
|
||||||
|
Map<String, String> stored;
|
||||||
|
try {
|
||||||
|
stored = await secrets.readAll();
|
||||||
|
_secretsOk = true;
|
||||||
|
} catch (_) {
|
||||||
|
// TODO(M0): 비밀 저장소를 못 읽으면 빈 것으로 보고 이어 간다 - 마스터가 없는 것으로 보여 문이 열린다 (보안).
|
||||||
|
// 설정 나누기 (A) 뒤 따로 고친다 (관리자 10/22). 나누기 동안 동작은 그대로
|
||||||
|
stored = {};
|
||||||
|
_secretsOk = false;
|
||||||
|
}
|
||||||
|
_known = Map.of(stored);
|
||||||
|
_filePlain.clear();
|
||||||
|
// 예전 판이 서버 목록을 지운 설정 파일 (항목 자체가 없음) → 저장소의 목록으로
|
||||||
|
if (!s.extra.containsKey('webdavServers') && stored[_davServers] != null) {
|
||||||
|
try {
|
||||||
|
s.webdavServers = [
|
||||||
|
for (final x in jsonDecode(stored[_davServers]!) as List)
|
||||||
|
if (x is Map) DavServer.fromJson(x),
|
||||||
|
];
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
// 파일의 평문 값 (예전 판 · 저장소에 못 넣었던 것) 기억
|
||||||
|
for (final e in _osKeys.entries) {
|
||||||
|
final v = _os(s, e.key);
|
||||||
|
if (v.isNotEmpty) _filePlain[e.value] = v;
|
||||||
|
}
|
||||||
|
for (final x in s.webdavServers) {
|
||||||
|
if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password;
|
||||||
|
}
|
||||||
|
for (final x in s.aiServices) {
|
||||||
|
if (x.apiKey.isNotEmpty) _filePlain[_aiKey(x.id)] = x.apiKey;
|
||||||
|
}
|
||||||
|
_fill(s, stored);
|
||||||
|
secretIssue.value = !_secretsOk
|
||||||
|
? const SecretIssue(SecretIssueKind.readFailed)
|
||||||
|
: secrets.lostCopy != null
|
||||||
|
? SecretIssue(SecretIssueKind.lost, kept: secrets.lostCopy)
|
||||||
|
: null;
|
||||||
|
// 옮기기: 저장소에 쓰고 파일에서 지운다 (못 쓴 것은 파일에 그대로)
|
||||||
|
if (_filePlain.isNotEmpty && _secretsOk) await save(s);
|
||||||
|
// 백업으로 되살렸거나 예전 이름의 파일을 옮겨 왔으면, 저장소에 다 들어간 뒤 그 파일들의 평문도 지운다 (40-4)
|
||||||
|
if (_secretsOk && _filePlain.isEmpty) {
|
||||||
|
for (final f in _scrub) {
|
||||||
|
await _scrubFile(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_scrub.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 평문 비밀 값이 남아 있을 수 있는 파일 (.bak 으로 되살림 · jj_capcut 에서 옮김)
|
||||||
|
final List<File> _scrub = [];
|
||||||
|
|
||||||
|
static Future<void> _scrubFile(File f) async {
|
||||||
|
try {
|
||||||
|
if (!await f.exists()) return;
|
||||||
|
final j = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||||
|
final clean = AppSettings.stripSecrets(j);
|
||||||
|
if (jsonEncode(clean) == jsonEncode(j)) return;
|
||||||
|
await f.writeAsString(const JsonEncoder.withIndent(' ').convert(clean), flush: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 깨진 설정 파일의 보관본에서 비밀 값을 지운다 (JSON 으로 읽을 수 없으니 글자로)
|
||||||
|
static String redactSecrets(String text) => text.replaceAllMapped(
|
||||||
|
RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password|apiKey|privateKey|keyPassphrase)"\s*:\s*"(?:[^"\\]|\\.)*"'),
|
||||||
|
(m) => '"${m[1]}": ""');
|
||||||
|
|
||||||
|
/// 읽기 오류를 알릴 글 (파일 내용은 넣지 않음 - JSON 오류 메시지에는 원문 일부가 들어간다)
|
||||||
|
static String _describe(Object e) =>
|
||||||
|
e is FormatException ? 'FormatException: ${e.message}${e.offset != null ? ' (offset ${e.offset})' : ''}' : '$e';
|
||||||
|
|
||||||
|
/// [다시 시도]: 안전 저장소를 다시 읽고 (못 읽었으면) 저장하지 못한 값을 다시 쓴다. 문제가 없어지면 true
|
||||||
|
Future<bool> retrySecrets(AppSettings s) async {
|
||||||
|
if (!_secretsOk) {
|
||||||
|
try {
|
||||||
|
final stored = await secrets.readAll();
|
||||||
|
_secretsOk = true;
|
||||||
|
_known = Map.of(stored);
|
||||||
|
_fill(s, stored);
|
||||||
|
} catch (_) {
|
||||||
|
secretIssue.value = const SecretIssue(SecretIssueKind.readFailed);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await save(s);
|
||||||
|
final i = secretIssue.value;
|
||||||
|
if (i?.kind == SecretIssueKind.readFailed) secretIssue.value = null;
|
||||||
|
return secretIssue.value == null || secretIssue.value!.kind == SecretIssueKind.lost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 마지막 [load] 에서 생긴 일 (null = 문제 없음). 앱이 켜질 때 사용자에게 알린다.
|
||||||
|
SettingsLoadProblem? problem;
|
||||||
|
|
||||||
|
Future<String> _file() async =>
|
||||||
|
_path ?? p.join((await getApplicationSupportDirectory()).path, 'settings.json');
|
||||||
|
|
||||||
|
static AppSettings _parse(String text) => AppSettings.fromJson(jsonDecode(text) as Map<String, dynamic>);
|
||||||
|
|
||||||
|
Future<AppSettings> load() async {
|
||||||
|
final s = await _loadFile();
|
||||||
|
await _loadSecrets(s);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AppSettings> _loadFile() async {
|
||||||
|
problem = null;
|
||||||
|
final f = File(await _file());
|
||||||
|
try {
|
||||||
|
// 이전 이름(jj_capcut) 의 설정 파일이 있으면 옮겨 온다
|
||||||
|
final legacy = File(p.join(p.dirname(f.parent.path), 'jj_capcut', 'settings.json'));
|
||||||
|
if (_path == null && !await f.exists() && await legacy.exists()) {
|
||||||
|
await f.parent.create(recursive: true);
|
||||||
|
await legacy.copy(f.path);
|
||||||
|
_scrub.add(legacy);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
final bak = File('${f.path}.bak');
|
||||||
|
if (!await f.exists()) {
|
||||||
|
// 바꿔치기 도중 끝나 settings.json 만 없는 경우: 백업이 있으면 그것으로
|
||||||
|
if (await bak.exists()) {
|
||||||
|
try {
|
||||||
|
final s = _parse(await bak.readAsString());
|
||||||
|
problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: null, error: tr('settings.json 없음'));
|
||||||
|
_scrub.add(bak);
|
||||||
|
return s;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return AppSettings();
|
||||||
|
}
|
||||||
|
String? text;
|
||||||
|
Object? error;
|
||||||
|
try {
|
||||||
|
text = await f.readAsString();
|
||||||
|
return _parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
error = e;
|
||||||
|
}
|
||||||
|
// 읽지 못함: 깨진 파일을 보관하고 백업으로
|
||||||
|
String? kept;
|
||||||
|
try {
|
||||||
|
final stamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.').first;
|
||||||
|
kept = p.join(f.parent.path, 'settings.broken-$stamp.json');
|
||||||
|
// 비밀 값은 지우고 보관 (40-4)
|
||||||
|
await File(kept).writeAsString(redactSecrets(utf8.decode(await f.readAsBytes(), allowMalformed: true)));
|
||||||
|
} catch (_) {
|
||||||
|
kept = null;
|
||||||
|
}
|
||||||
|
if (await bak.exists()) {
|
||||||
|
try {
|
||||||
|
final s = _parse(await bak.readAsString());
|
||||||
|
problem = SettingsLoadProblem(restoredFromBackup: true, brokenCopy: kept, error: _describe(error));
|
||||||
|
_scrub.add(bak);
|
||||||
|
return s;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
problem = SettingsLoadProblem(restoredFromBackup: false, brokenCopy: kept, error: _describe(error));
|
||||||
|
return AppSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 한 번에 하나씩 쓴다 (빠르게 여러 번 바꿔도 파일이 섞이지 않게)
|
||||||
|
Future<void> _last = Future.value();
|
||||||
|
|
||||||
|
Future<void> save(AppSettings s) {
|
||||||
|
final j = s.toJson();
|
||||||
|
final values = _secretsOf(s);
|
||||||
|
return _last = _last.catchError((_) {}).then((_) async {
|
||||||
|
final unsaved = await _writeSecrets(values);
|
||||||
|
// 40-1: 저장소에 들어가지 못한 값 중 파일에 원래 평문으로 있던 것은 지우지 않고 그대로 둔다
|
||||||
|
for (final k in _filePlain.keys.toList()) {
|
||||||
|
if (!values.containsKey(k) || !unsaved.contains(k)) {
|
||||||
|
_filePlain.remove(k); // 사용자가 지웠거나 저장소에 들어감
|
||||||
|
} else {
|
||||||
|
_putPlain(j, k, _filePlain[k]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final missing = unsaved.where((k) => k != _davServers).toList();
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
// 91: 새로 넣은 값이 저장되지 않았으면, 앱을 끈 뒤 어떻게 되는지 (예전 값으로 돌아감 / 사라짐)
|
||||||
|
var reverts = false, lost = false;
|
||||||
|
for (final k in missing) {
|
||||||
|
final old = _filePlain[k] ?? _known[k];
|
||||||
|
if (old == values[k]) continue; // 예전 평문 그대로 (파일에 남아 있음)
|
||||||
|
if (old != null) {
|
||||||
|
reverts = true;
|
||||||
|
} else {
|
||||||
|
lost = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secretIssue.value = SecretIssue(_secretsOk ? SecretIssueKind.writeFailed : SecretIssueKind.readFailed,
|
||||||
|
revertsToOld: reverts, lostOnExit: lost);
|
||||||
|
} else if (secretIssue.value?.kind == SecretIssueKind.writeFailed) {
|
||||||
|
secretIssue.value = null;
|
||||||
|
}
|
||||||
|
await _write(const JsonEncoder.withIndent(' ').convert(j));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴).
|
||||||
|
/// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를
|
||||||
|
/// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽).
|
||||||
|
/// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다
|
||||||
|
/// 규칙 (관리자 10/21): 새 종류의 비밀 값은 아래 꼴들과 앞부분이 겹치지 않는 새 머리글 (dav2. 등) 을 쓴다 (겹치면 예전 판이 저장할 때 지움)
|
||||||
|
static bool ownsSecret(String k) => secretForms.any((f) => f.hasMatch(k));
|
||||||
|
|
||||||
|
/// 이 판이 만드는 비밀 열쇠의 꼴 ([_secretsOf] 와 같아야 한다 - 시험이 모든 열쇠가 정확히 한 꼴에만 맞는지 본다)
|
||||||
|
static final secretForms = [
|
||||||
|
for (final k in _osKeys.values) RegExp('^${RegExp.escape(k)}\$'),
|
||||||
|
RegExp('^${RegExp.escape(_davServers)}\$'),
|
||||||
|
for (final pre in const ['dav.pw', 'sftp.pw', 'sftp.key', 'sftp.kp', 'smb.pw', 'ftp.pw', 'ai.key']) RegExp('^${RegExp.escape(pre)}' r'\..+$'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서
|
||||||
|
/// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다.
|
||||||
|
/// 지운 열쇠 수
|
||||||
|
Future<int> clearForeignSecrets() async {
|
||||||
|
final all = await secrets.readAll();
|
||||||
|
var n = 0;
|
||||||
|
for (final k in all.keys) {
|
||||||
|
if (k.startsWith('sec.') || ownsSecret(k)) continue;
|
||||||
|
await secrets.delete(k);
|
||||||
|
_known.remove(k);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 바뀐 비밀 값만 안전 저장소에 쓴다 (빈 값 · 지운 서버는 저장소에서도 지움). 저장소에 넣지 못한 열쇠를 돌려준다.
|
||||||
|
Future<Set<String>> _writeSecrets(Map<String, String> values) async {
|
||||||
|
if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])};
|
||||||
|
final unsaved = <String>{};
|
||||||
|
for (final k in {..._known.keys, ...values.keys}) {
|
||||||
|
if (!ownsSecret(k)) continue;
|
||||||
|
final v = values[k];
|
||||||
|
if (v == _known[k]) continue;
|
||||||
|
try {
|
||||||
|
if (v == null) {
|
||||||
|
await secrets.delete(k);
|
||||||
|
_known.remove(k);
|
||||||
|
} else {
|
||||||
|
await secrets.write(k, v);
|
||||||
|
_known[k] = v;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (v != null) unsaved.add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unsaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _write(String text) async {
|
||||||
|
final f = File(await _file());
|
||||||
|
await f.parent.create(recursive: true);
|
||||||
|
final tmp = File('${f.path}.tmp');
|
||||||
|
await tmp.writeAsString(text, flush: true);
|
||||||
|
if (await f.exists()) {
|
||||||
|
// 지금 파일이 정상이면 백업으로 남긴다 (깨진 파일로 좋은 백업을 덮지 않게). 비밀 값은 빼고 (54)
|
||||||
|
try {
|
||||||
|
final j = jsonDecode(await f.readAsString()) as Map<String, dynamic>;
|
||||||
|
AppSettings.fromJson(j);
|
||||||
|
// 저장소에 못 넣어 파일에 남겨 둔 평문이 있으면 백업에도 그대로 (40-1)
|
||||||
|
await File('${f.path}.bak').writeAsString(
|
||||||
|
const JsonEncoder.withIndent(' ').convert(_filePlain.isNotEmpty ? j : AppSettings.stripSecrets(j)),
|
||||||
|
flush: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await tmp.rename(f.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 안전 저장소 문제의 종류
|
||||||
|
enum SecretIssueKind {
|
||||||
|
/// 안전 저장소를 읽을 수 없음 (이번 실행에서 넣은 비밀번호는 저장되지 않음)
|
||||||
|
readFailed,
|
||||||
|
|
||||||
|
/// 안전 저장소에 쓰지 못함 (넣은 비밀번호가 저장되지 않음)
|
||||||
|
writeFailed,
|
||||||
|
|
||||||
|
/// 안전 저장소를 풀 수 없어 저장된 비밀번호를 되살리지 못함 (풀지 못한 파일은 [SecretIssue.kept] 에 보관)
|
||||||
|
lost,
|
||||||
|
}
|
||||||
|
|
||||||
|
class SecretIssue {
|
||||||
|
final SecretIssueKind kind;
|
||||||
|
final String? kept;
|
||||||
|
|
||||||
|
/// 91: 저장하지 못한 새 값 중 예전 값이 있는 것이 있다 (앱을 끄면 예전 비밀번호로 돌아감)
|
||||||
|
final bool revertsToOld;
|
||||||
|
|
||||||
|
/// 저장하지 못한 새 값 중 예전 값이 없는 것이 있다 (앱을 끄면 사라짐)
|
||||||
|
final bool lostOnExit;
|
||||||
|
|
||||||
|
const SecretIssue(this.kind, {this.kept, this.revertsToOld = false, this.lostOnExit = false});
|
||||||
|
|
||||||
|
// 같은 상황이면 알림을 다시 띄우지 않게 (90)
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is SecretIssue &&
|
||||||
|
other.kind == kind &&
|
||||||
|
other.kept == kept &&
|
||||||
|
other.revertsToOld == revertsToOld &&
|
||||||
|
other.lostOnExit == lostOnExit;
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(kind, kept, revertsToOld, lostOnExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설정 파일을 읽다 생긴 일 (앱이 켜질 때 알림)
|
||||||
|
class SettingsLoadProblem {
|
||||||
|
/// 백업 (직전 정상 저장) 으로 되살렸는지. false 면 처음 설정으로 켰다.
|
||||||
|
final bool restoredFromBackup;
|
||||||
|
|
||||||
|
/// 읽지 못한 원래 파일을 보관한 곳
|
||||||
|
final String? brokenCopy;
|
||||||
|
final String error;
|
||||||
|
const SettingsLoadProblem({required this.restoredFromBackup, required this.brokenCopy, required this.error});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
{
|
||||||
|
"addFinishedDownloads": true,
|
||||||
|
"aiAgreed": [],
|
||||||
|
"aiAutoDevice": "",
|
||||||
|
"aiAutoDeviceFor": "",
|
||||||
|
"aiCfg": 1.0,
|
||||||
|
"aiCount": 1,
|
||||||
|
"aiDevice": "auto",
|
||||||
|
"aiEngine": "local",
|
||||||
|
"aiHeight": 512,
|
||||||
|
"aiImageV1": true,
|
||||||
|
"aiNegative": "",
|
||||||
|
"aiPrompt": "",
|
||||||
|
"aiQualityButton": false,
|
||||||
|
"aiQualityStrength": "normal",
|
||||||
|
"aiRecent": [],
|
||||||
|
"aiSaveDir": "",
|
||||||
|
"aiServices": [],
|
||||||
|
"aiSource": "und",
|
||||||
|
"aiSteps": 4,
|
||||||
|
"aiStrength": 0.6,
|
||||||
|
"aiTaesd": true,
|
||||||
|
"aiTargets": [
|
||||||
|
"ko",
|
||||||
|
"en",
|
||||||
|
"ja"
|
||||||
|
],
|
||||||
|
"aiUpAfter": "upscaled",
|
||||||
|
"aiUpDir": "",
|
||||||
|
"aiUpFormat": "same",
|
||||||
|
"aiUpJpgQuality": 92,
|
||||||
|
"aiUpMemory": "normal",
|
||||||
|
"aiUpModel": "auto",
|
||||||
|
"aiUpScale": 2,
|
||||||
|
"aiUpSecPerMp": {},
|
||||||
|
"aiWhisper": "whisper-base",
|
||||||
|
"aiWidth": 512,
|
||||||
|
"allFilesHint2": false,
|
||||||
|
"allowInnerToOuter": true,
|
||||||
|
"androidTrashDays": 14,
|
||||||
|
"appIcon": "yellow",
|
||||||
|
"askAiOptions": true,
|
||||||
|
"autoCheckUpdates": true,
|
||||||
|
"backgroundV2": true,
|
||||||
|
"batchRenameLast": {},
|
||||||
|
"browserEngine": "edge",
|
||||||
|
"browserSwipeHinted": false,
|
||||||
|
"cefWhenElevated": false,
|
||||||
|
"cleanupPrechecked": [
|
||||||
|
"work",
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"clipboardWatch": true,
|
||||||
|
"closeAction": "background",
|
||||||
|
"components": [
|
||||||
|
"mkv",
|
||||||
|
"browser",
|
||||||
|
"explorer",
|
||||||
|
"rsync",
|
||||||
|
"downloads",
|
||||||
|
"aiimage",
|
||||||
|
"viewer"
|
||||||
|
],
|
||||||
|
"cookieScopeV2": true,
|
||||||
|
"copyBandwidthKBps": 0,
|
||||||
|
"copyConflict": "ask",
|
||||||
|
"copyMethodFile": "builtin",
|
||||||
|
"copyMethodFolder": "builtin",
|
||||||
|
"copyRunMode": "each",
|
||||||
|
"copySkipNewer": true,
|
||||||
|
"copyTasks": [],
|
||||||
|
"davExternalOpen": "ask",
|
||||||
|
"dnsDohUrl": "https://dns.google/dns-query",
|
||||||
|
"dnsMode": "custom",
|
||||||
|
"dnsPrimary": "8.8.8.8",
|
||||||
|
"dnsSecondary": "8.8.4.4",
|
||||||
|
"downloadRoot": null,
|
||||||
|
"encode": {
|
||||||
|
"brightness": 0,
|
||||||
|
"codec": "copy",
|
||||||
|
"contrast": 0,
|
||||||
|
"fit": "fill",
|
||||||
|
"frame": "original",
|
||||||
|
"quality": "normal",
|
||||||
|
"resolution": "original",
|
||||||
|
"rotate": "none",
|
||||||
|
"saturation": 0,
|
||||||
|
"temperature": 0
|
||||||
|
},
|
||||||
|
"explorerAlwaysMarks": false,
|
||||||
|
"explorerButtons": [],
|
||||||
|
"explorerClick": "select",
|
||||||
|
"explorerDropAction": "ask",
|
||||||
|
"explorerFolderSize": "sort",
|
||||||
|
"explorerFolderSorts": {},
|
||||||
|
"explorerHiddenItems": [],
|
||||||
|
"explorerHideConfirm": true,
|
||||||
|
"explorerHistory": [],
|
||||||
|
"explorerKeyOverrides": {},
|
||||||
|
"explorerLayout": "dual",
|
||||||
|
"explorerOnePaneCopy": "clip",
|
||||||
|
"explorerOrientV3": true,
|
||||||
|
"explorerOrientation": "side",
|
||||||
|
"explorerPaths": [],
|
||||||
|
"explorerProgressHidden": false,
|
||||||
|
"explorerRemoteThumbCacheMb": 200,
|
||||||
|
"explorerRemoteThumbs": "wifi",
|
||||||
|
"explorerRetapCollapse": true,
|
||||||
|
"explorerRootsInTree": true,
|
||||||
|
"explorerRowSpacing": "normal",
|
||||||
|
"explorerRulesV1": true,
|
||||||
|
"explorerShowHidden": false,
|
||||||
|
"explorerSort": "name",
|
||||||
|
"explorerSortByKind": false,
|
||||||
|
"explorerSortDesc": false,
|
||||||
|
"explorerSortPerFolder": false,
|
||||||
|
"explorerStyle": "xplore",
|
||||||
|
"explorerSwipePanes": false,
|
||||||
|
"explorerTextScale": 100,
|
||||||
|
"explorerToolbar": "middle",
|
||||||
|
"explorerV2": true,
|
||||||
|
"externalBrowser": "system",
|
||||||
|
"externalPlayers": {},
|
||||||
|
"folderBookmarks": [],
|
||||||
|
"ftpServers": [],
|
||||||
|
"homeUrl": "https://www.youtube.com/",
|
||||||
|
"imageExts": [
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"gif",
|
||||||
|
"webp",
|
||||||
|
"bmp"
|
||||||
|
],
|
||||||
|
"lastRunVersion": "",
|
||||||
|
"lastUpdateCheck": "",
|
||||||
|
"lastUpdateCheckVersion": "",
|
||||||
|
"liveSyncIntervalSec": 30,
|
||||||
|
"liveSyncOnStart": "auto",
|
||||||
|
"liveSyncPairs": [],
|
||||||
|
"loginCookieSites": [
|
||||||
|
"youtube.com",
|
||||||
|
"google.com",
|
||||||
|
"instagram.com",
|
||||||
|
"x.com",
|
||||||
|
"twitter.com",
|
||||||
|
"naver.com"
|
||||||
|
],
|
||||||
|
"maxParallelDownloads": 3,
|
||||||
|
"maxParallelJobs": 5,
|
||||||
|
"migrationNotice": true,
|
||||||
|
"minimizeToTray": true,
|
||||||
|
"mkvCompactList": "auto",
|
||||||
|
"mkvExisting": "ask",
|
||||||
|
"mkvOutputRoot": null,
|
||||||
|
"moveTargets": [],
|
||||||
|
"navOrder": [
|
||||||
|
"mkv",
|
||||||
|
"browser",
|
||||||
|
"tor",
|
||||||
|
"explorer",
|
||||||
|
"rsync",
|
||||||
|
"downloads",
|
||||||
|
"aiimage"
|
||||||
|
],
|
||||||
|
"openFileAction": "play",
|
||||||
|
"openFileWindow": "same",
|
||||||
|
"openSubtitlesSaved": {
|
||||||
|
"key": false,
|
||||||
|
"password": false,
|
||||||
|
"user": false
|
||||||
|
},
|
||||||
|
"orientationV2": true,
|
||||||
|
"playlistMode": "series",
|
||||||
|
"preferredSubtitleLanguage": "",
|
||||||
|
"readerAutoSeconds": 3,
|
||||||
|
"readerBrightness": 0.0,
|
||||||
|
"readerFit": "page",
|
||||||
|
"readerGallery": false,
|
||||||
|
"readerRtl": false,
|
||||||
|
"readerSplit": "auto",
|
||||||
|
"readerSystemBars": true,
|
||||||
|
"readerVolumeKeys": false,
|
||||||
|
"recycleOnDelete": true,
|
||||||
|
"rememberPasswords": true,
|
||||||
|
"remoteIdleSeconds": 60,
|
||||||
|
"robocopyLinksV1": true,
|
||||||
|
"robocopyOptions": "/E /COPY:DAT /DCOPY:T /R:2 /W:2",
|
||||||
|
"rollbackKeepModels": "",
|
||||||
|
"rootEnabled": false,
|
||||||
|
"rsyncOptions": "-avPog",
|
||||||
|
"rsyncPath": "",
|
||||||
|
"rsyncPaths": [],
|
||||||
|
"rsyncSource": "download",
|
||||||
|
"runInBackground": true,
|
||||||
|
"screenOrientation": "auto",
|
||||||
|
"sftpServers": [],
|
||||||
|
"shizukuEnabled": false,
|
||||||
|
"showHotkey": "Ctrl+Shift+X",
|
||||||
|
"showLog": true,
|
||||||
|
"skippedVersion": "",
|
||||||
|
"smbRequireSecure": false,
|
||||||
|
"smbServers": [],
|
||||||
|
"startMenuShortcut": true,
|
||||||
|
"startScreen": "home",
|
||||||
|
"subtitleScale": 1.0,
|
||||||
|
"swipeNav": true,
|
||||||
|
"swipeWrap": true,
|
||||||
|
"syncStopToast": true,
|
||||||
|
"torHomeUrl": "https://check.torproject.org/",
|
||||||
|
"torIdleMinutes": 2,
|
||||||
|
"torJavaScript": true,
|
||||||
|
"torKeepData": false,
|
||||||
|
"torNoticeSeen": false,
|
||||||
|
"torrentStallSeconds": 60,
|
||||||
|
"tripleTapAction": "move",
|
||||||
|
"uiBrightness": "dark",
|
||||||
|
"uiLanguage": "system",
|
||||||
|
"uiLanguagesAdded": [],
|
||||||
|
"uiScale": 1.0,
|
||||||
|
"uiScaleDefault": 1.0,
|
||||||
|
"vaultAutoLockSeconds": 60,
|
||||||
|
"vaultEnabled": false,
|
||||||
|
"vaultMasterCopy": false,
|
||||||
|
"webJavaScript": true,
|
||||||
|
"webPauseOnLeave": true,
|
||||||
|
"webTranslate": false,
|
||||||
|
"webdavServers": [],
|
||||||
|
"wifiShareFolder": "",
|
||||||
|
"wifiShareMinutes": 30,
|
||||||
|
"wifiShareTailscale": true,
|
||||||
|
"youtubeAdHide": true,
|
||||||
|
"youtubeAdSkip": true,
|
||||||
|
"ytContainer": "mp4",
|
||||||
|
"ytCookiesBrowser": "*internal",
|
||||||
|
"ytCookiesFile": "",
|
||||||
|
"ytExpandPlaylists": true,
|
||||||
|
"ytPreferH264": false,
|
||||||
|
"ytQuality": "best",
|
||||||
|
"zipComic": false,
|
||||||
|
"zipComicV2": true,
|
||||||
|
"zipLevel": 6
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
{
|
||||||
|
"mkvOutputRoot": null,
|
||||||
|
"moveTargets": [],
|
||||||
|
"folderBookmarks": [],
|
||||||
|
"youtubeAdSkip": false,
|
||||||
|
"youtubeAdHide": false,
|
||||||
|
"webPauseOnLeave": false,
|
||||||
|
"downloadRoot": null,
|
||||||
|
"clipboardWatch": false,
|
||||||
|
"minimizeToTray": false,
|
||||||
|
"closeAction": "x_background",
|
||||||
|
"showHotkey": "x_Ctrl+Shift+X",
|
||||||
|
"askAiOptions": false,
|
||||||
|
"addFinishedDownloads": false,
|
||||||
|
"torrentStallSeconds": 67,
|
||||||
|
"openSubtitlesSaved": {
|
||||||
|
"key": true,
|
||||||
|
"user": true,
|
||||||
|
"password": true
|
||||||
|
},
|
||||||
|
"openSubtitlesKey": "x_",
|
||||||
|
"openSubtitlesUser": "x_",
|
||||||
|
"openSubtitlesPassword": "x_",
|
||||||
|
"ytContainer": "x_mp4",
|
||||||
|
"ytQuality": "x_best",
|
||||||
|
"ytPreferH264": true,
|
||||||
|
"ytExpandPlaylists": false,
|
||||||
|
"ytCookiesBrowser": "x_*internal",
|
||||||
|
"loginCookieSites": [
|
||||||
|
"youtube.com",
|
||||||
|
"google.com",
|
||||||
|
"instagram.com",
|
||||||
|
"x.com",
|
||||||
|
"twitter.com",
|
||||||
|
"naver.com"
|
||||||
|
],
|
||||||
|
"cookieScopeV2": false,
|
||||||
|
"ytCookiesFile": "x_",
|
||||||
|
"maxParallelDownloads": 10,
|
||||||
|
"maxParallelJobs": 12,
|
||||||
|
"showLog": false,
|
||||||
|
"mkvCompactList": "x_auto",
|
||||||
|
"startScreen": "x_home",
|
||||||
|
"uiScale": 1.5,
|
||||||
|
"uiScaleDefault": 1.5,
|
||||||
|
"uiBrightness": "x_dark",
|
||||||
|
"openFileAction": "x_play",
|
||||||
|
"screenOrientation": "x_auto",
|
||||||
|
"orientationV2": false,
|
||||||
|
"uiLanguage": "x_system",
|
||||||
|
"appIcon": "x_yellow",
|
||||||
|
"uiLanguagesAdded": [],
|
||||||
|
"openFileWindow": "x_same",
|
||||||
|
"homeUrl": "x_https://www.youtube.com/",
|
||||||
|
"browserEngine": "x_edge",
|
||||||
|
"cefWhenElevated": true,
|
||||||
|
"externalBrowser": "x_system",
|
||||||
|
"webTranslate": true,
|
||||||
|
"webJavaScript": false,
|
||||||
|
"dnsMode": "x_custom",
|
||||||
|
"dnsPrimary": "x_8.8.8.8",
|
||||||
|
"dnsSecondary": "x_8.8.4.4",
|
||||||
|
"dnsDohUrl": "x_https://dns.google/dns-query",
|
||||||
|
"torKeepData": true,
|
||||||
|
"torIdleMinutes": 9,
|
||||||
|
"torHomeUrl": "x_https://check.torproject.org/",
|
||||||
|
"torJavaScript": false,
|
||||||
|
"torNoticeSeen": true,
|
||||||
|
"explorerLayout": "x_dual",
|
||||||
|
"explorerOnePaneCopy": "x_clip",
|
||||||
|
"explorerRetapCollapse": false,
|
||||||
|
"explorerTextScale": 107,
|
||||||
|
"explorerRowSpacing": "x_normal",
|
||||||
|
"batchRenameLast": {},
|
||||||
|
"explorerRootsInTree": false,
|
||||||
|
"explorerAlwaysMarks": true,
|
||||||
|
"explorerSwipePanes": true,
|
||||||
|
"androidTrashDays": 21,
|
||||||
|
"explorerClick": "x_select",
|
||||||
|
"explorerV2": false,
|
||||||
|
"explorerRulesV1": false,
|
||||||
|
"explorerStyle": "x_xplore",
|
||||||
|
"explorerOrientation": "x_side",
|
||||||
|
"explorerOrientV3": false,
|
||||||
|
"explorerToolbar": "x_middle",
|
||||||
|
"explorerButtons": [],
|
||||||
|
"explorerSort": "x_name",
|
||||||
|
"explorerSortDesc": true,
|
||||||
|
"explorerFolderSize": "x_sort",
|
||||||
|
"explorerDropAction": "x_ask",
|
||||||
|
"explorerProgressHidden": true,
|
||||||
|
"zipLevel": 13,
|
||||||
|
"wifiShareMinutes": 37,
|
||||||
|
"wifiShareTailscale": false,
|
||||||
|
"wifiShareFolder": "x_",
|
||||||
|
"explorerSortPerFolder": true,
|
||||||
|
"explorerSortByKind": true,
|
||||||
|
"explorerRemoteThumbs": "x_wifi",
|
||||||
|
"explorerRemoteThumbCacheMb": 207,
|
||||||
|
"explorerKeyOverrides": {},
|
||||||
|
"vaultEnabled": true,
|
||||||
|
"shizukuEnabled": true,
|
||||||
|
"rootEnabled": true,
|
||||||
|
"vaultMasterCopy": true,
|
||||||
|
"vaultAutoLockSeconds": 67,
|
||||||
|
"explorerFolderSorts": {},
|
||||||
|
"explorerShowHidden": true,
|
||||||
|
"explorerHiddenItems": [],
|
||||||
|
"explorerHideConfirm": false,
|
||||||
|
"explorerPaths": [],
|
||||||
|
"copyMethodFile": "x_builtin",
|
||||||
|
"copyConflict": "x_ask",
|
||||||
|
"mkvExisting": "x_ask",
|
||||||
|
"copySkipNewer": false,
|
||||||
|
"copyMethodFolder": "x_builtin",
|
||||||
|
"rsyncPaths": [],
|
||||||
|
"components": [
|
||||||
|
"mkv",
|
||||||
|
"browser",
|
||||||
|
"explorer",
|
||||||
|
"rsync",
|
||||||
|
"downloads",
|
||||||
|
"aiimage",
|
||||||
|
"viewer"
|
||||||
|
],
|
||||||
|
"aiImageV1": false,
|
||||||
|
"robocopyLinksV1": false,
|
||||||
|
"navOrder": [
|
||||||
|
"mkv",
|
||||||
|
"browser",
|
||||||
|
"tor",
|
||||||
|
"explorer",
|
||||||
|
"rsync",
|
||||||
|
"downloads",
|
||||||
|
"aiimage"
|
||||||
|
],
|
||||||
|
"swipeNav": false,
|
||||||
|
"imageExts": [
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"gif",
|
||||||
|
"webp",
|
||||||
|
"bmp"
|
||||||
|
],
|
||||||
|
"readerFit": "x_page",
|
||||||
|
"readerRtl": true,
|
||||||
|
"readerBrightness": 0.5,
|
||||||
|
"readerSystemBars": false,
|
||||||
|
"readerAutoSeconds": 10,
|
||||||
|
"readerGallery": true,
|
||||||
|
"readerVolumeKeys": true,
|
||||||
|
"readerSplit": "x_auto",
|
||||||
|
"startMenuShortcut": false,
|
||||||
|
"syncStopToast": false,
|
||||||
|
"swipeWrap": false,
|
||||||
|
"browserSwipeHinted": true,
|
||||||
|
"cleanupPrechecked": [
|
||||||
|
"work",
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"migrationNotice": false,
|
||||||
|
"allowInnerToOuter": false,
|
||||||
|
"rememberPasswords": false,
|
||||||
|
"recycleOnDelete": false,
|
||||||
|
"tripleTapAction": "x_move",
|
||||||
|
"preferredSubtitleLanguage": "x_",
|
||||||
|
"davExternalOpen": "x_ask",
|
||||||
|
"allFilesHint2": true,
|
||||||
|
"subtitleScale": 1.5,
|
||||||
|
"zipComic": true,
|
||||||
|
"zipComicV2": false,
|
||||||
|
"aiEngine": "x_local",
|
||||||
|
"aiDevice": "x_auto",
|
||||||
|
"aiAutoDevice": "x_",
|
||||||
|
"aiAutoDeviceFor": "x_",
|
||||||
|
"aiTaesd": false,
|
||||||
|
"aiWidth": 519,
|
||||||
|
"aiHeight": 519,
|
||||||
|
"aiSteps": 11,
|
||||||
|
"aiCfg": 1.5,
|
||||||
|
"aiCount": 8,
|
||||||
|
"aiStrength": 1.1,
|
||||||
|
"aiNegative": "x_",
|
||||||
|
"aiPrompt": "x_",
|
||||||
|
"aiRecent": [],
|
||||||
|
"aiSaveDir": "x_",
|
||||||
|
"aiAgreed": [],
|
||||||
|
"aiUpModel": "x_auto",
|
||||||
|
"aiUpMemory": "x_normal",
|
||||||
|
"aiUpSecPerMp": {},
|
||||||
|
"aiUpScale": 9,
|
||||||
|
"aiQualityButton": true,
|
||||||
|
"aiQualityStrength": "x_normal",
|
||||||
|
"aiUpFormat": "x_same",
|
||||||
|
"aiUpJpgQuality": 99,
|
||||||
|
"aiUpDir": "x_",
|
||||||
|
"aiUpAfter": "x_upscaled",
|
||||||
|
"aiServices": [],
|
||||||
|
"webdavServers": [],
|
||||||
|
"sftpServers": [],
|
||||||
|
"smbServers": [],
|
||||||
|
"smbRequireSecure": true,
|
||||||
|
"remoteIdleSeconds": 67,
|
||||||
|
"ftpServers": [],
|
||||||
|
"rsyncOptions": "x_-avPog",
|
||||||
|
"robocopyOptions": "x_/E /COPY:DAT /DCOPY:T /R:2 /W:2",
|
||||||
|
"copyRunMode": "x_each",
|
||||||
|
"copyBandwidthKBps": 7,
|
||||||
|
"rsyncSource": "x_download",
|
||||||
|
"rsyncPath": "x_",
|
||||||
|
"liveSyncPairs": [],
|
||||||
|
"liveSyncIntervalSec": 37,
|
||||||
|
"runInBackground": false,
|
||||||
|
"backgroundV2": false,
|
||||||
|
"liveSyncOnStart": "x_auto",
|
||||||
|
"copyTasks": [],
|
||||||
|
"explorerHistory": [],
|
||||||
|
"autoCheckUpdates": false,
|
||||||
|
"lastUpdateCheck": "x_",
|
||||||
|
"lastUpdateCheckVersion": "x_",
|
||||||
|
"lastRunVersion": "x_",
|
||||||
|
"rollbackKeepModels": "x_",
|
||||||
|
"skippedVersion": "x_",
|
||||||
|
"playlistMode": "x_series",
|
||||||
|
"externalPlayers": {},
|
||||||
|
"encode": {
|
||||||
|
"codec": "x_copy",
|
||||||
|
"resolution": "x_original",
|
||||||
|
"quality": "x_normal",
|
||||||
|
"frame": "x_original",
|
||||||
|
"fit": "x_fill",
|
||||||
|
"rotate": "x_none",
|
||||||
|
"brightness": 7,
|
||||||
|
"contrast": 7,
|
||||||
|
"saturation": 7,
|
||||||
|
"temperature": 7
|
||||||
|
},
|
||||||
|
"aiSource": "x_und",
|
||||||
|
"aiTargets": [
|
||||||
|
"ko",
|
||||||
|
"en",
|
||||||
|
"ja"
|
||||||
|
],
|
||||||
|
"aiWhisper": "x_whisper-base"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
{
|
||||||
|
"addFinishedDownloads": false,
|
||||||
|
"aiAgreed": [],
|
||||||
|
"aiAutoDevice": "x_",
|
||||||
|
"aiAutoDeviceFor": "x_",
|
||||||
|
"aiCfg": 1.5,
|
||||||
|
"aiCount": 8,
|
||||||
|
"aiDevice": "x_auto",
|
||||||
|
"aiEngine": "x_local",
|
||||||
|
"aiHeight": 519,
|
||||||
|
"aiImageV1": true,
|
||||||
|
"aiNegative": "x_",
|
||||||
|
"aiPrompt": "x_",
|
||||||
|
"aiQualityButton": true,
|
||||||
|
"aiQualityStrength": "normal",
|
||||||
|
"aiRecent": [],
|
||||||
|
"aiSaveDir": "x_",
|
||||||
|
"aiServices": [],
|
||||||
|
"aiSource": "x_und",
|
||||||
|
"aiSteps": 11,
|
||||||
|
"aiStrength": 1.0,
|
||||||
|
"aiTaesd": false,
|
||||||
|
"aiTargets": [
|
||||||
|
"ko",
|
||||||
|
"en",
|
||||||
|
"ja"
|
||||||
|
],
|
||||||
|
"aiUpAfter": "upscaled",
|
||||||
|
"aiUpDir": "x_",
|
||||||
|
"aiUpFormat": "same",
|
||||||
|
"aiUpJpgQuality": 99,
|
||||||
|
"aiUpMemory": "normal",
|
||||||
|
"aiUpModel": "auto",
|
||||||
|
"aiUpScale": 2,
|
||||||
|
"aiUpSecPerMp": {},
|
||||||
|
"aiWhisper": "x_whisper-base",
|
||||||
|
"aiWidth": 519,
|
||||||
|
"allFilesHint2": true,
|
||||||
|
"allowInnerToOuter": false,
|
||||||
|
"androidTrashDays": 21,
|
||||||
|
"appIcon": "yellow",
|
||||||
|
"askAiOptions": false,
|
||||||
|
"autoCheckUpdates": false,
|
||||||
|
"backgroundV2": true,
|
||||||
|
"batchRenameLast": {},
|
||||||
|
"browserEngine": "x_edge",
|
||||||
|
"browserSwipeHinted": true,
|
||||||
|
"cefWhenElevated": true,
|
||||||
|
"cleanupPrechecked": [
|
||||||
|
"work",
|
||||||
|
"download"
|
||||||
|
],
|
||||||
|
"clipboardWatch": false,
|
||||||
|
"closeAction": "background",
|
||||||
|
"components": [
|
||||||
|
"mkv",
|
||||||
|
"browser",
|
||||||
|
"explorer",
|
||||||
|
"rsync",
|
||||||
|
"downloads",
|
||||||
|
"aiimage",
|
||||||
|
"viewer"
|
||||||
|
],
|
||||||
|
"cookieScopeV2": true,
|
||||||
|
"copyBandwidthKBps": 7,
|
||||||
|
"copyConflict": "ask",
|
||||||
|
"copyMethodFile": "builtin",
|
||||||
|
"copyMethodFolder": "builtin",
|
||||||
|
"copyRunMode": "each",
|
||||||
|
"copySkipNewer": false,
|
||||||
|
"copyTasks": [],
|
||||||
|
"davExternalOpen": "ask",
|
||||||
|
"dnsDohUrl": "https://dns.google/dns-query",
|
||||||
|
"dnsMode": "custom",
|
||||||
|
"dnsPrimary": "8.8.8.8",
|
||||||
|
"dnsSecondary": "8.8.4.4",
|
||||||
|
"downloadRoot": null,
|
||||||
|
"encode": {
|
||||||
|
"brightness": 7,
|
||||||
|
"codec": "copy",
|
||||||
|
"contrast": 7,
|
||||||
|
"fit": "fill",
|
||||||
|
"frame": "original",
|
||||||
|
"quality": "normal",
|
||||||
|
"resolution": "original",
|
||||||
|
"rotate": "none",
|
||||||
|
"saturation": 7,
|
||||||
|
"temperature": 7
|
||||||
|
},
|
||||||
|
"explorerAlwaysMarks": false,
|
||||||
|
"explorerButtons": [],
|
||||||
|
"explorerClick": "select",
|
||||||
|
"explorerDropAction": "ask",
|
||||||
|
"explorerFolderSize": "sort",
|
||||||
|
"explorerFolderSorts": {},
|
||||||
|
"explorerHiddenItems": [],
|
||||||
|
"explorerHideConfirm": false,
|
||||||
|
"explorerHistory": [],
|
||||||
|
"explorerKeyOverrides": {},
|
||||||
|
"explorerLayout": "dual",
|
||||||
|
"explorerOnePaneCopy": "clip",
|
||||||
|
"explorerOrientV3": true,
|
||||||
|
"explorerOrientation": "side",
|
||||||
|
"explorerPaths": [],
|
||||||
|
"explorerProgressHidden": true,
|
||||||
|
"explorerRemoteThumbCacheMb": 207,
|
||||||
|
"explorerRemoteThumbs": "wifi",
|
||||||
|
"explorerRetapCollapse": true,
|
||||||
|
"explorerRootsInTree": false,
|
||||||
|
"explorerRowSpacing": "normal",
|
||||||
|
"explorerRulesV1": true,
|
||||||
|
"explorerShowHidden": true,
|
||||||
|
"explorerSort": "name",
|
||||||
|
"explorerSortByKind": true,
|
||||||
|
"explorerSortDesc": true,
|
||||||
|
"explorerSortPerFolder": true,
|
||||||
|
"explorerStyle": "xplore",
|
||||||
|
"explorerSwipePanes": true,
|
||||||
|
"explorerTextScale": 107,
|
||||||
|
"explorerToolbar": "middle",
|
||||||
|
"explorerV2": true,
|
||||||
|
"externalBrowser": "x_system",
|
||||||
|
"externalPlayers": {},
|
||||||
|
"folderBookmarks": [],
|
||||||
|
"ftpServers": [],
|
||||||
|
"homeUrl": "x_https://www.youtube.com/",
|
||||||
|
"imageExts": [
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"gif",
|
||||||
|
"webp",
|
||||||
|
"bmp"
|
||||||
|
],
|
||||||
|
"lastRunVersion": "x_",
|
||||||
|
"lastUpdateCheck": "x_",
|
||||||
|
"lastUpdateCheckVersion": "x_",
|
||||||
|
"liveSyncIntervalSec": 37,
|
||||||
|
"liveSyncOnStart": "auto",
|
||||||
|
"liveSyncPairs": [],
|
||||||
|
"loginCookieSites": [
|
||||||
|
"youtube.com",
|
||||||
|
"google.com",
|
||||||
|
"instagram.com",
|
||||||
|
"x.com",
|
||||||
|
"twitter.com",
|
||||||
|
"naver.com"
|
||||||
|
],
|
||||||
|
"maxParallelDownloads": 10,
|
||||||
|
"maxParallelJobs": 12,
|
||||||
|
"migrationNotice": false,
|
||||||
|
"minimizeToTray": false,
|
||||||
|
"mkvCompactList": "auto",
|
||||||
|
"mkvExisting": "ask",
|
||||||
|
"mkvOutputRoot": null,
|
||||||
|
"moveTargets": [],
|
||||||
|
"navOrder": [
|
||||||
|
"mkv",
|
||||||
|
"browser",
|
||||||
|
"tor",
|
||||||
|
"explorer",
|
||||||
|
"rsync",
|
||||||
|
"downloads",
|
||||||
|
"aiimage"
|
||||||
|
],
|
||||||
|
"openFileAction": "play",
|
||||||
|
"openFileWindow": "same",
|
||||||
|
"openSubtitlesSaved": {
|
||||||
|
"key": true,
|
||||||
|
"password": true,
|
||||||
|
"user": true
|
||||||
|
},
|
||||||
|
"orientationV2": true,
|
||||||
|
"playlistMode": "series",
|
||||||
|
"preferredSubtitleLanguage": "x_",
|
||||||
|
"readerAutoSeconds": 10,
|
||||||
|
"readerBrightness": 0.5,
|
||||||
|
"readerFit": "page",
|
||||||
|
"readerGallery": true,
|
||||||
|
"readerRtl": true,
|
||||||
|
"readerSplit": "auto",
|
||||||
|
"readerSystemBars": false,
|
||||||
|
"readerVolumeKeys": true,
|
||||||
|
"recycleOnDelete": false,
|
||||||
|
"rememberPasswords": false,
|
||||||
|
"remoteIdleSeconds": 60,
|
||||||
|
"robocopyLinksV1": true,
|
||||||
|
"robocopyOptions": "x_/E /COPY:DAT /DCOPY:T /R:2 /W:2",
|
||||||
|
"rollbackKeepModels": "",
|
||||||
|
"rootEnabled": true,
|
||||||
|
"rsyncOptions": "x_-avPog",
|
||||||
|
"rsyncPath": "x_",
|
||||||
|
"rsyncPaths": [],
|
||||||
|
"rsyncSource": "download",
|
||||||
|
"runInBackground": true,
|
||||||
|
"screenOrientation": "auto",
|
||||||
|
"sftpServers": [],
|
||||||
|
"shizukuEnabled": true,
|
||||||
|
"showHotkey": "x_Ctrl+Shift+X",
|
||||||
|
"showLog": false,
|
||||||
|
"skippedVersion": "x_",
|
||||||
|
"smbRequireSecure": true,
|
||||||
|
"smbServers": [],
|
||||||
|
"startMenuShortcut": false,
|
||||||
|
"startScreen": "x_home",
|
||||||
|
"subtitleScale": 1.5,
|
||||||
|
"swipeNav": false,
|
||||||
|
"swipeWrap": false,
|
||||||
|
"syncStopToast": false,
|
||||||
|
"torHomeUrl": "x_https://check.torproject.org/",
|
||||||
|
"torIdleMinutes": 9,
|
||||||
|
"torJavaScript": false,
|
||||||
|
"torKeepData": true,
|
||||||
|
"torNoticeSeen": true,
|
||||||
|
"torrentStallSeconds": 60,
|
||||||
|
"tripleTapAction": "move",
|
||||||
|
"uiBrightness": "dark",
|
||||||
|
"uiLanguage": "x_system",
|
||||||
|
"uiLanguagesAdded": [],
|
||||||
|
"uiScale": 1.5,
|
||||||
|
"uiScaleDefault": 1.5,
|
||||||
|
"vaultAutoLockSeconds": 67,
|
||||||
|
"vaultEnabled": true,
|
||||||
|
"vaultMasterCopy": true,
|
||||||
|
"webJavaScript": false,
|
||||||
|
"webPauseOnLeave": false,
|
||||||
|
"webTranslate": true,
|
||||||
|
"webdavServers": [],
|
||||||
|
"wifiShareFolder": "x_",
|
||||||
|
"wifiShareMinutes": 30,
|
||||||
|
"wifiShareTailscale": false,
|
||||||
|
"youtubeAdHide": false,
|
||||||
|
"youtubeAdSkip": false,
|
||||||
|
"ytContainer": "mp4",
|
||||||
|
"ytCookiesBrowser": "x_*internal",
|
||||||
|
"ytCookiesFile": "x_",
|
||||||
|
"ytExpandPlaylists": false,
|
||||||
|
"ytPreferH264": true,
|
||||||
|
"ytQuality": "best",
|
||||||
|
"zipComic": false,
|
||||||
|
"zipComicV2": true,
|
||||||
|
"zipLevel": 6
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jj_mkvmaker/app/settings.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// 설정 A 약속 시험: 설정 파일 (settings.json) 을 읽고 다시 쓴 결과가 나누기 전 판과 같다 (키 순서를 뺀 바이트 - 키를 정렬해 견줌).
|
||||||
|
/// - 합성: 모든 키의 값을 바꾼 입력 (test/fixtures/settings_golden_in.json) → 나누기 전 판의 출력 (…_out.json)
|
||||||
|
/// - 사용자 파일: JJ_USER_SETTINGS_EXPECT 가 있으면 사용자 설정 파일 (com.jj\jj_mkvmaker\settings.json) 을 시험 폴더에 복사해 읽기만 하고,
|
||||||
|
/// 나누기 전 판이 같은 파일로 낸 출력 (scratch, 커밋하지 않음) 과 견준다. 사용자 파일에는 쓰지 않는다
|
||||||
|
/// JJ_SETTINGS_GOLDEN_WRITE=1 이면 견주지 않고 기준을 만든다 (나누기 전 판에서 한 번)
|
||||||
|
void main() {
|
||||||
|
final write = Platform.environment['JJ_SETTINGS_GOLDEN_WRITE'] == '1';
|
||||||
|
const fixtures = 'test/fixtures';
|
||||||
|
|
||||||
|
/// 키를 정렬한 JSON (키 순서는 분야를 옮기면 바뀐다 - 값 · 키 집합만 견줌)
|
||||||
|
String canonical(Object? v) {
|
||||||
|
Object? sort(Object? x) => x is Map
|
||||||
|
? {for (final k in (x.keys.map((e) => '$e').toList()..sort())) k: sort(x[k])}
|
||||||
|
: x is List
|
||||||
|
? [for (final e in x) sort(e)]
|
||||||
|
: x;
|
||||||
|
return const JsonEncoder.withIndent(' ').convert(sort(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
String roundTrip(String text) => canonical(AppSettings.fromJson(jsonDecode(text) as Map<String, dynamic>).toJson());
|
||||||
|
|
||||||
|
/// 모든 키의 값을 바꾼 입력 (bool 은 반대, 수는 +7, 글은 앞에 x_, 안쪽 묶음도) - 읽기의 검사 · 고침도 함께 지나게
|
||||||
|
Object? change(Object? v) => switch (v) {
|
||||||
|
final bool b => !b,
|
||||||
|
final int n => n + 7,
|
||||||
|
final double d => d + 0.5,
|
||||||
|
final String s => 'x_$s',
|
||||||
|
final Map<dynamic, dynamic> m => {for (final e in m.entries) '${e.key}': change(e.value)},
|
||||||
|
_ => v,
|
||||||
|
};
|
||||||
|
Map<String, Object?> mutated() => {for (final e in AppSettings().toJson(plainSecrets: true).entries) e.key: change(e.value)};
|
||||||
|
|
||||||
|
test('합성: 모든 키를 바꾼 설정을 읽고 쓰면 나누기 전 판과 같다 · 처음 값도', () {
|
||||||
|
final inFile = File(p.join(fixtures, 'settings_golden_in.json'));
|
||||||
|
final outFile = File(p.join(fixtures, 'settings_golden_out.json'));
|
||||||
|
final defFile = File(p.join(fixtures, 'settings_golden_default.json'));
|
||||||
|
if (write) {
|
||||||
|
Directory(fixtures).createSync(recursive: true);
|
||||||
|
inFile.writeAsStringSync(const JsonEncoder.withIndent(' ').convert(mutated()));
|
||||||
|
outFile.writeAsStringSync(roundTrip(inFile.readAsStringSync()));
|
||||||
|
defFile.writeAsStringSync(canonical(AppSettings().toJson()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(roundTrip(inFile.readAsStringSync()), outFile.readAsStringSync());
|
||||||
|
expect(canonical(AppSettings().toJson()), defFile.readAsStringSync());
|
||||||
|
});
|
||||||
|
|
||||||
|
final expectPath = Platform.environment['JJ_USER_SETTINGS_EXPECT'];
|
||||||
|
test('사용자 설정 파일 (복사본): 읽고 쓰면 나누기 전 판과 같다', () {
|
||||||
|
final user = File(p.join(Platform.environment['APPDATA'] ?? '', 'com.jj', 'jj_mkvmaker', 'settings.json'));
|
||||||
|
final tmp = Directory.systemTemp.createTempSync('jj_settings_user_');
|
||||||
|
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||||
|
final copy = user.copySync(p.join(tmp.path, 'settings.json'));
|
||||||
|
final got = roundTrip(copy.readAsStringSync());
|
||||||
|
if (write) {
|
||||||
|
File(expectPath!).writeAsStringSync(got);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(got, File(expectPath!).readAsStringSync());
|
||||||
|
}, skip: expectPath == null ? '사용자 파일 기준 (JJ_USER_SETTINGS_EXPECT) 이 없으면 건너뜀 - 이 PC 에서만' : null);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue