(가) 10곳: 시험이 앱 동작을 바꾸던 길을 주입 · 모듈로 (lib 의 @visibleForTesting 은 들여다보기 4곳만 남음)

- 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 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-24 01:06:00 +09:00
parent 512c9d8b14
commit eaaa096ea1
23 changed files with 225 additions and 162 deletions

View File

@ -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<List<LanHost>> findLan({cancelled, onProgress}) async {
onProgress?.call(97, 254);
while (hold) {
await Future<void>.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<NavigatorState>();
final shotKey = GlobalKey();
await t.pumpWidget(RepaintBoundary(

View File

@ -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<void> settle([int n = 8]) async {
for (var i = 0; i < n; i++) {

View File

@ -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<DateTime?>(null);
Timer? _idleTimer;
ValueNotifier<DateTime?> get idleStopAt => idle.stopAt;
/// [after] 뒤에 끈다 (0 이면 바로). 그 사이 [cancelIdleStop] 하면 그대로 켜 둔다
/// 떠난 뒤 잠시 켜 두기 (시간 재기 · 지금 끄기) - 환경 설정 > Tor 가 이것을 받는다
late final idle = IdleStop(stop);
/// [after] 뒤에 끈다 (0 이면 바로). 그 사이 [cancelIdleStop] 하면 그대로 켜 둔다 (시간 재기는 [IdleStop])
Future<void> 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<String?> 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<void> start() {
@ -98,7 +83,7 @@ class TorService {
}
Future<void> _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<void> stop({bool keepStatus = false}) async {
cancelIdleStop();
debugRunning = false;
_watchdog?.cancel();
final proc = _proc;
_proc = null;

39
lib/core/idle_stop.dart Normal file
View File

@ -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<void> Function() onStop;
final stopAt = ValueNotifier<DateTime?>(null);
Timer? _timer;
Future<void> 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<void> stopNow() {
cancel();
return onStop();
}
void cancel() {
_timer?.cancel();
_timer = null;
stopAt.value = null;
}
}

View File

@ -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<String>? 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<WifiShare> 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 = <HttpServer>[];
final shown = <String>[];
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));

View File

@ -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<List<String>> addresses({bool tailscale = true});
Future<void> launch(String exe, List<String> args);
}
/// 이 기기 그대로
class RealWifiShareNet implements WifiShareNet {
const RealWifiShareNet();
@override
Future<List<String>> addresses({bool tailscale = true}) => WifiShare.localAddresses(tailscale: tailscale);
@override
Future<void> launch(String exe, List<String> args) => Process.start(exe, args, mode: ProcessStartMode.detached);
}

View File

@ -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 {

View File

@ -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<void> debugStopListening() async => _server?.close();
/// 끝낼 때: instance.json (이 실행이 쓴 것일 때만) 을 지우고 잠금 파일을 놓는다
Future<void> close() async {
_timer?.cancel();

View File

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

View File

@ -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<List<LanHost>> 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,

View File

@ -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<CefView> createState() => _CefViewState();
@ -158,7 +161,7 @@ class _CefViewState extends State<CefView> {
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) {

View File

@ -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<DnsSettings> {
/// 환경 설정 > 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<DateTime?>(
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(

View File

@ -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<BrowserSettingsSection> {
if (mounted) setState(() {});
},
title: Text(tr('관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)')),
subtitle: Text(Elevation.elevated
subtitle: Text(c.services.platform.elevated
? tr('지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.')
: tr('관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.')),
),

View File

@ -213,7 +213,7 @@ Future<SmbServer?> 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<SmbServer?> editSmbServer(BuildContext context, AppController c, {SmbServ
}
/// 시험: 찾는 함수 (null 이면 실제로 훑는다)
@visibleForTesting
Future<List<LanHost>> Function({bool Function()? cancelled, void Function(int done, int total)? onProgress})? debugFindSmbServers;
/// X-plore j: 같은 망의 SMB 서버를 찾아 고르는 창 (진행 · 취소). 이 기기의 사설망 (/24) 의 445 포트에 연결만 해 보고 바로 닫는다 (보내는 것 없음)
/// [find]: 시험에서 바꿔 끼움 (실제 망을 훑지 않게)
Future<LanHost?> pickLanSmbServer(BuildContext context) async {
final find = debugFindSmbServers ?? ({bool Function()? cancelled, void Function(int done, int total)? onProgress}) =>
/// 찾기는 조립의 [PlatformServices.findLanSmb] (없으면 이 기기의 망을 실제로 훑는다)
Future<LanHost?> 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;

View File

@ -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<void> Function(String exe, List<String> 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<void> 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<WifiSharePage> {
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<WifiSharePage> {
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<WifiSharePage> {
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('방화벽 허용 앱 열기')),
),

View File

@ -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 엔진으로 바꾸기'));

View File

@ -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<SocketException>()));
await expectLater(WifiShare.start(tmp.path, net: LoopbackNet(const [])), throwsA(isA<SocketException>()));
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);

View File

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

View File

@ -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<List<LanHost>> 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<NavigatorState>();
await tester.pumpWidget(MaterialApp(navigatorKey: key, home: const Scaffold()));
editSmbServer(key.currentContext!, c);

View File

@ -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<String> addrs;
final launched = <String>[];
@override
Future<List<String>> addresses({bool tailscale = true}) async => addrs;
@override
Future<void> launch(String exe, List<String> args) async => launched.add('$exe ${args.join(' ')}');
}

View File

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

View File

@ -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 = <String>[];
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']);
});
}

View File

@ -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<String> body(HttpClientResponse r) => r.transform(utf8.decoder).join();
/// [source] 주소에서 틀린 코드로 한 번 (실제 연결 - 서버가 보는 상대 주소가 [source])
Future<void> 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<List<int>>().transform(latin1.decoder).join();
} on SocketException {
// 30번째에서 공유가 꺼지며 끊길 수 있다
}
so.destroy();
}
Future<String> 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<void>();
// 모두 합쳐 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<void>.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<void>.delayed(const Duration(milliseconds: 500));
expect(t.running, isFalse);