JJ_MKVMaker/lib/ui/smb_settings.dart

250 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../app/app_controller.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.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('추가')),
),
),
for (final s in servers)
Padding(
padding: const EdgeInsets.only(left: 16),
child: SettingTile(
dense: true,
leading: const 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: const 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.smbServers = [for (final y in x.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 = trf('이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, '
'NAS 설정에서 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('이름 (탭에 보임, 비우면 주소)'))),
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)')),
onChanged: (_) => set(() {}),
),
),
const SizedBox(width: 8),
SizedBox(
width: 80,
child: TextField(
controller: port,
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: tr('포트')),
onChanged: (_) => set(() {}),
),
),
]),
TextField(
controller: share,
decoration: InputDecoration(labelText: tr('공유 이름'), helperText: tr('예: video · home (\\\\서버\\공유 의 공유)')),
onChanged: (_) => set(() {}),
),
TextField(controller: root, decoration: InputDecoration(labelText: tr('시작 폴더 (공유 안, 비우면 맨 위)'))),
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('도메인 · 작업 그룹 (보통 비움)'))),
const SizedBox(height: 8),
if (testing) const LinearProgressIndicator(),
if (result != null)
Text(result!, style: TextStyle(fontSize: 12, color: passed ? Colors.greenAccent : Colors.redAccent)),
if (warning != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Icon(Icons.warning_amber_rounded, size: 18, color: Colors.orangeAccent),
const SizedBox(width: 6),
Expanded(child: Text(warning!, style: const TextStyle(fontSize: 12, color: Colors.orangeAccent))),
]),
),
]),
),
actions: [
if (old != null)
TextButton(
onPressed: () async {
if (await deleteSmbServer(ctx, c, old) && ctx.mounted) Navigator.pop(ctx);
},
style: TextButton.styleFrom(foregroundColor: Colors.redAccent),
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.smbServers = [
for (final y in x.smbServers) y.id == saved.id ? saved : y,
if (!x.smbServers.any((y) => y.id == saved.id)) saved,
]);
return saved;
}