JJ_MKVMaker/lib/ui/browser/edge_view.dart

295 lines
12 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../app/net_guard.dart';
import '../../app/web_proxy.dart';
import '../../core/download_detect.dart' show CookieExport, CookieRecord, isLoginCookieDomain;
import '../../core/web_address.dart';
import '../../l10n/tr.dart';
import '../../platform/windows/com_guard.dart';
import '../bottom_inset_hold.dart';
import 'web_nav.dart';
// ───────── 앱 안 Edge 웹뷰 ─────────
/// 204: Android 만 (Windows 창에는 아래 시스템 막대가 없음)
Widget _holdBottom(Widget webView) => Platform.isAndroid ? BottomInsetHold(child: webView) : webView;
/// Edge · Android 웹뷰 설정 (처음 만들 때와 JavaScript 켜기 · 끄기에 같이 쓴다)
InAppWebViewSettings _webSettings({required bool javaScript}) => _JjWebSettings(javaScript: javaScript);
class _JjWebSettings extends InAppWebViewSettings {
_JjWebSettings({required bool javaScript})
: super(
javaScriptEnabled: javaScript,
mediaPlaybackRequiresUserGesture: false,
supportMultipleWindows: false,
// Android: 텍스처로 그린다. 기본 hybrid composition 은 백그라운드로 실행 중 화면 (Activity) 이 새로 붙을 때
// Impeller 가 "EGL Bad Access" 로 그리지 못해 앱 화면이 비어 버린다.
useHybridComposition: false,
);
/// Windows: 앱 안 프록시 (DNS 강제) 의 인증에 답하게 (packages/flutter_inappwebview_windows/JJ_PATCH.md)
@override
Map<String, dynamic> toMap() => {
...super.toMap(),
if (Platform.isWindows) 'jjProxyAuth': WebProxy.proxyAuthSetting(),
};
}
/// 앱 안 Edge (Windows WebView2) · Android 웹뷰. 약속: [host] 로 주소 · 제목 · 진행 · 동영상 · 기록 · 본문 오류를 알리고,
/// 붙을 때 [BrowserHost.attach] 로 조작 ([WebNav]) 을 준다. 웹뷰는 화면이 닫혀도 살려 둔다 (다시 열면 보던 페이지 그대로)
class EdgeView extends StatefulWidget {
final BrowserHost host;
final String initialUrl;
final String dataDir;
final bool javaScript;
const EdgeView({super.key, required this.host, required this.initialUrl, required this.dataDir, required this.javaScript});
@override
State<EdgeView> createState() => _EdgeViewState();
}
Future<WebViewEnvironment?>? _env;
/// 앱 안 웹뷰를 화면이 닫혀도 살려 둔다 (다시 열면 보던 페이지 · 재생 위치 · 뒤로 가기 기록 그대로)
final _keepAliveShared = InAppWebViewKeepAlive();
bool _keepAliveUsed = false;
/// 로그인 · 쿠키가 유지되는 데이터 폴더를 쓰는 브라우저 환경 (Windows, 한 번만 만들어 재사용).
/// Android 는 환경이 없다 (웹 뷰 프록시는 화면이 보일 때 [WebProxyVisibility] 가 건다)
Future<WebViewEnvironment?> browserEnvironment(String dir) => Platform.isWindows ? _env ??= _createEnvironment(dir) : Future.value(null);
/// DNS 강제 (환경 설정 > 네트워크 · DNS): Edge 의 모든 연결을 앱 안 프록시로 (이름을 정한 DNS 로 풂). 환경을 만들 때 정해진다
String? edgeBrowserArguments() {
final proxy = NetGuard.instance.browserProxy;
return proxy == null ? null : '--proxy-server=$proxy --dns-prefetch-disable';
}
Future<WebViewEnvironment?> _createEnvironment(String dir) async {
Future<WebViewEnvironment> create() => WebViewEnvironment.create(
settings: WebViewEnvironmentSettings(userDataFolder: dir.isEmpty ? null : dir, additionalBrowserArguments: edgeBrowserArguments()));
try {
// COM 이 풀려 있으면 "CoInitialize 가 호출되지 않았습니다" 로 실패한다 → 만들기 전에 다시 준비
ComGuard.ensure();
try {
return await create();
} catch (_) {
ComGuard.ensure();
return await create();
}
} catch (_) {
// 실패한 결과를 붙들고 있지 않는다: 브라우저를 다시 열면 새로 시도
_env = null;
rethrow;
}
}
class _EdgeViewState extends State<EdgeView> with WebProxyVisibility {
/// 살려 둔 웹뷰를 이 화면이 쓰는지 (브라우저 화면은 하나뿐이지만, 혹시 둘이면 둘째는 새 웹뷰)
InAppWebViewKeepAlive? _keepAlive;
/// 웹 뷰를 만들기 전에 기다릴 것: Windows 는 환경, Android 는 웹 뷰 프록시 (DNS 강제 · Tor 화면, 관리자 결정 10/11).
/// 처음 한 번만 기다린다 (뒤에 가려졌다 보이면 프록시만 다시 건다 - 웹 뷰는 그대로)
late final Future<WebViewEnvironment?> _ready = Platform.isAndroid
? webProxyReady.then<WebViewEnvironment?>((_) => null, onError: (_) => null)
: browserEnvironment(widget.dataDir);
@override
void initState() {
super.initState();
if (!_keepAliveUsed) {
_keepAliveUsed = true;
_keepAlive = _keepAliveShared;
}
WebProxy.browserViewCreated();
}
@override
void dispose() {
if (_keepAlive != null) _keepAliveUsed = false;
WebProxy.browserViewDisposed();
super.dispose();
}
@override
Widget build(BuildContext context) => FutureBuilder<WebViewEnvironment?>(
future: _ready,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(
child: Text(trf('웹 브라우저(Edge WebView2)를 시작할 수 없습니다.\n{0}', [snap.error]),
textAlign: TextAlign.center),
);
}
final h = widget.host;
Future<void> check(InAppWebViewController c) async {
h.onHistory(await c.canGoBack(), await c.canGoForward());
try {
final n = await c.evaluateJavascript(source: "document.querySelectorAll('video').length");
h.onVideo((n is num ? n : int.tryParse('$n') ?? 0) > 0);
} catch (_) {}
}
// 204: 아래 시스템 막대가 잠깐 커질 때 (제스처 · 작업 표시줄) 웹 뷰 크기를 바꾸지 않게 (검은 띠가 올라오던 것)
return _holdBottom(InAppWebView(
keepAlive: _keepAlive,
webViewEnvironment: snap.data,
initialUrlRequest: URLRequest(url: WebUri(normalizeAddress(widget.initialUrl))),
initialSettings: _webSettings(javaScript: widget.javaScript),
onWebViewCreated: (c) async {
final nav = _InAppNav(c, snap.data, widget.dataDir);
h.attach(nav);
// 살려 둔 웹뷰는 처음 설정 그대로이므로 지금 설정에 맞춘다
await nav.setJavaScript(widget.javaScript);
// 살려 둔 웹뷰를 다시 붙였으면 보던 주소 · 제목 · 뒤로 가기 상태를 다시 알린다
final u = await c.getUrl();
if (u != null) h.onUrl(u.toString());
final t = await c.getTitle();
if (t != null) h.onTitle(t);
await check(c);
},
onLoadStart: (c, u) {
h.onUrl(u?.toString() ?? '');
h.onVideo(false);
},
onLoadStop: (c, u) async {
h.onUrl(u?.toString() ?? '');
h.onProgress(1);
await check(c);
// YouTube · Google 페이지를 보면 로그인 쿠키를 내보내 둔다 (다운로드에 사용)
final host = u?.host ?? '';
if (host.endsWith('youtube.com') || host.endsWith('google.com')) {
try {
await _InAppNav(c, snap.data, widget.dataDir).exportCookies();
} catch (_) {}
}
},
// YouTube 처럼 페이지 이동 없이 주소만 바뀌는 사이트
onUpdateVisitedHistory: (c, u, _) async {
h.onUrl(u?.toString() ?? '');
await Future<void>.delayed(const Duration(milliseconds: 800));
await check(c);
},
onTitleChanged: (c, t) => h.onTitle(t ?? ''),
onProgressChanged: (c, p) => h.onProgress(p / 100),
// 250: 본문을 읽지 못함 (DNS 강제 프록시가 이름을 찾지 못하면 net::ERR_TUNNEL_CONNECTION_FAILED)
onReceivedError: (c, req, err) {
if (req.isForMainFrame ?? true) h.onMainFrameError?.call(req.url.toString(), err.description);
},
));
},
);
}
class _InAppNav implements WebNav {
final InAppWebViewController c;
final WebViewEnvironment? env;
final String dataDir;
_InAppNav(this.c, this.env, this.dataDir);
@override
Future<void> pauseMedia() async {
try {
await c.evaluateJavascript(source: pauseMediaScript);
} catch (_) {}
}
@override
Future<void> runScript(String js) async {
try {
await c.evaluateJavascript(source: js);
} catch (_) {}
}
@override
Future<Object?> evaluate(String js) async {
try {
return await c.evaluateJavascript(source: js);
} catch (_) {
return null;
}
}
/// Edge 는 앱이 넣는 스크립트 (번역 · 동영상 찾기) 는 JavaScript 를 꺼도 실행된다. Android 는 꺼지면 안 된다.
@override
Future<void> setJavaScript(bool on) async {
try {
if ((await c.getSettings())?.javaScriptEnabled == on) return;
await c.setSettings(settings: _webSettings(javaScript: on));
await c.reload();
} catch (_) {}
}
static const _cookieSites = [
'https://www.youtube.com/',
'https://m.youtube.com/',
'https://accounts.google.com/',
'https://www.google.com/',
// 28: 로그인해야 받을 수 있는 동영상이 많은 곳 (앱 안 브라우저에서 로그인하면 yt-dlp 가 같은 로그인으로 받는다)
'https://www.instagram.com/',
'https://x.com/',
'https://twitter.com/',
'https://chzzk.naver.com/',
'https://www.naver.com/',
'https://nid.naver.com/',
'https://tv.naver.com/',
];
@override
Future<void> clearData() async {
Future<void> quiet(Future<void> Function() f) async {
try {
await f();
} catch (_) {} // 그 엔진이 지원하지 않는 것은 건너뛴다
}
await quiet(() => CookieManager.instance(webViewEnvironment: env).deleteAllCookies());
await quiet(() => WebStorageManager.instance().deleteAllData());
await quiet(() => c.clearHistory());
await quiet(() => InAppWebViewController.clearAllCache());
}
@override
Future<void> exportCookies() async {
if (dataDir.isEmpty) return;
final cm = CookieManager.instance(webViewEnvironment: env);
final all = <CookieRecord>[];
if (!CookieExport.enabled) return;
for (final site in _cookieSites) {
if (!isLoginCookieDomain(Uri.parse(site).host)) continue;
for (final k in await cm.getCookies(url: WebUri(site))) {
// 만료 시각: Windows 는 초, Android 등은 밀리초로 준다 → 초로 통일
final raw = k.expiresDate ?? 0;
final exp = raw > 100000000000 ? raw ~/ 1000 : raw;
all.add(CookieRecord(
name: k.name,
value: '${k.value ?? ''}',
domain: k.domain ?? Uri.parse(site).host,
path: k.path ?? '/',
expires: (k.isSessionOnly ?? false) || exp <= 0 ? 0 : exp,
secure: k.isSecure ?? false,
httpOnly: k.isHttpOnly ?? false,
));
}
}
await writeLoginCookies(dataDir, all);
}
@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() => c.stopLoading();
}