From 8d26d1af1e2c0d88cedf0b521d96703fb7193fcc Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:37:49 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20A=20=EB=B6=84=EC=95=BC=201?= =?UTF-8?q?2:=20=EC=9B=90=EA=B2=A9=20=EC=84=9C=EB=B2=84=20(RemoteSettings)?= =?UTF-8?q?=20-=20WebDAV=20=C2=B7=20SFTP=20=C2=B7=20SMB=20=C2=B7=20FTP=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=89=AC=EB=8A=94=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=C2=B7=20WebDAV=20=EC=99=B8=EB=B6=80=20=EC=97=B4?= =?UTF-8?q?=EA=B8=B0,=20=ED=82=A4=207?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 비밀 값 처리 (webdavServers 의 plainSecrets · 새 비밀 값은 평문으로 쓰지 않음) 그대로, 설명 주석도 함께 옮김 Co-Authored-By: Claude Opus 5.5 --- integration_test/windows_ftp_shot_test.dart | 2 +- .../windows_sshkey_shot_test.dart | 2 +- lib/app/app_controller.dart | 12 ++-- lib/app/settings.dart | 57 ++------------- lib/app/settings/remote_settings.dart | 71 +++++++++++++++++++ lib/app/settings/settings_store.dart | 30 ++++---- lib/ui/dav_external.dart | 2 +- lib/ui/explorer/explorer_controller.dart | 8 +-- lib/ui/explorer/pane_header.dart | 2 +- lib/ui/ftp_settings.dart | 12 ++-- lib/ui/master_prompt.dart | 8 +-- lib/ui/settings/files_section.dart | 4 +- lib/ui/settings/media_sections.dart | 4 +- lib/ui/sftp_settings.dart | 14 ++-- lib/ui/smb_settings.dart | 14 ++-- lib/ui/webdav_settings.dart | 10 +-- test/confirm_actions_test.dart | 6 +- test/decisions_test.dart | 10 +-- test/explorer_webdav_test.dart | 10 +-- test/ftp_test.dart | 4 +- test/master_lock_test.dart | 16 ++--- test/remote_idle_test.dart | 8 +-- test/rsync_phsr_test.dart | 2 +- test/rsync_safety_test.dart | 2 +- test/secret_issue_test.dart | 6 +- test/secret_store_test.dart | 30 ++++---- test/settings_store_test.dart | 14 ++-- test/sftp_explorer_test.dart | 6 +- test/sftp_test.dart | 4 +- test/smb_test.dart | 10 +-- test/webdav_test.dart | 6 +- 31 files changed, 206 insertions(+), 180 deletions(-) create mode 100644 lib/app/settings/remote_settings.dart diff --git a/integration_test/windows_ftp_shot_test.dart b/integration_test/windows_ftp_shot_test.dart index af05fb7..3f1cba2 100644 --- a/integration_test/windows_ftp_shot_test.dart +++ b/integration_test/windows_ftp_shot_test.dart @@ -29,7 +29,7 @@ void main() { await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.ftpServers = [ + c.settings.remote.ftpServers = [ const FtpServer(id: 'f1', name: 'NAS FTPS', host: '192.168.0.15', user: 'jj'), const FtpServer(id: 'f2', name: '옛 공유기', host: '192.168.0.1', security: 'none'), ]; diff --git a/integration_test/windows_sshkey_shot_test.dart b/integration_test/windows_sshkey_shot_test.dart index 019ec32..56112e5 100644 --- a/integration_test/windows_sshkey_shot_test.dart +++ b/integration_test/windows_sshkey_shot_test.dart @@ -132,7 +132,7 @@ void main() { await shot('5_rejected', 'authorized_keys'); navKey.currentState!.popUntil((r) => r.isFirst); await settle(() => false, rounds: 10); - expect(c.settings.sftpServers, isEmpty, reason: '[저장] 은 누르지 않음'); + expect(c.settings.remote.sftpServers, isEmpty, reason: '[저장] 은 누르지 않음'); }, skip: skip, timeout: const Timeout(Duration(minutes: 3))); } } diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index cca845c..069daa4 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -157,15 +157,15 @@ class AppController extends ChangeNotifier { // 61: 기본 자막 언어 (MKV 기본 자막 트랙 · 플레이어가 먼저 켜는 자막) preferredSubtitleSetting = settings.preferredSubtitleLanguage; // WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다) - DavRegistry.configure(settings.webdavServers); + DavRegistry.configure(settings.remote.webdavServers); // 88: SFTP 서버 (sftp:// 경로) - SftpRegistry.configure(settings.sftpServers); + SftpRegistry.configure(settings.remote.sftpServers); // 88: Android SMB 서버 (smb:// 경로) - SmbRegistry.requireSecure = settings.smbRequireSecure; + SmbRegistry.requireSecure = settings.remote.smbRequireSecure; // 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기 - RemoteIdle.limit = Duration(seconds: settings.remoteIdleSeconds); - SmbRegistry.configure(settings.smbServers); - FtpRegistry.configure(settings.ftpServers); + RemoteIdle.limit = Duration(seconds: settings.remote.idleSeconds); + SmbRegistry.configure(settings.remote.smbServers); + FtpRegistry.configure(settings.remote.ftpServers); outputRootOverride = (settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null; } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 85d6094..62515b4 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -7,14 +7,10 @@ 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/remote_settings.dart'; import 'settings/explorer_tool_settings.dart'; import 'settings/vault_settings.dart'; import 'settings/run_settings.dart'; @@ -30,6 +26,7 @@ import 'settings/explorer_settings.dart'; export 'settings/records.dart'; export 'settings/settings_group.dart'; export 'settings/settings_store.dart'; +export 'settings/remote_settings.dart'; export 'settings/explorer_tool_settings.dart'; export 'settings/vault_settings.dart'; export 'settings/run_settings.dart'; @@ -239,8 +236,6 @@ class AppSettings { /// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …) String preferredSubtitleLanguage = ''; - /// 55: 로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때 'ask' (매번 묻기) · 'fetch' (늘 받아서) · 'url' (늘 주소로) - String davExternalOpen = 'ask'; /// 39: 켤 때 "모든 파일 접근" 안내를 한 번 보였는지 (그 뒤로는 파일 탐색기 안의 안내로) bool allFilesHintShown = false; @@ -249,25 +244,11 @@ class AppSettings { double subtitleScale = 1.0; - /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. - List webdavServers = []; - /// 88: SFTP 서버 (WebDAV 옆 탭). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) - List sftpServers = []; - /// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다 - List smbServers = []; - /// 보안 검토 6 (관리자 결정 10/18): SMB 서명이나 암호화가 되지 않는 연결은 막는다. 처음 끔 (오래된 NAS 가 붙지 않을 수 있어서). - /// 꺼 두어도 서명 · 암호화가 안 되는 서버는 탐색기 탭에 ⚠ - bool smbRequireSecure = false; - /// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) - List ftpServers = []; - /// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초). - /// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60 - int remoteIdleSeconds = RemoteIdle.defaultSeconds; // ───────── 123 · 121: AI 그림 · 해상도 올리기 ───────── @@ -352,8 +333,11 @@ class AppSettings { /// 탐색기 도구 - 시스템 폴더 (Shizuku) · 루트 · Wi-Fi 공유 · ZIP 묶기 final tools = ExplorerToolSettings(); + /// 원격 서버 (WebDAV · SFTP · SMB · FTP) + final remote = RemoteSettings(); + /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 - List get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools]; + List get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote]; /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 Map toJson({bool plainSecrets = false}) { @@ -406,19 +390,9 @@ class AppSettings { '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': { @@ -507,27 +481,8 @@ class AppSettings { ..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() ..encode = EncodeSettings( diff --git a/lib/app/settings/remote_settings.dart b/lib/app/settings/remote_settings.dart new file mode 100644 index 0000000..e6efedc --- /dev/null +++ b/lib/app/settings/remote_settings.dart @@ -0,0 +1,71 @@ + +import '../../core/webdav.dart'; +import '../../core/sftp.dart'; +import '../../core/smb.dart'; +import '../../core/ftp.dart'; +import '../../core/remote_idle.dart'; +import 'settings_group.dart'; + +/// 약속 (설정 A): 환경 설정 > 원격 서버 (WebDAV · SFTP · SMB · FTP) - 자기 키 (이름 · 처음 값 · 읽기 · 쓰기) 를 스스로 갖는다. +/// 설정 하나를 더할 때는 이 파일만 고친다. 저장 파일의 키 이름은 예전 그대로 (평평한 키) +class RemoteSettings implements SettingsGroup { + /// 55: 로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때 'ask' (매번 묻기) · 'fetch' (늘 받아서) · 'url' (늘 주소로) + String davExternalOpen = 'ask'; + + /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. + List webdavServers = []; + + /// 88: SFTP 서버 (WebDAV 옆 탭). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) + List sftpServers = []; + + /// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다 + List smbServers = []; + + /// 보안 검토 6 (관리자 결정 10/18): SMB 서명이나 암호화가 되지 않는 연결은 막는다. 처음 끔 (오래된 NAS 가 붙지 않을 수 있어서). + /// 꺼 두어도 서명 · 암호화가 안 되는 서버는 탐색기 탭에 ⚠ + bool smbRequireSecure = false; + + /// l: FTP · FTPS 서버. 비밀번호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) + List ftpServers = []; + + /// 관리자 판단 (10/20): 원격 서버 (FTP · SFTP · SMB · WebDAV) 받기 · 올리기에서 조각 사이를 기다리는 시간 (초). + /// 이 시간 동안 아무것도 오가지 않으면 실패 ("서버가 답하지 않습니다"). 30 · 60 (처음) · 120 · 300 - 그 밖의 값은 60 + int idleSeconds = RemoteIdle.defaultSeconds; + + @override + void write(Map j, {required bool plainSecrets}) { + j['webdavServers'] = [ + for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password}, + ]; + // 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음 + j['sftpServers'] = [for (final x in sftpServers) x.toJson()]; + j['smbServers'] = [for (final x in smbServers) x.toJson()]; + j['smbRequireSecure'] = smbRequireSecure; + j['ftpServers'] = [for (final x in ftpServers) x.toJson()]; + j['remoteIdleSeconds'] = idleSeconds; + j['davExternalOpen'] = davExternalOpen; + } + + @override + void read(Map j) { + davExternalOpen = const ['fetch', 'url'].contains(j['davExternalOpen']) ? j['davExternalOpen'] as String : 'ask'; + 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; + idleSeconds = 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), + ]; + } +} diff --git a/lib/app/settings/settings_store.dart b/lib/app/settings/settings_store.dart index 3bba188..78b3efd 100644 --- a/lib/app/settings/settings_store.dart +++ b/lib/app/settings/settings_store.dart @@ -65,21 +65,21 @@ class SettingsStore { static Map _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) + for (final x in s.remote.webdavServers) if (x.password.isNotEmpty) _davKey(x.id): x.password, for (final x in s.aiImage.services) if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey, - for (final x in s.sftpServers) ...{ + for (final x in s.remote.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) + for (final x in s.remote.smbServers) if (x.password.isNotEmpty) _smbPw(x.id): x.password, - for (final x in s.ftpServers) + for (final x in s.remote.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()]), + if (s.remote.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.remote.webdavServers) x.toJson()]), }; /// 저장소에 못 넣은 예전 평문 값을 설정 JSON 에 그대로 둔다 @@ -112,26 +112,26 @@ class SettingsStore { ..openSubtitlesKey = os['openSubtitlesKey']! ..openSubtitlesUser = os['openSubtitlesUser']! ..openSubtitlesPassword = os['openSubtitlesPassword']!; - s.webdavServers = [ - for (final x in s.webdavServers) + s.remote.webdavServers = [ + for (final x in s.remote.webdavServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''), ]; s.aiImage.services = [ for (final x in s.aiImage.services) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''), ]; - s.sftpServers = [ - for (final x in s.sftpServers) + s.remote.sftpServers = [ + for (final x in s.remote.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.remote.smbServers = [ + for (final x in s.remote.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)] ?? ''), + s.remote.ftpServers = [ + for (final x in s.remote.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''), ]; } @@ -152,7 +152,7 @@ class SettingsStore { // 예전 판이 서버 목록을 지운 설정 파일 (항목 자체가 없음) → 저장소의 목록으로 if (!s.extra.containsKey('webdavServers') && stored[_davServers] != null) { try { - s.webdavServers = [ + s.remote.webdavServers = [ for (final x in jsonDecode(stored[_davServers]!) as List) if (x is Map) DavServer.fromJson(x), ]; @@ -163,7 +163,7 @@ class SettingsStore { final v = _os(s, e.key); if (v.isNotEmpty) _filePlain[e.value] = v; } - for (final x in s.webdavServers) { + for (final x in s.remote.webdavServers) { if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password; } for (final x in s.aiImage.services) { diff --git a/lib/ui/dav_external.dart b/lib/ui/dav_external.dart index a0ff66b..37ec2ca 100644 --- a/lib/ui/dav_external.dart +++ b/lib/ui/dav_external.dart @@ -85,7 +85,7 @@ Future openExternalPlayable(BuildContext context, AppController c, String return; } // 102: 환경 설정 (매번 묻기 · 늘 받아서 · 늘 주소로) - final pick = switch (c.settings.davExternalOpen) { + final pick = switch (c.settings.remote.davExternalOpen) { 'fetch' => 'fetch', 'url' => 'url', _ => await showDialog( diff --git a/lib/ui/explorer/explorer_controller.dart b/lib/ui/explorer/explorer_controller.dart index e2b79e2..38f7783 100644 --- a/lib/ui/explorer/explorer_controller.dart +++ b/lib/ui/explorer/explorer_controller.dart @@ -83,11 +83,11 @@ class ExplorerController extends ChangeNotifier { /// 저장 장치 + 서버들 (WebDAV · SFTP · SMB · FTP · 시스템 폴더 · 루트): (경로, 이름) List<(String, String)> get volumes => [ ...local, - for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label), - for (final s in c.settings.sftpServers) ('$sftpScheme${s.id}/', s.label), - for (final s in c.settings.smbServers) ('$smbScheme${s.id}/', s.label), + for (final s in c.settings.remote.webdavServers) ('$davScheme${s.id}/', s.label), + for (final s in c.settings.remote.sftpServers) ('$sftpScheme${s.id}/', s.label), + for (final s in c.settings.remote.smbServers) ('$smbScheme${s.id}/', s.label), // l: 평문 FTP 는 이름 앞에 ⚠ - for (final s in c.settings.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'), + for (final s in c.settings.remote.ftpServers) ('$ftpScheme${s.id}/', s.tls ? s.label : '⚠ ${s.label}'), if (c.settings.tools.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')), if (c.settings.tools.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')), ]; diff --git a/lib/ui/explorer/pane_header.dart b/lib/ui/explorer/pane_header.dart index 4b50036..6e1b024 100644 --- a/lib/ui/explorer/pane_header.dart +++ b/lib/ui/explorer/pane_header.dart @@ -237,7 +237,7 @@ class ExplorerPaneHeader extends StatelessWidget { /// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력) List _end(BuildContext context) => [ - if (ctl.c.settings.webdavServers.isEmpty) + if (ctl.c.settings.remote.webdavServers.isEmpty) TextButton.icon( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, diff --git a/lib/ui/ftp_settings.dart b/lib/ui/ftp_settings.dart index 41b4570..30aa782 100644 --- a/lib/ui/ftp_settings.dart +++ b/lib/ui/ftp_settings.dart @@ -18,7 +18,7 @@ class FtpSettings extends StatelessWidget { @override Widget build(BuildContext context) { - final servers = c.settings.ftpServers; + final servers = c.settings.remote.ftpServers; return Column(children: [ SettingTile( leading: const Icon(Icons.folder_shared_outlined), @@ -81,7 +81,7 @@ Future deleteFtpServer(BuildContext context, AppController c, FtpServer s) ok: tr('지우기'), ); if (!ok) return false; - await c.updateSettings((x) => x.ftpServers = [for (final y in x.ftpServers) if (y.id != s.id) y]); + await c.updateSettings((x) => x.remote.ftpServers = [for (final y in x.remote.ftpServers) if (y.id != s.id) y]); return true; } @@ -124,7 +124,7 @@ Future askTrustFtpCert(BuildContext context, String serverLabel, FtpCertEx /// 믿은 인증서를 설정에 저장 (탐색기에서 처음 연결할 때) Future trustFtpCert(AppController c, String id, FtpCertException e) => - c.updateSettings((x) => x.ftpServers = [for (final y in x.ftpServers) y.id == id ? y.copyWith(certFingerprint: e.fingerprint) : y]); + c.updateSettings((x) => x.remote.ftpServers = [for (final y in x.remote.ftpServers) y.id == id ? y.copyWith(certFingerprint: e.fingerprint) : y]); /// FTP 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null) Future editFtpServer(BuildContext context, AppController c, {FtpServer? old}) async { @@ -312,9 +312,9 @@ Future editFtpServer(BuildContext context, AppController c, {FtpServ ), ); if (saved == null) return null; - await c.updateSettings((x) => x.ftpServers = [ - for (final y in x.ftpServers) y.id == saved.id ? saved : y, - if (!x.ftpServers.any((y) => y.id == saved.id)) saved, + await c.updateSettings((x) => x.remote.ftpServers = [ + for (final y in x.remote.ftpServers) y.id == saved.id ? saved : y, + if (!x.remote.ftpServers.any((y) => y.id == saved.id)) saved, ]); return saved; } diff --git a/lib/ui/master_prompt.dart b/lib/ui/master_prompt.dart index 6776522..e2a17e2 100644 --- a/lib/ui/master_prompt.dart +++ b/lib/ui/master_prompt.dart @@ -139,11 +139,11 @@ class _MasterDialogState extends State<_MasterDialog> { /// 저장된 실제 비밀번호 · API 키를 지운다 (서버 목록 · 아이디는 남김) Future clearStoredSecrets(AppController c) => c.updateSettings((s) => s - ..webdavServers = [for (final x in s.webdavServers) x.copyWith(password: '')] + ..remote.webdavServers = [for (final x in s.remote.webdavServers) x.copyWith(password: '')] // 88: SFTP 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김) - ..sftpServers = [for (final x in s.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')] - ..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')] - ..ftpServers = [for (final x in s.ftpServers) x.copyWith(password: '')] + ..remote.sftpServers = [for (final x in s.remote.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')] + ..remote.smbServers = [for (final x in s.remote.smbServers) x.copyWith(password: '')] + ..remote.ftpServers = [for (final x in s.remote.ftpServers) x.copyWith(password: '')] // AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17) ..aiImage.services = [for (final x in s.aiImage.services) x.copyWith(apiKey: '')] ..openSubtitlesKey = '' diff --git a/lib/ui/settings/files_section.dart b/lib/ui/settings/files_section.dart index 539505b..8c49a03 100644 --- a/lib/ui/settings/files_section.dart +++ b/lib/ui/settings/files_section.dart @@ -335,12 +335,12 @@ class _FilesSettingsSectionState extends State { title: Text(tr('원격 서버 응답 기다리기')), subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')), trailing: DropdownButton( - value: RemoteIdle.choices.contains(s.remoteIdleSeconds) ? s.remoteIdleSeconds : RemoteIdle.defaultSeconds, + value: RemoteIdle.choices.contains(s.remote.idleSeconds) ? s.remote.idleSeconds : RemoteIdle.defaultSeconds, items: [ for (final n in RemoteIdle.choices) DropdownMenuItem(value: n, child: Text(n == RemoteIdle.defaultSeconds ? trf('{0}초 (기본)', [n]) : trf('{0}초', [n]))), ], - onChanged: (v) => c.updateSettings((x) => x.remoteIdleSeconds = v!), + onChanged: (v) => c.updateSettings((x) => x.remote.idleSeconds = v!), ), ), ViewerSettings(c: c), diff --git a/lib/ui/settings/media_sections.dart b/lib/ui/settings/media_sections.dart index 912652a..25f05a8 100644 --- a/lib/ui/settings/media_sections.dart +++ b/lib/ui/settings/media_sections.dart @@ -206,13 +206,13 @@ class _PlaySettingsSectionState extends State { title: Text(tr('로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때')), subtitle: Text(tr('다른 앱에는 아이디 · 비밀번호를 넘기지 않습니다. 받아서 열기는 어느 앱에서나 열리고, 주소로 열기는 아이디를 묻는 앱 (VLC 등) 만 열립니다')), trailing: DropdownButton( - value: s.davExternalOpen, + value: s.remote.davExternalOpen, items: [ DropdownMenuItem(value: 'ask', child: Text(tr('매번 묻기 (기본)'))), DropdownMenuItem(value: 'fetch', child: Text(tr('늘 받아서 열기'))), DropdownMenuItem(value: 'url', child: Text(tr('늘 주소로 열기'))), ], - onChanged: (v) => c.updateSettings((x) => x.davExternalOpen = v!), + onChanged: (v) => c.updateSettings((x) => x.remote.davExternalOpen = v!), ), ), SettingTile( diff --git a/lib/ui/sftp_settings.dart b/lib/ui/sftp_settings.dart index 449a0e6..d7d8309 100644 --- a/lib/ui/sftp_settings.dart +++ b/lib/ui/sftp_settings.dart @@ -20,7 +20,7 @@ class SftpSettings extends StatelessWidget { @override Widget build(BuildContext context) { - final servers = c.settings.sftpServers; + final servers = c.settings.remote.sftpServers; return Column(children: [ SettingTile( leading: const Icon(Icons.vpn_key_outlined), @@ -83,7 +83,7 @@ Future deleteSftpServer(BuildContext context, AppController c, SftpServer ok: tr('지우기'), ); if (!ok) return false; - await c.updateSettings((x) => x.sftpServers = [for (final y in x.sftpServers) if (y.id != s.id) y]); + await c.updateSettings((x) => x.remote.sftpServers = [for (final y in x.remote.sftpServers) if (y.id != s.id) y]); return true; } @@ -137,8 +137,8 @@ Future askTrustHostKey(BuildContext context, String serverLabel, SftpHostK /// 믿은 서버 키를 설정에 저장 (탐색기에서 처음 연결할 때) Future trustSftpHostKey(AppController c, String id, SftpHostKeyException e) => - c.updateSettings((x) => x.sftpServers = [ - for (final y in x.sftpServers) y.id == id ? y.copyWith(hostKeyType: e.type, hostKeyFingerprint: e.fingerprint) : y, + c.updateSettings((x) => x.remote.sftpServers = [ + for (final y in x.remote.sftpServers) y.id == id ? y.copyWith(hostKeyType: e.type, hostKeyFingerprint: e.fingerprint) : y, ]); /// SFTP 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null) @@ -364,9 +364,9 @@ Future editSftpServer(BuildContext context, AppController c, {SftpS ), ); if (saved == null) return null; - await c.updateSettings((x) => x.sftpServers = [ - for (final y in x.sftpServers) y.id == saved.id ? saved : y, - if (!x.sftpServers.any((y) => y.id == saved.id)) saved, + await c.updateSettings((x) => x.remote.sftpServers = [ + for (final y in x.remote.sftpServers) y.id == saved.id ? saved : y, + if (!x.remote.sftpServers.any((y) => y.id == saved.id)) saved, ]); return saved; } diff --git a/lib/ui/smb_settings.dart b/lib/ui/smb_settings.dart index 849d933..75cc2aa 100644 --- a/lib/ui/smb_settings.dart +++ b/lib/ui/smb_settings.dart @@ -19,7 +19,7 @@ class SmbSettings extends StatelessWidget { @override Widget build(BuildContext context) { - final servers = c.settings.smbServers; + final servers = c.settings.remote.smbServers; return Column(children: [ SettingTile( leading: const Icon(Icons.dns_outlined), @@ -40,8 +40,8 @@ class SmbSettings extends StatelessWidget { title: Text(tr('SMB 서명 · 암호화 필수')), subtitle: Text(tr('서명도 암호화도 되지 않는 연결은 열지 않습니다 (중간에서 내용을 보거나 바꾸지 못하게). ' '오래된 NAS 는 붙지 않을 수 있습니다. 꺼 두어도 그런 서버는 탐색기 탭에 ⚠ 를 보입니다')), - value: c.settings.smbRequireSecure, - onChanged: (v) => c.updateSettings((x) => x.smbRequireSecure = v), + value: c.settings.remote.smbRequireSecure, + onChanged: (v) => c.updateSettings((x) => x.remote.smbRequireSecure = v), ), ), for (final s in servers) @@ -91,7 +91,7 @@ Future deleteSmbServer(BuildContext context, AppController c, SmbServer s) ok: tr('지우기'), ); if (!ok) return false; - await c.updateSettings((x) => x.smbServers = [for (final y in x.smbServers) if (y.id != s.id) y]); + await c.updateSettings((x) => x.remote.smbServers = [for (final y in x.remote.smbServers) if (y.id != s.id) y]); return true; } @@ -275,9 +275,9 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ ), ); if (saved == null) return null; - await c.updateSettings((x) => x.smbServers = [ - for (final y in x.smbServers) y.id == saved.id ? saved : y, - if (!x.smbServers.any((y) => y.id == saved.id)) saved, + await c.updateSettings((x) => x.remote.smbServers = [ + for (final y in x.remote.smbServers) y.id == saved.id ? saved : y, + if (!x.remote.smbServers.any((y) => y.id == saved.id)) saved, ]); return saved; } diff --git a/lib/ui/webdav_settings.dart b/lib/ui/webdav_settings.dart index 19f405f..b53f6a1 100644 --- a/lib/ui/webdav_settings.dart +++ b/lib/ui/webdav_settings.dart @@ -19,7 +19,7 @@ class WebDavSettings extends StatelessWidget { @override Widget build(BuildContext context) { - final servers = c.settings.webdavServers; + final servers = c.settings.remote.webdavServers; return Column(children: [ SettingTile( leading: const Icon(Icons.cloud_outlined), @@ -78,7 +78,7 @@ Future deleteDavServer(BuildContext context, AppController c, DavServer s) ok: tr('지우기'), ); if (!ok) return false; - await c.updateSettings((x) => x.webdavServers = [for (final y in x.webdavServers) if (y.id != s.id) y]); + await c.updateSettings((x) => x.remote.webdavServers = [for (final y in x.remote.webdavServers) if (y.id != s.id) y]); return true; } @@ -244,9 +244,9 @@ Future editDavServer(BuildContext context, AppController c, {DavServ ), ); if (saved == null) return null; - await c.updateSettings((x) => x.webdavServers = [ - for (final y in x.webdavServers) y.id == saved.id ? saved : y, - if (!x.webdavServers.any((y) => y.id == saved.id)) saved, + await c.updateSettings((x) => x.remote.webdavServers = [ + for (final y in x.remote.webdavServers) y.id == saved.id ? saved : y, + if (!x.remote.webdavServers.any((y) => y.id == saved.id)) saved, ]); return saved; } diff --git a/test/confirm_actions_test.dart b/test/confirm_actions_test.dart index b1714a2..0eeae92 100644 --- a/test/confirm_actions_test.dart +++ b/test/confirm_actions_test.dart @@ -128,18 +128,18 @@ void main() { }); testWidgets('46: WebDAV 서버 지우기는 확인 뒤', (tester) async { - c.settings.webdavServers = [const DavServer(id: 'n', name: '집 NAS', url: 'https://nas')]; + c.settings.remote.webdavServers = [const DavServer(id: 'n', name: '집 NAS', url: 'https://nas')]; await tester.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: WebDavSettings(c: c))))); await tester.tap(find.byTooltip('지우기')); await tester.pumpAndSettle(); expect(find.text('WebDAV 서버 "집 NAS" 을(를) 지울까요?'), findsOneWidget); await tester.tap(find.text('취소')); await tester.pumpAndSettle(); - expect(c.settings.webdavServers, hasLength(1)); + expect(c.settings.remote.webdavServers, hasLength(1)); await tester.tap(find.byTooltip('지우기')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, '지우기')); await tester.pumpAndSettle(); - expect(c.settings.webdavServers, isEmpty); + expect(c.settings.remote.webdavServers, isEmpty); }); } diff --git a/test/decisions_test.dart b/test/decisions_test.dart index 360828b..56d6be9 100644 --- a/test/decisions_test.dart +++ b/test/decisions_test.dart @@ -32,7 +32,7 @@ void main() { ], everyElement(isTrue)); expect(s.cleanupPrechecked, ['work', 'download']); expect(s.tripleTapAction, 'move'); - expect(s.davExternalOpen, 'ask'); + expect(s.remote.davExternalOpen, 'ask'); // 이미 있던 설정의 기본값도 결정대로 expect([s.browser.pauseOnLeave, s.run.autoCheckUpdates, s.browser.adSkip, s.browser.adHide, s.run.inBackground, s.swipeNav], everyElement(isTrue)); @@ -50,11 +50,11 @@ void main() { ..recycleOnDelete = false ..cleanupPrechecked = ['logs'] ..tripleTapAction = 'none' - ..davExternalOpen = 'url') + ..remote.davExternalOpen = 'url') .toJson()); expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords, b.recycleOnDelete], everyElement(isFalse)); - expect([b.cleanupPrechecked, b.tripleTapAction, b.davExternalOpen], [['logs'], 'none', 'url']); + expect([b.cleanupPrechecked, b.tripleTapAction, b.remote.davExternalOpen], [['logs'], 'none', 'url']); }); test('안쪽 → 바깥 허용을 끄면 늘 막는다', () { @@ -69,7 +69,7 @@ void main() { final secrets = MemorySecretStore(); final st = SettingsStore(p.join(dir.path, 'settings.json'), secrets); final s = await st.load(); - s.webdavServers = [const DavServer(id: 'd', name: 'n', url: 'https://n', user: 'u', password: 'pw')]; + s.remote.webdavServers = [const DavServer(id: 'd', name: 'n', url: 'https://n', user: 'u', password: 'pw')]; await st.save(s); expect(secrets.values['dav.pw.d'], 'pw'); s.rememberPasswords = false; @@ -82,7 +82,7 @@ void main() { testWidgets('로그인이 필요한 WebDAV 를 다른 앱으로 - "$mode" 로 정해 두면 묻지 않는다', (tester) async { final shell = _Shell(); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), shell: shell)); - c.settings.davExternalOpen = mode; + c.settings.remote.davExternalOpen = mode; DavRegistry.configure([const DavServer(id: 'n', name: 'n', url: 'http://127.0.0.1:9', user: 'u', password: 'p')]); addTearDown(() => DavRegistry.configure([])); await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); diff --git a/test/explorer_webdav_test.dart b/test/explorer_webdav_test.dart index c5d8f3a..99fcf93 100644 --- a/test/explorer_webdav_test.dart +++ b/test/explorer_webdav_test.dart @@ -91,8 +91,8 @@ void main() { await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '저장'))); // 저장하면 설정에 남고 오른쪽 창이 그 서버로 await settle(tester, () => find.text('원격').evaluate().isNotEmpty); - expect(c.settings.webdavServers.single.url, server.url); - final id = c.settings.webdavServers.single.id; + expect(c.settings.remote.webdavServers.single.url, server.url); + final id = c.settings.remote.webdavServers.single.id; expect(find.text('집 NAS'), findsNWidgets(3)); // 두 창의 탭 + 오른쪽 창의 맨 위 줄 expect(find.text('WebDAV'), findsNothing); @@ -121,7 +121,7 @@ void main() { testWidgets('178: 설정 옵션에 --remove-source-files 가 있으면 체크 켬 · [이동] · 경고, 끄면 정말 원본을 남긴다', (tester) async { final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); c.settings - ..webdavServers = [s] + ..remote.webdavServers = [s] ..copy.rsyncOptions = '-av --remove-source-files' ..copy.rsyncPaths = [left, 'dav://nas/']; DavRegistry.configure([s]); @@ -164,7 +164,7 @@ void main() { }; addTearDown(() => SecretGate.check = null); c.settings - ..webdavServers = [s] + ..remote.webdavServers = [s] ..explorer.paths = [left, left] ..folderBookmarks = [const FolderBookmark(name: 'NAS 원격', path: 'dav://nas/원격')]; DavRegistry.configure([s]); @@ -192,7 +192,7 @@ void main() { testWidgets('Rsync 화면: 로컬 ↔ WebDAV 폴더 → (앱이 직접 맞춤 · rsync 필요 없음)', (tester) async { final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); c.settings - ..webdavServers = [s] + ..remote.webdavServers = [s] ..copy.rsyncPaths = [left, 'dav://nas/']; DavRegistry.configure([s]); await pump(tester, ExplorerPage(device: device, c: c, rsync: true)); diff --git a/test/ftp_test.dart b/test/ftp_test.dart index 950768c..eda95a6 100644 --- a/test/ftp_test.dart +++ b/test/ftp_test.dart @@ -45,11 +45,11 @@ void main() { final secrets = MemorySecretStore(); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final s = AppSettings() - ..ftpServers = [const FtpServer(id: 'f1', name: 'NAS', host: 'nas.local', port: 2121, user: 'jj', password: 'ftp-secret', certFingerprint: 'SHA256:AB')]; + ..remote.ftpServers = [const FtpServer(id: 'f1', name: 'NAS', host: 'nas.local', port: 2121, user: 'jj', password: 'ftp-secret', certFingerprint: 'SHA256:AB')]; await store.save(s); expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('ftp-secret'))); expect(secrets.values['ftp.pw.f1'], 'ftp-secret'); - final x = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).ftpServers.single; + final x = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).remote.ftpServers.single; expect((x.host, x.port, x.user, x.password, x.security, x.certFingerprint), ('nas.local', 2121, 'jj', 'ftp-secret', 'explicit', 'SHA256:AB')); final stripped = AppSettings.stripSecrets({ 'ftpServers': [ diff --git a/test/master_lock_test.dart b/test/master_lock_test.dart index b6befe9..260650e 100644 --- a/test/master_lock_test.dart +++ b/test/master_lock_test.dart @@ -190,7 +190,7 @@ void main() { AppController controller() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..webdavServers = [const DavServer(id: 'n', name: 'NAS', url: 'https://nas', user: 'u', password: 'pw')] + ..remote.webdavServers = [const DavServer(id: 'n', name: 'NAS', url: 'https://nas', user: 'u', password: 'pw')] ..openSubtitlesKey = 'k' ..openSubtitlesUser = 'ou' ..openSubtitlesPassword = 'op'; @@ -238,7 +238,7 @@ void main() { await work(t); expect(result, isTrue); expect(lock.hasMaster, isFalse); - expect(c.settings.webdavServers.single.password, 'pw', reason: '마스터만 골랐으니 비밀번호는 그대로'); + expect(c.settings.remote.webdavServers.single.password, 'pw', reason: '마스터만 골랐으니 비밀번호는 그대로'); }); testWidgets('잊었습니다: 저장된 비밀번호 · API 키를 지우고 마스터 초기화 (서버 목록 · 아이디는 남김)', (t) async { @@ -254,8 +254,8 @@ void main() { await t.tap(find.widgetWithText(FilledButton, '지우고 초기화')); await work(t); expect(find.text('초기화했습니다'), findsOneWidget); - expect(c.settings.webdavServers.single.password, ''); - expect(c.settings.webdavServers.single.user, 'u'); + expect(c.settings.remote.webdavServers.single.password, ''); + expect(c.settings.remote.webdavServers.single.user, 'u'); expect((c.settings.openSubtitlesKey, c.settings.openSubtitlesPassword, c.settings.openSubtitlesUser), ('', '', 'ou')); expect(lock.hasMaster, isFalse); }); @@ -285,8 +285,8 @@ void main() { await c.updateSettings((s) => s ..openSubtitlesKey = 'k' ..openSubtitlesPassword = 'op' - ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] - ..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'PRIVATE KEY', keyPassphrase: 'kp')] + ..remote.webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] + ..remote.sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'PRIVATE KEY', keyPassphrase: 'kp')] ..aiImage.services = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]); await store.save(c.settings); bool secret(String k) => k.startsWith('os.') || k.startsWith('dav.pw.') || k.startsWith('sftp.') || k.startsWith('ai.'); @@ -295,7 +295,7 @@ void main() { await store.save(c.settings); expect(secrets.values.keys.where(secret), isEmpty); // 목록 · 아이디 · 믿은 서버 키는 남김 - expect((c.settings.webdavServers.single.user, c.settings.sftpServers.single.user, c.settings.aiImage.services.single.url), ('u', 'u', 'https://ai')); + expect((c.settings.remote.webdavServers.single.user, c.settings.remote.sftpServers.single.user, c.settings.aiImage.services.single.url), ('u', 'u', 'https://ai')); }); test('관리자 판단 (10/21): "앱 설정 전체" 는 이 판이 모르는 설정 키도 지우고, 다른 판의 비밀 값은 "저장된 비밀번호" 를 골랐을 때만', () async { @@ -330,7 +330,7 @@ void main() { final c = controller()..settings.explorer.layout = 'single'; await resetAllSettings(c); expect(c.settings.explorer.layout, AppSettings().explorer.layout); - expect(c.settings.webdavServers, isEmpty); + expect(c.settings.remote.webdavServers, isEmpty); }); }); diff --git a/test/remote_idle_test.dart b/test/remote_idle_test.dart index b82a876..a7c6131 100644 --- a/test/remote_idle_test.dart +++ b/test/remote_idle_test.dart @@ -134,14 +134,14 @@ void main() { tearDown(() => RemoteIdle.limit = const Duration(seconds: RemoteIdle.defaultSeconds)); test('설정 "원격 서버 응답 기다리기": 처음 60초 · 30 · 60 · 120 · 300 만 · 그 밖의 값은 60 · 오류 글은 초까지', () { - expect(AppSettings().remoteIdleSeconds, 60); + expect(AppSettings().remote.idleSeconds, 60); for (final n in RemoteIdle.choices) { - expect(AppSettings.fromJson({'remoteIdleSeconds': n}).remoteIdleSeconds, n); + expect(AppSettings.fromJson({'remoteIdleSeconds': n}).remote.idleSeconds, n); } for (final v in [0, 45, -1, 'x', null, 99999]) { - expect(AppSettings.fromJson({'remoteIdleSeconds': v}).remoteIdleSeconds, 60, reason: '$v'); + expect(AppSettings.fromJson({'remoteIdleSeconds': v}).remote.idleSeconds, 60, reason: '$v'); } - expect((AppSettings()..remoteIdleSeconds = 120).toJson()['remoteIdleSeconds'], 120); + expect((AppSettings()..remote.idleSeconds = 120).toJson()['remoteIdleSeconds'], 120); expect('${const RemoteIdleTimeout(60)}', '서버가 답하지 않습니다 (60초)'); }); diff --git a/test/rsync_phsr_test.dart b/test/rsync_phsr_test.dart index aa84ddc..7cb4ebd 100644 --- a/test/rsync_phsr_test.dart +++ b/test/rsync_phsr_test.dart @@ -162,7 +162,7 @@ void main() { ..copy.rsyncSource = 'custom' ..copy.rsyncPath = rsync ..copy.rsyncOptions = opts - ..webdavServers = [dav()]; + ..remote.webdavServers = [dav()]; for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) { final dir = local ? target : p.join(remote.path, 'l'); writeScene(src, dir, srcFiles: _src, dstFiles: _dst); diff --git a/test/rsync_safety_test.dart b/test/rsync_safety_test.dart index 98c773d..be9cb9e 100644 --- a/test/rsync_safety_test.dart +++ b/test/rsync_safety_test.dart @@ -37,7 +37,7 @@ void main() { final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..webdavServers = [s] + ..remote.webdavServers = [s] ..explorer.layout = 'single' ..copy.rsyncPaths = [left, 'dav://nas/']; DavRegistry.configure([s]); diff --git a/test/secret_issue_test.dart b/test/secret_issue_test.dart index 7ace6f5..a4ce601 100644 --- a/test/secret_issue_test.dart +++ b/test/secret_issue_test.dart @@ -27,7 +27,7 @@ void main() { PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()), settingsStore: store); c.settings = await store.load(); - await c.updateSettings((s) => s.webdavServers = [ + await c.updateSettings((s) => s.remote.webdavServers = [ const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'pw'), ]); }); @@ -60,11 +60,11 @@ void main() { PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()), settingsStore: store); c.settings = await store.load(); - await c.updateSettings((s) => s.webdavServers = [ + await c.updateSettings((s) => s.remote.webdavServers = [ const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'old'), ]); secrets.failWrite = true; - await c.updateSettings((s) => s.webdavServers = [s.webdavServers.single.copyWith(password: 'new')]); + await c.updateSettings((s) => s.remote.webdavServers = [s.remote.webdavServers.single.copyWith(password: 'new')]); }); expect(store.secretIssue.value?.revertsToOld, isTrue); final key = GlobalKey(); diff --git a/test/secret_store_test.dart b/test/secret_store_test.dart index 6e0e95d..0b778f1 100644 --- a/test/secret_store_test.dart +++ b/test/secret_store_test.dart @@ -37,7 +37,7 @@ void main() { final s = await store().load(); expect(s.openSubtitlesKey, 'KEY-1'); expect(s.openSubtitlesPassword, 'os-pw'); - expect(s.webdavServers.single.password, 'dav-pw'); + expect(s.remote.webdavServers.single.password, 'dav-pw'); expect(secrets.values.values, containsAll(['KEY-1', 'me', 'os-pw', 'dav-pw'])); for (final f in [file, '$file.bak']) { expect(text(f), isNot(contains('dav-pw')), reason: f); @@ -50,7 +50,7 @@ void main() { expect(j['openSubtitlesSaved'], {'key': true, 'user': true, 'password': true}); // 다시 켜도 그대로 final again = await store().load(); - expect(again.webdavServers.single.password, 'dav-pw'); + expect(again.remote.webdavServers.single.password, 'dav-pw'); expect(again.openSubtitlesKey, 'KEY-1'); }); @@ -59,32 +59,32 @@ void main() { await st.load(); await st.save(AppSettings() ..openSubtitlesKey = 'KEY-2' - ..webdavServers = [const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'pw')]); + ..remote.webdavServers = [const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'pw')]); // 예전 판: 모르는 항목을 지우고 저장 File(file).writeAsStringSync(jsonEncode({'homeUrl': 'https://old.example'})); final s = await store().load(); expect(s.browser.homeUrl, 'https://old.example'); - expect(s.webdavServers.single.url, 'https://nas'); - expect(s.webdavServers.single.password, 'pw'); + expect(s.remote.webdavServers.single.url, 'https://nas'); + expect(s.remote.webdavServers.single.password, 'pw'); expect(s.openSubtitlesKey, 'KEY-2'); // 사용자가 일부러 서버를 모두 지웠으면 (목록 항목은 있음) 되살리지 않는다 final st2 = store(); final s2 = await st2.load(); - await st2.save(s2..webdavServers = []); - expect((await store().load()).webdavServers, isEmpty); + await st2.save(s2..remote.webdavServers = []); + expect((await store().load()).remote.webdavServers, isEmpty); }); test('비밀번호를 지우거나 서버를 지우면 안전 저장소에서도 지운다', () async { final st = store(); final s = await st.load(); - s.webdavServers = [ + s.remote.webdavServers = [ const DavServer(id: 'a', name: 'a', url: 'https://a', password: 'pa'), const DavServer(id: 'b', name: 'b', url: 'https://b', password: 'pb'), ]; s.openSubtitlesPassword = 'x'; await st.save(s); expect(secrets.values.values, containsAll(['pa', 'pb', 'x'])); - s.webdavServers = [s.webdavServers.first]; + s.remote.webdavServers = [s.remote.webdavServers.first]; s.openSubtitlesPassword = ''; await st.save(s); expect(secrets.values.values, isNot(contains('pb'))); @@ -113,13 +113,13 @@ void main() { test('버전 보관본으로 되살려도 비밀번호는 안전 저장소의 것이 그대로', () async { final st = store(); final s = await st.load(); - s.webdavServers = [const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'pw')]; + s.remote.webdavServers = [const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'pw')]; await st.save(s); final snap = VersionSnapshot(dir.path); await snap.save('1.0'); expect(text(p.join(snap.dirOf('1.0'), 'settings.json')), isNot(contains('"pw"'))); await snap.restoreFrom(snap.dirOf('1.0')); - expect((await store().load()).webdavServers.single.password, 'pw'); + expect((await store().load()).remote.webdavServers.single.password, 'pw'); }); test('40-1: 안전 저장소에 쓰지 못하면 예전 평문은 파일에 그대로 · 새 값은 평문으로 쓰지 않음 · 알림 · [다시 시도]', () async { @@ -136,9 +136,9 @@ void main() { if (File(f).existsSync()) expect(text(f), contains('old-pw'), reason: f); } // 다시 켜도 비밀번호가 남아 있다 - expect((await SettingsStore(file, secrets).load()).webdavServers.single.password, 'old-pw'); + expect((await SettingsStore(file, secrets).load()).remote.webdavServers.single.password, 'old-pw'); // 새 비밀번호: 저장소에 못 쓰면 파일에 평문으로 쓰지 않는다 (예전 값은 그대로) - s.webdavServers = [s.webdavServers.single.copyWith(password: 'new-pw')]; + s.remote.webdavServers = [s.remote.webdavServers.single.copyWith(password: 'new-pw')]; await st.save(s); expect(text(file), isNot(contains('new-pw'))); expect(text(file), contains('old-pw')); @@ -160,7 +160,7 @@ void main() { final st = store(); final s = await st.load(); expect(st.secretIssue.value?.kind, SecretIssueKind.readFailed); - s.webdavServers = [const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'typed')]; + s.remote.webdavServers = [const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'typed')]; await st.save(s); expect(text(file), isNot(contains('typed'))); expect(secrets.values, isEmpty); @@ -196,7 +196,7 @@ void main() { expect(kept, isNot(contains(r'x\"y'))); expect(kept, contains('"openSubtitlesKey": ""')); // .bak 의 값은 저장소로 옮기고 .bak 에서 지운다 - expect(s.webdavServers.single.password, 'bak-pw'); + expect(s.remote.webdavServers.single.password, 'bak-pw'); expect(secrets.values['os.password'], 'bak-secret'); expect(text('$file.bak'), isNot(contains('bak-pw'))); expect(text('$file.bak'), isNot(contains('bak-secret'))); diff --git a/test/settings_store_test.dart b/test/settings_store_test.dart index 580d845..9ae4f17 100644 --- a/test/settings_store_test.dart +++ b/test/settings_store_test.dart @@ -41,8 +41,8 @@ void main() { ..values['sec.master'] = 'hash'; final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final s = await store.load() - ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] - ..sftpServers = [ + ..remote.webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] + ..remote.sftpServers = [ const SftpServer(id: 's1', name: 'a', host: 'h', user: 'u', password: 'sp1', privateKey: 'KEY1', keyPassphrase: 'kp1'), const SftpServer(id: 's2', name: 'b', host: 'h', user: 'u', password: 'sp2'), ]; @@ -51,7 +51,7 @@ void main() { {for (final k in ['dav.pw.d1', 'sftp.pw.s1', 'sftp.key.s1', 'sftp.kp.s1', 'sftp.pw.s2']) k: secrets.values[k]}, {'dav.pw.d1': 'dp', 'sftp.pw.s1': 'sp1', 'sftp.key.s1': 'KEY1', 'sftp.kp.s1': 'kp1', 'sftp.pw.s2': 'sp2'}); // 서버 s1 을 지움: s1 의 아는 열쇠 (pw · key · kp) 만 없어지고, s2 · 모르는 꼴 · 마스터는 그대로 - s.sftpServers = [s.sftpServers.last]; + s.remote.sftpServers = [s.remote.sftpServers.last]; await store.save(s); expect(secrets.values.keys.where((k) => k.endsWith('.s1')), ['sftp.cert.s1']); expect(secrets.values['sftp.pw.s2'], 'sp2'); @@ -74,10 +74,10 @@ void main() { ..openSubtitlesKey = 'k' ..openSubtitlesUser = 'u' ..openSubtitlesPassword = 'op' - ..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] - ..sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'KEY', keyPassphrase: 'kp')] - ..smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')] - ..ftpServers = [const FtpServer(id: 'f1', name: 'n', host: 'h', password: 'fp')] + ..remote.webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] + ..remote.sftpServers = [const SftpServer(id: 's1', name: 'n', host: 'h', user: 'u', password: 'sp', privateKey: 'KEY', keyPassphrase: 'kp')] + ..remote.smbServers = [const SmbServer(id: 'm1', name: 'n', host: 'h', share: 'v', password: 'mp')] + ..remote.ftpServers = [const FtpServer(id: 'f1', name: 'n', host: 'h', password: 'fp')] ..aiImage.services = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]; await store.save(s); final made = secrets.values.keys.where((k) => !k.startsWith('sec.')).toList()..sort(); diff --git a/test/sftp_explorer_test.dart b/test/sftp_explorer_test.dart index ca782a2..a52b096 100644 --- a/test/sftp_explorer_test.dart +++ b/test/sftp_explorer_test.dart @@ -51,7 +51,7 @@ void main() { final local = Directory(p.join(tmp.path, 'local'))..createSync(); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); await tester.runAsync(() => c.updateSettings((x) => x - ..sftpServers = [SftpServer(id: 's1', name: 'NAS', host: '127.0.0.1', port: s!.port, user: 'jj', password: 'pw')] + ..remote.sftpServers = [SftpServer(id: 's1', name: 'NAS', host: '127.0.0.1', port: s!.port, user: 'jj', password: 'pw')] ..explorer.paths = ['sftp://s1/', local.path])); tester.view.physicalSize = const Size(1600, 900); tester.view.devicePixelRatio = 1; @@ -69,8 +69,8 @@ void main() { await tester.tap(find.text('믿고 연결')); await settle(tester, () => find.text('hello.txt').evaluate().isNotEmpty); expect(find.text('hello.txt'), findsOneWidget); - expect(c.settings.sftpServers.single.hostKeyFingerprint, s.fingerprint); - expect(c.settings.sftpServers.single.hostKeyType, 'ssh-ed25519'); + expect(c.settings.remote.sftpServers.single.hostKeyFingerprint, s.fingerprint); + expect(c.settings.remote.sftpServers.single.hostKeyType, 'ssh-ed25519'); // 화면 내리기 · 연결 닫기 await tester.pumpWidget(const SizedBox()); await tester.runAsync(() async => SftpRegistry.configure(const [])); diff --git a/test/sftp_test.dart b/test/sftp_test.dart index 0beaa54..4c27d7e 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -28,7 +28,7 @@ void main() { final secrets = MemorySecretStore(); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final s = AppSettings() - ..sftpServers = [ + ..remote.sftpServers = [ const SftpServer( id: 's1', name: 'NAS', @@ -51,7 +51,7 @@ void main() { expect(secrets.values['sftp.kp.s1'], 'kp-secret'); expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); - final x = back.sftpServers.single; + final x = back.remote.sftpServers.single; expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc')); expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret')); expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); diff --git a/test/smb_test.dart b/test/smb_test.dart index 4361849..1314938 100644 --- a/test/smb_test.dart +++ b/test/smb_test.dart @@ -196,10 +196,10 @@ void main() { addTearDown(() => tmp.deleteSync(recursive: true)); final secrets = MemorySecretStore(); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); - await store.save(AppSettings()..smbServers = [server]); + await store.save(AppSettings()..remote.smbServers = [server]); expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('smb-Pw-5Qh9kT4r'))); expect(secrets.values['smb.pw.m1'], 'smb-Pw-5Qh9kT4r'); - final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single; + final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).remote.smbServers.single; expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'smb-Pw-5Qh9kT4r')); }); @@ -210,9 +210,9 @@ void main() { expect(parseSmbAddress('/storage/x'), isNull); }); test('보안 검토 6: 서명 · 암호화가 안 된 연결은 탐색기 탭에 ⚠ · 설정 "필수" 는 다리에 넘기고 막힌 이유를 알림', () async { - expect(AppSettings().smbRequireSecure, isFalse, reason: '처음 끔 (오래된 NAS)'); - expect(AppSettings.fromJson(const {'smbRequireSecure': true}).smbRequireSecure, isTrue); - expect(AppSettings.fromJson(const {'smbRequireSecure': 'x'}).smbRequireSecure, isFalse); + expect(AppSettings().remote.smbRequireSecure, isFalse, reason: '처음 끔 (오래된 NAS)'); + expect(AppSettings.fromJson(const {'smbRequireSecure': true}).remote.smbRequireSecure, isTrue); + expect(AppSettings.fromJson(const {'smbRequireSecure': 'x'}).remote.smbRequireSecure, isFalse); SmbRegistry.configure(const [server]); expect(DavPath.parse('smb://m1/').mark, '🗄', reason: '연결 전에는 모름'); fake diff --git a/test/webdav_test.dart b/test/webdav_test.dart index 95785d0..fa472f2 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -153,7 +153,7 @@ void main() { rf('mirror/old.txt', 'o'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); // 예전에 확인한 쌍은 처음 맞출 때 지금 옵션을 기억하며 설정을 저장한다 (서버 목록도 설정에서 다시 읽음) - 시험 서버를 설정에도 - c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]; + c.settings.remote.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]; final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); c.settings.copy.liveSyncPairs = [pair]; final live = LiveSync(c); @@ -327,7 +327,7 @@ void main() { final src = p.join(local.path, 'live_src'), want = p.join(local.path, 'live_rsync'), got = p.join(remote.path, 'live'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); // 지우기를 정하면 설정을 저장하며 서버 목록을 설정에서 다시 읽는다 - 시험 서버를 설정에도 - c.settings.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]; + c.settings.remote.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]; final bad = []; for (final opts in sceneOptions) { writeScene(src, want); @@ -367,7 +367,7 @@ void main() { rf('rqd/only.tmp', 'o'); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings - ..webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')] + ..remote.webdavServers = [DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')] ..copy.rsyncOptions = '-av --exclude=*.tmp' ..copy.liveSyncPairs = [ LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd',