import 'dart:async'; import 'dart:collection'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/tor_service.dart'; import '../app/web_proxy.dart'; import '../core/tor_control.dart'; import '../core/web_address.dart'; import '../platform/windows/com_guard.dart'; import '../l10n/tr.dart'; import 'app_actions.dart'; import 'key_hint.dart'; import 'settings_page.dart'; import 'theme.dart'; /// Tor 화면의 웹 뷰 조작 abstract class TorNav { Future load(String url); Future back(); Future forward(); Future reload(); Future stop(); /// 페이지에서 스크립트를 실행하고 결과를 받는다 (실패하면 null) Future evaluate(String js); } /// 웹 뷰 → 화면으로 알리는 통로 class TorViewHost { final void Function(String url) onUrl; /// 페이지가 실제로 열림 (읽기 끝 · 기록에 남음). 막은 파일 주소로 주소 칸이 바뀌었으면 이 주소로 되돌린다 final void Function(String url)? onShown; final void Function(double progress) onProgress; final void Function(bool back, bool forward) onHistory; final void Function(TorNav nav) attach; /// 파일 받기를 막았다 (Tor 화면은 받지 않는다 - 관리자 결정 10/11) final void Function(String url) onDownloadBlocked; const TorViewHost( {required this.onUrl, required this.onProgress, required this.onHistory, required this.attach, required this.onDownloadBlocked, this.onShown}); } /// WebRTC 끄기 (모든 틀 · 페이지 스크립트보다 먼저): STUN 으로 진짜 주소가 새지 않게. /// Windows 는 실행 인수 (--force-webrtc-ip-handling-policy) 로도 막는다 const torWebRtcBlockScript = r''' (function(){ var names=['RTCPeerConnection','webkitRTCPeerConnection','mozRTCPeerConnection','RTCDataChannel','RTCIceCandidate','RTCSessionDescription','RTCRtpSender','RTCRtpReceiver','RTCRtpTransceiver']; function strip(w){ try{ for(var i=0;i? _torEnv; int? _torEnvPort; /// Tor 화면 Edge 실행 인수: 프록시 (localhost 까지), QUIC 끄기, WebRTC 는 프록시를 거치지 않는 UDP 금지, SmartScreen (주소를 Microsoft 로 보냄) 끄기 String torBrowserArguments(int bridgePort) => [ '--proxy-server=http://127.0.0.1:$bridgePort', '--proxy-bypass-list=<-loopback>', '--disable-quic', '--webrtc-ip-handling-policy=disable_non_proxied_udp', '--force-webrtc-ip-handling-policy=disable_non_proxied_udp', '--dns-prefetch-disable', '--disable-features=msSmartScreenProtection,NetworkPrediction,msEdgeShoppingAssistant', ].join(' '); Future torEnvironment(String dataDir, int bridgePort) { // 다리를 다시 열 때 포트가 바뀌었으면 (그 사이 다른 프로그램이 씀) 새 환경으로 if (_torEnvPort != bridgePort) _torEnv = null; _torEnvPort = bridgePort; return _torEnv ??= () async { try { ComGuard.ensure(); return await WebViewEnvironment.create( settings: WebViewEnvironmentSettings( userDataFolder: p.join(dataDir, 'tor_webview'), additionalBrowserArguments: torBrowserArguments(bridgePort))); } catch (_) { _torEnv = null; rethrow; } }(); } /// Android 웹 뷰를 Tor 화면 전용 프로필로 만들게 하는 설정 (packages/flutter_inappwebview_android/JJ_PATCH.md: jjProfile) class _TorWebSettings extends InAppWebViewSettings { _TorWebSettings({required bool javaScript, required bool keepData}) : super( javaScriptEnabled: javaScript, mediaPlaybackRequiresUserGesture: true, supportMultipleWindows: false, useHybridComposition: false, thirdPartyCookiesEnabled: false, geolocationEnabled: false, saveFormData: false, // 파일 링크는 웹 뷰가 스스로 받지 않고 알린다 (Windows: packages/flutter_inappwebview_windows/JJ_PATCH.md) useOnDownloadStart: true, // Windows: 남기지 않으면 InPrivate (닫으면 쿠키 · 캐시가 없어진다) incognito: Platform.isWindows && !keepData, ); @override Map toMap() => { ...super.toMap(), if (Platform.isAndroid) 'jjProfile': WebProxy.torProfileName, // Windows: Tor 다리의 인증에 답하게 (packages/flutter_inappwebview_windows/JJ_PATCH.md) if (Platform.isWindows) 'jjProxyAuth': WebProxy.proxyAuthSetting(), }; } /// Tor 브라우저 화면 (사용자 요청 10/10): 웹 브라우저 옆. Tor 로만 나가고 쿠키 · 기록 · 캐시는 일반 브라우저와 따로. /// Tor 연결 100% · 프록시 적용 · 전용 프로필이 모두 된 뒤에야 웹 뷰를 만든다 ([torViewMayLoad]). class TorPage extends StatefulWidget { final AppController c; /// 시험용 웹 뷰 대체 (불렸다 = 페이지를 읽기 시작했다) final Widget Function(TorViewHost host, String url)? viewBuilder; /// 시험용: Tor 시작 · 기능 확인 · 프록시 걸기를 바꾼다 final Future Function()? startTor; /// 시험용: 떠난 뒤 정리 대신 불린다 final Future Function()? onLeft; final Future> Function()? missingFeatures; final Future Function()? applyProxy; const TorPage({super.key, required this.c, this.viewBuilder, this.startTor, this.missingFeatures, this.applyProxy, this.onLeft}); static const routeName = 'tor'; static int _open = 0; /// 지금 보이는 (맨 앞 · 앱이 앞) Tor 화면 수. 떠난 뒤 정리 중에 다시 들어왔는지 보려고 static int _visible = 0; /// 마지막으로 붙은 웹 뷰 (통합 시험에서 페이지 내용을 읽는다) @visibleForTesting static TorNav? debugNav; /// 이미 열려 있으면 그 화면으로, 없으면 새로 static Future open(NavigatorState nav, {required AppController c}) async { if (_open > 0) { var found = false; nav.popUntil((r) { if (r.settings.name == routeName) found = true; return found || r.isFirst; }); if (found) return; } await nav.push(MaterialPageRoute(settings: const RouteSettings(name: routeName), builder: (_) => TorPage(c: c))); } @override State createState() => _TorPageState(); } /// 처음 열 때 · 환경 설정에서: 공식 Tor Browser 가 아니라는 안내 Future showTorNotice(BuildContext context) => showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(tr('Tor 브라우저 안내')), content: SizedBox( width: 480, child: Text(tr('이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n' '· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n' '· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. ' '사이트가 이 기기를 알아볼 수 있습니다.\n' '· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.')), ), actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))], ), ); class _TorPageState extends State with WebProxyVisibility { final _tor = TorService.instance; late String _url = widget.c.settings.torHomeUrl; late final _address = TextEditingController(text: _url); final _addressFocus = FocusNode(); TorNav? _nav; double _progress = 1; bool _canBack = false, _canFwd = false; /// 이 기기 웹 뷰에 없는 기능 (있으면 화면을 열지 않는다 - Tor 를 거치지 않을 수 있으므로) List? _missing; bool _proxyApplied = false; bool _profileReady = false; String? _error; WebViewEnvironment? _env; bool _begun = false; /// 이 화면이 보이는지 (맨 앞 경로이고 앱이 앞에 있음). 떠나면 웹 뷰를 치우고 정리한다 (관리자 결정 10/11) bool _routeCurrent = true; bool _foreground = true; bool _shown = false; /// 떠나서 웹 뷰를 치운 상태 (돌아오면 홈부터 다시) bool _away = false; late final AppLifecycleListener _life = AppLifecycleListener( onResume: () => _setForeground(true), onHide: () => _setForeground(false), ); late final TorViewHost _host = TorViewHost( onUrl: (u) { if (!mounted || u.isEmpty) return; setState(() => _url = u); if (!_addressFocus.hasFocus) _address.text = u; }, onProgress: (v) => mounted ? setState(() => _progress = v) : null, onHistory: (b, f) { if (!mounted) return; setState(() { _canBack = b; _canFwd = f; }); }, attach: (n) => _nav = TorPage.debugNav = n, onDownloadBlocked: (_) => _downloadBlocked(), onShown: (u) { if (u.isNotEmpty) _shownUrl = u; }, ); /// 지금 보이는 페이지의 주소 (막은 파일 링크는 열리지 않으므로 주소 칸을 이것으로 되돌린다) String? _shownUrl; /// 파일 링크를 누르면: 받지 않고 이유를 알린다 (아무 반응이 없지 않게) void _downloadBlocked() { if (!mounted) return; // 주소 칸이 막은 파일 주소로 바뀌었으면 보이는 페이지 주소로 (페이지와 주소가 다르지 않게) final shown = _shownUrl; if (shown != null) { setState(() => _url = shown); if (!_addressFocus.hasFocus) _address.text = shown; } final m = ScaffoldMessenger.maybeOf(context); m?.hideCurrentSnackBar(); m?.showSnackBar(SnackBar(content: Text(tr('Tor 화면에서는 파일을 받지 않습니다 (Tor 밖으로 나가지 않게)')))); } @override void initState() { super.initState(); TorPage._open++; _tor.status.addListener(_onStatus); _tor.idleStopAt.addListener(_onStatus); _life; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _updateShown(); _begin(); }); } @override void didChangeDependencies() { super.didChangeDependencies(); final cur = ModalRoute.of(context)?.isCurrent ?? true; if (cur == _routeCurrent) return; _routeCurrent = cur; _updateShown(); } void _setForeground(bool v) { if (!mounted || v == _foreground) return; _foreground = v; _updateShown(); } void _updateShown() { final v = _routeCurrent && _foreground; if (v == _shown) return; _shown = v; if (v) { TorPage._visible++; _enter(); } else { TorPage._visible--; unawaited(_leave()); } } /// 돌아옴: 기다렸다 끄기를 그만두고, 홈부터 다시 (Tor 가 아직 켜져 있으면 바로) void _enter() { _tor.cancelIdleStop(); if (!_away) return; final home = widget.c.settings.torHomeUrl; setState(() { _away = false; _url = home; _address.text = home; _progress = 1; _canBack = _canFwd = false; }); if (_begun && _missing == null) unawaited(_startTor()); } /// 떠남 (다른 화면 · 앱이 뒤로): 웹 뷰를 먼저 치운 뒤 프록시 · 다리를 되돌리고, 남기지 않으면 지우고, tor 는 잠시 뒤 끈다 Future _leave() async { final nav = _nav; _nav = null; unawaited(nav?.stop()); setState(() { _away = true; _proxyApplied = _profileReady = false; _env = null; }); // 웹 뷰가 화면에서 빠질 때까지 (앱이 뒤에 있어 화면을 그리지 않으면 기다리지 않는다 - 그래도 남은 Tor 웹 뷰가 있는 동안은 // WebProxy 가 Tor 쪽 · 닫힌 포트만 걸어 일반 쪽으로 나가지 않는다) if (SchedulerBinding.instance.framesEnabled) { await WidgetsBinding.instance.endOfFrame.timeout(const Duration(seconds: 2), onTimeout: () {}); } if (widget.viewBuilder != null || widget.startTor != null) { // 시험: 실제 Tor · 프록시 대신 기록만 await (widget.onLeft ?? () async {})(); return; } await _cleanup(widget.c.settings.torKeepData, widget.c.settings.torIdleMinutes); } /// 떠난 뒤 정리 (화면을 닫았을 때도 같다) static Future _cleanup(bool keep, int idleMinutes) async { if (TorPage._visible > 0) return; // 그 사이 다시 들어왔다 if (Platform.isAndroid) { WebProxy.torOpen.value = false; try { await WebProxy.apply(); } catch (_) {} } // Tor 다리는 Tor 화면이 보이는 동안만 await TorService.instance.closeBridge(); unawaited(TorService.instance.stopAfter(Duration(minutes: idleMinutes))); if (!keep && Platform.isAndroid) await _clearProfile(); } /// Tor 프로필 (쿠키 · 기록 · 캐시) 지우기 - 쓰는 웹 뷰가 남아 있으면 지우지 못하므로 몇 번 다시 (마지막 웹 뷰가 없어질 때도 부른다) static Future _clearProfile() async { // 쓰는 웹 뷰가 남아 있어도 쿠키 · 저장소는 지운다 (MainActivity.clearWebProfile) - 프로필째 지우기는 웹 뷰가 없어진 뒤 for (var i = 0; i < 5 && TorPage._visible == 0 && !await WebProxy.clearTorProfile(); i++) { await Future.delayed(const Duration(seconds: 1)); } } Future _begin() async { if (_begun) return; _begun = true; final c = widget.c; if (!c.settings.torNoticeSeen && mounted) { await showTorNotice(context); await c.updateSettings((x) => x.torNoticeSeen = true); } final missing = await (widget.missingFeatures ?? WebProxy.torMissingFeatures)(); if (!mounted) return; if (missing.isNotEmpty) { setState(() => _missing = missing); return; } await _startTor(); } Future _startTor() async { setState(() => _error = null); await (widget.startTor ?? _tor.start)(); _onStatus(); } /// Tor 가 준비되면 프록시를 건다 (그 다음에야 웹 뷰를 만든다). 떠나 있는 동안은 걸지 않는다 void _onStatus() { if (!mounted) return; setState(() {}); if (_tor.status.value.ready && !_proxyApplied && !_away && _shown) unawaited(_applyProxy()); } bool _applying = false; Future _applyProxy() async { if (_applying) return; _applying = true; try { if (widget.applyProxy != null) { final ok = await widget.applyProxy!(); if (mounted) setState(() => _proxyApplied = _profileReady = ok); return; } if (Platform.isAndroid) { // Android: Tor 문 (인증 없음 · 숨긴 주소 · 이 화면이 보이는 동안만, WebProxy) 을 웹 뷰 프록시로 WebProxy.torOpen.value = true; final applied = await WebProxy.apply(); // 걸린 프록시가 Tor 문인지 확인한 뒤에만 if (mounted) setState(() => _proxyApplied = _profileReady = applied.isNotEmpty && applied == WebProxy.torGateUrl); } else { final port = await _tor.bridgePort(); final env = await torEnvironment(p.dirname(widget.c.settings.webViewDataDir), port); if (mounted) { setState(() { _env = env; _proxyApplied = _profileReady = true; }); } } } catch (e) { if (mounted) setState(() => _error = trf('Tor 화면을 준비할 수 없습니다: {0}', [e])); } finally { _applying = false; } } @override void dispose() { TorPage._open--; _tor.status.removeListener(_onStatus); _tor.idleStopAt.removeListener(_onStatus); _life.dispose(); final nav = _nav; _nav = null; unawaited(nav?.stop()); _address.dispose(); _addressFocus.dispose(); // 닫음 = 떠남과 같은 정리 (웹 뷰는 이 화면과 함께 빠진다) if (_shown) { _shown = false; TorPage._visible--; if (widget.viewBuilder == null && widget.startTor == null) { final keep = widget.c.settings.torKeepData, idle = widget.c.settings.torIdleMinutes; // 남은 Tor 웹 뷰가 있는 동안은 WebProxy 가 Tor 쪽 · 닫힌 포트만 건다 ([WebProxy.torViewCreated]) - 기다리지 않고 정리 unawaited(_cleanup(keep, idle)); } else { unawaited((widget.onLeft ?? () async {})()); } } super.dispose(); } void _go(String input) { final url = torAddress(input); _address.text = url; _addressFocus.unfocus(); _nav?.load(url); } @override Widget build(BuildContext context) { final status = _tor.status.value; return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.keyL, control: true): () => _addressFocus.requestFocus(), const SingleActivator(LogicalKeyboardKey.f5): () => _nav?.reload(), const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _nav?.back(), const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _nav?.forward(), }, child: PopScope( canPop: !(Platform.isAndroid && _canBack), onPopInvokedWithResult: (didPop, _) { if (!didPop) _nav?.back(); }, child: Focus( autofocus: true, child: Scaffold( body: Column(children: [ SwipeNav(current: 'tor', anywhere: true, child: _toolbar()), _statusBar(status), SizedBox(height: 2, child: _progress < 1 ? LinearProgressIndicator(value: _progress) : null), Expanded(child: _body(status)), ]), ), ), ), ); } Widget _toolbar() { IconButton btn(IconData i, String tip, VoidCallback? f) => IconButton(tooltip: tip, visualDensity: VisualDensity.compact, icon: Icon(i, size: 20), onPressed: f); final ready = _nav != null; // 201 (일반 브라우저와 같게): 폰 가로처럼 버튼이 폭을 다 차지해 주소 칸이 모자라면 두 줄 return AppTopBar( minMiddleWidth: 380, nav: const AppNavButtons(onTorPage: true), actions: AppActions(c: widget.c), middle: LayoutBuilder(builder: (context, box) { final compact = box.maxWidth < 560; return Row(children: [ btn(Icons.arrow_back_ios_new, keyHint(tr('이전 페이지'), 'Alt+←'), ready && _canBack ? () => _nav?.back() : null), btn(Icons.arrow_forward_ios, keyHint(tr('다음 페이지'), 'Alt+→'), ready && _canFwd ? () => _nav?.forward() : null), _progress < 1 ? btn(Icons.close, tr('중지'), ready ? () => _nav?.stop() : null) : btn(Icons.refresh, keyHint(tr('새로고침'), 'F5'), ready ? () => _nav?.reload() : null), if (!compact) btn(Icons.home_outlined, tr('홈'), ready ? () => _go(widget.c.settings.torHomeUrl) : null), const SizedBox(width: 6), Expanded( // 일반 브라우저의 기록 · 키보드 학습을 쓰지 않는다 (제안 없음) child: TextField( controller: _address, focusNode: _addressFocus, enabled: ready, enableSuggestions: false, autocorrect: false, enableIMEPersonalizedLearning: false, keyboardType: TextInputType.url, style: const TextStyle(fontSize: 13), decoration: InputDecoration( isDense: true, filled: true, fillColor: JjColors.bg, hintText: tr('주소 입력 또는 검색 (DuckDuckGo)'), prefixIcon: const Icon(Icons.vpn_lock_outlined, size: 16, color: Color(0xFF9C6ADE)), prefixIconConstraints: const BoxConstraints(minWidth: 32), border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none), ), onSubmitted: _go, ), ), const SizedBox(width: 4), Tooltip( message: tr('새 경로: 앞으로의 연결을 새 Tor 회로로 (사이트가 다른 사람으로 봄)'), child: compact ? IconButton( icon: const Icon(Icons.alt_route, size: 20), onPressed: _tor.status.value.ready ? _newIdentity : null, ) : OutlinedButton.icon( onPressed: _tor.status.value.ready ? _newIdentity : null, icon: const Icon(Icons.alt_route, size: 18), label: Text(tr('새 경로')), ), ), PopupMenuButton( tooltip: tr('더 보기'), icon: const Icon(Icons.more_vert, size: 20), onSelected: (v) => switch (v) { 'home' => _go(widget.c.settings.torHomeUrl), 'notice' => showTorNotice(context), _ => Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage(c: widget.c, initialGroup: 'tor'))), }, itemBuilder: (_) => [ PopupMenuItem(value: 'home', child: ListTile(dense: true, leading: const Icon(Icons.home_outlined), title: Text(tr('홈')))), PopupMenuItem(value: 'notice', child: ListTile(dense: true, leading: const Icon(Icons.info_outline), title: Text(tr('Tor 브라우저 안내')))), PopupMenuItem(value: 'settings', child: ListTile(dense: true, leading: const Icon(Icons.settings_outlined), title: Text(tr('Tor 설정')))), ], ), ]); }), ); } Future _newIdentity() async { await _tor.newIdentity(); if (!mounted) return; ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('새 Tor 경로로 바꿨습니다. 페이지를 다시 읽습니다.')))); unawaited(_nav?.reload()); } /// 연결 상태 줄: 연결 중 (n%) · 연결됨 · 실패 (이유 + [다시 시도]) Widget _statusBar(TorStatus s) { final idle = _tor.idleStopAt.value; final (Color color, String text) = switch (s.phase) { // 떠난 뒤 잠시 켜 두는 중 (관리자 결정 10/11) TorPhase.ready when idle != null => ( JjColors.textDim, trf('Tor 대기 중 ({0}분 뒤 끔)', [(idle.difference(DateTime.now()).inSeconds / 60).ceil().clamp(0, 60)]) ), TorPhase.ready => (JjColors.success, tr('Tor 연결됨')), TorPhase.failed => (JjColors.danger, trf('Tor 연결 실패: {0}', [s.message])), TorPhase.starting => (JjColors.accent, trf('Tor 연결 중 ({0}%) {1}', [s.percent, s.message])), TorPhase.off => (JjColors.textDim, tr('Tor 꺼짐')), }; return Material( color: color.withValues(alpha: 0.14), child: Padding( padding: const EdgeInsets.fromLTRB(12, 4, 6, 4), child: Row(children: [ Icon(s.phase == TorPhase.ready ? Icons.verified_user_outlined : Icons.vpn_lock_outlined, size: 16, color: color), const SizedBox(width: 8), Expanded(child: Text(text, style: const TextStyle(fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis)), if (s.phase == TorPhase.starting) SizedBox(width: 80, child: LinearProgressIndicator(value: s.percent / 100)), if (s.phase == TorPhase.failed && _missing == null) TextButton(onPressed: _startTor, child: Text(tr('다시 시도'))), ]), ), ); } Widget _body(TorStatus status) { if (_missing != null) { return _message(Icons.block, tr('이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다'), trf('Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. ' 'Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.', [_missing!.join(', ')])); } if (_error != null) return _message(Icons.error_outline, tr('Tor 화면을 준비할 수 없습니다'), _error!); // 떠나 있는 동안 (다른 화면 · 앱이 뒤로): 웹 뷰를 두지 않는다 if (_away) { return _message(Icons.vpn_lock_outlined, tr('Tor 화면을 떠나 페이지를 정리했습니다'), tr('돌아오면 홈부터 다시 엽니다.')); } if (!torViewMayLoad(status: status, proxyApplied: _proxyApplied, profileReady: _profileReady)) { return _message(Icons.vpn_lock_outlined, tr('Tor 에 연결하는 중'), tr('Tor 연결이 끝나고 웹 뷰가 Tor 로만 나가게 된 뒤에 페이지를 엽니다. 그 전에는 아무것도 읽지 않습니다.'), busy: status.phase == TorPhase.starting || status.ready); } final b = widget.viewBuilder; if (b != null) return b(_host, _url); return _TorWebView(host: _host, url: _url, env: _env, javaScript: widget.c.settings.torJavaScript, keepData: widget.c.settings.torKeepData); } Widget _message(IconData icon, String title, String body, {bool busy = false}) => Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: Column(mainAxisSize: MainAxisSize.min, children: [ busy ? const SizedBox(width: 40, height: 40, child: CircularProgressIndicator()) : Icon(icon, size: 40, color: JjColors.textDim), const SizedBox(height: 12), Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center), const SizedBox(height: 8), Text(body, style: TextStyle(color: JjColors.textDim), textAlign: TextAlign.center), ]), ), ), ); } /// Tor 화면의 주소창 입력 → 주소 (주소가 아니면 DuckDuckGo 검색 - Google 로 보내지 않음) String torAddress(String input) { final u = normalizeAddress(input); if (u.startsWith('https://www.google.com/search?q=')) { return 'https://duckduckgo.com/?q=${u.substring('https://www.google.com/search?q='.length)}'; } return u; } class _TorWebView extends StatefulWidget { final TorViewHost host; final String url; final WebViewEnvironment? env; final bool javaScript; final bool keepData; const _TorWebView({required this.host, required this.url, required this.env, required this.javaScript, required this.keepData}); @override State<_TorWebView> createState() => _TorWebViewState(); } /// 웹 뷰가 살아 있는 동안 WebProxy 에 알린다 ([WebProxy.torViewCreated]) - 없어지면 프록시를 다시 걸고, 남기지 않으면 프로필을 지운다 class _TorWebViewState extends State<_TorWebView> { @override void initState() { super.initState(); WebProxy.torViewCreated(); WebProxy.onLastTorView = widget.keepData || !Platform.isAndroid ? null : _TorPageState._clearProfile; } @override void dispose() { WebProxy.torViewDisposed(); super.dispose(); } @override Widget build(BuildContext context) { final host = widget.host, url = widget.url, env = widget.env, javaScript = widget.javaScript, keepData = widget.keepData; Future history(InAppWebViewController c) async => host.onHistory(await c.canGoBack(), await c.canGoForward()); return InAppWebView( webViewEnvironment: env, initialUrlRequest: URLRequest(url: WebUri(torAddress(url))), initialSettings: _TorWebSettings(javaScript: javaScript, keepData: keepData), initialUserScripts: UnmodifiableListView([ UserScript(source: torWebRtcBlockScript, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, forMainFrameOnly: false), ]), onWebViewCreated: (c) => host.attach(_InAppTorNav(c)), // 받지 않는다 (useOnDownloadStart: 웹 뷰도 스스로 받지 않음) onDownloadStartRequest: (c, r) => host.onDownloadBlocked(r.url.toString()), onLoadStart: (c, u) => host.onUrl(u?.toString() ?? ''), onLoadStop: (c, u) async { host.onUrl(u?.toString() ?? ''); host.onShown?.call(u?.toString() ?? ''); host.onProgress(1); await history(c); }, onUpdateVisitedHistory: (c, u, _) async { host.onUrl(u?.toString() ?? ''); host.onShown?.call(u?.toString() ?? ''); await history(c); }, onProgressChanged: (c, v) => host.onProgress(v / 100), // 카메라 · 마이크 · 위치는 묻지 않고 거절 (WebRTC 와 함께 진짜 기기 정보가 나가지 않게) onPermissionRequest: (c, r) async => PermissionResponse(resources: r.resources, action: PermissionResponseAction.DENY), onGeolocationPermissionsShowPrompt: (c, o) async => GeolocationPermissionShowPromptResponse(origin: o, allow: false, retain: false), ); } } class _InAppTorNav implements TorNav { final InAppWebViewController c; _InAppTorNav(this.c); @override Future load(String url) => c.loadUrl(urlRequest: URLRequest(url: WebUri(url))); @override Future back() => c.goBack(); @override Future forward() => c.goForward(); @override Future reload() => c.reload(); @override Future stop() async { try { await c.stopLoading(); } catch (_) {} } @override Future evaluate(String js) async { try { return await c.evaluateJavascript(source: js); } catch (_) { return null; } } }