From 6c61b8b82d0e7b7385004b5cdaa020277a4d1071 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:49:18 +0900 Subject: [PATCH] =?UTF-8?q?250=20=EC=9B=B9=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80:=20DNS=20=EC=84=9C=EB=B2=84=EC=97=90=20=EB=8B=BF?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=95=84=20=EB=AA=BB=20=EC=97=B0=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EB=8A=94=20=EC=9D=B4=EC=9C=A0=20?= =?UTF-8?q?=C2=B7=20[=EC=8B=9C=EC=8A=A4=ED=85=9C=20DNS=20=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B8=B0]=20[=EB=8B=A4=EC=8B=9C=20=EC=8B=9C?= =?UTF-8?q?=EB=8F=84]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 지시 (10/12, a4 자유 점검): 엔진 기본 오류 "net::ERR_TUNNEL_CONNECTION_FAILED" 만 보여 안내를 닫은 뒤에는 이유를 알 수 없었다. 앱 안 프록시가 기록한 실패 (NetGuard.dnsUnreachableFor: 이 페이지를 읽기 시작한 뒤 그 이름이 DNS 서버에 닿지 않음 · 아직 다시 답하지 않음) 로 판단해, 읽기가 끝나면 웹 뷰 위에 앱이 그린다. Edge · Chrome 엔진 · Android 모두 같은 길 (엔진마다 다른 오류 콜백에 기대지 않음). 새로 읽기 시작하면 걷힌다. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 ++ assets/l10n/ja.json | 2 ++ assets/l10n/zh-Hans.json | 2 ++ lib/app/net_guard.dart | 12 +++++++ lib/ui/browser_page.dart | 74 ++++++++++++++++++++++++++++++++++++++-- test/browser_test.dart | 68 +++++++++++++++++++++++++++++++++++- 6 files changed, 156 insertions(+), 4 deletions(-) diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 75daf2b..2550012 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -153,6 +153,7 @@ "DNS 서버": "DNS server", "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "Cannot reach the DNS server ({0}): {1}", "DNS 서버 ({0}) 에 닿지 않아 {1}": "Couldn't reach the DNS server ({0}), so {1}", + "DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.": "Can't open {1} because the DNS server ({0}) can't be reached.", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "Couldn't reach the DNS server ({0}), so the address couldn't be found. Check the internet connection or the server address in Settings > Network · DNS.", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "Could not look up the address because the DNS server ({0}) is unreachable: {1}", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "Can't reach DNS server ({0}) (since {1})", @@ -1691,6 +1692,7 @@ "인증서 확인 안 함": "Don't verify certificate", "인코딩": "Encoding", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "Check your internet connection, then try again in Settings > Check required programs.\n\n{0}", + "인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "Check your internet connection or the server address in Settings > Network · DNS.", "인터넷 자막 (OpenSubtitles.com)": "Internet subtitles (OpenSubtitles.com)", "인터넷 자막 찾기": "Find subtitles online", "인터넷 자막 찾기{0}": "Find subtitles online{0}", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 47df35d..6271f20 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -153,6 +153,7 @@ "DNS 서버": "DNS サーバー", "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "DNS サーバー ({0}) に届きません: {1}", "DNS 서버 ({0}) 에 닿지 않아 {1}": "DNS サーバー ({0}) に接続できないため、{1}", + "DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.": "DNS サーバー ({0}) に接続できないため、{1} を開けません。", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "DNS サーバー ({0}) に届かず、アドレスを見つけられませんでした。インターネット接続か、環境設定 > ネットワーク · DNS のサーバーアドレスを確認してください。", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "DNS サーバー ({0}) に届かず、アドレスを解決できませんでした: {1}", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "DNS サーバー ({0}) に接続できません ({1} から)", @@ -1691,6 +1692,7 @@ "인증서 확인 안 함": "証明書を検証しない", "인코딩": "エンコード", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "インターネット接続を確認してから、環境設定 > 必須プログラムの点検 でもう一度試してください。\n\n{0}", + "인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "インターネット接続、または 設定 > ネットワーク · DNS のサーバーアドレスを確認してください。", "인터넷 자막 (OpenSubtitles.com)": "インターネット字幕(OpenSubtitles.com)", "인터넷 자막 찾기": "インターネットで字幕を探す", "인터넷 자막 찾기{0}": "インターネットで字幕を探す{0}", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index adfa503..d64b119 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -153,6 +153,7 @@ "DNS 서버": "DNS 服务器", "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "无法连接 DNS 服务器 ({0}): {1}", "DNS 서버 ({0}) 에 닿지 않아 {1}": "无法连接 DNS 服务器 ({0}),因此{1}", + "DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.": "无法连接 DNS 服务器 ({0}),因此无法打开 {1}。", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "无法连接 DNS 服务器({0}),未能找到地址。请检查网络连接,或检查 设置 > 网络 · DNS 中的服务器地址。", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "无法连接 DNS 服务器 ({0}),未能解析地址: {1}", "DNS 서버 ({0}) 에 닿지 않음 ({1}부터)": "无法连接 DNS 服务器 ({0}) (自 {1} 起)", @@ -1691,6 +1692,7 @@ "인증서 확인 안 함": "不验证证书", "인코딩": "编码", "인터넷 연결을 확인한 뒤 환경 설정 > 필수 프로그램 점검에서 다시 시도하세요.\n\n{0}": "请检查网络连接后,在 设置 > 检查必需程序 中重试。\n\n{0}", + "인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "请检查网络连接或 设置 > 网络 · DNS 中的服务器地址。", "인터넷 자막 (OpenSubtitles.com)": "网络字幕(OpenSubtitles.com)", "인터넷 자막 찾기": "在线查找字幕", "인터넷 자막 찾기{0}": "在线查找字幕{0}", diff --git a/lib/app/net_guard.dart b/lib/app/net_guard.dart index aef93b8..f9ef161 100644 --- a/lib/app/net_guard.dart +++ b/lib/app/net_guard.dart @@ -94,6 +94,18 @@ class NetGuard { if (failure.value != null) failure.value = null; } + /// 250: [host] 를 [since] 뒤에 DNS 서버에 닿지 않아 잇지 못했으면 그 서버 (아니면 null). + /// 웹 브라우저가 엔진의 기본 오류 화면 ("net::ERR_TUNNEL_CONNECTION_FAILED") 대신 이유와 할 일을 보이게 + String? dnsUnreachableFor(String host, DateTime since) { + // 그 뒤 서버가 다시 답했으면 (failure 가 지워짐) 지난 실패로 막지 않는다 + if (failure.value == null) return null; + final last = _lastError[host.toLowerCase()]; + if (last == null || last.$2.isBefore(since)) return null; + final e = last.$1; + if (e is DnsException && e.unreachable) return e.server.isEmpty ? config().label : e.server; + return null; + } + /// 247: 앱의 HTTP 요청이 DNS 강제 프록시에서 실패했으면 그 까닭을 사용자 글로 ("DNS 서버 (…) 에 닿지 않아 …"). /// 프록시 실패가 아니면 null (부르는 쪽이 원래 글을 쓴다). [what] 은 무엇을 못 했는지 ("새 버전을 확인하지 못했습니다"). String? explain(Object e, String what) { diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index ef102ca..d882b33 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -155,6 +155,66 @@ class _BrowserPageState extends State with RouteAware { late String _url = widget.initialUrl ?? BrowserPage._lastUrl ?? widget.c.settings.homeUrl; String _title = ''; double _progress = 1; + + /// 250: 이 페이지를 DNS 서버에 닿지 않아 열지 못함 (주소 이름 · DNS 서버). 엔진의 오류 화면 위에 이유와 할 일을 보인다 + (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); + if (next != _dnsBlocked) setState(() => _dnsBlocked = next); + } + + /// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]) + Widget _withDnsBlock(Widget view) { + final b = _dnsBlocked; + if (b == null) return view; + void retry() { + setState(() => _dnsBlocked = null); + _nav?.reload(); + } + + return Stack(children: [ + Positioned.fill(child: view), + Positioned.fill( + child: ColoredBox( + color: JjColors.bg, + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.dns_outlined, size: 40, color: Colors.orange), + const SizedBox(height: 12), + Text(trf('DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.', [b.$2, b.$1]), + textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)), + const SizedBox(height: 8), + Text(tr('인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.'), + textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: JjColors.textDim)), + const SizedBox(height: 16), + Wrap(spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [ + FilledButton( + onPressed: () async { + await widget.c.updateSettings((x) => x.dnsMode = 'system'); + // 새 DNS 설정이 프록시에 걸린 뒤 다시 읽는다 + await Future.delayed(const Duration(milliseconds: 300)); + if (mounted) retry(); + }, + child: Text(tr('시스템 DNS 로 바꾸기')), + ), + OutlinedButton(onPressed: retry, child: Text(tr('다시 시도'))), + ]), + ]), + ), + ), + ), + ), + ), + ]); + } bool _hasVideo = false, _canBack = false, _canFwd = false, _panel = false; WebNav? _nav; @@ -178,8 +238,16 @@ class _BrowserPageState extends State with RouteAware { onTitle: (t) => mounted ? setState(() => _title = t) : null, onProgress: (p) { if (!mounted) return; + // 250: 새로 읽기 시작 - 지난 DNS 실패 화면을 걷고 시작 시각을 적어 둔다 + if (p < 1 && _progress >= 1) { + _loadStartAt = DateTime.now(); + _dnsBlocked = null; + } setState(() => _progress = p); - if (p >= 1) _translateNow(); // 다 읽었다: 바로 번역 (2초를 기다리지 않고) + if (p >= 1) { + _checkDnsBlocked(); + _translateNow(); // 다 읽었다: 바로 번역 (2초를 기다리지 않고) + } }, onVideo: (v) => mounted ? setState(() => _hasVideo = v) : null, onHistory: (b, f) { @@ -616,7 +684,7 @@ class _BrowserPageState extends State with RouteAware { child: _progress < 1 ? LinearProgressIndicator(value: _progress) : null, ), Expanded( - child: widget.viewBuilder?.call(_host, _url) ?? + child: _withDnsBlock(widget.viewBuilder?.call(_host, _url) ?? // 환경 설정에서 Chrome 을 고르고 엔진이 이번 실행에 준비되어 있으면 Chrome, 아니면 Edge (widget.c.settings.browserEngine == 'chrome' && CefRuntime.readyThisRun // 207: 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않는다 (보안) - 이유와 고를 것 @@ -637,7 +705,7 @@ class _BrowserPageState extends State with RouteAware { host: _host, initialUrl: _url, dataDir: widget.c.settings.webViewDataDir, - javaScript: widget.c.settings.webJavaScript)), + javaScript: widget.c.settings.webJavaScript))), ), ]), ), diff --git a/test/browser_test.dart b/test/browser_test.dart index db95a00..741213b 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -6,8 +6,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; import 'package:jj_mkvmaker/app/download_manager.dart'; +import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/bookmarks.dart'; +import 'package:jj_mkvmaker/core/dns_resolver.dart'; import 'package:jj_mkvmaker/core/download_detect.dart'; import 'package:jj_mkvmaker/core/web_address.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; @@ -56,8 +58,9 @@ class _Nav implements WebNav { Future back() async => backs++; @override Future forward() async {} + int reloads = 0; @override - Future reload() async {} + Future reload() async => reloads++; @override Future stop() async {} var exported = 0; @@ -302,6 +305,69 @@ void main() { d.dispose(); }); + testWidgets('250: DNS 서버에 닿지 않아 못 연 페이지는 엔진 오류 화면 대신 이유 · [시스템 DNS 로 바꾸기] [다시 시도]', (tester) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final dir = Directory.systemTemp.createTempSync('jj_browser_dns_'); + addTearDown(() => dir.deleteSync(recursive: true)); + final g = NetGuard.instance; + addTearDown(() => g.failure.value = null); + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + final bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); + late BrowserHost host; + final nav = _Nav(); + await tester.pumpWidget(MaterialApp( + home: BrowserPage( + c: c, + bookmarks: bm, + initialUrl: 'https://blocked.example/', + viewBuilder: (h, url) { + host = h; + h.attach(nav); + return const ColoredBox(color: Colors.white); + }, + ), + )); + await tester.pump(); + const msg = 'DNS 서버 (192.0.2.1) 에 닿지 않아 blocked.example 을(를) 열 수 없습니다.'; + // 읽기 시작 → 프록시가 DNS 서버에 닿지 못함 → 끝 (엔진은 ERR_TUNNEL_CONNECTION_FAILED 화면) + host.onProgress(0.1); + host.onUrl('https://blocked.example/'); + g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1')); + host.onProgress(1); + await tester.pump(); + expect(find.text(msg), findsOneWidget); + // [다시 시도]: 덮개를 걷고 다시 읽기 + await tester.tap(find.text('다시 시도')); + await tester.pump(); + expect(find.text(msg), findsNothing); + expect(nav.reloads, 1); + // 다른 이름은 막지 않는다 · 서버가 다시 답한 뒤에는 지난 실패로 막지 않는다 + host.onProgress(0.1); + host.onUrl('https://ok.example/'); + host.onProgress(1); + await tester.pump(); + expect(find.textContaining('에 닿지 않아'), findsNothing); + host.onProgress(0.1); + host.onUrl('https://blocked.example/'); + g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1')); + g.resolver.onAnswer?.call('blocked.example', '192.0.2.1'); + host.onProgress(1); + await tester.pump(); + expect(find.text(msg), findsNothing); + // [시스템 DNS 로 바꾸기] + host.onProgress(0.1); + g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1')); + host.onProgress(1); + await tester.pump(); + await tester.tap(find.text('시스템 DNS 로 바꾸기')); + await tester.pump(const Duration(milliseconds: 400)); + expect(c.settings.dnsMode, 'system'); + expect(find.text(msg), findsNothing); + expect(nav.reloads, 2); + }); + testWidgets('Android 뒤로 키: 앞 웹 페이지가 있으면 웹 뒤로, 없으면 화면 닫기 · 위쪽 ← 는 늘 닫기', (tester) async { tester.view.physicalSize = const Size(1500, 900); tester.view.devicePixelRatio = 1;