From 53f5c432bb105e9ac918829abb2cb33f95cb490c Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:47:58 +0900 Subject: [PATCH 1/8] =?UTF-8?q?WIP=20=EC=95=95=EC=B6=95=20=ED=8E=BC?= =?UTF-8?q?=EC=B9=98=EA=B8=B0:=20=ED=8E=BC=EC=B9=9C=20=EB=92=A4=20?= =?UTF-8?q?=EC=95=95=EC=B6=95=20=ED=8C=8C=EC=9D=BC=EC=9D=B4=20=EB=B0=94?= =?UTF-8?q?=EB=80=8C=EB=A9=B4=20(=ED=81=AC=EA=B8=B0=20=C2=B7=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EB=95=8C)=20=ED=8E=BC=EC=B9=98=EA=B1=B0=EB=82=98?= =?UTF-8?q?=20=EC=83=88=EB=A1=9C=20=EA=B3=A0=EC=B9=A0=20=EB=95=8C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/19, 사용자 "오늘 못했던 것 진행" 1번 - 압축 펼치기의 알려진 한계): 읽어 둔 압축 목록을 계속 써서, 다른 프로그램이 압축을 바꿔도 접었다 펼치거나 새로 고쳐도 예전 목록이 보였다. - 압축을 읽을 때의 (크기, 바뀐 때) 를 기억하고, 다시 펼칠 때 · 새로 고침 (F5 · [새로 고침] · 앱으로 돌아옴) 때 비교해 다르면 그 압축의 목록과 모든 창의 그 안 폴더 목록을 잊고 다시 읽는다. - 압축이 없어졌으면 목록을 잊고 그 안은 빈 목록으로 (예전 것을 보이지 않게). 시험: explorer_test (새 1: 펼친 뒤 압축을 바꾸고 F5 → 새 항목 · 예전 항목 없음) 62 통과, 임시 폴더 흔적 0 (tool/test_temp_check.ps1), analyze 문제 없음. Co-Authored-By: Claude Opus 5.5 --- lib/ui/explorer_page.dart | 49 ++++++++++++++++++++++++++++++++++----- test/explorer_test.dart | 22 ++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 32399c7..1657d93 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -908,11 +908,19 @@ class _ExplorerPageState extends State { Future _load(_Pane pane, String dir, {bool force = false}) async { if (!force && pane.cache.containsKey(dir)) return; - // X-plore 비교 f: 압축 안 (읽어 둔 목록에서) - if (_archiveOf(dir) != null) { - pane.cache[dir] = _sorted(_archiveList(dir), dir); - pane.changed(); - return; + // X-plore 비교 f: 압축 안 (읽어 둔 목록에서). 새로 고침이면 압축 파일이 바뀌었는지 보고 바뀌었으면 다시 읽는다 + final arc = _archiveOf(dir); + if (arc != null) { + if (force && !await _readArchive(arc)) { + pane.cache[dir] = const []; + pane.changed(); + return; + } + if (_archives.containsKey(arc)) { + pane.cache[dir] = _sorted(_archiveList(dir), dir); + pane.changed(); + return; + } } // 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다 if (force) _sizes.forget(dir); @@ -2173,6 +2181,28 @@ class _ExplorerPageState extends State { /// 펼쳐 본 압축의 목록 (압축 경로 → 항목) final _archives = >{}; + /// 압축을 읽은 때의 (크기, 바뀐 때) - 바뀌었으면 다음에 펼치거나 새로 고칠 때 다시 읽는다 (관리자 지시 10/19) + final _archiveStamps = {}; + + static Future<(int, DateTime)?> _stampOf(String path) async { + try { + final st = await File(path).stat(); + if (st.type == FileSystemEntityType.notFound) return null; + return (st.size, st.modified); + } catch (_) { + return null; + } + } + + /// [arc] 의 읽어 둔 목록과 창들의 그 안 목록을 잊는다 (다시 읽게) + void _forgetArchive(String arc) { + _archives.remove(arc); + _archiveStamps.remove(arc); + for (final q in _panes) { + q.cache.removeWhere((k, _) => samePath(k, arc) || isSameOrInside(k, arc)); + } + } + /// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면) bool _canBrowseArchive(FileEntry e) => !widget.rsync && !e.isDir && !isDav(e.path) && archiveKindOf(e.path) != null; @@ -2196,7 +2226,13 @@ class _ExplorerPageState extends State { /// 압축의 목록을 읽어 둔다 (비밀번호가 걸린 7z 등은 [압축 풀기] 로 안내). 읽었으면 true Future _readArchive(String path) async { - if (_archives.containsKey(path)) return true; + final stamp = await _stampOf(path); + if (_archives.containsKey(path)) { + // 펼친 뒤 압축 파일이 바뀌었으면 (크기 · 바뀐 때) 다시 읽는다. 없어졌으면 잊는다 + if (stamp != null && stamp == _archiveStamps[path]) return true; + _forgetArchive(path); + if (stamp == null) return false; + } final kind = archiveKindOf(path)!; try { if (kind == ArchiveKind.zip) { @@ -2209,6 +2245,7 @@ class _ExplorerPageState extends State { } _archives[path] = await list7z(exe, path); } + if (stamp != null) _archiveStamps[path] = stamp; return true; } on ArchivePasswordException { _snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요')); diff --git a/test/explorer_test.dart b/test/explorer_test.dart index d70bf15..cbbd623 100644 --- a/test/explorer_test.dart +++ b/test/explorer_test.dart @@ -358,6 +358,28 @@ void main() { await calm(tester); }); + testWidgets('X-plore f 보충: 펼친 뒤 압축 파일이 바뀌면 새로 고칠 때 목록을 다시 읽는다 (관리자 지시 10/19)', (tester) async { + final zip = p.join(left, 'pack.zip'); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()..addFile(ArchiveFile.string('old.txt', 'old')))); + await open(tester); + await settle(tester, () => find.text('pack.zip').evaluate().isNotEmpty); + await act(tester, () => tester.tap(chevronOf('pack.zip').first)); + await settle(tester, () => find.text('old.txt').evaluate().isNotEmpty); + expect(find.text('old.txt'), findsOneWidget); + // 다른 프로그램이 압축을 바꿈 (크기 · 바뀐 때가 달라짐) + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 1100)); + File(zip).writeAsBytesSync(ZipEncoder().encode(Archive() + ..addFile(ArchiveFile.string('new.txt', 'new content')) + ..addFile(ArchiveFile.string('more.txt', 'more')))); + }); + await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f5)); + await settle(tester, () => find.text('new.txt').evaluate().isNotEmpty); + expect(find.text('new.txt'), findsOneWidget); + expect(find.text('more.txt'), findsOneWidget); + expect(find.text('old.txt'), findsNothing); + }); + testWidgets('X-plore g · h: 폴더 메뉴 [격자로 보기] (길게 = 그 자리로) · 파일 메뉴 [글 뷰어로 보기]', (tester) async { File(p.join(left, 'sub', 'pic.png')).writeAsBytesSync([0x89, 0x50]); await open(tester); From 6366a2f880274fdb442cb45ec6ce259cb59445b7 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:50:59 +0900 Subject: [PATCH 2/8] =?UTF-8?q?WIP=20=EA=B8=80=20=EB=B7=B0=EC=96=B4:=20?= =?UTF-8?q?=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=97=86=EB=8A=94=20=ED=81=B0=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=80=20=ED=99=94=EB=A9=B4=20=ED=8F=AD?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EB=82=98=EB=88=A0=20=EA=B7=B8?= =?UTF-8?q?=EB=A0=A4=20=EB=A9=88=EC=B6=94=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/19, 사용자 "오늘 못했던 것 진행" 2번 - 데이터 검토에서 남긴 작은 점): 줄바꿈 없는 큰 파일 (한 줄 수십만 자) 을 한 Text 로 그려 잠깐 멈췄다. - 화면 한 줄에 드는 글자 수 (고정폭 글꼴 · 글자 크기 · 줄 번호 칸) 를 보고, 그 8배보다 긴 줄만 4배 (화면 네 줄쯤) 씩 나눠 그린다. 보통 줄은 그대로 (화면에서 저절로 줄바꿈). 줄 번호는 첫 조각에만. 이모지 같은 두 단위 글자는 반으로 자르지 않는다. - 나눈 줄은 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다 (viewerRows). 시험: xplore_tools_test (새 2: viewerRows - 글 그대로 · 줄 번호 · 8배까지 그대로 · 이모지 / 200KB 한 줄 파일을 412 폭에서 여러 짧은 조각으로) 4 통과, 임시 폴더 흔적 0 (tool/test_temp_check.ps1), analyze 문제 없음. Co-Authored-By: Claude Opus 5.5 --- lib/ui/text_viewer.dart | 55 +++++++++++++++++++++++++++++++++---- test/xplore_tools_test.dart | 35 +++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/lib/ui/text_viewer.dart b/lib/ui/text_viewer.dart index 9097737..444709c 100644 --- a/lib/ui/text_viewer.dart +++ b/lib/ui/text_viewer.dart @@ -84,9 +84,22 @@ class _TextViewerPageState extends State { _text = t; } + /// 그릴 줄 (줄 번호 · 글) - 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다 + List<(int, String)> _rows = const []; + String? _rowsText; + int _rowsPer = 0; + + List<(int, String)> _rowsFor(int perRow) { + if (!identical(_rowsText, _text) || _rowsPer != perRow) { + _rowsText = _text; + _rowsPer = perRow; + _rows = viewerRows(_text, perRow); + } + return _rows; + } + @override Widget build(BuildContext context) { - final lines = _text.split('\n'); // 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (412 에서 파일 이름이 다 가려졌다) final narrow = MediaQuery.sizeOf(context).width < 600; return Scaffold( @@ -150,23 +163,29 @@ class _TextViewerPageState extends State { Expanded( // 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다) child: SelectionArea( - child: Scrollbar( + child: LayoutBuilder(builder: (context, box) { + // 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 - + // 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다 + final room = box.maxWidth - 16 - (_lineNumbers ? 56 : 0); + final rows = _rowsFor((room / (_font * 0.62)).floor().clamp(20, 4000)); + return Scrollbar( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - itemCount: lines.length, + itemCount: rows.length, itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_lineNumbers) SizedBox( width: 48, - child: Text('${i + 1}', + child: Text(rows[i].$1 > 0 ? '${rows[i].$1}' : '', textAlign: TextAlign.right, style: TextStyle(fontSize: _font - 2, color: JjColors.textDim, fontFamily: 'monospace')), ), if (_lineNumbers) const SizedBox(width: 8), - Expanded(child: Text(lines[i], style: TextStyle(fontSize: _font, fontFamily: 'monospace'))), + Expanded(child: Text(rows[i].$2, style: TextStyle(fontSize: _font, fontFamily: 'monospace'))), ]), ), - ), + ); + }), ), ), if (!_complete) @@ -187,3 +206,27 @@ class _TextViewerPageState extends State { ); } } + +/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 [perRow] 자가 들 때, +/// 그보다 8배 넘게 긴 줄은 4배 (화면 네 줄쯤) 씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈) +List<(int, String)> viewerRows(String text, int perRow) { + final out = <(int, String)>[]; + final lines = text.split('\n'); + final piece = perRow * 4; + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + if (line.length <= perRow * 8) { + out.add((i + 1, line)); + continue; + } + var at = 0; + while (at < line.length) { + var end = (at + piece).clamp(0, line.length); + // 두 단위 글자 (이모지 등) 를 반으로 자르지 않게 + if (end < line.length && end > at + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--; + out.add((at == 0 ? i + 1 : 0, line.substring(at, end))); + at = end; + } + } + return out; +} diff --git a/test/xplore_tools_test.dart b/test/xplore_tools_test.dart index 24d1119..8cccfca 100644 --- a/test/xplore_tools_test.dart +++ b/test/xplore_tools_test.dart @@ -35,6 +35,41 @@ void main() { expect(find.text('CP949 (EUC-KR)'), findsOneWidget); }); + test('h 글 뷰어: 아주 긴 줄만 나눠 그린다 (줄 번호는 첫 조각에만, 이모지를 반으로 자르지 않음)', () { + expect(viewerRows('a\nbb', 10), [(1, 'a'), (2, 'bb')]); + final long = 'x' * 1000; + final rows = viewerRows('first\n$long\nlast', 10); + expect(rows.first, (1, 'first')); + expect(rows.last, (3, 'last')); + final mid = rows.sublist(1, rows.length - 1); + expect(mid.map((r) => r.$2).join(), long, reason: '글은 그대로 (빠지거나 겹치지 않음)'); + expect(mid.first.$1, 2); + expect(mid.skip(1).every((r) => r.$1 == 0), isTrue); + expect(mid.every((r) => r.$2.length <= 40), isTrue, reason: '화면 네 줄쯤씩'); + expect(viewerRows('y' * 80, 10).length, 1, reason: '8배까지는 그대로 (화면에서 줄바꿈)'); + // 나누는 자리에 두 단위 글자 (😀) 가 걸리면 앞으로 당긴다 + final emoji = '${'z' * 39}😀${'z' * 100}'; + final cut = viewerRows(emoji, 10); + expect(cut.map((r) => r.$2).join(), emoji); + expect(cut.every((r) => !r.$2.endsWith('\uD83D')), isTrue); + }); + + testWidgets('h 글 뷰어: 줄바꿈 없는 큰 파일 (200KB 한 줄) 도 나눠 그려 멈추지 않는다', (t) async { + final f = File(p.join(tmp.path, 'one.txt'))..writeAsStringSync('${'0123456789' * 20000}END'); + t.view.physicalSize = const Size(412, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path))); + for (var i = 0; i < 40 && find.textContaining('0123456789').evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(); + } + final texts = t.widgetList(find.textContaining('0123456789')).toList(); + expect(texts.length, greaterThan(1), reason: '한 줄을 여러 조각으로'); + expect(texts.every((x) => (x.data ?? '').length < 2000), isTrue, reason: '조각마다 짧게'); + expect(find.text('1'), findsOneWidget, reason: '줄 번호는 첫 조각에만'); + }); + testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async { final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]); final opened = []; From 58254e42b246d2249c4eaf5109a4ef1a7790729f Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:55:38 +0900 Subject: [PATCH 3/8] =?UTF-8?q?WIP=20Wi-Fi=20=EA=B3=B5=EC=9C=A0=20(Windows?= =?UTF-8?q?):=20=EC=BC=9C=EA=B8=B0=20=EC=A0=84=20=EB=B0=A9=ED=99=94?= =?UTF-8?q?=EB=B2=BD=20=EC=95=88=EB=82=B4=20=C2=B7=20=EB=A7=89=ED=98=94?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20"=EA=B0=9C=EC=9D=B8=20=EB=84=A4?= =?UTF-8?q?=ED=8A=B8=EC=9B=8C=ED=81=AC=20=ED=97=88=EC=9A=A9"=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B2=95=EA=B3=BC=20=EC=84=A4=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=97=AC=EB=8A=94=20=EB=B2=84=ED=8A=BC=20(=EA=B2=80=ED=86=A0?= =?UTF-8?q?=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/19, 사용자 "오늘 못했던 것 진행" 3번): - 켜기 전 화면: "켜면 Windows 방화벽 창이 뜰 수 있습니다: '개인 네트워크' 에 체크하고 [허용]. '공용 네트워크' 는 체크하지 않는 것이 안전" (전에는 한 줄 안내만). - 켜기 전 · 공유 중 화면 모두에 펼침 도움말 [다른 기기에서 열리지 않으면 (Windows 방화벽)]: ① 이 PC 의 망이 "개인 네트워크" 인지 (설정 > 네트워크 및 인터넷 > … > 네트워크 프로필 유형) + [네트워크 설정 열기] (ms-settings:network-status) ② 방화벽에서 이 앱 "개인" 허용 (제어판 > Windows Defender 방화벽 > 앱 허용 > [설정 변경]) + [방화벽 허용 앱 열기] (control /name Microsoft.WindowsFirewall /page pageConfigureApps) ③ 목록에 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe · 바꾼 뒤 공유를 끄고 다시 켜기 · 회사 · 학교 PC 는 바꿀 수 없을 수 있음. 내 판단: 방화벽을 앱이 직접 바꾸지 않는다 (관리자 권한 · 사용자 몰래 바꾸지 않게) - 안내와 설정 화면 열기만. 시험: wifi_share_page_test (새 1: 켜기 전 안내 · 도움말 펼치기 · 두 버튼이 여는 것) · wifi_share_test 10 통과, 임시 폴더 흔적 0, analyze 문제 없음, l10n 빠짐 0. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 8 ++++++ assets/l10n/ja.json | 8 ++++++ assets/l10n/zh-Hans.json | 8 ++++++ lib/ui/wifi_share_page.dart | 47 +++++++++++++++++++++++++++++++++- test/wifi_share_page_test.dart | 21 +++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 866554c..6f6893a 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -908,6 +908,7 @@ "네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다": "Network shares (SMB) aren't opened by path. Add the server with [Add SMB server] and it opens as a tab", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "Network shares (SMB) aren't supported yet. Use the WebDAV tab", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "For a network share, include the shared folder name (e.g. \\\\NAS\\Videos).", + "네트워크 설정 열기": "Open network settings", "네팔어": "Nepali", "노랑": "Yellow", "노르웨이어": "Norwegian", @@ -927,6 +928,7 @@ "다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "{0} is already in the MKV list, so it was removed from the download list (the file is kept)", "다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "Added {0} to the MKV list and removed it from the download list (the file is kept)", "다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "It may have been deleted or moved elsewhere. Try the parent folder.", + "다른 기기에서 열리지 않으면 (Windows 방화벽)": "If other devices can't open it (Windows Firewall)", "다른 기기의 브라우저에서 이 주소를 여세요": "Open this address in a browser on the other device", "다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "You came back to v{1} after using another version ({0}). That version may not know some of this version's settings.\nRestore the settings kept when you last used v{1} (including the video list and bookmarks)?", "다른 버전 고르기": "Choose another version", @@ -1218,6 +1220,7 @@ "목록": "list", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos", "목록 보기": "View list", + "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "If JJ_MKVMaker is not in the list, use [Allow another app] to pick JJ_MKVMaker.exe and check \"Private\". Turn sharing off and on again afterwards.", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here", "목록에서 빼기": "Remove from list", "목록에서 빼기 (파일은 그대로)": "Remove from list (file is kept)", @@ -1333,6 +1336,8 @@ "밤 0~6시": "Night 0-6", "방법: {0}": "Method: {0}", "방식": "Type", + "방화벽 허용 앱 열기": "Open allowed apps", + "방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "Allow this app in the firewall: Control Panel > Windows Defender Firewall > Allow an app or feature through Windows Defender Firewall > [Change settings] > check \"Private\" for JJ_MKVMaker > [OK].", "배율": "Scale", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)", "백그라운드로": "To background", @@ -1976,6 +1981,7 @@ "음성인식 중 {0}%": "Recognizing speech {0}%", "음소거 (M)": "Mute (M)", "음악": "Music", + "이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "Check that this PC's network is a \"Private network\": Settings > Network & internet > Wi-Fi (or Ethernet) > the connected network > Network profile type = Private. Only use Private on home or trusted networks.", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "This PC's secure storage could not be decrypted. Please enter your WebDAV and OpenSubtitles passwords once more. The file that could not be decrypted was kept, not deleted: {0}", "이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in", "이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file", @@ -2527,6 +2533,7 @@ "컴포넌트 목록을 받을 수 없습니다 ({0})": "Could not get the component list ({0})", "켜기": "On", "켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "Turning it on downloads Tor Expert Bundle {0} (tor {1}) from the official Tor distribution (about {2} MB, SHA-256 verified). Turning it off deletes it.", + "켜면 Windows 방화벽 창이 뜰 수 있습니다: \"개인 네트워크\" 에 체크하고 [허용] 을 누르세요. \"공용 네트워크\" 는 체크하지 않는 것이 안전합니다.": "When you turn it on, a Windows Firewall window may appear: check \"Private networks\" and click [Allow]. It is safer not to check \"Public networks\".", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "When on, double-tapping shows the images inside right away. When off, opens the file list (extract selected).", "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "Turning it on downloads and installs a converter (about 420MB). Turning it off deletes it.", "켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "When on, a sort changed from a column header or Sort by applies to that folder only. When off, all folders share one sort", @@ -2795,6 +2802,7 @@ "환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "As set in Settings, it {0} (Settings > File explorer > When a file with the same name exists)", "환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "Enter the master password to open settings. It won't be asked again until the app is closed.", "환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)": "{0} from Settings is not used when copying (to move, use [Move])", + "회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.": "On work or school PCs, an administrator may have locked the firewall so you cannot change it.", "회전": "Rotation", "회전 없음": "No rotation", "회차": "Episode", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 50c26a4..5dbd030 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -908,6 +908,7 @@ "네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다": "ネットワーク共有 (SMB) はパスでは直接開きません。[SMB サーバーを追加] でサーバーを登録するとタブで開けます", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "ネットワーク共有 (SMB) にはまだ対応していません。WebDAV タブを使ってください", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。", + "네트워크 설정 열기": "ネットワーク設定を開く", "네팔어": "ネパール語", "노랑": "黄色", "노르웨이어": "ノルウェー語", @@ -927,6 +928,7 @@ "다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "ダウンロードした {0} はすでに MKV 一覧にあるため、ダウンロード一覧から外しました (ファイルはそのまま)", "다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "ダウンロードした {0} を MKV 一覧に入れ、ダウンロード一覧から外しました (ファイルはそのまま)", "다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "別の場所で削除・移動された可能性があります。上のフォルダーへ移動してみてください。", + "다른 기기에서 열리지 않으면 (Windows 방화벽)": "他の端末で開けない場合 (Windows ファイアウォール)", "다른 기기의 브라우저에서 이 주소를 여세요": "もう一方の端末のブラウザーでこのアドレスを開いてください", "다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "別のバージョン ({0}) を使ったあと v{1} に戻りました。そのバージョンはこのバージョンの設定の一部を知らない可能性があります。\nv{1} を最後に使ったときに保管した設定 (動画リスト · ブックマークを含む) に戻しますか?", "다른 버전 고르기": "別のバージョンを選ぶ", @@ -1218,6 +1220,7 @@ "목록": "一覧", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します", "목록 보기": "リストを見る", + "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます", "목록에서 빼기": "リストから外す", "목록에서 빼기 (파일은 그대로)": "リストから外す (ファイルはそのまま)", @@ -1333,6 +1336,8 @@ "밤 0~6시": "夜 0~6 時", "방법: {0}": "方法: {0}", "방식": "方式", + "방화벽 허용 앱 열기": "許可されたアプリを開く", + "방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "ファイアウォールでこのアプリを許可します: コントロール パネル > Windows Defender ファイアウォール > Windows Defender ファイアウォールを介したアプリまたは機能を許可 > [設定の変更] > JJ_MKVMaker の「プライベート」にチェック > [OK]。", "배율": "倍率", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)", "백그라운드로": "バックグラウンドへ", @@ -1976,6 +1981,7 @@ "음성인식 중 {0}%": "音声認識中 {0}%", "음소거 (M)": "ミュート(M)", "음악": "音楽", + "이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "この PC が接続しているネットワークが「プライベート ネットワーク」か確認します: 設定 > ネットワークとインターネット > Wi-Fi (またはイーサネット) > 接続中のネットワーク > ネットワーク プロファイルの種類 = プライベート。自宅や信頼できるネットワークでのみプライベートにしてください。", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "この PC の安全な保存領域を復号できませんでした。WebDAV・OpenSubtitles のパスワードをもう一度だけ入力してください。復号できなかったファイルは削除せず保管しました: {0}", "이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です", "이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます", @@ -2527,6 +2533,7 @@ "컴포넌트 목록을 받을 수 없습니다 ({0})": "コンポーネント一覧を取得できません ({0})", "켜기": "オン", "켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "オンにすると Tor 公式配布元から Tor Expert Bundle {0} (tor {1}) をダウンロードします (約 {2}MB、SHA-256 確認)。オフにすると削除します。", + "켜면 Windows 방화벽 창이 뜰 수 있습니다: \"개인 네트워크\" 에 체크하고 [허용] 을 누르세요. \"공용 네트워크\" 는 체크하지 않는 것이 안전합니다.": "オンにすると Windows ファイアウォールの画面が出ることがあります:「プライベート ネットワーク」にチェックして [許可] を押してください。「パブリック ネットワーク」はチェックしない方が安全です。", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "オンにするとダブルタップで中の画像をすぐめくって表示します。オフにすると一覧 (選んで展開) を開きます。", "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "オンにすると変換ツールをダウンロードしてインストールします (約 420MB)。オフにすると削除します。", "켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "オンにすると、列見出し・並べ替えで変えた並べ替えはそのフォルダーだけに使われます。オフではすべてのフォルダーが同じ並べ替えです", @@ -2795,6 +2802,7 @@ "환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "設定どおり{0} (環境設定 > ファイルエクスプローラー > 同じ名前があるとき)", "환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "環境設定を開くにはマスターパスワードを入力してください。アプリを終了するまで再度尋ねません。", "환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)": "環境設定の {0} はコピーでは使いません (移動は [移動] で)", + "회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.": "会社・学校の PC は管理者がファイアウォールを固定していて変更できないことがあります。", "회전": "回転", "회전 없음": "回転なし", "회차": "話数", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 915a373..18f44af 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -908,6 +908,7 @@ "네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다": "网络共享 (SMB) 不能直接按路径打开。用 [添加 SMB 服务器] 添加服务器后即可在标签中打开", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "尚不支持网络共享(SMB)。请使用 WebDAV 标签", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。", + "네트워크 설정 열기": "打开网络设置", "네팔어": "尼泊尔语", "노랑": "黄色", "노르웨이어": "挪威语", @@ -927,6 +928,7 @@ "다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "下载完成的 {0} 已在 MKV 列表中,已从下载列表移除(文件保留)", "다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "已将下载完成的 {0} 加入 MKV 列表并从下载列表移除(文件保留)", "다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "可能已在别处被删除或移动。请返回上级文件夹。", + "다른 기기에서 열리지 않으면 (Windows 방화벽)": "其他设备无法打开时 (Windows 防火墙)", "다른 기기의 브라우저에서 이 주소를 여세요": "在另一台设备的浏览器中打开此地址", "다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "使用其他版本 ({0}) 后回到了 v{1}。该版本可能不认识此版本的部分设置。\n要恢复最后一次使用 v{1} 时保存的设置 (包括视频列表 · 书签) 吗?", "다른 버전 고르기": "选择其他版本", @@ -1218,6 +1220,7 @@ "목록": "列表", "목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域", "목록 보기": "查看列表", + "목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。", "목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情", "목록에서 빼기": "从列表中移除", "목록에서 빼기 (파일은 그대로)": "从列表中移除 (保留文件)", @@ -1333,6 +1336,8 @@ "밤 0~6시": "夜间 0~6 点", "방법: {0}": "方式: {0}", "방식": "方式", + "방화벽 허용 앱 열기": "打开允许的应用", + "방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "在防火墙中允许此应用:控制面板 > Windows Defender 防火墙 > 允许应用或功能通过 Windows Defender 防火墙 > [更改设置] > 勾选 JJ_MKVMaker 的“专用” > [确定]。", "배율": "倍率", "백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)", "백그라운드로": "转到后台", @@ -1976,6 +1981,7 @@ "음성인식 중 {0}%": "正在识别语音 {0}%", "음소거 (M)": "静音(M)", "음악": "音乐", + "이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "确认这台电脑连接的网络是“专用网络”:设置 > 网络和 Internet > WLAN (或以太网) > 已连接的网络 > 网络配置文件类型 = 专用。仅在家庭或可信网络中设为专用。", "이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "无法解密此电脑的安全存储。请再输入一次 WebDAV · OpenSubtitles 密码。无法解密的文件未删除,已保留: {0}", "이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录", "이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中", @@ -2527,6 +2533,7 @@ "컴포넌트 목록을 받을 수 없습니다 ({0})": "无法获取组件列表({0})", "켜기": "开启", "켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "开启后从 Tor 官方发布处下载 Tor Expert Bundle {0} (tor {1}) (约 {2}MB,校验 SHA-256)。关闭时删除。", + "켜면 Windows 방화벽 창이 뜰 수 있습니다: \"개인 네트워크\" 에 체크하고 [허용] 을 누르세요. \"공용 네트워크\" 는 체크하지 않는 것이 안전합니다.": "开启时可能会出现 Windows 防火墙窗口:勾选“专用网络”并点击 [允许]。不勾选“公用网络”更安全。", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "开启后双击会直接翻看其中的图片;关闭则打开文件列表 (选择解压)。", "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "开启时会下载并安装转换器 (约 420MB)。关闭时会删除。", "켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "开启后,通过列标题或排序方式更改的排序只用于该文件夹。关闭时所有文件夹使用同一排序", @@ -2795,6 +2802,7 @@ "환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "按设置{0} (设置 > 文件资源管理器 > 存在同名项目时)", "환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "要打开设置,请输入主密码。在关闭应用前不会再次询问。", "환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)": "设置中的 {0} 不用于复制(要移动请用 [移动])", + "회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.": "公司或学校的电脑可能由管理员锁定了防火墙,无法更改。", "회전": "旋转", "회전 없음": "不旋转", "회차": "集数", diff --git a/lib/ui/wifi_share_page.dart b/lib/ui/wifi_share_page.dart index 6722348..d240203 100644 --- a/lib/ui/wifi_share_page.dart +++ b/lib/ui/wifi_share_page.dart @@ -20,6 +20,11 @@ class WifiSharePage extends StatefulWidget { final String folder; const WifiSharePage({super.key, required this.c, required this.folder}); + /// 시험: Windows 설정 · 제어판 열기를 바꾼다 + @visibleForTesting + static Future Function(String exe, List args) debugLaunch = + (exe, args) async => Process.start(exe, args, mode: ProcessStartMode.detached); + static Future open(BuildContext context, AppController c, String folder) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => WifiSharePage(c: c, folder: folder))); @@ -139,7 +144,11 @@ class _WifiSharePageState extends State { const SizedBox(height: 12), _note(Icons.lock_outline, tr('켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.')), _note(Icons.visibility_outlined, tr('읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.')), - if (Platform.isWindows) _note(Icons.shield_outlined, tr('처음 켤 때 Windows 방화벽 창이 뜨면 "개인 네트워크" 에 [허용] 을 누르세요.')), + if (Platform.isWindows) ...[ + _note(Icons.shield_outlined, + tr('켜면 Windows 방화벽 창이 뜰 수 있습니다: "개인 네트워크" 에 체크하고 [허용] 을 누르세요. "공용 네트워크" 는 체크하지 않는 것이 안전합니다.')), + _firewallHelp(), + ], if (_lastReason != null) _note(Icons.info_outline, _lastReason!, color: JjColors.danger), if (_error != null) _note(Icons.error_outline, _error!, color: JjColors.danger), const SizedBox(height: 16), @@ -193,6 +202,8 @@ class _WifiSharePageState extends State { const SizedBox(height: 8), Center(child: QrView(data: s.urlWithCode(hosts.first), size: 200)), ], + // 관리자 지시 (10/19): 공유 중에도 다른 기기에서 안 열릴 때 볼 곳 + if (Platform.isWindows) _firewallHelp(), const SizedBox(height: 12), Row(children: [ Expanded( @@ -223,6 +234,40 @@ class _WifiSharePageState extends State { static String _hm(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; + /// 관리자 지시 (10/19): Windows 방화벽이 막았을 때 "개인 네트워크 허용" 하는 법 (펼쳐 봄) · 그 화면을 바로 여는 버튼 + Widget _firewallHelp() => ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(left: 8, bottom: 8), + leading: const Icon(Icons.help_outline, size: 20), + title: Text(tr('다른 기기에서 열리지 않으면 (Windows 방화벽)'), style: const TextStyle(fontSize: 14)), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + children: [ + _note(Icons.looks_one_outlined, + tr('이 PC 가 연결한 망이 "개인 네트워크" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.')), + Padding( + padding: const EdgeInsets.only(left: 26), + child: TextButton.icon( + onPressed: () => WifiSharePage.debugLaunch('explorer.exe', ['ms-settings:network-status']), + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(tr('네트워크 설정 열기')), + ), + ), + _note(Icons.looks_two_outlined, + tr('방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 "개인" 에 체크 > [확인].')), + Padding( + padding: const EdgeInsets.only(left: 26), + child: TextButton.icon( + onPressed: () => WifiSharePage.debugLaunch('control.exe', ['/name', 'Microsoft.WindowsFirewall', '/page', 'pageConfigureApps']), + icon: const Icon(Icons.open_in_new, size: 16), + label: Text(tr('방화벽 허용 앱 열기')), + ), + ), + _note(Icons.looks_3_outlined, + tr('목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 "개인" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.')), + _note(Icons.info_outline, tr('회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.')), + ], + ); + Widget _note(IconData icon, String text, {Color color = JjColors.textDim}) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/test/wifi_share_page_test.dart b/test/wifi_share_page_test.dart index 3449d76..faeb993 100644 --- a/test/wifi_share_page_test.dart +++ b/test/wifi_share_page_test.dart @@ -57,4 +57,25 @@ void main() { expect(WifiShare.instance, isNull); expect(find.text('켜기'), findsOneWidget); }); + testWidgets('Windows: 켜기 전에 방화벽 안내 · 펼치면 "개인 네트워크 허용" 하는 법과 설정 · 제어판을 여는 버튼 (관리자 지시 10/19)', (tester) async { + if (!Platform.isWindows) return; + final launched = []; + final old = WifiSharePage.debugLaunch; + WifiSharePage.debugLaunch = (exe, args) async => launched.add('$exe ${args.join(' ')}'); + addTearDown(() => WifiSharePage.debugLaunch = old); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + tester.view.physicalSize = const Size(412, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: WifiSharePage(c: c, folder: tmp.path))); + expect(find.textContaining('"개인 네트워크" 에 체크하고 [허용]'), findsOneWidget, reason: '켜기 전에 미리'); + expect(find.text('방화벽 허용 앱 열기'), findsNothing, reason: '도움말은 접혀 있음'); + await tester.tap(find.text('다른 기기에서 열리지 않으면 (Windows 방화벽)')); + await tester.pumpAndSettle(); + expect(find.textContaining('네트워크 프로필 유형 = 개인'), findsOneWidget); + await tester.tap(find.text('네트워크 설정 열기')); + await tester.tap(find.text('방화벽 허용 앱 열기')); + await tester.pump(); + expect(launched, ['explorer.exe ms-settings:network-status', 'control.exe /name Microsoft.WindowsFirewall /page pageConfigureApps']); + }); } From 25a0cd3c96d72a49d3af69b392151b5829c71647 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:51:39 +0900 Subject: [PATCH 4/8] =?UTF-8?q?WIP=20=ED=86=B5=ED=95=A9=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=C2=B7=20=EC=BA=A1=EC=B2=98=EC=9D=98=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=ED=8F=B4=EB=8D=94:=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EB=81=9D=EC=97=90=20(=EC=8B=A4=ED=8C=A8=ED=95=B4=EB=8F=84)=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EA=B8=B0=20=C2=B7=20test=5Ftemp=5Fcheck=20?= =?UTF-8?q?=EA=B0=80=20=ED=86=B5=ED=95=A9=20=EC=8B=9C=ED=97=98=EC=97=90?= =?UTF-8?q?=EB=8F=84=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/19, 28 이 찾음): Windows 캡처 · 통합 시험 (integration_test, -d windows) 이 %TEMP%\jj_* 시험 폴더를 남겼다 (시험 본문 끝의 deleteSync 는 실패하면 건너뛰고, 앱 · WebView 가 파일을 잡고 있으면 지우기가 실패했다). - 새 integration_test/support/temp_dir.dart: testTempDir(prefix, {base}) - 만들면서 addTearDown 으로 지우기를 걸어 실패해도 지운다. 잠겨 있으면 0.25초씩 20번 다시 (실제 시간 - 가짜 시계 밖), 긴 경로는 \?\, 끝내 못 지우면 알리기만. 통합 시험 26개 파일의 Directory.systemTemp.createTempSync('jj…') 를 모두 이것으로 (JJ_SHOT_TMP 를 쓰는 캡처 도우미 포함). - tool/test_temp_check.ps1: integration_test 의 이름 (testTempDir 포함) 도 모은다. 넘겨받은 .dart 파일이 있으면 그 파일과 그것이 import 하는 support 파일의 이름만 센다 - 같은 때 다른 세션 · worktree 가 돌린 시험의 폴더 (jj_explorer_ · jj_sync_ · jj_fake_recycle_) 를 세던 거짓 실패를 없앰. 시험 소스에 없는 이름의 새 jj 폴더는 참고로만 보인다. 사용: test_temp_check.ps1 integration_test\windows_b6_shot_test.dart -d windows - com_guard_test 의 쓰지 않는 dart:io import 정리 (analyze). 시험: windows_b6 · windows_xplore · windows_ftp · windows_wifi 캡처 통과 · 새로 남은 것 0 (test_temp_check), analyze 문제 없음. Co-Authored-By: Claude Opus 5.5 --- integration_test/ai_e2e_test.dart | 3 +- integration_test/browser_keep_test.dart | 3 +- integration_test/browser_real_test.dart | 3 +- integration_test/cef_dns_real_test.dart | 3 +- integration_test/com_guard_test.dart | 5 ++-- integration_test/dns_block_real_test.dart | 3 +- integration_test/full_player_test.dart | 3 +- integration_test/monitor_shot_test.dart | 3 +- integration_test/pdf_test.dart | 4 +-- integration_test/player_test.dart | 3 +- integration_test/rsync_page_test.dart | 5 ++-- .../secret_store_platform_test.dart | 3 +- integration_test/support/temp_dir.dart | 29 ++++++++++++++++++ integration_test/tor_dns_real_test.dart | 3 +- integration_test/translate_split_test.dart | 5 ++-- integration_test/video_adjust_e2e_test.dart | 3 +- integration_test/web_translate_test.dart | 3 +- integration_test/whisper_probe_test.dart | 3 +- integration_test/windows_662_shot_test.dart | 5 ++-- integration_test/windows_b3_shot_test.dart | 5 ++-- integration_test/windows_b6_input_test.dart | 3 +- integration_test/windows_b6_shot_test.dart | 4 +-- integration_test/windows_check_shot_test.dart | 5 ++-- integration_test/windows_p3_shot_test.dart | 4 +-- integration_test/windows_wifi_shot_test.dart | 4 +-- .../windows_xplore_shot_test.dart | 4 +-- .../windows_xplore_tools_shot_test.dart | 4 +-- tool/test_temp_check.ps1 | 30 +++++++++++++++++-- 28 files changed, 110 insertions(+), 45 deletions(-) create mode 100644 integration_test/support/temp_dir.dart diff --git a/integration_test/ai_e2e_test.dart b/integration_test/ai_e2e_test.dart index 1f662c6..5eef314 100644 --- a/integration_test/ai_e2e_test.dart +++ b/integration_test/ai_e2e_test.dart @@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/core/languages.dart'; import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/output_paths.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 Whisper + NLLB 로 AI 자막 → MKV (모델이 JJ_MKVMAKER_MODELS 에 있어야 함) @@ -16,7 +17,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('영어 음성 영상 → AI 자막(ko/en/ja) → MKV', (tester) async { - final dir = Directory.systemTemp.createTempSync('jj_e2e_'); + final dir = testTempDir('jj_e2e_'); final video = p.join(dir.path, '영어 강의.mp4'); final speech = p.join(Directory.current.path, 'test_assets', 'speech_en.wav'); final r = await Process.run('ffmpeg', [ diff --git a/integration_test/browser_keep_test.dart b/integration_test/browser_keep_test.dart index 97ff79e..64a6837 100644 --- a/integration_test/browser_keep_test.dart +++ b/integration_test/browser_keep_test.dart @@ -10,6 +10,7 @@ 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/browser_page.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 Edge(WebView2): MKV 화면 ↔ 브라우저를 여러 번 오가도 보던 페이지를 다시 읽지 않고 그대로 이어진다 @@ -27,7 +28,7 @@ void main() { await req.response.close(); }); final url = 'http://127.0.0.1:${server.port}/page'; - final dataDir = Directory.systemTemp.createTempSync('jj_keep_').path; + final dataDir = testTempDir('jj_keep_').path; final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings diff --git a/integration_test/browser_real_test.dart b/integration_test/browser_real_test.dart index 3e8a1bd..d9c3a6c 100644 --- a/integration_test/browser_real_test.dart +++ b/integration_test/browser_real_test.dart @@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/platform/windows/ytdlp_backend.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 Edge(WebView2): 페이지 열기 · 동영상 감지 · 쿠키 저장 → yt-dlp 가 같은 쿠키를 읽는지 @@ -31,7 +32,7 @@ void main() { await req.response.close(); }); final url = 'http://127.0.0.1:${server.port}/'; - final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path; + final dataDir = testTempDir('jj_webview_').path; final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings.webViewDataDir = dataDir; diff --git a/integration_test/cef_dns_real_test.dart b/integration_test/cef_dns_real_test.dart index c1967ab..e5f74d2 100644 --- a/integration_test/cef_dns_real_test.dart +++ b/integration_test/cef_dns_real_test.dart @@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/app_actions.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 망 (Windows): DNS 강제 (8.8.8.8) 상태에서 Chrome 엔진 (CEF) 이 앱 안 프록시의 인증 (비밀값) 에 답해 페이지를 여는지. @@ -33,7 +34,7 @@ void main() { } CefRuntime.rememberStartState(); expect(CefRuntime.readyThisRun, isTrue); - final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_cef_real_').path; + final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_').path; Directory(data).createSync(recursive: true); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings diff --git a/integration_test/com_guard_test.dart b/integration_test/com_guard_test.dart index 4517322..819e2f9 100644 --- a/integration_test/com_guard_test.dart +++ b/integration_test/com_guard_test.dart @@ -1,11 +1,10 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/platform/windows/com_guard.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'support/temp_dir.dart'; /// 실제 Edge(WebView2): 다른 구성 요소가 COM 준비 상태를 풀어 버린 상황을 만들고 /// (브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨던 문제) 그래도 브라우저 환경이 만들어지는지 @@ -26,7 +25,7 @@ void main() { for (var i = 0; i < 8; i++) { ComGuard.releaseForTest(); } - final dir = Directory.systemTemp.createTempSync('jj_com_').path; + final dir = testTempDir('jj_com_').path; final raw = await tester.runAsync(() async { try { await WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: '${dir}_raw')); diff --git a/integration_test/dns_block_real_test.dart b/integration_test/dns_block_real_test.dart index 58c7485..f38fd6b 100644 --- a/integration_test/dns_block_real_test.dart +++ b/integration_test/dns_block_real_test.dart @@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/app_actions.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 250 실제 엔진 (Windows Edge WebView2): DNS 강제 서버에 닿지 않으면 (TEST-NET 192.0.2.1) 엔진의 오류 화면 @@ -20,7 +21,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async { - final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_dns_block_').path; + final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_').path; Directory(data).createSync(recursive: true); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings diff --git a/integration_test/full_player_test.dart b/integration_test/full_player_test.dart index 4df2e85..2c45854 100644 --- a/integration_test/full_player_test.dart +++ b/integration_test/full_player_test.dart @@ -9,6 +9,7 @@ import 'package:jj_mkvmaker/platform/windows/desktop_shell.dart'; import 'package:jj_mkvmaker/services/media_player.dart'; import 'package:path/path.dart' as p; import 'package:window_manager/window_manager.dart'; +import 'support/temp_dir.dart'; /// 실제 mpv 플레이어 + 창 모드 void main() { @@ -16,7 +17,7 @@ void main() { testWidgets('재생 목록 · 자막 트랙 · 음량 · 전체 화면 · 팝업 · 창 크기', (tester) async { MediaKitPreviewPlayer.ensureInitialized(); - final dir = Directory.systemTemp.createTempSync('jj_fp_'); + final dir = testTempDir('jj_fp_'); final srt = p.join(dir.path, 's.srt'); File(srt).writeAsStringSync('1\n00:00:00,000 --> 00:00:05,000\n내장 자막\n'); final ext = p.join(dir.path, 'ep1_ko.srt'); diff --git a/integration_test/monitor_shot_test.dart b/integration_test/monitor_shot_test.dart index 1b15e36..9e994c7 100644 --- a/integration_test/monitor_shot_test.dart +++ b/integration_test/monitor_shot_test.dart @@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:jj_mkvmaker/ui/schedule_editor.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 모니터링 화면 (복사 진행 중 · lsync · 일정 격자) 을 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR). 화면 배치 확인용. @@ -21,7 +22,7 @@ void main() { testWidgets('모니터링 화면 그림', (tester) async { final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; - final tmp = Directory.systemTemp.createTempSync('jj_monshot_'); + final tmp = testTempDir('jj_monshot_'); final src = p.join(tmp.path, '영상 원본'), dst = p.join(tmp.path, '백업'); for (var i = 0; i < 6; i++) { File(p.join(src, i < 3 ? 'A' : 'B', 'clip$i.mkv')) diff --git a/integration_test/pdf_test.dart b/integration_test/pdf_test.dart index 40dec40..c8a13e0 100644 --- a/integration_test/pdf_test.dart +++ b/integration_test/pdf_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/core/reader_sources.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 pdfium: 그림 → PDF, 열기 · 그리기, 페이지 지우기 · 그림 넣기 · 저장 @@ -12,8 +13,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('PDF: 만들기 · 열기 · 지우기 · 넣기 · 저장', (tester) async { - final dir = Directory.systemTemp.createTempSync('jj_pdf_'); - addTearDown(() => dir.deleteSync(recursive: true)); + final dir = testTempDir('jj_pdf_'); String image(String name, int w, int h, {bool jpg = false}) { final im = img.Image(width: w, height: h); img.fill(im, color: img.ColorRgb8(200, 50, 50)); diff --git a/integration_test/player_test.dart b/integration_test/player_test.dart index 41eefb0..e0dd52f 100644 --- a/integration_test/player_test.dart +++ b/integration_test/player_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/platform/common/media_kit_player.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 Windows 앱에서 libmpv 재생 확인 @@ -14,7 +15,7 @@ void main() { testWidgets('media_kit: 영상 열기 · 재생 · 이동', (tester) async { MediaKitPreviewPlayer.ensureInitialized(); - final dir = Directory.systemTemp.createTempSync('jj_play_'); + final dir = testTempDir('jj_play_'); final video = p.join(dir.path, '재생 테스트.mp4'); final r = await Process.run('ffmpeg', [ '-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=25', diff --git a/integration_test/rsync_page_test.dart b/integration_test/rsync_page_test.dart index ff95e49..26d8c56 100644 --- a/integration_test/rsync_page_test.dart +++ b/integration_test/rsync_page_test.dart @@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/monitor_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// Rsync 화면을 실제 rsync (앱에 들어 있는 것) 로: → 한 방향, ⇄ 양쪽 (-u), 모니터링에 남는지. 화면은 JJ_SHOT_DIR 에 PNG. @@ -20,7 +21,7 @@ void main() { testWidgets('Rsync 화면: → · ⇄ 실제 실행', (tester) async { final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; - final tmp = Directory.systemTemp.createTempSync('jj_rsyncpage_'); + final tmp = testTempDir('jj_rsyncpage_'); final a = p.join(tmp.path, '왼쪽 폴더'), b = p.join(tmp.path, 'right'); File(p.join(a, 'sub', '한글 파일.txt')) ..createSync(recursive: true) @@ -108,7 +109,7 @@ void main() { testWidgets('원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리', (tester) async { final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path; - final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_'); + final tmp = testTempDir('jj_rsyncmove_'); final a = p.join(tmp.path, 'srcA'), b = p.join(tmp.path, 'dstB'); final cDir = p.join(tmp.path, 'liveC'), d = p.join(tmp.path, 'liveD'); for (final (dir, name) in [(a, 'x'), (cDir, 'y')]) { diff --git a/integration_test/secret_store_platform_test.dart b/integration_test/secret_store_platform_test.dart index 4541124..4b53a61 100644 --- a/integration_test/secret_store_platform_test.dart +++ b/integration_test/secret_store_platform_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/services/secret_store.dart'; +import 'support/temp_dir.dart'; /// 54: 이 기기의 안전 저장소 (Windows DPAPI · Android Keystore) 에 실제로 쓰고 읽고 지운다. /// 시험용 열쇠 하나만 쓰고 끝에 지운다 (사용자가 저장한 값은 건드리지 않음). @@ -13,7 +14,7 @@ void main() { Directory? tmp; SecretStore store() { if (!Platform.isWindows) return SecretStore.platform(); - return SecretStore.dpapiAt((tmp ??= Directory.systemTemp.createTempSync('jj_secretit_')).path); + return SecretStore.dpapiAt((tmp ??= testTempDir('jj_secretit_')).path); } tearDownAll(() { diff --git a/integration_test/support/temp_dir.dart b/integration_test/support/temp_dir.dart new file mode 100644 index 0000000..63cb631 --- /dev/null +++ b/integration_test/support/temp_dir.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// 관리자 지시 (10/19, 28 이 찾음): 통합 시험 · 캡처가 %TEMP%\jj_* 시험 폴더를 남겼다 - 시험 끝 (실패해도) 에 지운다. +/// [base] 가 있으면 그 아래 (짧은 경로가 필요한 캡처). 반드시 시험 (testWidgets · test) 안에서 부른다 (addTearDown). +Directory testTempDir(String prefix, {String? base}) { + final root = base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true)); + final dir = root.createTempSync(prefix); + addTearDown(() => deleteTestTemp(dir)); + return dir; +} + +/// 시험 폴더 지우기 - Windows 에서 앱 · WebView · 백신이 파일을 잠깐 잡고 있으면 몇 번 다시 해 본다 (기다림은 실제 시간: 가짜 시계 밖). +/// 긴 경로는 \\?\ 로. 끝내 못 지우면 알리기만 하고 시험은 실패시키지 않는다 (tool/test_temp_check.ps1 이 남은 것을 잡는다) +void deleteTestTemp(Directory dir) { + final path = dir.absolute.path; + final target = Platform.isWindows && !path.startsWith(r'\\?\') ? Directory('\\\\?\\$path') : dir; + for (var i = 0; i < 20; i++) { + try { + if (!target.existsSync()) return; + target.deleteSync(recursive: true); + return; + } on FileSystemException { + sleep(const Duration(milliseconds: 250)); + } + } + stderr.writeln('시험 임시 폴더를 지우지 못함: $path'); +} diff --git a/integration_test/tor_dns_real_test.dart b/integration_test/tor_dns_real_test.dart index acadafb..7570d25 100644 --- a/integration_test/tor_dns_real_test.dart +++ b/integration_test/tor_dns_real_test.dart @@ -17,6 +17,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/app_actions.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; import 'package:jj_mkvmaker/ui/tor_page.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 망 (Windows): DNS 강제 (8.8.8.8) 로 앱의 HTTP · 일반 브라우저 (Edge) 가 이름을 푸는지, 닿지 않는 DNS 는 실패로 알리는지, @@ -26,7 +27,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async { - final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_tor_real_').path; + final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_').path; Directory(data).createSync(recursive: true); ComponentStore.dataDirectory = data; ComponentStore.shared = ComponentStore(data); diff --git a/integration_test/translate_split_test.dart b/integration_test/translate_split_test.dart index 0d7da6d..1ca12f2 100644 --- a/integration_test/translate_split_test.dart +++ b/integration_test/translate_split_test.dart @@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/core/models.dart'; import 'package:jj_mkvmaker/core/output_paths.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 NLLB + 실제 Edge(WebView2): 브라우저를 보면서 영어 자막 → 한국어 번역 → MKV 를 @@ -19,7 +20,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('브라우저 + 작업 현황: 영어 자막 → 한국어 번역 → MKV (대기열)', (tester) async { - final dir = Directory.systemTemp.createTempSync('jj_split_'); + final dir = testTempDir('jj_split_'); final video = p.join(dir.path, 'movie.mp4'); final r = await Process.run('ffmpeg', [ '-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine', @@ -42,7 +43,7 @@ void main() { await c.init(); await c.addVideos([video]); }); - c.settings.webViewDataDir = Directory.systemTemp.createTempSync('jj_webview_').path; + c.settings.webViewDataDir = testTempDir('jj_webview_').path; final v = c.videos.single; // 같은 폴더의 movie.en.srt 가 자동으로 잡힘 final s = v.subtitles.firstWhere((x) => x.path != null && p.equals(x.path!, en)); diff --git a/integration_test/video_adjust_e2e_test.dart b/integration_test/video_adjust_e2e_test.dart index ea70673..29219f6 100644 --- a/integration_test/video_adjust_e2e_test.dart +++ b/integration_test/video_adjust_e2e_test.dart @@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/core/output_paths.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:jj_mkvmaker/ui/video_adjust_dialog.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 FFmpeg: 가로 영상 → 화면 · 색 보정 창 미리보기 → 세로 9:16 + 채도 MKV 만들기 @@ -19,7 +20,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async { - final dir = Directory.systemTemp.createTempSync('jj_adj_e2e_'); + final dir = testTempDir('jj_adj_e2e_'); final video = p.join(dir.path, '가로 영상.mp4'); final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe'); final r = await Process.run(ffmpeg, [ diff --git a/integration_test/web_translate_test.dart b/integration_test/web_translate_test.dart index d2cd6f1..7761425 100644 --- a/integration_test/web_translate_test.dart +++ b/integration_test/web_translate_test.dart @@ -10,6 +10,7 @@ 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/browser_page.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 실제 웹뷰 (Windows: Edge WebView2 · Android: 시스템 WebView) + 실제 Google 번역: @@ -50,7 +51,7 @@ void main() { await req.response.close(); }); final url = 'http://127.0.0.1:${server.port}/'; - final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path; + final dataDir = testTempDir('jj_webview_').path; final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings diff --git a/integration_test/whisper_probe_test.dart b/integration_test/whisper_probe_test.dart index 0429a42..7a1bf2b 100644 --- a/integration_test/whisper_probe_test.dart +++ b/integration_test/whisper_probe_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:path/path.dart' as p; import 'package:whisper_ggml/whisper_ggml.dart'; +import 'support/temp_dir.dart'; /// whisper.cpp 동작 확인 (스마트 앱 컨트롤·속도) void main() { @@ -14,7 +15,7 @@ void main() { 'whisper', 'ggml-base.bin'); final assets = p.join(Directory.current.path, 'test_assets'); for (final name in ['speech_en.wav', 'speech_ko.wav']) { - final tmp = Directory.systemTemp.createTempSync('jj_w_'); + final tmp = testTempDir('jj_w_'); final wav = File(p.join(assets, name)).copySync(p.join(tmp.path, name)).path; final sw = Stopwatch()..start(); final r = await tester.runAsync(() => const Whisper(model: WhisperModel.base).transcribe( diff --git a/integration_test/windows_662_shot_test.dart b/integration_test/windows_662_shot_test.dart index 5980132..080f16e 100644 --- a/integration_test/windows_662_shot_test.dart +++ b/integration_test/windows_662_shot_test.dart @@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/ui/app_actions.dart'; import 'package:jj_mkvmaker/ui/browser_page.dart'; import 'package:jj_mkvmaker/ui/home_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 66-2 기기 감독 확인용 (Windows): 지금 화면 아이콘 (넓은 · 좁은 화면) · 처음으로 알림 · 브라우저 안내 한 줄을 PNG (JJ_SHOT_DIR) 로. @@ -83,9 +84,7 @@ void main() { ..components = ['mkv', 'browser', 'explorer', 'rsync'] ..navOrder = ['mkv', 'browser', 'explorer', 'rsync']; final base = Platform.environment['JJ_SHOT_TMP']; - final dir = (base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true))) - .createTempSync('jj_shot_662_'); - addTearDown(() => dir.deleteSync(recursive: true)); + final dir = testTempDir('jj_shot_662_', base: base); final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); t.view.physicalSize = const Size(1280, 800); t.view.devicePixelRatio = 1; diff --git a/integration_test/windows_b3_shot_test.dart b/integration_test/windows_b3_shot_test.dart index f6c9399..562c199 100644 --- a/integration_test/windows_b3_shot_test.dart +++ b/integration_test/windows_b3_shot_test.dart @@ -14,6 +14,7 @@ import 'package:path/path.dart' as p; import '../test/support/fake_recycle_bin.dart'; import '../test/support/recycle_leftovers.dart'; +import 'support/temp_dir.dart'; /// 묶음 3 (데이터 쪽) 기기 감독 확인용 (Windows): 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다. /// 165 (드라이브 · 연결 안 됨) · 48 (같은 이름) · 52 (돌아와도 진행 막대) · 148 (휴지통 되돌리기) · 144 (긴 경로). @@ -23,9 +24,7 @@ void main() { final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_b3'); Directory tempDir(String prefix) { final base = Platform.environment['JJ_SHOT_TMP']; - if (base == null || base.isEmpty) return Directory.systemTemp.createTempSync(prefix); - Directory(base).createSync(recursive: true); - return Directory(base).createTempSync(prefix); + return testTempDir(prefix, base: base); } final key = GlobalKey(); diff --git a/integration_test/windows_b6_input_test.dart b/integration_test/windows_b6_input_test.dart index fda1d56..1b74e68 100644 --- a/integration_test/windows_b6_input_test.dart +++ b/integration_test/windows_b6_input_test.dart @@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 관리자 지시 9 (10/17): 82 끌어 놓기 · 75 마우스 4 · 5 번을 Windows 실제 빌드에서 가짜 포인터로 (실제 마우스 · 키보드는 쓰지 않음). @@ -26,7 +27,7 @@ void main() { setUp(() async { await i18n.apply('ko', save: false); - tmp = Directory.systemTemp.createTempSync('jj_b6input_'); + tmp = testTempDir('jj_b6input_'); left = p.join(tmp.path, 'left'); right = p.join(tmp.path, 'right'); File(p.join(left, 'doc.txt')) diff --git a/integration_test/windows_b6_shot_test.dart b/integration_test/windows_b6_shot_test.dart index bd103a0..56363f3 100644 --- a/integration_test/windows_b6_shot_test.dart +++ b/integration_test/windows_b6_shot_test.dart @@ -21,6 +21,7 @@ import 'package:jj_mkvmaker/ui/explorer_search.dart'; import 'package:jj_mkvmaker/ui/quick_look.dart'; import 'package:jj_mkvmaker/ui/storage_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 관리자 지시 5 (10/17): 기기 없이 15 가 볼 수 있게 묶음6 새 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더). @@ -38,8 +39,7 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = Directory.systemTemp.createTempSync('jj_b6shot_'); - addTearDown(() => tmp.deleteSync(recursive: true)); + final tmp = testTempDir('jj_b6shot_'); // 시험 파일 File(p.join(tmp.path, 'Show', 'ep01.mkv')) ..createSync(recursive: true) diff --git a/integration_test/windows_check_shot_test.dart b/integration_test/windows_check_shot_test.dart index fc82b1a..a16fca4 100644 --- a/integration_test/windows_check_shot_test.dart +++ b/integration_test/windows_check_shot_test.dart @@ -20,6 +20,7 @@ import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; import '../test/support/fake_recycle_bin.dart'; +import 'support/temp_dir.dart'; /// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다. /// 65 · 94 · 98 (휴지통 · 휴지통 없는 위치 · 오류 문구), 37 (경로 입력), 124 · 130 (마스터 · 최상), 123 (AI 그림). @@ -30,9 +31,7 @@ void main() { // 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더) Directory tempDir(String prefix) { final base = Platform.environment['JJ_SHOT_TMP']; - if (base == null || base.isEmpty) return Directory.systemTemp.createTempSync(prefix); - Directory(base).createSync(recursive: true); - return Directory(base).createTempSync(prefix); + return testTempDir(prefix, base: base); } final key = GlobalKey(); const sizes = [('narrow', Size(700, 900)), ('wide', Size(1280, 800))]; diff --git a/integration_test/windows_p3_shot_test.dart b/integration_test/windows_p3_shot_test.dart index 0fc0f72..b9961aa 100644 --- a/integration_test/windows_p3_shot_test.dart +++ b/integration_test/windows_p3_shot_test.dart @@ -8,6 +8,7 @@ import 'package:integration_test/integration_test.dart'; import 'package:jj_mkvmaker/app/i18n_controller.dart'; import 'package:jj_mkvmaker/ui/file_tools.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 관리자 지시 (10/18): X-plore P3 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더) - 해시 창 · 폴더 속성 · 16진 보기. @@ -23,8 +24,7 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = Directory.systemTemp.createTempSync('jj_p3shot_'); - addTearDown(() => tmp.deleteSync(recursive: true)); + final tmp = testTempDir('jj_p3shot_'); final file = File(p.join(tmp.path, '영상 폴더', 'ep01 긴 이름의 동영상 파일.mkv')) ..createSync(recursive: true) ..writeAsBytesSync(List.generate(5000, (i) => i % 256)); diff --git a/integration_test/windows_wifi_shot_test.dart b/integration_test/windows_wifi_shot_test.dart index c0f589a..78aa826 100644 --- a/integration_test/windows_wifi_shot_test.dart +++ b/integration_test/windows_wifi_shot_test.dart @@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:jj_mkvmaker/ui/wifi_share_page.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// 관리자 지시 (10/18): 89 Wi-Fi 공유 화면을 PNG 로 (JJ_SHOT_DIR) - 켜기 전 · 켠 뒤 (코드 · 주소 · QR · 남은 시간 · 접속 기록). @@ -37,8 +38,7 @@ void main() { WifiShare.debugLoopbackOnly = false; WifiShare.debugAddresses = null; }); - final tmp = Directory.systemTemp.createTempSync('jj_wifishot_'); - addTearDown(() => tmp.deleteSync(recursive: true)); + final tmp = testTempDir('jj_wifishot_'); final dir = Directory(p.join(tmp.path, '여행 사진'))..createSync(); final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); final shotKey = GlobalKey(); diff --git a/integration_test/windows_xplore_shot_test.dart b/integration_test/windows_xplore_shot_test.dart index 17f8d19..03b573e 100644 --- a/integration_test/windows_xplore_shot_test.dart +++ b/integration_test/windows_xplore_shot_test.dart @@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/explorer_page.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더). @@ -31,8 +32,7 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = Directory.systemTemp.createTempSync('jj_xpshot_'); - addTearDown(() => tmp.deleteSync(recursive: true)); + final tmp = testTempDir('jj_xpshot_'); final a = Directory(p.join(tmp.path, 'Videos'))..createSync(); final b = Directory(p.join(tmp.path, 'Backup'))..createSync(); for (final n in ['Show S01', 'Movies']) { diff --git a/integration_test/windows_xplore_tools_shot_test.dart b/integration_test/windows_xplore_tools_shot_test.dart index d32f3b7..240b0b2 100644 --- a/integration_test/windows_xplore_tools_shot_test.dart +++ b/integration_test/windows_xplore_tools_shot_test.dart @@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/ui/explorer_grid.dart'; import 'package:jj_mkvmaker/ui/storage_page.dart'; import 'package:jj_mkvmaker/ui/text_viewer.dart'; import 'package:jj_mkvmaker/ui/theme.dart'; +import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; /// X-plore 비교 g · h · i · P3 (관리자 지시 10/18): 격자 보기 · 글 뷰어 · 일괄 이름 (날짜) · 저장 공간 그림을 PNG 로 @@ -31,8 +32,7 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - final tmp = Directory.systemTemp.createTempSync('jj_xptshot_'); - addTearDown(() => tmp.deleteSync(recursive: true)); + final tmp = testTempDir('jj_xptshot_'); // 그림 (색이 다른 작은 그림) · 동영상 이름 · 글 (CP949) final pics = []; for (var i = 0; i < 7; i++) { diff --git a/tool/test_temp_check.ps1 b/tool/test_temp_check.ps1 index adaa7f7..7504eeb 100644 --- a/tool/test_temp_check.ps1 +++ b/tool/test_temp_check.ps1 @@ -5,15 +5,33 @@ # - 다른 worktree · 세션이 같은 시험을 동시에 돌리면 그쪽 폴더가 섞일 수 있다 (이름으로 확인). # - 이미 있던 폴더는 건드리지 않는다 (지우지 않음). # 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\test_temp_check.ps1 [flutter test 인자…] +# 통합 시험 · 캡처도 (관리자 지시 10/19, 28 이 찾음): … test_temp_check.ps1 integration_test\windows_b6_shot_test.dart -d windows +# - 폴더 이름 앞부분은 test 와 integration_test 두 곳의 시험 소스에서 모은다 $ErrorActionPreference = 'Stop' $app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $temp = [System.IO.Path]::GetTempPath() # 시험 소스가 쓰는 임시 폴더 이름 앞부분 (createTemp('jj_x_') · createTempSync('jj_x_')) -$prefixes = @(Get-ChildItem (Join-Path $app 'test') -Recurse -Filter *.dart | - ForEach-Object { [regex]::Matches((Get-Content $_.FullName -Raw -Encoding utf8), 'createTemp(?:Sync)?\(''(jj[^''$]*)''') } | +# 넘겨받은 시험 파일 (.dart) 이 있으면 그 파일들과 support 폴더만 본다 - 다른 세션 · worktree 가 같은 때 돌린 시험의 폴더를 세지 않게 +# (10/19: b6 캡처 앞뒤에 다른 세션의 단위 시험 폴더 jj_explorer_ · jj_sync_ 가 섞여 거짓 실패) +$named = @($args | Where-Object { "$_" -like '*.dart' } | ForEach-Object { Join-Path $app "$_" } | Where-Object { Test-Path $_ }) +$sources = if ($named.Count -gt 0) { + # 그 파일들이 가져다 쓰는 support 파일만 함께 (import 'support/…') - 다른 세션의 시험과 이름이 겹치지 않게 (10/19: jj_fake_recycle_) + $support = @($named | ForEach-Object { + $dir = Split-Path $_ + [regex]::Matches((Get-Content $_ -Raw -Encoding utf8), "import '(support/[^']+\.dart)'") | + ForEach-Object { Join-Path $dir $_.Groups[1].Value } }) + @($named) + @($support | Where-Object { Test-Path $_ }) +} else { + @(@('test', 'integration_test') | ForEach-Object { Join-Path $app $_ } | Where-Object { Test-Path $_ } | + ForEach-Object { Get-ChildItem $_ -Recurse -Filter *.dart | ForEach-Object { $_.FullName } }) +} +# 이름이 하나도 없으면 (임시 폴더를 만들지 않는 시험) 새 jj 폴더는 참고로만 보인다 +# 이름 앞부분: createTemp('jj_x_') · createTempSync('jj_x_') · testTempDir('jj_x_') (integration_test/support/temp_dir.dart) +$prefixes = @($sources | + ForEach-Object { [regex]::Matches((Get-Content $_ -Raw -Encoding utf8), '(?:createTemp(?:Sync)?|testTempDir)\(''(jj[^''$]*)''') } | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) -if ($prefixes.Count -eq 0) { throw '시험 소스에서 임시 폴더 이름을 찾지 못했습니다' } +if ($prefixes.Count -eq 0 -and $named.Count -eq 0) { throw '시험 소스에서 임시 폴더 이름을 찾지 못했습니다' } function Get-TestTemp { @(Get-ChildItem $temp -Directory -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) @@ -34,6 +52,12 @@ $left = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) } | Where-O $n = $_ @($prefixes | Where-Object { $n.StartsWith($_) }).Count -gt 0 }) +# 참고: 시험 소스의 이름이 아닌 새 jj 폴더 (앱 코드가 만든 것일 수 있음 - 실패로 세지 않고 보이기만) +$other = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) -and $left -notcontains $_ }) +if ($other.Count -gt 0) { + Write-Host ("참고 - 시험 소스에 없는 이름의 새 jj 폴더 {0}개:" -f $other.Count) + $other | Sort-Object | ForEach-Object { Write-Host " $_" } +} if ($left.Count -gt 0) { Write-Host ("시험이 남긴 임시 폴더 {0}개 (%TEMP%):" -f $left.Count) $left | Sort-Object | ForEach-Object { Write-Host " $_" } From b10158b46bdb41d3ed85329641b2c6d7bf462e6f Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:51:53 +0900 Subject: [PATCH 5/8] =?UTF-8?q?WIP=20Android:=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20specialUse?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EC=9D=BC=EB=8C=80=EB=A1=9C=20=C2=B7=20=EB=A7=9D=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=EB=A5=BC=20ConnectivityManager=20=EB=A1=9C?= =?UTF-8?q?=20=ED=8C=90=EB=8B=A8=20-=20=EA=B6=8C=ED=95=9C=201=EA=B0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=8C=90?= =?UTF-8?q?=EB=8B=A8)=20(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28 의 APK 점검 · 관리자 지시 (10/19): 1. KeepAliveService 의 PROPERTY_SPECIAL_USE_FGS_SUBTYPE 설명: "동기화 · 변환 · 다운로드" 만 적혀 있던 것을 실제로 하는 일 모두로 (실시간 폴더 동기화 · MKV 변환 · AI 자막 · 다운로드 · 복사/이동 · 사용자의 SFTP/SMB/FTP/WebDAV 서버 전송 · 읽기 전용 Wi-Fi 공유, 알림에 무엇이 도는지 보임). 2. 망 종류 판단 (관리자 판단): 인터페이스 이름 (rmnet · ccmni …) 대신 Android 의 ConnectivityManager · NetworkCapabilities 로 주소마다 wifi · ethernet · cellular · vpn 을 알아 (MainActivity "netKinds"), Wi-Fi 공유 주소 · SMB 찾기는 Wi-Fi · 유선만 쓴다. 모바일 데이터 · VPN 은 뺀다. Tailscale (100.64/10) 은 VPN 이어도 설정 "Tailscale 에서도 열기" 대로 (통신사 CGNAT = cellular 는 뺀다). 판단하지 못하면 (Android 가 아님 · 오류 · 종류를 모르는 주소 - 예: 휴대폰 핫스팟) 예전의 이름 방식. **권한 1개 추가 (관리자 판단): ACCESS_NETWORK_STATE** - 일반 권한 (사용자에게 묻지 않음), 망의 종류만 본다. 릴리스 노트 초안 (위키 릴리스\다음판_노트_초안 · release\RELEASE_NOTES_draft_next.md) 에도 적음. usesCleartextTraffic 은 그대로 (관리자: Android 는 IP 범위로 줄일 수 없고 http NAS 사용자가 있음). 시험: net_security_test (새 1: 종류가 이름보다 먼저 · 모르는 주소는 이름 · Tailscale 설정 · CGNAT) · lan_scan · smb_find · mdns_smb 13 통과, analyze 문제 없음, APK 빌드 (Kotlin 컴파일) 성공. 실제 기기 (Wi-Fi + 모바일 데이터 동시 · VPN · Tailscale) 확인은 테스터 몫. Co-Authored-By: Claude Opus 5.5 --- android/app/src/main/AndroidManifest.xml | 6 ++++- .../kotlin/com/jj/jj_mkvmaker/MainActivity.kt | 27 +++++++++++++++++++ lib/core/lan_scan.dart | 26 +++++++++++++++++- lib/core/wifi_share.dart | 13 +++++---- test/net_security_test.dart | 23 ++++++++++++++++ 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 92b6167..418e8d8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + @@ -135,13 +137,15 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/jj_file_paths"/> + + android:value="Keeps user-started work running while the app is in the background (user setting: run in background): real-time folder sync, video to MKV conversion and AI subtitles, downloads, file copy/move, transfers to the user's own SFTP/SMB/FTP/WebDAV servers, and the read-only Wi-Fi file sharing server. A notification shows what is running."/> diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index 1f7b66c..64dd537 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -170,6 +170,8 @@ class MainActivity : FlutterActivity() { startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) result.success(null) } + // 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다) + "netKinds" -> result.success(netKinds()) "diskSpace" -> result.success( try { val st = android.os.StatFs(call.argument("path") ?: "") @@ -619,4 +621,29 @@ class MainActivity : FlutterActivity() { ) } } + + /// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로) + @Suppress("DEPRECATION") + private fun netKinds(): Map? = try { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager + val out = HashMap() + for (n in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(n) ?: continue + val lp = cm.getLinkProperties(n) ?: continue + val kind = when { + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "other" + } + for (la in lp.linkAddresses) { + val a = la.address + if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind } + } + } + out + } catch (e: Exception) { + null + } } diff --git a/lib/core/lan_scan.dart b/lib/core/lan_scan.dart index 5454797..3fd1fe5 100644 --- a/lib/core/lan_scan.dart +++ b/lib/core/lan_scan.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/services.dart'; + /// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다. /// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께. /// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다. @@ -24,6 +26,27 @@ final _mobileIf = RegExp(r'^(v4-)?(rmnet|ccmni|pdp|seth|wwan|usb_rmnet|rev_rmnet final _virtualIf = RegExp(r'vethernet|hyper-v|virtualbox|vbox|vmware|vmnet|wsl|docker|^br-|^veth|virbr|^tun|^tap|^ppp|^ipsec|wireguard|^wg|zerotier|openvpn|tailscale'); +/// 28 APK 점검 · 관리자 판단 (10/19): Android 의 망 종류 (ConnectivityManager · NetworkCapabilities) - 주소 → "wifi" · "ethernet" · +/// "cellular" · "vpn" · "other". 판단하지 못하면 (Android 가 아님 · 오류) null → 연결 이름으로 (예전 방식). 시험에서 바꾼다 +Future?> Function() netKindsOf = _androidNetKinds; + +Future?> _androidNetKinds() async { + if (!Platform.isAndroid) return null; + try { + final m = await const MethodChannel('jj_mkvmaker/android').invokeMethod>('netKinds'); + return m?.map((k, v) => MapEntry('$k', '$v')); + } catch (_) { + return null; + } +} + +/// 집 안 망 (Wi-Fi · 유선) 의 주소인지: Android 가 알려 준 망 종류가 있으면 그것으로, 없으면 연결 이름으로 +bool isLanAddress(String ip, String interfaceName, Map? kinds) { + final k = kinds?[ip]; + if (k != null) return k == 'wifi' || k == 'ethernet'; + return isLanInterfaceName(interfaceName); +} + /// 사설망 IPv4 (10/8 · 172.16/12 · 192.168/16) 인지 bool isPrivateLanAddress(String ip) { final v = ip.split('.').map(int.tryParse).toList(); @@ -34,9 +57,10 @@ bool isPrivateLanAddress(String ip) { /// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선만 - Tailscale 100.64/10 은 넓어 훑지 않고, 모바일 데이터 · 가상 어댑터 · VPN 도 뺀다) Future> localPrivateIPv4() async { final out = []; + final kinds = await netKindsOf(); for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) { - if (!isLanInterfaceName(ni.name)) continue; for (final a in ni.addresses) { + if (!isLanAddress(a.address, ni.name, kinds)) continue; 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); diff --git a/lib/core/wifi_share.dart b/lib/core/wifi_share.dart index 1de015c..73cbc4d 100644 --- a/lib/core/wifi_share.dart +++ b/lib/core/wifi_share.dart @@ -8,7 +8,7 @@ import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'file_ops.dart' show formatSize, isAppTempName, isPartialFile; -import 'lan_scan.dart' show isLanInterfaceName, isMobileInterfaceName; +import 'lan_scan.dart' show isLanAddress, isMobileInterfaceName, netKindsOf; /// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). /// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17): @@ -160,10 +160,11 @@ class WifiShare extends ChangeNotifier { static Future> localAddresses({bool tailscale = true}) async { try { final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false); + final kinds = await netKindsOf(); final all = [ for (final i in list) for (final a in i.addresses) - if (shareInterface(i.name, a.address, tailscale: tailscale)) a.address + if (shareInterface(i.name, a.address, tailscale: tailscale, kinds: kinds)) a.address ]; int rank(String a) => a.startsWith('192.168.') ? 0 : a.startsWith('10.') ? 1 : a.startsWith('172.') ? 2 : 3; return (all.where(isLocalNetwork).toSet().toList()..sort((a, b) => rank(a).compareTo(rank(b)))); @@ -174,12 +175,14 @@ class WifiShare extends ChangeNotifier { /// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0) /// [tailscale] 이 거짓이면 Tailscale 주소는 열지 않는다 (설정 "Tailscale 에서도 열기") - static bool shareInterface(String name, String ip, {bool tailscale = true}) { + /// [kinds]: Android 가 알려 준 주소별 망 종류 (있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 뺀다) + static bool shareInterface(String name, String ip, {bool tailscale = true, Map? kinds}) { final v = ip.split('.'); final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0; - if (a == 100 && b >= 64 && b <= 127) return tailscale && !isMobileInterfaceName(name); + // Tailscale 은 VPN 이지만 설정대로 (통신사 CGNAT 100.x 는 모바일 데이터라 뺀다) + if (a == 100 && b >= 64 && b <= 127) return tailscale && kinds?[ip] != 'cellular' && !isMobileInterfaceName(name); if (a == 169 && b == 254) return false; // 주소를 못 받은 연결 - return isLanInterfaceName(name); + return isLanAddress(ip, name, kinds); } /// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지 diff --git a/test/net_security_test.dart b/test/net_security_test.dart index 241990e..daf62c0 100644 --- a/test/net_security_test.dart +++ b/test/net_security_test.dart @@ -95,4 +95,27 @@ void main() { } } }); + test('관리자 판단 (10/19): Android 가 알려 준 망 종류가 있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 빼고 Tailscale 은 설정대로', () { + const kinds = { + '192.168.0.5': 'wifi', + '10.1.2.3': 'ethernet', + '10.20.30.40': 'cellular', + '10.8.0.2': 'vpn', + '100.80.1.2': 'vpn', + '100.70.1.2': 'cellular', + }; + // 이름이 Wi-Fi 같아도 종류가 모바일 · VPN 이면 뺀다 (이름이 낯선 기기 - 예: 제조사마다 다른 이름) + expect(isLanAddress('192.168.0.5', 'oddname0', kinds), isTrue); + expect(isLanAddress('10.1.2.3', 'eth9', kinds), isTrue); + expect(isLanAddress('10.20.30.40', 'wlan0', kinds), isFalse); + expect(isLanAddress('10.8.0.2', 'wlan1', kinds), isFalse); + // 종류를 모르는 주소 (예: 휴대폰 핫스팟 · Android 가 아님) 는 이름으로 + expect(isLanAddress('192.168.43.1', 'wlan0', kinds), isTrue); + expect(isLanAddress('192.168.43.1', 'rmnet0', null), isFalse); + expect(WifiShare.shareInterface('oddname0', '192.168.0.5', kinds: kinds), isTrue); + expect(WifiShare.shareInterface('wlan0', '10.20.30.40', kinds: kinds), isFalse); + expect(WifiShare.shareInterface('tun0', '100.80.1.2', kinds: kinds), isTrue, reason: 'Tailscale (VPN) 은 설정대로'); + expect(WifiShare.shareInterface('tun0', '100.80.1.2', kinds: kinds, tailscale: false), isFalse); + expect(WifiShare.shareInterface('oddname1', '100.70.1.2', kinds: kinds), isFalse, reason: '통신사 CGNAT'); + }); } From 27baf918764eb6c92edc4a30254ca984404b4382 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:54:05 +0900 Subject: [PATCH 6/8] =?UTF-8?q?WIP=20=EA=B8=80=20=EB=B7=B0=EC=96=B4=20(636?= =?UTF-8?q?6a2f=20=EC=9D=98=20=ED=9A=8C=EA=B7=80=20=EA=B3=A0=EC=B9=A8):=20?= =?UTF-8?q?=EA=B8=B4=20=EC=A4=84=EC=9D=84=20=EB=82=98=EB=88=A0=20=EA=B7=B8?= =?UTF-8?q?=EB=A0=A4=EB=8F=84=20Ctrl+A=20=E2=86=92=20Ctrl+C=20=EB=8A=94=20?= =?UTF-8?q?=EC=A4=84=20=EC=A0=84=EC=B2=B4=20=C2=B7=20=EB=82=98=EB=88=84?= =?UTF-8?q?=EB=8A=94=20=ED=8F=AD=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=ED=8F=AD=EC=97=90=20=C2=B7=20=EB=A7=89=EB=8C=80?= =?UTF-8?q?=EC=97=90=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20(=EA=B2=80?= =?UTF-8?q?=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 28 지적 · 관리자 지시 (10/19): ① 회귀: 줄바꿈 없는 긴 줄을 Ctrl+A → Ctrl+C 하면 화면에 그려진 조각만 복사됐다 (a74e7be 11,091자 → 6366a2f 1,513자). 나눈 조각이 ListView.builder 의 줄이라 화면 밖 조각은 고르기에 들지 않았다. → Ctrl+A 를 누른 뒤 (다른 키 · 누르기 전까지) 의 Ctrl+C 는 읽은 글 전체를 복사한다 (화면의 강조는 앱 단축키 그대로). 나누는 것은 화면만. 위 막대 [전체 복사] 는 원래부터 글 전체. ② (예전부터) Windows 에서 Scrollbar 에 컨트롤러가 없어 "ScrollPosition 없음" assert (데스크톱 ListView 는 PrimaryScrollController 를 쓰지 않음) → 같은 ScrollController 를 Scrollbar · ListView 에. ③ 보기: 4줄마다 줄 끝이 비어 보이던 것 - 글자 폭을 어림 (글자 크기 × 0.62) 대신 TextPainter 로 고정폭 글자 폭을 재서 (글자 크기 · 기기 글자 배율) 나눈다. (한글 · 한자처럼 두 칸 글자가 섞인 줄은 여전히 조금 빌 수 있음) 시험: xplore_tools_test (새 1: 11,090자 한 줄 + "\nend" 를 412 에서 열고 Ctrl+A · Ctrl+C → 11,094자 그대로 · 막대 컨트롤러 붙음 - 고치기 전 코드에서는 1,513자로 실패) 5 통과, explorer_test X-plore g · h 통과, 임시 폴더 흔적 0, analyze 문제 없음. Co-Authored-By: Claude Opus 5.5 --- lib/ui/text_viewer.dart | 60 +++++++++++++++++++++++++++++++++++-- test/xplore_tools_test.dart | 35 ++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/lib/ui/text_viewer.dart b/lib/ui/text_viewer.dart index 444709c..108c6ad 100644 --- a/lib/ui/text_viewer.dart +++ b/lib/ui/text_viewer.dart @@ -84,6 +84,52 @@ class _TextViewerPageState extends State { _text = t; } + /// 28 지적 (10/19): Scrollbar 에 컨트롤러를 붙인다 (데스크톱에서 "ScrollPosition 없음" assert · 막대가 안 그려짐) + final _scroll = ScrollController(); + + /// 28 지적 (10/19, 6366a2f 의 회귀): 긴 줄을 나눠 그리면 화면 밖 조각은 고르기에 들지 않아 Ctrl+A → Ctrl+C 가 보이는 조각만 복사했다. + /// Ctrl+A 를 누른 뒤 (다른 키 · 누르기 전까지) 의 Ctrl+C 는 읽은 글 전체를 복사한다 - 고르기 · 복사는 원래 줄 기준 + bool _allSelected = false; + + KeyEventResult _onKey(FocusNode _, KeyEvent e) { + if (e is! KeyDownEvent) return KeyEventResult.ignored; + final k = e.logicalKey; + if (k == LogicalKeyboardKey.controlLeft || k == LogicalKeyboardKey.controlRight || k == LogicalKeyboardKey.metaLeft || + k == LogicalKeyboardKey.metaRight || k == LogicalKeyboardKey.shiftLeft || k == LogicalKeyboardKey.shiftRight) { + return KeyEventResult.ignored; + } + final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed; + if (ctrl && k == LogicalKeyboardKey.keyA) { + _allSelected = true; // 화면의 고르기 (강조) 는 그대로 앱의 단축키가 한다 + return KeyEventResult.ignored; + } + if (ctrl && k == LogicalKeyboardKey.keyC && _allSelected) { + Clipboard.setData(ClipboardData(text: _text)); + return KeyEventResult.handled; + } + _allSelected = false; + return KeyEventResult.ignored; + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + /// 고정폭 글자 하나의 실제 폭 (글자 크기 · 기기 글자 배율) - 나누는 폭을 실제 줄바꿈 폭에 맞추려고 (28 지적: 4줄마다 줄 끝이 비었다) + double _charWidth(BuildContext context) { + final tp = TextPainter( + text: TextSpan(text: 'M' * 40, style: DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'))), + textDirection: TextDirection.ltr, + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + final w = tp.width / 40; + tp.dispose(); + return w > 0 ? w : _font * 0.62; + } + /// 그릴 줄 (줄 번호 · 글) - 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다 List<(int, String)> _rows = const []; String? _rowsText; @@ -162,14 +208,22 @@ class _TextViewerPageState extends State { : Column(children: [ Expanded( // 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다) - child: SelectionArea( + child: Focus( + onKeyEvent: _onKey, + canRequestFocus: false, + skipTraversal: true, + child: Listener( + onPointerDown: (_) => _allSelected = false, + child: SelectionArea( child: LayoutBuilder(builder: (context, box) { // 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 - // 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다 final room = box.maxWidth - 16 - (_lineNumbers ? 56 : 0); - final rows = _rowsFor((room / (_font * 0.62)).floor().clamp(20, 4000)); + final rows = _rowsFor((room / _charWidth(context)).floor().clamp(20, 4000)); return Scrollbar( + controller: _scroll, child: ListView.builder( + controller: _scroll, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: rows.length, itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -187,6 +241,8 @@ class _TextViewerPageState extends State { ); }), ), + ), + ), ), if (!_complete) SafeArea( diff --git a/test/xplore_tools_test.dart b/test/xplore_tools_test.dart index 8cccfca..dcf15e4 100644 --- a/test/xplore_tools_test.dart +++ b/test/xplore_tools_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; import 'package:jj_mkvmaker/core/text_codec.dart'; @@ -70,6 +71,40 @@ void main() { expect(find.text('1'), findsOneWidget, reason: '줄 번호는 첫 조각에만'); }); + testWidgets('h 글 뷰어 (28 지적 · 6366a2f 회귀): 긴 줄을 나눠 그려도 Ctrl+A → Ctrl+C 는 줄 전체 · 막대 컨트롤러', (t) async { + final line = List.generate(1109, (i) => 'abcdefghi${i % 10}').join(); + final f = File(p.join(tmp.path, 'long.txt'))..writeAsStringSync('$line\nend'); + t.view.physicalSize = const Size(412, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + String? copied; + t.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String?; + return null; + }); + addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path))); + for (var i = 0; i < 40 && find.textContaining('abcdefghi0').evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(); + } + expect(t.widgetList(find.textContaining('abcdefghi')).length, greaterThan(1), reason: '화면에는 나눠 그림'); + await t.tapAt(const Offset(150, 140)); + await t.pump(); + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyEvent(LogicalKeyboardKey.keyA); + await t.sendKeyEvent(LogicalKeyboardKey.keyC); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await t.pump(); + expect(copied, isNotNull); + expect(copied!.length, line.length + 4, reason: '줄 전체 + "\nend" (보이는 조각만이 아님)'); + expect(copied, '$line\nend'); + // 막대에 컨트롤러가 붙어 있다 (데스크톱에서 ScrollPosition 없음 assert 가 나지 않게) + final bar = t.widget(find.byType(Scrollbar)); + expect(bar.controller, isNotNull); + expect(bar.controller!.hasClients, isTrue); + }); + testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async { final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]); final opened = []; From 47165371eb79282946a75d0bb1421ffef810510b Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:05:43 +0900 Subject: [PATCH 7/8] =?UTF-8?q?WIP=20=EA=B8=80=20=EB=B7=B0=EC=96=B4:=20?= =?UTF-8?q?=EA=B8=B4=20=EC=A4=84=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20=EA=B8=80?= =?UTF-8?q?=EA=BC=B4=EB=A1=9C=20=EC=A4=84=EC=9D=84=20=EB=B0=94=EA=BF=94=20?= =?UTF-8?q?=EB=B3=B8=20=EC=9E=90=EB=A6=AC=20(=EB=84=A4=20=EC=A4=84=20?= =?UTF-8?q?=EB=81=9D)=20=EC=97=90=EC=84=9C=20=EB=82=98=EB=88=94=20-=20?= =?UTF-8?q?=EB=84=93=EC=9D=80=20=EA=B8=80=EC=9E=90=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=8F=AD=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=EA=B8=80?= =?UTF-8?q?=EA=BC=B4=EC=97=90=EC=84=9C=EB=8F=84=20=EC=A4=84=20=EB=81=9D?= =?UTF-8?q?=EC=9D=B4=20=EB=B9=84=EC=A7=80=20=EC=95=8A=EA=B2=8C=20(?= =?UTF-8?q?=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/19, 27baf91 의 ③ 남은 것): 한글 · 한자처럼 넓은 글자가 섞인 줄도 줄 끝이 비지 않게. Windows 캡처로 보니 'monospace' 가 실제로 고정폭이 아니라, 글자 수로 세면 (가장 넓은 'M' 기준) 영문 줄도 넷째 줄 끝이 비었다. - 화면에서는 TextPainter 에 Text 와 같은 글꼴 · 글자 크기 · 기기 배율 · 폭으로 조각을 그려 보고, 다섯째 줄이 시작하는 자리 (= 네 줄의 끝) 에서 자른다. (조각 창은 화면 한 줄 글자 수 × 8 부터, 다섯 줄이 안 되면 두 배로). 한 번 나눈 결과는 글 · 폭 · 글자 크기가 같으면 다시 쓰지 않는다. - viewerRows 는 cut (줄 끝 자리) 이 없으면 글자 폭 단위로: 넓은 글자 (isWideChar - 한글 자모 · 음절, 한자 · 가나 · 한중일 기호, 전각) 와 이모지는 잰 비율 (기본 2) 칸으로 센다. 두 단위 글자는 반으로 자르지 않는다. - 새 캡처 시험 integration_test/windows_textview_shot_test.dart (영문 · "한x" 섞임 · 한글만, 412 / 960). 확인: Windows 캡처 (release\test\shots_textview2 - 고치기 전 shots_textview 에서는 영문 · 섞인 줄의 넷째 줄 끝이 비었음) 6 통과, xplore_tools_test (새 2: 넓은 글자 칸 세기 · 한글 섞인 긴 줄의 조각이 화면 네 줄) 7 통과, explorer X-plore g · h 통과, 임시 폴더 흔적 0, analyze 문제 없음. Co-Authored-By: Claude Opus 5.5 --- .../windows_textview_shot_test.dart | 51 ++++++++++ lib/ui/text_viewer.dart | 98 +++++++++++++++---- test/xplore_tools_test.dart | 38 +++++++ 3 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 integration_test/windows_textview_shot_test.dart diff --git a/integration_test/windows_textview_shot_test.dart b/integration_test/windows_textview_shot_test.dart new file mode 100644 index 0000000..d1f7638 --- /dev/null +++ b/integration_test/windows_textview_shot_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/ui/text_viewer.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dir.dart'; + +/// 관리자 지시 (10/19): 글 뷰어가 줄바꿈 없는 긴 줄 (영문 · 한글 섞임) 을 나눠 그릴 때 실제 Windows 글꼴에서 줄 끝이 비지 않는지 PNG 로. +/// 크기 412 · 960. 실행: flutter test integration_test\windows_textview_shot_test.dart -d windows +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_textview'); + for (final (w, h) in [(412, 900), (960, 600)]) { + for (final (name, line) in [ + ('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()), + ('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()), + ('korean', '가나다라마바사아자차카타파하' * 200), + ]) { + testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async { + t.view.physicalSize = Size(w.toDouble(), h.toDouble()); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + final dir = testTempDir('jj_textview_'); + final f = File(p.join(dir.path, '$name.txt'))..writeAsStringSync('$line\nend'); + final key = GlobalKey(); + await t.pumpWidget(RepaintBoundary( + key: key, + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: TextViewerPage(path: f.path)), + )); + for (var i = 0; i < 40 && find.text('end').evaluate().isEmpty && find.textContaining(line.substring(0, 6)).evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 60))); + await t.pump(); + } + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); + await t.pump(); + final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final img = await t.runAsync(() => b.toImage()); + final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); + Directory(out).createSync(recursive: true); + File(p.join(out, 'textview_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); + await t.pumpWidget(const SizedBox()); + }); + } + } +} diff --git a/lib/ui/text_viewer.dart b/lib/ui/text_viewer.dart index 108c6ad..c3c6d98 100644 --- a/lib/ui/text_viewer.dart +++ b/lib/ui/text_viewer.dart @@ -118,28 +118,61 @@ class _TextViewerPageState extends State { } /// 고정폭 글자 하나의 실제 폭 (글자 크기 · 기기 글자 배율) - 나누는 폭을 실제 줄바꿈 폭에 맞추려고 (28 지적: 4줄마다 줄 끝이 비었다) - double _charWidth(BuildContext context) { + double _charWidth(BuildContext context) => _measure(context, 'M') ?? _font * 0.62; + + /// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게) + double _wideRatio(BuildContext context) { + final m = _measure(context, 'M'), w = _measure(context, '가'); + return m == null || w == null ? 2 : w / m; + } + + double? _measure(BuildContext context, String ch) { final tp = TextPainter( - text: TextSpan(text: 'M' * 40, style: DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'))), + text: TextSpan(text: ch * 40, style: DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'))), textDirection: TextDirection.ltr, textScaler: MediaQuery.textScalerOf(context), maxLines: 1, )..layout(); final w = tp.width / 40; tp.dispose(); - return w > 0 ? w : _font * 0.62; + return w > 0 ? w : null; } - /// 그릴 줄 (줄 번호 · 글) - 글 · 한 줄 글자 수가 같으면 다시 만들지 않는다 + /// 그릴 줄 (줄 번호 · 글) - 글 · 폭 · 글자 크기가 같으면 다시 만들지 않는다 List<(int, String)> _rows = const []; String? _rowsText; - int _rowsPer = 0; + String _rowsKey = ''; - List<(int, String)> _rowsFor(int perRow) { - if (!identical(_rowsText, _text) || _rowsPer != perRow) { - _rowsText = _text; - _rowsPer = perRow; - _rows = viewerRows(_text, perRow); + /// [room] 폭에 [style] 로 그릴 때의 줄. 아주 긴 줄은 화면에서 실제로 네 줄이 끝나는 자리 (TextPainter 로 그 글꼴 그대로 줄을 바꿔 봄) 에서 자른다 - + /// 관리자 지시 (10/19): Windows 의 'monospace' 는 고정폭이 아니라 글자 수로 세면 넷째 줄 끝이 비었다 (영문 · 한글 섞인 줄 모두) + List<(int, String)> _rowsFor(BuildContext context, double room) { + final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace')); + final scaler = MediaQuery.textScalerOf(context); + final perRow = (room / _charWidth(context)).floor().clamp(20, 4000); + final key = '$room|$_font|${scaler.scale(100)}|$perRow'; + if (identical(_rowsText, _text) && _rowsKey == key) return _rows; + _rowsText = _text; + _rowsKey = key; + final tp = TextPainter(textDirection: Directionality.of(context), textScaler: scaler); + try { + _rows = viewerRows(_text, perRow, wide: _wideRatio(context), cut: (line, from) { + var size = perRow * 8; + while (true) { + final end = (from + size).clamp(0, line.length); + tp.text = TextSpan(text: line.substring(from, end), style: style); + tp.layout(maxWidth: room); + final m = tp.computeLineMetrics(); + if (m.length > 4) { + // 다섯째 줄의 시작 = 네 줄의 끝 + final at = tp.getPositionForOffset(Offset(0, m[4].baseline - m[4].ascent / 2)).offset; + if (at > 0) return from + at; + } + if (end >= line.length) return line.length; + size *= 2; + } + }); + } finally { + tp.dispose(); } return _rows; } @@ -219,7 +252,7 @@ class _TextViewerPageState extends State { // 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 - // 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다 final room = box.maxWidth - 16 - (_lineNumbers ? 56 : 0); - final rows = _rowsFor((room / _charWidth(context)).floor().clamp(20, 4000)); + final rows = _rowsFor(context, room); return Scrollbar( controller: _scroll, child: ListView.builder( @@ -263,12 +296,14 @@ class _TextViewerPageState extends State { } } -/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 [perRow] 자가 들 때, -/// 그보다 8배 넘게 긴 줄은 4배 (화면 네 줄쯤) 씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈) -List<(int, String)> viewerRows(String text, int perRow) { +/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 고정폭 글자 [perRow] 개가 들 때, +/// 그보다 8배 넘게 긴 줄은 화면 네 줄 분량씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈). +/// 넓은 글자 (한글 · 한자 · 가나 · 전각 · 이모지) 는 고정폭 글자 [wide] 개 폭으로 세어 (관리자 지시 10/19) 넓은 글자가 섞여도 네 줄이 꽉 차게. +/// [cut] 이 있으면 (화면) 조각의 끝을 그것이 정한다 - 글꼴로 실제 줄을 바꿔 본 자리 (글자 수 어림보다 정확) +List<(int, String)> viewerRows(String text, int perRow, {double wide = 2, int Function(String line, int from)? cut}) { final out = <(int, String)>[]; final lines = text.split('\n'); - final piece = perRow * 4; + final budget = perRow * 4.0; for (var i = 0; i < lines.length; i++) { final line = lines[i]; if (line.length <= perRow * 8) { @@ -277,12 +312,39 @@ List<(int, String)> viewerRows(String text, int perRow) { } var at = 0; while (at < line.length) { - var end = (at + piece).clamp(0, line.length); - // 두 단위 글자 (이모지 등) 를 반으로 자르지 않게 - if (end < line.length && end > at + 1 && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end--; + final byLayout = cut?.call(line, at); + if (byLayout != null && byLayout > at) { + var end = byLayout.clamp(at + 1, line.length); + // 두 단위 글자 (이모지 등) 를 반으로 자르지 않게 + if (end < line.length && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end++; + out.add((at == 0 ? i + 1 : 0, line.substring(at, end))); + at = end; + continue; + } + var end = at; + var used = 0.0; + while (end < line.length) { + final c = line.codeUnitAt(end); + // 두 단위 글자 (이모지 등) 는 한 덩어리로 - 반으로 자르지 않게 + final pair = (c & 0xFC00) == 0xD800 && end + 1 < line.length && (line.codeUnitAt(end + 1) & 0xFC00) == 0xDC00; + final w = pair || isWideChar(c) ? wide : 1.0; + if (used + w > budget + 1e-6 && end > at) break; + used += w; + end += pair ? 2 : 1; + } out.add((at == 0 ? i + 1 : 0, line.substring(at, end))); at = end; } } return out; } + +/// 고정폭 글꼴에서 두 칸을 쓰는 글자인지 (한글 자모 · 음절, 한자 · 가나 · 한중일 기호, 전각) +bool isWideChar(int c) => + (c >= 0x1100 && c <= 0x115F) || + (c >= 0x2E80 && c <= 0xA4CF) || + (c >= 0xAC00 && c <= 0xD7A3) || + (c >= 0xF900 && c <= 0xFAFF) || + (c >= 0xFE30 && c <= 0xFE4F) || + (c >= 0xFF00 && c <= 0xFF60) || + (c >= 0xFFE0 && c <= 0xFFE6); diff --git a/test/xplore_tools_test.dart b/test/xplore_tools_test.dart index dcf15e4..ee0ee57 100644 --- a/test/xplore_tools_test.dart +++ b/test/xplore_tools_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/file_ops.dart'; @@ -105,6 +106,43 @@ void main() { expect(bar.controller!.hasClients, isTrue); }); + test('h 글 뷰어 (관리자 지시 10/19): 넓은 글자 (한글 · 한자) 는 두 칸으로 세어 네 줄 분량이 꽉 차게 나눈다', () { + final line = List.generate(600, (i) => i.isEven ? '가' : 'a').join(); + final rows = viewerRows(line, 10, wide: 2); + expect(rows.map((r) => r.$2).join(), line); + double units(String s) => s.runes.fold(0.0, (a, c) => a + (isWideChar(c) ? 2 : 1)); + for (final r in rows.take(rows.length - 1)) { + expect(units(r.$2), inInclusiveRange(39, 40), reason: '네 줄 (40칸) 에서 한 칸 넘게 비지 않음: ${r.$2}'); + } + expect(isWideChar('漢'.codeUnitAt(0)), isTrue); + expect(isWideChar('ア'.codeUnitAt(0)), isTrue); + expect(isWideChar('A'.codeUnitAt(0)), isTrue); + expect(isWideChar('a'.codeUnitAt(0)), isFalse); + // 비율이 2 가 아닌 글꼴 (잰 값) 도 그대로 + final r15 = viewerRows('가' * 400, 10, wide: 1.5); + expect(r15.first.$2.length, 26, reason: '40 / 1.5 = 26.6 → 26자'); + }); + + testWidgets('h 글 뷰어: 한글이 섞인 긴 줄도 나눈 조각마다 화면 네 줄 (다섯째 반쪽 줄 없음)', (t) async { + // 한 글자씩 바뀌어 어디서든 줄을 바꿀 수 있는 글 (영문 낱말이 이어지면 낱말 단위로 줄이 바뀌어 보통 글처럼 줄 끝이 조금 빈다) + final line = List.generate(3000, (i) => i.isEven ? '한' : 'x').join(); + final f = File(p.join(tmp.path, 'ko.txt'))..writeAsStringSync(line); + t.view.physicalSize = const Size(412, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path))); + for (var i = 0; i < 40 && find.textContaining('한x한x').evaluate().isEmpty; i++) { + await t.runAsync(() => Future.delayed(const Duration(milliseconds: 30))); + await t.pump(); + } + final pieces = find.textContaining('한x한x'); + expect(pieces.evaluate().length, greaterThan(1)); + final first = t.renderObject(find.descendant(of: pieces.first, matching: find.byType(RichText))); + final txt = t.widget(pieces.first).data!; + final lines = {for (final bx in first.getBoxesForSelection(TextSelection(baseOffset: 0, extentOffset: txt.length))) bx.top}.length; + expect(lines, 4, reason: '네 줄 분량으로 나눔 (다섯째 반쪽 줄 없음)'); + }); + testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async { final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]); final opened = []; From 62784039e4601d8bbf479e0bdc0e0045b447d310 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:15:22 +0900 Subject: [PATCH 8/8] =?UTF-8?q?WIP=20P0=20=EC=9B=90=EA=B2=A9=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=94=EA=B8=B0:=20--delete=20=EA=B0=80=20=EC=97=86=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EB=B0=9B=EB=8A=94=20=EC=AA=BD=EC=9D=98=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=9D=B4=EB=A6=84=20=ED=8F=B4=EB=8D=94=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A=EC=9D=8C=20(=EC=98=88?= =?UTF-8?q?=EC=A0=84:=20=ED=86=B5=EC=A7=B8=EB=A1=9C=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B3=A0=20=ED=8C=8C=EC=9D=BC=EB=A1=9C)=20=C2=B7=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EA=B8=B0=EC=97=90=20=EB=B3=B4=EC=9E=84=20?= =?UTF-8?q?(=EA=B2=80=ED=86=A0=20=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/19, 15 의 원격 동기화 검토 M1 - P0): WebDAV · SFTP · SMB · FTP 가 끼면 앱이 직접 맞추는데 (file_ops._mirrorV), 원본은 파일 X, 받는 쪽에는 같은 이름의 **폴더** X 가 있으면 --delete 가 없어도 그 폴더를 통째로 지우고 파일로 바꿨다. 미리 보기에는 "바뀜" 한 줄로만 보여 사용자가 모르게 지워졌다 (10/8 WebDAV 부터 정식 판에 있던 것으로 봄). 실제 rsync 는 --delete (또는 --force) 없이는 비어 있지 않은 폴더를 지우지 않는다. - _mirrorV: --delete 가 없으면 그 이름은 건너뛰고 FileOps.dirConflicts 에 남긴다. --delete 면 지금처럼 지우고 파일로. - 미리 보기 (sync_preview): 지우기 없음 → "받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로" (주황, 새 PreviewAction.skipDir), 지우기 → 지울 목록에 "폴더 · 안의 n개를 지우고 같은 이름의 파일로" (빨강, 지울 수에 들어가 72 의 보호를 받음). - 알림: 복사 · 이동 작업 끝 "같은 이름의 폴더가 있어 건너뜀 n개: …", 실시간 동기화 (lsync, 원격 쌍) 카드 "n개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 n개" (같은 규칙). - 로컬끼리 맞추기는 원래 폴더를 지우지 않았다 (파일 이름 바꾸기가 실패) - 그대로. 시험: webdav_test · sftp_test (새 각 1, 시험 WebDAV 서버 · paramiko SFTP 서버): 지우기 없음 → 미리 보기 skipDir · 지울 것 없음 · 실제로 폴더와 안의 파일 그대로 · 다른 파일은 맞춤 · dirConflicts 1 / 지우기 → 미리 보기 지우기 줄 (안의 n개) · 실제로 파일로. analyze 문제 없음, l10n 빠짐 0. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 5 + assets/l10n/ja.json | 5 + assets/l10n/zh-Hans.json | 5 + lib/app/live_sync.dart | 9 +- lib/app/transfer_job.dart | 5 + lib/core/file_ops.dart | 14 +- lib/core/sync_preview.dart | 42 ++- lib/ui/explorer_page.dart | 2 + lib/ui/sync_preview_view.dart | 9 +- test/sftp_test.dart | 519 ++++++++++++++++++---------------- test/webdav_test.dart | 413 ++++++++++++++------------- 11 files changed, 580 insertions(+), 448 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 6f6893a..4a6267d 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -59,11 +59,13 @@ "(덮어씀)": "(overwritten)", "(동영상을 세 번 누르면 옮깁니다)": "(Tapping a video three times moves it)", "(링크 · junction - 따라가지 않고 건너뜀)": "(link · junction - not followed, skipped)", + "(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(skipped - the destination has a folder with the same name; the folder is kept)", "(받는 쪽에만 있음)": "(only on receiving side)", "(받는 쪽이 더 새것 - 건너뜀)": "(receiving side is newer - skipped)", "(비어 있음)": "(empty)", "(설명 없음)": "(no description)", "(지워짐)": "(deleted)", + "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(folder and the {0} items inside are deleted and replaced by the file of the same name)", "(현재 방식은 옵션 없음)": "(no options for the built-in method)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* matches any text, ? one character. Separate several with ; (e.g. *.srt; *.ass)", "+ 여기에 새 줄": "+ New line here", @@ -546,6 +548,7 @@ "{0}개 고름": "{0} selected", "{0}개 더 보기": "Show {0} more", "{0}개 맞춤": "{0} synced", + "{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "{0} synced · skipped {1} (a folder with the same name exists)", "{0}개 목록에서 숨기기": "Hide {0} items from list", "{0}개 보임": "{0} shown", "{0}개 삭제": "Delete {0} items", @@ -657,6 +660,8 @@ "같은 시리즈 (번호만 다른 파일)": "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}", + "같은 이름의 폴더가 있어 건너뜀 {0}": "Skipped {0} (a folder with the same name exists)", + "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "Skipped {0} because a folder with the same name exists: {1}", "같은 이름이 이미 있습니다": "An item with the same name already exists", "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", "같은 이름이 이미 있음": "Name already exists", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 5dbd030..64307fa 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -59,11 +59,13 @@ "(덮어씀)": "(上書き)", "(동영상을 세 번 누르면 옮깁니다)": "(動画を 3 回タップすると移動します)", "(링크 · junction - 따라가지 않고 건너뜀)": "(リンク · ジャンクション - たどらずにスキップ)", + "(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(受け取る側に同名のフォルダーがあるためスキップ - フォルダーはそのまま)", "(받는 쪽에만 있음)": "(受け取る側にだけある)", "(받는 쪽이 더 새것 - 건너뜀)": "(受け取る側が新しい - スキップ)", "(비어 있음)": "(空)", "(설명 없음)": "(説明なし)", "(지워짐)": "(削除)", + "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(フォルダーと中の {0} 件を削除して同名のファイルに)", "(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* は任意の文字列、? は 1 文字。複数は ; で区切る (例: *.srt; *.ass)", "+ 여기에 새 줄": "+ ここに新しい行", @@ -546,6 +548,7 @@ "{0}개 고름": "{0} 件選択", "{0}개 더 보기": "さらに {0} 件表示", "{0}개 맞춤": "{0} 件を同期", + "{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "{0} 件同期 · 同名のフォルダーがあるため {1} 件スキップ", "{0}개 목록에서 숨기기": "{0} 件をリストから隠す", "{0}개 보임": "{0} 件表示", "{0}개 삭제": "{0} 件を削除", @@ -657,6 +660,8 @@ "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}": "同名のフォルダーがあるためスキップ {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "同名のフォルダーがあるため {0} 件スキップ: {1}", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", "같은 이름이 이미 있음": "同じ名前が既にあります", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 18f44af..10aedc3 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -59,11 +59,13 @@ "(덮어씀)": "(覆盖)", "(동영상을 세 번 누르면 옮깁니다)": "(连按视频三次会移动它)", "(링크 · junction - 따라가지 않고 건너뜀)": "(链接 · 联接 - 不跟随,已跳过)", + "(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(已跳过 - 目标有同名文件夹,文件夹保持不变)", "(받는 쪽에만 있음)": "(仅在接收方)", "(받는 쪽이 더 새것 - 건너뜀)": "(接收端较新 - 已跳过)", "(비어 있음)": "(空)", "(설명 없음)": "(无说明)", "(지워짐)": "(将删除)", + "(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(删除文件夹及其中 {0} 项,换成同名文件)", "(현재 방식은 옵션 없음)": "(当前方式没有选项)", "* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* 表示任意字符,? 表示一个字符。多个用 ; 分隔(例:*.srt; *.ass)", "+ 여기에 새 줄": "+ 在此新增一行", @@ -546,6 +548,7 @@ "{0}개 고름": "已选 {0} 个", "{0}개 더 보기": "再显示 {0} 个", "{0}개 맞춤": "已同步 {0} 个", + "{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "已同步 {0} 项 · 因同名文件夹跳过 {1} 项", "{0}개 목록에서 숨기기": "从列表中隐藏 {0} 项", "{0}개 보임": "显示 {0} 项", "{0}개 삭제": "删除 {0} 项", @@ -657,6 +660,8 @@ "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}": "因存在同名文件夹而跳过 {0}", + "같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "因存在同名文件夹跳过 {0} 项: {1}", "같은 이름이 이미 있습니다": "已存在同名项目", "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", "같은 이름이 이미 있음": "同名项已存在", diff --git a/lib/app/live_sync.dart b/lib/app/live_sync.dart index 5d5ab2a..137898f 100644 --- a/lib/app/live_sync.dart +++ b/lib/app/live_sync.dart @@ -416,15 +416,18 @@ class LiveSync extends ChangeNotifier { if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty); // 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교) final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method); + // M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다 + final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps); final n = switch (method) { - CopyMethod.builtin => await FileOps(bandwidthKBps: s.copyBandwidthKBps) - .mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty), + CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty), CopyMethod.rsync => await _rsync(x, s), CopyMethod.robocopy => await _robocopy(x, s), }; status[k] = toDelete[k] != null ? (DateTime.now(), trf('지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요', [toDelete[k]!.length])) - : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); + : ops.dirConflicts.isNotEmpty + ? (DateTime.now(), trf('{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개', [n < 0 ? 0 : n, ops.dirConflicts.length])) + : (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n])); problems.remove(k); emptyDeletes.remove(k); pending[k] = await diff(asked); diff --git a/lib/app/transfer_job.dart b/lib/app/transfer_job.dart index 6ac6a8b..a10b640 100644 --- a/lib/app/transfer_job.dart +++ b/lib/app/transfer_job.dart @@ -100,6 +100,9 @@ class TransferJob extends ChangeNotifier { /// 같은 이름이라 건너뛴 항목 (원본 경로) final skipped = []; + /// M1 (10/19): 원격 맞추기에서 받는 쪽에 같은 이름의 폴더가 있어 건너뛴 것 (--delete 가 없을 때) + final dirConflicts = []; + TransferJob({ required this.sources, required this.dest, @@ -341,6 +344,8 @@ class TransferJob extends ChangeNotifier { // 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도 settled.clear(); await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move); + dirConflicts.addAll(_ops!.dirConflicts); + _ops!.dirConflicts.clear(); if (move && !cancelled) await _deleteSettled(); made.add(dest); } else { diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index c319e2e..aecae60 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -381,6 +381,10 @@ class FileOps { /// 건너뛴 항목 (원본 경로) final skipped = []; + /// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것. + /// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음) + final dirConflicts = []; + FileOps({ this.bandwidthKBps = 0, this.onFileDone, @@ -950,7 +954,15 @@ class FileOps { } } if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue; - if (t != null && t.isDir) await vDelete(target); + // M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만). + // 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다 + if (t != null && t.isDir) { + if (!delete) { + dirConflicts.add(target); + continue; + } + await vDelete(target); + } await _copyFileV(e.path, target, e.size, e.modified, null); n++; onSettled?.call(e.path, e.size, e.modified); diff --git a/lib/core/sync_preview.dart b/lib/core/sync_preview.dart index e2ac112..40dd972 100644 --- a/lib/core/sync_preview.dart +++ b/lib/core/sync_preview.dart @@ -25,6 +25,9 @@ enum PreviewAction { /// 231: 원본의 링크 · junction - 앱이 직접 맞출 때는 따라가지 않고 건너뛴다 (214) skipLink, + + /// M1 (10/19): 원본은 파일인데 받는 쪽에 같은 이름의 폴더가 있어 건너뜀 (--delete 가 없을 때 - 폴더를 지우지 않는다) + skipDir, } class PreviewItem { @@ -36,7 +39,12 @@ class PreviewItem { final bool toRight; final bool isDir; final int size; - const PreviewItem(this.rel, this.action, {required this.toRight, this.isDir = false, this.size = 0, this.newer = false}); + const PreviewItem(this.rel, this.action, + {required this.toRight, this.isDir = false, this.size = 0, this.newer = false, this.replacedByFile = false, this.inner = 0}); + + /// M1 (10/19): [PreviewAction.delete] 인 폴더가 원본의 같은 이름 파일로 바뀜 (--delete) - 그 폴더 안의 항목 수 [inner] + final bool replacedByFile; + final int inner; /// 229: [PreviewAction.update] 인데 받는 쪽이 더 새것 (예전 원본으로 새 파일을 덮어씀) final bool newer; @@ -133,6 +141,17 @@ Future> previewSync(String src, String dst, await walk(e.path, vJoin(d, name), r); continue; } + // M1 (관리자 P0, 10/19): 받는 쪽의 같은 이름이 폴더 - --delete 면 폴더 (안의 것 모두) 를 지우고 파일로, 아니면 건너뜀 (앱 맞추기 · rsync 같음) + if (t != null && t.isDir) { + if (delete) { + out.add(PreviewItem(r, PreviewAction.delete, + toRight: toRight, isDir: true, replacedByFile: true, inner: await _countInside(t.path))); + out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size)); + } else { + out.add(PreviewItem(r, PreviewAction.skipDir, toRight: toRight, size: e.size)); + } + continue; + } if (t == null) { out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size)); } else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) { @@ -175,3 +194,24 @@ Future> previewBoth(String left, String right, {bool delete = x, ]; } + +/// M1: 폴더 [dir] 안의 모든 항목 수 (지우고 파일로 바뀔 때 보이려고, [limit] 에서 멈춤) +Future _countInside(String dir, {int limit = 100000}) async { + var n = 0; + Future go(String d) async { + List<({String path, bool isDir, int size, DateTime modified})> items; + try { + items = await vList(d); + } catch (_) { + return; + } + for (final x in items) { + if (n >= limit) return; + n++; + if (x.isDir) await go(x.path); + } + } + + await go(dir); + return n; +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 1657d93..6f129d0 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -3306,6 +3306,8 @@ class _ExplorerPageState extends State { if (move && job.leftInSource > 0) trf('{0}개는 원본에 남음 (건너뜀)', [job.leftInSource]), if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것 {0}개 건너뜀', [job.keptNewer]), if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]), + // M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다 + if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 건너뜀 {0}개: {1}', [job.dirConflicts.length, _namesOf(job.dirConflicts)]), if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]), ].join(' · '); // 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창) diff --git a/lib/ui/sync_preview_view.dart b/lib/ui/sync_preview_view.dart index f31e1cc..9893eff 100644 --- a/lib/ui/sync_preview_view.dart +++ b/lib/ui/sync_preview_view.dart @@ -101,6 +101,7 @@ class _SyncPreviewViewState extends State { final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length; final skipped = n(PreviewAction.skipNewer); final links = n(PreviewAction.skipLink); + final dirSkips = n(PreviewAction.skipDir); final more = items.length >= widget.limit; final parts = [ for (final right in [true, false]) @@ -109,6 +110,7 @@ class _SyncPreviewViewState extends State { if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]), if (skipped > 0) trf('받는 쪽이 더 새것 {0} (건너뜀)', [skipped]), if (links > 0) trf('링크 {0} (건너뜀)', [links]), + if (dirSkips > 0) trf('같은 이름의 폴더가 있어 건너뜀 {0}', [dirSkips]), ]; return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row(children: [ @@ -153,14 +155,17 @@ class _SyncPreviewViewState extends State { PreviewAction.skipNewer => ' · ${x.rel} ${tr('(받는 쪽이 더 새것 - 건너뜀)')}', PreviewAction.skipLink => ' · ${x.rel}${x.isDir ? '/' : ''} ${tr('(링크 · junction - 따라가지 않고 건너뜀)')}', PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}', - PreviewAction.delete => '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}', + PreviewAction.delete => x.replacedByFile + ? '${_arrow(x)} − ${x.rel}/ ${trf('(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)', [x.inner])}' + : '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}', + PreviewAction.skipDir => ' · ${x.rel} ${tr('(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)')}', }, style: TextStyle( fontSize: 11.5, fontFamily: 'monospace', color: switch (x.action) { PreviewAction.delete => Colors.redAccent, - PreviewAction.update => Colors.orangeAccent, + PreviewAction.update || PreviewAction.skipDir => Colors.orangeAccent, PreviewAction.onlyTarget || PreviewAction.skipNewer || PreviewAction.skipLink => JjColors.textDim, _ => null, }, diff --git a/test/sftp_test.dart b/test/sftp_test.dart index dfe604a..24b9b23 100644 --- a/test/sftp_test.dart +++ b/test/sftp_test.dart @@ -1,248 +1,271 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/settings.dart'; -import 'package:jj_mkvmaker/core/sftp.dart'; -import 'package:jj_mkvmaker/core/vfs.dart'; -import 'package:jj_mkvmaker/services/secret_store.dart'; -import 'package:path/path.dart' as p; - -import 'support/sftp_server.dart'; - -/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - -/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. -void main() { - final hasServer = sftpTestServerAvailable; - final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen'; - - group('설정 · 경로 (서버 없이)', () { - test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async { - final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_'); - addTearDown(() => tmp.deleteSync(recursive: true)); - final secrets = MemorySecretStore(); - final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); - final s = AppSettings() - ..sftpServers = [ - const SftpServer( - id: 's1', - name: 'NAS', - host: 'nas.local', - port: 2222, - user: 'jj', - password: 'pw-secret', - privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----', - keyPassphrase: 'kp-secret', - legacyRsa: true, - hostKeyType: 'ssh-ed25519', - hostKeyFingerprint: 'SHA256:abc'), - ]; - await store.save(s); - final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync(); - expect(text, isNot(contains('pw-secret'))); - expect(text, isNot(contains('kp-secret'))); - expect(text, isNot(contains('OPENSSH PRIVATE KEY'))); - expect(secrets.values['sftp.pw.s1'], 'pw-secret'); - expect(secrets.values['sftp.kp.s1'], 'kp-secret'); - expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); - final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); - final x = back.sftpServers.single; - expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc')); - expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret')); - expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); - // .bak · 모르는 항목 보존용 JSON 에서도 빠진다 - final stripped = AppSettings.stripSecrets({ - 'sftpServers': [ - {'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'} - ] - }); - expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"')))); - expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}'); - }); - - test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () { - SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]); - addTearDown(() => SftpRegistry.configure(const [])); - final d = DavPath.parse(r'sftp://s1/a\b/'); - expect(d.full, 'sftp://s1/a/b'); - expect(d.isSftp, isTrue); - expect(d.parent.full, 'sftp://s1/a'); - expect(d.parent.parent.full, 'sftp://s1/'); - expect(d.child('c').full, 'sftp://s1/a/b/c'); - expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x'); - expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a'); - expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)'); - expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true)); - expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b'); - expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다'); - expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse); - }); - }); - - group('실제 SFTP 서버 (paramiko)', () { - late Directory tmp; - late Directory root; - SftpTestServer? server; - late int port; - late String fingerprint; - - Future start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async { - final s = await SftpTestServer.start( - root: root.path, - hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'), - password: password, - pubkey: pubkey, - keytype: keytype, - legacyOnly: legacyOnly); - server = s; - port = s.port; - fingerprint = s.fingerprint; - } - - setUp(() { - tmp = Directory.systemTemp.createTempSync('jj_sftp88_'); - root = Directory(p.join(tmp.path, 'root'))..createSync(); - }); - tearDown(() async { - SftpRegistry.configure(const []); - final s = server; - server = null; - await s?.stop(); - for (var i = 0;; i++) { - try { - if (tmp.existsSync()) tmp.deleteSync(recursive: true); - break; - } on FileSystemException { - if (i > 30) rethrow; - await Future.delayed(const Duration(milliseconds: 100)); - } - } - }); - - SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) => - SftpServer( - id: 's1', - name: 'test', - host: '127.0.0.1', - port: port, - user: 'jj', - password: password, - privateKey: key, - keyPassphrase: kp, - legacyRsa: legacy, - hostKeyFingerprint: fp, - root: rootDir); - - Future err(Future Function() f) async { - try { - await f(); - return null; - } catch (e) { - return e; - } - } - - test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async { - await start(); - final e = await err(() => SftpRemote(srv()).list('/')); - expect(e, isA()); - final hk = e! as SftpHostKeyException; - expect(hk.kind, SftpFailure.hostKeyUnknown); - expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문'); - expect(hk.type, 'ssh-ed25519'); - expect(hk.changed, isFalse); - // 믿음 → 연결 - final ok = SftpRemote(srv(fp: fingerprint)); - addTearDown(ok.close); - expect(await ok.list('/'), isEmpty); - // 바뀐 키 → 막고 예전 · 지금 지문을 알림 - final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/')); - expect(e2, isA()); - expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged); - expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키'); - expect('$e2', contains('바뀌었습니다')); - }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); - - test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async { - await start(); - final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/')); - expect((bad! as SftpException).kind, SftpFailure.auth); - final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/')); - expect((none! as SftpException).kind, SftpFailure.noSecret); - expect('$none', '저장된 비밀번호가 없습니다'); - // 닫힌 포트 - final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - final closedPort = free.port; - await free.close(); - final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'), - timeout: const Duration(seconds: 3)) - .list('/')); - expect((off! as SftpException).kind, SftpFailure.unreachable); - }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); - - test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async { - await start(); - SftpRegistry.configure([srv(fp: fingerprint)]); - const base = 'sftp://s1/'; - await vMkdirs('${base}a/b'); - expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue); - Stream> bytes(String s) => Stream.value(utf8.encode(s)); - await vWrite('${base}a/b/f.txt', bytes('hello')); - await vWrite('${base}a/b/f.txt', bytes('hello again')); - expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)'); - expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음'); - final list = await vList('${base}a/b'); - expect(list.single.path, 'sftp://s1/a/b/f.txt'); - expect((list.single.isDir, list.single.size), (false, 11)); - final st = await vStat('${base}a'); - expect(st!.isDir, isTrue); - expect(await vStat('$base없음'), isNull); - expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again'); - await vRename('${base}a/b/f.txt', '${base}a/g.txt'); - expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue); - // 받아서 열기 (임시 폴더) - final local = await vLocalCopy('${base}a/g.txt', tmp.path); - expect(File(local).readAsStringSync(), 'hello again'); - expect(local, contains('jj_sftp')); - await vDelete('${base}a'); - expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse); - // 시작 폴더: 그 안이 탭의 맨 위 - Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true); - File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m'); - SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]); - expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']); - final (free, _) = await DavPath.parse(base).client.quota(); - expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null'); - }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); - - test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async { - final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}'); - final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]); - if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음'); - addTearDown(() { - for (final f in [key, '$key.pub']) { - if (File(f).existsSync()) File(f).deleteSync(); - } - }); - await start(password: null, pubkey: '$key.pub'); - final pem = File(key).readAsStringSync(); - final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint)); - addTearDown(c.close); - expect(await c.list('/'), isEmpty); - final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); - expect((bad! as SftpException).kind, SftpFailure.auth); - expect('$bad', contains('개인 키의 암호')); - }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); - - test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { - await start(keytype: 'rsa', legacyOnly: true); - final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/')); - expect(e, isA()); - expect('$e', contains('ssh-rsa')); - final c = SftpRemote(srv(fp: fingerprint, legacy: true)); - addTearDown(c.close); - expect(await c.list('/'), isEmpty); - }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); - }); -} +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/sftp.dart'; +import 'package:jj_mkvmaker/core/vfs.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; +import 'package:path/path.dart' as p; + +import 'support/sftp_server.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/sync_preview.dart'; + +/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - +/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. +void main() { + final hasServer = sftpTestServerAvailable; + final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen'; + + group('설정 · 경로 (서버 없이)', () { + test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async { + final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final secrets = MemorySecretStore(); + final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + final s = AppSettings() + ..sftpServers = [ + const SftpServer( + id: 's1', + name: 'NAS', + host: 'nas.local', + port: 2222, + user: 'jj', + password: 'pw-secret', + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----', + keyPassphrase: 'kp-secret', + legacyRsa: true, + hostKeyType: 'ssh-ed25519', + hostKeyFingerprint: 'SHA256:abc'), + ]; + await store.save(s); + final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync(); + expect(text, isNot(contains('pw-secret'))); + expect(text, isNot(contains('kp-secret'))); + expect(text, isNot(contains('OPENSSH PRIVATE KEY'))); + expect(secrets.values['sftp.pw.s1'], 'pw-secret'); + expect(secrets.values['sftp.kp.s1'], 'kp-secret'); + expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); + final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); + final x = back.sftpServers.single; + expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc')); + expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret')); + expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); + // .bak · 모르는 항목 보존용 JSON 에서도 빠진다 + final stripped = AppSettings.stripSecrets({ + 'sftpServers': [ + {'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'} + ] + }); + expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"')))); + expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}'); + }); + + test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () { + SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]); + addTearDown(() => SftpRegistry.configure(const [])); + final d = DavPath.parse(r'sftp://s1/a\b/'); + expect(d.full, 'sftp://s1/a/b'); + expect(d.isSftp, isTrue); + expect(d.parent.full, 'sftp://s1/a'); + expect(d.parent.parent.full, 'sftp://s1/'); + expect(d.child('c').full, 'sftp://s1/a/b/c'); + expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x'); + expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a'); + expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)'); + expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true)); + expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b'); + expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다'); + expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse); + }); + }); + + group('실제 SFTP 서버 (paramiko)', () { + late Directory tmp; + late Directory root; + SftpTestServer? server; + late int port; + late String fingerprint; + + Future start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async { + final s = await SftpTestServer.start( + root: root.path, + hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'), + password: password, + pubkey: pubkey, + keytype: keytype, + legacyOnly: legacyOnly); + server = s; + port = s.port; + fingerprint = s.fingerprint; + } + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_sftp88_'); + root = Directory(p.join(tmp.path, 'root'))..createSync(); + }); + tearDown(() async { + SftpRegistry.configure(const []); + final s = server; + server = null; + await s?.stop(); + for (var i = 0;; i++) { + try { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + break; + } on FileSystemException { + if (i > 30) rethrow; + await Future.delayed(const Duration(milliseconds: 100)); + } + } + }); + + SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) => + SftpServer( + id: 's1', + name: 'test', + host: '127.0.0.1', + port: port, + user: 'jj', + password: password, + privateKey: key, + keyPassphrase: kp, + legacyRsa: legacy, + hostKeyFingerprint: fp, + root: rootDir); + + Future err(Future Function() f) async { + try { + await f(); + return null; + } catch (e) { + return e; + } + } + + test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async { + await start(); + final e = await err(() => SftpRemote(srv()).list('/')); + expect(e, isA()); + final hk = e! as SftpHostKeyException; + expect(hk.kind, SftpFailure.hostKeyUnknown); + expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문'); + expect(hk.type, 'ssh-ed25519'); + expect(hk.changed, isFalse); + // 믿음 → 연결 + final ok = SftpRemote(srv(fp: fingerprint)); + addTearDown(ok.close); + expect(await ok.list('/'), isEmpty); + // 바뀐 키 → 막고 예전 · 지금 지문을 알림 + final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/')); + expect(e2, isA()); + expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged); + expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키'); + expect('$e2', contains('바뀌었습니다')); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async { + await start(); + final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/')); + expect((bad! as SftpException).kind, SftpFailure.auth); + final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/')); + expect((none! as SftpException).kind, SftpFailure.noSecret); + expect('$none', '저장된 비밀번호가 없습니다'); + // 닫힌 포트 + final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final closedPort = free.port; + await free.close(); + final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'), + timeout: const Duration(seconds: 3)) + .list('/')); + expect((off! as SftpException).kind, SftpFailure.unreachable); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async { + await start(); + SftpRegistry.configure([srv(fp: fingerprint)]); + final local = Directory(p.join(tmp.path, 'local', 'src'))..createSync(recursive: true); + File(p.join(local.path, 'x')).writeAsStringSync('file'); + Directory(p.join(root.path, 'dst', 'x', 'sub')).createSync(recursive: true); + File(p.join(root.path, 'dst', 'x', 'keep.txt')).writeAsStringSync('k'); + const dst = 'sftp://s1/dst'; + var pv = await previewSync(local.path, dst, toRight: true); + expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']); + final ops = FileOps(); + await ops.mirror(local.path, dst); + expect(File(p.join(root.path, 'dst', 'x', 'keep.txt')).existsSync(), isTrue); + expect(ops.dirConflicts, hasLength(1)); + pv = await previewSync(local.path, dst, toRight: true, delete: true); + final del = pv.singleWhere((x) => x.action == PreviewAction.delete); + expect((del.replacedByFile, del.inner), (true, 2)); + await FileOps().mirror(local.path, dst, delete: true); + expect(File(p.join(root.path, 'dst', 'x')).readAsStringSync(), 'file'); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async { + await start(); + SftpRegistry.configure([srv(fp: fingerprint)]); + const base = 'sftp://s1/'; + await vMkdirs('${base}a/b'); + expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue); + Stream> bytes(String s) => Stream.value(utf8.encode(s)); + await vWrite('${base}a/b/f.txt', bytes('hello')); + await vWrite('${base}a/b/f.txt', bytes('hello again')); + expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)'); + expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음'); + final list = await vList('${base}a/b'); + expect(list.single.path, 'sftp://s1/a/b/f.txt'); + expect((list.single.isDir, list.single.size), (false, 11)); + final st = await vStat('${base}a'); + expect(st!.isDir, isTrue); + expect(await vStat('$base없음'), isNull); + expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again'); + await vRename('${base}a/b/f.txt', '${base}a/g.txt'); + expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue); + // 받아서 열기 (임시 폴더) + final local = await vLocalCopy('${base}a/g.txt', tmp.path); + expect(File(local).readAsStringSync(), 'hello again'); + expect(local, contains('jj_sftp')); + await vDelete('${base}a'); + expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse); + // 시작 폴더: 그 안이 탭의 맨 위 + Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true); + File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m'); + SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]); + expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']); + final (free, _) = await DavPath.parse(base).client.quota(); + expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null'); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async { + final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}'); + final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]); + if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음'); + addTearDown(() { + for (final f in [key, '$key.pub']) { + if (File(f).existsSync()) File(f).deleteSync(); + } + }); + await start(password: null, pubkey: '$key.pub'); + final pem = File(key).readAsStringSync(); + final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint)); + addTearDown(c.close); + expect(await c.list('/'), isEmpty); + final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); + expect((bad! as SftpException).kind, SftpFailure.auth); + expect('$bad', contains('개인 키의 암호')); + }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); + + test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { + await start(keytype: 'rsa', legacyOnly: true); + final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/')); + expect(e, isA()); + expect('$e', contains('ssh-rsa')); + final c = SftpRemote(srv(fp: fingerprint, legacy: true)); + addTearDown(c.close); + expect(await c.list('/'), isEmpty); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + }); +} diff --git a/test/webdav_test.dart b/test/webdav_test.dart index 5129482..fb35f13 100644 --- a/test/webdav_test.dart +++ b/test/webdav_test.dart @@ -1,193 +1,220 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/app_controller.dart'; -import 'package:jj_mkvmaker/app/live_sync.dart'; -import 'package:jj_mkvmaker/app/settings.dart'; -import 'package:jj_mkvmaker/core/file_ops.dart'; -import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; -import 'package:jj_mkvmaker/core/vfs.dart'; -import 'package:jj_mkvmaker/core/webdav.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:path/path.dart' as p; - -import 'support/dav_server.dart'; - -void main() { - late Directory tmp, remote, local; - late TestDavServer server; - const id = 'nas'; - - setUp(() async { - tmp = Directory.systemTemp.createTempSync('jj_dav_'); - remote = Directory(p.join(tmp.path, 'remote'))..createSync(); - local = Directory(p.join(tmp.path, 'local'))..createSync(); - server = TestDavServer(remote); - await server.start(); - DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]); - }); - tearDown(() async { - DavRegistry.configure([]); - await server.stop(); - tmp.deleteSync(recursive: true); - }); - - File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel)) - ..parent.createSync(recursive: true) - ..writeAsStringSync(text); - File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel)) - ..parent.createSync(recursive: true) - ..writeAsStringSync(text); - - test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () { - expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b'); - expect(DavPath.parse('dav://nas').full, 'dav://nas/'); - expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더'); - expect(vDirname('dav://nas/a/b'), 'dav://nas/a'); - expect(vDirname('dav://nas/a'), 'dav://nas/'); - expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv'); - expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue); - expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue); - expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse); - expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse); - expect(samePath('dav://nas/a', r'C:\a'), isFalse); - }); - - test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async { - rf('영상/a.mkv', 'aaaa'); - rf('영상/sub/b.txt', 'b'); - final top = await listEntries('dav://$id/'); - expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]); - final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name); - expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]); - final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더'); - expect(made, 'dav://$id/영상/새 폴더'); - expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue); - final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv'); - expect(renamed, 'dav://$id/영상/c.mkv'); - expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa'); - await FileOps().delete(['dav://$id/영상/sub']); - expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse); - // 잘못된 비밀번호 - DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]); - expect(() => listEntries('dav://$id/'), throwsA(isA().having((e) => e.status, 'status', 401))); - }); - - test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async { - lf('up/x.txt', 'xx'); - lf('up/deep/y.bin', 'yyy'); - var bytes = 0; - final ops = FileOps(onBytes: (n) => bytes += n); - expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']); - expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy'); - expect(bytes, 5); - // 같은 이름이면 "이름 (2)" - expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']); - // WebDAV → 로컬 - final down = Directory(p.join(local.path, 'down'))..createSync(); - await ops.copy(['dav://$id/up'], down.path); - expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx'); - // 같은 서버 안 이동 (서버가 MOVE) - await FileOps.makeFolder('dav://$id/', 'box'); - await ops.move(['dav://$id/up (2)'], 'dav://$id/box'); - expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue); - expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse); - expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty); - // 로컬 → WebDAV 이동 (복사 후 원본 지움) - lf('mv/m.txt', 'm'); - await ops.move([p.join(local.path, 'mv')], 'dav://$id/box'); - expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm'); - expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse); - }); - - test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async { - lf('src/a.txt', 'a'); - lf('src/sub/b.txt', 'b'); - final ops = FileOps(); - expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2); - expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b'); - // 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것) - expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0); - // 원본에서 내용 (크기) 이 바뀐 것만 - lf('src/a.txt', 'aaa'); - expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1); - expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa'); - // 지우기 포함: 대상에만 있는 것 - rf('dst/extra.txt', 'e'); - await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true); - expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse); - // -u: 대상이 더 새것이면 덮어쓰지 않음 - rf('dst/a.txt', 'remote newer!!'); - final old = DateTime.now().subtract(const Duration(days: 1)); - File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old); - await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true); - expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!'); - // 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음) - final back = p.join(local.path, 'back'); - expect(await ops.mirror('dav://$id/dst', back), 2); - expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b'); - expect(await ops.mirror('dav://$id/dst', back), 0); - }); - - test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async { - rf('p/e1/e2/.keep', ''); - File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync(); - rf('p/f/z.txt', 'z'); - expect(await vCountFiles('dav://$id/p'), 1); - expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1 - expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue); - expect(await DavRegistry.client(id).quota(), (1000000, 500)); - final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache')); - expect(File(got).readAsStringSync(), 'z'); - expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로')); - }); - - test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async { - lf('live/a.txt', 'a'); - lf('live/sub/b.txt', 'bb'); - rf('mirror/old.txt', 'o'); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); - c.settings.liveSyncPairs = [pair]; - final live = LiveSync(c); - expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt'])); - await live.syncNow(pair); - expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); - expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb'); - expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse); - expect(live.pending[LiveSync.keyOf(pair)], isEmpty); - live.dispose(); - }); - - test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async { - rf('영상/1화.mkv', 'v1'); - rf('영상/2화.mkv', 'v2'); - rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); - rf('영상/메모.txt', 't'); - final s = davStream('dav://$id/영상/1화.mkv'); - expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}'); - expect(s.headers['Authorization'], startsWith('Basic ')); - // 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다 - expect(vPlayable('dav://$id/영상/1화.mkv'), s.url); - expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass'))); - expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv'); - // 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청) - final http = HttpClient(); - final req = await http.getUrl(Uri.parse(s.url)); - s.headers.forEach(req.headers.set); - final res = await req.close(); - expect(await res.transform(const SystemEncoding().decoder).join(), 'v1'); - http.close(); - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - c.settings.playlistMode = PlaylistMode.folder; - final before = server.requests.where((r) => r.startsWith('GET')).length; - final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']); - expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']); - expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음 - final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv'); - expect(subs.map(p.basename), ['1화.ko.srt']); - expect(File(subs.single).readAsStringSync(), contains('안녕')); - }); -} +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/live_sync.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode; +import 'package:jj_mkvmaker/core/vfs.dart'; +import 'package:jj_mkvmaker/core/webdav.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:path/path.dart' as p; + +import 'support/dav_server.dart'; +import 'package:jj_mkvmaker/core/sync_preview.dart'; + +void main() { + late Directory tmp, remote, local; + late TestDavServer server; + const id = 'nas'; + + setUp(() async { + tmp = Directory.systemTemp.createTempSync('jj_dav_'); + remote = Directory(p.join(tmp.path, 'remote'))..createSync(); + local = Directory(p.join(tmp.path, 'local'))..createSync(); + server = TestDavServer(remote); + await server.start(); + DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]); + }); + tearDown(() async { + DavRegistry.configure([]); + await server.stop(); + tmp.deleteSync(recursive: true); + }); + + File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel)) + ..parent.createSync(recursive: true) + ..writeAsStringSync(text); + File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel)) + ..parent.createSync(recursive: true) + ..writeAsStringSync(text); + + test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () { + expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b'); + expect(DavPath.parse('dav://nas').full, 'dav://nas/'); + expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더'); + expect(vDirname('dav://nas/a/b'), 'dav://nas/a'); + expect(vDirname('dav://nas/a'), 'dav://nas/'); + expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv'); + expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue); + expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue); + expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse); + expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse); + expect(samePath('dav://nas/a', r'C:\a'), isFalse); + }); + + test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async { + rf('영상/a.mkv', 'aaaa'); + rf('영상/sub/b.txt', 'b'); + final top = await listEntries('dav://$id/'); + expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]); + final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name); + expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]); + final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더'); + expect(made, 'dav://$id/영상/새 폴더'); + expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue); + final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv'); + expect(renamed, 'dav://$id/영상/c.mkv'); + expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa'); + await FileOps().delete(['dav://$id/영상/sub']); + expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse); + // 잘못된 비밀번호 + DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]); + expect(() => listEntries('dav://$id/'), throwsA(isA().having((e) => e.status, 'status', 401))); + }); + + test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async { + lf('up/x.txt', 'xx'); + lf('up/deep/y.bin', 'yyy'); + var bytes = 0; + final ops = FileOps(onBytes: (n) => bytes += n); + expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']); + expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy'); + expect(bytes, 5); + // 같은 이름이면 "이름 (2)" + expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']); + // WebDAV → 로컬 + final down = Directory(p.join(local.path, 'down'))..createSync(); + await ops.copy(['dav://$id/up'], down.path); + expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx'); + // 같은 서버 안 이동 (서버가 MOVE) + await FileOps.makeFolder('dav://$id/', 'box'); + await ops.move(['dav://$id/up (2)'], 'dav://$id/box'); + expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue); + expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse); + expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty); + // 로컬 → WebDAV 이동 (복사 후 원본 지움) + lf('mv/m.txt', 'm'); + await ops.move([p.join(local.path, 'mv')], 'dav://$id/box'); + expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm'); + expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse); + }); + + test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async { + lf('src/a.txt', 'a'); + lf('src/sub/b.txt', 'b'); + final ops = FileOps(); + expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2); + expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b'); + // 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것) + expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0); + // 원본에서 내용 (크기) 이 바뀐 것만 + lf('src/a.txt', 'aaa'); + expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1); + expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa'); + // 지우기 포함: 대상에만 있는 것 + rf('dst/extra.txt', 'e'); + await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true); + expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse); + // -u: 대상이 더 새것이면 덮어쓰지 않음 + rf('dst/a.txt', 'remote newer!!'); + final old = DateTime.now().subtract(const Duration(days: 1)); + File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old); + await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true); + expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!'); + // 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음) + final back = p.join(local.path, 'back'); + expect(await ops.mirror('dav://$id/dst', back), 2); + expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b'); + expect(await ops.mirror('dav://$id/dst', back), 0); + }); + + test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async { + rf('p/e1/e2/.keep', ''); + File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync(); + rf('p/f/z.txt', 'z'); + expect(await vCountFiles('dav://$id/p'), 1); + expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1 + expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue); + expect(await DavRegistry.client(id).quota(), (1000000, 500)); + final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache')); + expect(File(got).readAsStringSync(), 'z'); + expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로')); + }); + + test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async { + lf('live/a.txt', 'a'); + lf('live/sub/b.txt', 'bb'); + rf('mirror/old.txt', 'o'); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true); + c.settings.liveSyncPairs = [pair]; + final live = LiveSync(c); + expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt'])); + await live.syncNow(pair); + expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패'))); + expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb'); + expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse); + expect(live.pending[LiveSync.keyOf(pair)], isEmpty); + live.dispose(); + }); + + test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async { + rf('영상/1화.mkv', 'v1'); + rf('영상/2화.mkv', 'v2'); + rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n'); + rf('영상/메모.txt', 't'); + final s = davStream('dav://$id/영상/1화.mkv'); + expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}'); + expect(s.headers['Authorization'], startsWith('Basic ')); + // 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다 + expect(vPlayable('dav://$id/영상/1화.mkv'), s.url); + expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass'))); + expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv'); + // 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청) + final http = HttpClient(); + final req = await http.getUrl(Uri.parse(s.url)); + s.headers.forEach(req.headers.set); + final res = await req.close(); + expect(await res.transform(const SystemEncoding().decoder).join(), 'v1'); + http.close(); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.playlistMode = PlaylistMode.folder; + final before = server.requests.where((r) => r.startsWith('GET')).length; + final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']); + expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']); + expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음 + final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv'); + expect(subs.map(p.basename), ['1화.ko.srt']); + expect(File(subs.single).readAsStringSync(), contains('안녕')); + }); + test('M1 (관리자 P0): 원본 파일 · 받는 쪽 같은 이름 폴더 - 지우기 없으면 폴더를 지우지 않고 건너뜀, 지우기면 지우고 파일로 · 미리 보기도 같음', () async { + lf('src/x', 'file'); + lf('src/ok.txt', 'ok'); + rf('dst/x/keep.txt', 'k'); + rf('dst/x/sub/deep.txt', 'd'); + final src = p.join(local.path, 'src'); + const dst = 'dav://$id/dst'; + // 지우기 없음: 미리 보기 "건너뜀" · 지울 것 없음 → 실제로도 폴더 그대로 + var pv = await previewSync(src, dst, toRight: true); + expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']); + expect(pv.where((x) => x.action == PreviewAction.delete), isEmpty); + final ops = FileOps(); + await ops.mirror(src, dst); + expect(File(p.join(remote.path, 'dst', 'x', 'keep.txt')).readAsStringSync(), 'k', reason: '예전에는 이 폴더를 통째로 지웠다'); + expect(File(p.join(remote.path, 'dst', 'x', 'sub', 'deep.txt')).existsSync(), isTrue); + expect(File(p.join(remote.path, 'dst', 'ok.txt')).readAsStringSync(), 'ok', reason: '다른 것은 그대로 맞춤'); + expect(ops.dirConflicts.map(vBasename), ['x']); + // 지우기 포함: 미리 보기에 "폴더 · 안의 3개를 지우고 파일로" → 실제로 폴더가 파일로 + pv = await previewSync(src, dst, toRight: true, delete: true); + final del = pv.singleWhere((x) => x.action == PreviewAction.delete); + expect((del.rel, del.isDir, del.replacedByFile, del.inner), ('x', true, true, 3)); + final ops2 = FileOps(); + await ops2.mirror(src, dst, delete: true); + expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file'); + expect(ops2.dirConflicts, isEmpty); + }); +}