295 lines
12 KiB
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();
|
|
}
|