327 lines
13 KiB
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 엔진에는 멈춤이 없다
|
|
}
|