설정 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));
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'),
];

View File

@ -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)));
}
}

View File

@ -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;
}

View File

@ -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<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 그림 · 해상도 올리기 ─────────
@ -352,8 +333,11 @@ class AppSettings {
/// 탐색기 도구 - 시스템 폴더 (Shizuku) · 루트 · Wi-Fi 공유 · ZIP 묶기
final tools = ExplorerToolSettings();
/// 원격 서버 (WebDAV · SFTP · SMB · FTP)
final remote = RemoteSettings();
/// 설정 분야들 (설정 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]: 안전 저장소를 쓸 수 없을 때만 (예전 파일에 있던 비밀 값을 잃지 않게) 예전처럼 파일에 둔다
Map<String, Object?> 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<String, String>()
..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 {} : {
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) {

View File

@ -85,7 +85,7 @@ Future<void> 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<String>(

View File

@ -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('루트 (읽기 전용)')),
];

View File

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

View File

@ -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<bool> 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<bool> askTrustFtpCert(BuildContext context, String serverLabel, FtpCertEx
/// 믿은 인증서를 설정에 저장 (탐색기에서 처음 연결할 때)
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)
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;
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;
}

View File

@ -139,11 +139,11 @@ class _MasterDialogState extends State<_MasterDialog> {
/// 저장된 실제 비밀번호 · API 키를 지운다 (서버 목록 · 아이디는 남김)
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 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김)
..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 = ''

View File

@ -335,12 +335,12 @@ class _FilesSettingsSectionState extends State<FilesSettingsSection> {
title: Text(tr('원격 서버 응답 기다리기')),
subtitle: Text(tr('WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)')),
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: [
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),

View File

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

View File

@ -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<bool> 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<bool> askTrustHostKey(BuildContext context, String serverLabel, SftpHostK
/// 믿은 서버 키를 설정에 저장 (탐색기에서 처음 연결할 때)
Future<void> 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<SftpServer?> 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;
}

View File

@ -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<bool> 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<SmbServer?> 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;
}

View File

@ -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<bool> 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<DavServer?> 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;
}

View File

@ -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);
});
}

View File

@ -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())));

View File

@ -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));

View File

@ -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': [

View File

@ -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);
});
});

View File

@ -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초)');
});

View File

@ -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);

View File

@ -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]);

View File

@ -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<ScaffoldMessengerState>();

View File

@ -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')));

View File

@ -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();

View File

@ -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 []));

View File

@ -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'));

View File

@ -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

View File

@ -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 = <String>[];
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',