import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../core/lan_scan.dart'; import '../core/mdns_smb.dart'; import '../core/secret_gate.dart'; import '../core/smb.dart'; import '../l10n/tr.dart'; import 'confirm.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'webdav_settings.dart' show davServerUsers; /// 88: 환경 설정 > 파일 탐색기 > SMB (Android 만): 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). /// Windows 는 운영체제가 \\서버\공유 를 연다 (경로 입력 · 네트워크 드라이브) - 사용자 결정 88-1. class SmbSettings extends StatelessWidget { final AppController c; const SmbSettings({super.key, required this.c}); @override Widget build(BuildContext context) { final servers = c.settings.remote.smbServers; return Column(children: [ SettingTile( leading: const Icon(Icons.dns_outlined), title: const Text('SMB'), subtitle: Text(tr('NAS · PC 의 공유 폴더 (SMB2 · SMB3) 를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). ' '비밀번호는 이 기기의 안전 저장소에만 둡니다. 보안 문제로 오래된 SMB1 은 쓰지 않습니다.')), trailing: TextButton.icon( onPressed: () => editSmbServer(context, c), icon: const Icon(Icons.add, size: 18), label: Text(tr('추가')), ), ), // 보안 검토 6 (관리자 결정 10/18): 처음 끔 Padding( padding: const EdgeInsets.only(left: 16), child: SwitchListTile( dense: true, title: Text(tr('SMB 서명 · 암호화 필수')), subtitle: Text(tr('서명도 암호화도 되지 않는 연결은 열지 않습니다 (중간에서 내용을 보거나 바꾸지 못하게). ' '오래된 NAS 는 붙지 않을 수 있습니다. 꺼 두어도 그런 서버는 탐색기 탭에 ⚠ 를 보입니다')), value: c.settings.remote.smbRequireSecure, onChanged: (v) => c.updateSettings((x) => x.remote.smbRequireSecure = v), ), ), for (final s in servers) Padding( padding: const EdgeInsets.only(left: 16), child: SettingTile( dense: true, leading: Icon(Icons.dns, color: JjColors.accent), title: Text(s.label), subtitle: Text( [ '\\\\${s.host}${s.port == 445 ? '' : ':${s.port}'}\\${s.share}${s.root.isEmpty ? '' : '\\${s.root}'}', if (s.user.isNotEmpty) s.user else tr('게스트'), ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis), trailing: Row(mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: tr('고치기'), icon: const Icon(Icons.edit_outlined, size: 18), onPressed: () => editSmbServer(context, c, old: s), ), IconButton( tooltip: tr('지우기'), icon: Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), onPressed: () => deleteSmbServer(context, c, s), ), ]), ), ), ]); } } /// 서버 지우기 (쓰는 곳을 알린 뒤 확인). 지웠으면 true Future deleteSmbServer(BuildContext context, AppController c, SmbServer s) async { final users = davServerUsers(c.settings, s.id); final ok = await confirmAction( context, title: trf('SMB 서버 "{0}" 을(를) 지울까요?', [s.label]), body: tr('서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. ' '이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.') + (users.isEmpty ? '' : '\n\n${trf('이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):', [users.length])}\n' '${users.take(8).map((u) => '• $u').join('\n')}${users.length > 8 ? '\n…' : ''}'), ok: tr('지우기'), ); if (!ok) return false; await c.updateSettings((x) => x.remote.smbServers = [for (final y in x.remote.smbServers) if (y.id != s.id) y]); return true; } /// smb://서버/공유/폴더 · \\서버\공유\폴더 → (서버, 공유, 폴더). 아니면 null (경로 입력에서 넣은 것을 서버 추가 창에 채운다) (String, String, String)? parseSmbAddress(String text) { final t = text.trim(); final rest = RegExp(r'^(smb|cifs)://(.+)$', caseSensitive: false).firstMatch(t)?.group(2) ?? (t.startsWith(r'\\') ? t.substring(2) : null); if (rest == null) return null; final parts = rest.split(RegExp(r'[/\\]+')).where((s) => s.isNotEmpty).map((s) { try { return Uri.decodeComponent(s); } catch (_) { return s; } }).toList(); if (parts.isEmpty) return null; return (parts[0], parts.length > 1 ? parts[1] : '', parts.length > 2 ? parts.sublist(2).join('/') : ''); } /// SMB 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null). [from]: 경로 입력에 넣은 smb:// · \\서버\공유 (채워 둠) Future editSmbServer(BuildContext context, AppController c, {SmbServer? old, String? from}) async { if (old != null && !await SecretGate.passIf(old.password.isNotEmpty, force: true)) return null; if (!context.mounted) return null; final pre = from == null ? null : parseSmbAddress(from); final name = TextEditingController(text: old?.name ?? ''); final host = TextEditingController(text: old?.host ?? pre?.$1 ?? ''); final port = TextEditingController(text: '${old?.port ?? 445}'); final share = TextEditingController(text: old?.share ?? pre?.$2 ?? ''); final root = TextEditingController(text: old?.root ?? pre?.$3 ?? ''); final user = TextEditingController(text: old?.user ?? ''); final domain = TextEditingController(text: old?.domain ?? ''); final pass = TextEditingController(text: old?.password ?? ''); var hide = true; String? result; String? warning; var passed = false; var testing = false; SmbServer current() => SmbServer( id: old?.id ?? 'smb${DateTime.now().microsecondsSinceEpoch}', name: name.text.trim(), host: host.text.trim(), port: int.tryParse(port.text.trim()) ?? 445, share: share.text.trim(), user: user.text.trim(), domain: domain.text.trim(), root: root.text.trim(), password: pass.text, ); final saved = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) { Future test() async { FocusScope.of(ctx).unfocus(); set(() { testing = true; result = null; warning = null; }); final client = SmbRemote(current()); try { final items = await client.list('/'); final info = await client.info(); final (free, _) = await client.quota(); passed = true; result = trf('연결됨: 항목 {0}개{1}', [items.length, free == null ? '' : ' · ${tr('남은 용량')} ${(free / (1 << 30)).toStringAsFixed(1)}GB']); // 88-4: 암호화는 허용 (서버가 되면 SMB3 암호화) - 안 되면 경고 if (!info.encrypted) { warning = info.signed ? trf('이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, ' 'NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다', [info.dialect]) // 보안 검토 6: 서명도 안 되면 중간에서 바꿀 수도 있다 : trf('이 연결은 서명도 암호화도 되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 보거나 바꿀 수 있으니, ' 'NAS 설정에서 SMB 서명이나 SMB3 암호화를 켜는 것이 좋습니다', [info.dialect]); } } catch (e) { passed = false; result = '$e'; } finally { client.close(); } if (ctx.mounted) set(() => testing = false); } final ok = host.text.trim().isNotEmpty && share.text.trim().isNotEmpty && (int.tryParse(port.text.trim()) ?? 0) > 0; return AlertDialog( scrollable: true, title: Text(old == null ? tr('SMB 서버 추가') : tr('SMB 서버 고치기')), content: SizedBox( width: 520, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField(controller: name, decoration: InputDecoration(labelText: tr('이름'), helperText: tr('탭에 보입니다 · 비우면 주소'), helperMaxLines: 3)), Row(children: [ Expanded( child: TextField( controller: host, keyboardType: TextInputType.url, decoration: InputDecoration(labelText: tr('서버 주소'), helperText: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)'), helperMaxLines: 3), onChanged: (_) => set(() {}), ), ), const SizedBox(width: 8), SizedBox( width: 80, child: TextField( controller: port, keyboardType: TextInputType.number, decoration: InputDecoration(labelText: tr('포트')), onChanged: (_) => set(() {}), ), ), ]), // X-plore j: 같은 망 (Wi-Fi · 유선) 에서 SMB 서버 찾기 - 누를 때만 훑는다 Align( alignment: Alignment.centerLeft, child: TextButton.icon( icon: const Icon(Icons.radar, size: 18), label: Text(tr('같은 망에서 찾기')), onPressed: () async { final h = await pickLanSmbServer(ctx, c); if (h == null) return; set(() { host.text = h.name ?? h.address; if (name.text.trim().isEmpty && h.name != null) name.text = h.name!.split('.').first; }); }, ), ), TextField( controller: share, decoration: InputDecoration(labelText: tr('공유 이름'), helperText: tr('예: video · home (\\\\서버\\공유 의 공유)'), helperMaxLines: 3), onChanged: (_) => set(() {}), ), TextField(controller: root, decoration: InputDecoration(labelText: tr('시작 폴더'), helperText: tr('공유 안의 폴더 · 비우면 공유의 맨 위'), helperMaxLines: 3)), TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 게스트)'))), TextField( controller: pass, obscureText: hide, decoration: InputDecoration( labelText: tr('비밀번호'), suffixIcon: IconButton( tooltip: hide ? tr('보이기') : tr('가리기'), icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), onPressed: () => set(() => hide = !hide), ), ), ), TextField(controller: domain, decoration: InputDecoration(labelText: tr('도메인 · 작업 그룹'), helperText: tr('보통 비워 둡니다'), helperMaxLines: 3)), const SizedBox(height: 8), if (testing) const LinearProgressIndicator(), if (result != null) Text(result!, style: TextStyle(fontSize: 12, color: passed ? JjColors.positive : JjColors.alert)), if (warning != null) Padding( padding: const EdgeInsets.only(top: 6), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.warning_amber_rounded, size: 18, color: JjColors.warning), const SizedBox(width: 6), Expanded(child: Text(warning!, style: TextStyle(fontSize: 12, color: JjColors.warning))), ]), ), ]), ), actions: [ if (old != null) TextButton( onPressed: () async { if (await deleteSmbServer(ctx, c, old) && ctx.mounted) Navigator.pop(ctx); }, style: TextButton.styleFrom(foregroundColor: JjColors.alert), child: Text(tr('지우기')), ), TextButton(onPressed: !ok || testing ? null : test, child: Text(tr('연결 확인'))), TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton(onPressed: ok ? () => Navigator.pop(ctx, current()) : null, child: Text(tr('저장'))), ], ); }, ), ); if (saved == null) return null; 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; } /// X-plore j: 같은 망의 SMB 서버를 찾아 고르는 창 (진행 · 취소). 이 기기의 사설망 (/24) 의 445 포트에 연결만 해 보고 바로 닫는다 (보내는 것 없음) /// 찾기는 조립의 [PlatformServices.findLanSmb] (없으면 이 기기의 망을 실제로 훑는다) Future pickLanSmbServer(BuildContext context, AppController c) async { final find = c.services.findLanSmb ?? ({bool Function()? cancelled, void Function(int done, int total)? onProgress}) => findSmbServersBoth(findSmbServers, cancelled: cancelled, onProgress: onProgress); var cancelled = false; var done = 0, total = 0; List? found; Object? error; final picked = await showDialog( context: context, builder: (ctx) => StatefulBuilder(builder: (ctx, set) { if (found == null && error == null && total == 0) { total = -1; find( cancelled: () => cancelled, onProgress: (d, t) { if (ctx.mounted) set(() => (done, total) = (d, t)); }, ).then((r) { if (ctx.mounted) set(() => found = r); }, onError: (Object e) { if (ctx.mounted) set(() => error = e); }); } final list = found; return AlertDialog( scrollable: true, title: Text(tr('같은 망의 SMB 서버')), content: SizedBox( width: 460, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr('먼저 mDNS 로 묻고 (이름을 알리는 NAS), 이어서 이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 파일 · 계정 정보는 보내지 않습니다.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 10), if (list == null && error == null) ...[ LinearProgressIndicator(value: total > 0 ? done / total : null), const SizedBox(height: 4), Text(total > 0 ? trf('{0} / {1} 확인', [done, total]) : tr('준비하는 중'), style: const TextStyle(fontSize: 12)), ], if (error != null) Text('$error', style: TextStyle(color: JjColors.danger)), if (list != null && list.isEmpty) Text(tr('찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.')), if (list != null) for (final h in list) ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.dns_outlined), title: Text(h.name ?? h.address), subtitle: h.name == null ? null : Text(h.address), onTap: () => Navigator.pop(ctx, h), ), ]), ), actions: [ TextButton( onPressed: () { cancelled = true; Navigator.pop(ctx); }, child: Text(list == null && error == null ? tr('취소') : tr('닫기')), ), ], ); }), ); cancelled = true; return picked; }