tor_page 나누기 (740 → 499): 웹 뷰 부분 (tor_web_view) · 안내 (tor_messages) 를 lib/ui/tor/ 모듈로

- 웹 뷰가 화면 상태의 _clearProfile 을 직접 부르던 것을 인자 (clearProfile) 로 - 모듈이 화면에 기대지 않게
- 약속은 tor_test 의 torAddress · torBrowserArguments · torWebRtcBlockScript · TorViewHost 시험이 본다 (20 통과)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 03:36:12 +09:00
parent 3b3ce6386e
commit 3b3d70fc18
3 changed files with 279 additions and 250 deletions

View File

@ -0,0 +1,42 @@
/// 약속: Tor 화면의 안내 - 공식 Tor Browser 가 아니라는 안내 창 ([showTorNotice]) · 웹 뷰 대신 보이는 안내 ([torMessage]).
library;
import 'package:flutter/material.dart';
import '../../l10n/tr.dart';
import '../theme.dart';
/// 처음 열 때 · 환경 설정에서: 공식 Tor Browser 가 아니라는 안내
Future<void> showTorNotice(BuildContext context) => showDialog<void>(
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('확인')))],
),
);
/// 웹 뷰 대신 보이는 안내 (아이콘 또는 빙글이 · 제목 · 설명)
Widget torMessage(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),
]),
),
),
);

View File

