WIP X-plore j: [SMB 서버 추가] 창에 [같은 망에서 찾기] (검토 대기)

관리자 지시 (10/18): cd 의 lan_scan.dart (xplore b6e4717) 를 묶음7_88 (88 SMB) 위로 가져와 SMB 추가 창의 [찾기] 에 붙인다.
- lib/core/lan_scan.dart · test/lan_scan_test.dart: b6e4717 의 것 그대로 (cd 작성 - 사설 /24 의 445 포트에 연결만 해 보고 닫음, 64개 동시, Tailscale 은 훑지 않음)
- SMB 추가 · 고치기 창: 서버 주소 아래 [같은 망에서 찾기] → 찾는 창 (무엇을 하는지 안내 · 진행 "n / 254 확인" · [취소]).
  찾은 서버를 누르면 서버 주소 칸에 이름 (역방향 조회, 모르면 주소), 이름 칸이 비었으면 이름 앞부분을 채움. 못 찾으면 직접 넣으라는 안내
- 누를 때만 훑는다 (저절로 훑지 않음 - 내 판단: 망 전체에 연결을 시도하므로 사용자가 고를 때만)
- mDNS (_smb._tcp) 는 아직 (관리자 "더하면 좋음") - 포트 훑기로 찾은 것의 이름은 역방향 DNS

시험: smb_find_test (가짜 찾기 - 실제 망을 훑지 않음: 고르면 주소 · 이름 채움, 이름 모르면 주소, 못 찾음 안내, 추가 창 유지),
lan_scan_test (127.0.0.1 만), smb_test - 7 통과, analyze 문제 없음, l10n 빠짐 0. Android 실제 망에서는 기기 확인 필요.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hint_fix
RyuJongJin 2026-10-18 00:59:10 +09:00
parent e5a115d450
commit 3a1a6391ca
7 changed files with 257 additions and 0 deletions

View File

@ -450,6 +450,7 @@
"{0} (기본): {1}": "{0} (default): {1}", "{0} (기본): {1}": "{0} (default): {1}",
"{0} (동영상 {1})": "{0} (videos: {1})", "{0} (동영상 {1})": "{0} (videos: {1})",
"{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)", "{0} - {1} (약 {2}MB)": "{0} - {1} (about {2}MB)",
"{0} / {1} 확인": "{0} / {1} checked",
"{0} / {1}장": "{0} / {1} images", "{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} (change at the top of the main screen)",
"{0} · {1}배 · {2}": "{0} · {1}× · {2}", "{0} · {1}배 · {2}": "{0} · {1}× · {2}",
@ -603,6 +604,8 @@
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "Downloaded only if the detected language differs from the subtitle language {0}",
"강하게 (0.4)": "Strong (0.4)", "강하게 (0.4)": "Strong (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "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)", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", "같은 이름을 덮어쓴 파일 {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}", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {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)", "이 기기 (SD1.5 + LCM)": "This device (SD1.5 + LCM)",
"이 기기 ({0})": "This device ({0})", "이 기기 ({0})": "This device ({0})",
"이 기기 메모리 {0}GB 기준:": "For this device's {0}GB of memory:", "이 기기 메모리 {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.", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "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} (지난번 걸린 시간 기준)": "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)", "이 기기에서 한 장 약 {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.", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "No subtitles found. Try the original English title or add languages.",
"찾을 글": "Find", "찾을 글": "Find",
"찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "Not found (already deleted or moved)", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "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", "채도": "Saturation",
"채우기 (가운데 잘라냄)": "Fill (crop the center)", "채우기 (가운데 잘라냄)": "Fill (crop the center)",
"처리 장치": "Processor", "처리 장치": "Processor",

View File

