250 웹 브라우저: DNS 서버에 닿지 않아 못 연 페이지는 이유 · [시스템 DNS 로 바꾸기] [다시 시도]

관리자 지시 (10/12, a4 자유 점검): 엔진 기본 오류 "net::ERR_TUNNEL_CONNECTION_FAILED" 만 보여 안내를 닫은 뒤에는
이유를 알 수 없었다. 앱 안 프록시가 기록한 실패 (NetGuard.dnsUnreachableFor: 이 페이지를 읽기 시작한 뒤 그 이름이
DNS 서버에 닿지 않음 · 아직 다시 답하지 않음) 로 판단해, 읽기가 끝나면 웹 뷰 위에 앱이 그린다.
Edge · Chrome 엔진 · Android 모두 같은 길 (엔진마다 다른 오류 콜백에 기대지 않음). 새로 읽기 시작하면 걷힌다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix_mkv
RyuJongJin 2026-10-12 05:49:18 +09:00
parent ba39f4bc92
commit 6c61b8b82d
6 changed files with 156 additions and 4 deletions

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

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

View File

@ -155,6 +155,66 @@ class _BrowserPageState extends State<BrowserPage> 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<void>.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<BrowserPage> 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<BrowserPage> 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<BrowserPage> with RouteAware {
host: _host,
initialUrl: _url,
dataDir: widget.c.settings.webViewDataDir,
javaScript: widget.c.settings.webJavaScript)),
javaScript: widget.c.settings.webJavaScript))),
),
]),
),

View File

@ -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<void> back() async => backs++;
@override
Future<void> forward() async {}
int reloads = 0;
@override
Future<void> reload() async {}
Future<void> reload() async => reloads++;
@override
Future<void> 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;