From eaaa096ea106312a32fc2d503df425d50ff956f7 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:06:00 +0900 Subject: [PATCH] =?UTF-8?q?(=EA=B0=80)=2010=EA=B3=B3:=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9D=B4=20=EC=95=B1=20=EB=8F=99=EC=9E=91=EC=9D=84=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EB=8D=98=20=EA=B8=B8=EC=9D=84=20=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?=C2=B7=20=EB=AA=A8=EB=93=88=EB=A1=9C=20(lib=20=EC=9D=98=20@visi?= =?UTF-8?q?bleForTesting=20=EC=9D=80=20=EB=93=A4=EC=97=AC=EB=8B=A4?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=204=EA=B3=B3=EB=A7=8C=20=EB=82=A8=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tor: debugRunning · executable 없앰 → 떠난 뒤 잠시 켜 두기를 IdleStop 모듈로 (schedule · cancel · stopNow). 환경 설정 > Tor 는 IdleStop 을 받음 (처음 값 = 앱의 Tor), [지금 끄기] = stopNow - Wi-Fi 공유: debugLoopbackOnly · debugAddresses · loopbackOnly 인자 없앰 → WifiShareNet 주입 (열 주소 · 설정 열기). WifiSharePage(net:) 가 debugLaunch 를 대신함. debugFail 없앰 - 시험이 실제 다른 루프백 주소 (127.0.0.x) 에서 틀린 코드를 보냄 - 관리자 권한: Elevation.debugElevated 없앰 → PlatformInfo.elevated (조립 주입), CefView(elevated:) - CEF 시작 시간: 바꿀 수 없게 (const) - 시험은 가짜 시간을 넘김 - 단일 실행: debugStopListening 없앰 - 시험이 instance.json 을 아무도 듣지 않는 포트로 (받는 곳이 죽은 첫 창) - SMB 찾기: 전역 debugFindSmbServers 없앰 → PlatformServices.findLanSmb 주입 - 남은 @visibleForTesting (들여다보기만): browser_page · tor_page debugNav, secret_store dpapiAt, reader_page dropMissingFiles 확인: analyze 0 · 단위 1473 ×2 · 통합 6/6 ×2 · base10 214장 0px · windows_wifi_shot ×2 · windows_smbfind_shot ×2 Co-Authored-By: Claude Opus 5.5 --- .../windows_smbfind_shot_test.dart | 7 +-- integration_test/windows_wifi_shot_test.dart | 9 +-- lib/app/tor_service.dart | 40 ++++--------- lib/core/idle_stop.dart | 39 +++++++++++++ lib/core/wifi_share.dart | 26 +++------ lib/core/wifi_share_net.dart | 21 +++++++ lib/platform/windows/elevation.dart | 7 --- lib/platform/windows/single_instance.dart | 5 -- lib/services/platform_info.dart | 13 ++++- lib/services/platform_services.dart | 5 ++ lib/ui/browser/cef_view.dart | 17 +++--- lib/ui/network_settings.dart | 11 +++- lib/ui/settings/browser_section.dart | 3 +- lib/ui/smb_settings.dart | 12 ++-- lib/ui/wifi_share_page.dart | 15 +++-- test/cef_elevation_test.dart | 22 +++---- test/net_security_test.dart | 10 ++-- test/player_test.dart | 8 ++- test/smb_find_test.dart | 8 +-- test/support/loopback_net.dart | 14 +++++ test/tor_test.dart | 58 ++++++++++--------- test/wifi_share_page_test.dart | 15 ++--- test/wifi_share_test.dart | 22 +++++-- 23 files changed, 225 insertions(+), 162 deletions(-) create mode 100644 lib/core/idle_stop.dart create mode 100644 lib/core/wifi_share_net.dart create mode 100644 test/support/loopback_net.dart diff --git a/integration_test/windows_smbfind_shot_test.dart b/integration_test/windows_smbfind_shot_test.dart index 1889657..f0f45d5 100644 --- a/integration_test/windows_smbfind_shot_test.dart +++ b/integration_test/windows_smbfind_shot_test.dart @@ -28,17 +28,16 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - addTearDown(() => debugFindSmbServers = null); var hold = true; var result = const [LanHost('192.168.0.15', 'ds1815.local'), LanHost('192.168.0.20', null)]; - debugFindSmbServers = ({cancelled, onProgress}) async { + Future> findLan({cancelled, onProgress}) async { onProgress?.call(97, 254); while (hold) { await Future.delayed(const Duration(milliseconds: 50)); } return result; - }; - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + } + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), findLanSmb: findLan)); final navKey = GlobalKey(); final shotKey = GlobalKey(); await t.pumpWidget(RepaintBoundary( diff --git a/integration_test/windows_wifi_shot_test.dart b/integration_test/windows_wifi_shot_test.dart index 8817f2e..49a503c 100644 --- a/integration_test/windows_wifi_shot_test.dart +++ b/integration_test/windows_wifi_shot_test.dart @@ -16,6 +16,8 @@ import 'package:jj_mkvmaker/ui/wifi_share_page.dart'; import 'support/temp_dir.dart'; import 'package:path/path.dart' as p; +import '../test/support/loopback_net.dart'; + /// 관리자 지시 (10/18): 89 Wi-Fi 공유 화면을 PNG 로 (JJ_SHOT_DIR) - 켜기 전 · 켠 뒤 (코드 · 주소 · QR · 남은 시간 · 접속 기록). /// 서버는 127.0.0.1 에만 연다 (시험 PC 의 방화벽 창 · 바깥 노출 없이), 주소 줄은 시험용 사설 주소로 바꿔 보인다. /// 크기 412 · 960, 언어 ko · en · ja. 실행: flutter test integration_test\windows_wifi_shot_test.dart -d windows @@ -32,13 +34,8 @@ void main() { addTearDown(t.view.reset); await t.runAsync(() => i18n.apply(lang, save: false)); addTearDown(() => i18n.apply('ko', save: false)); - WifiShare.debugLoopbackOnly = true; - // 화면에는 이 PC 의 실제 주소 대신 시험용 사설 주소를 (캡처에 사무실 망 주소가 남지 않게) - WifiShare.debugAddresses = const ['192.168.0.23']; addTearDown(() async { await WifiShare.instance?.stop(); - WifiShare.debugLoopbackOnly = false; - WifiShare.debugAddresses = null; }); final tmp = testTempDir('jj_wifishot_'); final dir = Directory(p.join(tmp.path, '여행 사진'))..createSync(); @@ -46,7 +43,7 @@ void main() { final shotKey = GlobalKey(); await t.pumpWidget(RepaintBoundary( key: shotKey, - child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: WifiSharePage(c: c, folder: dir.path)), + child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: WifiSharePage(c: c, folder: dir.path, net: LoopbackNet())), )); Future settle([int n = 8]) async { for (var i = 0; i < n; i++) { diff --git a/lib/app/tor_service.dart b/lib/app/tor_service.dart index 143e4fb..550dce6 100644 --- a/lib/app/tor_service.dart +++ b/lib/app/tor_service.dart @@ -9,6 +9,7 @@ import '../core/local_proxy.dart'; import '../core/tor_control.dart'; import '../l10n/tr.dart'; import 'tor_bundle.dart'; +import '../core/idle_stop.dart'; /// Tor 실행 (Tor 화면이 열려 있는 동안). tor 를 자식 프로세스로 띄우고 (SOCKS · 제어 포트는 127.0.0.1 의 빈 포트), /// 웹 뷰가 쓸 HTTP 프록시 다리 ([LocalProxy] + [TorConnector]) 를 연다. 다리는 이름을 Tor 에 넘긴다 (이 기기의 DNS 를 쓰지 않음). @@ -23,37 +24,25 @@ class TorService { /// Tor 화면을 떠난 뒤 tor 를 끌 때 (기다리는 중이면 그 시각, 아니면 null) - 관리자 결정 10/11: /// 화면을 떠나면 웹 뷰 · 다리 · 프록시는 바로 정리하고 tor 만 잠시 (환경 설정, 처음 2분) 켜 둔다 (다시 들어오면 바로 쓰게) - final idleStopAt = ValueNotifier(null); - Timer? _idleTimer; + ValueNotifier get idleStopAt => idle.stopAt; - /// [after] 뒤에 끈다 (0 이면 바로). 그 사이 [cancelIdleStop] 하면 그대로 켜 둔다 + /// 떠난 뒤 잠시 켜 두기 (시간 재기 · 지금 끄기) - 환경 설정 > Tor 가 이것을 받는다 + late final idle = IdleStop(stop); + + /// [after] 뒤에 끈다 (0 이면 바로). 그 사이 [cancelIdleStop] 하면 그대로 켜 둔다 (시간 재기는 [IdleStop]) Future stopAfter(Duration after) async { cancelIdleStop(); - if (_proc == null && !debugRunning) return; - if (after <= Duration.zero) return stop(); - idleStopAt.value = DateTime.now().add(after); - log?.call(trf('Tor 대기 ({0}분 뒤 끔)', [after.inMinutes])); - _idleTimer = Timer(after, () { - _idleTimer = null; - idleStopAt.value = null; - unawaited(stop()); - }); + if (_proc == null) return; + if (after > Duration.zero) log?.call(trf('Tor 대기 ({0}분 뒤 끔)', [after.inMinutes])); + await idle.schedule(after); } /// 기다렸다 끄기를 그만둔다 (Tor 화면으로 돌아옴) - void cancelIdleStop() { - _idleTimer?.cancel(); - _idleTimer = null; - idleStopAt.value = null; - } + void cancelIdleStop() => idle.cancel(); /// 작업 기록 void Function(String)? log; - /// 시험용: 실행 파일 · 인수를 바꾼다 - @visibleForTesting - Future Function() executable = TorBundle.executable; - Process? _proc; int? _socksPort; int? _controlPort; @@ -85,11 +74,7 @@ class TorService { await b?.close(); } - bool get running => _proc != null || debugRunning; - - /// 시험용: tor 가 떠 있는 것처럼 ([stopAfter] 시험) - @visibleForTesting - bool debugRunning = false; + bool get running => _proc != null; /// 켜기 (이미 켜져 있거나 켜는 중이면 그것을 기다린다) Future start() { @@ -98,7 +83,7 @@ class TorService { } Future _start() async { - final exe = await executable(); + final exe = await TorBundle.executable(); if (exe == null) { _set(TorStatus(TorPhase.failed, message: tr('Tor 가 설치되어 있지 않습니다 (환경 설정 > 컴포넌트)'))); return; @@ -224,7 +209,6 @@ class TorService { /// 끄기 (Tor 화면을 닫을 때 · 앱이 끝날 때 · 제거할 때) Future stop({bool keepStatus = false}) async { cancelIdleStop(); - debugRunning = false; _watchdog?.cancel(); final proc = _proc; _proc = null; diff --git a/lib/core/idle_stop.dart b/lib/core/idle_stop.dart new file mode 100644 index 0000000..cec7790 --- /dev/null +++ b/lib/core/idle_stop.dart @@ -0,0 +1,39 @@ +/// 약속: 정한 시간 뒤에 한 번 끈다 ([schedule]). 입력 = 기다릴 시간 (0 이하면 바로 끔), 출력 = 끌 시각 [stopAt] (기다리지 않으면 null). +/// [cancel] 하면 끄지 않는다. [stopNow] 는 기다리지 않고 바로 끈다. 다시 [schedule] 하면 앞의 것은 잊는다. 오류 = 끄는 함수의 오류 (바로 끌 때만 돌려줌). +/// 쓰는 곳: Tor (화면을 떠난 뒤 잠시 켜 둠 - 관리자 결정 10/11) +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +class IdleStop { + IdleStop(this.onStop); + + final Future Function() onStop; + final stopAt = ValueNotifier(null); + Timer? _timer; + + Future schedule(Duration after) async { + cancel(); + if (after <= Duration.zero) return onStop(); + stopAt.value = DateTime.now().add(after); + _timer = Timer(after, () { + _timer = null; + stopAt.value = null; + unawaited(onStop()); + }); + } + + /// 기다리지 않고 지금 끈다 ([cancel] 뒤 끄는 함수) + Future stopNow() { + cancel(); + return onStop(); + } + + void cancel() { + _timer?.cancel(); + _timer = null; + stopAt.value = null; + } +} diff --git a/lib/core/wifi_share.dart b/lib/core/wifi_share.dart index 73cbc4d..b1945fb 100644 --- a/lib/core/wifi_share.dart +++ b/lib/core/wifi_share.dart @@ -9,6 +9,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 isLanAddress, isMobileInterfaceName, netKindsOf; +import 'wifi_share_net.dart'; /// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). /// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17): @@ -37,14 +38,6 @@ class WifiShare extends ChangeNotifier { static const lockout = Duration(minutes: 1); static const totalFails = 30; - /// 시험: 화면 시험에서 이 기기 (127.0.0.1) 에만 연다 (시험 PC 의 방화벽 창 · 바깥 노출 없이) - @visibleForTesting - static bool debugLoopbackOnly = false; - - /// 시험: 이 기기의 주소 대신 쓸 것 (화면 캡처에 시험 PC 의 실제 망 주소가 남지 않게) - @visibleForTesting - static List? debugAddresses; - /// 화면에 보이는 접속 기록 개수 static const maxLog = 200; @@ -79,20 +72,19 @@ class WifiShare extends ChangeNotifier { /// 접속 주소 (코드를 넣은 것 - QR 용) String urlWithCode(String host) => 'http://$host:$port/?code=$code'; - /// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다 + /// 공유를 켠다. 이미 켜진 것이 있으면 먼저 끈다. [autoOff] 가 null 이면 저절로 끄지 않는다. 열 주소는 [net] (처음 값 = 이 기기) static Future start(String root, - {Duration? autoOff, int port = defaultPort, Random? random, bool loopbackOnly = false, bool tailscale = true}) async { + {Duration? autoOff, int port = defaultPort, Random? random, bool tailscale = true, WifiShareNet net = const RealWifiShareNet()}) async { await instance?.stop(); final dir = Directory(root); if (!await dir.exists()) throw FileSystemException(tr('폴더가 없습니다'), root); final real = await dir.resolveSymbolicLinks(); final rng = random ?? Random.secure(); final code = List.generate(6, (_) => rng.nextInt(10)).join(); - final loop = loopbackOnly || debugLoopbackOnly; - final addrs = debugAddresses ?? await localAddresses(tailscale: tailscale); + final addrs = await net.addresses(tailscale: tailscale); // 보안 검토 3: 화면에 보이는 주소 (Wi-Fi · 유선 · Tailscale) 에만 연다 - 모바일 데이터 · 가상 어댑터 · 다른 VPN 에서는 열리지 않게 - if (!loop && addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다')); - final binds = loop ? ['127.0.0.1'] : addrs; + if (addrs.isEmpty) throw SocketException(tr('Wi-Fi 나 유선 망에 연결되어 있지 않아 켤 수 없습니다')); + final binds = addrs; final servers = []; final shown = []; try { @@ -118,7 +110,7 @@ class WifiShare extends ChangeNotifier { server.defaultResponseHeaders.clear(); server.serverHeader = null; } - s.addresses = loop ? addrs : shown; + s.addresses = shown; if (autoOff != null && autoOff > Duration.zero) { s.endsAt = s.started.add(autoOff); s._timer = Timer(autoOff, () => s.stop(reason: tr('정한 시간이 지나 저절로 껐습니다'))); @@ -345,10 +337,6 @@ class WifiShare extends ChangeNotifier { } } - /// 시험: 틀린 코드 하나를 센다 (막힌 기기에서 온 것처럼 서버를 거치지 않고) - @visibleForTesting - void debugFail(String ip) => _fail(ip); - void _record(String ip, String path, int status) { if (_stopped) return; log.insert(0, WifiAccess(DateTime.now(), ip, path, status)); diff --git a/lib/core/wifi_share_net.dart b/lib/core/wifi_share_net.dart new file mode 100644 index 0000000..b854ff2 --- /dev/null +++ b/lib/core/wifi_share_net.dart @@ -0,0 +1,21 @@ +/// 약속: Wi-Fi 공유가 기기에서 받는 것 - 열 주소 ([addresses]: 사설망 먼저, 없으면 빈 목록) · Windows 설정 · 제어판 열기 ([launch]). +/// 앱은 [RealWifiShareNet] (이 기기 그대로) 를 쓰고, 시험은 생성자로 다른 것을 넘긴다 (예: 127.0.0.1 에만 열기 - 시험 PC 의 방화벽 창 · 바깥 노출 없이) +library; + +import 'dart:io'; + +import 'wifi_share.dart' show WifiShare; + +abstract interface class WifiShareNet { + Future> addresses({bool tailscale = true}); + Future launch(String exe, List args); +} + +/// 이 기기 그대로 +class RealWifiShareNet implements WifiShareNet { + const RealWifiShareNet(); + @override + Future> addresses({bool tailscale = true}) => WifiShare.localAddresses(tailscale: tailscale); + @override + Future launch(String exe, List args) => Process.start(exe, args, mode: ProcessStartMode.detached); +} diff --git a/lib/platform/windows/elevation.dart b/lib/platform/windows/elevation.dart index 3ec797d..f9ef235 100644 --- a/lib/platform/windows/elevation.dart +++ b/lib/platform/windows/elevation.dart @@ -1,8 +1,6 @@ import 'dart:ffi'; import 'dart:io'; -import 'package:flutter/foundation.dart'; - typedef _IsAdminC = Int32 Function(); typedef _IsAdminD = int Function(); @@ -11,15 +9,10 @@ typedef _IsAdminD = int Function(); class Elevation { Elevation._(); - /// 시험용: 정해 두면 그 값 - @visibleForTesting - static bool? debugElevated; - static bool? _cached; /// 관리자 권한으로 실행 중인지 (Windows 만, 모르면 false) static bool get elevated { - if (debugElevated case final v?) return v; if (!Platform.isWindows) return false; return _cached ??= () { try { diff --git a/lib/platform/windows/single_instance.dart b/lib/platform/windows/single_instance.dart index a392a92..62e0fbe 100644 --- a/lib/platform/windows/single_instance.dart +++ b/lib/platform/windows/single_instance.dart @@ -4,7 +4,6 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:math' as math; -import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:ffi/ffi.dart'; import 'package:path/path.dart' as p; @@ -158,10 +157,6 @@ class SingleInstance { }); } - /// 시험용: 받는 곳만 닫는다 (잠금 · instance.json 은 그대로 - 응답하지 않는 창) - @visibleForTesting - Future debugStopListening() async => _server?.close(); - /// 끝낼 때: instance.json (이 실행이 쓴 것일 때만) 을 지우고 잠금 파일을 놓는다 Future close() async { _timer?.cancel(); diff --git a/lib/services/platform_info.dart b/lib/services/platform_info.dart index 010adbe..bcd1dd0 100644 --- a/lib/services/platform_info.dart +++ b/lib/services/platform_info.dart @@ -1,10 +1,12 @@ /// 약속: 앱이 도는 기기가 무엇인지 (Android · Windows) 와 메모리를 아껴야 하는지 - 한곳에서만 정한다. -/// 입력 없음 ([PlatformInfo.host] 는 이 기기를 그대로 읽는다), 출력 = 세 값, 오류 없음. +/// 입력 없음 ([PlatformInfo.host] 는 이 기기를 그대로 읽는다), 출력 = 네 값, 오류 없음. /// 기기에 따라 다르게 도는 곳은 이것을 받아 (생성자 · 조립) 쓴다 - "Android 인 척" 하는 스위치를 따로 두지 않는다 (M1) library; import 'dart:io'; +import '../platform/windows/elevation.dart'; + class PlatformInfo { final bool isAndroid; final bool isWindows; @@ -12,10 +14,15 @@ class PlatformInfo { /// 메모리를 아끼는 방식 (모델을 나눠 열기 등). 처음 값은 Android final bool lowMemory; - const PlatformInfo({required this.isAndroid, required this.isWindows, bool? lowMemory}) : lowMemory = lowMemory ?? isAndroid; + /// 관리자 권한 (높은 무결성) 으로 실행 중인지 (Windows 만 - 207, 내장 Chrome 엔진을 시작하지 않는 까닭) + final bool elevated; + + const PlatformInfo({required this.isAndroid, required this.isWindows, bool? lowMemory, this.elevated = false}) + : lowMemory = lowMemory ?? isAndroid; /// 이 기기 - static final PlatformInfo host = PlatformInfo(isAndroid: Platform.isAndroid, isWindows: Platform.isWindows); + static final PlatformInfo host = + PlatformInfo(isAndroid: Platform.isAndroid, isWindows: Platform.isWindows, elevated: Elevation.elevated); static const android = PlatformInfo(isAndroid: true, isWindows: false); static const windows = PlatformInfo(isAndroid: false, isWindows: true); diff --git a/lib/services/platform_services.dart b/lib/services/platform_services.dart index 362da07..440d3b2 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -7,6 +7,7 @@ import '../platform/common/media_kit_player.dart'; import '../app/ai_upscale.dart' show AiUpscaler; import '../app/settings.dart'; import '../core/download_detect.dart'; +import '../core/lan_scan.dart' show LanHost; import '../platform/common/nllb_translator.dart'; import '../platform/common/opensubtitles_provider.dart'; import '../platform/common/whisper_recognizer.dart'; @@ -68,6 +69,9 @@ class PlatformServices { /// 엔진 · 모델을 이미 정해 둔 해상도 올리기 (없으면 설정대로 준비하고, 없는 엔진 · 모델은 그 자리에서 받는다) final AiUpscaler Function()? createUpscaler; + /// 같은 망의 SMB 서버 찾기 (없으면 이 기기의 사설망을 실제로 훑는다 - smb_settings) + final Future> Function({bool Function()? cancelled, void Function(int done, int total)? onProgress})? findLanSmb; + /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기) final PlatformInfo platform; @@ -84,6 +88,7 @@ class PlatformServices { this.createDownloadBackends, this.createSubtitleProviders, this.createUpscaler, + this.findLanSmb, this.updater, this.usage, ModelStore? models, diff --git a/lib/ui/browser/cef_view.dart b/lib/ui/browser/cef_view.dart index ecb8efb..a676b6e 100644 --- a/lib/ui/browser/cef_view.dart +++ b/lib/ui/browser/cef_view.dart @@ -10,7 +10,6 @@ import '../../core/download_detect.dart' show CookieExport, CookieRecord, isLogi import '../../core/web_address.dart'; import '../../l10n/tr.dart'; import '../../platform/windows/cef_runtime.dart'; -import '../../platform/windows/elevation.dart'; import '../cef_setup.dart' show confirmCefWhenElevated; import '../theme.dart'; import 'edge_view.dart'; @@ -27,7 +26,7 @@ Widget browserEngineView(AppController c, return EdgeView(host: host, initialUrl: url, dataDir: c.settings.webViewDataDir, javaScript: b.javaScript); } // 207: 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않는다 (보안) - 이유와 고를 것 - if (Elevation.elevated && !b.cefWhenElevated) return CefElevatedBlocked(c: c); + if (c.services.platform.elevated && !b.cefWhenElevated) return CefElevatedBlocked(c: c); // Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다 return CefView( key: ValueKey('${b.javaScript}|$cefTry'), @@ -38,7 +37,8 @@ Widget browserEngineView(AppController c, // 207: 시작하지 못하면 사용자가 바로 고를 수 있게 onUseEdge: () => c.updateSettings((x) => x.browser.engine = 'edge'), onRetry: onCefRetry, - cefWhenElevated: b.cefWhenElevated); + cefWhenElevated: b.cefWhenElevated, + elevated: c.services.platform.elevated); } /// 내장 Chrome 엔진 (환경 설정에서 고르고 내려받은 경우). 약속은 [EdgeView] 와 같다 (host 로 알리고 attach 로 조작을 준다): @@ -51,6 +51,9 @@ class CefView extends StatefulWidget { final VoidCallback? onUseEdge; final VoidCallback? onRetry; final bool cefWhenElevated; + + /// 관리자 권한으로 실행 중인지 (조립의 PlatformInfo) + final bool elevated; const CefView( {super.key, required this.host, @@ -59,11 +62,11 @@ class CefView extends StatefulWidget { required this.javaScript, this.onUseEdge, this.onRetry, - this.cefWhenElevated = false}); + this.cefWhenElevated = false, + this.elevated = false}); /// 207: 내장 Chrome 엔진이 이 시간 안에 준비되지 않으면 빙글이만 돌게 두지 않고 "시작하지 못했습니다" 와 고를 것을 보인다 - @visibleForTesting - static Duration startTimeout = const Duration(seconds: 20); + static const startTimeout = Duration(seconds: 20); @override State createState() => _CefViewState(); @@ -158,7 +161,7 @@ class _CefViewState extends State { rootCachePath: widget.dataDir.isEmpty ? null : p.join(p.dirname(widget.dataDir), 'cef'), proxyServer: gate, // 207: 관리자 권한이면 Chromium 이 스스로 끝나므로, 사용자가 설정에서 고른 때만 그대로 돈다 (JJ_PATCH.md 7) - doNotDeElevate: Elevation.elevated && widget.cefWhenElevated); + doNotDeElevate: widget.elevated && widget.cefWhenElevated); await _cefStarted; _c.setWebviewListener(cef.WebviewEventsListener( onUrlChanged: (u) { diff --git a/lib/ui/network_settings.dart b/lib/ui/network_settings.dart index b768cad..da36ac2 100644 --- a/lib/ui/network_settings.dart +++ b/lib/ui/network_settings.dart @@ -14,6 +14,7 @@ import '../platform/windows/cef_runtime.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'tor_page.dart' show showTorNotice; +import '../core/idle_stop.dart'; /// 249: DNS 서버에 닿지 않는 동안 보이는 줄 (환경 설정 > 네트워크 · DNS · 작업 현황) - 아래 안내를 놓쳐도 찾을 수 있게. /// 서버가 다시 답하거나 DNS 설정을 바꾸면 사라진다. @@ -256,22 +257,26 @@ class _DnsSettingsState extends State { /// 환경 설정 > Tor 브라우저: 기록 남기기 · 홈 주소 · JavaScript · 안내 다시 보기 · 지금 지우기 class TorSettings extends StatelessWidget { final AppController c; - const TorSettings({super.key, required this.c}); + + /// 떠난 뒤 잠시 켜 두기 (처음 값 = 이 앱의 Tor) + final IdleStop? idle; + const TorSettings({super.key, required this.c, this.idle}); @override Widget build(BuildContext context) { final s = c.settings; + final idle = this.idle ?? TorService.instance.idle; return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Tor 화면을 떠난 뒤 잠시 켜 두는 중이면 보인다 (언제 끄는지 · 지금 끄기) ValueListenableBuilder( - valueListenable: TorService.instance.idleStopAt, + valueListenable: idle.stopAt, builder: (context, at, _) => at == null ? const SizedBox.shrink() : SettingTile( leading: const Icon(Icons.timer_outlined), title: Text(trf('Tor 대기 중 ({0}분 뒤 끔)', [(at.difference(DateTime.now()).inSeconds / 60).ceil().clamp(0, 60)])), subtitle: Text(tr('Tor 화면을 떠난 뒤 다시 들어오면 바로 쓸 수 있게 켜 두고 있습니다.')), - trailing: OutlinedButton(onPressed: () => TorService.instance.stop(), child: Text(tr('지금 끄기'))), + trailing: OutlinedButton(onPressed: idle.stopNow, child: Text(tr('지금 끄기'))), ), ), SwitchListTile( diff --git a/lib/ui/settings/browser_section.dart b/lib/ui/settings/browser_section.dart index 0ce5ee5..908b6da 100644 --- a/lib/ui/settings/browser_section.dart +++ b/lib/ui/settings/browser_section.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/i18n_controller.dart' show I18nController; import '../../platform/windows/cef_runtime.dart'; -import '../../platform/windows/elevation.dart'; import '../cef_setup.dart'; import '../theme.dart'; import '../../l10n/tr.dart'; @@ -119,7 +118,7 @@ class _BrowserSettingsSectionState extends State { if (mounted) setState(() {}); }, title: Text(tr('관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)')), - subtitle: Text(Elevation.elevated + subtitle: Text(c.services.platform.elevated ? tr('지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.') : tr('관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.')), ), diff --git a/lib/ui/smb_settings.dart b/lib/ui/smb_settings.dart index 75cc2aa..623aa2b 100644 --- a/lib/ui/smb_settings.dart +++ b/lib/ui/smb_settings.dart @@ -213,7 +213,7 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ icon: const Icon(Icons.radar, size: 18), label: Text(tr('같은 망에서 찾기')), onPressed: () async { - final h = await pickLanSmbServer(ctx); + final h = await pickLanSmbServer(ctx, c); if (h == null) return; set(() { host.text = h.name ?? h.address; @@ -283,14 +283,10 @@ Future editSmbServer(BuildContext context, AppController c, {SmbServ } -/// 시험: 찾는 함수 (null 이면 실제로 훑는다) -@visibleForTesting -Future> Function({bool Function()? cancelled, void Function(int done, int total)? onProgress})? debugFindSmbServers; - /// X-plore j: 같은 망의 SMB 서버를 찾아 고르는 창 (진행 · 취소). 이 기기의 사설망 (/24) 의 445 포트에 연결만 해 보고 바로 닫는다 (보내는 것 없음) -/// [find]: 시험에서 바꿔 끼움 (실제 망을 훑지 않게) -Future pickLanSmbServer(BuildContext context) async { - final find = debugFindSmbServers ?? ({bool Function()? cancelled, void Function(int done, int total)? onProgress}) => +/// 찾기는 조립의 [PlatformServices.findLanSmb] (없으면 이 기기의 망을 실제로 훑는다) +Future pickLanSmbServer(BuildContext context, AppController c) async { + final find = c.services.findLanSmb ?? ({bool Function()? cancelled, void Function(int done, int total)? onProgress}) => findSmbServersBoth(findSmbServers, cancelled: cancelled, onProgress: onProgress); var cancelled = false; var done = 0, total = 0; diff --git a/lib/ui/wifi_share_page.dart b/lib/ui/wifi_share_page.dart index e674853..c425cdd 100644 --- a/lib/ui/wifi_share_page.dart +++ b/lib/ui/wifi_share_page.dart @@ -9,6 +9,7 @@ import '../app/app_controller.dart'; import '../core/wifi_share.dart'; import '../l10n/tr.dart'; import 'theme.dart'; +import '../core/wifi_share_net.dart'; /// 89: Wi-Fi 공유 화면 - 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용). /// 켜기 전: 폴더 · 저절로 끄는 시간 · 안내 · [켜기]. 켠 뒤: 6자리 코드 (크게) · 주소 · QR · 남은 시간 [+30분] · [끄기] · 접속 기록. @@ -18,12 +19,10 @@ 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); + /// 열 주소 · Windows 설정 · 제어판 열기 (처음 값 = 이 기기) + final WifiShareNet net; + const WifiSharePage({super.key, required this.c, required this.folder, this.net = const RealWifiShareNet()}); static Future open(BuildContext context, AppController c, String folder) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => WifiSharePage(c: c, folder: folder))); @@ -76,7 +75,7 @@ class _WifiSharePageState extends State { try { final mins = widget.c.settings.tools.wifiShareMinutes; final s = await WifiShare.start(widget.folder, - autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.tools.wifiShareTailscale); + autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.tools.wifiShareTailscale, net: widget.net); await widget.c.updateSettings((x) => x.tools.wifiShareFolder = widget.folder); _s?.removeListener(_changed); _s = s..addListener(_changed); @@ -247,7 +246,7 @@ class _WifiSharePageState extends State { Padding( padding: const EdgeInsets.only(left: 26), child: TextButton.icon( - onPressed: () => WifiSharePage.debugLaunch('explorer.exe', ['ms-settings:network-status']), + onPressed: () => widget.net.launch('explorer.exe', ['ms-settings:network-status']), icon: const Icon(Icons.open_in_new, size: 16), label: Text(tr('네트워크 설정 열기')), ), @@ -257,7 +256,7 @@ class _WifiSharePageState extends State { Padding( padding: const EdgeInsets.only(left: 26), child: TextButton.icon( - onPressed: () => WifiSharePage.debugLaunch('control.exe', ['/name', 'Microsoft.WindowsFirewall', '/page', 'pageConfigureApps']), + onPressed: () => widget.net.launch('control.exe', ['/name', 'Microsoft.WindowsFirewall', '/page', 'pageConfigureApps']), icon: const Icon(Icons.open_in_new, size: 16), label: Text(tr('방화벽 허용 앱 열기')), ), diff --git a/test/cef_elevation_test.dart b/test/cef_elevation_test.dart index 6d74c0f..5849587 100644 --- a/test/cef_elevation_test.dart +++ b/test/cef_elevation_test.dart @@ -7,8 +7,8 @@ import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/platform/windows/cef_runtime.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; -import 'package:jj_mkvmaker/platform/windows/elevation.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_info.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'; @@ -21,9 +21,15 @@ void main() { late Directory dir; late AppController c; late BookmarksController bm; + + /// 관리자 권한 여부는 조립 (PlatformInfo) 으로 넘긴다 + AppController controller({required bool elevated}) => AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), + storage: DesktopStorageService(), + platform: PlatformInfo(isAndroid: false, isWindows: true, elevated: elevated))); setUp(() { dir = Directory.systemTemp.createTempSync('jj_cef_elev_'); - c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c = controller(elevated: false); c.settings ..browser.engine = 'chrome' ..webViewDataDir = p.join(dir.path, 'webview'); @@ -31,7 +37,6 @@ void main() { CefRuntime.readyThisRun = true; }); tearDown(() { - Elevation.debugElevated = null; CefRuntime.readyThisRun = false; dir.deleteSync(recursive: true); }); @@ -48,7 +53,7 @@ void main() { } testWidgets('관리자 권한: Chrome 엔진을 시작하지 않고 이유 · [Edge 엔진으로 바꾸기]', (t) async { - Elevation.debugElevated = true; + c = controller(elevated: true)..settings = c.settings; await open(t); expect(find.text('관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)'), findsOneWidget); await t.tap(find.text('Edge 엔진으로 바꾸기')); @@ -58,7 +63,7 @@ void main() { }); testWidgets('관리자 권한: [그래도 Chrome 엔진 쓰기…] 는 경고를 보이고, 취소하면 그대로 · 고르면 설정이 켜짐', (t) async { - Elevation.debugElevated = true; + c = controller(elevated: true)..settings = c.settings; await open(t); await t.tap(find.text('그래도 Chrome 엔진 쓰기…')); await t.pumpAndSettle(); @@ -76,13 +81,10 @@ void main() { }); testWidgets('시작하지 못하면 빙글이만 돌지 않고 "Chrome 엔진을 시작하지 못했습니다" · [Edge 엔진으로 바꾸기] · [다시 시도]', (t) async { - Elevation.debugElevated = false; - CefView.startTimeout = const Duration(seconds: 2); - addTearDown(() => CefView.startTimeout = const Duration(seconds: 20)); await open(t); - // 시험에는 Chrome 엔진 (플러그인) 이 없어 끝나지 않거나 실패한다 - 정한 시간 (여기선 2초) 이 지나면 안내 + // 시험에는 Chrome 엔진 (플러그인) 이 없어 끝나지 않거나 실패한다 - 정한 시간 (20초) 이 지나면 안내 (가짜 시간을 넘김) expect(find.text('Chrome 엔진을 시작하지 못했습니다'), findsNothing); - await t.pump(const Duration(seconds: 3)); + await t.pump(CefView.startTimeout + const Duration(seconds: 1)); expect(find.text('Chrome 엔진을 시작하지 못했습니다'), findsOneWidget); expect(find.text('다시 시도'), findsOneWidget); await t.tap(find.text('Edge 엔진으로 바꾸기')); diff --git a/test/net_security_test.dart b/test/net_security_test.dart index b054df7..959c7b4 100644 --- a/test/net_security_test.dart +++ b/test/net_security_test.dart @@ -7,6 +7,8 @@ import 'package:jj_mkvmaker/core/vfs.dart'; import 'package:jj_mkvmaker/core/wifi_share.dart'; import 'package:path/path.dart' as p; +import 'support/loopback_net.dart'; + /// 새 네트워크 기능 보안 검토 (관리자 지시 10/18) 의 고침: /// 1 원격 이름으로 로컬 경로 벗어나기 · 3 Wi-Fi 공유는 Wi-Fi · 유선 주소에만 · 4 SMB 찾기는 Wi-Fi · 유선만 · 11 mDNS 는 사설망 주소만 void main() { @@ -72,18 +74,14 @@ void main() { test('3: Wi-Fi · 유선 주소가 없으면 공유를 켜지 않는다 (모든 연결 0.0.0.0 에 열지 않음)', () async { final tmp = Directory.systemTemp.createTempSync('jj_netsec_'); addTearDown(() => tmp.deleteSync(recursive: true)); - WifiShare.debugAddresses = const []; - addTearDown(() => WifiShare.debugAddresses = null); - await expectLater(WifiShare.start(tmp.path), throwsA(isA())); + await expectLater(WifiShare.start(tmp.path, net: LoopbackNet(const [])), throwsA(isA())); expect(WifiShare.instance, isNull); }); test('3: 공유는 고른 주소에만 열린다 (127.0.0.1 로 열면 다른 주소로는 닿지 않음)', () async { final tmp = Directory.systemTemp.createTempSync('jj_netsec_'); addTearDown(() => tmp.deleteSync(recursive: true)); - WifiShare.debugAddresses = const ['127.0.0.1']; - addTearDown(() => WifiShare.debugAddresses = null); - final s = await WifiShare.start(tmp.path, port: 0); + final s = await WifiShare.start(tmp.path, port: 0, net: LoopbackNet()); addTearDown(s.stop); expect(s.addresses, ['127.0.0.1']); final ok = await Socket.connect('127.0.0.1', s.port); diff --git a/test/player_test.dart b/test/player_test.dart index 9c530ea..0f7916b 100644 --- a/test/player_test.dart +++ b/test/player_test.dart @@ -249,8 +249,12 @@ void main() { test('첫 창이 응답하지 않으면 unresponsive (묻기 - 같은 데이터 위에 창 두 개를 막는다)', () async { final holder = SingleInstance(dataDir: d1.path); expect(await holder.claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.first); - // 응답하지 않는 창: 받는 곳을 닫았다 - await holder.debugStopListening(); + // 응답하지 않는 창: instance.json 의 포트에 아무도 듣지 않는다 (첫 창의 받는 곳이 죽음 - 잠금은 그대로) + final gone = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final port = gone.port; + await gone.close(); + final info = File(p.join(d1.path, 'instance.json')); + info.writeAsStringSync(jsonEncode({...jsonDecode(info.readAsStringSync()) as Map, 'port': port})); expect(await SingleInstance(dataDir: d1.path).claim(const LaunchRequest(LaunchAction.play, [])), ClaimResult.unresponsive); await holder.close(); diff --git a/test/smb_find_test.dart b/test/smb_find_test.dart index e6b0a36..fe05df6 100644 --- a/test/smb_find_test.dart +++ b/test/smb_find_test.dart @@ -9,20 +9,18 @@ import 'package:jj_mkvmaker/ui/smb_settings.dart'; /// X-plore j: [SMB 서버 추가] 창의 [같은 망에서 찾기] - 진행 · 찾은 서버를 고르면 주소 (이름) 칸을 채움. 시험은 실제 망을 훑지 않는다 (가짜 찾기) void main() { - tearDown(() => debugFindSmbServers = null); - testWidgets('찾기 → 고르면 주소 · 이름 칸 채움, 못 찾으면 안내', (tester) async { tester.view.physicalSize = const Size(1000, 1200); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); var calls = 0; var result = const [LanHost('192.168.0.9', 'nas.local'), LanHost('192.168.0.20', null)]; - debugFindSmbServers = ({cancelled, onProgress}) async { + Future> findLan({cancelled, onProgress}) async { calls++; onProgress?.call(254, 254); return result; - }; - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + } + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), findLanSmb: findLan)); final key = GlobalKey(); await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold())); editSmbServer(key.currentContext!, c); diff --git a/test/support/loopback_net.dart b/test/support/loopback_net.dart new file mode 100644 index 0000000..447e819 --- /dev/null +++ b/test/support/loopback_net.dart @@ -0,0 +1,14 @@ +import 'package:jj_mkvmaker/core/wifi_share_net.dart'; + +/// 시험용 Wi-Fi 공유 망: 정한 주소에만 연다 (처음 값 127.0.0.1 - 시험 PC 의 방화벽 창 · 바깥 노출 없이), +/// Windows 설정 · 제어판은 열지 않고 적어 둔다 (이 PC 의 앱을 열지 않게). 열기 자체는 실제 서버 그대로 +class LoopbackNet implements WifiShareNet { + LoopbackNet([this.addrs = const ['127.0.0.1']]); + final List addrs; + final launched = []; + + @override + Future> addresses({bool tailscale = true}) async => addrs; + @override + Future launch(String exe, List args) async => launched.add('$exe ${args.join(' ')}'); +} diff --git a/test/tor_test.dart b/test/tor_test.dart index 56b1e75..6a437ad 100644 --- a/test/tor_test.dart +++ b/test/tor_test.dart @@ -21,6 +21,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/app_actions.dart'; import 'package:jj_mkvmaker/ui/tor_page.dart'; import 'package:jj_mkvmaker/ui/network_settings.dart'; +import 'package:jj_mkvmaker/core/idle_stop.dart'; void main() { test('tor 알림 줄 읽기 · torrc', () { @@ -288,30 +289,38 @@ void main() { expect(find.textContaining('VIEW'), findsOneWidget); }); - testWidgets('떠난 뒤 tor 는 정한 시간 뒤에 끈다 · 돌아오면 그대로 · 0 은 바로 (가짜 시간)', (t) async { - final s = TorService.instance; - addTearDown(() => s.debugRunning = false); - s.debugRunning = true; - s.status.value = const TorStatus(TorPhase.ready, percent: 100); - await s.stopAfter(const Duration(minutes: 2)); - expect(s.idleStopAt.value, isNotNull); + testWidgets('떠난 뒤 tor 는 정한 시간 뒤에 끈다 · 돌아오면 그대로 · 0 은 바로 (가짜 시간, IdleStop 약속)', (t) async { + var stopped = 0; + final idle = IdleStop(() async => stopped++); + await idle.schedule(const Duration(minutes: 2)); + expect(idle.stopAt.value, isNotNull); await t.pump(const Duration(minutes: 1, seconds: 59)); - expect(s.running, isTrue); + expect(stopped, 0); await t.pump(const Duration(seconds: 2)); - expect(s.running, isFalse); - expect(s.idleStopAt.value, isNull); - expect(s.status.value.phase, TorPhase.off); + expect([stopped, idle.stopAt.value], [1, null]); // 그 사이 돌아오면 그대로 켜 둔다 - s.debugRunning = true; - s.status.value = const TorStatus(TorPhase.ready, percent: 100); - await s.stopAfter(const Duration(minutes: 2)); + await idle.schedule(const Duration(minutes: 2)); await t.pump(const Duration(minutes: 1)); - s.cancelIdleStop(); + idle.cancel(); await t.pump(const Duration(minutes: 5)); - expect(s.running, isTrue); + expect([stopped, idle.stopAt.value], [1, null]); + // 다시 정하면 앞의 것은 잊는다 + await idle.schedule(const Duration(minutes: 1)); + await idle.schedule(const Duration(minutes: 3)); + await t.pump(const Duration(minutes: 2)); + expect(stopped, 1); + await t.pump(const Duration(minutes: 1, seconds: 1)); + expect(stopped, 2); // 0 = 바로 - await s.stopAfter(Duration.zero); + await idle.schedule(Duration.zero); + expect([stopped, idle.stopAt.value], [3, null]); + }); + + test('tor 가 떠 있지 않으면 stopAfter 는 아무것도 하지 않는다', () async { + final s = TorService.instance; expect(s.running, isFalse); + await s.stopAfter(const Duration(minutes: 2)); + expect(s.idleStopAt.value, isNull); }); test('설정: Tor 를 켜 둘 시간 (처음 2분, 틀린 값은 2분)', () { @@ -347,20 +356,17 @@ void main() { testWidgets('226: 떠난 뒤 tor 를 켜 두는 동안 환경 설정 > Tor 에 "Tor 대기 중 (n분 뒤 끔)" · [지금 끄기]', (t) async { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); - final s = TorService.instance; - addTearDown(() async { - s.cancelIdleStop(); - s.debugRunning = false; - }); - await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: TorSettings(c: c))))); + var stopped = 0; + final idle = IdleStop(() async => stopped++); + addTearDown(idle.cancel); + await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: TorSettings(c: c, idle: idle))))); expect(find.textContaining('Tor 대기 중'), findsNothing); - s.debugRunning = true; - await s.stopAfter(const Duration(minutes: 2)); + await idle.schedule(const Duration(minutes: 2)); await t.pump(); expect(find.text('Tor 대기 중 (2분 뒤 끔)'), findsOneWidget); await t.tap(find.text('지금 끄기')); await t.pump(); - expect(s.running, isFalse); + expect(stopped, 1); expect(find.textContaining('Tor 대기 중'), findsNothing); }); diff --git a/test/wifi_share_page_test.dart b/test/wifi_share_page_test.dart index e151a77..fd60fde 100644 --- a/test/wifi_share_page_test.dart +++ b/test/wifi_share_page_test.dart @@ -9,19 +9,19 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/wifi_share_page.dart'; +import 'support/loopback_net.dart'; + /// 89: Wi-Fi 공유 화면 - 처음엔 꺼짐 · [켜기] 뒤 6자리 코드 · 주소 · 남은 시간 · [끄기] 로 꺼짐. 설정 파일은 쓰지 않는다 (저장소 없음) void main() { late Directory tmp; setUp(() { - WifiShare.debugLoopbackOnly = true; tmp = Directory.systemTemp.createTempSync('jj_wifipage_'); File('${tmp.path}${Platform.pathSeparator}a.txt').writeAsStringSync('a'); }); tearDown(() async { await WifiShare.instance?.stop(); - WifiShare.debugLoopbackOnly = false; try { tmp.deleteSync(recursive: true); } catch (_) {} @@ -34,7 +34,7 @@ void main() { 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))); + await tester.pumpWidget(MaterialApp(home: WifiSharePage(c: c, folder: tmp.path, net: LoopbackNet()))); expect(find.text('켜기'), findsOneWidget); expect(find.text('30분 뒤'), findsOneWidget); expect(WifiShare.instance, isNull, reason: '화면을 열기만 해서는 켜지지 않는다 (기본 꺼짐)'); @@ -59,15 +59,12 @@ void main() { }); 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 net = LoopbackNet(); 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))); + await tester.pumpWidget(MaterialApp(home: WifiSharePage(c: c, folder: tmp.path, net: net))); expect(find.textContaining('"개인 네트워크" 에 체크하고 [허용]'), findsOneWidget, reason: '켜기 전에 미리'); expect(find.text('방화벽 허용 앱 열기'), findsNothing, reason: '도움말은 접혀 있음'); await tester.tap(find.text('다른 기기에서 열리지 않으면 (Windows 방화벽)')); @@ -76,6 +73,6 @@ void main() { 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']); + expect(net.launched, ['explorer.exe ms-settings:network-status', 'control.exe /name Microsoft.WindowsFirewall /page pageConfigureApps']); }); } diff --git a/test/wifi_share_test.dart b/test/wifi_share_test.dart index 286042e..059191b 100644 --- a/test/wifi_share_test.dart +++ b/test/wifi_share_test.dart @@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/core/wifi_share.dart'; import 'package:path/path.dart' as p; +import 'support/loopback_net.dart'; + /// 89: Wi-Fi 공유 서버 - 코드 없으면 401 · 틀리면 막기 · 폴더 밖 · 링크 403 · 읽기만 (405) · Range · 격리 · 저절로 끄기 void main() { late Directory tmp, shared; @@ -21,7 +23,7 @@ void main() { Directory(p.join(shared.path, 'sub')).createSync(); File(p.join(shared.path, 'sub', 'b.bin')).writeAsBytesSync([1, 2, 3]); File(p.join(tmp.path, 'secret.txt')).writeAsStringSync('밖의 파일'); - s = await WifiShare.start(shared.path, port: 0, random: Random(7), loopbackOnly: true); + s = await WifiShare.start(shared.path, port: 0, random: Random(7), net: LoopbackNet()); }); tearDown(() async { @@ -45,6 +47,18 @@ void main() { Future body(HttpClientResponse r) => r.transform(utf8.decoder).join(); + /// [source] 주소에서 틀린 코드로 한 번 (실제 연결 - 서버가 보는 상대 주소가 [source]) + Future wrongFrom(String source, String wrong) async { + final so = await Socket.connect('127.0.0.1', s.port, sourceAddress: source); + so.write('GET /?code=$wrong HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n'); + try { + await so.cast>().transform(latin1.decoder).join(); + } on SocketException { + // 30번째에서 공유가 꺼지며 끊길 수 있다 + } + so.destroy(); + } + Future login() async { final r = await get('/?code=${s.code}'); expect(r.statusCode, 303); @@ -164,9 +178,9 @@ void main() { var r = await get('/?code=${s.code}'); expect(r.statusCode, 429, reason: '막힌 동안은 맞는 코드도 안 됨'); await r.drain(); - // 모두 합쳐 30번 (막힌 동안은 세지 않으므로 시험에서 바로 센다) + // 모두 합쳐 30번: 다른 기기들처럼 다른 루프백 주소 (127.0.0.x) 에서 실제로 틀린 코드를 보낸다 (주소마다 막히기 전까지 4번) for (var i = WifiShare.perClientFails; i < WifiShare.totalFails; i++) { - s.debugFail('10.0.0.${i % 200}'); + await wrongFrom('127.0.0.${2 + i ~/ 4}', wrong); } await Future.delayed(const Duration(milliseconds: 50)); expect(s.running, isFalse); @@ -185,7 +199,7 @@ void main() { expect(s.log.map((x) => x.path), contains('/a.txt')); expect(s.log.any((x) => x.path.contains(s.code)), isFalse); await s.stop(); - final t = await WifiShare.start(shared.path, port: 0, autoOff: const Duration(milliseconds: 200), loopbackOnly: true); + final t = await WifiShare.start(shared.path, port: 0, autoOff: const Duration(milliseconds: 200), net: LoopbackNet()); expect(t.endsAt, isNotNull); await Future.delayed(const Duration(milliseconds: 500)); expect(t.running, isFalse);