JJ_MKVMaker/lib/ui/browser/cef_view.dart

327 lines
13 KiB
Dart

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 '../../platform/windows/elevation.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 (Elevation.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);
}
/// 내장 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;
const CefView(
{super.key,
required this.host,
required this.initialUrl,
required this.dataDir,
required this.javaScript,
this.onUseEdge,
this.onRetry,
this.cefWhenElevated = false});
/// 207: 내장 Chrome 엔진이 이 시간 안에 준비되지 않으면 빙글이만 돌게 두지 않고 "시작하지 못했습니다" 와 고를 것을 보인다
@visibleForTesting
static Duration startTimeout = const Duration(seconds: 20);
@override
State<CefView> createState() => _CefViewState();
}
/// Chrome 엔진 시작은 프로그램이 켜져 있는 동안 한 번만
Future<void>? _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<CefView> {
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<void> _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: Elevation.elevated && widget.cefWhenElevated);
await _cefStarted;
_c.setWebviewListener(cef.WebviewEventsListener(
onUrlChanged: (u) {
h.onUrl(u);
// YouTube 처럼 페이지 이동 없이 주소만 바뀌는 사이트
Future<void>.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<void> _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<bool>(
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<void> pauseMedia() async {
try {
await c.evaluateJavascript(pauseMediaScript);
} catch (_) {}
}
@override
Future<void> runScript(String js) async {
try {
await c.evaluateJavascript(js);
} catch (_) {}
}
/// JavaScript 를 끈 Chrome 엔진에서는 결과를 받지 못한다 (null)
@override
Future<Object?> evaluate(String js) async {
try {
return await c.evaluateJavascript(js).timeout(const Duration(seconds: 10));
} catch (_) {
return null;
}
}
/// Chrome 엔진은 만든 뒤에 바꿀 수 없다: 화면이 JavaScript 설정을 열쇠로 웹뷰를 새로 만든다 ([CefView])
@override
Future<void> setJavaScript(bool on) async {}
/// YouTube · Google 등의 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다).
/// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다.
@override
Future<void> 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<void> exportCookies() async {
if (dataDir.isEmpty || !CookieExport.enabled) return;
final raw = await cef.WebviewManager().visitAllCookies();
if (raw is! Map) return;
final all = <CookieRecord>[];
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<void> load(String url) => c.loadUrl(url);
@override
Future<void> back() => c.goBack();
@override
Future<void> forward() => c.goForward();
@override
Future<void> reload() => c.reload();
@override
Future<void> stop() async {} // Chrome 엔진에는 멈춤이 없다
}