설정 A 분야 12: 원격 서버 (RemoteSettings) - WebDAV · SFTP · SMB · FTP 목록 · 쉬는 시간 · WebDAV 외부 열기, 키 7

- 비밀 값 처리 (webdavServers 의 plainSecrets · 새 비밀 값은 평문으로 쓰지 않음) 그대로, 설명 주석도 함께 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-22 23:37:49 +09:00
parent 828d978812
commit 8d26d1af1e
31 changed files with 206 additions and 180 deletions

View File

@ -29,7 +29,7 @@ void main() {
await t.runAsync(() => i18n.apply(lang, save: false)); await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false)); addTearDown(() => i18n.apply('ko', save: false));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); 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: 'f1', name: 'NAS FTPS', host: '192.168.0.15', user: 'jj'),
const FtpServer(id: 'f2', name: '옛 공유기', host: '192.168.0.1', security: 'none'), const FtpServer(id: 'f2', name: '옛 공유기', host: '192.168.0.1', security: 'none'),
]; ];

View File

@ -132,7 +132,7 @@ void main() {
await shot('5_rejected', 'authorized_keys'); await shot('5_rejected', 'authorized_keys');
navKey.currentState!.popUntil((r) => r.isFirst); navKey.currentState!.popUntil((r) => r.isFirst);
await settle(() => false, rounds: 10); 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))); }, skip: skip, timeout: const Timeout(Duration(minutes: 3)));
} }
} }

View File

