250 고침: 덮개를 띄우면 웹 뷰가 새로 만들어져 덮개가 바로 걷히던 것 · 엔진의 본문 오류로도 판단

a4 의 931 기기 확인 (노트 10+): DNS 192.0.2.1 에서 브라우저에 덮개가 보이지 않고 ERR_TUNNEL_CONNECTION_FAILED 만 (버그 수정).
- 원인: 덮개가 없을 때는 웹 뷰를, 있을 때는 Stack 안의 웹 뷰를 돌려 트리 모양이 바뀌었다 → 웹 뷰가 새로 만들어져
  페이지를 다시 읽고, 다시 읽기 시작에서 덮개가 걷혔다. Windows 실제 Edge 통합 시험에서 재현
  ("WindowsInAppWebViewController was used after being disposed"). 늘 같은 Stack 모양으로.
- 엔진이 알려 주는 본문 오류 (onReceivedError, Edge · Android) 도 받아 판단한다: 그 이름의 실패 기록이 있거나,
  DNS 서버에 지금 닿지 않는 중에 프록시 터널 실패 (TUNNEL · PROXY) 면 덮개. 오류 뒤에 오는 읽기 끝이 덮개를 지우지 않는다.
  DNS 강제 중의 본문 오류는 작업 기록에 남긴다 (기기에서 원인을 볼 수 있게).
- 시험: 덮개를 띄워도 웹 뷰를 다시 만들지 않음 · 본문 오류 경로 · DNS 와 상관없는 오류는 엔진 화면 그대로.
  실제 Edge: integration_test/dns_block_real_test.dart (덮개 → [다시 시도] → 다시 덮개) 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
통합
RyuJongJin 2026-10-12 06:59:07 +09:00
parent 614584c78c
commit 0b0f2617ae
6 changed files with 151 additions and 7 deletions

View File

@ -1568,6 +1568,7 @@
"웹 브라우저의 시작 · 🏠 주소": "Start · 🏠 page of the web browser", "웹 브라우저의 시작 · 🏠 주소": "Start · 🏠 page of the web browser",
"웹 페이지 자동 번역": "Translate web pages automatically", "웹 페이지 자동 번역": "Translate web pages automatically",
"웹 페이지를 번역하지 못했습니다: {0}": "Could not translate the web page: {0}", "웹 페이지를 번역하지 못했습니다: {0}": "Could not translate the web page: {0}",
"웹 페이지를 열지 못함 ({0}): {1}": "Couldn't open web page ({0}): {1}",
"위 ← 아래": "Top ← bottom", "위 ← 아래": "Top ← bottom",
"위 → 아래": "Top → bottom", "위 → 아래": "Top → bottom",
"위 ⇄ 아래": "Top ⇄ bottom", "위 ⇄ 아래": "Top ⇄ bottom",

View File

@ -1568,6 +1568,7 @@
"웹 브라우저의 시작 · 🏠 주소": "ウェブブラウザーの開始・🏠 ページ", "웹 브라우저의 시작 · 🏠 주소": "ウェブブラウザーの開始・🏠 ページ",
"웹 페이지 자동 번역": "ウェブページを自動翻訳", "웹 페이지 자동 번역": "ウェブページを自動翻訳",
"웹 페이지를 번역하지 못했습니다: {0}": "ウェブページを翻訳できませんでした: {0}", "웹 페이지를 번역하지 못했습니다: {0}": "ウェブページを翻訳できませんでした: {0}",
"웹 페이지를 열지 못함 ({0}): {1}": "Web ページを開けませんでした ({0}): {1}",
"위 ← 아래": "上 ← 下", "위 ← 아래": "上 ← 下",
"위 → 아래": "上 → 下", "위 → 아래": "上 → 下",
"위 ⇄ 아래": "上 ⇄ 下", "위 ⇄ 아래": "上 ⇄ 下",

View File

@ -1568,6 +1568,7 @@
"웹 브라우저의 시작 · 🏠 주소": "网页浏览器的起始 · 🏠 页面", "웹 브라우저의 시작 · 🏠 주소": "网页浏览器的起始 · 🏠 页面",
"웹 페이지 자동 번역": "自动翻译网页", "웹 페이지 자동 번역": "自动翻译网页",
"웹 페이지를 번역하지 못했습니다: {0}": "无法翻译网页: {0}", "웹 페이지를 번역하지 못했습니다: {0}": "无法翻译网页: {0}",
"웹 페이지를 열지 못함 ({0}): {1}": "无法打开网页 ({0}):{1}",
"위 ← 아래": "上 ← 下", "위 ← 아래": "上 ← 下",
"위 → 아래": "上 → 下", "위 → 아래": "上 → 下",
"위 ⇄ 아래": "上 ⇄ 下", "위 ⇄ 아래": "上 ⇄ 下",

View File

@ -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 = <String>[];
final g = NetGuard.instance
..config = (() => c.settings.dnsConfig)
..log = logs.add;
await tester.runAsync(g.ensure);
Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 200; i++) {
await tester.runAsync(() => Future<void>.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;
});
}

View File

