import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart' show sha256; import '../l10n/tr.dart'; import 'dns_resolver.dart'; import 'remote_fs.dart'; import 'secret_gate.dart'; import 'webdav.dart' show DavException, DavItem, DavLockedException; /// FTP · FTPS 서버 설정 (관리자 지시 10/17, l). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). /// [security]: 'explicit' (FTPS - AUTH TLS, 처음 값) · 'implicit' (FTPS 990) · 'none' (평문 FTP - 연결 · 설정에서 "암호화 안 됨" 경고). /// 자체 서명 인증서는 처음 연결 때 사용자가 믿은 지문 ([certFingerprint], SHA-256) 만 받는다 (TOFU - SFTP 호스트 키와 같은 판단). class FtpServer { const FtpServer({ required this.id, required this.name, required this.host, this.port = 21, this.user = '', this.root = '', this.password = '', this.security = 'explicit', this.certFingerprint = '', }); final String id; final String name; final String host; final int port; final String user; /// 서버의 시작 폴더 (비우면 로그인한 곳) final String root; /// 안전 저장소에서 채움 (설정 파일에는 없음) final String password; final String security; /// 믿은 자체 서명 인증서의 지문 "SHA256:xx:xx…" (비밀 아님) final String certFingerprint; bool get tls => security != 'none'; String get label => name.trim().isNotEmpty ? name.trim() : (host.trim().isEmpty ? 'FTP' : host.trim()); FtpServer copyWith({String? name, String? host, int? port, String? user, String? root, String? password, String? security, String? certFingerprint}) => FtpServer( id: id, name: name ?? this.name, host: host ?? this.host, port: port ?? this.port, user: user ?? this.user, root: root ?? this.root, password: password ?? this.password, security: security ?? this.security, certFingerprint: certFingerprint ?? this.certFingerprint, ); /// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만 Map toJson() => { 'id': id, 'name': name, 'host': host, 'port': port, 'user': user, 'root': root, 'hasPassword': password.isNotEmpty, 'security': security, 'certFingerprint': certFingerprint, }; factory FtpServer.fromJson(Map j) => FtpServer( id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', name: j['name'] as String? ?? '', host: j['host'] as String? ?? '', port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => j['security'] == 'implicit' ? 990 : 21 }, user: j['user'] as String? ?? '', root: j['root'] as String? ?? '', // 모르는 값 · 없는 값은 FTPS (지침 3 - 덜 위험한 쪽, 관리자 지시 "처음 값은 FTPS 우선") security: const ['explicit', 'implicit', 'none'].contains(j['security']) ? j['security'] as String : 'explicit', certFingerprint: j['certFingerprint'] as String? ?? '', ); } /// FTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) enum FtpFailure { unreachable, auth, noSecret, certUnknown, certChanged, noTls, permission, notFound, other } class FtpException extends DavException { final FtpFailure kind; const FtpException(super.message, this.kind); } /// 처음 보는 (또는 바뀐) 자체 서명 인증서: 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [FtpServer.certFingerprint] 에 저장 class FtpCertException extends FtpException { final String fingerprint; final String subject; /// 바뀐 경우 예전에 믿은 지문 final String? previous; const FtpCertException(super.message, super.kind, {required this.fingerprint, required this.subject, this.previous}); bool get changed => previous != null; } /// 인증서 지문 "SHA256:AB:CD:…" String certSha256(X509Certificate c) => 'SHA256:${sha256.convert(c.der).bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':')}'; /// 이름 → 연결할 주소 (DNS 강제면 정한 DNS - main 에서 바꾼다). 처음 값: 기기 DNS (주소를 그대로 넘김) typedef FtpResolve = Future Function(String host); /// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝) class _Session { final FtpServer server; final Duration timeout; final InternetAddress addr; RawSocket _raw; StreamSubscription? _sub; final _buf = []; final _lines = []; Completer? _wake; bool _closed = false; bool secure = false; bool mlsd = false; bool _protP = false; /// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위) String home = '/'; _Session._(this.server, this.timeout, this.addr, this._raw); static Future<_Session> open(FtpServer s, Duration timeout, FtpResolve resolve) async { final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first; final raw = await RawSocket.connect(addr, s.port, timeout: timeout); final x = _Session._(s, timeout, addr, raw); if (s.security == 'implicit') { x._raw = await x._secure(raw, null); x.secure = true; } x._listen(); return x; } void _listen() { _sub = _raw.listen((ev) { if (ev == RawSocketEvent.read) { final d = _raw.read(); if (d != null) _feed(d); } else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) { _closed = true; _poke(); } }, onError: (_) { _closed = true; _poke(); }); } void _feed(Uint8List d) { _buf.addAll(d); while (true) { final i = _buf.indexOf(10); if (i < 0) break; var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true); if (line.endsWith('\r')) line = line.substring(0, line.length - 1); _buf.removeRange(0, i + 1); debugLog?.call('< $line'); _lines.add(line); } _poke(); } void _poke() { final w = _wake; _wake = null; w?.complete(); } Future _line() async { final end = DateTime.now().add(timeout); while (_lines.isEmpty) { if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable); final left = end.difference(DateTime.now()); if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다')); _wake = Completer(); await _wake!.future.timeout(left, onTimeout: () {}); } return _lines.removeAt(0); } /// 응답 하나 (여러 줄이면 합침): (코드, 글) Future<(int, String)> reply() async { final first = await _line(); final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0; final text = StringBuffer(first.length > 4 ? first.substring(4) : ''); if (first.length > 3 && first[3] == '-') { while (true) { final l = await _line(); if (l.startsWith('$code ')) { text.write('\n${l.substring(4)}'); break; } text.write('\n${l.startsWith(' ') ? l.substring(1) : l}'); } } return (code, text.toString()); } /// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림) static void Function(String line)? debugLog; void send(String cmd) { debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}'); _raw.write(utf8.encode('$cmd\r\n')); } Future<(int, String)> cmd(String c) async { send(c); return reply(); } /// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException] Future must(String c, {bool Function(int code)? ok, String? path}) async { final (code, text) = await cmd(c); if (ok != null ? ok(code) : code >= 200 && code < 300) return text; throw failure(code, text, path); } FtpException failure(int code, String text, String? path) { if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth); if (code == 550 || code == 450) { final t = text.toLowerCase(); if (t.contains('permission') || t.contains('denied') || t.contains('access')) { return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission); } return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound); } return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other); } /// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만 Future _secure(RawSocket raw, StreamSubscription? sub) async { X509Certificate? bad; try { return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) { bad = c; return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint; }).timeout(timeout); } on HandshakeException { final c = bad; if (c == null) rethrow; final fp = certSha256(c); throw FtpCertException( server.certFingerprint.isEmpty ? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요') : tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'), server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged, fingerprint: fp, subject: c.subject, previous: server.certFingerprint.isEmpty ? null : server.certFingerprint); } } /// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더 Future login() async { final (hello, text) = await reply(); if (hello != 220) throw failure(hello, text, null); if (server.security == 'explicit') { final (code, t) = await cmd('AUTH TLS'); if (code != 234) { // FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야) throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]), FtpFailure.noTls); } final sub = _sub; _sub = null; _raw = await _secure(_raw, sub); secure = true; // RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다 _listen(); } if (secure) { await must('PBSZ 0'); await must('PROT P'); _protP = true; } if (server.user.isNotEmpty || server.password.isNotEmpty) { final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}'); if (code == 331 || code == 332) { await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202); } else if (code != 230) { throw failure(code, t, null); } } else { await must('USER anonymous', ok: (c) => c == 230 || c == 331); } final feat = await cmd('FEAT'); // MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다) mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase()); if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON'); await must('TYPE I'); final pwd = await cmd('PWD'); final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2); if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"'); } /// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기) Future data() async { int? port; final (c1, t1) = await cmd('EPSV'); if (c1 == 229) { final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1); if (m != null) port = int.parse(m[1]!); } if (port == null) { final t = await must('PASV', ok: (c) => c == 227); final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t); if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other); port = int.parse(m[5]!) * 256 + int.parse(m[6]!); } return Socket.connect(addr, port, timeout: timeout); } /// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등) Future secureData(Socket sock) async { if (!_protP) return sock; final x = await SecureSocket.secure(sock, host: server.host.trim(), onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout); return x; } void close() { if (_closed) return; _closed = true; try { send('QUIT'); } catch (_) {} _sub?.cancel(); _raw.close(); } } /// FTP · FTPS 클라이언트 (dart:io 로 직접 - 관리자 지시 l, 패키지 없이). 목록 · 정보 · 만들기 · 지우기 · 옮기기는 서버마다 명령 채널 하나를 /// 열어 두고 차례로 쓰고, 받기 · 올리기는 따로 연결해 (동영상 건너뛰기처럼 여럿이 동시에 읽어도 서로 막지 않게) 끝나면 닫는다. class FtpRemote implements RemoteClient { final FtpServer server; final Duration timeout; FtpRemote(this.server, {this.timeout = const Duration(seconds: 15)}); Future<_Session>? _main; Future _lock = Future.value(); Future<_Session> _connect() async { final s = server; if (s.host.trim().isEmpty) throw FtpException(tr('FTP 서버 주소가 없습니다'), FtpFailure.other); if (s.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); if (s.user.isNotEmpty && s.password.isEmpty) { throw FtpException(tr('저장된 비밀번호가 없습니다. 서버 설정에서 비밀번호를 넣어 주세요'), FtpFailure.noSecret); } _Session? x; try { x = await _Session.open(s, timeout, FtpRegistry.resolve); await x.login(); return x; } catch (e) { x?.close(); throw _explain(e); } } Object _explain(Object e) { if (e is FtpException || e is DavLockedException) return e; if (e is TimeoutException) return FtpException(trf('서버 ({0}) 가 답하지 않습니다', [server.host]), FtpFailure.unreachable); if (e is SocketException) return FtpException(trf('서버 ({0}) 에 연결할 수 없습니다: {1}', [server.host, e.message]), FtpFailure.unreachable); if (e is HandshakeException) return FtpException(trf('암호화 (TLS) 연결을 맺지 못했습니다: {0}', [e.message]), FtpFailure.other); return FtpException('$e', FtpFailure.other); } /// 명령 채널을 차례로 쓴다 (한 번에 하나). 끊겼으면 한 번 다시 연결 Future _run(Future Function(_Session s) f) { final prev = _lock; final done = Completer(); _lock = done.future; return prev.then((_) async { try { for (var attempt = 0;; attempt++) { final _Session s; try { s = await (_main ??= _connect()); } catch (_) { _main = null; rethrow; } try { return await f(s); } on FtpException catch (e) { if (e.kind == FtpFailure.unreachable && attempt == 0) { s.close(); _main = null; continue; } rethrow; } } } catch (e) { if (e is! FtpException && e is! DavLockedException) { _main = null; throw _explain(e); } rethrow; } finally { done.complete(); } }); } /// 서버 기준 경로 ("/" 로 시작) → 서버의 절대 경로 String _abs(_Session s, String rel) { var base = server.root.trim().isEmpty ? s.home : server.root.trim(); if (!base.startsWith('/')) base = '/$base'; if (base.length > 1 && base.endsWith('/')) base = base.substring(0, base.length - 1); final r = rel == '/' || rel.isEmpty ? '' : rel; if (base == '/') return r.isEmpty ? '/' : r; return '$base$r'; } static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); /// MLSD · MLST 의 한 줄 "type=dir;size=1;modify=20261017120000; 이름" static (String, bool, int, DateTime)? parseMlsx(String line) { final sp = line.indexOf(' '); if (sp < 0) return null; final facts = {}; for (final f in line.substring(0, sp).split(';')) { final eq = f.indexOf('='); if (eq > 0) facts[f.substring(0, eq).toLowerCase()] = f.substring(eq + 1); } final name = line.substring(sp + 1); final type = (facts['type'] ?? '').toLowerCase(); if (type == 'cdir' || type == 'pdir' || name == '.' || name == '..') return null; final m = facts['modify'] ?? ''; var when = DateTime.fromMillisecondsSinceEpoch(0); if (m.length >= 14) { when = DateTime.utc(int.parse(m.substring(0, 4)), int.parse(m.substring(4, 6)), int.parse(m.substring(6, 8)), int.parse(m.substring(8, 10)), int.parse(m.substring(10, 12)), int.parse(m.substring(12, 14))); } final isDir = type == 'dir'; return (name, isDir, isDir ? 0 : int.tryParse(facts['size'] ?? '') ?? 0, when); } static const _months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; /// LIST 의 유닉스 꼴 한 줄 "drwxr-xr-x 1 u g 0 Oct 17 12:00 이름" (MLSD 가 없는 서버) static (String, bool, int, DateTime)? parseList(String line) { final m = RegExp(r'^([dl-])[rwxsStT-]{9}\S*\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d{1,2}:\d{2}|\d{4})\s(.+)$').firstMatch(line); if (m == null) return null; var name = m[6]!; if (m[1] == 'l' && name.contains(' -> ')) name = name.substring(0, name.indexOf(' -> ')); if (name == '.' || name == '..') return null; final mon = _months.indexOf(m[3]!.toLowerCase()) + 1; final now = DateTime.now(); final t = m[5]!; final when = t.contains(':') ? DateTime(now.year, mon, int.parse(m[4]!), int.parse(t.split(':')[0]), int.parse(t.split(':')[1])) : DateTime(int.parse(t), mon, int.parse(m[4]!)); final isDir = m[1] == 'd'; return (name, isDir, isDir ? 0 : int.parse(m[2]!), when); } /// 자료 채널로 목록 (MLSD 또는 LIST) 을 읽는다 Future> _listRaw(_Session s, String abs, String rel) async { final plain = await s.data(); // LIST 에 -a 같은 옵션을 붙이면 경로로 읽는 서버가 있다 s.send('${s.mlsd ? 'MLSD' : 'LIST'} $abs'); final (code, text) = await s.reply(); if (code != 150 && code != 125) { plain.destroy(); throw s.failure(code, text, rel); } final sock = await s.secureData(plain); final bytes = await sock.fold>([], (a, b) => a..addAll(b)); final (end, et) = await s.reply(); if (end != 226 && end != 250) throw s.failure(end, et, rel); final out = <(String, bool, int, DateTime)>[]; for (final l in const LineSplitter().convert(utf8.decode(bytes, allowMalformed: true))) { final x = s.mlsd ? parseMlsx(l) : parseList(l); if (x != null) out.add(x); } return out; } @override Future> list(String rel) => _run((s) async => [ for (final (n, dir, size, at) in await _listRaw(s, _abs(s, rel), rel)) DavItem(_child(rel, n), isDir: dir, size: size, modified: at), ]); @override Future stat(String rel) async { if (rel == '/' || rel.isEmpty) return DavItem('/', isDir: true, modified: DateTime.fromMillisecondsSinceEpoch(0)); // 위 폴더의 목록에서 찾는다 (MLST · SIZE 를 모든 서버가 같게 답하지 않아서 - 느리지만 확실하게) try { for (final x in await list(_parent(rel))) { if (x.rel == rel) return x; } } on FtpException catch (e) { if (e.kind == FtpFailure.notFound) return null; rethrow; } return null; } @override Future mkdirs(String rel) async { if (rel == '/' || rel.isEmpty) return; if (await stat(rel) != null) return; await mkdirs(_parent(rel)); await _run((s) => s.must('MKD ${_abs(s, rel)}', path: rel)); } @override Future delete(String rel) async { final st = await stat(rel); if (st == null) return; if (st.isDir) { for (final x in await list(rel)) { await delete(x.rel); } await _run((s) => s.must('RMD ${_abs(s, rel)}', path: rel)); } else { await _run((s) => s.must('DELE ${_abs(s, rel)}', path: rel)); } } @override Future move(String from, String to, {bool overwrite = false}) async { if (await stat(to) != null) { if (!overwrite) throw FtpException(trf('같은 이름이 이미 있습니다: {0}', [to]), FtpFailure.other); await delete(to); } await _run((s) async { await s.must('RNFR ${_abs(s, from)}', ok: (c) => c == 350, path: from); await s.must('RNTO ${_abs(s, to)}', path: to); }); } @override Future>> openRead(String rel) => openReadFrom(rel); /// [offset] 부터 [length] 바이트 (null 이면 끝까지) - 따로 연결해 REST 로 (동영상 건너뛰기 · 이어 받기) Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { final s = await _connect(); Socket? sock; try { sock = await s.data(); if (offset > 0) await s.must('REST $offset', ok: (c) => c == 350, path: rel); s.send('RETR ${_abs(s, rel)}'); final (code, text) = await s.reply(); if (code != 150 && code != 125) throw s.failure(code, text, rel); sock = await s.secureData(sock); } catch (e) { sock?.destroy(); s.close(); throw _explain(e); } final data = sock; Stream> read() async* { var left = length; try { await for (var chunk in data) { if (left != null) { if (chunk.length >= left) { yield chunk.sublist(0, left); left = 0; break; } left -= chunk.length; } yield chunk; } if (left == null) { final (end, et) = await s.reply(); if (end != 226 && end != 250) throw s.failure(end, et, rel); } } finally { data.destroy(); s.close(); // 다 읽기 전에 멈추면 이 연결째 닫는다 (ABOR 대신 - 서버마다 답이 달라서) } } return read(); } @override Future write(String rel, Stream> data, {int? length}) async { final s = await _connect(); try { final plain = await s.data(); s.send('STOR ${_abs(s, rel)}'); final (code, text) = await s.reply(); if (code != 150 && code != 125) { plain.destroy(); throw s.failure(code, text, rel); } final sock = await s.secureData(plain); await sock.addStream(data); await sock.flush(); // 닫기 (TLS 는 close_notify) 를 보내고, 서버가 답하지 않아도 오래 기다리지 않게 연결째 끊는다 // 다 보낸 뒤 닫는다 (TLS 는 close_notify). close() 가 끝나도 암호화된 마지막 자료가 아직 나가지 않았을 수 있어 // 연결이 정말 끝날 때 (done) 까지 기다리고, 서버가 답하지 않을 때만 끊는다 (곧바로 destroy 하면 빈 파일이 되었다) await sock.close().timeout(const Duration(seconds: 5), onTimeout: () => sock); await sock.done.timeout(const Duration(seconds: 5), onTimeout: () => sock.destroy()).catchError((_) {}); final (end, et) = await s.reply(); if (end != 226 && end != 250) throw s.failure(end, et, rel); } catch (e) { throw _explain(e); } finally { s.close(); } } /// FTP 는 남은 공간을 알려 주는 표준 명령이 없다 @override Future<(int?, int?)> quota() async => (null, null); @override void close() { final m = _main; _main = null; m?.then((s) => s.close(), onError: (_) {}); } } /// 설정의 FTP 서버 목록과 클라이언트 (서버마다 하나) class FtpRegistry { static final _clients = {}; static final _servers = {}; /// 이름 풀기 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS (null = InternetAddress.lookup) static FtpResolve resolve = (host) async => InternetAddress.tryParse(host); /// 정한 DNS 로 이름을 풀기 (집 안 이름 · 주소는 기기 DNS) - SFTP 와 같게 static FtpResolve resolveWithDns(DnsResolver resolver) => (host) async { final ip = InternetAddress.tryParse(host); if (ip != null) return ip; if (!resolver.config().forced || isLocalName(host)) return null; final addrs = await resolver.lookup(host); return addrs.where((a) => a.type == InternetAddressType.IPv4).firstOrNull ?? addrs.firstOrNull; }; static void configure(List servers) { final keep = {for (final s in servers) s.id: s}; for (final id in _clients.keys.toList()) { final old = _servers[id], now = keep[id]; if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { _clients.remove(id)?.close(); } } _servers ..clear() ..addAll(keep); } static List get servers => _servers.values.toList(); static FtpServer? server(String id) => _servers[id]; static FtpRemote client(String id) { final s = _servers[id]; if (s == null) throw FtpException(trf('FTP 서버 설정이 없습니다 ({0})', [id]), FtpFailure.other); return _clients.putIfAbsent(id, () => FtpRemote(s)); } } /// 시험: 명령 채널 기록 (비밀번호 가림) void debugFtpLog(void Function(String line)? f) => _Session.debugLog = f;