@ -157,15 +157,15 @@ class AppController extends ChangeNotifier {
// 61: 기본 자막 언어 (MKV 기본 자막 트랙 · 플레이어가 먼저 켜는 자막) // 61: 기본 자막 언어 (MKV 기본 자막 트랙 · 플레이어가 먼저 켜는 자막)
preferredSubtitleSetting = settings.preferredSubtitleLanguage; preferredSubtitleSetting = settings.preferredSubtitleLanguage;
// WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다) // WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다)
DavRegistry.configure(settings.webdavServers); DavRegistry.configure(settings.remote.webdavServers);
// 88: SFTP 서버 (sftp:// 경로) // 88: SFTP 서버 (sftp:// 경로)
SftpRegistry.configure(settings.sftpServers); SftpRegistry.configure(settings.remote.sftpServers);
// 88: Android SMB 서버 (smb:// 경로) // 88: Android SMB 서버 (smb:// 경로)
SmbRegistry.requireSecure = settings.smbRequireSecure; SmbRegistry.requireSecure = settings.remote.smbRequireSecure;
// 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기 // 관리자 판단 (10/20): 원격 서버 받기 · 올리기의 조각 사이 기다리기
RemoteIdle.limit = Duration(seconds: settings.remoteIdleSeconds); RemoteIdle.limit = Duration(seconds: settings.remote.idleSeconds);
SmbRegistry.configure(settings.smbServers); SmbRegistry.configure(settings.remote.smbServers);
FtpRegistry.configure(settings.ftpServers); FtpRegistry.configure(settings.remote.ftpServers);
outputRootOverride = outputRootOverride =
(settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null; (settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null;
} }

View File

@ -7,14 +7,10 @@ import '../core/reader_sources.dart' show defaultImageExtensions;
import '../core/download_detect.dart'; import '../core/download_detect.dart';
import '../core/encode_options.dart'; import '../core/encode_options.dart';
import '../core/playlist.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 '../services/app_shell.dart' show appIconOf;
import '../core/remote_idle.dart';
import 'settings/records.dart'; import 'settings/records.dart';
import 'settings/settings_group.dart'; import 'settings/settings_group.dart';
import 'settings/remote_settings.dart';
import 'settings/explorer_tool_settings.dart'; import 'settings/explorer_tool_settings.dart';
import 'settings/vault_settings.dart'; import 'settings/vault_settings.dart';
import 'settings/run_settings.dart'; import 'settings/run_settings.dart';
@ -30,6 +26,7 @@ import 'settings/explorer_settings.dart';
export 'settings/records.dart'; export 'settings/records.dart';
export 'settings/settings_group.dart'; export 'settings/settings_group.dart';
export 'settings/settings_store.dart'; export 'settings/settings_store.dart';
export 'settings/remote_settings.dart';
export 'settings/explorer_tool_settings.dart'; export 'settings/explorer_tool_settings.dart';
export 'settings/vault_settings.dart'; export 'settings/vault_settings.dart';
export 'settings/run_settings.dart'; export 'settings/run_settings.dart';
@ -239,8 +236,6 @@ class AppSettings {
/// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …) /// 61: 기본 자막 언어 (MKV 의 기본 자막 트랙 · 플레이어가 먼저 켜는 자막). '' = 화면 언어 따르기, 그 밖은 자막 언어 코드 (ko · ja …)
String preferredSubtitleLanguage = ''; String preferredSubtitleLanguage = '';
/// 55: 로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때 'ask' (매번 묻기) · 'fetch' (늘 받아서) · 'url' (늘 주소로)
String davExternalOpen = 'ask';
/// 39: 켤 때 "모든 파일 접근" 안내를 한 번 보였는지 (그 뒤로는 파일 탐색기 안의 안내로) /// 39: 켤 때 "모든 파일 접근" 안내를 한 번 보였는지 (그 뒤로는 파일 탐색기 안의 안내로)
bool allFilesHintShown = false; bool allFilesHintShown = false;
@ -249,25 +244,11 @@ class AppSettings {
double subtitleScale = 1.0; 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 그림 · 해상도 올리기 ───────── // ───────── 123 · 121: AI 그림 · 해상도 올리기 ─────────
@ -352,8 +333,11 @@ class AppSettings {
/// 탐색기 도구 - 시스템 폴더 (Shizuku) · 루트 · Wi-Fi 공유 · ZIP 묶기 /// 탐색기 도구 - 시스템 폴더 (Shizuku) · 루트 · Wi-Fi 공유 · ZIP 묶기
final tools = ExplorerToolSettings(); final tools = ExplorerToolSettings();
/// 원격 서버 (WebDAV · SFTP · SMB · FTP)
final remote = RemoteSettings();
/// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다 /// 설정 분야들 (설정 A) - 저마다 자기 키를 읽고 쓴다. toJson · fromJson 은 이 목록을 돌며 합치기만 한다
List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools]; List<SettingsGroup> get groups => [explorer, network, tor, copy, aiImage, reader, download, browser, run, vault, tools, remote];
/// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다 /// [plainSecrets]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> toJson({bool plainSecrets = false}) { Map<String, Object?> toJson({bool plainSecrets = false}) {
@ -406,19 +390,9 @@ class AppSettings {
'recycleOnDelete': recycleOnDelete, 'recycleOnDelete': recycleOnDelete,
'tripleTapAction': tripleTapAction, 'tripleTapAction': tripleTapAction,
'preferredSubtitleLanguage': preferredSubtitleLanguage, 'preferredSubtitleLanguage': preferredSubtitleLanguage,
'davExternalOpen': davExternalOpen,
// 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더 // 116: 예전 판의 표시 ('allFilesHintShown') 는 권한이 없어진 것을 몰라 다시 띄우지 못했다 → 새 이름으로 한 번 더
'allFilesHint2': allFilesHintShown, 'allFilesHint2': allFilesHintShown,
'subtitleScale': subtitleScale, '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, 'playlistMode': playlistMode.name,
'externalPlayers': externalPlayers, 'externalPlayers': externalPlayers,
'encode': { 'encode': {
@ -507,27 +481,8 @@ class AppSettings {
..recycleOnDelete = j['recycleOnDelete'] != false ..recycleOnDelete = j['recycleOnDelete'] != false
..tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move' ..tripleTapAction = j['tripleTapAction'] == 'none' ? 'none' : 'move'
..preferredSubtitleLanguage = j['preferredSubtitleLanguage'] is String ? j['preferredSubtitleLanguage'] as String : '' ..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 ..allFilesHintShown = j['allFilesHint2'] == true
..subtitleScale = ((j['subtitleScale'] as num?)?.toDouble() ?? 1.0).clamp(0.5, 2.5) ..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) ..playlistMode = pick(PlaylistMode.values, j['playlistMode'], PlaylistMode.series)
..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>() ..externalPlayers = ((j['externalPlayers'] as Map?) ?? const {}).cast<String, String>()
..encode = EncodeSettings( ..encode = EncodeSettings(

View File

@ -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<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 idleSeconds = RemoteIdle.defaultSeconds;
@override
void write(Map<String, Object?> 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<String, dynamic> 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),
];
}
}

View File

@ -65,21 +65,21 @@ class SettingsStore {
static Map<String, String> _secretsOf(AppSettings s) => !s.rememberPasswords ? const {} : { static Map<String, String> _secretsOf(AppSettings s) => !s.rememberPasswords ? const {} : {
for (final e in _osKeys.entries) for (final e in _osKeys.entries)
if (_os(s, e.key).isNotEmpty) e.value: _os(s, e.key), 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, if (x.password.isNotEmpty) _davKey(x.id): x.password,
for (final x in s.aiImage.services) for (final x in s.aiImage.services)
if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey, 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.password.isNotEmpty) _sftpPw(x.id): x.password,
if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey, if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey,
if (x.keyPassphrase.isNotEmpty) _sftpKp(x.id): x.keyPassphrase, 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, 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, if (x.password.isNotEmpty) _ftpPw(x.id): x.password,
// 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40) // 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (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 에 그대로 둔다 /// 저장소에 못 넣은 예전 평문 값을 설정 JSON 에 그대로 둔다
@ -112,26 +112,26 @@ class SettingsStore {
..openSubtitlesKey = os['openSubtitlesKey']! ..openSubtitlesKey = os['openSubtitlesKey']!
..openSubtitlesUser = os['openSubtitlesUser']! ..openSubtitlesUser = os['openSubtitlesUser']!
..openSubtitlesPassword = os['openSubtitlesPassword']!; ..openSubtitlesPassword = os['openSubtitlesPassword']!;
s.webdavServers = [ s.remote.webdavServers = [
for (final x in s.webdavServers) for (final x in s.remote.webdavServers)
x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''), x.password.isNotEmpty ? x : x.copyWith(password: stored[_davKey(x.id)] ?? ''),
]; ];
s.aiImage.services = [ s.aiImage.services = [
for (final x in s.aiImage.services) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''), for (final x in s.aiImage.services) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''),
]; ];
s.sftpServers = [ s.remote.sftpServers = [
for (final x in s.sftpServers) for (final x in s.remote.sftpServers)
x.copyWith( x.copyWith(
password: x.password.isNotEmpty ? x.password : stored[_sftpPw(x.id)] ?? '', password: x.password.isNotEmpty ? x.password : stored[_sftpPw(x.id)] ?? '',
privateKey: x.privateKey.isNotEmpty ? x.privateKey : stored[_sftpKey(x.id)] ?? '', privateKey: x.privateKey.isNotEmpty ? x.privateKey : stored[_sftpKey(x.id)] ?? '',
keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '', keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '',
), ),
]; ];
s.smbServers = [ s.remote.smbServers = [
for (final x in s.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''), for (final x in s.remote.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''),
]; ];
s.ftpServers = [ s.remote.ftpServers = [
for (final x in s.ftpServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_ftpPw(x.id)] ?? ''), 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) { if (!s.extra.containsKey('webdavServers') && stored[_davServers] != null) {
try { try {
s.webdavServers = [ s.remote.webdavServers = [
for (final x in jsonDecode(stored[_davServers]!) as List) for (final x in jsonDecode(stored[_davServers]!) as List)
if (x is Map) DavServer.fromJson(x), if (x is Map) DavServer.fromJson(x),
]; ];
@ -163,7 +163,7 @@ class SettingsStore {
final v = _os(s, e.key); final v = _os(s, e.key);
if (v.isNotEmpty) _filePlain[e.value] = v; 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; if (x.password.isNotEmpty) _filePlain[_davKey(x.id)] = x.password;
} }
for (final x in s.aiImage.services) { for (final x in s.aiImage.services) {

View File

@ -85,7 +85,7 @@ Future<void> openExternalPlayable(BuildContext context, AppController c, String
return; return;
} }
// 102: 환경 설정 (매번 묻기 · 늘 받아서 · 늘 주소로) // 102: 환경 설정 (매번 묻기 · 늘 받아서 · 늘 주소로)
final pick = switch (c.settings.davExternalOpen) { final pick = switch (c.settings.remote.davExternalOpen) {
'fetch' => 'fetch', 'fetch' => 'fetch',
'url' => 'url', 'url' => 'url',
_ => await showDialog<String>( _ => await showDialog<String>(

View File

@ -83,11 +83,11 @@ class ExplorerController extends ChangeNotifier {
/// 저장 장치 + 서버들 (WebDAV · SFTP · SMB · FTP · 시스템 폴더 · 루트): (경로, 이름) /// 저장 장치 + 서버들 (WebDAV · SFTP · SMB · FTP · 시스템 폴더 · 루트): (경로, 이름)
List<(String, String)> get volumes => [ List<(String, String)> get volumes => [
...local, ...local,
for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label), for (final s in c.settings.remote.webdavServers) ('$davScheme${s.id}/', s.label),
for (final s in c.settings.sftpServers) ('$sftpScheme${s.id}/', s.label), for (final s in c.settings.remote.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.smbServers) ('$smbScheme${s.id}/', s.label),
// l: 평문 FTP 는 이름 앞에 ⚠ // 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.shizukuEnabled && shizukuSupported()) ('$shizukuScheme$shizukuServer/', tr('시스템 (읽기 전용)')),
if (c.settings.tools.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')), if (c.settings.tools.rootEnabled && rootSupported()) (rootHome, tr('루트 (읽기 전용)')),
]; ];

View File

@ -237,7 +237,7 @@ class ExplorerPaneHeader extends StatelessWidget {
/// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력) /// 202: 창 위 줄 끝 - 탭이 넘쳐도 늘 보이는 버튼 (WebDAV 서버 추가 · 경로 입력)
List<Widget> _end(BuildContext context) => [ List<Widget> _end(BuildContext context) => [
if (ctl.c.settings.webdavServers.isEmpty) if (ctl.c.settings.remote.webdavServers.isEmpty)
TextButton.icon( TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,

View File

@ -18,7 +18,7 @@ class FtpSettings extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final servers = c.settings.ftpServers; final servers = c.settings.remote.ftpServers;
return Column(children: [ return Column(children: [
SettingTile( SettingTile(
leading: const Icon(Icons.folder_shared_outlined), leading: const Icon(Icons.folder_shared_outlined),
@ -81,7 +81,7 @@ Future<bool> deleteFtpServer(BuildContext context, AppController c, FtpServer s)
ok: tr('지우기'), ok: tr('지우기'),
); );
if (!ok) return false; 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; return true;
} }
@ -124,7 +124,7 @@ Future<bool> askTrustFtpCert(BuildContext context, String serverLabel, FtpCertEx
/// 믿은 인증서를 설정에 저장 (탐색기에서 처음 연결할 때) /// 믿은 인증서를 설정에 저장 (탐색기에서 처음 연결할 때)
Future<void> trustFtpCert(AppController c, String id, FtpCertException e) => Future<void> 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) /// FTP 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null)
Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServer? old}) async { Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServer? old}) async {
@ -312,9 +312,9 @@ Future<FtpServer?> editFtpServer(BuildContext context, AppController c, {FtpServ
), ),
); );
if (saved == null) return null; if (saved == null) return null;
await c.updateSettings((x) => x.ftpServers = [ await c.updateSettings((x) => x.remote.ftpServers = [
for (final y in x.ftpServers) y.id == saved.id ? saved : y, for (final y in x.remote.ftpServers) y.id == saved.id ? saved : y,
if (!x.ftpServers.any((y) => y.id == saved.id)) saved, if (!x.remote.ftpServers.any((y) => y.id == saved.id)) saved,
]); ]);
return saved; return saved;
} }

View File

@ -139,11 +139,11 @@ class _MasterDialogState extends State<_MasterDialog> {
/// 저장된 실제 비밀번호 · API 키를 지운다 (서버 목록 · 아이디는 남김) /// 저장된 실제 비밀번호 · API 키를 지운다 (서버 목록 · 아이디는 남김)
Future<void> clearStoredSecrets(AppController c) => c.updateSettings((s) => s Future<void> 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 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김) // 88: SFTP 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김)
..sftpServers = [for (final x in s.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')] ..remote.sftpServers = [for (final x in s.remote.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')]
..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')] ..remote.smbServers = [for (final x in s.remote.smbServers) x.copyWith(password: '')]
..ftpServers = [for (final x in s.ftpServers) x.copyWith(password: '')] ..remote.ftpServers = [for (final x in s.remote.ftpServers) x.copyWith(password: '')]
// AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17) // AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17)
..aiImage.services = [for (final x in s.aiImage.services) x.copyWith(apiKey: '')] ..aiImage.services = [for (final x in s.aiImage.services) x.copyWith(apiKey: '')]
..openSubtitlesKey = '' ..openSubtitlesKey = ''

View File

@ -335,12 +335,12 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('원격 서버 응답 기다리기')), title: Text(tr('원격 서버 응답 기다리기')),
subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')), subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')),
trailing: DropdownButton<int>( trailing: DropdownButton<int>(
value: RemoteIdle.choices.contains(s.remoteIdleSeconds) ? s.remoteIdleSeconds : RemoteIdle.defaultSeconds, value: RemoteIdle.choices.contains(s.remote.idleSeconds) ? s.remote.idleSeconds : RemoteIdle.defaultSeconds,
items: [ items: [
for (final n in RemoteIdle.choices) for (final n in RemoteIdle.choices)
DropdownMenuItem(value: n, child: Text(n == RemoteIdle.defaultSeconds ? trf('{0}초 (기본)', [n]) : trf('{0}초', [n]))), 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), ViewerSettings(c: c),

View File

@ -206,13 +206,13 @@ class _PlaySettingsSectionState extends State<PlaySettingsSection> {
title: Text(tr('로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때')), title: Text(tr('로그인이 필요한 WebDAV 동영상을 다른 앱으로 열 때')),
subtitle: Text(tr('다른 앱에는 아이디 · 비밀번호를 넘기지 않습니다. 받아서 열기는 어느 앱에서나 열리고, 주소로 열기는 아이디를 묻는 앱 (VLC 등) 만 열립니다')), subtitle: Text(tr('다른 앱에는 아이디 · 비밀번호를 넘기지 않습니다. 받아서 열기는 어느 앱에서나 열리고, 주소로 열기는 아이디를 묻는 앱 (VLC 등) 만 열립니다')),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
value: s.davExternalOpen, value: s.remote.davExternalOpen,
items: [ items: [
DropdownMenuItem(value: 'ask', child: Text(tr('매번 묻기 (기본)'))), DropdownMenuItem(value: 'ask', child: Text(tr('매번 묻기 (기본)'))),
DropdownMenuItem(value: 'fetch', child: Text(tr('늘 받아서 열기'))), DropdownMenuItem(value: 'fetch', child: Text(tr('늘 받아서 열기'))),
DropdownMenuItem(value: 'url', 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( SettingTile(

View File

@ -20,7 +20,7 @@ class SftpSettings extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final servers = c.settings.sftpServers; final servers = c.settings.remote.sftpServers;
return Column(children: [ return Column(children: [
SettingTile( SettingTile(
leading: const Icon(Icons.vpn_key_outlined), leading: const Icon(Icons.vpn_key_outlined),
@ -83,7 +83,7 @@ Future<bool> deleteSftpServer(BuildContext context, AppController c, SftpServer
ok: tr('지우기'), ok: tr('지우기'),
); );
if (!ok) return false; 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; return true;
} }
@ -137,8 +137,8 @@ Future<bool> askTrustHostKey(BuildContext context, String serverLabel, SftpHostK
/// 믿은 서버 키를 설정에 저장 (탐색기에서 처음 연결할 때) /// 믿은 서버 키를 설정에 저장 (탐색기에서 처음 연결할 때)
Future<void> trustSftpHostKey(AppController c, String id, SftpHostKeyException e) => Future<void> trustSftpHostKey(AppController c, String id, SftpHostKeyException e) =>
c.updateSettings((x) => x.sftpServers = [ c.updateSettings((x) => x.remote.sftpServers = [
for (final y in x.sftpServers) y.id == id ? y.copyWith(hostKeyType: e.type, hostKeyFingerprint: e.fingerprint) : y, for (final y in x.remote.sftpServers) y.id == id ? y.copyWith(hostKeyType: e.type, hostKeyFingerprint: e.fingerprint) : y,
]); ]);
/// SFTP 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null) /// SFTP 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null)
@ -364,9 +364,9 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
), ),
); );
if (saved == null) return null; if (saved == null) return null;
await c.updateSettings((x) => x.sftpServers = [ await c.updateSettings((x) => x.remote.sftpServers = [
for (final y in x.sftpServers) y.id == saved.id ? saved : y, for (final y in x.remote.sftpServers) y.id == saved.id ? saved : y,
if (!x.sftpServers.any((y) => y.id == saved.id)) saved, if (!x.remote.sftpServers.any((y) => y.id == saved.id)) saved,
]); ]);
return saved; return saved;
} }

View File

@ -19,7 +19,7 @@ class SmbSettings extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final servers = c.settings.smbServers; final servers = c.settings.remote.smbServers;
return Column(children: [ return Column(children: [
SettingTile( SettingTile(
leading: const Icon(Icons.dns_outlined), leading: const Icon(Icons.dns_outlined),
@ -40,8 +40,8 @@ class SmbSettings extends StatelessWidget {
title: Text(tr('SMB 서명 · 암호화 필수')), title: Text(tr('SMB 서명 · 암호화 필수')),
subtitle: Text(tr('서명도 암호화도 되지 않는 연결은 열지 않습니다 (중간에서 내용을 보거나 바꾸지 못하게). ' subtitle: Text(tr('서명도 암호화도 되지 않는 연결은 열지 않습니다 (중간에서 내용을 보거나 바꾸지 못하게). '
'오래된 NAS 는 붙지 않을 수 있습니다. 꺼 두어도 그런 서버는 탐색기 탭에 ⚠ 를 보입니다')), '오래된 NAS 는 붙지 않을 수 있습니다. 꺼 두어도 그런 서버는 탐색기 탭에 ⚠ 를 보입니다')),
value: c.settings.smbRequireSecure, value: c.settings.remote.smbRequireSecure,
onChanged: (v) => c.updateSettings((x) => x.smbRequireSecure = v), onChanged: (v) => c.updateSettings((x) => x.remote.smbRequireSecure = v),
), ),
), ),
for (final s in servers) for (final s in servers)
@ -91,7 +91,7 @@ Future<bool> deleteSmbServer(BuildContext context, AppController c, SmbServer s)
ok: tr('지우기'), ok: tr('지우기'),
); );
if (!ok) return false; 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; return true;
} }
@ -275,9 +275,9 @@ Future<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServ
), ),
); );
if (saved == null) return null; if (saved == null) return null;
await c.updateSettings((x) => x.smbServers = [ await c.updateSettings((x) => x.remote.smbServers = [
for (final y in x.smbServers) y.id == saved.id ? saved : y, for (final y in x.remote.smbServers) y.id == saved.id ? saved : y,
if (!x.smbServers.any((y) => y.id == saved.id)) saved, if (!x.remote.smbServers.any((y) => y.id == saved.id)) saved,
]); ]);
return saved; return saved;
} }

View File

@ -19,7 +19,7 @@ class WebDavSettings extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final servers = c.settings.webdavServers; final servers = c.settings.remote.webdavServers;
return Column(children: [ return Column(children: [
SettingTile( SettingTile(
leading: const Icon(Icons.cloud_outlined), leading: const Icon(Icons.cloud_outlined),
@ -78,7 +78,7 @@ Future<bool> deleteDavServer(BuildContext context, AppController c, DavServer s)
ok: tr('지우기'), ok: tr('지우기'),
); );
if (!ok) return false; 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; return true;
} }
@ -244,9 +244,9 @@ Future<DavServer?> editDavServer(BuildContext context, AppController c, {DavServ
), ),
); );
if (saved == null) return null; if (saved == null) return null;
await c.updateSettings((x) => x.webdavServers = [ await c.updateSettings((x) => x.remote.webdavServers = [
for (final y in x.webdavServers) y.id == saved.id ? saved : y, for (final y in x.remote.webdavServers) y.id == saved.id ? saved : y,
if (!x.webdavServers.any((y) => y.id == saved.id)) saved, if (!x.remote.webdavServers.any((y) => y.id == saved.id)) saved,
]); ]);
return saved; return saved;
} }

View File

@ -128,18 +128,18 @@ void main() {
}); });
testWidgets('46: WebDAV 서버 지우기는 확인 뒤', (tester) async { 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.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: WebDavSettings(c: c)))));
await tester.tap(find.byTooltip('지우기')); await tester.tap(find.byTooltip('지우기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('WebDAV 서버 "집 NAS" 을(를) 지울까요?'), findsOneWidget); expect(find.text('WebDAV 서버 "집 NAS" 을(를) 지울까요?'), findsOneWidget);
await tester.tap(find.text('취소')); await tester.tap(find.text('취소'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.webdavServers, hasLength(1)); expect(c.settings.remote.webdavServers, hasLength(1));
await tester.tap(find.byTooltip('지우기')); await tester.tap(find.byTooltip('지우기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '지우기')); await tester.tap(find.widgetWithText(FilledButton, '지우기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(c.settings.webdavServers, isEmpty); expect(c.settings.remote.webdavServers, isEmpty);
}); });
} }

View File

@ -32,7 +32,7 @@ void main() {
], everyElement(isTrue)); ], everyElement(isTrue));
expect(s.cleanupPrechecked, ['work', 'download']); expect(s.cleanupPrechecked, ['work', 'download']);
expect(s.tripleTapAction, 'move'); 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], expect([s.browser.pauseOnLeave, s.run.autoCheckUpdates, s.browser.adSkip, s.browser.adHide, s.run.inBackground, s.swipeNav],
everyElement(isTrue)); everyElement(isTrue));
@ -50,11 +50,11 @@ void main() {
..recycleOnDelete = false ..recycleOnDelete = false
..cleanupPrechecked = ['logs'] ..cleanupPrechecked = ['logs']
..tripleTapAction = 'none' ..tripleTapAction = 'none'
..davExternalOpen = 'url') ..remote.davExternalOpen = 'url')
.toJson()); .toJson());
expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords, expect([b.run.startMenuShortcut, b.copy.syncStopToast, b.swipeWrap, b.migrationNotice, b.allowInnerToOuter, b.rememberPasswords,
b.recycleOnDelete], everyElement(isFalse)); 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('안쪽 → 바깥 허용을 끄면 늘 막는다', () { test('안쪽 → 바깥 허용을 끄면 늘 막는다', () {
@ -69,7 +69,7 @@ void main() {
final secrets = MemorySecretStore(); final secrets = MemorySecretStore();
final st = SettingsStore(p.join(dir.path, 'settings.json'), secrets); final st = SettingsStore(p.join(dir.path, 'settings.json'), secrets);
final s = await st.load(); 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); await st.save(s);
expect(secrets.values['dav.pw.d'], 'pw'); expect(secrets.values['dav.pw.d'], 'pw');
s.rememberPasswords = false; s.rememberPasswords = false;
@ -82,7 +82,7 @@ void main() {
testWidgets('로그인이 필요한 WebDAV 를 다른 앱으로 - "$mode" 로 정해 두면 묻지 않는다', (tester) async { testWidgets('로그인이 필요한 WebDAV 를 다른 앱으로 - "$mode" 로 정해 두면 묻지 않는다', (tester) async {
final shell = _Shell(); final shell = _Shell();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), 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')]); DavRegistry.configure([const DavServer(id: 'n', name: 'n', url: 'http://127.0.0.1:9', user: 'u', password: 'p')]);
addTearDown(() => DavRegistry.configure([])); addTearDown(() => DavRegistry.configure([]));
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox()))); await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox())));

View File

@ -91,8 +91,8 @@ void main() {
await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '저장'))); await act(tester, () => tester.tap(find.widgetWithText(FilledButton, '저장')));
// 저장하면 설정에 남고 오른쪽 창이 그 서버로 // 저장하면 설정에 남고 오른쪽 창이 그 서버로
await settle(tester, () => find.text('원격').evaluate().isNotEmpty); await settle(tester, () => find.text('원격').evaluate().isNotEmpty);
expect(c.settings.webdavServers.single.url, server.url); expect(c.settings.remote.webdavServers.single.url, server.url);
final id = c.settings.webdavServers.single.id; final id = c.settings.remote.webdavServers.single.id;
expect(find.text('집 NAS'), findsNWidgets(3)); // 두 창의 탭 + 오른쪽 창의 맨 위 줄 expect(find.text('집 NAS'), findsNWidgets(3)); // 두 창의 탭 + 오른쪽 창의 맨 위 줄
expect(find.text('WebDAV'), findsNothing); expect(find.text('WebDAV'), findsNothing);
@ -121,7 +121,7 @@ void main() {
testWidgets('178: 설정 옵션에 --remove-source-files 가 있으면 체크 켬 · [이동] · 경고, 끄면 정말 원본을 남긴다', (tester) async { testWidgets('178: 설정 옵션에 --remove-source-files 가 있으면 체크 켬 · [이동] · 경고, 끄면 정말 원본을 남긴다', (tester) async {
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
c.settings c.settings
..webdavServers = [s] ..remote.webdavServers = [s]
..copy.rsyncOptions = '-av --remove-source-files' ..copy.rsyncOptions = '-av --remove-source-files'
..copy.rsyncPaths = [left, 'dav://nas/']; ..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]); DavRegistry.configure([s]);
@ -164,7 +164,7 @@ void main() {
}; };
addTearDown(() => SecretGate.check = null); addTearDown(() => SecretGate.check = null);
c.settings c.settings
..webdavServers = [s] ..remote.webdavServers = [s]
..explorer.paths = [left, left] ..explorer.paths = [left, left]
..folderBookmarks = [const FolderBookmark(name: 'NAS 원격', path: 'dav://nas/원격')]; ..folderBookmarks = [const FolderBookmark(name: 'NAS 원격', path: 'dav://nas/원격')];
DavRegistry.configure([s]); DavRegistry.configure([s]);
@ -192,7 +192,7 @@ void main() {
testWidgets('Rsync 화면: 로컬 ↔ WebDAV 폴더 → (앱이 직접 맞춤 · rsync 필요 없음)', (tester) async { testWidgets('Rsync 화면: 로컬 ↔ WebDAV 폴더 → (앱이 직접 맞춤 · rsync 필요 없음)', (tester) async {
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
c.settings c.settings
..webdavServers = [s] ..remote.webdavServers = [s]
..copy.rsyncPaths = [left, 'dav://nas/']; ..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]); DavRegistry.configure([s]);
await pump(tester, ExplorerPage(device: device, c: c, rsync: true)); await pump(tester, ExplorerPage(device: device, c: c, rsync: true));

View File

@ -45,11 +45,11 @@ void main() {
final secrets = MemorySecretStore(); final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = AppSettings() 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); await store.save(s);
expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('ftp-secret'))); expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('ftp-secret')));
expect(secrets.values['ftp.pw.f1'], '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')); 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({ final stripped = AppSettings.stripSecrets({
'ftpServers': [ 'ftpServers': [

View File

@ -190,7 +190,7 @@ void main() {
AppController controller() { AppController controller() {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings 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' ..openSubtitlesKey = 'k'
..openSubtitlesUser = 'ou' ..openSubtitlesUser = 'ou'
..openSubtitlesPassword = 'op'; ..openSubtitlesPassword = 'op';
@ -238,7 +238,7 @@ void main() {
await work(t); await work(t);
expect(result, isTrue); expect(result, isTrue);
expect(lock.hasMaster, isFalse); 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 { testWidgets('잊었습니다: 저장된 비밀번호 · API 키를 지우고 마스터 초기화 (서버 목록 · 아이디는 남김)', (t) async {
@ -254,8 +254,8 @@ void main() {
await t.tap(find.widgetWithText(FilledButton, '지우고 초기화')); await t.tap(find.widgetWithText(FilledButton, '지우고 초기화'));
await work(t); await work(t);
expect(find.text('초기화했습니다'), findsOneWidget); expect(find.text('초기화했습니다'), findsOneWidget);
expect(c.settings.webdavServers.single.password, ''); expect(c.settings.remote.webdavServers.single.password, '');
expect(c.settings.webdavServers.single.user, 'u'); expect(c.settings.remote.webdavServers.single.user, 'u');
expect((c.settings.openSubtitlesKey, c.settings.openSubtitlesPassword, c.settings.openSubtitlesUser), ('', '', 'ou')); expect((c.settings.openSubtitlesKey, c.settings.openSubtitlesPassword, c.settings.openSubtitlesUser), ('', '', 'ou'));
expect(lock.hasMaster, isFalse); expect(lock.hasMaster, isFalse);
}); });
@ -285,8 +285,8 @@ void main() {
await c.updateSettings((s) => s await c.updateSettings((s) => s
..openSubtitlesKey = 'k' ..openSubtitlesKey = 'k'
..openSubtitlesPassword = 'op' ..openSubtitlesPassword = 'op'
..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..remote.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.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')]); ..aiImage.services = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')]);
await store.save(c.settings); await store.save(c.settings);
bool secret(String k) => k.startsWith('os.') || k.startsWith('dav.pw.') || k.startsWith('sftp.') || k.startsWith('ai.'); 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); await store.save(c.settings);
expect(secrets.values.keys.where(secret), isEmpty); 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 { test('관리자 판단 (10/21): "앱 설정 전체" 는 이 판이 모르는 설정 키도 지우고, 다른 판의 비밀 값은 "저장된 비밀번호" 를 골랐을 때만', () async {
@ -330,7 +330,7 @@ void main() {
final c = controller()..settings.explorer.layout = 'single'; final c = controller()..settings.explorer.layout = 'single';
await resetAllSettings(c); await resetAllSettings(c);
expect(c.settings.explorer.layout, AppSettings().explorer.layout); expect(c.settings.explorer.layout, AppSettings().explorer.layout);
expect(c.settings.webdavServers, isEmpty); expect(c.settings.remote.webdavServers, isEmpty);
}); });
}); });

View File

@ -134,14 +134,14 @@ void main() {
tearDown(() => RemoteIdle.limit = const Duration(seconds: RemoteIdle.defaultSeconds)); tearDown(() => RemoteIdle.limit = const Duration(seconds: RemoteIdle.defaultSeconds));
test('설정 "원격 서버 응답 기다리기": 처음 60초 · 30 · 60 · 120 · 300 만 · 그 밖의 값은 60 · 오류 글은 초까지', () { test('설정 "원격 서버 응답 기다리기": 처음 60초 · 30 · 60 · 120 · 300 만 · 그 밖의 값은 60 · 오류 글은 초까지', () {
expect(AppSettings().remoteIdleSeconds, 60); expect(AppSettings().remote.idleSeconds, 60);
for (final n in RemoteIdle.choices) { 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]) { 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초)'); expect('${const RemoteIdleTimeout(60)}', '서버가 답하지 않습니다 (60초)');
}); });

View File

@ -162,7 +162,7 @@ void main() {
..copy.rsyncSource = 'custom' ..copy.rsyncSource = 'custom'
..copy.rsyncPath = rsync ..copy.rsyncPath = rsync
..copy.rsyncOptions = opts ..copy.rsyncOptions = opts
..webdavServers = [dav()]; ..remote.webdavServers = [dav()];
for (final (label, target, local) in [('실시간 (로컬)', p.join(tmp.path, 'live'), true), ('실시간 (원격)', 'dav://$id/l', false)]) { 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'); final dir = local ? target : p.join(remote.path, 'l');
writeScene(src, dir, srcFiles: _src, dstFiles: _dst); writeScene(src, dir, srcFiles: _src, dstFiles: _dst);

View File

@ -37,7 +37,7 @@ void main() {
final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass'); final s = DavServer(id: 'nas', name: '집 NAS', url: server.url, user: 'user', password: 'pass');
c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings c.settings
..webdavServers = [s] ..remote.webdavServers = [s]
..explorer.layout = 'single' ..explorer.layout = 'single'
..copy.rsyncPaths = [left, 'dav://nas/']; ..copy.rsyncPaths = [left, 'dav://nas/'];
DavRegistry.configure([s]); DavRegistry.configure([s]);

View File

@ -27,7 +27,7 @@ void main() {
PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()), PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()),
settingsStore: store); settingsStore: store);
c.settings = await store.load(); 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'), 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()), PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()),
settingsStore: store); settingsStore: store);
c.settings = await store.load(); 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'), const DavServer(id: 'd1', name: 'nas', url: 'https://nas', user: 'u', password: 'old'),
]); ]);
secrets.failWrite = true; 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); expect(store.secretIssue.value?.revertsToOld, isTrue);
final key = GlobalKey<ScaffoldMessengerState>(); final key = GlobalKey<ScaffoldMessengerState>();

