JJ_MKVMaker/lib/app/settings.dart

684 lines
29 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:io';
import 'package:path/path.dart' as p;
import 'components.dart';
import '../core/reader_sources.dart' show defaultImageExtensions;
import '../core/download_detect.dart';
import '../core/encode_options.dart';
import '../core/playlist.dart';
import '../core/ftp.dart';
import '../core/sftp.dart';
import '../core/smb.dart';
import '../core/webdav.dart';
import '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart';
import 'settings/records.dart';
import 'settings/settings_group.dart';
import 'settings/explorer_tool_settings.dart';
import 'settings/vault_settings.dart';
import 'settings/run_settings.dart';
import 'settings/browser_settings.dart';
import 'settings/download_settings.dart';
import 'settings/reader_settings.dart';
import 'settings/ai_image_settings.dart';
import 'settings/copy_settings.dart';
import 'settings/tor_settings.dart';
import 'settings/network_settings.dart';
import 'settings/explorer_settings.dart';
export 'settings/records.dart';
export 'settings/settings_group.dart';
export 'settings/settings_store.dart';
export 'settings/explorer_tool_settings.dart';
export 'settings/vault_settings.dart';
export 'settings/run_settings.dart';
export 'settings/browser_settings.dart';
export 'settings/download_settings.dart';
export 'settings/reader_settings.dart';
export 'settings/ai_image_settings.dart';
export 'settings/copy_settings.dart';
export 'settings/tor_settings.dart';
export 'settings/network_settings.dart';
export 'settings/explorer_settings.dart';
/// 환경 설정 (settings.json 에 저장)
class AppSettings {
/// MKV·자막 저장 위치. null 이면 동영상이 있는 폴더 아래 jj_mkv
String? mkvOutputRoot;
/// MKV 세부 정보 오른쪽 아래의 이동 버튼들 (표시 이름 · 폴더). 세 번 누르기는 첫 번째 버튼의 폴더로.
/// 비어 있으면 [이동] 을 처음 누를 때 폴더를 골라 하나 만든다.
List<MoveTarget> moveTargets = [];
/// 73: 파일 탐색기 · Rsync 즐겨찾기 (폴더, Rsync 는 짝)
List<FolderBookmark> folderBookmarks = [];
/// AI 자막: 매번 설정 창을 띄울지 (끄면 마지막 설정으로 바로 시작)
bool askAiOptions = true;
/// OpenSubtitles (인터넷 자막 검색) - 키는 필수, 아이디·비밀번호는 선택 (받기 횟수 늘어남)
String openSubtitlesKey = '';
String openSubtitlesUser = '';
String openSubtitlesPassword = '';
/// 동시에 만드는 MKV 수 (0 = 무제한)
int maxParallelJobs = 5;
/// 메인 화면 아래 작업 기록 창 보이기
bool showLog = true;
/// 폰 세로 (좁은 화면) MKV 목록 크기: 'auto' 목록은 내용만큼 · 자세히 칸에 최소 높이 (처음 값) · 'small' · 'large' (관리자 판단 10/17)
String mkvCompactList = 'auto';
/// 처음 화면: 'home' = 지금처럼 MKV 화면 (기본), 'browser' = 웹 브라우저 ([browser.homeUrl]), 'files' = 파일 탐색기
String startScreen = 'home';
/// 화면 (글자 · 버튼) 크기 배율. [uiScale] 은 지금 크기 (위쪽 막대의 − · + 로 바꿈),
/// [uiScaleDefault] 는 기본 크기 (환경 설정에서 정함, 가운데 숫자를 누르면 이 크기로 돌아감)
double uiScale = 1.0;
double uiScaleDefault = 1.0;
// 18: 50 ~ 250% (사용자 결정)
static const double uiScaleMin = 0.5, uiScaleMax = 2.5;
/// X-plore 비교 5-1 ⑦ 화면 밝기 (관리자 지시 10/21): 'dark' 어둡게 (처음 값 - 예전 그대로) · 'light' 밝게 · 'system' 기기 따라.
/// 다 끝날 때까지 설정에 "시험" 표시. 모르는 값은 어둡게
String uiBrightness = 'dark';
static const uiBrightnessModes = ['dark', 'light', 'system'];
static double clampUiScale(num? v) =>
((v ?? 1.0).toDouble().clamp(uiScaleMin, uiScaleMax) * 20).round() / 20;
/// 탐색기에서 동영상을 열었을 때 (더블클릭 · 연결 프로그램): 'play' 바로 재생 / 'add' 편집 목록에 추가
String openFileAction = 'play';
/// Android 화면 방향: 'auto' 기기 방향 따라 (기본) / 'landscape' 가로 고정 / 'portrait' 세로 고정.
/// 값이 없거나 틀리면 자동 (제한 없는 쪽). 고정은 사용자가 환경 설정에서 고를 때만.
String screenOrientation = 'auto';
/// 화면 언어 (ko · en · ja · zh-Hans, 또는 더한 언어)
String uiLanguage = 'system';
/// 앱 아이콘 (appIconIds 중 하나: 'yellow' 기본 · 'black' · 'film_jj' · 'film' · 'blue')
String appIcon = 'yellow';
/// 더한 화면 언어 (AI 로 자동 번역한 사전이 설정 폴더 l10n 에 있음)
List<String> uiLanguagesAdded = [];
/// 프로그램이 켜져 있을 때 탐색기에서 연 동영상 재생: 'same' 켜져 있는 창에서 / 'new' 새 재생 창
String openFileWindow = 'same';
// ── 네트워크 · DNS (사용자 요청 10/10: "DNS 주소를 강제로 내가 설정한 주소로", 기본 8.8.8.8 · 보조 8.8.4.4 - 관리자 판단) ──
// ── Tor 브라우저 (사용자 요청 10/10) ──
// ── 파일 탐색기 (X-plore 참고) ──
// ── 복사 · 이동 (파일 탐색기) · rsync · lsync (Rsync 화면) ──
/// P0: MKV 를 만들 때 결과 MKV 가 이미 있으면 - 'ask' 묻기 (처음 값) · 'overwrite' 덮어쓰기 · 'skip' 건너뛰기 · 'rename' 다른 이름.
/// 덮어쓰기여도 다 만든 뒤에만 바꿔 넣는다 (도중에 취소 · 실패하면 예전 것 그대로)
String mkvExisting = 'ask';
/// 설치한 (켠) 컴포넌트 id (app/components.dart). 처음엔 기본으로 들어 있는 것 모두
List<String> components = AppComponent.defaultInstalled;
/// 화면 순서 (위쪽 이동 버튼 · 화면 가운데를 좌우로 밀어 이동): 마지막 다음은 처음으로
List<String> navOrder = AppComponent.defaultOrder;
/// 화면 가운데를 좌우로 밀어 다음 · 이전 화면으로
bool swipeNav = true;
/// 그림 보기 (만화 보기) 대상 확장자 (파일 탐색기에서 두 번 누르면 보기로 연다)
List<String> imageExts = [...defaultImageExtensions];
// ── 102: 사용자에게 물어 정한 것 · 관리자 1차 판단 (기본값 = 그 답) ──
/// 66: 좌우로 밀 때 끝에서 처음으로 (끄면 끝에서 멈춤)
bool swipeWrap = true;
/// 저장 공간 정리 창에서 미리 체크할 묶음 (작업 임시 파일 · 받다 만 다운로드)
List<String> cleanupPrechecked = ['work', 'download'];
/// 19: 업데이트로 바뀐 기본값을 켤 때 한 번 알림
bool migrationNotice = true;
/// 34: Rsync · 동기화에서 원본이 대상 안 (안쪽 → 바깥) 을 허용 (지우기가 있을 때만 막음). 끄면 늘 막음
bool allowInnerToOuter = true;
/// 40 · 54: 비밀번호를 이 기기의 안전 저장소에 기억 (끄면 저장하지 않고 앱을 켤 때마다 다시 넣음)
bool rememberPasswords = true;
/// 65 (Windows): 탐색기에서 지우면 휴지통으로 (끄면 늘 영구 삭제로 묻는다)
bool recycleOnDelete = true;
/// 47: MKV 목록에서 세 번 누르면 'move' (확인 뒤 이동 폴더로) · 'none' (아무것도 안 함)
String tripleTapAction = 'move';
/// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …)
String preferredSubtitleLanguage = '';
/// 55: 로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때 'ask' (매번 묻기) · 'fetch' (늘 받아서) · 'url' (늘 주소로)
String davExternalOpen = 'ask';
/// 39: 켤 때 "모든 파일 접근" 안내를 한 번 보였는지 (그 뒤로는 파일 탐색기 안의 안내로)
bool allFilesHintShown = false;
/// 내장 플레이어 자막 글자 크기 배율 (0.5 ~ 2.5, 기본 1)
double subtitleScale = 1.0;
/// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만.
List<DavServer> webdavServers = [];
/// 88: SFTP 서버 (WebDAV 옆 탭). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
List<SftpServer> sftpServers = [];
/// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다
List<SmbServer> smbServers = [];
/// 보안 검토 6 (관리자 결정 10/18): SMB 서명이나 암호화가 되지 않는 연결은 막는다. 처음 끔 (오래된 NAS 가 붙지 않을 수 있어서).
/// 꺼 두어도 서명 · 암호화가 안 되는 서버는 탐색기 탭에 ⚠
bool smbRequireSecure = false;
/// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다)
List<FtpServer> ftpServers = [];
/// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초).
/// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60
int remoteIdleSeconds = RemoteIdle.defaultSeconds;
// ───────── 123 · 121: AI 그림 · 해상도 올리기 ─────────
// 121: 해상도 올리기
/// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음
String webViewDataDir = '';
/// 동영상 하나를 재생할 때 재생 목록 (기본: 같은 시리즈)
PlaylistMode playlistMode = PlaylistMode.series;
/// 확장자별 재생 프로그램 (없으면 내장 플레이어): 'system' 또는 실행 파일 경로
Map<String, String> externalPlayers = {};
/// 마지막 인코딩 설정
EncodeSettings encode = const EncodeSettings();
/// 마지막 AI 자막 설정
String aiSource = 'und';
List<String> aiTargets = ['ko', 'en', 'ja'];
String aiWhisper = 'whisper-base';
AppSettings();
/// 파일 탐색기 (X-plore 참고)
final explorer = ExplorerSettings();
/// 네트워크 · DNS (사용자 요청 10/10)
final network = NetworkSettings();
/// Tor 釉뚮씪?곗? (?ъ슜???붿껌 10/10)
final tor = TorBrowserSettings();
/// 복사 · 이동 (파일 탐색기) · rsync · 실시간 동기화 (Rsync 화면)
final copy = CopySettings();
/// AI 그림 · 해상도 올리기 (123 · 121)
final aiImage = AiImageSettings();
/// 보기 화면 (그림 · 만화 · PDF · 문서)
final reader = ReaderSettings();
/// 다운로드 · YouTube (yt-dlp · aria2)
final download = DownloadSettings();
/// 웹 브라우저 (앱 안 · 외부)
final browser = BrowserSettings();
/// 실행 · 종료 · 업데이트
final run = RunSettings();
/// 암호 보관함
final vault = VaultSettings();
/// 탐색기 도구 - 시스템 폴더 (Shizuku) · 루트 · Wi-Fi 공유 · ZIP 묶기
final tools = ExplorerToolSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) {
final j = <String, Object?>{
// 이 판이 모르는 항목 (새 판이 쓴 것 등) 은 지우지 않고 그대로 (40)
...stripSecrets(extra),
'mkvOutputRoot': mkvOutputRoot,
'moveTargets': [for (final t in moveTargets) t.toJson()],
'folderBookmarks': [for (final b in folderBookmarks) b.toJson()],
'askAiOptions': askAiOptions,
// 비밀 값은 안전 저장소에 (54). 파일에는 저장해 두었는지만
'openSubtitlesSaved': {
'key': openSubtitlesKey.isNotEmpty,
'user': openSubtitlesUser.isNotEmpty,
'password': openSubtitlesPassword.isNotEmpty,
},
if (plainSecrets) ...{
'openSubtitlesKey': openSubtitlesKey,
'openSubtitlesUser': openSubtitlesUser,
'openSubtitlesPassword': openSubtitlesPassword,
},
'cookieScopeV2': true,
'maxParallelJobs': maxParallelJobs,
'showLog': showLog,
'mkvCompactList': mkvCompactList,
'startScreen': startScreen,
'uiScale': uiScale,
'uiScaleDefault': uiScaleDefault,
'uiBrightness': uiBrightness,
'openFileAction': openFileAction,
'screenOrientation': screenOrientation,
// 기본이 가로 고정이던 때의 값은 한 번 자동으로 옮긴다 (사용자가 정한 적 없는 가로 고정)
'orientationV2': true,
'uiLanguage': uiLanguage,
'appIcon': appIcon,
'uiLanguagesAdded': uiLanguagesAdded,
'openFileWindow': openFileWindow,
'mkvExisting': mkvExisting,
'components': components,
'aiImageV1': true,
'robocopyLinksV1': true,
'navOrder': navOrder,
'swipeNav': swipeNav,
'imageExts': imageExts,
'swipeWrap': swipeWrap,
'cleanupPrechecked': cleanupPrechecked,
'migrationNotice': migrationNotice,
'allowInnerToOuter': allowInnerToOuter,
'rememberPasswords': rememberPasswords,
'recycleOnDelete': recycleOnDelete,
'tripleTapAction': tripleTapAction,
'preferredSubtitleLanguage': preferredSubtitleLanguage,
'davExternalOpen': davExternalOpen,
// 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더
'allFilesHint2': allFilesHintShown,
'subtitleScale': subtitleScale,
'webdavServers': [
for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password},
],
// 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음
'sftpServers': [for (final x in sftpServers) x.toJson()],
'smbServers': [for (final x in smbServers) x.toJson()],
'smbRequireSecure': smbRequireSecure,
'remoteIdleSeconds': remoteIdleSeconds,
'ftpServers': [for (final x in ftpServers) x.toJson()],
'playlistMode': playlistMode.name,
'externalPlayers': externalPlayers,
'encode': {
'codec': encode.codec.name,
'resolution': encode.resolution.name,
'quality': encode.quality.name,
'frame': encode.frame.name,
'fit': encode.fit.name,
'rotate': encode.rotate.name,
'brightness': encode.brightness,
'contrast': encode.contrast,
'saturation': encode.saturation,
'temperature': encode.temperature,
},
'aiSource': aiSource,
'aiTargets': aiTargets,
'aiWhisper': aiWhisper,
};
for (final g in groups) {
g.write(j, plainSecrets: plainSecrets);
}
return j;
}
/// 색 보정 값 (-100 ~ 100)
static int _adj(Object? v) => ((v as num?)?.round() ?? 0).clamp(-100, 100);
factory AppSettings.fromJson(Map<String, dynamic> j) {
T pick<T extends Enum>(List<T> values, Object? name, T fallback) =>
values.firstWhere((v) => v.name == name, orElse: () => fallback);
final e = (j['encode'] as Map?) ?? const {};
final s = AppSettings()
..mkvOutputRoot = j['mkvOutputRoot'] as String?
..folderBookmarks = [for (final x in (j['folderBookmarks'] as List?) ?? const []) ?FolderBookmark.fromJson(x)]
..moveTargets = [
for (final x in (j['moveTargets'] as List?) ?? const [])
if (x is Map && x['dir'] is String) MoveTarget.fromJson(x),
// 예전 판의 이동 폴더 하나 → "이동" 버튼으로
if (j['moveTargets'] == null && j['moveTargetDir'] is String) MoveTarget('이동', j['moveTargetDir'] as String),
]
..askAiOptions = j['askAiOptions'] as bool? ?? true
..openSubtitlesKey = j['openSubtitlesKey'] as String? ?? ''
..openSubtitlesUser = j['openSubtitlesUser'] as String? ?? ''
..openSubtitlesPassword = j['openSubtitlesPassword'] as String? ?? ''
..maxParallelJobs = (j['maxParallelJobs'] as num?)?.toInt() ?? 5
..showLog = j['showLog'] as bool? ?? true
..mkvCompactList = const ['auto', 'small', 'large'].contains(j['mkvCompactList']) ? j['mkvCompactList'] as String : 'auto'
..startScreen = j['startScreen'] as String? ?? 'home'
..uiScale = clampUiScale(j['uiScale'] as num?)
..uiScaleDefault = clampUiScale(j['uiScaleDefault'] as num?)
..uiBrightness = uiBrightnessModes.contains(j['uiBrightness']) ? j['uiBrightness'] as String : 'dark'
..openFileAction = j['openFileAction'] == 'add' ? 'add' : 'play'
..uiLanguage = j['uiLanguage'] as String? ?? 'ko'
..appIcon = appIconOf(j['appIcon'] as String?)
..uiLanguagesAdded = [for (final x in (j['uiLanguagesAdded'] as List?) ?? const []) '$x']
..screenOrientation = j['orientationV2'] == true && const ['landscape', 'portrait', 'auto'].contains(j['screenOrientation'])
? j['screenOrientation'] as String
: 'auto'
..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same'
..mkvExisting = const ['rename', 'overwrite', 'skip'].contains(j['mkvExisting']) ? j['mkvExisting'] as String : 'ask'
..components = j['components'] is List
? [
// 225: 이 판이 모르는 구성 요소 (새 판의 Tor 등) 도 지우지 않고 그대로 둔다 - 예전 판을 거쳐 다시 올려도 켠 것이 남게 (40 과 같은 원칙).
// 화면 · 버튼은 아는 것만 쓴다 (AppComponent.pages · contains)
for (final x in j['components'] as List) if (x is String && x.isNotEmpty) x,
// 123: 새 화면 "AI 그림" 은 예전 설정에도 한 번 켜서 넣는다 (끄기: 환경 설정 > 컴포넌트)
if (j['aiImageV1'] != true && !(j['components'] as List).contains('aiimage')) 'aiimage',
]
: AppComponent.defaultInstalled
..navOrder = j['navOrder'] is List
// 225: 모르는 화면 id 도 순서에 그대로 (그 판으로 돌아가면 사용자가 정한 순서대로)
? [for (final x in j['navOrder'] as List) if (x is String && x.isNotEmpty) x]
: AppComponent.defaultOrder
..swipeNav = j['swipeNav'] as bool? ?? true
..imageExts = j['imageExts'] is List
? [for (final x in j['imageExts'] as List) '$x'.toLowerCase()]
: [...defaultImageExtensions]
..swipeWrap = j['swipeWrap'] != false
..cleanupPrechecked = j['cleanupPrechecked'] is List
? [for (final x in j['cleanupPrechecked'] as List) '$x']
: ['work', 'download']
..migrationNotice = j['migrationNotice'] != false
..allowInnerToOuter = j['allowInnerToOuter'] != false
..rememberPasswords = j['rememberPasswords'] != false
..recycleOnDelete = j['recycleOnDelete'] != false
..tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move'
..preferredSubtitleLanguage = j['preferredSubtitleLanguage'] is String ? j['preferredSubtitleLanguage'] as String : ''
..davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask'
..allFilesHintShown = j['allFilesHint2'] == true
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5)
..webdavServers = [
for (final x in (j['webdavServers'] as List?) ?? const [])
if (x is Map) DavServer.fromJson(x),
]
..sftpServers = [
for (final x in (j['sftpServers'] as List?) ?? const [])
if (x is Map) SftpServer.fromJson(x),
]
..smbRequireSecure = j['smbRequireSecure'] is bool ? j['smbRequireSecure'] as bool : false
..remoteIdleSeconds = RemoteIdle.choices.contains(j['remoteIdleSeconds']) ? j['remoteIdleSeconds'] as int : RemoteIdle.defaultSeconds
..smbServers = [
for (final x in (j['smbServers'] as List?) ?? const [])
if (x is Map) SmbServer.fromJson(x),
]
..ftpServers = [
for (final x in (j['ftpServers'] as List?) ?? const [])
if (x is Map) FtpServer.fromJson(x),
]
..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series)
..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>()
..encode = EncodeSettings(
codec: pick(VideoCodecChoice.values, e['codec'], VideoCodecChoice.copy),
resolution: pick(ResolutionChoice.values, e['resolution'], ResolutionChoice.original),
quality: pick(QualityChoice.values, e['quality'], QualityChoice.normal),
frame: pick(FrameChoice.values, e['frame'], FrameChoice.original),
fit: pick(FitChoice.values, e['fit'], FitChoice.fill),
rotate: pick(RotateChoice.values, e['rotate'], RotateChoice.none),
brightness: _adj(e['brightness']),
contrast: _adj(e['contrast']),
saturation: _adj(e['saturation']),
temperature: _adj(e['temperature']),
)
..aiSource = j['aiSource'] as String? ?? 'und'
..aiTargets = (j['aiTargets'] as List?)?.cast<String>() ?? ['ko', 'en', 'ja']
..aiWhisper = j['aiWhisper'] as String? ?? 'whisper-base';
// 분야를 먼저 읽는다 - 바뀐 기본값 알림 ([_migrated]) 이 분야의 값을 본다
for (final g in s.groups) {
g.read(j);
}
s.migrated = _migrated(j, s);
s.extra = Map.of(j);
return s;
}
/// 업데이트하면서 예전 기본값을 새 기본값으로 옮긴 것 (예전 설정 파일에 그 값이 실제로 있었을 때만).
/// 앱이 켜질 때 한 번 알리고 (무엇이 바뀌었는지 · 환경 설정에서 되돌릴 수 있다는 것) 비운다. 저장하지 않는다.
List<String> migrated = [];
/// 읽은 설정 파일 그대로 (이 판이 모르는 항목을 저장할 때 지우지 않게)
Map<String, dynamic> extra = {};
/// 설정 파일에 평문으로 두면 안 되는 항목 (54)
static const secretKeys = ['openSubtitlesKey', 'openSubtitlesUser', 'openSubtitlesPassword'];
/// 설정 JSON 에서 비밀 값을 뺀 것 (.bak · 모르는 항목 보존용)
static Map<String, dynamic> stripSecrets(Map<String, dynamic> j) {
final r = Map<String, dynamic>.of(j);
for (final k in secretKeys) {
r.remove(k);
}
final servers = r['webdavServers'];
if (servers is List) {
r['webdavServers'] = [
for (final x in servers) x is Map ? (Map.of(x)..remove('password')) : x,
];
}
final sftp = r['sftpServers'];
if (sftp is List) {
r['sftpServers'] = [
for (final x in sftp) x is Map ? (Map.of(x)..remove('password')..remove('privateKey')..remove('keyPassphrase')) : x,
];
}
final smb = r['smbServers'];
if (smb is List) {
r['smbServers'] = [
for (final x in smb) x is Map ? (Map.of(x)..remove('password')) : x,
];
}
final ftp = r['ftpServers'];
if (ftp is List) {
r['ftpServers'] = [
for (final x in ftp) x is Map ? (Map.of(x)..remove('password')) : x,
];
}
final ai = r['aiServices'];
if (ai is List) {
r['aiServices'] = [
for (final x in ai) x is Map ? (Map.of(x)..remove('apiKey')) : x,
];
}
return r;
}
static List<String> _migrated(Map<String, dynamic> j, AppSettings s) => [
if (j['orientationV2'] != true && j['screenOrientation'] == 'landscape' && s.screenOrientation == 'auto')
'orientation',
if (j['explorerV2'] != true && j['explorerLayout'] == 'dual' && s.explorer.layout == 'auto') 'explorerLayout',
if (j['explorerV2'] != true && j['explorerClick'] == 'select' && s.explorer.click == 'open') 'explorerClick',
if (j['explorerOrientV3'] != true && j['explorerOrientation'] == 'auto') 'explorerOrientation',
if (j['aiImageV1'] != true && j['components'] is List && !(j['components'] as List).contains('aiimage')) 'aiImage',
// 53 · 28: 앱 안 브라우저 쿠키를 쓰던 사람에게 넘기는 사이트가 넓어졌음을 한 번 알린다
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.run.inBackground && Platform.isAndroid)
'background',
// 193: 그 설정이 생기기 전 판 (설정 파일은 있는데 그 항목이 없음 - 예: 10.03) 에서 올라온 사람이 바로 겪는 동작 변화.
// 예전 판에는 키가 없어 위의 "예전 값 → 새 값" 조건에 걸리지 않았다 (P11: 탐색기에서 한 번 눌렀는데 바로 열려 놀람)
if (j.isNotEmpty && !j.containsKey('explorerClick') && s.explorer.click == 'open') 'explorerClickNew',
// E3 (10/22): 선택 규칙의 처음 값으로 옮긴 것 - 규칙 이전 판에서 올라온 사람에게 한 번
if (j.isNotEmpty && j['explorerRulesV1'] != true && j['explorerAlwaysMarks'] != false) 'explorerMarksRule',
if (j.isNotEmpty && j['explorerRulesV1'] != true && j['explorerRetapCollapse'] != true) 'explorerRetapRule',
if (j.isNotEmpty && j['explorerRulesV1'] != true && Platform.isAndroid && j['explorerClick'] != 'select')
'explorerClickRule',
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.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: 예전 판을 쓰던 사람 (설정 파일이 있는데 이 항목이 없음) 에게 복사 · 이동이 받는 쪽의 더 새 파일을 건너뛰게 바뀐 것을 한 번
if (j.isNotEmpty && !j.containsKey('copySkipNewer')) 'copySkipNewer',
// 193: robocopy 를 쓰던 사람 (Windows) 에게 링크 · junction 을 따라가지 않게 (/XJ) 바뀐 것을 한 번
if (j['robocopyLinksV1'] != true && Platform.isWindows && _usedRobocopy(j)) 'robocopyLinks',
];
/// 193: 예전 설정에서 robocopy 를 쓰고 있었는지 (복사 방법 · 기억한 복사 · 실시간 동기화 쌍)
static bool _usedRobocopy(Map<String, dynamic> j) {
bool robo(Object? x) => x is Map && x['method'] == 'robocopy';
return j['copyMethodFile'] == 'robocopy' ||
j['copyMethodFolder'] == 'robocopy' ||
((j['copyTasks'] as List?) ?? const []).any(robo) ||
((j['liveSyncPairs'] as List?) ?? const []).any(robo);
}
/// 다운로드 기본 위치: 프로그램 폴더 (쓸 수 없으면 사용자 다운로드 폴더)
String resolvedDownloadRoot() {
if (download.root != null && download.root!.isNotEmpty) return download.root!;
// Android: 프로그램 폴더가 없으므로 내장 저장소의 Download\JJ_MKVMaker
if (Platform.isAndroid) return '/storage/emulated/0/Download/JJ_MKVMaker';
final appDir = p.dirname(Platform.resolvedExecutable);
try {
final probe = File(p.join(appDir, '.jj_write_test'));
probe.writeAsStringSync('x');
probe.deleteSync();
return appDir;
} catch (_) {
final home = Platform.environment['USERPROFILE'] ?? Platform.environment['HOME'] ?? appDir;
return p.join(home, 'Downloads');
}
}
String get ytDlpDir => p.join(resolvedDownloadRoot(), 'jj_yt-dlp');
/// 앱 안 브라우저가 내보낸 YouTube · Google 쿠키 (cookies.txt). 없으면 빈 값
String get internalCookieFile {
if (webViewDataDir.isEmpty) return '';
final f = p.join(webViewDataDir, 'cookies_youtube.txt');
return File(f).existsSync() ? f : '';
}
/// 앱 안 브라우저의 쿠키가 있는 프로필 폴더 (로그인한 적이 없으면 빈 값)
String get internalCookieProfile {
if (webViewDataDir.isEmpty) return '';
final profile = p.join(webViewDataDir, 'EBWebView', 'Default');
final hasCookies = File(p.join(profile, 'Network', 'Cookies')).existsSync() ||
File(p.join(profile, 'Cookies')).existsSync();
return hasCookies ? profile : '';
}
String get aria2Dir => p.join(resolvedDownloadRoot(), 'jj_aria2');
}