diff --git a/assets/l10n/en.json b/assets/l10n/en.json index f64de1e..09e6d7c 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -294,6 +294,7 @@ "{0} 안의 파일": "Files in {0}", "{0} 에 보내는 중": "Sending to {0}", "{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})", + "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "Cannot connect to {0}. Check that the network and NAS are on.", "{0} 에서 그리는 중 ({1}초)": "Drawing on {0} ({1} s)", "{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}", "{0} 외 {1}개": "{0} and {1} more", @@ -1132,6 +1133,7 @@ "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)", "여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s", "연결 실패: {0}": "Connection failed: {0}", + "연결 안 됨": "Not connected", "연결 확인": "Test connection", "연결됨: 항목 {0}개{1}": "Connected: {0} items{1}", "연결이 끊겨 다시 잇는 중 ({0}번째)": "Connection lost, reconnecting (attempt {0})", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index a7adfc9..e28d1e0 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -294,6 +294,7 @@ "{0} 안의 파일": "{0} 内のファイル", "{0} 에 보내는 중": "{0} に送信中", "{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)", + "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "{0} に接続できません。ネットワーク・NAS がオンか確認してください。", "{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)", "{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした", "{0} 외 {1}개": "{0} ほか {1} 件", @@ -1132,6 +1133,7 @@ "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", "여러 번 틀려 {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 de04c82..668f1e6 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -294,6 +294,7 @@ "{0} 안의 파일": "{0} 中的文件", "{0} 에 보내는 중": "正在发送到 {0}", "{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)", + "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "无法连接到 {0}。请确认网络和 NAS 已开启。", "{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)", "{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件", "{0} 외 {1}개": "{0} 等 {1} 个", @@ -1132,6 +1133,7 @@ "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", "여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入", "연결 실패: {0}": "连接失败: {0}", + "연결 안 됨": "未连接", "연결 확인": "测试连接", "연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}", "연결이 끊겨 다시 잇는 중 ({0}번째)": "连接中断,正在重新连接 (第 {0} 次)", diff --git a/lib/core/file_ops.dart b/lib/core/file_ops.dart index 7da674a..b9a86cc 100644 --- a/lib/core/file_ops.dart +++ b/lib/core/file_ops.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:ffi'; import 'dart:io'; +import 'package:ffi/ffi.dart'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; @@ -67,11 +69,37 @@ Future> listEntries(String dir, {bool showHidden = false}) async return out; } -/// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록 -List<(String path, String label)> windowsDrives() => [ - for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split('')) - if (FileSystemEntity.isDirectorySync('$c:\\')) ('$c:\\', '$c:'), - ]; +/// 저장 장치 (맨 위 폴더): Windows 는 드라이브 (C:\ …), 그 밖은 주어진 목록. +/// 165: 드라이브 문자는 Windows 가 알려 주는 목록 (GetLogicalDrives) 으로 - 드라이브마다 열어 보지 않는다 +/// (끊긴 · 느린 네트워크 드라이브 (NAS) 를 열어 보느라 탐색기가 10초씩 멈췄다) +List<(String path, String label)> windowsDrives() { + int mask; + try { + mask = DynamicLibrary.open('kernel32.dll').lookupFunction('GetLogicalDrives')(); + } catch (_) { + mask = 0; + } + if (mask == 0) return [(r'C:\', 'C:')]; + return [ + for (final c in 'CDEFGHIJKLMNOPQRSTUVWXYZAB'.split('')) + if (mask & (1 << (c.codeUnitAt(0) - 65)) != 0) ('$c:\\', '$c:'), + ]; +} + +/// 165: 네트워크 드라이브인지 (GetDriveTypeW = DRIVE_REMOTE). 드라이브를 열어 보지 않는다 +bool isNetworkDrive(String root) { + if (!Platform.isWindows) return false; + final w = root.toNativeUtf16(); + try { + final f = DynamicLibrary.open('kernel32.dll') + .lookupFunction), int Function(Pointer)>('GetDriveTypeW'); + return f(w) == 4; + } catch (_) { + return false; + } finally { + calloc.free(w); + } +} /// 48: 받는 폴더에 같은 이름이 있을 때 enum NameConflict { diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index cd34449..2367c90 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -55,6 +55,14 @@ class ExplorerPage extends StatefulWidget { final bool rsync; const ExplorerPage({super.key, required this.c, this.rsync = false}); + /// 165: 드라이브를 열 수 있는지 (시험에서 응답 없는 드라이브로 바꾼다). 화면을 멈추지 않는 비동기 + @visibleForTesting + static Future Function(String path) probeDrive = (path) => Directory(path).exists(); + + /// 시험: 이 기기의 저장 장치 목록 (null 이면 실제 목록) + @visibleForTesting + static List<(String, String)> Function()? debugVolumes; + static const routeName = 'explorer'; static const rsyncRouteName = 'rsync'; static int _open = 0, _openRsync = 0; @@ -188,6 +196,33 @@ class _ExplorerPageState extends State { /// 이 기기의 저장 장치: (경로, 이름) List<(String, String)> _local = []; + /// 165: 드라이브마다 따로 뒤에서 확인 (Windows). null = 확인 중, true = 열 수 있음, false = 연결 안 됨 + final Map _driveOk = {}; + final Set _networkDrives = {}; + + Future _probeDrive(String path) async { + if (mounted) setState(() => _driveOk[path] = null); + bool ok; + try { + ok = await ExplorerPage.probeDrive(path); + } catch (_) { + ok = false; + } + if (mounted) setState(() => _driveOk[path] = ok); + } + + /// 연결 안 된 드라이브를 누르면 다시 연결 (다시 읽기) - 되면 연다 + Future _reconnect(_Pane pane, String path) async { + await _probeDrive(path); + if (!mounted) return; + if (_driveOk[path] == true) { + pane.cache.remove(path); + await _goTo(pane, path); + } else { + _snack(trf('{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.', [path])); + } + } + /// 저장 장치 + WebDAV 서버 (환경 설정 > 파일 탐색기 > WebDAV): (경로, 이름) List<(String, String)> get _volumes => [ ..._local, @@ -235,8 +270,11 @@ class _ExplorerPageState extends State { if (Platform.isAndroid) unawaited(_checkAccess()); final saved = widget.rsync ? c.settings.rsyncPaths : c.settings.explorerPaths; // 창마다 따로 읽는다: 한 창이 WebDAV 서버를 기다려도 다른 창 (이 기기 파일) 은 바로 보이게 + // 165: 지난 폴더가 느린 네트워크 드라이브여도 화면이 멈추지 않게 (뒤에서 확인, 오래 걸리면 그냥 열어 본다 - 못 읽으면 그 창에 이유) + final reach = await Future.wait( + [for (var i = 0; i < 2; i++) i < saved.length ? _reachableAsync(saved[i]) : Future.value(false)]); final wants = [ - for (var i = 0; i < 2; i++) i < saved.length && _reachable(saved[i]) ? saved[i] : _local[0].$1, + for (var i = 0; i < 2; i++) reach[i] ? saved[i] : _local[0].$1, ]; for (var i = 0; i < 2; i++) { _panes[i] @@ -246,6 +284,13 @@ class _ExplorerPageState extends State { } if (!mounted) return; setState(() => _ready = true); + // 165: 드라이브 종류 · 연결은 드라이브마다 따로 뒤에서 (느린 드라이브가 있어도 다른 드라이브 · 창은 바로) + if (Platform.isWindows) { + for (final (path, _) in _local) { + if (isNetworkDrive(path)) _networkDrives.add(path); + unawaited(_probeDrive(path)); + } + } for (var i = 0; i < 2; i++) { final pane = _panes[i]; unawaited(_goTo(pane, wants[i], remember: false).then((_) => _reveal(pane, pane.current))); @@ -458,6 +503,7 @@ class _ExplorerPageState extends State { } Future> _loadVolumes() async { + if (ExplorerPage.debugVolumes case final f?) return f(); if (Platform.isWindows) return windowsDrives(); if (Platform.isAndroid) { final v = await AndroidAccess.volumes(); @@ -487,6 +533,16 @@ class _ExplorerPageState extends State { ? DavRegistry.server(DavPath.parse(path).server) != null : Directory(path).existsSync(); + /// [_reachable] 을 화면을 멈추지 않고 (2초 넘게 걸리면 있다고 보고 열어 본다) + Future _reachableAsync(String path) async { + if (isDav(path)) return _reachable(path); + try { + return await Directory(path).exists().timeout(const Duration(seconds: 2), onTimeout: () => true); + } catch (_) { + return false; + } + } + String _volumeOf(String path) { if (isDav(path)) return '$davScheme${DavPath.parse(path).server}/'; // 37: Windows 네트워크 공유 (\\NAS\공유) 는 그 공유가 맨 위 @@ -2444,14 +2500,23 @@ class _ExplorerPageState extends State { icon: Icon( isDav(path) ? Icons.cloud_outlined - : Platform.isWindows - ? Icons.storage - : Icons.sd_storage_outlined, + : _networkDrives.contains(path) + ? Icons.lan_outlined + : Platform.isWindows + ? Icons.storage + : Icons.sd_storage_outlined, size: 16), - label: Text(label), + // 165: 열 수 없는 드라이브는 숨기지 않고 "연결 안 됨" - 누르면 다시 연결 (다시 읽기) + label: _driveOk[path] == false + ? Text('$label · ${tr('연결 안 됨')} ⟳', style: const TextStyle(color: Colors.orangeAccent)) + : Text(label), onPressed: () { setState(() => _active = _panes.indexOf(pane)); - _goTo(pane, path); + if (_driveOk[path] == false) { + unawaited(_reconnect(pane, path)); + } else { + _goTo(pane, path); + } }, onLongPress: isDav(path) ? () => _editDav(pane, path) : null, ), diff --git a/test/explorer_drives_test.dart b/test/explorer_drives_test.dart new file mode 100644 index 0000000..06940ba --- /dev/null +++ b/test/explorer_drives_test.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/core/file_ops.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory tmp; + late AppController c; + late Completer stuck; + var dead = {}; + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_drives_'); + File(p.join(tmp.path, 'left', 'doc.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('x'); + Directory(p.join(tmp.path, 'right')).createSync(); + c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings.explorerPaths = [p.join(tmp.path, 'left'), p.join(tmp.path, 'right')]; + stuck = Completer(); + dead = {r'R:\'}; + // T: 시험 폴더 (정상) · Q: 응답 없는 네트워크 드라이브 · R: 연결 안 된 드라이브 + ExplorerPage.debugVolumes = () => [(tmp.path, 'T:'), (r'Q:\', 'Q:'), (r'R:\', 'R:')]; + ExplorerPage.probeDrive = (path) => path == r'Q:\' ? stuck.future : Future.value(!dead.contains(path)); + }); + tearDown(() { + ExplorerPage.debugVolumes = null; + ExplorerPage.probeDrive = (path) => Directory(path).exists(); + if (!stuck.isCompleted) stuck.complete(false); + tmp.deleteSync(recursive: true); + }); + + Future settle(WidgetTester tester, bool Function() done) async { + for (var i = 0; i < 50 && !done(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(); + } + } + + testWidgets('165: 응답 없는 드라이브가 있어도 첫 화면이 바로 뜨고, 연결 안 된 드라이브는 숨기지 않고 "연결 안 됨" · 누르면 다시 연결', (tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final sw = Stopwatch()..start(); + await tester.runAsync(() => tester.pumpWidget(MaterialApp(home: ExplorerPage(c: c)))); + await settle(tester, () => find.text('doc.txt').evaluate().isNotEmpty); + // Q: 는 끝내 대답하지 않지만 목록 · 두 창은 바로 (Q: 를 기다리지 않음) + expect(find.text('doc.txt'), findsOneWidget); + expect(sw.elapsed, lessThan(const Duration(seconds: 5))); + expect(find.text('Q:'), findsWidgets, reason: '응답 없는 드라이브도 목록에 (확인 중)'); + // R: 는 숨기지 않고 "연결 안 됨" + await settle(tester, () => find.textContaining('연결 안 됨').evaluate().isNotEmpty); + expect(find.textContaining('R: · 연결 안 됨'), findsWidgets); + // 누르면 다시 연결: 아직 안 되면 알림 + await tester.tap(find.textContaining('R: · 연결 안 됨').first); + await settle(tester, () => find.textContaining('R:\\ 에 연결할 수 없습니다').evaluate().isNotEmpty); + expect(find.textContaining(r'R:\ 에 연결할 수 없습니다'), findsOneWidget); + // 연결되면 "연결 안 됨" 이 사라진다 + dead = {}; + await tester.tap(find.textContaining('R: · 연결 안 됨').first); + await settle(tester, () => find.textContaining('R: · 연결 안 됨').evaluate().isEmpty); + expect(find.textContaining('R: · 연결 안 됨'), findsNothing); + }); + + test('165: 드라이브 목록은 열어 보지 않고 Windows 에게 받는다 (빠름)', () { + final sw = Stopwatch()..start(); + final d = windowsDrives(); + expect(sw.elapsedMilliseconds, lessThan(500)); + expect(d.map((e) => e.$2), contains('C:')); + }, skip: !Platform.isWindows); +}