JJ_MKVMaker/lib/ui/network_settings.dart

358 lines
16 KiB
Dart

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;
/// 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<DnsFailure?>(
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<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;
const DnsSettings({super.key, required this.c});
@override
State<DnsSettings> createState() => _DnsSettingsState();
}
class _DnsSettingsState extends State<DnsSettings> {
AppController get c => widget.c;
String? _primaryError, _secondaryError, _dohError;
String? _test;
bool _testing = false;
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(() {
_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<String> 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<String>(
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.browserEngine == '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;
const TorSettings({super.key, required this.c});
@override
Widget build(BuildContext context) {
final s = c.settings;
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
// Tor 화면을 떠난 뒤 잠시 켜 두는 중이면 보인다 (언제 끄는지 · 지금 끄기)
ValueListenableBuilder<DateTime?>(
valueListenable: TorService.instance.idleStopAt,
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: () => TorService.instance.stop(), child: Text(tr('지금 끄기'))),
),
),
SwitchListTile(
value: s.torKeepData,
onChanged: (v) => c.updateSettings((x) => x.torKeepData = v),
title: Text(tr('Tor 화면을 닫아도 쿠키 · 기록 · 캐시 남기기')),
subtitle: Text(tr('끄면 (기본) Tor 화면을 떠날 때 (다른 화면 · 앱이 뒤로) 지웁니다. 일반 브라우저와는 늘 따로입니다.')),
),
SettingTile(
title: Text(tr('Tor 화면을 떠난 뒤 Tor 를 켜 둘 시간')),
subtitle: Text(tr('다른 화면으로 가거나 앱이 뒤로 가면 Tor 화면의 페이지 · 연결은 바로 정리합니다. Tor 자체는 이 시간 동안 켜 두어 다시 들어오면 바로 쓸 수 있습니다.')),
trailing: DropdownButton<int>(
value: const [0, 1, 2, 5, 10].contains(s.torIdleMinutes) ? s.torIdleMinutes : 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.torIdleMinutes = v ?? 2),
),
),
SwitchListTile(
value: s.torJavaScript,
onChanged: (v) => c.updateSettings((x) => x.torJavaScript = 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.torHomeUrl,
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
onChanged: (v) => c.updateSettings((x) => x.torHomeUrl = v.trim().isEmpty ? AppSettings.defaultTorHome : 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<bool> 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 화면을 열어 웹 뷰가 폴더를 쥐고 있음
}
}
}