355 lines
17 KiB
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;
|
|
}
|