248 다듬기: 아래 DNS 알림은 상태가 풀리면 닫고, 같은 안내를 보이는 화면 (브라우저 덮개 · 설정 DNS 칸) 에서는 띄우지 않음

a4 의 933 기기 확인 · 관리자 지시 (10/12, 이번 릴리스 전에):
- DNS 를 고친 뒤 (정한 서버가 다시 답함 · 설정을 바꿈) 에도 옛 문구 "DNS 서버 (192.0.2.1) 에 닿지 않아…" 가 남아
  설정의 DNS 칸을 가렸다 → 알림 내용 위젯 (DnsFailureNotice) 이 상태를 듣다가 풀리면 스스로 닫는다
  (Flutter 의 알림 닫기는 "지금 보이는 알림" 에만 해야 해서, 보이는 동안 그려지는 내용이 닫는다).
- 브라우저 덮개와 함께 떠 [시스템 DNS 로 바꾸기] 가 두 개 보였다 → 덮개 · 환경 설정의 DNS 칸이 열려 있는 동안은
  띄우지 않고, 떠 있다가 그런 화면이 열리면 닫는다 (NetGuard.noticeBlockers). 상태는 설정 · 작업 기록 줄 (249) 에 그대로.
- 버튼이 있는 알림이 저절로 사라지지 않는 것 (248 처음 지시) 은 그대로.
- 시험: 상태 풀림 → 닫힘 · 막는 화면이 있으면 안 뜸 · 뜬 뒤 열리면 닫힘 · 설정 DNS 칸 · 덮개가 막는 수를 올리고 내림.
  실제 Edge 통합 시험 다시 통과.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
통합
RyuJongJin 2026-10-12 07:47:50 +09:00
parent f7d73da301
commit c350499b79
6 changed files with 144 additions and 7 deletions

View File

