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
parent
3b3ce6386e
commit
3b3d70fc18
|
|
@ -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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue