browser_page 나누기 1: 웹 엔진 (Edge · Chrome) · 즐겨찾기 막대 · 패널 · WebNav 를 lib/ui/browser/ 모듈로

- 로그인 쿠키 파일 쓰기 (Edge · Chrome 이 따로 하던 것) 를 writeLoginCookies 하나로
- 살려 둔 웹뷰 · Chrome 시작 제한 시간은 그 모듈이 갖는다 (CefView.startTimeout)
- 약속 시험: NavWatch · writeLoginCookies · deleteExportedCookies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 00:45:07 +09:00
parent d83b0b5aef
commit ad9a12fbe1
8 changed files with 1264 additions and 1129 deletions

View File

@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import '../../app/bookmarks_controller.dart';
import '../../core/bookmarks.dart';
import '../../l10n/tr.dart';
import '../bookmark_ui.dart';
import '../theme.dart';
// ───────── 즐겨찾기 표시줄 (주소창 아래) ─────────
/// 약속: 즐겨찾기 표시줄의 항목을 보이고, 누르면 [onOpen] · [onOpenExternal] · [onOpenManager] 를 부른다 (바꾸기는 [bm] 이)
class BookmarkBar extends StatelessWidget {
final BookmarksController bm;
final void Function(String url) onOpen;
final void Function(String url) onOpenExternal;
final String currentUrl;
final String currentTitle;
final void Function({String? folderId}) onOpenManager;
const BookmarkBar({
super.key,
required this.bm,
required this.onOpen,
required this.onOpenExternal,
this.currentUrl = '',
this.currentTitle = '',
required this.onOpenManager,
});
void _menu(BuildContext context, Offset at, [BookmarkNode? n]) => showBookmarkMenu(context,
bm: bm,
globalPosition: at,
n: n,
folderId: BookmarkTree.barId,
onOpen: onOpen,
onOpenExternal: onOpenExternal,
currentUrl: currentUrl,
currentTitle: currentTitle,
onOpenManager: () => onOpenManager(folderId: n != null && n.isFolder ? n.id : null));
@override
Widget build(BuildContext context) {
final items = bm.tree.bar.children!;
// 빈 곳: 오른쪽 클릭 메뉴 (페이지 · 폴더 추가, 관리자), 다른 폴더의 즐겨찾기를 끌어다 놓으면 표시줄 맨 뒤로
return DragTarget<BookmarkDrag>(
// 다른 폴더에서 온 것만 (표시줄 안에서는 다른 즐겨찾기 위에 놓아 순서를 바꾼다. 길게 눌렀다 떼도 맨 뒤로 가지 않게)
onWillAcceptWithDetails: (d) =>
bm.canMoveInto(d.data.id, BookmarkTree.barId) && bm.tree.parentOf(d.data.id)?.id != BookmarkTree.barId,
onAcceptWithDetails: (d) => bm.moveInto(d.data.id, BookmarkTree.barId),
builder: (context, _, _) => GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (d) => _menu(context, d.globalPosition),
// 터치 화면: 길게 누르면 같은 메뉴
onLongPressStart: (d) => _menu(context, d.globalPosition),
child: Container(
height: 32,
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 6),
child: items.isEmpty
? Align(
alignment: Alignment.centerLeft,
child: Text(tr(' ☆ 를 누르면 여기에 즐겨찾기가 추가됩니다 · 오른쪽 클릭: 폴더 추가'),
style: TextStyle(fontSize: 11, color: JjColors.textDim)),
)
: ListView(
scrollDirection: Axis.horizontal,
children: [for (final (i, n) in items.indexed) _barItem(context, n, i)],
),
),
),
);
}
Widget _barItem(BuildContext context, BookmarkNode n, int index) {
final label = n.title.length > 24 ? '${n.title.substring(0, 23)}…' : n.title;
final Widget button;
if (n.isFolder) {
// 폴더: 누르면 내용 (하위 폴더는 옆으로 펼침), 즐겨찾기를 끌어다 놓으면 그 안으로
button = BookmarkFolderDrop(
bm: bm,
folderId: n.id,
child: MenuAnchor(
menuChildren: _folderMenu(context, n),
builder: (context, ctl, _) => TextButton.icon(
onPressed: () => ctl.isOpen ? ctl.close() : ctl.open(),
icon: Icon(Icons.folder, size: 15, color: JjColors.amber),
label: Text(label, style: const TextStyle(fontSize: 12)),
),
),
);
} else {
// 주소: 다른 즐겨찾기를 이 위에 놓으면 그 자리 (앞) 로 옮긴다
button = DragTarget<BookmarkDrag>(
onWillAcceptWithDetails: (d) => d.data.id != n.id && bm.canMoveInto(d.data.id, BookmarkTree.barId),
onAcceptWithDetails: (d) => bm.move(d.data.id, BookmarkTree.barId, index),
builder: (context, cand, _) => Container(
decoration: BoxDecoration(
border: Border(left: BorderSide(color: cand.isNotEmpty ? JjColors.accent : Colors.transparent, width: 2)),
),
child: Tooltip(
message: '${n.title}\n${n.url}',
waitDuration: const Duration(milliseconds: 600),
// 길게 누르기는 메뉴에 (마우스를 올리면 주소)
triggerMode: TooltipTriggerMode.manual,
child: TextButton.icon(
onPressed: () => onOpen(n.url!),
icon: const Icon(Icons.public, size: 14),
label: Text(label, style: const TextStyle(fontSize: 12)),
),
),
),
);
}
return GestureDetector(
onSecondaryTapDown: (d) => _menu(context, d.globalPosition, n),
// 길게 누르기 (터치 화면): 움직이지 않고 떼면 메뉴, 끌면 옮기기 (BookmarkDraggable)
child: BookmarkDraggable(n: n, onMenu: (at) => _menu(context, at, n), child: button),
);
}
List<Widget> _folderMenu(BuildContext context, BookmarkNode f) => [
if (f.children!.isEmpty) MenuItemButton(child: Text(tr('(비어 있음)'))),
for (final c in f.children!)
c.isFolder
? SubmenuButton(
leadingIcon: Icon(Icons.folder, size: 16, color: JjColors.amber),
menuChildren: _folderMenu(context, c),
child: Text(c.title),
)
: MenuItemButton(
leadingIcon: const Icon(Icons.public, size: 16),
onPressed: () => onOpen(c.url!),
child: Text(c.title),
),
const Divider(height: 8),
MenuItemButton(
leadingIcon: const Icon(Icons.bookmark_add_outlined, size: 16),
onPressed: currentUrl.startsWith('http')
? () => bm.addLink(currentTitle.isEmpty ? currentUrl : currentTitle, currentUrl, parentId: f.id)
: null,
child: Text(tr('이 폴더에 현재 페이지 추가')),
),
MenuItemButton(
leadingIcon: const Icon(Icons.create_new_folder_outlined, size: 16),
onPressed: () => showNewFolderDialog(context, bm, f.id),
child: Text(tr('이 폴더에 새 폴더')),
),
MenuItemButton(
leadingIcon: const Icon(Icons.edit_outlined, size: 16),
onPressed: () => showBookmarkEditor(context, bm, f),
child: Text(tr('폴더 이름 바꾸기 · 이동')),
),
MenuItemButton(
leadingIcon: const Icon(Icons.bookmarks_outlined, size: 16),
onPressed: () => onOpenManager(folderId: f.id),
child: Text(tr('즐겨찾기 관리자에서 보기')),
),
];
}

View File

@ -0,0 +1,287 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../app/bookmarks_controller.dart';
import '../../core/bookmarks.dart';
import '../../l10n/tr.dart';
import '../bookmark_ui.dart';
import '../key_hint.dart';
import '../theme.dart';
// ───────── 즐겨찾기 관리 패널 (Chrome 즐겨찾기 관리자 참고) ─────────
class BookmarkPanel extends StatefulWidget {
final BookmarksController bm;
final String currentUrl;
final String currentTitle;
final void Function(String url) onOpen;
final void Function(String url) onOpenExternal;
final VoidCallback onClose;
/// 즐겨찾기 관리자 (전체 화면) 열기
final void Function({String? folderId})? onOpenManager;
const BookmarkPanel({
super.key,
required this.bm,
required this.currentUrl,
required this.currentTitle,
required this.onOpen,
required this.onOpenExternal,
required this.onClose,
this.onOpenManager,
});
@override
State<BookmarkPanel> createState() => _BookmarkPanelState();
}
class _BookmarkPanelState extends State<BookmarkPanel> {
String _folderId = BookmarkTree.barId;
BookmarksController get bm => widget.bm;
List<BookmarkNode> _path() {
final out = <BookmarkNode>[];
var id = _folderId;
while (true) {
final n = bm.tree.find(id);
if (n == null) break;
out.insert(0, n);
final parent = bm.tree.parentOf(id);
if (parent == null) break;
id = parent.id;
}
return out;
}
@override
Widget build(BuildContext context) {
final folder = bm.tree.find(_folderId) ?? bm.tree.bar;
final items = folder.children!;
return Material(
color: JjColors.panel,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 0),
child: Row(children: [
Icon(Icons.bookmarks_outlined, size: 18, color: JjColors.accent),
const SizedBox(width: 6),
Text(tr('즐겨찾기'), style: TextStyle(fontWeight: FontWeight.w600)),
const Spacer(),
if (widget.onOpenManager != null)
IconButton(
tooltip: keyHint(tr('즐겨찾기 관리자'), 'Ctrl+Shift+O'),
iconSize: 18,
icon: const Icon(Icons.open_in_full),
onPressed: () => widget.onOpenManager!(folderId: _folderId),
),
IconButton(tooltip: tr('닫기'), iconSize: 18, icon: const Icon(Icons.close), onPressed: widget.onClose),
]),
),
// 최상위 폴더 선택
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Wrap(spacing: 4, children: [
for (final r in bm.tree.roots)
ChoiceChip(
label: Text(r.title, style: const TextStyle(fontSize: 11)),
selected: _path().first.id == r.id,
onSelected: (_) => setState(() => _folderId = r.id),
),
]),
),
// 현재 폴더 경로
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [
for (final (i, n) in _path().indexed) ...[
if (i > 0) Icon(Icons.chevron_right, size: 14, color: JjColors.textDim),
BookmarkFolderDrop(
bm: bm,
folderId: n.id,
child: InkWell(
onTap: () => setState(() => _folderId = n.id),
child: Padding(
padding: const EdgeInsets.all(2),
child: Text(n.title,
style: TextStyle(fontSize: 12, color: n.id == _folderId ? JjColors.text : JjColors.accent)),
),
),
),
],
]),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Wrap(spacing: 4, runSpacing: 4, children: [
OutlinedButton.icon(
onPressed: widget.currentUrl.startsWith('http')
? () => bm.addLink(widget.currentTitle.isEmpty ? widget.currentUrl : widget.currentTitle,
widget.currentUrl,
parentId: _folderId)
: null,
icon: const Icon(Icons.add, size: 16),
label: Text(tr('현재 페이지'), style: TextStyle(fontSize: 12)),
),
OutlinedButton.icon(
onPressed: () async {
await showNewFolderDialog(context, bm, _folderId);
},
icon: const Icon(Icons.create_new_folder_outlined, size: 16),
label: Text(tr('폴더'), style: TextStyle(fontSize: 12)),
),
PopupMenuButton<String>(
tooltip: tr('다른 브라우저의 즐겨찾기 가져오기'),
itemBuilder: (_) => [
PopupMenuItem(value: 'chrome', child: Text(tr('Chrome 에서 가져오기'))),
PopupMenuItem(value: 'edge', child: Text(tr('Edge 에서 가져오기'))),
PopupMenuItem(value: 'whale', child: Text(tr('Whale 에서 가져오기'))),
],
onSelected: (b) async {
final n = await bm.importFrom(b);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(n < 0
? tr('이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.')
: trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]))));
if (n > 0) setState(() => _folderId = BookmarkTree.otherId);
},
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.download_for_offline_outlined, size: 16),
SizedBox(width: 4),
Text(tr('가져오기'), style: TextStyle(fontSize: 12)),
]),
),
),
]),
),
const Divider(height: 12),
Expanded(
child: items.isEmpty
? Center(child: Text(tr('비어 있는 폴더'), style: TextStyle(color: JjColors.textDim)))
: ReorderableListView.builder(
buildDefaultDragHandles: false,
itemCount: items.length,
onReorderItem: (o, n) => bm.reorder(folder.id, o, n),
itemBuilder: (_, i) {
final n = items[i];
// 오른쪽 클릭 · 길게 누르기 (터치 화면) 로 같은 메뉴
void menuAt(Offset at) => showBookmarkMenu(context,
bm: bm,
globalPosition: at,
n: n,
folderId: folder.id,
onOpen: widget.onOpen,
onOpenExternal: widget.onOpenExternal,
currentUrl: widget.currentUrl,
currentTitle: widget.currentTitle);
final tile = ListTile(
dense: true,
leading: Row(mainAxisSize: MainAxisSize.min, children: [
ReorderableDragStartListener(
index: i,
child: Icon(Icons.drag_indicator, size: 16, color: JjColors.textDim),
),
const SizedBox(width: 4),
// 아이콘을 끌어 폴더 줄 · 위 경로에 놓으면 그 폴더로
BookmarkDraggable(
n: n,
onMenu: menuAt,
child: Icon(n.isFolder ? Icons.folder : Icons.public,
size: 18, color: n.isFolder ? JjColors.amber : JjColors.textDim),
),
]),
title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: n.isFolder
? Text(trf('{0}개', [n.children!.length]), style: const TextStyle(fontSize: 11))
: Text(n.url!, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11)),
onTap: () => n.isFolder ? setState(() => _folderId = n.id) : widget.onOpen(n.url!),
trailing: PopupMenuButton<String>(
tooltip: tr('더 보기'),
iconSize: 18,
itemBuilder: (_) => [
if (!n.isFolder) PopupMenuItem(value: 'ext', child: Text(tr('외부 브라우저로 열기'))),
PopupMenuItem(value: 'edit', child: Text(tr('수정'))),
PopupMenuItem(value: 'move', child: Text(tr('다른 폴더로 이동'))),
if (i > 0) PopupMenuItem(value: 'up', child: Text(tr('위로'))),
if (i < items.length - 1) PopupMenuItem(value: 'down', child: Text(tr('아래로'))),
const PopupMenuDivider(),
PopupMenuItem(value: 'del', child: Text(tr('삭제'))),
],
onSelected: (v) async {
switch (v) {
case 'ext':
widget.onOpenExternal(n.url!);
case 'edit':
await editBookmark(context, bm, n);
case 'move':
await _moveTo(context, n);
case 'up':
bm.move(n.id, folder.id, i - 1);
case 'down':
bm.move(n.id, folder.id, i + 2);
case 'del':
removeBookmarkWithUndo(context, bm, n);
}
},
),
);
return KeyedSubtree(
key: ValueKey(n.id),
child: GestureDetector(
onSecondaryTapDown: (d) => menuAt(d.globalPosition),
onLongPressStart: (d) => menuAt(d.globalPosition),
child: n.isFolder ? BookmarkFolderDrop(bm: bm, folderId: n.id, child: tile) : tile,
),
);
},
),
),
Padding(
padding: EdgeInsets.all(8),
child: Text(tr('⋮⋮ 를 끌어 순서 바꾸기 · 아이콘을 끌어 폴더에 넣기 · 오른쪽 클릭 (길게 누르기) 메뉴'),
textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: JjColors.textDim)),
),
]),
);
}
Future<void> _moveTo(BuildContext context, BookmarkNode n) async {
final target = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(trf('"{0}" 이동', [n.title])),
children: [
for (final (f, depth) in bm.tree.folders())
if (f.id != n.id && !bm.tree.isInside(f.id, n.id))
SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, f.id),
child: Padding(
padding: EdgeInsets.only(left: depth * 16.0),
child: Row(children: [
const Icon(Icons.folder_outlined, size: 16),
const SizedBox(width: 6),
Text(f.title),
]),
),
),
],
),
);
if (target != null) {
final dest = bm.tree.find(target)!;
bm.move(n.id, target, dest.children!.length);
}
}
}
// ───────── 공용 (bookmark_ui.dart) ─────────
/// 즐겨찾기 수정 (이름 · 주소 · 폴더)
Future<void> editBookmark(BuildContext context, BookmarksController bm, BookmarkNode n) =>
showBookmarkEditor(context, bm, n);

