JJ_MKVMaker/lib/app/web_proxy.dart

289 lines
12 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../core/local_proxy.dart';
import '../l10n/tr.dart';
import 'net_guard.dart';
import 'tor_service.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 shownForTest => _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() {
final r = math.Random.secure();
return InternetAddress('127.${1 + r.nextInt(254)}.${1 + r.nextInt(254)}.${2 + r.nextInt(253)}');
}
/// 문이 닫혀 있을 때 웹 뷰에 거는 곳 (아무도 듣지 않는 포트 - 페이지를 열지 못하게)
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'];
}
}
/// 시험용: Android 처럼 돌리고 ([debugAndroid]), 웹 뷰에 프록시를 거는 대신 기록 ([debugSetProxy], '' = 없앰)
@visibleForTesting
static bool debugAndroid = false;
@visibleForTesting
static Future<void> Function(String url, bool removeImplicitRules)? debugSetProxy;
static bool get _android => Platform.isAndroid || debugAndroid;
/// 지금 상태에 맞게 다시 건다 (차례로 - 겹치지 않게). 끝나면 걸린 프록시
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;
}
if (debugSetProxy case final f?) {
await f(want, tor);
_applied = want;
await closeUnused();
return want;
}
final pc = ProxyController.instance();
if (want.isEmpty) {
await pc.clearProxyOverride();
} else {
await pc.setProxyOverride(
settings: ProxySettings(
proxyRules: [ProxyRule(url: want)],
// Tor 화면: localhost 까지 Tor 로 (이 기기 · 집 안을 들여다보지 못하게). DNS 강제: 기본 (이 기기 주소는 직접)
removeImplicitRules: torOpen.value,
));
}
_applied = want;
debugPrint(trf('웹 뷰 프록시: {0}', [want.isEmpty ? tr('없음') : want]));
await closeUnused();
return want;
}
/// 시험용: 처음 상태로
@visibleForTesting
static Future<void> debugReset() 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();
}
}