@ -450,6 +450,7 @@
"{0} (기본): {1}": "{0}(既定): {1}", "{0} (기본): {1}": "{0}(既定): {1}",
"{0} (동영상 {1})": "{0} (動画 {1})", "{0} (동영상 {1})": "{0} (動画 {1})",
"{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)", "{0} - {1} (약 {2}MB)": "{0} - {1}(約 {2}MB)",
"{0} / {1} 확인": "{0} / {1} 確認",
"{0} / {1}장": "{0} / {1} 枚", "{0} / {1}장": "{0} / {1} 枚",
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(メイン画面の上部で変更)",
"{0} · {1}배 · {2}": "{0}・{1} 倍・{2}", "{0} · {1}배 · {2}": "{0}・{1} 倍・{2}",
@ -603,6 +604,8 @@
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "検出した言語が作成する言語と異なる場合のみダウンロード {0}",
"강하게 (0.4)": "強め (0.4)", "강하게 (0.4)": "強め (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります",
"같은 망에서 찾기": "同じネットワークで探す",
"같은 망의 SMB 서버": "同じネットワークの SMB サーバー",
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。",
"같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}",
@ -1835,6 +1838,7 @@
"이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)", "이 기기 (SD1.5 + LCM)": "この端末 (SD1.5 + LCM)",
"이 기기 ({0})": "この端末 ({0})", "이 기기 ({0})": "この端末 ({0})",
"이 기기 메모리 {0}GB 기준:": "この端末のメモリ {0}GB の場合:", "이 기기 메모리 {0}GB 기준:": "この端末のメモリ {0}GB の場合:",
"이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 아무것도 보내지 않습니다.": "この端末が接続しているネットワーク (Wi-Fi · 有線) の機器に SMB (445) で接続だけ試し、すぐに閉じます。何も送りません。",
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "この端末にはパスワード自体ではなく、元に戻せないハッシュだけが残ります。",
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0} (前回の所要時間から)",
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)",
@ -2283,6 +2287,7 @@
"찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "字幕が見つかりませんでした。タイトルを英語の原題に変えるか、言語を追加してみてください。", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "字幕が見つかりませんでした。タイトルを英語の原題に変えるか、言語を追加してみてください。",
"찾을 글": "検索する文字", "찾을 글": "検索する文字",
"찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "見つかりません (すでに削除または移動されました)", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "見つかりません (すでに削除または移動されました)",
"찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.": "見つかりませんでした。NAS の電源が入っていて同じネットワークにあるか確認するか、アドレスを直接入力してください。",
"채도": "彩度", "채도": "彩度",
"채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)",
"처리 장치": "処理装置", "처리 장치": "処理装置",

View File

