import 'dart:async'; import 'dart:convert'; import 'dart:math' show min; import 'dart:typed_data'; import 'package:flutter/services.dart'; import '../l10n/tr.dart'; import 'remote_fs.dart'; import 'secret_gate.dart'; import 'webdav.dart' show DavException, DavItem, DavLockedException; /// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1). /// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). class SmbServer { final String id; final String name; final String host; final int port; /// 공유 이름 (예: video · home) final String share; final String user; /// 도메인 · 작업 그룹 (보통 비움) final String domain; /// 시작 폴더 (공유 안, 비우면 공유의 맨 위) final String root; final String password; const SmbServer({ required this.id, required this.name, required this.host, this.port = 445, required this.share, this.user = '', this.domain = '', this.root = '', this.password = '', }); /// 탐색기 위쪽에 보일 이름 String get label => name.trim().isNotEmpty ? name.trim() : '$host/$share'; SmbServer copyWith({String? name, String? host, int? port, String? share, String? user, String? domain, String? root, String? password}) => SmbServer( id: id, name: name ?? this.name, host: host ?? this.host, port: port ?? this.port, share: share ?? this.share, user: user ?? this.user, domain: domain ?? this.domain, root: root ?? this.root, password: password ?? this.password, ); /// 비밀번호는 넣지 않는다 (안전 저장소). 저장해 두었는지만 Map toJson() => { 'id': id, 'name': name, 'host': host, 'port': port, 'share': share, 'user': user, 'domain': domain, 'root': root, 'hasPassword': password.isNotEmpty, }; factory SmbServer.fromJson(Map j) => SmbServer( 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, _ => 445 }, share: j['share'] as String? ?? '', user: j['user'] as String? ?? '', domain: j['domain'] as String? ?? '', root: j['root'] as String? ?? '', ); } enum SmbFailure { unreachable, auth, noPassword, noShare, smb1, permission, notFound, other } /// SMB 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다 class SmbException extends DavException { final SmbFailure kind; const SmbException(super.message, this.kind); } /// 88: Android SMB 클라이언트 - smbj (Kotlin, android/.../SmbBridge.kt) 를 플랫폼 채널로 부른다. /// 파일 내용은 1MB 조각으로 오간다 (열어 둔 파일 손잡이로). class SmbRemote implements RemoteClient { final SmbServer server; SmbRemote(this.server); /// 시험에서 가짜로 바꾼다 static MethodChannel channel = const MethodChannel('jj_mkvmaker/smb'); static const chunk = 1 << 20; Map get _cfg => { 'id': server.id, 'host': server.host.trim(), 'port': server.port, 'share': server.share.trim(), 'user': server.user.trim(), 'domain': server.domain.trim(), 'password': server.password, }; /// 서버 기준 경로 → 공유 안의 경로 (시작 폴더 포함) String _path(String rel) { final root = server.root.trim().replaceAll('\\', '/').replaceAll(RegExp(r'^/+|/+$'), ''); final r = rel == '/' ? '' : rel; return root.isEmpty ? (r.isEmpty ? '/' : r) : '/$root$r'; } Future _call(String method, [Map args = const {}, String rel = '']) async { if (server.host.trim().isEmpty || server.share.trim().isEmpty) { throw SmbException(tr('SMB 서버 주소 · 공유 이름이 없습니다'), SmbFailure.other); } if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); if (server.user.isNotEmpty && server.password.isEmpty) throw SmbException(tr('저장된 비밀번호가 없습니다'), SmbFailure.noPassword); try { return await channel.invokeMethod(method, {..._cfg, ...args}); } on PlatformException catch (e) { throw _explain(e, rel); } on MissingPluginException { throw SmbException(tr('이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)'), SmbFailure.other); } } SmbException _explain(PlatformException e, String rel) { final s = server; return switch (e.code) { 'unreachable' => SmbException( trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), SmbFailure.unreachable), 'auth' => SmbException(tr('아이디나 비밀번호가 맞지 않습니다'), SmbFailure.auth), 'noShare' => SmbException(trf('공유 폴더 "{0}" 이(가) 없습니다. 공유 이름을 확인하세요', [s.share]), SmbFailure.noShare), // 88: SMB1 은 보안 문제로 쓰지 않는다 (설계안 5) 'smb1' => SmbException(tr('이 서버는 오래된 SMB1 만 지원합니다 - 보안 문제로 연결하지 않습니다. NAS 설정에서 SMB2 · SMB3 을 켜세요'), SmbFailure.smb1), 'permission' => SmbException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [rel]), SmbFailure.permission), 'notFound' => SmbException(trf('없는 경로입니다: {0}', [rel]), SmbFailure.notFound), _ => SmbException(e.message ?? e.code, SmbFailure.other), }; } static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); DavItem _item(String rel, Map m) => DavItem(rel, isDir: m['dir'] == true, size: m['dir'] == true ? 0 : (m['size'] as num?)?.toInt() ?? 0, modified: DateTime.fromMillisecondsSinceEpoch((m['mtime'] as num?)?.toInt() ?? 0)); /// 연결이 암호화되는지 · 어떤 SMB 판인지 (88-4: 암호화되지 않으면 화면이 경고) Future<({bool encrypted, String dialect})> info() async { final m = await _call>('info', const {}, '/'); return (encrypted: m?['encrypted'] == true, dialect: '${m?['dialect'] ?? ''}'); } @override Future> list(String rel) async { final out = await _call>('list', {'path': _path(rel)}, rel) ?? const []; return [ for (final x in out) if (x is Map) _item(_child(rel, '${x['name']}'), x), ]; } @override Future stat(String rel) async { try { final m = await _call>('stat', {'path': _path(rel)}, rel); return m == null ? null : _item(rel, m); } on SmbException catch (e) { if (e.kind == SmbFailure.notFound) return null; rethrow; } } @override Future mkdirs(String rel) async { if (rel == '/' || rel.isEmpty) return; final st = await stat(rel); if (st != null) { if (st.isDir) return; throw SmbException(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]), SmbFailure.other); } await mkdirs(_parent(rel)); await _call('mkdir', {'path': _path(rel)}, rel); } @override Future delete(String rel) => _call('delete', {'path': _path(rel)}, rel); @override Future move(String from, String to, {bool overwrite = false}) => _call('move', {'path': _path(from), 'to': _path(to), 'overwrite': overwrite}, from); @override Future>> openRead(String rel) => openReadFrom(rel); /// [start] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next) Future>> openReadFrom(String rel, {int start = 0, int? length}) async { final m = await _call>('openRead', {'path': _path(rel)}, rel); final handle = m!['handle'] as int; Stream> read() async* { var offset = start; var left = length; try { while (left == null || left > 0) { final Uint8List? data; try { data = await channel.invokeMethod( 'readChunk', {'handle': handle, 'offset': offset, 'length': left == null ? chunk : min(chunk, left)}); } on PlatformException catch (e) { throw _explain(e, rel); } if (data == null || data.isEmpty) break; offset += data.length; if (left != null) left -= data.length; yield data; } } finally { await channel.invokeMethod('closeHandle', {'handle': handle}).catchError((_) {}); } } return read(); } @override Future write(String rel, Stream> data, {int? length}) async { final handle = await _call('openWrite', {'path': _path(rel)}, rel); var offset = 0; final buf = BytesBuilder(copy: false); // 모인 것을 [chunk] 크기로 잘라 보낸다 (큰 조각 하나가 와도 채널로 한 번에 수백 MB 를 보내지 않게). [all] 이면 남은 것까지 Future flush({bool all = false}) async { if (buf.isEmpty || (!all && buf.length < chunk)) return; final bytes = buf.takeBytes(); var at = 0; while (bytes.length - at >= chunk || (all && at < bytes.length)) { final end = (at + chunk).clamp(0, bytes.length); try { await channel.invokeMethod( 'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)}); } on PlatformException catch (e) { throw _explain(e, rel); } offset += end - at; at = end; } if (at < bytes.length) buf.add(Uint8List.sublistView(bytes, at)); } try { await for (final x in data) { buf.add(x); await flush(); } await flush(all: true); } finally { await channel.invokeMethod('closeHandle', {'handle': handle}).catchError((_) {}); } } @override Future<(int?, int?)> quota() async { try { final v = await _call>('quota', const {}, '/'); return ((v?[0] as num?)?.toInt(), (v?[1] as num?)?.toInt()); } catch (_) { return (null, null); } } @override void close() => unawaited(channel.invokeMethod('close', {'id': server.id}).catchError((_) {})); } /// 설정의 SMB 서버 목록과 클라이언트 (서버마다 하나) class SmbRegistry { static final _clients = {}; static final _servers = {}; 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 SmbServer? server(String id) => _servers[id]; static SmbRemote client(String id) { final s = _servers[id]; if (s == null) throw SmbException(trf('SMB 서버 설정이 없습니다 ({0})', [id]), SmbFailure.other); return _clients.putIfAbsent(id, () => SmbRemote(s)); } }