JJ_MKVMaker/lib/ui/smb_settings.dart

355 lines
17 KiB
Dart

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<bool> 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<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServer? old, String? from}) async {
if (old != null && old.password.isNotEmpty && !await SecretGate.pass(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<SmbServer>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
Future<void> 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<LanHost?> 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<LanHost>? found;
Object? error;
final picked = await showDialog<LanHost>(
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;
}