View File

@ -0,0 +1,302 @@
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/elevation.dart';
import '../cef_setup.dart' show confirmCefWhenElevated;
import '../theme.dart';
import 'edge_view.dart';
import 'web_nav.dart';
// ───────── 앱 안 Chrome (CEF) ─────────
/// 내장 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 엔진에는 멈춤이 없다
}

View File

@ -0,0 +1,294 @@
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();
}

130
lib/ui/browser/web_nav.dart Normal file
View File

@ -0,0 +1,130 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import '../../core/download_detect.dart' show CookieExport, CookieRecord, toNetscapeCookies;
/// 브라우저 조작 (앱 안 웹뷰)
abstract class WebNav {
Future<void> load(String url);
Future<void> back();
Future<void> forward();
Future<void> reload();
Future<void> stop();
/// YouTube · Google 로그인 쿠키를 yt-dlp 용 cookies.txt 로 내보내기
Future<void> exportCookies();
/// 페이지의 동영상 · 소리 멈추기 (다른 화면으로 갈 때)
Future<void> pauseMedia();
/// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등)
Future<void> runScript(String js);
/// 53: 쿠키 · 사이트 데이터 · 캐시 · 방문 기록 (뒤로 · 앞으로 목록) 지우기 - 엔진이 지원하는 것만
Future<void> clearData();
/// 페이지에서 스크립트를 실행하고 결과를 받는다 (페이지 번역). 실패하면 null.
Future<Object?> evaluate(String js);
/// 페이지의 JavaScript 켜기 · 끄기 (바뀌면 지금 페이지를 다시 읽는다)
Future<void> setJavaScript(bool on);
}
/// 250: 사용자가 페이지를 옮길 때 (주소 · 뒤로 · 앞으로 · 새로고침 · 다시 시도) 알린다 - DNS 덮개는 이때만 걷는다.
/// 진행률로 걷으면 Android 는 본문 오류 뒤에 엔진의 오류 페이지를 또 읽어 (진행률 0 → 100) 덮개가 뜨자마자 걷혔다 (932 노트 10+)
class NavWatch implements WebNav {
final WebNav n;
final void Function() onUserNavigate;
NavWatch(this.n, this.onUserNavigate);
@override
Future<void> load(String url) {
onUserNavigate();
return n.load(url);
}
@override
Future<void> back() {
onUserNavigate();
return n.back();
}
@override
Future<void> forward() {
onUserNavigate();
return n.forward();
}
@override
Future<void> reload() {
onUserNavigate();
return n.reload();
}
@override
Future<void> stop() => n.stop();
@override
Future<void> exportCookies() => n.exportCookies();
@override
Future<void> pauseMedia() => n.pauseMedia();
@override
Future<void> runScript(String js) => n.runScript(js);
@override
Future<void> clearData() => n.clearData();
@override
Future<Object?> evaluate(String js) => n.evaluate(js);
@override
Future<void> setJavaScript(bool on) => n.setJavaScript(on);
}
/// 페이지의 모든 동영상 · 소리를 멈추는 스크립트
const pauseMediaScript = "document.querySelectorAll('video,audio').forEach(function(m){try{m.pause()}catch(e){}})";
/// 화면 이동 알림 (브라우저 위에 다른 화면이 올라오면 동영상을 멈춘다). MaterialApp.navigatorObservers 에 넣는다.
final browserRouteObserver = RouteObserver<ModalRoute<void>>();
/// 웹뷰 → 화면으로 알리는 통로
class BrowserHost {
final void Function(String url) onUrl;
final void Function(String title) onTitle;
final void Function(double progress) onProgress;
final void Function(bool hasVideo) onVideo;
final void Function(bool back, bool forward) onHistory;
final void Function(WebNav nav) attach;
/// 250: 본문 (main frame) 을 읽지 못함 - 엔진이 알려 준 주소와 오류 (예: net::ERR_TUNNEL_CONNECTION_FAILED)
final void Function(String url, String error)? onMainFrameError;
const BrowserHost({
required this.onUrl,
required this.onTitle,
required this.onProgress,
required this.onVideo,
required this.onHistory,
required this.attach,
this.onMainFrameError,
});
}
/// 53: 다운로드용으로 내보낸 로그인 쿠키 파일 (평문) 을 지운다
Future<void> deleteExportedCookies(String dataDir) async {
if (dataDir.isEmpty) return;
for (final name in [CookieExport.fileName, '${CookieExport.fileName}.tmp']) {
try {
final f = File('$dataDir${Platform.pathSeparator}$name');
if (await f.exists()) await f.delete();
} catch (_) {}
}
}
/// 로그인 쿠키를 yt-dlp 용 cookies.txt 로 쓴다 (Edge · Chrome 같은 파일). 임시 파일에 다 쓴 뒤 한 번에 바꿔 넣는다
/// (yt-dlp 가 쓰는 도중의 파일을 읽지 않도록)
Future<void> writeLoginCookies(String dataDir, List<CookieRecord> all) async {
if (dataDir.isEmpty || all.isEmpty) return;
final target = '$dataDir${Platform.pathSeparator}cookies_youtube.txt';
final tmp = File('$target.tmp');
await tmp.parent.create(recursive: true);
await tmp.writeAsString(toNetscapeCookies(all), flush: true);
await tmp.rename(target);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,76 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/download_detect.dart' show CookieExport, CookieRecord;
import 'package:jj_mkvmaker/ui/browser/web_nav.dart';
import 'package:path/path.dart' as p;
/// 받은 조작을 적어 두는 WebNav (NavWatch 가 무엇을 그대로 넘기는지 보려고)
class _Log implements WebNav {
final calls = <String>[];
@override
Future<void> load(String url) async => calls.add('load $url');
@override
Future<void> back() async => calls.add('back');
@override
Future<void> forward() async => calls.add('forward');
@override
Future<void> reload() async => calls.add('reload');
@override
Future<void> stop() async => calls.add('stop');
@override
Future<void> exportCookies() async => calls.add('cookies');
@override
Future<void> pauseMedia() async => calls.add('pause');
@override
Future<void> runScript(String js) async => calls.add('run $js');
@override
Future<void> clearData() async => calls.add('clear');
@override
Future<Object?> evaluate(String js) async {
calls.add('eval $js');
return 7;
}
@override
Future<void> setJavaScript(bool on) async => calls.add('js $on');
}
void main() {
test('약속 NavWatch: 사용자가 옮길 때 (주소 · 뒤로 · 앞으로 · 새로고침) 만 알리고, 모든 조작은 그대로 넘긴다', () async {
final inner = _Log();
var moved = 0;
final w = NavWatch(inner, () => moved++);
await w.load('https://a.example/');
await w.back();
await w.forward();
await w.reload();
expect(moved, 4);
await w.stop();
await w.exportCookies();
await w.pauseMedia();
await w.runScript('x');
await w.clearData();
expect(await w.evaluate('y'), 7);
await w.setJavaScript(false);
expect(moved, 4, reason: '멈춤 · 쿠키 · 스크립트 등은 페이지를 옮기지 않는다');
expect(inner.calls,
['load https://a.example/', 'back', 'forward', 'reload', 'stop', 'cookies', 'pause', 'run x', 'clear', 'eval y', 'js false']);
});
test('약속 writeLoginCookies · deleteExportedCookies: cookies.txt 를 한 번에 바꿔 넣고, 지우면 임시 파일까지 없앤다', () async {
final dir = await Directory.systemTemp.createTemp('jj_cookie_');
addTearDown(() => dir.delete(recursive: true));
final file = File(p.join(dir.path, CookieExport.fileName));
await writeLoginCookies(dir.path, const []);
expect(file.existsSync(), isFalse, reason: '쿠키가 없으면 쓰지 않는다');
await writeLoginCookies('', [const CookieRecord(name: 'a', value: 'b', domain: '.youtube.com')]);
await writeLoginCookies(dir.path, [const CookieRecord(name: 'SID', value: 'v1', domain: '.youtube.com', expires: 0)]);
expect(file.readAsStringSync(), contains('SID\tv1'));
expect(File('${file.path}.tmp').existsSync(), isFalse, reason: '임시 파일은 바꿔 넣은 뒤 남지 않는다');
File('${file.path}.tmp').writeAsStringSync('half');
await deleteExportedCookies(dir.path);
expect([file.existsSync(), File('${file.path}.tmp').existsSync()], [false, false]);
await deleteExportedCookies(''); // 데이터 폴더가 없으면 아무것도 안 함
});
}

View File

@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:jj_mkvmaker/ui/browser/cef_view.dart';
import 'package:path/path.dart' as p;
/// 207: 내장 Chrome 엔진 (CEF) 은 관리자 권한이면 스스로 끝난다 (종료 코드 38). 관리자 판단: 처음 값은 시작하지 않고 이유와 고를 것을 보인다.
@ -76,8 +77,8 @@ void main() {
testWidgets('시작하지 못하면 빙글이만 돌지 않고 "Chrome 엔진을 시작하지 못했습니다" · [Edge 엔진으로 바꾸기] · [다시 시도]', (t) async {
Elevation.debugElevated = false;
BrowserPage.cefStartTimeout = const Duration(seconds: 2);
addTearDown(() => BrowserPage.cefStartTimeout = const Duration(seconds: 20));
CefView.startTimeout = const Duration(seconds: 2);
addTearDown(() => CefView.startTimeout = const Duration(seconds: 20));
await open(t);
// 시험에는 Chrome 엔진 (플러그인) 이 없어 끝나지 않거나 실패한다 - 정한 시간 (여기선 2초) 이 지나면 안내
expect(find.text('Chrome 엔진을 시작하지 못했습니다'), findsNothing);