@ -37,6 +37,9 @@ class NetGuard {
/// 249: 서버가 다시 답하거나 DNS 설정을 바꿀 때까지 남는다 - 아래 안내를 놓쳐도 환경 설정 > 네트워크 · DNS 와 작업 현황에 보인다.
final failure = ValueNotifier<DnsFailure?>(null);
/// 248: 아래 알림을 띄우지 않을 화면이 열려 있는 수 (브라우저의 DNS 덮개 · 환경 설정의 DNS 칸 - 같은 안내 · 버튼을 이미 보인다)
final noticeBlockers = ValueNotifier<int>(0);
/// 작업 기록
void Function(String)? log;

View File

@ -57,6 +57,7 @@ import 'ui/downloads_page.dart';
import 'ui/exit_dialog.dart';
import 'ui/home_page.dart';
import 'ui/monitor_page.dart' show askLiveSyncStart, MonitorPage;
import 'ui/network_settings.dart' show DnsFailureNotice;
import 'ui/player_page.dart';
import 'ui/setup_dialog.dart';
import 'ui/update_dialog.dart';
@ -503,13 +504,15 @@ void watchDnsFailure(GlobalKey<ScaffoldMessengerState> messengerKey, AppControll
NetGuard.instance.failure.addListener(() {
final f = NetGuard.instance.failure.value;
if (f == null) return;
// 248: 같은 안내를 보이는 화면 (브라우저 덮개 · 설정의 DNS 칸) 이 열려 있으면 띄우지 않는다 (상태는 설정 · 작업 기록 줄에)
if (NetGuard.instance.noticeBlockers.value > 0) return;
messengerKey.currentState?.showSnackBar(SnackBar(
// 248: 버튼이 있는 안내라 사용자가 닫거나 누를 때까지 둔다 (한 번만 뜨고, 상태는 설정 · 작업 현황에 남음 - 249)
persist: true,
showCloseIcon: true,
// 사용자에게는 무엇이 안 되고 무엇을 할지만 (예외 원문은 작업 기록에 - NetGuard.reportError)
content: Text(trf('DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.',
[f.server])),
// 사용자에게는 무엇이 안 되고 무엇을 할지만 (예외 원문은 작업 기록에 - NetGuard.reportError).
// 상태가 풀리거나 같은 안내를 보이는 화면이 열리면 스스로 닫힌다 (248)
content: DnsFailureNotice(failure: f),
action: SnackBarAction(
label: tr('시스템 DNS 로 바꾸기'),
onPressed: () => c.updateSettings((x) => x.dnsMode = 'system'),

View File

@ -215,10 +215,18 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
/// [error]: 엔진이 알려 준 본문 오류. 프록시 터널 실패이고 DNS 서버에 지금 닿지 않는 중이면, 그 이름의 실패 기록이 없어도
/// (Chromium 이 실패한 프록시를 기억해 다시 묻지 않는 때 등) DNS 때문으로 본다.
/// 덮개는 여기서 걷지 않는다 (새로 읽기 시작 · [다시 시도] 에서) - Android 는 오류 뒤에 읽기 끝이 또 온다.
/// 248: 덮개가 보이는 동안은 아래 알림을 띄우지 않는다 (같은 안내 · [시스템 DNS 로 바꾸기] 가 두 개 보였다 - a4 933)
void _setDnsBlocked((String, String)? v) {
final g = NetGuard.instance.noticeBlockers;
if (_dnsBlocked == null && v != null) g.value++;
if (_dnsBlocked != null && v == null) g.value--;
_dnsBlocked = v;
}
/// 250: 사용자가 다른 곳으로 가거나 다시 읽음 - 덮개를 걷는다 (다시 실패하면 또 뜬다)
void _userNavigated() {
_loadStartAt = DateTime.now();
if (_dnsBlocked != null && mounted) setState(() => _dnsBlocked = null);
if (_dnsBlocked != null && mounted) setState(() => _setDnsBlocked(null));
}
void _checkDnsBlocked({String? host, String? error}) {
@ -233,7 +241,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
if (next == _dnsBlocked) return;
// 기기에서 "덮개를 띄웠는데 안 보임" 과 "띄우지 않음" 을 가릴 수 있게
widget.c.note(trf('DNS 안내 덮개를 띄움 ({0} · DNS {1})', [h, server]));
setState(() => _dnsBlocked = next);
setState(() => _setDnsBlocked(next));
}
/// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]).
@ -242,7 +250,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
Widget _withDnsBlock(Widget view) {
final b = _dnsBlocked;
void retry() {
setState(() => _dnsBlocked = null);
setState(() => _setDnsBlocked(null));
_nav?.reload();
}
@ -595,6 +603,8 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
@override
void dispose() {
// 화면을 닫는 중에는 다른 위젯 (아래 알림) 을 바꾸지 않게 다음 차례에
if (_dnsBlocked != null) scheduleMicrotask(() => NetGuard.instance.noticeBlockers.value--);
browserRouteObserver.unsubscribe(this);
BrowserPage._open--;
widget.c.removeListener(_onSettings);

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -42,6 +43,50 @@ class DnsFailureLine extends StatelessWidget {
);
}
/// 248: 아래 알림 (SnackBar) 의 내용. 스스로 상태를 듣다가 DNS 가 다시 답하거나 설정을 바꿔 상태가 풀리면,
/// 또는 같은 안내를 보이는 화면 (브라우저 덮개 · 설정의 DNS 칸) 이 열리면 알림을 닫는다.
/// (알림 닫기는 "지금 보이는 알림" 에만 해야 해서, 보이는 동안 그려지는 내용 위젯이 스스로 닫는다)
class DnsFailureNotice extends StatefulWidget {
final DnsFailure failure;
const DnsFailureNotice({super.key, required this.failure});
@override
State<DnsFailureNotice> createState() => _DnsFailureNoticeState();
}
class _DnsFailureNoticeState extends State<DnsFailureNotice> {
final g = NetGuard.instance;
bool _closing = false;
@override
void initState() {
super.initState();
g.failure.addListener(_check);
g.noticeBlockers.addListener(_check);
// 대기열에 있다가 차례가 왔을 때 이미 풀렸으면 바로 닫는다
WidgetsBinding.instance.addPostFrameCallback((_) => _check());
}
@override
void dispose() {
g.failure.removeListener(_check);
g.noticeBlockers.removeListener(_check);
super.dispose();
}
void _check() {
if (_closing || !mounted) return;
if (g.failure.value == widget.failure && g.noticeBlockers.value == 0) return;
_closing = true;
ScaffoldMessenger.maybeOf(context)?.hideCurrentSnackBar();
}
@override
Widget build(BuildContext context) => Text(trf(
'DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.',
[widget.failure.server]));
}
/// 환경 설정 > 네트워크 · DNS (사용자 요청 10/10): DNS 서버 강제 (처음: 직접 지정 8.8.8.8 · 8.8.4.4)
class DnsSettings extends StatefulWidget {
final AppController c;
@ -59,6 +104,20 @@ class _DnsSettingsState extends State<DnsSettings> {
Future<void> _save(void Function(AppSettings x) f) => c.updateSettings(f);
// 248: 이 칸이 보이는 동안은 아래 알림을 띄우지 않는다 (맨 위 줄 [DnsFailureLine] 이 같은 안내를 하고, 알림이 칸을 가렸다)
@override
void initState() {
super.initState();
NetGuard.instance.noticeBlockers.value++;
}
@override
void dispose() {
// 화면을 닫는 중에는 다른 위젯을 바꾸지 않게 다음 차례에
scheduleMicrotask(() => NetGuard.instance.noticeBlockers.value--);
super.dispose();
}
/// [지금 확인]: 지금 설정으로 이름 하나를 찾아 본다 (어느 서버가 답했는지)
Future<void> _check() async {
setState(() {

View File

@ -331,7 +331,10 @@ void main() {
final dir = Directory.systemTemp.createTempSync('jj_browser_dns_');
addTearDown(() => dir.deleteSync(recursive: true));
final g = NetGuard.instance;
addTearDown(() => g.failure.value = null);
addTearDown(() {
g.failure.value = null;
g.noticeBlockers.value = 0;
});
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final bm = BookmarksController(p.join(dir.path, 'bookmarks.json'));
late BrowserHost host;
@ -360,12 +363,15 @@ void main() {
expect(find.text(msg), findsOneWidget);
// 덮개를 띄워도 웹 뷰는 그대로 (새로 만들면 페이지를 다시 읽어 덮개가 걷혔다 - 931)
expect(_Probe.created, 0);
// 248: 덮개가 보이는 동안은 아래 알림을 막는다
expect(g.noticeBlockers.value, 1);
// [다시 시도]: 덮개를 걷고 다시 읽기
await tester.tap(find.text('다시 시도'));
await tester.pump();
expect(find.text(msg), findsNothing);
expect(nav.reloads, 1);
expect(_Probe.created, 0);
expect(g.noticeBlockers.value, 0);
// 다른 이름은 막지 않는다 · 서버가 다시 답한 뒤에는 지난 실패로 막지 않는다
host.onProgress(0.1);
host.onUrl('https://ok.example/');

View File

@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/app/tor_bundle.dart';
import 'package:jj_mkvmaker/app/tor_service.dart';
import 'package:jj_mkvmaker/core/tor_control.dart';
import 'package:jj_mkvmaker/main.dart' show watchDnsFailure;
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';
@ -363,6 +364,61 @@ void main() {
expect(find.textContaining('Tor 대기 중'), findsNothing);
});
testWidgets('248: 아래 알림 - 상태가 풀리면 닫힘 · 같은 안내를 보이는 화면 (덮개 · 설정 DNS 칸) 이 열려 있으면 띄우지 않고, 열리면 닫힘', (t) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final g = NetGuard.instance;
addTearDown(() {
g.failure.value = null;
g.noticeBlockers.value = 0;
});
final key = GlobalKey<ScaffoldMessengerState>();
watchDnsFailure(key, c);
await t.pumpWidget(MaterialApp(scaffoldMessengerKey: key, home: const Scaffold(body: Text('home'))));
const text = 'DNS 서버 (192.0.2.1) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.';
Future<void> settle() async {
for (var i = 0; i < 10; i++) {
await t.pump(const Duration(milliseconds: 100));
}
}
// 1) 뜬 뒤 상태가 풀리면 (정한 서버가 다시 답함 · 설정을 바꿈) 바로 닫힌다
g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now());
await settle();
expect(find.text(text), findsOneWidget);
await t.pump(const Duration(minutes: 5));
expect(find.text(text), findsOneWidget, reason: '버튼이 있는 안내는 저절로 사라지지 않는다');
g.failure.value = null;
await settle();
expect(find.text(text), findsNothing);
// 2) 같은 안내를 보이는 화면이 열려 있으면 띄우지 않는다
g.noticeBlockers.value = 1;
g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now());
await settle();
expect(find.text(text), findsNothing);
g.failure.value = null;
g.noticeBlockers.value = 0;
// 3) 떠 있는 동안 그런 화면이 열리면 닫힌다
g.failure.value = DnsFailure('192.0.2.1', 'x', DateTime.now());
await settle();
expect(find.text(text), findsOneWidget);
g.noticeBlockers.value = 1;
await settle();
expect(find.text(text), findsNothing);
});
testWidgets('248: 환경 설정의 DNS 칸 · 브라우저 덮개가 열려 있는 동안 알림을 막는 수', (t) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final g = NetGuard.instance;
expect(g.noticeBlockers.value, 0);
await t.pumpWidget(MaterialApp(home: Scaffold(body: SingleChildScrollView(child: DnsSettings(c: c)))));
expect(g.noticeBlockers.value, 1);
await t.pumpWidget(const SizedBox());
await t.pump();
expect(g.noticeBlockers.value, 0);
});
testWidgets('249: DNS 서버에 닿지 않는 동안 환경 설정 > 네트워크 · DNS 에 상태 줄 · [시스템 DNS 로 바꾸기]', (t) async {
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final g = NetGuard.instance;