diff --git a/lib/app/web_proxy.dart b/lib/app/web_proxy.dart index f3eabe4..a39c6a3 100644 --- a/lib/app/web_proxy.dart +++ b/lib/app/web_proxy.dart @@ -3,12 +3,12 @@ import 'dart:io'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; - import '../core/local_proxy.dart'; import '../l10n/tr.dart'; import 'net_guard.dart'; import 'tor_service.dart'; +import '../services/platform_info.dart'; +import 'web_view_proxy_target.dart'; /// Android 웹 뷰 프록시 (androidx.webkit ProxyController - 앱의 모든 웹 뷰에 한꺼번에 걸린다). /// - Tor 화면이 열려 있으면: 모든 웹 뷰가 Tor 로 (관리자 결정 10/10 - 일반 브라우저에는 안내 띠) @@ -63,7 +63,7 @@ class WebProxy { /// 보이는 웹 뷰 화면 수 ([show] · [hide]) 와 앱이 앞에 있는지 static int _shown = 0; static bool _foreground = true; - static int get shownForTest => _shown; + static int get shown => _shown; /// 웹 뷰 문 (인증 없음): 정한 DNS 로 잇는 것 · Tor 로 잇는 것 (따로 - Tor 화면을 닫으면 Tor 문도 닫혀 다른 길로 새지 않게) static LocalProxy? _dnsGate; @@ -155,12 +155,9 @@ class WebProxy { } } - /// 시험용: Android 처럼 돌리고 ([debugAndroid]), 웹 뷰에 프록시를 거는 대신 기록 ([debugSetProxy], '' = 없앰) - @visibleForTesting - static bool debugAndroid = false; - @visibleForTesting - static Future Function(String url, bool removeImplicitRules)? debugSetProxy; - static bool get _android => Platform.isAndroid || debugAndroid; + /// 웹 뷰에 프록시를 거는 곳 (이 기기에 맞는 것 - Windows 는 쓰지 않음). 조립 · 시험이 바꿔 끼운다 (M1) + static WebViewProxyTarget target = WebViewProxyTarget.forPlatform(PlatformInfo.host); + static bool get _android => target.active; /// 지금 상태에 맞게 다시 건다 (차례로 - 겹치지 않게). 끝나면 걸린 프록시 static Future apply() { @@ -191,32 +188,16 @@ class WebProxy { await closeUnused(); return _applied; } - if (debugSetProxy case final f?) { - await f(want, tor); - _applied = want; - await closeUnused(); - return want; - } - final pc = ProxyController.instance(); - if (want.isEmpty) { - await pc.clearProxyOverride(); - } else { - await pc.setProxyOverride( - settings: ProxySettings( - proxyRules: [ProxyRule(url: want)], - // Tor 화면: localhost 까지 Tor 로 (이 기기 · 집 안을 들여다보지 못하게). DNS 강제: 기본 (이 기기 주소는 직접) - removeImplicitRules: torOpen.value, - )); - } + // Tor 화면: localhost 까지 Tor 로 (이 기기 · 집 안을 들여다보지 못하게). DNS 강제: 기본 (이 기기 주소는 직접) + await target.set(want, removeImplicitRules: tor); _applied = want; debugPrint(trf('웹 뷰 프록시: {0}', [want.isEmpty ? tr('없음') : want])); await closeUnused(); return want; } - /// 시험용: 처음 상태로 - @visibleForTesting - static Future debugReset() async { + /// 처음 상태로 (문을 모두 닫고 수를 0 으로) + static Future reset() async { _shown = 0; _foreground = true; _applied = ''; diff --git a/lib/app/web_view_proxy_target.dart b/lib/app/web_view_proxy_target.dart new file mode 100644 index 0000000..7a4118a --- /dev/null +++ b/lib/app/web_view_proxy_target.dart @@ -0,0 +1,46 @@ +/// 약속: 앱의 모든 웹 뷰에 프록시를 거는 곳 ([WebProxy] 가 부른다). +/// 입력 = 프록시 주소 ('' = 없앰) · Tor 처럼 localhost 까지 프록시로 보낼지. 출력 없음. 오류 = 기기가 거절하면 그 예외. +/// [active] 가 false 인 기기 (Windows - 웹 뷰마다 따로 건다) 에서는 [WebProxy] 가 아무것도 하지 않는다 +library; + +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +import '../services/platform_info.dart'; + +abstract class WebViewProxyTarget { + /// 이 기기에서 앱 전체 웹 뷰 프록시를 쓰는지 + bool get active; + + Future set(String url, {required bool removeImplicitRules}); + + /// 이 기기에 맞는 것 (Android: androidx.webkit ProxyController, 아니면 쓰지 않음) + factory WebViewProxyTarget.forPlatform(PlatformInfo platform) => + platform.isAndroid ? const AndroidWebViewProxy() : const NoWebViewProxy(); +} + +/// Android: androidx.webkit ProxyController - 앱의 모든 웹 뷰에 한꺼번에 걸린다 +class AndroidWebViewProxy implements WebViewProxyTarget { + const AndroidWebViewProxy(); + @override + bool get active => true; + + @override + Future set(String url, {required bool removeImplicitRules}) async { + final pc = ProxyController.instance(); + if (url.isEmpty) { + await pc.clearProxyOverride(); + } else { + await pc.setProxyOverride( + settings: ProxySettings(proxyRules: [ProxyRule(url: url)], removeImplicitRules: removeImplicitRules)); + } + } +} + +/// 앱 전체 웹 뷰 프록시가 없는 기기 (Windows) +class NoWebViewProxy implements WebViewProxyTarget { + const NoWebViewProxy(); + @override + bool get active => false; + @override + Future set(String url, {required bool removeImplicitRules}) async {} +} diff --git a/lib/core/archive_ops.dart b/lib/core/archive_ops.dart index 6dbe22a..b2ff067 100644 --- a/lib/core/archive_ops.dart +++ b/lib/core/archive_ops.dart @@ -7,6 +7,7 @@ import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'file_ops.dart' show NameConflict, nameProblem; +import '../services/platform_info.dart'; /// 86: 압축 종류 (확장자로) enum ArchiveKind { zip, sevenZip, rar } @@ -82,7 +83,7 @@ class ExtractPlan { /// 목록 [entries] 에서 풀 것을 고른다. [names] 가 있으면 그 이름 (폴더면 그 안 모두) 만 /// 15 지적 · 관리자 판단: 리눅스 · 맥에서 만든 압축의 이름이 Windows 에서 쓸 수 없으면 (78 의 nameProblem) 미리 걸러 건너뛴다 ExtractPlan planExtract(List entries, {List names = const [], bool? windowsRules}) { - final win = windowsRules ?? Platform.isWindows; + final win = windowsRules ?? PlatformInfo.host.isWindows; bool wanted(String n) => names.isEmpty || names.any((x) => n == x || n.startsWith(x.endsWith('/') ? x : '$x/')); final files = []; final unsafe = <(String, String)>[]; diff --git a/lib/core/rsync_launch.dart b/lib/core/rsync_launch.dart index 79d7852..a05e09f 100644 --- a/lib/core/rsync_launch.dart +++ b/lib/core/rsync_launch.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; +import '../services/platform_info.dart'; /// 관리자 지시 (10/20, 28 재현 - P0 데이터): Windows 의 rsync (MSYS2 · Cygwin 런타임) 는 Windows 프로그램이 넘긴 명령 줄을 /// 스스로 다시 읽는다 - ' 를 따옴표로 짝지어 (--exclude=it's.txt --exclude=Bob's.txt 가 한 인수로 합쳐져 거르기가 사라지고 @@ -32,7 +33,7 @@ List msysUnsafeArgs(List args) => [for (final a in args) if (a.c /// [args] 를 rsync 에 그대로 넘기도록 준비한다 (Windows 만 응답 파일 · MSYS=noglob, 그 밖은 그대로). 끝나면 [RsyncLaunch.dispose] Future prepareRsyncLaunch(List args, {bool? windows}) async { - if (!(windows ?? Platform.isWindows)) return RsyncLaunch._(args, null, null, null); + if (!(windows ?? PlatformInfo.host.isWindows)) return RsyncLaunch._(args, null, null, null); final bad = msysUnsafeArgs(args); if (bad.isNotEmpty) { throw StateError(trf('큰따옴표 (") 가 든 인수는 Windows 의 rsync 에 그대로 넘길 수 없어 실행하지 않았습니다: {0}', [bad.join(' · ')])); diff --git a/lib/platform/common/nllb_translator.dart b/lib/platform/common/nllb_translator.dart index a333950..2be0455 100644 --- a/lib/platform/common/nllb_translator.dart +++ b/lib/platform/common/nllb_translator.dart @@ -11,6 +11,7 @@ import 'package:path/path.dart' as p; import '../../core/nllb_tokenizer.dart'; import '../../services/ai_services.dart'; +import '../../services/platform_info.dart'; import 'onnx_external.dart'; import 'ort_tensors.dart'; import '../../l10n/tr.dart'; @@ -29,7 +30,10 @@ class NllbTranslator implements Translator { /// 한 번에 번역할 줄 수 final int batchSize; - NllbTranslator({this.batchSize = 8}); + /// 메모리를 아끼는 방식 (모델을 하나씩 · 외부 데이터로 연다). 처음 값 = 이 기기 ([PlatformInfo.lowMemory]) + final bool lowMemory; + + NllbTranslator({this.batchSize = 8, bool? lowMemory}) : lowMemory = lowMemory ?? PlatformInfo.host.lowMemory; Isolate? _isolate; SendPort? _toWorker; @@ -76,7 +80,7 @@ class NllbTranslator implements Translator { final threads = math.max(1, Platform.numberOfProcessors ~/ 2); _isolate = await Isolate.spawn( _workerMain, - [rp.sendPort, modelDir, threads, _cancelFlag.address, batchSize], + [rp.sendPort, modelDir, threads, _cancelFlag.address, batchSize, lowMemory], debugName: 'nllb', ); try { @@ -143,13 +147,14 @@ void _workerMain(List args) { final threads = args[2] as int; final cancel = ffi.Pointer.fromAddress(args[3] as int); final batch = args[4] as int; + final low = args[5] as bool; final inbox = ReceivePort(); main.send({'type': 'port', 'port': inbox.sendPort}); _NllbEngine engine; try { - engine = _NllbEngine.load(modelDir, threads); + engine = _NllbEngine.load(modelDir, threads, low); } catch (e) { main.send({'type': 'fatal', 'error': '$e'}); inbox.close(); @@ -204,12 +209,10 @@ class _NllbEngine { _NllbEngine(this.tok, this.dir, this.threads, this.lowMemory); - factory _NllbEngine.load(String dir, int threads) { + factory _NllbEngine.load(String dir, int threads, bool low) { OrtEnv.instance.init(); final tok = NllbTokenizer.fromJson( File(p.join(dir, 'tokenizer.json')).readAsStringSync()); - // JJ_NLLB_LOW_MEMORY=1: PC 에서 Android 방식을 시험할 때 - final low = Platform.isAndroid || Platform.environment['JJ_NLLB_LOW_MEMORY'] == '1'; final e = _NllbEngine(tok, dir, threads, low); // 넉넉한 기기는 미리 둘 다 연다 (모델 파일이 깨졌으면 여기서 알 수 있게) if (!e.lowMemory) { diff --git a/lib/services/platform_info.dart b/lib/services/platform_info.dart new file mode 100644 index 0000000..010adbe --- /dev/null +++ b/lib/services/platform_info.dart @@ -0,0 +1,22 @@ +/// 약속: 앱이 도는 기기가 무엇인지 (Android · Windows) 와 메모리를 아껴야 하는지 - 한곳에서만 정한다. +/// 입력 없음 ([PlatformInfo.host] 는 이 기기를 그대로 읽는다), 출력 = 세 값, 오류 없음. +/// 기기에 따라 다르게 도는 곳은 이것을 받아 (생성자 · 조립) 쓴다 - "Android 인 척" 하는 스위치를 따로 두지 않는다 (M1) +library; + +import 'dart:io'; + +class PlatformInfo { + final bool isAndroid; + final bool isWindows; + + /// 메모리를 아끼는 방식 (모델을 나눠 열기 등). 처음 값은 Android + final bool lowMemory; + + const PlatformInfo({required this.isAndroid, required this.isWindows, bool? lowMemory}) : lowMemory = lowMemory ?? isAndroid; + + /// 이 기기 + static final PlatformInfo host = PlatformInfo(isAndroid: Platform.isAndroid, isWindows: Platform.isWindows); + + 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 4cea341..ce54207 100644 --- a/lib/services/platform_services.dart +++ b/lib/services/platform_services.dart @@ -27,6 +27,7 @@ import 'downloader.dart'; import 'media_player.dart'; import 'media_tool.dart'; import 'model_store.dart'; +import 'platform_info.dart'; import 'preview_player.dart'; import 'storage_service.dart'; import 'subtitle_provider.dart'; @@ -63,6 +64,9 @@ class PlatformServices { /// PC 의 CPU · 메모리 사용량 (없으면 표시 숨김) final SystemUsage? usage; + /// 이 기기 (Android · Windows · 메모리) - 기기마다 다르게 도는 곳은 이것을 본다 (M1, 처음 값 = 이 기기) + final PlatformInfo platform; + /// 인터넷 자막 사이트 (설정값을 읽는 함수를 받아 만든다) final List Function(AppSettings Function() settings)? createSubtitleProviders; @@ -79,7 +83,9 @@ class PlatformServices { this.usage, ModelStore? models, AppShell? shell, + PlatformInfo? platform, }) : models = models ?? ModelStore(), + platform = platform ?? PlatformInfo.host, shell = shell ?? NoopShell(); factory PlatformServices.create() { diff --git a/lib/services/secret_store.dart b/lib/services/secret_store.dart index 82526c4..bc652df 100644 --- a/lib/services/secret_store.dart +++ b/lib/services/secret_store.dart @@ -165,24 +165,14 @@ Uint8List _dpapi(Uint8List data, {required bool protect}) { class MemorySecretStore implements SecretStore { final Map values = {}; - /// 시험: 읽기 · 쓰기 실패 흉내 - bool failRead = false; - bool failWrite = false; - @override String? lostCopy; @override - Future> readAll() async { - if (failRead) throw StateError('secret store unavailable'); - return Map.of(values); - } + Future> readAll() async => Map.of(values); @override - Future write(String key, String value) async { - if (failWrite) throw StateError('secret store write failed'); - values[key] = value; - } + Future write(String key, String value) async => values[key] = value; @override Future delete(String key) async => values.remove(key); diff --git a/lib/ui/exit_guard.dart b/lib/ui/exit_guard.dart index 132cec8..477e5ce 100644 --- a/lib/ui/exit_guard.dart +++ b/lib/ui/exit_guard.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -44,10 +42,6 @@ class ExitGuard extends StatelessWidget { static const _ch = MethodChannel('jj_mkvmaker/android'); - /// 시험용: Android 가 아니어도 Android 처럼 - @visibleForTesting - static bool forceAndroid = false; - /// 돌고 있는 작업 (글로) List _running() { final live = LiveSync.instance; @@ -61,7 +55,7 @@ class ExitGuard extends StatelessWidget { ]; } - bool get _guard => (Platform.isAndroid || forceAndroid) && !c.settings.run.inBackground && _running().isNotEmpty; + bool get _guard => c.services.platform.isAndroid && !c.settings.run.inBackground && _running().isNotEmpty; @override Widget build(BuildContext context) => ListenableBuilder( diff --git a/lib/ui/file_error.dart b/lib/ui/file_error.dart index 9a93f3c..15013f9 100644 --- a/lib/ui/file_error.dart +++ b/lib/ui/file_error.dart @@ -2,6 +2,7 @@ import 'dart:io'; import '../core/secret_gate.dart'; import '../l10n/tr.dart'; +import '../services/platform_info.dart'; /// 파일 · WebDAV 오류를 사람이 읽을 말로 (제목, 할 일). 원문 예외는 알아볼 수 없을 때만 그대로. /// 파일 탐색기 · 폴더 고르기 · 그 밖에 WebDAV 를 읽는 화면이 함께 쓴다 (12 · 134). @@ -10,7 +11,7 @@ const androidRestrictedError = 'ANDROID_RESTRICTED_FOLDER'; /// 50: Android 11 부터 어떤 파일 앱도 다른 앱의 데이터 폴더 (Android/data · Android/obb) 를 볼 수 없다 bool isAndroidRestricted(String path, {bool? android}) => - (android ?? Platform.isAndroid) && RegExp(r'/Android/(data|obb)(/|$)', caseSensitive: false).hasMatch(path); + (android ?? PlatformInfo.host.isAndroid) && RegExp(r'/Android/(data|obb)(/|$)', caseSensitive: false).hasMatch(path); (String, String) explainFileError(String e, {required bool dav, bool noPassword = false}) { if (e == androidRestrictedError) { diff --git a/test/exit_guard_test.dart b/test/exit_guard_test.dart index 63f6712..119e655 100644 --- a/test/exit_guard_test.dart +++ b/test/exit_guard_test.dart @@ -3,15 +3,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.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_info.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/exit_guard.dart'; void main() { - setUp(() => ExitGuard.forceAndroid = true); - tearDown(() => ExitGuard.forceAndroid = false); - Future pump(WidgetTester tester, {required bool background, required bool busy}) async { - final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final c = AppController(PlatformServices( + mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), platform: PlatformInfo.android)); c.settings.run.inBackground = background; c.busy = busy; c.currentJob = busy ? 'MKV 만들기 3/10' : null; diff --git a/test/master_store_unreadable_test.dart b/test/master_store_unreadable_test.dart index 6ec1229..15d6886 100644 --- a/test/master_store_unreadable_test.dart +++ b/test/master_store_unreadable_test.dart @@ -1,32 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/master_lock.dart'; -import 'package:jj_mkvmaker/services/secret_store.dart'; -/// 읽기가 될 때와 안 될 때를 바꿀 수 있는 안전 저장소 (기기 저장소가 잠깐 잠긴 때처럼) -class _FlakyStore implements SecretStore { - final Map values = {}; - bool readable = true; - @override - String? lostCopy; - @override - Future> readAll() async { - if (!readable) throw StateError('안전 저장소를 읽을 수 없음'); - return Map.of(values); - } - - @override - Future write(String key, String value) async => values[key] = value; - @override - Future delete(String key) async => values.remove(key); -} +import 'support/flaky_secret_store.dart'; /// M0 약속: 안전 저장소를 읽지 못하면 마스터 잠금은 '알 수 없음' - 문을 열지 않고 (한 번 다시 읽어 봄), 마스터 · 최상 · 묻는 때를 바꾸지 않는다. /// 다시 읽히면 평소대로 (마스터가 있으면 묻는다) void main() { - late _FlakyStore store; + late FlakySecretStore store; late int asked; setUp(() async { - store = _FlakyStore(); + store = FlakySecretStore(); // 마스터가 정해진 기기 final setup = MasterLock(store); await setup.load(); @@ -40,7 +23,7 @@ void main() { tearDown(() => MasterLock.prompt = null); test('읽지 못하면 문을 열지 않는다 (예전: 마스터가 없는 것으로 보여 열렸다)', () async { - store.readable = false; + store.failRead = true; final lock = MasterLock(store); await lock.load(); // 예외를 내지 않는다 - 앱은 켜진다 expect([lock.storeUnreadable, lock.hasMaster], [true, false]); @@ -51,7 +34,7 @@ void main() { test('알 수 없는 동안 마스터 · 최상 · 묻는 때를 바꾸지 않는다 (진짜 마스터를 덮어쓰지 않게)', () async { final before = Map.of(store.values); - store.readable = false; + store.failRead = true; final lock = MasterLock(store); await lock.load(); await expectLater(lock.setMaster('new-pass'), throwsA(isA())); @@ -62,20 +45,20 @@ void main() { }); test('다시 읽히면 평소대로: 문을 열 때 한 번 다시 읽고, 마스터가 있으면 묻는다', () async { - store.readable = false; + store.failRead = true; final lock = MasterLock(store); await lock.load(); - store.readable = true; + store.failRead = false; expect(await lock.ensure(), isTrue); expect([lock.storeUnreadable, lock.hasMaster, asked], [false, true, 1]); }); test('reload: 읽혔는지 돌려준다', () async { - store.readable = false; + store.failRead = true; final lock = MasterLock(store); await lock.load(); expect(await lock.reload(), isFalse); - store.readable = true; + store.failRead = false; expect(await lock.reload(), isTrue); expect(lock.hasMaster, isTrue); }); diff --git a/test/platform_info_contract_test.dart b/test/platform_info_contract_test.dart new file mode 100644 index 0000000..cf19e02 --- /dev/null +++ b/test/platform_info_contract_test.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/web_view_proxy_target.dart'; +import 'package:jj_mkvmaker/services/platform_info.dart'; + +/// M1 약속: 기기 판단은 PlatformInfo 한곳 - 이 기기는 그대로 읽고, 메모리 아끼기 처음 값은 Android, +/// 앱 전체 웹 뷰 프록시는 Android 에서만 (Windows 는 웹 뷰마다 따로) +void main() { + test('host 는 이 기기 그대로', () { + expect([PlatformInfo.host.isAndroid, PlatformInfo.host.isWindows], [Platform.isAndroid, Platform.isWindows]); + }); + + test('lowMemory 처음 값 = Android, 준 값은 그대로', () { + expect([PlatformInfo.android.lowMemory, PlatformInfo.windows.lowMemory], [true, false]); + expect(const PlatformInfo(isAndroid: false, isWindows: true, lowMemory: true).lowMemory, isTrue); + expect(const PlatformInfo(isAndroid: true, isWindows: false, lowMemory: false).lowMemory, isFalse); + }); + + test('웹 뷰 프록시: Android 만 쓰고, 아닌 기기는 걸어도 아무 일 없음', () async { + expect(WebViewProxyTarget.forPlatform(PlatformInfo.android), isA()); + final none = WebViewProxyTarget.forPlatform(PlatformInfo.windows); + expect(none.active, isFalse); + await none.set('http://127.0.0.1:9', removeImplicitRules: true); + }); +} diff --git a/test/secret_issue_test.dart b/test/secret_issue_test.dart index a4ce601..8d3a9a2 100644 --- a/test/secret_issue_test.dart +++ b/test/secret_issue_test.dart @@ -8,16 +8,17 @@ import 'package:jj_mkvmaker/core/webdav.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; -import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:jj_mkvmaker/ui/secret_issue.dart'; import 'package:path/path.dart' as p; +import 'support/flaky_secret_store.dart'; + /// 40-1 · 40-3: 비밀번호를 저장하지 못했으면 화면 위에 분명히 알리고 [다시 시도] 로 저장한다 void main() { testWidgets('저장 실패 알림 → [다시 시도] 로 저장되면 알림이 사라진다', (tester) async { final dir = Directory.systemTemp.createTempSync('jj_secret_ui_'); addTearDown(() => dir.deleteSync(recursive: true)); - final secrets = MemorySecretStore()..failWrite = true; + final secrets = FlakySecretStore()..failWrite = true; // 저장 순서용 Future 가 시험의 가짜 시간 영역에 묶이지 않게 실제 영역에서 만든다 late SettingsStore store; late AppController c; @@ -51,7 +52,7 @@ void main() { testWidgets('90 · 91: 예전 비밀번호로 돌아간다고 알림 · [닫기] 하면 같은 상황에서는 다시 안 뜸 · [다시 시도] 실패도 알림', (tester) async { final dir = Directory.systemTemp.createTempSync('jj_secret_ui2_'); addTearDown(() => dir.deleteSync(recursive: true)); - final secrets = MemorySecretStore()..values['dav.pw.d1'] = 'old'; + final secrets = FlakySecretStore()..values['dav.pw.d1'] = 'old'; late SettingsStore store; late AppController c; await tester.runAsync(() async { diff --git a/test/secret_store_test.dart b/test/secret_store_test.dart index bb821f1..dcf1d00 100644 --- a/test/secret_store_test.dart +++ b/test/secret_store_test.dart @@ -5,18 +5,19 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/app/version_snapshot.dart'; import 'package:jj_mkvmaker/core/webdav.dart'; -import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:path/path.dart' as p; +import 'support/flaky_secret_store.dart'; + /// 40 · 54: 비밀번호는 설정 파일에 평문으로 쓰지 않고 안전 저장소에. 한 번 넣으면 업데이트 · 예전 판 · 되살리기 뒤에도 남는다 void main() { late Directory dir; late String file; - late MemorySecretStore secrets; + late FlakySecretStore secrets; setUp(() { dir = Directory.systemTemp.createTempSync('jj_secret_'); file = p.join(dir.path, 'settings.json'); - secrets = MemorySecretStore(); + secrets = FlakySecretStore(); }); tearDown(() => dir.deleteSync(recursive: true)); diff --git a/test/support/flaky_secret_store.dart b/test/support/flaky_secret_store.dart new file mode 100644 index 0000000..6b9e15e --- /dev/null +++ b/test/support/flaky_secret_store.dart @@ -0,0 +1,27 @@ +import 'package:jj_mkvmaker/services/secret_store.dart'; + +/// 읽기 · 쓰기가 될 때와 안 될 때를 바꿀 수 있는 안전 저장소 (기기 저장소가 잠깐 잠긴 때처럼). +/// 시험에만 있다 - lib 의 저장소에는 실패 흉내 플래그를 두지 않는다 (M1) +class FlakySecretStore implements SecretStore { + final Map values = {}; + bool failRead = false; + bool failWrite = false; + + @override + String? lostCopy; + + @override + Future> readAll() async { + if (failRead) throw StateError('secret store unavailable'); + return Map.of(values); + } + + @override + Future write(String key, String value) async { + if (failWrite) throw StateError('secret store write failed'); + values[key] = value; + } + + @override + Future delete(String key) async => values.remove(key); +} diff --git a/test/web_proxy_gate_test.dart b/test/web_proxy_gate_test.dart index bf30af4..d9b69aa 100644 --- a/test/web_proxy_gate_test.dart +++ b/test/web_proxy_gate_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/app/web_proxy.dart'; +import 'package:jj_mkvmaker/app/web_view_proxy_target.dart'; import 'package:jj_mkvmaker/core/dns_resolver.dart'; /// Android 웹 뷰 프록시 문 (관리자 결정 10/11 A+B): 인증 없이, 실행마다 고른 루프백 주소에, @@ -12,18 +13,16 @@ import 'package:jj_mkvmaker/core/dns_resolver.dart'; void main() { final calls = <(String, bool)>[]; setUp(() async { - await WebProxy.debugReset(); - WebProxy.debugAndroid = true; - WebProxy.debugSetProxy = (url, tor) async => calls.add((url, tor)); + await WebProxy.reset(); + WebProxy.target = _RecordingProxy(calls); WebProxy.torOpen.value = false; NetGuard.instance.config = () => const DnsConfig(mode: 'custom', primary: '8.8.8.8', secondary: '8.8.4.4', dohUrl: ''); calls.clear(); }); tearDown(() async { WebProxy.torOpen.value = false; - await WebProxy.debugReset(); - WebProxy.debugAndroid = false; - WebProxy.debugSetProxy = null; + await WebProxy.reset(); + WebProxy.target = const NoWebViewProxy(); NetGuard.instance.config = () => const DnsConfig.system(); }); @@ -126,15 +125,15 @@ void main() { final nav = GlobalKey(); await t.pumpWidget(MaterialApp(navigatorKey: nav, home: const _WebScreen())); await t.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); - expect(WebProxy.shownForTest, 1); + expect(WebProxy.shown, 1); nav.currentState!.push(MaterialPageRoute(builder: (_) => const Text('other'))); await t.pumpAndSettle(); - expect(WebProxy.shownForTest, 0); + expect(WebProxy.shown, 0); nav.currentState!.pop(); await t.pumpAndSettle(); - expect(WebProxy.shownForTest, 1); + expect(WebProxy.shown, 1); await t.pumpWidget(const SizedBox()); - expect(WebProxy.shownForTest, 0); + expect(WebProxy.shown, 0); await t.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); }); } @@ -149,3 +148,13 @@ class _WebScreenState extends State<_WebScreen> with WebProxyVisibility { @override Widget build(BuildContext context) => const Text('web'); } + +/// Android 처럼 앱 전체 웹 뷰 프록시를 쓰는 기기 - 거는 대신 기록 (시험 파일 안의 것, lib 에 흉내 장치 없음) +class _RecordingProxy implements WebViewProxyTarget { + final List<(String, bool)> calls; + _RecordingProxy(this.calls); + @override + bool get active => true; + @override + Future set(String url, {required bool removeImplicitRules}) async => calls.add((url, removeImplicitRules)); +}