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 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 createState() => _EdgeViewState(); } Future? _env; /// 앱 안 웹뷰를 화면이 닫혀도 살려 둔다 (다시 열면 보던 페이지 · 재생 위치 · 뒤로 가기 기록 그대로) final _keepAliveShared = InAppWebViewKeepAlive(); bool _keepAliveUsed = false; /// 로그인 · 쿠키가 유지되는 데이터 폴더를 쓰는 브라우저 환경 (Windows, 한 번만 만들어 재사용). /// Android 는 환경이 없다 (웹 뷰 프록시는 화면이 보일 때 [WebProxyVisibility] 가 건다) Future 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 _createEnvironment(String dir) async { Future 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 with WebProxyVisibility { /// 살려 둔 웹뷰를 이 화면이 쓰는지 (브라우저 화면은 하나뿐이지만, 혹시 둘이면 둘째는 새 웹뷰) InAppWebViewKeepAlive? _keepAlive; /// 웹 뷰를 만들기 전에 기다릴 것: Windows 는 환경, Android 는 웹 뷰 프록시 (DNS 강제 · Tor 화면, 관리자 결정 10/11). /// 처음 한 번만 기다린다 (뒤에 가려졌다 보이면 프록시만 다시 건다 - 웹 뷰는 그대로) late final Future _ready = Platform.isAndroid ? webProxyReady.then((_) => 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( 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 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.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 pauseMedia() async { try { await c.evaluateJavascript(source: pauseMediaScript); } catch (_) {} } @override Future runScript(String js) async { try { await c.evaluateJavascript(source: js); } catch (_) {} } @override Future evaluate(String js) async { try { return await c.evaluateJavascript(source: js); } catch (_) { return null; } } /// Edge 는 앱이 넣는 스크립트 (번역 · 동영상 찾기) 는 JavaScript 를 꺼도 실행된다. Android 는 꺼지면 안 된다. @override Future 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 clearData() async { Future quiet(Future 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 exportCookies() async { if (dataDir.isEmpty) return; final cm = CookieManager.instance(webViewEnvironment: env); final all = []; 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 load(String url) => c.loadUrl(urlRequest: URLRequest(url: WebUri(url))); @override Future back() => c.goBack(); @override Future forward() => c.goForward(); @override Future reload() => c.reload(); @override Future stop() => c.stopLoading(); }