View File

@ -37,7 +37,7 @@ void main() {
final s = await store().load(); final s = await store().load();
expect(s.openSubtitlesKey, 'KEY-1'); expect(s.openSubtitlesKey, 'KEY-1');
expect(s.openSubtitlesPassword, 'os-pw'); 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'])); expect(secrets.values.values, containsAll(['KEY-1', 'me', 'os-pw', 'dav-pw']));
for (final f in [file, '$file.bak']) { for (final f in [file, '$file.bak']) {
expect(text(f), isNot(contains('dav-pw')), reason: f); expect(text(f), isNot(contains('dav-pw')), reason: f);
@ -50,7 +50,7 @@ void main() {
expect(j['openSubtitlesSaved'], {'key': true, 'user': true, 'password': true}); expect(j['openSubtitlesSaved'], {'key': true, 'user': true, 'password': true});
// 다시 켜도 그대로 // 다시 켜도 그대로
final again = await store().load(); 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'); expect(again.openSubtitlesKey, 'KEY-1');
}); });
@ -59,32 +59,32 @@ void main() {
await st.load(); await st.load();
await st.save(AppSettings() await st.save(AppSettings()
..openSubtitlesKey = 'KEY-2' ..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'})); File(file).writeAsStringSync(jsonEncode({'homeUrl': 'https://old.example'}));
final s = await store().load(); final s = await store().load();
expect(s.browser.homeUrl, 'https://old.example'); expect(s.browser.homeUrl, 'https://old.example');
expect(s.webdavServers.single.url, 'https://nas'); expect(s.remote.webdavServers.single.url, 'https://nas');
expect(s.webdavServers.single.password, 'pw'); expect(s.remote.webdavServers.single.password, 'pw');
expect(s.openSubtitlesKey, 'KEY-2'); expect(s.openSubtitlesKey, 'KEY-2');
// 사용자가 일부러 서버를 모두 지웠으면 (목록 항목은 있음) 되살리지 않는다 // 사용자가 일부러 서버를 모두 지웠으면 (목록 항목은 있음) 되살리지 않는다
final st2 = store(); final st2 = store();
final s2 = await st2.load(); final s2 = await st2.load();
await st2.save(s2..webdavServers = []); await st2.save(s2..remote.webdavServers = []);
expect((await store().load()).webdavServers, isEmpty); expect((await store().load()).remote.webdavServers, isEmpty);
}); });
test('비밀번호를 지우거나 서버를 지우면 안전 저장소에서도 지운다', () async { test('비밀번호를 지우거나 서버를 지우면 안전 저장소에서도 지운다', () async {
final st = store(); final st = store();
final s = await st.load(); final s = await st.load();
s.webdavServers = [ s.remote.webdavServers = [
const DavServer(id: 'a', name: 'a', url: 'https://a', password: 'pa'), const DavServer(id: 'a', name: 'a', url: 'https://a', password: 'pa'),
const DavServer(id: 'b', name: 'b', url: 'https://b', password: 'pb'), const DavServer(id: 'b', name: 'b', url: 'https://b', password: 'pb'),
]; ];
s.openSubtitlesPassword = 'x'; s.openSubtitlesPassword = 'x';
await st.save(s); await st.save(s);
expect(secrets.values.values, containsAll(['pa', 'pb', 'x'])); expect(secrets.values.values, containsAll(['pa', 'pb', 'x']));
s.webdavServers = [s.webdavServers.first]; s.remote.webdavServers = [s.remote.webdavServers.first];
s.openSubtitlesPassword = ''; s.openSubtitlesPassword = '';
await st.save(s); await st.save(s);
expect(secrets.values.values, isNot(contains('pb'))); expect(secrets.values.values, isNot(contains('pb')));
@ -113,13 +113,13 @@ void main() {
test('버전 보관본으로 되살려도 비밀번호는 안전 저장소의 것이 그대로', () async { test('버전 보관본으로 되살려도 비밀번호는 안전 저장소의 것이 그대로', () async {
final st = store(); final st = store();
final s = await st.load(); 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); await st.save(s);
final snap = VersionSnapshot(dir.path); final snap = VersionSnapshot(dir.path);
await snap.save('1.0'); await snap.save('1.0');
expect(text(p.join(snap.dirOf('1.0'), 'settings.json')), isNot(contains('"pw"'))); expect(text(p.join(snap.dirOf('1.0'), 'settings.json')), isNot(contains('"pw"')));
await snap.restoreFrom(snap.dirOf('1.0')); 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 { test('40-1: 안전 저장소에 쓰지 못하면 예전 평문은 파일에 그대로 · 새 값은 평문으로 쓰지 않음 · 알림 · [다시 시도]', () async {
@ -136,9 +136,9 @@ void main() {
if (File(f).existsSync()) expect(text(f), contains('old-pw'), reason: f); 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); await st.save(s);
expect(text(file), isNot(contains('new-pw'))); expect(text(file), isNot(contains('new-pw')));
expect(text(file), contains('old-pw')); expect(text(file), contains('old-pw'));
@ -160,7 +160,7 @@ void main() {
final st = store(); final st = store();
final s = await st.load(); final s = await st.load();
expect(st.secretIssue.value?.kind, SecretIssueKind.readFailed); 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); await st.save(s);
expect(text(file), isNot(contains('typed'))); expect(text(file), isNot(contains('typed')));
expect(secrets.values, isEmpty); expect(secrets.values, isEmpty);
@ -196,7 +196,7 @@ void main() {
expect(kept, isNot(contains(r'x\"y'))); expect(kept, isNot(contains(r'x\"y')));
expect(kept, contains('"openSubtitlesKey": ""')); expect(kept, contains('"openSubtitlesKey": ""'));
// .bak 의 값은 저장소로 옮기고 .bak 에서 지운다 // .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(secrets.values['os.password'], 'bak-secret');
expect(text('$file.bak'), isNot(contains('bak-pw'))); expect(text('$file.bak'), isNot(contains('bak-pw')));
expect(text('$file.bak'), isNot(contains('bak-secret'))); expect(text('$file.bak'), isNot(contains('bak-secret')));

View File

@ -41,8 +41,8 @@ void main() {
..values['sec.master'] = 'hash'; ..values['sec.master'] = 'hash';
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = await store.load() final s = await store.load()
..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..remote.webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')]
..sftpServers = [ ..remote.sftpServers = [
const SftpServer(id: 's1', name: 'a', host: 'h', user: 'u', password: 'sp1', privateKey: 'KEY1', keyPassphrase: 'kp1'), 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'), 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]}, {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'}); {'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 · 모르는 꼴 · 마스터는 그대로 // 서버 s1 을 지움: s1 의 아는 열쇠 (pw · key · kp) 만 없어지고, s2 · 모르는 꼴 · 마스터는 그대로
s.sftpServers = [s.sftpServers.last]; s.remote.sftpServers = [s.remote.sftpServers.last];
await store.save(s); await store.save(s);
expect(secrets.values.keys.where((k) => k.endsWith('.s1')), ['sftp.cert.s1']); expect(secrets.values.keys.where((k) => k.endsWith('.s1')), ['sftp.cert.s1']);
expect(secrets.values['sftp.pw.s2'], 'sp2'); expect(secrets.values['sftp.pw.s2'], 'sp2');
@ -74,10 +74,10 @@ void main() {
..openSubtitlesKey = 'k' ..openSubtitlesKey = 'k'
..openSubtitlesUser = 'u' ..openSubtitlesUser = 'u'
..openSubtitlesPassword = 'op' ..openSubtitlesPassword = 'op'
..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')] ..remote.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')] ..remote.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')] ..remote.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.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')]; ..aiImage.services = [const AiService(id: 'a1', name: 'n', url: 'https://ai', kind: 'openai', apiKey: 'ak')];
await store.save(s); await store.save(s);
final made = secrets.values.keys.where((k) => !k.startsWith('sec.')).toList()..sort(); final made = secrets.values.keys.where((k) => !k.startsWith('sec.')).toList()..sort();

View File

@ -51,7 +51,7 @@ void main() {
final local = Directory(p.join(tmp.path, 'local'))..createSync(); final local = Directory(p.join(tmp.path, 'local'))..createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await tester.runAsync(() => c.updateSettings((x) => x 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])); ..explorer.paths = ['sftp://s1/', local.path]));
tester.view.physicalSize = const Size(1600, 900); tester.view.physicalSize = const Size(1600, 900);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -69,8 +69,8 @@ void main() {
await tester.tap(find.text('믿고 연결')); await tester.tap(find.text('믿고 연결'));
await settle(tester, () => find.text('hello.txt').evaluate().isNotEmpty); await settle(tester, () => find.text('hello.txt').evaluate().isNotEmpty);
expect(find.text('hello.txt'), findsOneWidget); expect(find.text('hello.txt'), findsOneWidget);
expect(c.settings.sftpServers.single.hostKeyFingerprint, s.fingerprint); expect(c.settings.remote.sftpServers.single.hostKeyFingerprint, s.fingerprint);
expect(c.settings.sftpServers.single.hostKeyType, 'ssh-ed25519'); expect(c.settings.remote.sftpServers.single.hostKeyType, 'ssh-ed25519');
// 화면 내리기 · 연결 닫기 // 화면 내리기 · 연결 닫기
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
await tester.runAsync(() async => SftpRegistry.configure(const [])); await tester.runAsync(() async => SftpRegistry.configure(const []));

View File

@ -28,7 +28,7 @@ void main() {
final secrets = MemorySecretStore(); final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = AppSettings() final s = AppSettings()
..sftpServers = [ ..remote.sftpServers = [
const SftpServer( const SftpServer(
id: 's1', id: 's1',
name: 'NAS', name: 'NAS',
@ -51,7 +51,7 @@ void main() {
expect(secrets.values['sftp.kp.s1'], 'kp-secret'); expect(secrets.values['sftp.kp.s1'], 'kp-secret');
expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY'));
final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); 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.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.password, x.keyPassphrase), ('pw-secret', 'kp-secret'));
expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); expect(x.privateKey, contains('OPENSSH PRIVATE KEY'));

View File

@ -196,10 +196,10 @@ void main() {
addTearDown(() => tmp.deleteSync(recursive: true)); addTearDown(() => tmp.deleteSync(recursive: true));
final secrets = MemorySecretStore(); final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); 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(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('smb-Pw-5Qh9kT4r')));
expect(secrets.values['smb.pw.m1'], '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')); 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); expect(parseSmbAddress('/storage/x'), isNull);
}); });
test('보안 검토 6: 서명 · 암호화가 안 된 연결은 탐색기 탭에 ⚠ · 설정 "필수" 는 다리에 넘기고 막힌 이유를 알림', () async { test('보안 검토 6: 서명 · 암호화가 안 된 연결은 탐색기 탭에 ⚠ · 설정 "필수" 는 다리에 넘기고 막힌 이유를 알림', () async {
expect(AppSettings().smbRequireSecure, isFalse, reason: '처음 끔 (오래된 NAS)'); expect(AppSettings().remote.smbRequireSecure, isFalse, reason: '처음 끔 (오래된 NAS)');
expect(AppSettings.fromJson(const {'smbRequireSecure': true}).smbRequireSecure, isTrue); expect(AppSettings.fromJson(const {'smbRequireSecure': true}).remote.smbRequireSecure, isTrue);
expect(AppSettings.fromJson(const {'smbRequireSecure': 'x'}).smbRequireSecure, isFalse); expect(AppSettings.fromJson(const {'smbRequireSecure': 'x'}).remote.smbRequireSecure, isFalse);
SmbRegistry.configure(const [server]); SmbRegistry.configure(const [server]);
expect(DavPath.parse('smb://m1/').mark, '🗄', reason: '연결 전에는 모름'); expect(DavPath.parse('smb://m1/').mark, '🗄', reason: '연결 전에는 모름');
fake fake

View File

@ -153,7 +153,7 @@ void main() {
rf('mirror/old.txt', 'o'); rf('mirror/old.txt', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); 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); final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.copy.liveSyncPairs = [pair]; c.settings.copy.liveSyncPairs = [pair];
final live = LiveSync(c); 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 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())); 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 = <String>[]; final bad = <String>[];
for (final opts in sceneOptions) { for (final opts in sceneOptions) {
writeScene(src, want); writeScene(src, want);
@ -367,7 +367,7 @@ void main() {
rf('rqd/only.tmp', 'o'); rf('rqd/only.tmp', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings 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.rsyncOptions = '-av --exclude=*.tmp'
..copy.liveSyncPairs = [ ..copy.liveSyncPairs = [
LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd', LiveSyncPair(p.join(local.path, 'rq'), 'dav://$id/rqd',