229 lines
9.8 KiB
Dart
229 lines
9.8 KiB
Dart
/// 약속: 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;
|
|
}
|
|
}
|
|
}
|