266 lines
11 KiB
Dart
266 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import '../core/local_proxy.dart';
|
|
import '../l10n/tr.dart';
|
|
import 'net_guard.dart';
|
|
import 'tor_service.dart';
|
|
import '../services/platform_info.dart';
|
|
import 'web_view_proxy_target.dart';
|
|
|
|
/// Android 웹 뷰 프록시 (androidx.webkit ProxyController - 앱의 모든 웹 뷰에 한꺼번에 걸린다).
|
|
/// - Tor 화면이 열려 있으면: 모든 웹 뷰가 Tor 로 (관리자 결정 10/10 - 일반 브라우저에는 안내 띠)
|
|
/// - 아니면: DNS 강제면 정한 DNS 로, 아니면 프록시 없음
|
|
/// 바꿀 때는 끝날 때까지 기다린다 (그 전에 페이지를 읽지 않게). Windows 는 웹 뷰마다 따로라 여기서 하지 않는다.
|
|
///
|
|
/// Android 웹 뷰는 프록시 인증 (407) 에 답하지 못한다 (노트 10+ 확인 10/11). 그래서 웹 뷰가 쓰는 길만 인증 없는 "문" 을 따로 둔다
|
|
/// (관리자 결정 10/11 A+B): 실행마다 고른 루프백 주소 127.x.y.z 의 빈 포트에, 웹 뷰 화면 (일반 브라우저 · Tor 화면) 이 보이는 동안만 연다.
|
|
/// 앱이 뒤로 가거나 다른 화면이면 닫고 웹 뷰는 닫힌 포트로 (페이지를 열지 못함 - 몰래 직접 나가지 않음). 앱의 HttpClient · yt-dlp · aria2 는
|
|
/// 127.0.0.1 의 비밀값 인증 프록시 ([ProxyAuth]) 를 그대로 쓴다.
|
|
class WebProxy {
|
|
WebProxy._();
|
|
|
|
static const _ch = MethodChannel('jj_mkvmaker/android');
|
|
|
|
/// Tor 화면이 열려 있는지 (일반 브라우저의 안내 띠도 이것을 본다)
|
|
static final torOpen = ValueNotifier<bool>(false);
|
|
|
|
/// 아직 남아 있는 Tor 웹 뷰 수. 하나라도 있으면 [torOpen] 이 꺼져도 Tor 쪽 (또는 닫힌 포트) 만 건다
|
|
/// (Tor 화면을 떠난 직후 · 앱이 뒤에 있어 화면에서 아직 빠지지 않은 Tor 웹 뷰가 일반 쪽으로 나가지 않게)
|
|
static int _torViews = 0;
|
|
|
|
/// Tor 웹 뷰가 생김 · 없어짐 (tor_page 의 웹 뷰). 마지막이 없어지면 [onLastTorView] (프로필 지우기 등)
|
|
static void torViewCreated() => _torViews++;
|
|
static Future<void> Function()? onLastTorView;
|
|
|
|
/// 살아 있는 일반 브라우저 웹 뷰 수와, 가려져도 소리를 멈추지 않는지 (환경 설정 "다른 화면으로 가면 소리 멈춤" 꺼짐).
|
|
/// 꺼져 있으면 일반 브라우저가 살아 있는 동안 (다른 화면 · 앱이 뒤로여도) DNS 문을 열어 둔다 - 다음 조각 · 다음 곡이 끊기지 않게
|
|
/// (관리자 결정 10/11). Tor 쪽은 늘 보이는 동안만
|
|
static int _browserViews = 0;
|
|
static bool Function() keepPlayingWhenHidden = () => false;
|
|
static void browserViewCreated() {
|
|
_browserViews++;
|
|
unawaited(apply());
|
|
}
|
|
|
|
static void browserViewDisposed() {
|
|
if (_browserViews > 0) _browserViews--;
|
|
unawaited(apply());
|
|
}
|
|
static void torViewDisposed() {
|
|
if (_torViews > 0) _torViews--;
|
|
if (_torViews > 0) return;
|
|
unawaited(apply().then((_) => onLastTorView?.call(), onError: (_) {}));
|
|
}
|
|
|
|
/// 지금 걸린 프록시 ('' = 없음)
|
|
static String _applied = '';
|
|
static String get applied => _applied;
|
|
static Future<void> _chain = Future.value();
|
|
|
|
/// 보이는 웹 뷰 화면 수 ([show] · [hide]) 와 앱이 앞에 있는지
|
|
static int _shown = 0;
|
|
static bool _foreground = true;
|
|
static int get shown => _shown;
|
|
|
|
/// 웹 뷰 문 (인증 없음): 정한 DNS 로 잇는 것 · Tor 로 잇는 것 (따로 - Tor 화면을 닫으면 Tor 문도 닫혀 다른 길로 새지 않게)
|
|
static LocalProxy? _dnsGate;
|
|
static LocalProxy? _torGate;
|
|
|
|
/// 이번 실행의 문 주소 (127.x.y.z). bind 가 안 되는 기기는 127.0.0.1
|
|
static InternetAddress? _gateAddress;
|
|
static InternetAddress _pickAddress() => randomLoopback();
|
|
|
|
/// 문이 닫혀 있을 때 웹 뷰에 거는 곳 (아무도 듣지 않는 포트 - 페이지를 열지 못하게)
|
|
static const closedProxy = 'http://127.0.0.1:9';
|
|
|
|
/// 지금 열린 문 (시험 · 작업 기록)
|
|
static String? get dnsGateUrl => _dnsGate?.running == true ? 'http://${_dnsGate!.host}:${_dnsGate!.port}' : null;
|
|
static String? get torGateUrl => _torGate?.running == true ? 'http://${_torGate!.host}:${_torGate!.port}' : null;
|
|
|
|
/// 작업 기록
|
|
static void Function(String)? log;
|
|
|
|
/// 웹 뷰 화면이 보이기 시작함. 끝나면 걸린 프록시 (그 뒤에 페이지를 연다)
|
|
static Future<String> show() {
|
|
_shown++;
|
|
return apply();
|
|
}
|
|
|
|
/// 웹 뷰 화면이 가려짐 · 닫힘
|
|
static Future<String> hide() {
|
|
if (_shown > 0) _shown--;
|
|
return apply();
|
|
}
|
|
|
|
/// 앱이 앞 · 뒤로 (main 의 AppLifecycleListener)
|
|
static Future<String> setForeground(bool v) {
|
|
_foreground = v;
|
|
return apply();
|
|
}
|
|
|
|
static Future<String> _openGate({required bool tor}) async {
|
|
var g = tor ? _torGate : _dnsGate;
|
|
if (g == null || !g.running) {
|
|
g = tor
|
|
? LocalProxy(TorConnector(() => TorService.instance.socksPort))
|
|
: LocalProxy(DnsConnector(NetGuard.instance.resolver), onError: NetGuard.instance.reportError);
|
|
final want = _gateAddress ??= _pickAddress();
|
|
try {
|
|
await g.start(address: want);
|
|
} catch (e) {
|
|
// 127.x.y.z 에 묶을 수 없는 기기: 127.0.0.1 로 (열려 있는 시간 원칙은 같다)
|
|
log?.call(trf('웹 뷰 프록시: {0} 에 열 수 없어 127.0.0.1 로 ({1})', [want.address, e]));
|
|
_gateAddress = InternetAddress.loopbackIPv4;
|
|
await g.start(address: _gateAddress);
|
|
}
|
|
if (tor) {
|
|
_torGate = g;
|
|
} else {
|
|
_dnsGate = g;
|
|
}
|
|
log?.call(trf('웹 뷰 프록시 문 열림: {0}:{1} ({2})', [g.host, g.port, tor ? 'Tor' : 'DNS']));
|
|
}
|
|
return 'http://${g.host}:${g.port}';
|
|
}
|
|
|
|
static Future<void> _closeGate({required bool tor}) async {
|
|
final g = tor ? _torGate : _dnsGate;
|
|
if (g == null) return;
|
|
if (tor) {
|
|
_torGate = null;
|
|
} else {
|
|
_dnsGate = null;
|
|
}
|
|
final at = '${g.host}:${g.port}';
|
|
await g.close();
|
|
log?.call(trf('웹 뷰 프록시 문 닫힘: {0} ({1})', [at, tor ? 'Tor' : 'DNS']));
|
|
}
|
|
|
|
/// 이 기기 웹 뷰가 Tor 화면에 필요한 기능을 갖췄는지: 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트 (WebRTC 끄기).
|
|
/// 없는 것의 이름 목록 (모두 있으면 빈 목록)
|
|
static Future<List<String>> torMissingFeatures() async {
|
|
if (!Platform.isAndroid) return const [];
|
|
try {
|
|
final m = await _ch.invokeMapMethod<String, bool>('torWebSupport') ?? const {};
|
|
return [
|
|
if (m['proxyOverride'] != true) 'PROXY_OVERRIDE',
|
|
if (m['multiProfile'] != true) 'MULTI_PROFILE',
|
|
if (m['documentStartScript'] != true) 'DOCUMENT_START_SCRIPT',
|
|
];
|
|
} catch (e) {
|
|
return ['$e'];
|
|
}
|
|
}
|
|
|
|
/// 웹 뷰에 프록시를 거는 곳 (이 기기에 맞는 것 - Windows 는 쓰지 않음). 조립 · 시험이 바꿔 끼운다 (M1)
|
|
static WebViewProxyTarget target = WebViewProxyTarget.forPlatform(PlatformInfo.host);
|
|
static bool get _android => target.active;
|
|
|
|
/// 지금 상태에 맞게 다시 건다 (차례로 - 겹치지 않게). 끝나면 걸린 프록시
|
|
static Future<String> apply() {
|
|
if (!_android) return Future.value('');
|
|
final f = _chain.then((_) => _apply());
|
|
_chain = f.then((_) {}, onError: (_) {});
|
|
return f;
|
|
}
|
|
|
|
static Future<String> _apply() async {
|
|
final shownActive = _shown > 0 && _foreground;
|
|
final tor = torOpen.value || _torViews > 0;
|
|
final dns = NetGuard.instance.config().forced;
|
|
// Tor 는 보이는 동안만. DNS 는 보이는 동안 + 소리를 멈추지 않는 일반 브라우저가 살아 있는 동안
|
|
final active = tor ? shownActive : shownActive || (_browserViews > 0 && keepPlayingWhenHidden());
|
|
String want = '';
|
|
if (tor || dns) {
|
|
// 쓸 웹 뷰가 없으면 문을 닫고 닫힌 포트로 (뒤에 남은 웹 뷰가 직접 나가지 않게)
|
|
want = active ? await _openGate(tor: tor) : closedProxy;
|
|
}
|
|
// 쓰지 않는 문은 닫는다 (프록시를 바꾼 뒤)
|
|
Future<void> closeUnused() async {
|
|
if (!(active && tor)) await _closeGate(tor: true);
|
|
if (!(active && !tor && dns)) await _closeGate(tor: false);
|
|
}
|
|
|
|
if (want == _applied) {
|
|
await closeUnused();
|
|
return _applied;
|
|
}
|
|
// Tor 화면: localhost 까지 Tor 로 (이 기기 · 집 안을 들여다보지 못하게). DNS 강제: 기본 (이 기기 주소는 직접)
|
|
await target.set(want, removeImplicitRules: tor);
|
|
_applied = want;
|
|
debugPrint(trf('웹 뷰 프록시: {0}', [want.isEmpty ? tr('없음') : want]));
|
|
await closeUnused();
|
|
return want;
|
|
}
|
|
|
|
/// 처음 상태로 (문을 모두 닫고 수를 0 으로)
|
|
static Future<void> reset() async {
|
|
_shown = 0;
|
|
_foreground = true;
|
|
_applied = '';
|
|
_torViews = 0;
|
|
_browserViews = 0;
|
|
keepPlayingWhenHidden = () => false;
|
|
await _closeGate(tor: true);
|
|
await _closeGate(tor: false);
|
|
_gateAddress = null;
|
|
}
|
|
|
|
/// Tor 화면 전용 프로필 지우기 (쿠키 · 저장소 · 캐시). 웹 뷰가 없어진 뒤에 부른다
|
|
static Future<bool> clearTorProfile() async {
|
|
if (!Platform.isAndroid) return true;
|
|
try {
|
|
return await _ch.invokeMethod<bool>('torProfileClear', {'name': torProfileName}) ?? false;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Tor 화면 웹 뷰의 프로필 이름 (androidx.webkit Profile)
|
|
static const torProfileName = 'jj_tor';
|
|
|
|
/// 앱 안 프록시 (DNS 강제 · Tor 다리) 의 포트들 (열려 있는 것만)
|
|
static List<int> proxyPorts() =>
|
|
[?NetGuard.instance.port, ?TorService.instance.bridgeOpenPort];
|
|
|
|
/// Windows 웹 뷰 설정 jjProxyAuth: "아이디:비밀값@포트,포트" (packages/flutter_inappwebview_windows/JJ_PATCH.md).
|
|
/// 그 포트의 127.0.0.1 프록시가 realm JJ_MKVMaker 로 물을 때만 답한다
|
|
static String proxyAuthSetting({int? extraPort}) {
|
|
final a = ProxyAuth.instance;
|
|
final ports = {...proxyPorts(), ?extraPort};
|
|
return '${a.user}:${a.password}@${ports.join(',')}';
|
|
}
|
|
}
|
|
|
|
/// Android: 웹 뷰 화면 (일반 브라우저 · Tor 화면) 이 맨 앞에 보이는 동안만 웹 뷰 프록시 문을 연다 ([WebProxy.show] · [WebProxy.hide]).
|
|
/// [webProxyReady] 가 끝난 뒤에 페이지를 연다. Windows 에서는 아무것도 하지 않는다
|
|
mixin WebProxyVisibility<T extends StatefulWidget> on State<T> {
|
|
bool _proxyVisible = false;
|
|
Future<String> _proxyReady = Future.value('');
|
|
|
|
/// 지금 화면에 맞게 걸린 프록시 (보이기 시작할 때마다 새로)
|
|
Future<String> get webProxyReady => _proxyReady;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (!WebProxy._android) return;
|
|
final visible = ModalRoute.of(context)?.isCurrent ?? true;
|
|
if (visible == _proxyVisible) return;
|
|
_proxyVisible = visible;
|
|
_proxyReady = visible ? WebProxy.show() : WebProxy.hide();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_proxyVisible) {
|
|
_proxyVisible = false;
|
|
unawaited(WebProxy.hide());
|
|
}
|
|
super.dispose();
|
|
}
|
|
}
|