@ -77,6 +77,9 @@ class BrowserHost {
final void Function(bool hasVideo) onVideo; final void Function(bool hasVideo) onVideo;
final void Function(bool back, bool forward) onHistory; final void Function(bool back, bool forward) onHistory;
final void Function(WebNav nav) attach; final void Function(WebNav nav) attach;
/// 250: 본문 (main frame) 을 읽지 못함 - 엔진이 알려 준 주소와 오류 (예: net::ERR_TUNNEL_CONNECTION_FAILED)
final void Function(String url, String error)? onMainFrameError;
const BrowserHost({ const BrowserHost({
required this.onUrl, required this.onUrl,
required this.onTitle, required this.onTitle,
@ -84,6 +87,7 @@ class BrowserHost {
required this.onVideo, required this.onVideo,
required this.onHistory, required this.onHistory,
required this.attach, required this.attach,
this.onMainFrameError,
}); });
} }
@ -161,17 +165,26 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
(String, String)? _dnsBlocked; (String, String)? _dnsBlocked;
DateTime _loadStartAt = DateTime.now(); DateTime _loadStartAt = DateTime.now();
void _checkDnsBlocked() { /// [error]: 엔진이 알려 준 본문 오류. 프록시 터널 실패이고 DNS 서버에 지금 닿지 않는 중이면, 그 이름의 실패 기록이 없어도
final host = Uri.tryParse(_url)?.host ?? ''; /// (Chromium 이 실패한 프록시를 기억해 다시 묻지 않는 때 등) DNS 때문으로 본다.
final server = host.isEmpty ? null : NetGuard.instance.dnsUnreachableFor(host, _loadStartAt); /// 덮개는 여기서 걷지 않는다 (새로 읽기 시작 · [다시 시도] 에서) - Android 는 오류 뒤에 읽기 끝이 또 온다.
final next = server == null ? null : (host, server); 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); if (next != _dnsBlocked) setState(() => _dnsBlocked = next);
} }
/// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]) /// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]).
/// 트리 모양은 늘 같게 (Stack 첫 칸이 웹 뷰) - 덮개를 띄울 때 모양이 바뀌면 웹 뷰가 새로 만들어져 페이지를 다시 읽고,
/// 다시 읽기가 시작되며 덮개가 걷혔다 (931 노트 10+ 에서 덮개가 보이지 않던 것, Windows 실제 시험에서 재현)
Widget _withDnsBlock(Widget view) { Widget _withDnsBlock(Widget view) {
final b = _dnsBlocked; final b = _dnsBlocked;
if (b == null) return view;
void retry() { void retry() {
setState(() => _dnsBlocked = null); setState(() => _dnsBlocked = null);
_nav?.reload(); _nav?.reload();
@ -179,6 +192,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
return Stack(children: [ return Stack(children: [
Positioned.fill(child: view), Positioned.fill(child: view),
if (b != null)
Positioned.fill( Positioned.fill(
child: ColoredBox( child: ColoredBox(
color: JjColors.bg, color: JjColors.bg,
@ -259,6 +273,15 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
}); });
}, },
attach: (n) => _nav = BrowserPage.debugNav = n, 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; BookmarksController get bm => widget.bookmarks;
@ -1080,6 +1103,10 @@ class _EdgeViewState extends State<_EdgeView> with WebProxyVisibility {
}, },
onTitleChanged: (c, t) => h.onTitle(t ?? ''), onTitleChanged: (c, t) => h.onTitle(t ?? ''),
onProgressChanged: (c, p) => h.onProgress(p / 100), 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);
},
); );
}, },
); );

View File

@ -79,6 +79,25 @@ class _Nav implements WebNav {
Future<void> setJavaScript(bool on) async => javaScript.add(on); Future<void> 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() { void main() {
group('즐겨찾기 트리', () { group('즐겨찾기 트리', () {
test('추가 · 찾기 · 이동 · 삭제', () { test('추가 · 찾기 · 이동 · 삭제', () {
@ -325,7 +344,7 @@ void main() {
viewBuilder: (h, url) { viewBuilder: (h, url) {
host = h; host = h;
h.attach(nav); h.attach(nav);
return const ColoredBox(color: Colors.white); return const _Probe();
}, },
), ),
)); ));
@ -335,14 +354,18 @@ void main() {
host.onProgress(0.1); host.onProgress(0.1);
host.onUrl('https://blocked.example/'); host.onUrl('https://blocked.example/');
g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1')); g.reportError('blocked.example', const DnsException('timeout', unreachable: true, server: '192.0.2.1'));
_Probe.created = 0;
host.onProgress(1); host.onProgress(1);
await tester.pump(); await tester.pump();
expect(find.text(msg), findsOneWidget); expect(find.text(msg), findsOneWidget);
// 덮개를 띄워도 웹 뷰는 그대로 (새로 만들면 페이지를 다시 읽어 덮개가 걷혔다 - 931)
expect(_Probe.created, 0);
// [다시 시도]: 덮개를 걷고 다시 읽기 // [다시 시도]: 덮개를 걷고 다시 읽기
await tester.tap(find.text('다시 시도')); await tester.tap(find.text('다시 시도'));
await tester.pump(); await tester.pump();
expect(find.text(msg), findsNothing); expect(find.text(msg), findsNothing);
expect(nav.reloads, 1); expect(nav.reloads, 1);
expect(_Probe.created, 0);
// 다른 이름은 막지 않는다 · 서버가 다시 답한 뒤에는 지난 실패로 막지 않는다 // 다른 이름은 막지 않는다 · 서버가 다시 답한 뒤에는 지난 실패로 막지 않는다
host.onProgress(0.1); host.onProgress(0.1);
host.onUrl('https://ok.example/'); host.onUrl('https://ok.example/');
@ -366,6 +389,24 @@ void main() {
expect(c.settings.dnsMode, 'system'); expect(c.settings.dnsMode, 'system');
expect(find.text(msg), findsNothing); expect(find.text(msg), findsNothing);
expect(nav.reloads, 2); 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 { testWidgets('Android 뒤로 키: 앞 웹 페이지가 있으면 웹 뒤로, 없으면 화면 닫기 · 위쪽 ← 는 늘 닫기', (tester) async {