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:async';
|
||||||
import 'dart:collection';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -12,124 +11,16 @@ import '../app/app_controller.dart';
|
||||||
import '../app/tor_service.dart';
|
import '../app/tor_service.dart';
|
||||||
import '../app/web_proxy.dart';
|
import '../app/web_proxy.dart';
|
||||||
import '../core/tor_control.dart';
|
import '../core/tor_control.dart';
|
||||||
import '../core/web_address.dart';
|
|
||||||
import '../platform/windows/com_guard.dart';
|
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
import 'app_actions.dart';
|
import 'app_actions.dart';
|
||||||
import 'key_hint.dart';
|
import 'key_hint.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
import 'tor/tor_messages.dart';
|
||||||
|
import 'tor/tor_web_view.dart';
|
||||||
|
|
||||||
/// Tor 화면의 웹 뷰 조작
|
export 'tor/tor_messages.dart' show showTorNotice;
|
||||||
abstract class TorNav {
|
export 'tor/tor_web_view.dart' show TorNav, TorViewHost, torAddress, torBrowserArguments, torEnvironment, torWebRtcBlockScript;
|
||||||
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 브라우저 화면 (사용자 요청 10/10): 웹 브라우저 옆. Tor 로만 나가고 쿠키 · 기록 · 캐시는 일반 브라우저와 따로.
|
/// Tor 브라우저 화면 (사용자 요청 10/10): 웹 브라우저 옆. Tor 로만 나가고 쿠키 · 기록 · 캐시는 일반 브라우저와 따로.
|
||||||
/// Tor 연결 100% · 프록시 적용 · 전용 프로필이 모두 된 뒤에야 웹 뷰를 만든다 ([torViewMayLoad]).
|
/// Tor 연결 100% · 프록시 적용 · 전용 프로필이 모두 된 뒤에야 웹 뷰를 만든다 ([torViewMayLoad]).
|
||||||
|
|
@ -176,24 +67,6 @@ class TorPage extends StatefulWidget {
|
||||||
State<TorPage> createState() => _TorPageState();
|
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 {
|
class _TorPageState extends State<TorPage> with WebProxyVisibility {
|
||||||
final _tor = TorService.instance;
|
final _tor = TorService.instance;
|
||||||
late String _url = widget.c.settings.tor.homeUrl;
|
late String _url = widget.c.settings.tor.homeUrl;
|
||||||
|
|
@ -605,136 +478,22 @@ class _TorPageState extends State<TorPage> with WebProxyVisibility {
|
||||||
|
|
||||||
Widget _body(TorStatus status) {
|
Widget _body(TorStatus status) {
|
||||||
if (_missing != null) {
|
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 을 업데이트한 뒤 다시 여세요. '
|
trf('Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. '
|
||||||
'Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.', [_missing!.join(', ')]));
|
'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) {
|
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)) {
|
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 로만 나가게 된 뒤에 페이지를 엽니다. 그 전에는 아무것도 읽지 않습니다.'),
|
tr('Tor 연결이 끝나고 웹 뷰가 Tor 로만 나가게 된 뒤에 페이지를 엽니다. 그 전에는 아무것도 읽지 않습니다.'),
|
||||||
busy: status.phase == TorPhase.starting || status.ready);
|
busy: status.phase == TorPhase.starting || status.ready);
|
||||||
}
|
}
|
||||||
final b = widget.viewBuilder;
|
final b = widget.viewBuilder;
|
||||||
if (b != null) return b(_host, _url);
|
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);
|
return TorWebView(clearProfile: _clearProfile, 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue