diff --git a/lib/ui/browser/bookmark_bar.dart b/lib/ui/browser/bookmark_bar.dart new file mode 100644 index 0000000..50e07ab --- /dev/null +++ b/lib/ui/browser/bookmark_bar.dart @@ -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( + // 다른 폴더에서 온 것만 (표시줄 안에서는 다른 즐겨찾기 위에 놓아 순서를 바꾼다. 길게 눌렀다 떼도 맨 뒤로 가지 않게) + 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( + 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 _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('즐겨찾기 관리자에서 보기')), + ), + ]; +} diff --git a/lib/ui/browser/bookmark_panel.dart b/lib/ui/browser/bookmark_panel.dart new file mode 100644 index 0000000..5ebe77b --- /dev/null +++ b/lib/ui/browser/bookmark_panel.dart @@ -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 createState() => _BookmarkPanelState(); +} + +class _BookmarkPanelState extends State { + String _folderId = BookmarkTree.barId; + + BookmarksController get bm => widget.bm; + + List _path() { + final out = []; + 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( + 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( + 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 _moveTo(BuildContext context, BookmarkNode n) async { + final target = await showDialog( + 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 editBookmark(BuildContext context, BookmarksController bm, BookmarkNode n) => + showBookmarkEditor(context, bm, n); diff --git a/lib/ui/browser/cef_view.dart b/lib/ui/browser/cef_view.dart new file mode 100644 index 0000000..76aee38 --- /dev/null +++ b/lib/ui/browser/cef_view.dart @@ -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 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: Elevation.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 엔진에는 멈춤이 없다 +} diff --git a/lib/ui/browser/edge_view.dart b/lib/ui/browser/edge_view.dart new file mode 100644 index 0000000..53620d4 --- /dev/null +++ b/lib/ui/browser/edge_view.dart @@ -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 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(); +} diff --git a/lib/ui/browser/web_nav.dart b/lib/ui/browser/web_nav.dart new file mode 100644 index 0000000..55ee8fd --- /dev/null +++ b/lib/ui/browser/web_nav.dart @@ -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 load(String url); + Future back(); + Future forward(); + Future reload(); + Future stop(); + + /// YouTube · Google 로그인 쿠키를 yt-dlp 용 cookies.txt 로 내보내기 + Future exportCookies(); + + /// 페이지의 동영상 · 소리 멈추기 (다른 화면으로 갈 때) + Future pauseMedia(); + + /// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등) + Future runScript(String js); + + /// 53: 쿠키 · 사이트 데이터 · 캐시 · 방문 기록 (뒤로 · 앞으로 목록) 지우기 - 엔진이 지원하는 것만 + Future clearData(); + + /// 페이지에서 스크립트를 실행하고 결과를 받는다 (페이지 번역). 실패하면 null. + Future evaluate(String js); + + /// 페이지의 JavaScript 켜기 · 끄기 (바뀌면 지금 페이지를 다시 읽는다) + Future 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 load(String url) { + onUserNavigate(); + return n.load(url); + } + + @override + Future back() { + onUserNavigate(); + return n.back(); + } + + @override + Future forward() { + onUserNavigate(); + return n.forward(); + } + + @override + Future reload() { + onUserNavigate(); + return n.reload(); + } + + @override + Future stop() => n.stop(); + @override + Future exportCookies() => n.exportCookies(); + @override + Future pauseMedia() => n.pauseMedia(); + @override + Future runScript(String js) => n.runScript(js); + @override + Future clearData() => n.clearData(); + @override + Future evaluate(String js) => n.evaluate(js); + @override + Future 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>(); + +/// 웹뷰 → 화면으로 알리는 통로 +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 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 writeLoginCookies(String dataDir, List 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); +} diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 2fc8e42..0586527 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -1,12 +1,8 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:path/path.dart' as p; -import 'package:webview_cef/webview_cef.dart' as cef; import '../app/app_controller.dart'; import '../app/bookmarks_controller.dart'; @@ -14,132 +10,35 @@ import '../app/download_manager.dart'; import '../app/net_guard.dart'; import '../app/tor_service.dart'; import '../app/web_proxy.dart'; -import '../core/bookmarks.dart'; -import '../core/download_detect.dart' show CookieExport, CookieRecord, internalBrowserCookies, isLoginCookieDomain, toNetscapeCookies; +import '../core/download_detect.dart' show CookieExport, internalBrowserCookies; import '../core/web_address.dart'; import '../core/web_translate.dart'; import '../app/i18n_controller.dart' show I18nController; import '../core/youtube_ads.dart'; import '../platform/windows/cef_runtime.dart'; -import '../platform/windows/com_guard.dart'; import '../platform/windows/elevation.dart'; import '../services/downloader.dart' show DownloadState; import 'app_actions.dart'; import 'bookmark_manager_page.dart'; import 'bookmark_ui.dart'; -import 'cef_setup.dart' show confirmCefWhenElevated; import 'confirm.dart'; import 'downloads_page.dart'; import 'key_hint.dart'; import 'settings_page.dart'; import 'tor_page.dart'; import 'theme.dart'; -import 'bottom_inset_hold.dart'; import 'work_panel.dart'; import '../l10n/tr.dart'; import 'notice.dart'; +import 'browser/bookmark_bar.dart'; +import 'browser/bookmark_panel.dart'; +import 'browser/cef_view.dart'; +import 'browser/edge_view.dart'; +import 'browser/web_nav.dart'; -/// 브라우저 조작 (앱 안 웹뷰) -abstract class WebNav { - Future load(String url); - Future back(); - Future forward(); - Future reload(); - Future stop(); - - /// YouTube · Google 로그인 쿠키를 yt-dlp 용 cookies.txt 로 내보내기 - Future exportCookies(); - - /// 페이지의 동영상 · 소리 멈추기 (다른 화면으로 갈 때) - Future pauseMedia(); - - /// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등) - Future runScript(String js); - - /// 53: 쿠키 · 사이트 데이터 · 캐시 · 방문 기록 (뒤로 · 앞으로 목록) 지우기 - 엔진이 지원하는 것만 - Future clearData(); - - /// 페이지에서 스크립트를 실행하고 결과를 받는다 (페이지 번역). 실패하면 null. - Future evaluate(String js); - - /// 페이지의 JavaScript 켜기 · 끄기 (바뀌면 지금 페이지를 다시 읽는다) - Future 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 load(String url) { - onUserNavigate(); - return n.load(url); - } - - @override - Future back() { - onUserNavigate(); - return n.back(); - } - - @override - Future forward() { - onUserNavigate(); - return n.forward(); - } - - @override - Future reload() { - onUserNavigate(); - return n.reload(); - } - - @override - Future stop() => n.stop(); - @override - Future exportCookies() => n.exportCookies(); - @override - Future pauseMedia() => n.pauseMedia(); - @override - Future runScript(String js) => n.runScript(js); - @override - Future clearData() => n.clearData(); - @override - Future evaluate(String js) => n.evaluate(js); - @override - Future 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>(); - -/// 웹뷰 → 화면으로 알리는 통로 -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, - }); -} +export 'browser/bookmark_panel.dart' show BookmarkPanel, editBookmark; +export 'browser/edge_view.dart' show browserEnvironment, edgeBrowserArguments; +export 'browser/web_nav.dart'; /// 웹 브라우저 (앱 안 Edge 엔진). 동영상 페이지면 상단 [다운로드] 로 바로 받는다. class BrowserPage extends StatefulWidget { @@ -176,14 +75,6 @@ class BrowserPage extends StatefulWidget { @visibleForTesting static WebNav? debugNav; - /// 207: 내장 Chrome 엔진이 이 시간 안에 준비되지 않으면 빙글이만 돌게 두지 않고 "시작하지 못했습니다" 와 고를 것을 보인다 - @visibleForTesting - static Duration cefStartTimeout = const Duration(seconds: 20); - - /// 앱 안 웹뷰를 화면이 닫혀도 살려 둔다 (다시 열면 보던 페이지 · 재생 위치 · 뒤로 가기 기록 그대로) - static final _keepAlive = InAppWebViewKeepAlive(); - static bool _keepAliveUsed = false; - /// 웹 브라우저로: 이미 열린 브라우저가 있으면 그 화면으로 돌아가고 (보던 페이지 그대로), /// 없으면 새로 연다. 다운로드 목록 · 플레이어 등에서 브라우저 버튼을 눌러도 보던 페이지를 잃지 않는다. static Future open(NavigatorState nav, @@ -336,7 +227,7 @@ class _BrowserPageState extends State with RouteAware { _canFwd = f; }); }, - attach: (n) => _nav = BrowserPage.debugNav = _NavWatch(n, _userNavigated), + attach: (n) => _nav = BrowserPage.debugNav = NavWatch(n, _userNavigated), // 250: 엔진의 본문 오류 (Edge · Android). 읽기 끝 (진행률) 만으로는 Android 에서 덮개가 뜨지 않았다 (931, 노트 10+) onMainFrameError: (u, e) { if (!mounted) return; @@ -759,7 +650,7 @@ class _BrowserPageState extends State with RouteAware { if (_panel) const VerticalDivider(width: 1), Expanded( child: Column(children: [ - _BookmarkBar( + BookmarkBar( bm: bm, onOpen: _go, onOpenExternal: _openExternal, @@ -777,9 +668,9 @@ class _BrowserPageState extends State with RouteAware { (widget.c.settings.browser.engine == 'chrome' && CefRuntime.readyThisRun // 207: 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않는다 (보안) - 이유와 고를 것 ? Elevation.elevated && !widget.c.settings.browser.cefWhenElevated - ? _CefElevatedBlocked(c: widget.c) + ? CefElevatedBlocked(c: widget.c) // Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다 - : _CefView( + : CefView( key: ValueKey('${widget.c.settings.browser.javaScript}|$_cefTry'), host: _host, initialUrl: _url, @@ -789,7 +680,7 @@ class _BrowserPageState extends State with RouteAware { onUseEdge: () => widget.c.updateSettings((x) => x.browser.engine = 'edge'), onRetry: () => setState(() => _cefTry++), cefWhenElevated: widget.c.settings.browser.cefWhenElevated) - : _EdgeView( + : EdgeView( host: _host, initialUrl: _url, dataDir: widget.c.settings.webViewDataDir, @@ -979,16 +870,6 @@ class _BrowserPageState extends State with RouteAware { } } -/// 53: 다운로드용으로 내보낸 로그인 쿠키 파일 (평문) 을 지운다 -Future 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 (_) {} - } -} /// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대 class _SplitHandle extends StatelessWidget { @@ -1010,997 +891,3 @@ class _SplitHandle extends StatelessWidget { ), ); } - -// ───────── 앱 안 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(), - }; -} - -class _EdgeView extends StatefulWidget { - final BrowserHost host; - final String initialUrl; - final String dataDir; - final bool javaScript; - const _EdgeView({required this.host, required this.initialUrl, required this.dataDir, required this.javaScript}); - - @override - State<_EdgeView> createState() => _EdgeViewState(); -} - -Future? _env; - -/// 로그인 · 쿠키가 유지되는 데이터 폴더를 쓰는 브라우저 환경 (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<_EdgeView> 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 (!BrowserPage._keepAliveUsed) { - BrowserPage._keepAliveUsed = true; - _keepAlive = BrowserPage._keepAlive; - } - WebProxy.browserViewCreated(); - } - - @override - void dispose() { - if (_keepAlive != null) BrowserPage._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, - )); - } - } - if (all.isEmpty) return; - // 임시 파일에 다 쓴 뒤 한 번에 바꿔 넣는다 (yt-dlp 가 쓰는 도중의 파일을 읽지 않도록) - 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); - } - @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(); -} - -// ───────── 앱 안 Chrome (CEF) ───────── - -/// 내장 Chrome 엔진 (환경 설정에서 고르고 내려받은 경우). 기능은 Edge 화면과 같다: -/// 주소 · 제목 · 진행 · 동영상 페이지 판별 · 뒤로 / 앞으로 · 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}); - - - @override - State<_CefView> createState() => _CefViewState(); -} - -/// Chrome 엔진 시작은 프로그램이 켜져 있는 동안 한 번만 -Future? _cefStarted; - -/// Chrome 엔진을 시작할 때 준 프록시 주소 (문을 다시 열 때 같아야 한다) -String? _cefProxy; - -/// 207: 관리자 권한으로 실행 중이라 Chrome 엔진을 시작하지 않음 - 이유와 [Edge 엔진으로 바꾸기] [그래도 Chrome 엔진 쓰기…] -class _CefElevatedBlocked extends StatelessWidget { - final AppController c; - const _CefElevatedBlocked({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(BrowserPage.cefStartTimeout, () { - if (!mounted || _c.value) return; - setState(() { - _timedOut = true; - _error = trf('{0}초 안에 준비되지 않았습니다.', [BrowserPage.cefStartTimeout.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: Elevation.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))); - }); - if (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); - } - - @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 엔진에는 멈춤이 없다 -} - -// ───────── 즐겨찾기 표시줄 (주소창 아래) ───────── - -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({ - 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( - // 다른 폴더에서 온 것만 (표시줄 안에서는 다른 즐겨찾기 위에 놓아 순서를 바꾼다. 길게 눌렀다 떼도 맨 뒤로 가지 않게) - 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( - 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 _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('즐겨찾기 관리자에서 보기')), - ), - ]; -} - -// ───────── 즐겨찾기 관리 패널 (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 createState() => _BookmarkPanelState(); -} - -class _BookmarkPanelState extends State { - String _folderId = BookmarkTree.barId; - - BookmarksController get bm => widget.bm; - - List _path() { - final out = []; - 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( - 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( - 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 _moveTo(BuildContext context, BookmarkNode n) async { - final target = await showDialog( - 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 editBookmark(BuildContext context, BookmarksController bm, BookmarkNode n) => - showBookmarkEditor(context, bm, n); diff --git a/test/browser_modules_contract_test.dart b/test/browser_modules_contract_test.dart new file mode 100644 index 0000000..a26aeb9 --- /dev/null +++ b/test/browser_modules_contract_test.dart @@ -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 = []; + @override + Future load(String url) async => calls.add('load $url'); + @override + Future back() async => calls.add('back'); + @override + Future forward() async => calls.add('forward'); + @override + Future reload() async => calls.add('reload'); + @override + Future stop() async => calls.add('stop'); + @override + Future exportCookies() async => calls.add('cookies'); + @override + Future pauseMedia() async => calls.add('pause'); + @override + Future runScript(String js) async => calls.add('run $js'); + @override + Future clearData() async => calls.add('clear'); + @override + Future evaluate(String js) async { + calls.add('eval $js'); + return 7; + } + + @override + Future 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(''); // 데이터 폴더가 없으면 아무것도 안 함 + }); +} diff --git a/test/cef_elevation_test.dart b/test/cef_elevation_test.dart index 788a4a3..6d74c0f 100644 --- a/test/cef_elevation_test.dart +++ b/test/cef_elevation_test.dart @@ -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);