@ -450,6 +450,7 @@
"{0} (기본): {1}": "{0}(默认): {1}", "{0} (기본): {1}": "{0}(默认): {1}",
"{0} (동영상 {1})": "{0}(视频 {1})", "{0} (동영상 {1})": "{0}(视频 {1})",
"{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)", "{0} - {1} (약 {2}MB)": "{0} - {1}(约 {2}MB)",
"{0} / {1} 확인": "已检查 {0} / {1}",
"{0} / {1}장": "{0} / {1} 张", "{0} / {1}장": "{0} / {1} 张",
"{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)", "{0} · {1}{2} (메인 화면 상단에서 변경)": "{0} · {1}{2}(在主界面顶部更改)",
"{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}", "{0} · {1}배 · {2}": "{0} · {1} 倍 · {2}",
@ -603,6 +604,8 @@
"감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}", "감지한 원어가 만들 언어와 다를 때만 내려받기 {0}": "仅当检测到的语言与要生成的语言不同时下载 {0}",
"강하게 (0.4)": "强 (0.4)", "강하게 (0.4)": "强 (0.4)",
"강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同", "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同",
"같은 망에서 찾기": "在同一网络中查找",
"같은 망의 SMB 서버": "同一网络中的 SMB 服务器",
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。",
"같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}",
@ -1835,6 +1838,7 @@
"이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)", "이 기기 (SD1.5 + LCM)": "本设备 (SD1.5 + LCM)",
"이 기기 ({0})": "本设备 ({0})", "이 기기 ({0})": "本设备 ({0})",
"이 기기 메모리 {0}GB 기준:": "按本设备内存 {0}GB:", "이 기기 메모리 {0}GB 기준:": "按本设备内存 {0}GB:",
"이 기기가 연결된 망 (Wi-Fi · 유선) 의 기기들에 SMB (445) 연결만 해 보고 바로 닫습니다. 아무것도 보내지 않습니다.": "仅尝试与本设备所在网络 (Wi-Fi · 有线) 中的设备建立 SMB (445) 连接并立即关闭。不会发送任何内容。",
"이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。", "이 기기에는 원문이 아니라 되돌릴 수 없는 해시만 남습니다.": "本设备只保存无法还原的哈希值,而不是密码本身。",
"이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)", "이 기기에서 한 장 약 {0} (지난번 걸린 시간 기준)": "本设备每张约 {0} (按上次耗时)",
"이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)", "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)",
@ -2283,6 +2287,7 @@
"찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "未找到字幕。请尝试改用英文原名或添加语言。", "찾은 자막이 없습니다. 제목을 영어 원제로 바꾸거나 언어를 추가해 보세요.": "未找到字幕。请尝试改用英文原名或添加语言。",
"찾을 글": "查找", "찾을 글": "查找",
"찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "找不到 (已被删除或移动)", "찾을 수 없습니다 (이미 지워졌거나 옮겨졌습니다)": "找不到 (已被删除或移动)",
"찾지 못했습니다. NAS 가 켜져 있고 같은 망에 있는지 확인하거나, 주소를 직접 넣으세요.": "未找到。请确认 NAS 已开启且在同一网络中,或直接输入地址。",
"채도": "饱和度", "채도": "饱和度",
"채우기 (가운데 잘라냄)": "填充(裁切中间)", "채우기 (가운데 잘라냄)": "填充(裁切中间)",
"처리 장치": "处理器", "처리 장치": "处理器",

78
lib/core/lan_scan.dart Normal file
View File

@ -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<List<InternetAddress>> localPrivateIPv4() async {
final out = <InternetAddress>[];
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<List<LanHost>> scanPort(
List<String> 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 = <String>{};
var next = 0, done = 0;
Future<void> 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 = <LanHost>[];
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<List<LanHost>> findSmbServers({bool Function()? cancelled, void Function(int done, int total)? onProgress}) async {
final hosts = <String>[];
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);
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../core/lan_scan.dart';
import '../core/secret_gate.dart'; import '../core/secret_gate.dart';
import '../core/smb.dart'; import '../core/smb.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -188,6 +189,22 @@ Future<SmbServer?> 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( TextField(
controller: share, controller: share,
decoration: InputDecoration(labelText: tr('공유 이름'), helperText: tr('예: video · home (\\\\서버\\공유 의 공유)')), decoration: InputDecoration(labelText: tr('공유 이름'), helperText: tr('예: video · home (\\\\서버\\공유 의 공유)')),
@ -247,3 +264,77 @@ Future<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServ
]); ]);
return saved; return saved;
} }
/// 시험: 찾는 함수 (null 이면 실제로 훑는다)
@visibleForTesting
Future<List<LanHost>> Function({bool Function()? cancelled, void Function(int done, int total)? onProgress})? debugFindSmbServers;
/// X-plore j: 같은 망의 SMB 서버를 찾아 고르는 창 (진행 · 취소). 이 기기의 사설망 (/24) 의 445 포트에 연결만 해 보고 바로 닫는다 (보내는 것 없음)
/// [find]: 시험에서 바꿔 끼움 (실제 망을 훑지 않게)
Future<LanHost?> pickLanSmbServer(BuildContext context) async {
final find = debugFindSmbServers ?? findSmbServers;
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('이 기기가 연결된 망 (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;
}

23
test/lan_scan_test.dart Normal file
View File

@ -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);
});
}

50
test/smb_find_test.dart Normal file
View File

@ -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<NavigatorState>();
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: '추가 창은 그대로');
});
}