import 'dart:async'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import 'package:webview_cef/webview_cef.dart' as cef; import '../../app/app_controller.dart'; import '../../app/net_guard.dart'; import '../../core/download_detect.dart' show CookieExport, CookieRecord, isLoginCookieDomain; import '../../core/web_address.dart'; import '../../l10n/tr.dart'; import '../../platform/windows/cef_runtime.dart'; import '../cef_setup.dart' show confirmCefWhenElevated; import '../theme.dart'; import 'edge_view.dart'; import 'web_nav.dart'; // ───────── 앱 안 Chrome (CEF) ───────── /// 약속: 환경 설정에서 Chrome 을 고르고 엔진이 이번 실행에 준비되어 있으면 Chrome, 아니면 Edge 웹 뷰. /// [cefTry] 는 Chrome 엔진 [다시 시도] 때마다 늘린다 (새로 만든다) Widget browserEngineView(AppController c, {required BrowserHost host, required String url, required int cefTry, required VoidCallback onCefRetry}) { final b = c.settings.browser; if (!(b.engine == 'chrome' && CefRuntime.readyThisRun)) { return EdgeView(host: host, initialUrl: url, dataDir: c.settings.webViewDataDir, javaScript: b.javaScript); } // 207: 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않는다 (보안) - 이유와 고를 것 if (c.services.platform.elevated && !b.cefWhenElevated) return CefElevatedBlocked(c: c); // Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다 return CefView( key: ValueKey('${b.javaScript}|$cefTry'), host: host, initialUrl: url, dataDir: c.settings.webViewDataDir, javaScript: b.javaScript, // 207: 시작하지 못하면 사용자가 바로 고를 수 있게 onUseEdge: () => c.updateSettings((x) => x.browser.engine = 'edge'), onRetry: onCefRetry, cefWhenElevated: b.cefWhenElevated, elevated: c.services.platform.elevated); } /// 내장 Chrome 엔진 (환경 설정에서 고르고 내려받은 경우). 약속은 [EdgeView] 와 같다 (host 로 알리고 attach 로 조작을 준다): /// 주소 · 제목 · 진행 · 동영상 페이지 판별 · 뒤로 / 앞으로 · YouTube 쿠키를 yt-dlp 용으로 내보내기. class CefView extends StatefulWidget { final BrowserHost host; final String initialUrl; final String dataDir; final bool javaScript; final VoidCallback? onUseEdge; final VoidCallback? onRetry; final bool cefWhenElevated; /// 관리자 권한으로 실행 중인지 (조립의 PlatformInfo) final bool elevated; const CefView( {super.key, required this.host, required this.initialUrl, required this.dataDir, required this.javaScript, this.onUseEdge, this.onRetry, this.cefWhenElevated = false, this.elevated = false}); /// 207: 내장 Chrome 엔진이 이 시간 안에 준비되지 않으면 빙글이만 돌게 두지 않고 "시작하지 못했습니다" 와 고를 것을 보인다 static const startTimeout = Duration(seconds: 20); @override State createState() => _CefViewState(); } /// Chrome 엔진 시작은 프로그램이 켜져 있는 동안 한 번만 Future? _cefStarted; /// Chrome 엔진을 시작할 때 준 프록시 주소 (문을 다시 열 때 같아야 한다) String? _cefProxy; /// 207: 관리자 권한으로 실행 중이라 Chrome 엔진을 시작하지 않음 - 이유와 [Edge 엔진으로 바꾸기] [그래도 Chrome 엔진 쓰기…] class CefElevatedBlocked extends StatelessWidget { final AppController c; const CefElevatedBlocked({super.key, required this.c}); @override Widget build(BuildContext context) => Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.admin_panel_settings_outlined, size: 40, color: JjColors.textDim), const SizedBox(height: 12), Text(tr('관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center), const SizedBox(height: 8), Text( tr('내장 Chrome 엔진은 샌드박스가 꺼져 있어 관리자 권한으로 돌리면 위험합니다. ' 'Edge 엔진을 쓰거나, 관리자 권한 없이 프로그램을 실행하세요.'), style: TextStyle(color: JjColors.textDim), textAlign: TextAlign.center), const SizedBox(height: 16), Wrap(spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ FilledButton( onPressed: () => c.updateSettings((x) => x.browser.engine = 'edge'), child: Text(tr('Edge 엔진으로 바꾸기')), ), OutlinedButton( onPressed: () => confirmCefWhenElevated(context, c), child: Text(tr('그래도 Chrome 엔진 쓰기…')), ), ]), ]), ), ), ); } class _CefViewState extends State { late final cef.WebViewController _c = cef.WebviewManager().createWebView( loading: const Center(child: CircularProgressIndicator())); Object? _error; /// 207: 시작이 끝나지 않을 때 Timer? _startTimer; bool _timedOut = false; /// 웹 뷰 만들기를 시작했는지 (엔진 시작을 기다리는 중에 닫으면 webview_cef 의 dispose 가 LateInitializationError 를 낸다) bool _initCalled = false; /// Chrome 엔진 문을 열었는지 (닫을 때 짝을 맞추려고) bool _gateOpened = false; @override void initState() { super.initState(); _startTimer = Timer(CefView.startTimeout, () { if (!mounted || _c.value) return; setState(() { _timedOut = true; _error = trf('{0}초 안에 준비되지 않았습니다.', [CefView.startTimeout.inSeconds]); }); }); _start(); } Future _start() async { final h = widget.host; try { // DNS 강제: Chrome 엔진 문 (인증 없음, 숨긴 루프백 주소, 이 웹 뷰가 있는 동안만 - 207). 엔진이 시작할 때 정한 주소와 같아야 한다 _gateOpened = true; final gate = await NetGuard.instance.openCefGate(); if (_cefStarted != null && gate != _cefProxy) { throw StateError(tr('Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.')); } // 로그인 · 쿠키를 Edge 와 따로 보관 (데이터 폴더 아래 cef) // DNS 강제: Chrome 엔진도 앱 안 프록시로 (packages/webview_cef/JJ_PATCH.md 6 - 시작할 때 정해짐) _cefProxy ??= gate; _cefStarted ??= cef.WebviewManager().initialize( rootCachePath: widget.dataDir.isEmpty ? null : p.join(p.dirname(widget.dataDir), 'cef'), proxyServer: gate, // 207: 관리자 권한이면 Chromium 이 스스로 끝나므로, 사용자가 설정에서 고른 때만 그대로 돈다 (JJ_PATCH.md 7) doNotDeElevate: widget.elevated && widget.cefWhenElevated); await _cefStarted; _c.setWebviewListener(cef.WebviewEventsListener( onUrlChanged: (u) { h.onUrl(u); // YouTube 처럼 페이지 이동 없이 주소만 바뀌는 사이트 Future.delayed(const Duration(milliseconds: 800), _check); }, onTitleChanged: h.onTitle, // 읽기 시작 · 끝 (본문만 - packages/webview_cef/JJ_PATCH.md 3) onLoadStart: (_, u) { if (u.startsWith('http')) h.onUrl(u); h.onProgress(0.1); h.onVideo(false); }, onLoadEnd: (_, u) async { h.onProgress(1); await _check(); final host = Uri.tryParse(u)?.host ?? ''; if (host.endsWith('youtube.com') || host.endsWith('google.com')) { try { await _CefNav(_c, widget.dataDir).exportCookies(); } catch (_) {} } }, )); _initCalled = true; await _c.initialize(normalizeAddress(widget.initialUrl), javaScript: widget.javaScript); if (_timedOut) return; _startTimer?.cancel(); h.attach(_CefNav(_c, widget.dataDir)); h.onHistory(true, true); // Chrome 엔진은 뒤로 · 앞으로 가능 여부를 알려 주지 않는다 if (mounted) setState(() {}); } catch (e) { // ignore: avoid_print if (mounted) setState(() => _error = e); } } Future _check() async { try { final n = await _c.evaluateJavascript("document.querySelectorAll('video').length"); widget.host.onVideo((n is num ? n : int.tryParse('$n') ?? 0) > 0); } catch (_) {} } @override void dispose() { _startTimer?.cancel(); if (_initCalled) unawaited(_c.dispose().catchError((Object _) {})); if (_gateOpened) unawaited(NetGuard.instance.closeCefGate()); super.dispose(); } @override Widget build(BuildContext context) { if (_error != null) { // 207: 빙글이만 돌게 두지 않는다 - 이유와 바로 고를 것 return Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 40, color: JjColors.textDim), const SizedBox(height: 12), Text(tr('Chrome 엔진을 시작하지 못했습니다'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center), const SizedBox(height: 8), Text('$_error', style: TextStyle(color: JjColors.textDim), textAlign: TextAlign.center), const SizedBox(height: 16), Wrap(spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ if (widget.onUseEdge != null) FilledButton(onPressed: widget.onUseEdge, child: Text(tr('Edge 엔진으로 바꾸기'))), if (widget.onRetry != null) OutlinedButton(onPressed: widget.onRetry, child: Text(tr('다시 시도'))), ]), ]), ), ), ); } return ValueListenableBuilder( valueListenable: _c, builder: (_, ready, _) => ready ? _c.webviewWidget : _c.loadingWidget, ); } } class _CefNav implements WebNav { final cef.WebViewController c; final String dataDir; _CefNav(this.c, this.dataDir); @override Future pauseMedia() async { try { await c.evaluateJavascript(pauseMediaScript); } catch (_) {} } @override Future runScript(String js) async { try { await c.evaluateJavascript(js); } catch (_) {} } /// JavaScript 를 끈 Chrome 엔진에서는 결과를 받지 못한다 (null) @override Future evaluate(String js) async { try { return await c.evaluateJavascript(js).timeout(const Duration(seconds: 10)); } catch (_) { return null; } } /// Chrome 엔진은 만든 뒤에 바꿀 수 없다: 화면이 JavaScript 설정을 열쇠로 웹뷰를 새로 만든다 ([CefView]) @override Future setJavaScript(bool on) async {} /// YouTube · Google 등의 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다). /// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다. @override Future clearData() async { // Chrome 엔진은 모두 지우기가 없어 쿠키를 하나씩 지운다 try { final raw = await cef.WebviewManager().visitAllCookies(); if (raw is Map) { for (final e in raw.entries) { if (e.value is! Map) continue; for (final name in (e.value as Map).keys) { await cef.WebviewManager().deleteCookie('${e.key}', '$name'); } } } } catch (_) {} } @override Future exportCookies() async { if (dataDir.isEmpty || !CookieExport.enabled) return; final raw = await cef.WebviewManager().visitAllCookies(); if (raw is! Map) return; final all = []; final later = DateTime.now().add(const Duration(days: 30)).millisecondsSinceEpoch ~/ 1000; raw.forEach((domain, cookies) { final d = '$domain'; if (!isLoginCookieDomain(d) || cookies is! Map) return; cookies.forEach((name, value) => all.add(CookieRecord( name: '$name', value: '$value', domain: d, expires: later, secure: true))); }); await writeLoginCookies(dataDir, all); } @override Future load(String url) => c.loadUrl(url); @override Future back() => c.goBack(); @override Future forward() => c.goForward(); @override Future reload() => c.reload(); @override Future stop() async {} // Chrome 엔진에는 멈춤이 없다 }