diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3394ead..0d3b3b9 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -1568,6 +1568,7 @@ "웹 브라우저의 시작 · 🏠 주소": "Start · 🏠 page of the web browser", "웹 페이지 자동 번역": "Translate web pages automatically", "웹 페이지를 번역하지 못했습니다: {0}": "Could not translate the web page: {0}", + "웹 페이지를 열지 못함 ({0}): {1}": "Couldn't open web page ({0}): {1}", "위 ← 아래": "Top ← bottom", "위 → 아래": "Top → bottom", "위 ⇄ 아래": "Top ⇄ bottom", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index b43e046..2b930bb 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -1568,6 +1568,7 @@ "웹 브라우저의 시작 · 🏠 주소": "ウェブブラウザーの開始・🏠 ページ", "웹 페이지 자동 번역": "ウェブページを自動翻訳", "웹 페이지를 번역하지 못했습니다: {0}": "ウェブページを翻訳できませんでした: {0}", + "웹 페이지를 열지 못함 ({0}): {1}": "Web ページを開けませんでした ({0}): {1}", "위 ← 아래": "上 ← 下", "위 → 아래": "上 → 下", "위 ⇄ 아래": "上 ⇄ 下", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 1140ad5..bb26336 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -1568,6 +1568,7 @@ "웹 브라우저의 시작 · 🏠 주소": "网页浏览器的起始 · 🏠 页面", "웹 페이지 자동 번역": "自动翻译网页", "웹 페이지를 번역하지 못했습니다: {0}": "无法翻译网页: {0}", + "웹 페이지를 열지 못함 ({0}): {1}": "无法打开网页 ({0}):{1}", "위 ← 아래": "上 ← 下", "위 → 아래": "上 → 下", "위 ⇄ 아래": "上 ⇄ 下", diff --git a/integration_test/dns_block_real_test.dart b/integration_test/dns_block_real_test.dart new file mode 100644 index 0000000..58c7485 --- /dev/null +++ b/integration_test/dns_block_real_test.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; +import 'package:jj_mkvmaker/app/net_guard.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/app_actions.dart'; +import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'package:path/path.dart' as p; + +/// 250 실제 엔진 (Windows Edge WebView2): DNS 강제 서버에 닿지 않으면 (TEST-NET 192.0.2.1) 엔진의 오류 화면 +/// (net::ERR_TUNNEL_CONNECTION_FAILED) 대신 앱의 덮개 "DNS 서버 (…) 에 닿지 않아 … 을(를) 열 수 없습니다" 가 뜨는지. +/// 데이터는 JJ_MKVMAKER_DATA (없으면 임시 폴더) 에만 - 사용자 앱 데이터를 건드리지 않는다. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async { + final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_dns_block_').path; + Directory(data).createSync(recursive: true); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..webViewDataDir = p.join(data, 'webview') + ..dnsMode = 'custom' + ..dnsPrimary = '192.0.2.1' + ..dnsSecondary = ''; + final logs = []; + final g = NetGuard.instance + ..config = (() => c.settings.dnsConfig) + ..log = logs.add; + await tester.runAsync(g.ensure); + Future wait(int ms) async { + for (var i = 0; i < ms ~/ 200; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 200))); + await tester.pump(); + } + } + + final bm = BookmarksController(p.join(data, 'bm.json')); + await tester.pumpWidget(MaterialApp( + builder: (context, child) => AppScope(controller: c, bookmarks: bm, child: child!), + home: BrowserPage(c: c, bookmarks: bm, initialUrl: 'https://example.org/'), + )); + final cover = find.textContaining('에 닿지 않아 example.org 을(를) 열 수 없습니다'); + for (var i = 0; i < 90 && cover.evaluate().isEmpty; i++) { + await wait(400); + } + // ignore: avoid_print + print('RESULT 덮개: ${cover.evaluate().isNotEmpty} / 알림: ${g.failure.value?.server} / 기록: ${c.logs.where((l) => l.contains('웹 페이지')).toList()}'); + expect(cover, findsOneWidget); + expect(find.text('시스템 DNS 로 바꾸기'), findsWidgets); + expect(find.text('다시 시도'), findsOneWidget); + + // [다시 시도]: 덮개를 걷고 다시 읽는다 → 여전히 닿지 않으니 다시 덮개 + await tester.tap(find.text('다시 시도')); + await tester.pump(); + expect(cover, findsNothing); + for (var i = 0; i < 90 && cover.evaluate().isEmpty; i++) { + await wait(400); + } + // ignore: avoid_print + print('RESULT 다시 시도 뒤 덮개: ${cover.evaluate().isNotEmpty}'); + expect(cover, findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + await wait(1000); + g.config = () => c.settings.dnsConfig; + }); +} diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 7a3def8..6a9ef07 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -77,6 +77,9 @@ class BrowserHost { final void Function(bool hasVideo) onVideo; final void Function(bool back, bool forward) onHistory; final void Function(WebNav nav) attach; + + /// 250: 본문 (main frame) 을 읽지 못함 - 엔진이 알려 준 주소와 오류 (예: net::ERR_TUNNEL_CONNECTION_FAILED) + final void Function(String url, String error)? onMainFrameError; const BrowserHost({ required this.onUrl, required this.onTitle, @@ -84,6 +87,7 @@ class BrowserHost { required this.onVideo, required this.onHistory, required this.attach, + this.onMainFrameError, }); } @@ -161,17 +165,26 @@ class _BrowserPageState extends State with RouteAware { (String, String)? _dnsBlocked; DateTime _loadStartAt = DateTime.now(); - void _checkDnsBlocked() { - final host = Uri.tryParse(_url)?.host ?? ''; - final server = host.isEmpty ? null : NetGuard.instance.dnsUnreachableFor(host, _loadStartAt); - final next = server == null ? null : (host, server); + /// [error]: 엔진이 알려 준 본문 오류. 프록시 터널 실패이고 DNS 서버에 지금 닿지 않는 중이면, 그 이름의 실패 기록이 없어도 + /// (Chromium 이 실패한 프록시를 기억해 다시 묻지 않는 때 등) DNS 때문으로 본다. + /// 덮개는 여기서 걷지 않는다 (새로 읽기 시작 · [다시 시도] 에서) - Android 는 오류 뒤에 읽기 끝이 또 온다. + void _checkDnsBlocked({String? host, String? error}) { + final h = host ?? Uri.tryParse(_url)?.host ?? ''; + if (h.isEmpty) return; + final g = NetGuard.instance; + var server = g.dnsUnreachableFor(h, _loadStartAt); + final f = g.failure.value; + if (server == null && f != null && error != null && (error.contains('TUNNEL') || error.contains('PROXY'))) server = f.server; + if (server == null) return; + final next = (h, server); if (next != _dnsBlocked) setState(() => _dnsBlocked = next); } - /// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]) + /// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]). + /// 트리 모양은 늘 같게 (Stack 첫 칸이 웹 뷰) - 덮개를 띄울 때 모양이 바뀌면 웹 뷰가 새로 만들어져 페이지를 다시 읽고, + /// 다시 읽기가 시작되며 덮개가 걷혔다 (931 노트 10+ 에서 덮개가 보이지 않던 것, Windows 실제 시험에서 재현) Widget _withDnsBlock(Widget view) { final b = _dnsBlocked; - if (b == null) return view; void retry() { setState(() => _dnsBlocked = null); _nav?.reload(); @@ -179,6 +192,7 @@ class _BrowserPageState extends State with RouteAware { return Stack(children: [ Positioned.fill(child: view), + if (b != null) Positioned.fill( child: ColoredBox( color: JjColors.bg, @@ -259,6 +273,15 @@ class _BrowserPageState extends State with RouteAware { }); }, attach: (n) => _nav = BrowserPage.debugNav = n, + // 250: 엔진의 본문 오류 (Edge · Android). 읽기 끝 (진행률) 만으로는 Android 에서 덮개가 뜨지 않았다 (931, 노트 10+) + onMainFrameError: (u, e) { + if (!mounted) return; + final host = Uri.tryParse(u)?.host ?? ''; + if (NetGuard.instance.forcing || NetGuard.instance.failure.value != null) { + widget.c.note(trf('웹 페이지를 열지 못함 ({0}): {1}', [host, e])); + } + _checkDnsBlocked(host: host, error: e); + }, ); BookmarksController get bm => widget.bookmarks; @@ -1080,6 +1103,10 @@ class _EdgeViewState extends State<_EdgeView> with WebProxyVisibility { }, onTitleChanged: (c, t) => h.onTitle(t ?? ''), onProgressChanged: (c, p) => h.onProgress(p / 100), + // 250: 본문을 읽지 못함 (DNS 강제 프록시가 이름을 찾지 못하면 net::ERR_TUNNEL_CONNECTION_FAILED) + onReceivedError: (c, req, err) { + if (req.isForMainFrame ?? true) h.onMainFrameError?.call(req.url.toString(), err.description); + }, ); }, ); diff --git a/test/browser_test.dart b/test/browser_test.dart index 741213b..627e5ff 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -79,6 +79,25 @@ class _Nav implements WebNav { Future setJavaScript(bool on) async => javaScript.add(on); } +/// 웹 뷰 대신: 몇 번 새로 만들어졌는지 센다 (250 - 덮개를 띄워도 웹 뷰를 다시 만들지 않아야 함) +class _Probe extends StatefulWidget { + static int created = 0; + const _Probe(); + @override + State<_Probe> createState() => _ProbeState(); +} + +class _ProbeState extends State<_Probe> { + @override + void initState() { + super.initState(); + _Probe.created++; + } + + @override + Widget build(BuildContext context) => const ColoredBox(color: Colors.white); +} + void main() { group('즐겨찾기 트리', () { test('추가 · 찾기 · 이동 · 삭제', () { @@ -325,7 +344,7 @@ void main() { viewBuilder: (h, url) { host = h; h.attach(nav); - return const ColoredBox(color: Colors.white); + return const _Probe(); }, ), )); @@ -335,14 +354,18 @@ void main() { host.onProgress(0.1); host.onUrl('https://blocked.example/'); g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1')); + _Probe.created = 0; host.onProgress(1); await tester.pump(); expect(find.text(msg), findsOneWidget); + // 덮개를 띄워도 웹 뷰는 그대로 (새로 만들면 페이지를 다시 읽어 덮개가 걷혔다 - 931) + expect(_Probe.created, 0); // [다시 시도]: 덮개를 걷고 다시 읽기 await tester.tap(find.text('다시 시도')); await tester.pump(); expect(find.text(msg), findsNothing); expect(nav.reloads, 1); + expect(_Probe.created, 0); // 다른 이름은 막지 않는다 · 서버가 다시 답한 뒤에는 지난 실패로 막지 않는다 host.onProgress(0.1); host.onUrl('https://ok.example/'); @@ -366,6 +389,24 @@ void main() { expect(c.settings.dnsMode, 'system'); expect(find.text(msg), findsNothing); expect(nav.reloads, 2); + + // 931 (노트 10+): 엔진이 본문 오류 (ERR_TUNNEL_CONNECTION_FAILED) 를 알림 - 그 이름의 실패 기록이 없어도 DNS 가 닿지 않는 중이면 덮개. + // 오류 뒤에 오는 읽기 끝이 덮개를 지우지 않는다 + g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now()); + host.onProgress(0.1); + host.onUrl('https://example.org/'); + host.onMainFrameError!('https://example.org/', 'net::ERR_TUNNEL_CONNECTION_FAILED'); + host.onProgress(1); + await tester.pump(); + expect(find.text('DNS 서버 (192.0.2.1) 에 닿지 않아 example.org 을(를) 열 수 없습니다.'), findsOneWidget); + // DNS 와 상관없는 오류 (이름 기록도 없음) 는 엔진 화면 그대로 + await tester.tap(find.text('다시 시도')); + await tester.pump(); + host.onProgress(0.1); + host.onMainFrameError!('https://example.org/', 'net::ERR_CONNECTION_REFUSED'); + host.onProgress(1); + await tester.pump(); + expect(find.textContaining('에 닿지 않아'), findsNothing); }); testWidgets('Android 뒤로 키: 앞 웹 페이지가 있으면 웹 뒤로, 없으면 화면 닫기 · 위쪽 ← 는 늘 닫기', (tester) async {