363 lines
17 KiB
Dart
363 lines
17 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;
|
|
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<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.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<DateTime?>(
|
|
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<int>(
|
|
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<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 화면을 열어 웹 뷰가 폴더를 쥐고 있음
|
|
}
|
|
}
|
|
}
|