741 lines
31 KiB
Dart
741 lines
31 KiB
Dart
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<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 연결 100% · 프록시 적용 · 전용 프로필이 모두 된 뒤에야 웹 뷰를 만든다 ([torViewMayLoad]).
|
|
class TorPage extends StatefulWidget {
|
|
final AppController c;
|
|
|
|
/// 시험용 웹 뷰 대체 (불렸다 = 페이지를 읽기 시작했다)
|
|
final Widget Function(TorViewHost host, String url)? viewBuilder;
|
|
|
|
/// 시험용: Tor 시작 · 기능 확인 · 프록시 걸기를 바꾼다
|
|
final Future<void> Function()? startTor;
|
|
|
|
/// 시험용: 떠난 뒤 정리 대신 불린다
|
|
final Future<void> Function()? onLeft;
|
|
final Future<List<String>> Function()? missingFeatures;
|
|
final Future<bool> 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<void> 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<void>(settings: const RouteSettings(name: routeName), builder: (_) => TorPage(c: c)));
|
|
}
|
|
|
|
@override
|
|
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;
|
|
late final _address = TextEditingController(text: _url);
|
|
final _addressFocus = FocusNode();
|
|
TorNav? _nav;
|
|
double _progress = 1;
|
|
bool _canBack = false, _canFwd = false;
|
|
|
|
/// 이 기기 웹 뷰에 없는 기능 (있으면 화면을 열지 않는다 - Tor 를 거치지 않을 수 있으므로)
|
|
List<String>? _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.tor.homeUrl;
|
|
setState(() {
|
|
_away = false;
|
|
_url = home;
|
|
_address.text = home;
|
|
_progress = 1;
|
|
_canBack = _canFwd = false;
|
|
});
|
|
if (_begun && _missing == null) unawaited(_startTor());
|
|
}
|
|
|
|
/// 떠남 (다른 화면 · 앱이 뒤로): 웹 뷰를 먼저 치운 뒤 프록시 · 다리를 되돌리고, 남기지 않으면 지우고, tor 는 잠시 뒤 끈다
|
|
Future<void> _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.tor.keepData, widget.c.settings.tor.idleMinutes);
|
|
}
|
|
|
|
/// 떠난 뒤 정리 (화면을 닫았을 때도 같다)
|
|
static Future<void> _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<void> _clearProfile() async {
|
|
// 쓰는 웹 뷰가 남아 있어도 쿠키 · 저장소는 지운다 (MainActivity.clearWebProfile) - 프로필째 지우기는 웹 뷰가 없어진 뒤
|
|
for (var i = 0; i < 5 && TorPage._visible == 0 && !await WebProxy.clearTorProfile(); i++) {
|
|
await Future<void>.delayed(const Duration(seconds: 1));
|
|
}
|
|
}
|
|
|
|
Future<void> _begin() async {
|
|
if (_begun) return;
|
|
_begun = true;
|
|
final c = widget.c;
|
|
if (!c.settings.tor.noticeSeen && mounted) {
|
|
await showTorNotice(context);
|
|
await c.updateSettings((x) => x.tor.noticeSeen = true);
|
|
}
|
|
final missing = await (widget.missingFeatures ?? WebProxy.torMissingFeatures)();
|
|
if (!mounted) return;
|
|
if (missing.isNotEmpty) {
|
|
setState(() => _missing = missing);
|
|
return;
|
|
}
|
|
await _startTor();
|
|
}
|
|
|
|
Future<void> _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<void> _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.tor.keepData, idle = widget.c.settings.tor.idleMinutes;
|
|
// 남은 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.tor.homeUrl) : 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<String>(
|
|
tooltip: tr('더 보기'),
|
|
icon: const Icon(Icons.more_vert, size: 20),
|
|
onSelected: (v) => switch (v) {
|
|
'home' => _go(widget.c.settings.tor.homeUrl),
|
|
'notice' => showTorNotice(context),
|
|
_ => Navigator.push(context, MaterialPageRoute<void>(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<void> _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.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;
|
|
}
|
|
}
|
|
}
|