@ -0,0 +1,228 @@
/// 약속: Tor 화면의 웹 뷰 부분 - 조작 ([TorNav]) · 화면으로 알리는 통로 ([TorViewHost]) · Windows 전용 환경 (모든 연결을 Tor 다리로,
/// QUIC · 프록시 밖 UDP 끄기) · Android 전용 프로필 · WebRTC 막는 스크립트 · 주소 고르기 (검색은 DuckDuckGo).
library;
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:path/path.dart' as p;
import '../../app/web_proxy.dart';
import '../../core/web_address.dart';
import '../../platform/windows/com_guard.dart';
/// Tor 화면의 웹 뷰 조작
abstract class TorNav {
Future<void> load(String url);
Future<void> back();
Future<void> forward();
Future<void> reload();
Future<void> stop();
/// 페이지에서 스크립트를 실행하고 결과를 받는다 (실패하면 null)
Future<Object?> 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<names.length;i++){ try{ Object.defineProperty(w,names[i],{value:undefined,writable:false,configurable:false}); }catch(e){ try{ delete w[names[i]]; w[names[i]]=undefined; }catch(_){} } } }catch(e){}
try{ if(w.navigator && w.navigator.mediaDevices){ Object.defineProperty(w.navigator,'mediaDevices',{value:undefined,configurable:false}); } }catch(e){}
}
strip(window);
// 스크립트가 먼저 돌지 않는 빈 틀 (about:blank) 에서 꺼내 쓰지 못하게: 틀의 창을 건네기 전에 지운다
try{
var d=Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,'contentWindow');
Object.defineProperty(HTMLIFrameElement.prototype,'contentWindow',{configurable:false,get:function(){ var w=d.get.call(this); if(w){ try{ strip(w); }catch(e){} } return w; }});
var dd=Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,'contentDocument');
Object.defineProperty(HTMLIFrameElement.prototype,'contentDocument',{configurable:false,get:function(){ var doc=dd.get.call(this); if(doc && doc.defaultView){ try{ strip(doc.defaultView); }catch(e){} } return doc; }});
}catch(e){}
})();
''';
/// Windows: Tor 화면 전용 WebView2 환경 (데이터 폴더를 일반 브라우저와 따로, 모든 연결을 Tor 다리로)
Future<WebViewEnvironment>? _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<WebViewEnvironment> 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<String, dynamic> 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 화면의 주소창 입력 → 주소 (주소가 아니면 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;
}
/// Tor 화면의 웹 뷰 (전용 환경 · 전용 프로필 · 파일 받기 막기 · WebRTC 끄기)
class TorWebView extends StatefulWidget {
final TorViewHost host;
final String url;
final WebViewEnvironment? env;
final bool javaScript;
final bool keepData;
/// 마지막 Tor 웹 뷰가 없어질 때 프로필 (쿠키 · 기록 · 캐시) 을 지울 것 (Android, 남기지 않을 때)
final Future<void> Function() clearProfile;
const TorWebView({super.key, required this.clearProfile, 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 : widget.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<void> 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<void> load(String url) => c.loadUrl(urlRequest: URLRequest(url: WebUri(url)));
@override
Future<void> back() => c.goBack();
@override
Future<void> forward() => c.goForward();
@override
Future<void> reload() => c.reload();
@override
Future<void> stop() async {
try {
await c.stopLoading();
} catch (_) {}
}
@override
Future<Object?> evaluate(String js) async {
try {
return await c.evaluateJavascript(source: js);
} catch (_) {
return null;
}
}
}

View File

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:flutter/material.dart';
@ -12,124 +11,16 @@ 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';
import 'tor/tor_messages.dart';
import 'tor/tor_web_view.dart';
/// Tor 화면의 웹 뷰 조작
abstract class TorNav {
Future<void> load(String url);
Future<void> back();
Future<void> forward();
Future<void> reload();
Future<void> stop();
/// 페이지에서 스크립트를 실행하고 결과를 받는다 (실패하면 null)
Future<Object?> 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<names.length;i++){ try{ Object.defineProperty(w,names[i],{value:undefined,writable:false,configurable:false}); }catch(e){ try{ delete w[names[i]]; w[names[i]]=undefined; }catch(_){} } } }catch(e){}
try{ if(w.navigator && w.navigator.mediaDevices){ Object.defineProperty(w.navigator,'mediaDevices',{value:undefined,configurable:false}); } }catch(e){}
}
strip(window);
// 스크립트가 먼저 돌지 않는 빈 틀 (about:blank) 에서 꺼내 쓰지 못하게: 틀의 창을 건네기 전에 지운다
try{
var d=Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,'contentWindow');
Object.defineProperty(HTMLIFrameElement.prototype,'contentWindow',{configurable:false,get:function(){ var w=d.get.call(this); if(w){ try{ strip(w); }catch(e){} } return w; }});
var dd=Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,'contentDocument');
Object.defineProperty(HTMLIFrameElement.prototype,'contentDocument',{configurable:false,get:function(){ var doc=dd.get.call(this); if(doc && doc.defaultView){ try{ strip(doc.defaultView); }catch(e){} } return doc; }});
}catch(e){}
})();
''';
/// Windows: Tor 화면 전용 WebView2 환경 (데이터 폴더를 일반 브라우저와 따로, 모든 연결을 Tor 다리로)
Future<WebViewEnvironment>? _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<WebViewEnvironment> 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<String, dynamic> toMap() => {
...super.toMap(),
if (Platform.isAndroid) 'jjProfile': WebProxy.torProfileName,
// Windows: Tor 다리의 인증에 답하게 (packages/flutter_inappwebview_windows/JJ_PATCH.md)
if (Platform.isWindows) 'jjProxyAuth': WebProxy.proxyAuthSetting(),
};
}
export 'tor/tor_messages.dart' show showTorNotice;
export 'tor/tor_web_view.dart' show TorNav, TorViewHost, torAddress, torBrowserArguments, torEnvironment, torWebRtcBlockScript;
/// Tor 브라우저 화면 (사용자 요청 10/10): 웹 브라우저 옆. Tor 로만 나가고 쿠키 · 기록 · 캐시는 일반 브라우저와 따로.
/// Tor 연결 100% · 프록시 적용 · 전용 프로필이 모두 된 뒤에야 웹 뷰를 만든다 ([torViewMayLoad]).
@ -176,24 +67,6 @@ class TorPage extends StatefulWidget {
State<TorPage> createState() => _TorPageState();
}
/// 처음 열 때 · 환경 설정에서: 공식 Tor Browser 가 아니라는 안내
Future<void> showTorNotice(BuildContext context) => showDialog<void>(
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<TorPage> with WebProxyVisibility {
final _tor = TorService.instance;
late String _url = widget.c.settings.tor.homeUrl;
@ -605,136 +478,22 @@ class _TorPageState extends State<TorPage> with WebProxyVisibility {
Widget _body(TorStatus status) {
if (_missing != null) {
return _message(Icons.block, tr('이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다'),
return torMessage(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 (_error != null) return torMessage(Icons.error_outline, tr('Tor 화면을 준비할 수 없습니다'), _error!);
// 떠나 있는 동안 (다른 화면 · 앱이 뒤로): 웹 뷰를 두지 않는다
if (_away) {
return _message(Icons.vpn_lock_outlined, tr('Tor 화면을 떠나 페이지를 정리했습니다'), tr('돌아오면 홈부터 다시 엽니다.'));
return torMessage(Icons.vpn_lock_outlined, tr('Tor 화면을 떠나 페이지를 정리했습니다'), tr('돌아오면 홈부터 다시 엽니다.'));
}
if (!torViewMayLoad(status: status, proxyApplied: _proxyApplied, profileReady: _profileReady)) {
return _message(Icons.vpn_lock_outlined, tr('Tor 에 연결하는 중'),
return torMessage(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.tor.javaScript, keepData: widget.c.settings.tor.keepData);
}
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<void> 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<void> load(String url) => c.loadUrl(urlRequest: URLRequest(url: WebUri(url)));
@override
Future<void> back() => c.goBack();
@override
Future<void> forward() => c.goForward();
@override
Future<void> reload() => c.reload();
@override
Future<void> stop() async {
try {
await c.stopLoading();
} catch (_) {}
}
@override
Future<Object?> evaluate(String js) async {
try {
return await c.evaluateJavascript(source: js);
} catch (_) {
return null;
}
return TorWebView(clearProfile: _clearProfile, host: _host, url: _url, env: _env, javaScript: widget.c.settings.tor.javaScript, keepData: widget.c.settings.tor.keepData);
}
}