diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 503c831..1ba4f1b 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -450,6 +450,7 @@ "{0} (기본): {1}": "{0} (default): {1}", "{0} (동영상 {1})": "{0} (videos: {1})", "{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)", + "{0} / {1} 확인": "{0} / {1} checked", "{0} / {1}장": "{0} / {1} images", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2} (change at the top of the main screen)", "{0} · {1}배 · {2}": "{0} · {1}× · {2}", @@ -603,6 +604,8 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}", "강하게 (0.4)": "Strong (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original", + "같은 망에서 찾기": "Find on this network", + "같은 망의 SMB 서버": "SMB servers on this network", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "Can't create the folder because a file with the same name exists: {0}", @@ -1835,6 +1838,7 @@ "이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)", "이 기기 ({0})": "This device ({0})", "이 기기 메모리 {0}GB 기준:": "For this device's {0}GB of memory:", + "이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 아무것도 보내지 않습니다.": "Tries an SMB (445) connection to devices on the network this device is on (Wi-Fi · wired) and closes it right away. Nothing is sent.", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "Only an irreversible hash is stored on this device, not the password itself.", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "About {0} per image on this device (based on last time)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "About {0} per image, {1} in total on this device (based on last time)", @@ -2283,6 +2287,7 @@ "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "No subtitles found. Try the original English title or add languages.", "찾을 글": "Find", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "Not found (already deleted or moved)", + "찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.": "Nothing found. Check that the NAS is on and on the same network, or enter the address yourself.", "채도": "Saturation", "채우기 (가운데 잘라냄)": "Fill (crop the center)", "처리 장치": "Processor", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 6698091..f5893b6 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -450,6 +450,7 @@ "{0} (기본): {1}": "{0}(既定): {1}", "{0} (동영상 {1})": "{0} (動画 {1})", "{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)", + "{0} / {1} 확인": "{0} / {1} 確認", "{0} / {1}장": "{0} / {1} 枚", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", "{0} · {1}배 · {2}": "{0}・{1} 倍・{2}", @@ -603,6 +604,8 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", "강하게 (0.4)": "強め (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります", + "같은 망에서 찾기": "同じネットワークで探す", + "같은 망의 SMB 서버": "同じネットワークの SMB サーバー", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}", @@ -1835,6 +1838,7 @@ "이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)", "이 기기 ({0})": "この端末 ({0})", "이 기기 메모리 {0}GB 기준:": "この端末のメモリ {0}GB の場合:", + "이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 아무것도 보내지 않습니다.": "この端末が接続しているネットワーク (Wi-Fi · 有線) の機器に SMB (445) で接続だけ試し、すぐに閉じます。何も送りません。", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)", @@ -2283,6 +2287,7 @@ "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "字幕が見つかりませんでした。タイトルを英語の原題に変えるか、言語を追加してみてください。", "찾을 글": "検索する文字", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "見つかりません (すでに削除または移動されました)", + "찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.": "見つかりませんでした。NAS の電源が入っていて同じネットワークにあるか確認するか、アドレスを直接入力してください。", "채도": "彩度", "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", "처리 장치": "処理装置", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 341d4a6..05774a3 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -450,6 +450,7 @@ "{0} (기본): {1}": "{0}(默认): {1}", "{0} (동영상 {1})": "{0}(视频 {1})", "{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)", + "{0} / {1} 확인": "已检查 {0} / {1}", "{0} / {1}장": "{0} / {1} 张", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", "{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}", @@ -603,6 +604,8 @@ "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", "강하게 (0.4)": "强 (0.4)", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同", + "같은 망에서 찾기": "在同一网络中查找", + "같은 망의 SMB 서버": "同一网络中的 SMB 服务器", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}", @@ -1835,6 +1838,7 @@ "이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)", "이 기기 ({0})": "本设备 ({0})", "이 기기 메모리 {0}GB 기준:": "按本设备内存 {0}GB:", + "이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 아무것도 보내지 않습니다.": "仅尝试与本设备所在网络 (Wi-Fi · 有线) 中的设备建立 SMB (445) 连接并立即关闭。不会发送任何内容。", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)", @@ -2283,6 +2287,7 @@ "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "未找到字幕。请尝试改用英文原名或添加语言。", "찾을 글": "查找", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "找不到 (已被删除或移动)", + "찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.": "未找到。请确认 NAS 已开启且在同一网络中,或直接输入地址。", "채도": "饱和度", "채우기 (가운데 잘라냄)": "填充(裁切中间)", "처리 장치": "处理器", diff --git a/lib/core/lan_scan.dart b/lib/core/lan_scan.dart new file mode 100644 index 0000000..ae26ba3 --- /dev/null +++ b/lib/core/lan_scan.dart @@ -0,0 +1,78 @@ +import 'dart:async'; +import 'dart:io'; + +/// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다. +/// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께. +/// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다. +class LanHost { + final String address; + final String? name; + const LanHost(this.address, this.name); +} + +/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선 · Tailscale 은 빼고 - 100.64/10 은 넓어 훑지 않는다) +Future> localPrivateIPv4() async { + final out = []; + for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) { + for (final a in ni.addresses) { + final b = a.rawAddress; + final private = b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168); + if (private && !a.isLoopback) out.add(a); + } + } + return out; +} + +/// [hosts] 의 [port] 가 열려 있는지 동시에 [parallel] 개씩 본다. 열린 것만 (주소 순서대로) +Future> scanPort( + List hosts, { + int port = 445, + Duration timeout = const Duration(milliseconds: 400), + int parallel = 64, + bool Function()? cancelled, + void Function(int done, int total)? onProgress, + bool lookupNames = true, +}) async { + final open = {}; + var next = 0, done = 0; + Future worker() async { + while (next < hosts.length) { + if (cancelled?.call() ?? false) return; + final h = hosts[next++]; + try { + final s = await Socket.connect(h, port, timeout: timeout); + open.add(h); + s.destroy(); + } catch (_) {} + onProgress?.call(++done, hosts.length); + } + } + + await Future.wait([for (var i = 0; i < parallel; i++) worker()]); + final sorted = hosts.where(open.contains).toList(); + final out = []; + for (final h in sorted) { + String? name; + if (lookupNames) { + try { + final r = await InternetAddress(h).reverse().timeout(const Duration(seconds: 1)); + if (r.host != h) name = r.host; + } catch (_) {} + } + out.add(LanHost(h, name)); + } + return out; +} + +/// 이 기기의 사설망 (/24 마다) 의 SMB 서버들 +Future> findSmbServers({bool Function()? cancelled, void Function(int done, int total)? onProgress}) async { + final hosts = []; + for (final a in await localPrivateIPv4()) { + final b = a.rawAddress; + for (var i = 1; i < 255; i++) { + final h = '${b[0]}.${b[1]}.${b[2]}.$i'; + if (!hosts.contains(h)) hosts.add(h); + } + } + return scanPort(hosts, cancelled: cancelled, onProgress: onProgress); +} diff --git a/lib/ui/smb_settings.dart b/lib/ui/smb_settings.dart index cf50899..6c8550e 100644 --- a/lib/ui/smb_settings.dart +++ b/lib/ui/smb_settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; +import '../core/lan_scan.dart'; import '../core/secret_gate.dart'; import '../core/smb.dart'; import '../l10n/tr.dart'; @@ -188,6 +189,22 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ ), ), ]), + // 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); + 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 (\\\\서버\\공유 의 공유)')), @@ -247,3 +264,77 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ ]); return saved; } + + +/// 시험: 찾는 함수 (null 이면 실제로 훑는다) +@visibleForTesting +Future> Function({bool Function()? cancelled, void Function(int done, int total)? onProgress})? debugFindSmbServers; + +/// X-plore j: 같은 망의 SMB 서버를 찾아 고르는 창 (진행 · 취소). 이 기기의 사설망 (/24) 의 445 포트에 연결만 해 보고 바로 닫는다 (보내는 것 없음) +/// [find]: 시험에서 바꿔 끼움 (실제 망을 훑지 않게) +Future pickLanSmbServer(BuildContext context) async { + final find = debugFindSmbServers ?? findSmbServers; + 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('이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 아무것도 보내지 않습니다.'), + style: const 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: const 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; +} diff --git a/test/lan_scan_test.dart b/test/lan_scan_test.dart new file mode 100644 index 0000000..039fede --- /dev/null +++ b/test/lan_scan_test.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/lan_scan.dart'; + +/// X-plore 비교 j: 같은 망 SMB 서버 찾기의 포트 훑기 (실제 망 대신 이 기기의 열린 · 닫힌 포트로) +void main() { + test('열린 포트만 찾는다 · 순서는 넣은 대로 · 진행 · 취소', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + server.listen((s) => s.destroy()); + final closed = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final closedPort = closed.port; + await closed.close(); + var progress = 0; + final found = await scanPort(['127.0.0.1', '127.0.0.2'], port: server.port, lookupNames: false, onProgress: (d, _) => progress = d); + expect(found.map((h) => h.address), ['127.0.0.1'], reason: '127.0.0.2 는 듣지 않음 (Windows 는 127.0.0.1 에만 묶임)'); + expect(progress, 2); + expect(await scanPort(['127.0.0.1'], port: closedPort, lookupNames: false), isEmpty); + expect(await scanPort(['127.0.0.1'], port: server.port, lookupNames: false, cancelled: () => true), isEmpty, reason: '취소'); + expect((await localPrivateIPv4()).every((a) => !a.isLoopback), isTrue); + }); +} diff --git a/test/smb_find_test.dart b/test/smb_find_test.dart new file mode 100644 index 0000000..e6b0a36 --- /dev/null +++ b/test/smb_find_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/lan_scan.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/smb_settings.dart'; + +/// X-plore j: [SMB 서버 추가] 창의 [같은 망에서 찾기] - 진행 · 찾은 서버를 고르면 주소 (이름) 칸을 채움. 시험은 실제 망을 훑지 않는다 (가짜 찾기) +void main() { + tearDown(() => debugFindSmbServers = null); + + testWidgets('찾기 → 고르면 주소 · 이름 칸 채움, 못 찾으면 안내', (tester) async { + tester.view.physicalSize = const Size(1000, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + var calls = 0; + var result = const [LanHost('192.168.0.9', 'nas.local'), LanHost('192.168.0.20', null)]; + debugFindSmbServers = ({cancelled, onProgress}) async { + calls++; + onProgress?.call(254, 254); + return result; + }; + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final key = GlobalKey(); + await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold())); + editSmbServer(key.currentContext!, c); + await tester.pumpAndSettle(); + expect(find.text('SMB 서버 추가'), findsOneWidget); + await tester.tap(find.text('같은 망에서 찾기')); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.text('같은 망의 SMB 서버'), findsOneWidget); + expect(find.text('nas.local'), findsOneWidget); + expect(find.text('192.168.0.20'), findsOneWidget, reason: '이름을 모르면 주소'); + await tester.tap(find.text('nas.local')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(TextField, 'nas.local'), findsOneWidget, reason: '서버 주소 칸'); + expect(find.widgetWithText(TextField, 'nas'), findsOneWidget, reason: '이름 칸 (비어 있었으면)'); + // 못 찾음 + result = const []; + await tester.tap(find.text('같은 망에서 찾기')); + await tester.pumpAndSettle(); + expect(find.textContaining('찾지 못했습니다'), findsOneWidget); + await tester.tap(find.text('닫기')); + await tester.pumpAndSettle(); + expect(find.text('SMB 서버 추가'), findsOneWidget, reason: '추가 창은 그대로'); + }); +}