import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import '../app/app_controller.dart'; import '../app/net_guard.dart'; import '../app/settings.dart'; import '../app/tor_service.dart'; import '../app/web_proxy.dart'; import '../core/dns_resolver.dart'; import '../l10n/tr.dart'; import '../platform/windows/cef_runtime.dart'; import 'setting_tile.dart'; import 'theme.dart'; import 'tor_page.dart' show showTorNotice; import '../core/idle_stop.dart'; /// 249: DNS 서버에 닿지 않는 동안 보이는 줄 (환경 설정 > 네트워크 · DNS · 작업 현황) - 아래 안내를 놓쳐도 찾을 수 있게. /// 서버가 다시 답하거나 DNS 설정을 바꾸면 사라진다. class DnsFailureLine extends StatelessWidget { final AppController c; /// 266: 좁은 화면 (폰 세로) - 한 줄 (글은 줄임, 길게 누르면 전체 · 버튼은 같은 줄). 두 줄이면 위의 세부 정보 칸이 줄어 /// [AI 자막 만들기] · [인터넷 자막 찾기] 가 칸 밖으로 밀렸다 final bool compact; const DnsFailureLine({super.key, required this.c, this.compact = false}); @override Widget build(BuildContext context) => ValueListenableBuilder( valueListenable: NetGuard.instance.failure, builder: (context, f, _) { if (f == null) return const SizedBox.shrink(); String two(int x) => x.toString().padLeft(2, '0'); final text = trf('DNS 서버 ({0}) 에 닿지 않음 ({1}부터)', [f.server, '${two(f.at.hour)}:${two(f.at.minute)}']); if (compact) { return SizedBox( height: 36, child: Row(children: [ const Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange), const SizedBox(width: 6), Expanded( child: Tooltip( message: text, child: Text(text, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: Colors.orange)), ), ), TextButton( style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 8), minimumSize: const Size(0, 36)), onPressed: () => c.updateSettings((x) => x.network.mode = 'system'), child: Text(tr('시스템 DNS 로 바꾸기'), style: const TextStyle(fontSize: 13)), ), ]), ); } return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ const Icon(Icons.warning_amber_rounded, size: 18, color: Colors.orange), Text(text, style: const TextStyle(fontSize: 12, color: Colors.orange)), TextButton( onPressed: () => c.updateSettings((x) => x.network.mode = 'system'), child: Text(tr('시스템 DNS 로 바꾸기')), ), ]), ); }, ); } /// 248: 아래 알림 (SnackBar) 의 내용. 스스로 상태를 듣다가 DNS 가 다시 답하거나 설정을 바꿔 상태가 풀리면, /// 또는 같은 안내를 보이는 화면 (브라우저 덮개 · 설정의 DNS 칸) 이 열리면 알림을 닫는다. /// (알림 닫기는 "지금 보이는 알림" 에만 해야 해서, 보이는 동안 그려지는 내용 위젯이 스스로 닫는다) class DnsFailureNotice extends StatefulWidget { final DnsFailure failure; const DnsFailureNotice({super.key, required this.failure}); @override State createState() => _DnsFailureNoticeState(); } class _DnsFailureNoticeState extends State { 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; const DnsSettings({super.key, required this.c}); @override State createState() => _DnsSettingsState(); } class _DnsSettingsState extends State { AppController get c => widget.c; String? _primaryError, _secondaryError, _dohError; String? _test; bool _testing = false; Future _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 _check() async { setState(() { _testing = true; _test = null; }); final r = DnsResolver(config: () => c.settings.network.config); String? server; r.onAnswer = (_, s) => server = s; try { final a = c.settings.network.mode == 'system' ? await InternetAddress.lookup('www.google.com').then((v) { server = tr('기기 DNS'); return v; }) : await r.lookup('www.google.com'); _test = trf('www.google.com → {0} (답한 서버: {1})', [a.map((x) => x.address).take(2).join(', '), server]); } catch (e) { _test = trf('실패: {0}', [e]); } if (mounted) setState(() => _testing = false); } Widget _field(String label, String value, String? error, ValueChanged onChanged, {String? hint}) => SizedBox( width: 300, child: TextFormField( initialValue: value, decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: label, hintText: hint, errorText: error), onChanged: onChanged, onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ); @override Widget build(BuildContext context) { final s = c.settings; final desk = Platform.isWindows; return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ DnsFailureLine(c: c), SettingTile( leading: const Icon(Icons.dns_outlined), title: Text(tr('DNS 서버')), subtitle: Text(tr('주소 이름 (예: youtube.com) 을 찾을 때 물어볼 서버. 직접 지정하면 기기의 DNS 대신 이 서버만 씁니다. ' '서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 알립니다.')), trailing: DropdownButton( value: s.network.mode, items: [ DropdownMenuItem(value: 'system', child: Text(tr('시스템 DNS (끄기)'))), DropdownMenuItem(value: 'custom', child: Text(tr('직접 지정 (기본)'))), DropdownMenuItem(value: 'doh', child: Text(tr('DNS over HTTPS'))), ], onChanged: (v) => _save((x) => x.network.mode = v!), ), ), if (s.network.mode != 'system') Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Wrap(spacing: 12, runSpacing: 12, children: [ if (s.network.mode == 'doh') _field(tr('DoH 주소'), s.network.dohUrl, _dohError, hint: DnsConfig.defaultDohUrl, (v) { final ok = isValidDohUrl(v); setState(() => _dohError = ok ? null : tr('https:// 로 시작하는 주소를 넣으세요')); if (ok) _save((x) => x.network.dohUrl = v.trim()); }), _field(s.network.mode == 'doh' ? tr('DoH 서버 이름을 찾을 서버') : tr('기본 DNS'), s.network.primary, _primaryError, hint: DnsConfig.defaultPrimary, (v) { final ok = parseDnsServer(v) != null; setState(() => _primaryError = ok ? null : tr('IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)')); if (ok) _save((x) => x.network.primary = v.trim()); }), _field(tr('보조 DNS (비워 둘 수 있음)'), s.network.secondary, _secondaryError, hint: DnsConfig.defaultSecondary, (v) { final ok = v.trim().isEmpty || parseDnsServer(v) != null; setState(() => _secondaryError = ok ? null : tr('IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)')); if (ok) _save((x) => x.network.secondary = v.trim()); }), ]), ), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Row(children: [ OutlinedButton.icon( onPressed: _testing ? null : _check, icon: const Icon(Icons.network_check, size: 18), label: Text(tr('지금 확인')), ), const SizedBox(width: 12), Expanded(child: Text(_testing ? tr('확인하는 중…') : _test ?? '', style: TextStyle(fontSize: 12, color: JjColors.textDim))), ]), ), SettingTile( leading: const Icon(Icons.info_outline), title: Text(tr('이 설정을 따르는 것')), subtitle: Text([ desk ? tr('· 웹 브라우저 (Edge · Chrome 엔진): 끄기 ↔ 켜기는 앱을 다시 시작한 뒤, 서버 주소는 바로') : tr('· 웹 브라우저: 바로 (Tor 화면이 열려 있는 동안은 Tor)'), tr('· 다운로드: yt-dlp, aria2 (http · https · HTTP 트래커는 바로, 토렌트의 UDP 트래커 · DHT 이름은 직접 지정일 때 - 끄기 ↔ 켜기는 다음 실행부터)'), tr('· 앱의 인터넷 연결: 새 버전 확인 · 컴포넌트 · AI 모델 받기 · WebDAV · 웹 페이지 번역 · 자막 검색'), tr('따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)'), if (!desk) tr('· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)'), ].join('\n')), ), if (desk && s.browser.engine == 'chrome' && CefRuntime.readyThisRun) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Text(tr('Chrome 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ), ]); } } /// 환경 설정 > Tor 브라우저: 기록 남기기 · 홈 주소 · JavaScript · 안내 다시 보기 · 지금 지우기 class TorSettings extends StatelessWidget { final AppController c; /// 떠난 뒤 잠시 켜 두기 (처음 값 = 이 앱의 Tor) final IdleStop? idle; const TorSettings({super.key, required this.c, this.idle}); @override Widget build(BuildContext context) { final s = c.settings; final idle = this.idle ?? TorService.instance.idle; return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Tor 화면을 떠난 뒤 잠시 켜 두는 중이면 보인다 (언제 끄는지 · 지금 끄기) ValueListenableBuilder( valueListenable: idle.stopAt, builder: (context, at, _) => at == null ? const SizedBox.shrink() : SettingTile( leading: const Icon(Icons.timer_outlined), title: Text(trf('Tor 대기 중 ({0}분 뒤 끔)', [(at.difference(DateTime.now()).inSeconds / 60).ceil().clamp(0, 60)])), subtitle: Text(tr('Tor 화면을 떠난 뒤 다시 들어오면 바로 쓸 수 있게 켜 두고 있습니다.')), trailing: OutlinedButton(onPressed: idle.stopNow, child: Text(tr('지금 끄기'))), ), ), SwitchListTile( value: s.tor.keepData, onChanged: (v) => c.updateSettings((x) => x.tor.keepData = v), title: Text(tr('Tor 화면을 닫아도 쿠키 · 기록 · 캐시 남기기')), subtitle: Text(tr('끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.')), ), SettingTile( title: Text(tr('Tor 화면을 떠난 뒤 Tor 를 켜 둘 시간')), subtitle: Text(tr('다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.')), trailing: DropdownButton( value: const [0, 1, 2, 5, 10].contains(s.tor.idleMinutes) ? s.tor.idleMinutes : 2, items: [ DropdownMenuItem(value: 0, child: Text(tr('바로 끔'))), for (final m in const [1, 2, 5, 10]) DropdownMenuItem(value: m, child: Text(trf('{0}분', [m]))), ], onChanged: (v) => c.updateSettings((x) => x.tor.idleMinutes = v ?? 2), ), ), SwitchListTile( value: s.tor.javaScript, onChanged: (v) => c.updateSettings((x) => x.tor.javaScript = v), title: Text(tr('Tor 화면의 JavaScript 사용')), subtitle: Text(tr('끄면 더 안전하지만 많은 사이트가 보이지 않습니다 (다음에 열 때부터).')), ), SettingTile( title: Text(tr('Tor 화면 홈 주소')), subtitle: Text(tr('처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)')), trailing: SizedBox( width: 300, child: TextFormField( initialValue: s.tor.homeUrl, decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), onChanged: (v) => c.updateSettings((x) => x.tor.homeUrl = v.trim().isEmpty ? TorBrowserSettings.defaultHome : v.trim()), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), ), ), if (Platform.isAndroid) SettingTile( leading: const Icon(Icons.info_outline), title: Text(tr('Android: Tor 화면이 열려 있는 동안')), subtitle: Text(tr('Android 웹 뷰의 프록시는 앱 전체에 걸려, Tor 화면이 열려 있는 동안 일반 웹 브라우저도 Tor 로 나갑니다 (일반 브라우저 위쪽에 안내). ' 'Tor 화면을 닫으면 원래대로 돌아갑니다.')), ), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), child: Wrap(spacing: 8, runSpacing: 8, children: [ OutlinedButton.icon( onPressed: () => showTorNotice(context), icon: const Icon(Icons.info_outline, size: 18), label: Text(tr('Tor 브라우저 안내')), ), OutlinedButton.icon( onPressed: () async { final ok = await clearTorData(); if (context.mounted) { ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar( content: Text(ok ? tr('Tor 화면의 쿠키 · 기록 · 캐시를 지웠습니다.') : tr('Tor 화면이 열려 있어 지금은 지울 수 없습니다. 닫은 뒤 다시 하세요.')))); } }, icon: const Icon(Icons.cookie_outlined, size: 18), label: Text(tr('Tor 기록 지금 지우기')), ), ]), ), ]); } /// Tor 화면의 저장된 것 지우기 (화면이 닫혀 있을 때) Future clearTorData() async { if (WebProxy.torOpen.value || TorService.instance.running) return false; if (Platform.isAndroid) return WebProxy.clearTorProfile(); try { final d = Directory('${Directory(c.settings.webViewDataDir).parent.path}${Platform.pathSeparator}tor_webview'); if (d.existsSync()) d.deleteSync(recursive: true); return true; } catch (_) { return false; // 이번 실행에서 Tor 화면을 열어 웹 뷰가 폴더를 쥐고 있음 } } }