diff --git a/lib/ui/browser/browser_actions.dart b/lib/ui/browser/browser_actions.dart new file mode 100644 index 0000000..7c95073 --- /dev/null +++ b/lib/ui/browser/browser_actions.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/download_manager.dart'; +import '../../l10n/tr.dart'; +import '../../services/downloader.dart' show DownloadState; +import '../confirm.dart'; +import '../downloads_page.dart'; +import '../notice.dart'; +import '../settings_page.dart'; +import '../tor_page.dart'; +import 'web_nav.dart'; + +/// Tor 브라우저로: 켜져 있으면 Tor 화면, 아니면 짧은 안내와 [환경 설정 > 컴포넌트] +Future openTorOrExplain(BuildContext context, AppController c) async { + if (c.settings.display.components.contains('tor')) { + await TorPage.open(Navigator.of(context), c: c); + return; + } + final go = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('Tor 브라우저')), + content: Text(tr('Tor 브라우저는 페이지를 Tor 로 열어 사이트가 이 기기의 주소를 알 수 없게 합니다 (쿠키 · 기록은 따로, 닫으면 지움). ' + '환경 설정 > 컴포넌트 에서 켜면 이 브라우저 옆에 Tor 화면이 생깁니다.')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('환경 설정 > 컴포넌트'))), + ], + ), + ); + if (go == true && context.mounted) { + await Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage(c: c, initialGroup: 'components'))); + } +} + +/// 53: 쿠키 · 방문 기록 지우기 (확인 뒤). 다운로드용으로 내보낸 쿠키 파일도 지우고 지금 페이지를 다시 읽는다 +Future confirmClearBrowserData(BuildContext context, {required WebNav? nav, required String dataDir}) async { + final ok = await confirmAction( + context, + title: tr('쿠키 · 방문 기록 지우기'), + body: tr('앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. ' + '사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.'), + ok: tr('지우기'), + ); + if (!ok || !context.mounted) return; + try { + await nav?.clearData(); + } catch (_) {} + await deleteExportedCookies(dataDir); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('쿠키 · 방문 기록을 지웠습니다.')))); + unawaited(nav?.reload()); +} + +/// 27: 이 페이지를 다운로드 목록에 넣는다 (yt-dlp 가 지원하면 받고, 아니면 다운로드 목록에 이유가 보인다). +/// 로그인 쿠키를 먼저 내보내 yt-dlp 가 같은 로그인으로 받도록 하고, 받기 준비가 끝날 때까지 알림을 띄워 둔다 +Future addPageDownload(BuildContext context, DownloadManager d, + {required WebNav? nav, required String url, required String title}) async { + try { + await nav?.exportCookies(); + } catch (_) {} + if (!context.mounted) return; + final t = d.addPage(url); + final m = ScaffoldMessenger.of(context); + if (t == null) { + m.showSnackBar(SnackBar(content: Text(tr('이미 받는 중이거나 받을 수 없는 주소입니다.')))); + return; + } + // 알림은 앱 전체에 떠 있으므로 이 브라우저 화면이 아니라 앱의 Navigator 로 연다 + // (화면을 오가 이 브라우저 화면이 닫혀도 "목록 보기" 가 된다) + final navigator = Navigator.of(context); + // 받기 준비가 끝날 때까지 두고 아래 check 가 닫는다 ([✕] 로 바로 닫기) + final bar = showNotice(m, + life: NoticeLife.untilClosed, + text: trf('다운로드 추가: {0}', [title.isEmpty ? url : title]), + action: (tr('목록 보기'), () => DownloadsPage.open(navigator, d)))!; + // 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다 + void check() { + final ready = !d.tasks.contains(t) || + t.progress != null || + (t.state != DownloadState.queued && t.state != DownloadState.downloading); + if (ready) { + d.removeListener(check); + bar.close(); + } + } + + d.addListener(check); + unawaited(bar.closed.then((_) => d.removeListener(check))); +} diff --git a/lib/ui/browser/browser_banners.dart b/lib/ui/browser/browser_banners.dart new file mode 100644 index 0000000..6899439 --- /dev/null +++ b/lib/ui/browser/browser_banners.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../../app/tor_service.dart'; +import '../../l10n/tr.dart'; +import '../theme.dart'; + +/// 66-2: 화면 옮기기 안내 한 줄 (처음 한 번, ✕ 로 닫음) +class SwipeHintBar extends StatelessWidget { + final VoidCallback onClose; + const SwipeHintBar({super.key, required this.onClose}); + + @override + Widget build(BuildContext context) => Material( + color: JjColors.accent.withValues(alpha: 0.14), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 2, 4, 2), + child: Row(children: [ + Icon(Icons.swipe, size: 16, color: JjColors.accent), + const SizedBox(width: 8), + Expanded( + child: Text(tr('웹 브라우저에서는 위쪽 막대를 좌우로 밀면 다른 화면으로 옮깁니다 (가운데는 페이지가 밀립니다).'), + style: const TextStyle(fontSize: 12))), + IconButton( + tooltip: tr('안내 닫기'), + visualDensity: VisualDensity.compact, + iconSize: 16, + icon: const Icon(Icons.close), + onPressed: onClose, + ), + ]), + ), + ); +} + +/// 226: Tor 화면을 떠난 뒤 tor 를 잠시 켜 두는 중 (이 브라우저는 Tor 를 쓰지 않음) - 언제 끄는지 · 지금 끄기 +class TorIdleBanner extends StatelessWidget { + final DateTime at; + const TorIdleBanner({super.key, required this.at}); + + @override + Widget build(BuildContext context) => Material( + color: JjColors.panel, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 2, 4, 2), + child: Row(children: [ + Icon(Icons.timer_outlined, size: 16, color: JjColors.textDim), + const SizedBox(width: 8), + Expanded( + child: Text( + trf('Tor 대기 중 ({0}분 뒤 끔) - 이 브라우저는 Tor 를 쓰지 않습니다', [(at.difference(DateTime.now()).inSeconds / 60).ceil().clamp(0, 60)]), + style: TextStyle(fontSize: 12, color: JjColors.textDim), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton(onPressed: () => TorService.instance.stop(), child: Text(tr('지금 끄기'))), + ]), + ), + ); +} + +/// Android: Tor 화면이 열려 있는 동안은 웹 뷰 프록시가 앱 전체라 이 브라우저도 Tor 로 (관리자 결정 10/10) +class TorBanner extends StatelessWidget { + const TorBanner({super.key}); + + @override + Widget build(BuildContext context) => Material( + color: const Color(0xFF9C6ADE).withValues(alpha: 0.18), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 4), + child: Row(children: [ + const Icon(Icons.vpn_lock_outlined, size: 16, color: Color(0xFF9C6ADE)), + const SizedBox(width: 8), + Expanded(child: Text(tr('Tor 화면이 열려 있는 동안 이 브라우저도 Tor 로 나갑니다'), style: const TextStyle(fontSize: 12))), + ]), + ), + ); +} + +/// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대 +class SplitHandle extends StatelessWidget { + final ValueChanged onDrag; + const SplitHandle({super.key, required this.onDrag}); + + @override + Widget build(BuildContext context) => MouseRegion( + cursor: SystemMouseCursors.resizeColumn, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (d) => onDrag(d.delta.dx), + child: Container( + width: 6, + color: JjColors.bg, + alignment: Alignment.center, + child: Container(width: 1, color: JjColors.border), + ), + ), + ); +} diff --git a/lib/ui/browser/browser_toolbar.dart b/lib/ui/browser/browser_toolbar.dart new file mode 100644 index 0000000..b26c064 --- /dev/null +++ b/lib/ui/browser/browser_toolbar.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; + +import '../../app/app_controller.dart'; +import '../../app/i18n_controller.dart' show I18nController; +import '../../l10n/tr.dart'; +import '../app_actions.dart'; +import '../key_hint.dart'; +import '../theme.dart'; +import '../work_panel.dart'; +import 'web_nav.dart'; + +/// 브라우저 위쪽 막대 (앱 화면 이동 · 웹 페이지 이동 · 주소 칸 · 번역 · 다운로드 · Tor · 외부 · 쿠키 · 작업 현황 · 즐겨찾기). +/// 약속: 화면 상태를 보이기만 하고, 누르면 받은 함수를 부른다. 여기서 좌우로 밀면 화면 이동 +/// (웹 페이지 위에서는 웹 페이지의 스크롤과 다투어 위 · 아래가 이상했음) +class BrowserToolbar extends StatelessWidget { + final AppController c; + final WebNav? nav; + final String url; + final double progress; + final bool canBack, canForward, starred, translating, likelyVideo, canDownload, workOpen, panelOpen; + final String downloadTip; + final TextEditingController address; + final FocusNode addressFocus; + final void Function(String input) onGo; + final VoidCallback onStar, onToggleTranslate, onDownload, onTor, onExternal, onClearData, onToggleWork, onTogglePanel; + + const BrowserToolbar({ + super.key, + required this.c, + required this.nav, + required this.url, + required this.progress, + required this.canBack, + required this.canForward, + required this.starred, + required this.translating, + required this.likelyVideo, + required this.canDownload, + required this.downloadTip, + required this.workOpen, + required this.panelOpen, + required this.address, + required this.addressFocus, + required this.onGo, + required this.onStar, + required this.onToggleTranslate, + required this.onDownload, + required this.onTor, + required this.onExternal, + required this.onClearData, + required this.onToggleWork, + required this.onTogglePanel, + }); + + @override + Widget build(BuildContext context) => SwipeNav(current: 'browser', anywhere: true, child: _bar()); + + Widget _bar() { + IconButton btn(IconData i, String tip, VoidCallback? f, {Color? color}) => IconButton( + tooltip: tip, + visualDensity: VisualDensity.compact, + icon: Icon(i, size: 20, color: color), + onPressed: f, + ); + final download = canDownload ? onDownload : null; + final home = c.settings.browser.homeUrl; + // 주소 칸이 좁아지면 (폰 세로 · 태블릿 세로 · 폰 가로 100%) 자주 안 쓰는 버튼은 ⋮ 메뉴로 - 주소가 "http" 만 보이거나 + // 아예 사라지던 것. 버튼을 없애지는 않는다. + // 201: 폰 가로처럼 넓어도 버튼이 폭을 다 차지하면 주소 칸이 사라졌다 - 그때는 두 줄 (아래 줄을 주소 칸이 다 씀) + return AppTopBar( + minMiddleWidth: 380, + nav: const AppNavButtons(onBrowserPage: true), + actions: AppActions(c: c), + middle: LayoutBuilder(builder: (context, box) { + final compact = box.maxWidth < 640; + return Row(children: [ + if (!compact) ...[ + Container(width: 1, height: 24, color: JjColors.border), + const SizedBox(width: 4), + ], + // 웹 페이지 이동 (앱 화면 이동인 왼쪽 버튼과 구분되는 모양) + btn(Icons.arrow_back_ios_new, keyHint(tr('이전 페이지'), 'Alt+←'), canBack ? () => nav?.back() : null), + btn(Icons.arrow_forward_ios, keyHint(tr('다음 페이지'), 'Alt+→'), canForward ? () => nav?.forward() : null), + progress < 1 + ? btn(Icons.close, tr('중지'), () => nav?.stop()) + : btn(Icons.refresh, keyHint(tr('새로고침'), 'F5'), () => nav?.reload()), + if (!compact) btn(Icons.home_outlined, tr('홈'), () => onGo(home)), + const SizedBox(width: 6), + Expanded( + child: TextField( + controller: address, + focusNode: addressFocus, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: JjColors.bg, + hintText: keyHint(tr('주소 입력 또는 검색'), 'Ctrl+L'), + prefixIcon: Icon(url.startsWith('https') ? Icons.lock_outline : Icons.public, size: 16), + prefixIconConstraints: const BoxConstraints(minWidth: 32), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none), + suffixIcon: IconButton( + tooltip: starred ? keyHint(tr('즐겨찾기 수정'), 'Ctrl+D') : keyHint(tr('즐겨찾기 추가'), 'Ctrl+D'), + icon: Icon(starred ? Icons.star : Icons.star_border, size: 18, + color: starred ? JjColors.amber : JjColors.textDim), + onPressed: onStar, + ), + ), + onSubmitted: onGo, + ), + ), + const SizedBox(width: 4), + if (!compact) + btn( + Icons.translate, + translating + ? tr('원문 보기 (번역 끄기)') + : trf('이 페이지를 {0}(으)로 번역', [I18nController.nativeName(c.settings.display.language)]), + onToggleTranslate, + color: translating ? JjColors.accent : null), + const SizedBox(width: 4), + if (compact) + likelyVideo + ? IconButton.filled( + tooltip: downloadTip, + visualDensity: VisualDensity.compact, + onPressed: download, + icon: const Icon(Icons.download, size: 18), + ) + : IconButton.outlined( + tooltip: downloadTip, + visualDensity: VisualDensity.compact, + onPressed: download, + icon: const Icon(Icons.download, size: 18), + ) + else + Tooltip( + message: downloadTip, + child: likelyVideo + ? FilledButton.icon( + onPressed: download, + icon: const Icon(Icons.download, size: 18), + label: Text(tr('다운로드')), + ) + : OutlinedButton.icon( + onPressed: download, + icon: const Icon(Icons.download, size: 18), + label: Text(tr('다운로드')), + ), + ), + const SizedBox(width: 4), + if (!compact) btn(Icons.vpn_lock_outlined, tr('Tor 브라우저'), onTor), + if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), onExternal), + if (!compact) btn(Icons.cookie_outlined, tr('쿠키 · 방문 기록 지우기'), onClearData), + if (c.busy && !workOpen && !compact) + InkWell( + onTap: onToggleWork, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: JobIndicator(c: c, compact: true), + ), + ), + if (!compact) + btn(workOpen ? Icons.view_sidebar : Icons.view_sidebar_outlined, keyHint(tr('작업 현황 보기 · 화면 분할'), 'Ctrl+Shift+J'), + onToggleWork, color: c.busy ? JjColors.accent : null), + if (!compact) + btn(panelOpen ? Icons.bookmarks : Icons.bookmarks_outlined, keyHint(tr('즐겨찾기 관리'), 'Ctrl+Shift+B'), onTogglePanel) + else + PopupMenuButton( + tooltip: tr('더 보기'), + icon: const Icon(Icons.more_vert, size: 20), + onSelected: (v) => switch (v) { + 'home' => onGo(home), + 'translate' => onToggleTranslate(), + 'external' => onExternal(), + 'work' => onToggleWork(), + 'clear' => onClearData(), + 'tor' => onTor(), + _ => onTogglePanel(), + }, + itemBuilder: (_) => [ + PopupMenuItem(value: 'home', child: ListTile(dense: true, leading: const Icon(Icons.home_outlined), title: Text(tr('홈')))), + PopupMenuItem( + value: 'translate', + child: ListTile( + dense: true, + leading: Icon(Icons.translate, color: translating ? JjColors.accent : null), + title: Text(translating ? tr('원문 보기 (번역 끄기)') : tr('이 페이지 번역')))), + PopupMenuItem( + value: 'bookmarks', + child: ListTile(dense: true, leading: const Icon(Icons.bookmarks_outlined), title: Text(tr('즐겨찾기 관리')))), + PopupMenuItem( + value: 'work', + child: ListTile(dense: true, leading: const Icon(Icons.view_sidebar_outlined), title: Text(tr('작업 현황')))), + PopupMenuItem( + value: 'tor', + child: ListTile(dense: true, leading: const Icon(Icons.vpn_lock_outlined), title: Text(tr('Tor 브라우저')))), + PopupMenuItem( + value: 'external', + child: ListTile(dense: true, leading: const Icon(Icons.open_in_new), title: Text(tr('외부 브라우저로 열기')))), + PopupMenuItem( + value: 'clear', + child: ListTile(dense: true, leading: const Icon(Icons.cookie_outlined), title: Text(tr('쿠키 · 방문 기록 지우기')))), + ], + ), + ]); + }), + ); + } +} diff --git a/lib/ui/browser/cef_view.dart b/lib/ui/browser/cef_view.dart index 76aee38..ecb8efb 100644 --- a/lib/ui/browser/cef_view.dart +++ b/lib/ui/browser/cef_view.dart @@ -9,6 +9,7 @@ import '../../app/net_guard.dart'; import '../../core/download_detect.dart' show CookieExport, CookieRecord, isLoginCookieDomain; import '../../core/web_address.dart'; import '../../l10n/tr.dart'; +import '../../platform/windows/cef_runtime.dart'; import '../../platform/windows/elevation.dart'; import '../cef_setup.dart' show confirmCefWhenElevated; import '../theme.dart'; @@ -17,6 +18,29 @@ import 'web_nav.dart'; // ───────── 앱 안 Chrome (CEF) ───────── +/// 약속: 환경 설정에서 Chrome 을 고르고 엔진이 이번 실행에 준비되어 있으면 Chrome, 아니면 Edge 웹 뷰. +/// [cefTry] 는 Chrome 엔진 [다시 시도] 때마다 늘린다 (새로 만든다) +Widget browserEngineView(AppController c, + {required BrowserHost host, required String url, required int cefTry, required VoidCallback onCefRetry}) { + final b = c.settings.browser; + if (!(b.engine == 'chrome' && CefRuntime.readyThisRun)) { + return EdgeView(host: host, initialUrl: url, dataDir: c.settings.webViewDataDir, javaScript: b.javaScript); + } + // 207: 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않는다 (보안) - 이유와 고를 것 + if (Elevation.elevated && !b.cefWhenElevated) return CefElevatedBlocked(c: c); + // Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다 + return CefView( + key: ValueKey('${b.javaScript}|$cefTry'), + host: host, + initialUrl: url, + dataDir: c.settings.webViewDataDir, + javaScript: b.javaScript, + // 207: 시작하지 못하면 사용자가 바로 고를 수 있게 + onUseEdge: () => c.updateSettings((x) => x.browser.engine = 'edge'), + onRetry: onCefRetry, + cefWhenElevated: b.cefWhenElevated); +} + /// 내장 Chrome 엔진 (환경 설정에서 고르고 내려받은 경우). 약속은 [EdgeView] 와 같다 (host 로 알리고 attach 로 조작을 준다): /// 주소 · 제목 · 진행 · 동영상 페이지 판별 · 뒤로 / 앞으로 · YouTube 쿠키를 yt-dlp 용으로 내보내기. class CefView extends StatefulWidget { diff --git a/lib/ui/browser/dns_block.dart b/lib/ui/browser/dns_block.dart new file mode 100644 index 0000000..0ffc075 --- /dev/null +++ b/lib/ui/browser/dns_block.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../app/net_guard.dart'; +import '../../l10n/tr.dart'; +import '../theme.dart'; + +/// 250: 페이지를 DNS 서버에 닿지 않아 열지 못함 (주소 이름 · DNS 서버). 엔진의 오류 화면 위에 이유와 할 일을 보인다. +/// 약속: [find] 가 막혔다고 보면 새 값을 주고 (같거나 아니면 null), [set] 으로 정하고 걷는다. +/// 덮개가 보이는 동안은 NetGuard 의 아래 알림을 막는다 (248: 같은 안내 · [시스템 DNS 로 바꾸기] 가 두 개 보였다 - a4 933) +class DnsBlockWatch { + (String, String)? _blocked; + + /// 이번 읽기를 시작한 시각 (이보다 앞선 DNS 실패는 이 페이지와 상관없다) + DateTime loadStartAt = DateTime.now(); + + (String, String)? get blocked => _blocked; + + /// 덮개는 여기서만 바꾼다 (새로 읽기 시작 · [다시 시도] 에서 걷는다 - Android 는 오류 뒤에 읽기 끝이 또 온다) + void set((String, String)? v) { + final g = NetGuard.instance.noticeBlockers; + if (_blocked == null && v != null) g.value++; + if (_blocked != null && v == null) g.value--; + _blocked = v; + } + + /// [error]: 엔진이 알려 준 본문 오류. 프록시 터널 실패이고 DNS 서버에 지금 닿지 않는 중이면, 그 이름의 실패 기록이 없어도 + /// (Chromium 이 실패한 프록시를 기억해 다시 묻지 않는 때 등) DNS 때문으로 본다. + (String, String)? find({required String host, String? error}) { + if (host.isEmpty) return null; + final g = NetGuard.instance; + var server = g.dnsUnreachableFor(host, loadStartAt); + final f = g.failure.value; + if (server == null && f != null && error != null && (error.contains('TUNNEL') || error.contains('PROXY'))) server = f.server; + if (server == null) return null; + final next = (host, server); + return next == _blocked ? null : next; + } + + /// 화면을 닫을 때: 막아 둔 알림을 푼다 (닫는 중에는 다른 위젯을 바꾸지 않게 다음 차례에) + void dispose() { + if (_blocked != null) scheduleMicrotask(() => NetGuard.instance.noticeBlockers.value--); + } +} + +/// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]). +/// 트리 모양은 늘 같게 (Stack 첫 칸이 웹 뷰) - 덮개를 띄울 때 모양이 바뀌면 웹 뷰가 새로 만들어져 페이지를 다시 읽고, +/// 다시 읽기가 시작되며 덮개가 걷혔다 (931 노트 10+ 에서 덮개가 보이지 않던 것, Windows 실제 시험에서 재현) +class DnsBlockCover extends StatelessWidget { + final Widget view; + + /// (주소 이름, DNS 서버). null 이면 웹 뷰만 + final (String, String)? blocked; + final VoidCallback onSystemDns; + final VoidCallback onRetry; + const DnsBlockCover({super.key, required this.view, required this.blocked, required this.onSystemDns, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final b = blocked; + return Stack(children: [ + Positioned.fill(child: view), + if (b != null) + Positioned.fill( + child: ColoredBox( + color: JjColors.bg, + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.dns_outlined, size: 40, color: Colors.orange), + const SizedBox(height: 12), + Text(trf('DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.', [b.$2, b.$1]), + textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)), + const SizedBox(height: 8), + Text(tr('인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.'), + textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: JjColors.textDim)), + const SizedBox(height: 16), + Wrap(spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [ + FilledButton(onPressed: onSystemDns, child: Text(tr('시스템 DNS 로 바꾸기'))), + OutlinedButton(onPressed: onRetry, child: Text(tr('다시 시도'))), + ]), + ]), + ), + ), + ), + ), + ), + ]); + } +} diff --git a/lib/ui/browser/page_translation.dart b/lib/ui/browser/page_translation.dart new file mode 100644 index 0000000..93efa1c --- /dev/null +++ b/lib/ui/browser/page_translation.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import '../../core/web_translate.dart'; +import '../../l10n/tr.dart'; +import 'web_nav.dart'; + +/// 페이지 번역 (환경 설정 > 웹 브라우저 > 웹 페이지 자동 번역). +/// 약속: [now] 는 켜져 있으면 지금 번역하고, 켜져 있는 동안 2초마다 새로 나온 글자를 번역한다 (YouTube 처럼 글이 계속 바뀌는 페이지). +/// 세 번 실패하면 [resetFails] (다른 페이지로 감) 까지 쉬고 [onGiveUp] 으로 한 번 알린다. [setOn] 으로 끄면 원문으로 되돌린다. +class PageTranslation { + static final _translator = WebPageTranslator(); + + /// 지금 페이지의 조작 (아직 웹뷰가 붙지 않았으면 null) + final WebNav? Function() nav; + + /// 지금 번역해도 되는지 (화면이 가려져 있지 않고 웹 주소일 때) + final bool Function() canRun; + final void Function(Object error) onGiveUp; + + PageTranslation({required this.on, required this.nav, required this.canRun, required this.onGiveUp}); + + /// 번역 중인지. 처음엔 설정을 따르고, 도구 막대의 [번역] 버튼으로 바꾼다. + bool on; + + /// 버튼으로 직접 켰다: 이미 화면 언어로 보이는 페이지도 번역한다 + bool force = false; + bool _busy = false; + int _fails = 0; + Timer? _timer; + bool _disposed = false; + + /// 다른 페이지: 번역을 다시 시도 + void resetFails() => _fails = 0; + + void setOn(bool v, {required bool force}) { + on = v; + this.force = v && force; + _fails = 0; + if (v) { + now(); + } else { + _timer?.cancel(); + _timer = null; + nav()?.evaluate(webTranslateRestoreScript); + } + } + + void now() { + if (!on || _disposed) return; + _timer ??= Timer.periodic(const Duration(seconds: 2), (_) => _tick()); + _tick(); + } + + Future _tick() async { + final n = nav(); + if (!on || _busy || n == null || !canRun() || _fails >= 3) return; + _busy = true; + try { + final target = googleLang(uiLanguage); // 실제 화면 언어 (시스템 언어 따르기면 그 언어) + // 한 번에 300개씩, 페이지가 크면 몇 번 더 + for (var round = 0; round < 10 && !_disposed && on; round++) { + final got = CollectedText.parse(await n.evaluate(webTranslateCollectScript(target, force: force))); + if (got == null || got.texts.isEmpty) break; + final out = await _translator.translate(got.texts, target); + // 대부분 이미 화면 언어면 (직접 켠 게 아니면) 이 페이지는 더 번역하지 않는다 + final chars = got.texts.fold(0, (a, t) => a + t.length); + var same = 0; + for (var i = 0; i < out.length; i++) { + if (out[i] == null) same += got.texts[i].length; + } + if (_disposed || !on) break; + await n.evaluate(webTranslateApplyScript({for (var i = 0; i < got.ids.length; i++) got.ids[i]: out[i]}, + same: round == 0 && chars > 0 && same >= chars * 0.8)); + if (!got.more) break; + } + _fails = 0; + } catch (e) { + // 번역 서버에 닿지 않음 등: 세 번 실패하면 다른 페이지로 갈 때까지 쉰다 + if (++_fails == 3 && !_disposed) onGiveUp(e); + } finally { + _busy = false; + } + } + + void dispose() { + _disposed = true; + _timer?.cancel(); + } +} diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 0586527..edd5c31 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -12,29 +12,20 @@ import '../app/tor_service.dart'; import '../app/web_proxy.dart'; 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/elevation.dart'; -import '../services/downloader.dart' show DownloadState; -import 'app_actions.dart'; import 'bookmark_manager_page.dart'; import 'bookmark_ui.dart'; -import 'confirm.dart'; -import 'downloads_page.dart'; -import 'key_hint.dart'; -import 'settings_page.dart'; -import 'tor_page.dart'; -import 'theme.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'; +import 'browser/browser_actions.dart'; +import 'browser/browser_banners.dart'; +import 'browser/browser_toolbar.dart'; +import 'browser/dns_block.dart'; +import 'browser/page_translation.dart'; export 'browser/bookmark_panel.dart' show BookmarkPanel, editBookmark; export 'browser/edge_view.dart' show browserEnvironment, edgeBrowserArguments; @@ -102,92 +93,42 @@ class _BrowserPageState extends State with RouteAware { String _title = ''; double _progress = 1; - /// 250: 이 페이지를 DNS 서버에 닿지 않아 열지 못함 (주소 이름 · DNS 서버). 엔진의 오류 화면 위에 이유와 할 일을 보인다 - (String, String)? _dnsBlocked; - DateTime _loadStartAt = DateTime.now(); - - /// [error]: 엔진이 알려 준 본문 오류. 프록시 터널 실패이고 DNS 서버에 지금 닿지 않는 중이면, 그 이름의 실패 기록이 없어도 - /// (Chromium 이 실패한 프록시를 기억해 다시 묻지 않는 때 등) DNS 때문으로 본다. - /// 덮개는 여기서 걷지 않는다 (새로 읽기 시작 · [다시 시도] 에서) - Android 는 오류 뒤에 읽기 끝이 또 온다. - /// 248: 덮개가 보이는 동안은 아래 알림을 띄우지 않는다 (같은 안내 · [시스템 DNS 로 바꾸기] 가 두 개 보였다 - a4 933) - void _setDnsBlocked((String, String)? v) { - final g = NetGuard.instance.noticeBlockers; - if (_dnsBlocked == null && v != null) g.value++; - if (_dnsBlocked != null && v == null) g.value--; - _dnsBlocked = v; - } + /// 250: DNS 서버에 닿지 않아 열지 못한 페이지 (덮개) + final _dns = DnsBlockWatch(); /// 250: 사용자가 다른 곳으로 가거나 다시 읽음 - 덮개를 걷는다 (다시 실패하면 또 뜬다) void _userNavigated() { - _loadStartAt = DateTime.now(); - if (_dnsBlocked != null && mounted) setState(() => _setDnsBlocked(null)); + _dns.loadStartAt = DateTime.now(); + if (_dns.blocked != null && mounted) setState(() => _dns.set(null)); } void _checkDnsBlocked({String? host, String? error}) { - final h = host ?? Uri.tryParse(_url)?.host ?? ''; - if (h.isEmpty) return; - final g = NetGuard.instance; - var server = g.dnsUnreachableFor(h, _loadStartAt); - final f = g.failure.value; - if (server == null && f != null && error != null && (error.contains('TUNNEL') || error.contains('PROXY'))) server = f.server; - if (server == null) return; - final next = (h, server); - if (next == _dnsBlocked) return; + final next = _dns.find(host: host ?? Uri.tryParse(_url)?.host ?? '', error: error); + if (next == null) return; // 기기에서 "덮개를 띄웠는데 안 보임" 과 "띄우지 않음" 을 가릴 수 있게 - widget.c.note(trf('DNS 안내 덮개를 띄움 ({0} · DNS {1})', [h, server])); - setState(() => _setDnsBlocked(next)); + widget.c.note(trf('DNS 안내 덮개를 띄움 ({0} · DNS {1})', [next.$1, next.$2])); + setState(() => _dns.set(next)); } - /// 250: DNS 실패면 웹 뷰 위에 덮는다 ([시스템 DNS 로 바꾸기] [다시 시도]). - /// 트리 모양은 늘 같게 (Stack 첫 칸이 웹 뷰) - 덮개를 띄울 때 모양이 바뀌면 웹 뷰가 새로 만들어져 페이지를 다시 읽고, - /// 다시 읽기가 시작되며 덮개가 걷혔다 (931 노트 10+ 에서 덮개가 보이지 않던 것, Windows 실제 시험에서 재현) Widget _withDnsBlock(Widget view) { - final b = _dnsBlocked; void retry() { - setState(() => _setDnsBlocked(null)); + setState(() => _dns.set(null)); _nav?.reload(); } - return Stack(children: [ - Positioned.fill(child: view), - if (b != null) - Positioned.fill( - child: ColoredBox( - color: JjColors.bg, - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.dns_outlined, size: 40, color: Colors.orange), - const SizedBox(height: 12), - Text(trf('DNS 서버 ({0}) 에 닿지 않아 {1} 을(를) 열 수 없습니다.', [b.$2, b.$1]), - textAlign: TextAlign.center, style: const TextStyle(fontSize: 16)), - const SizedBox(height: 8), - Text(tr('인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.'), - textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: JjColors.textDim)), - const SizedBox(height: 16), - Wrap(spacing: 8, runSpacing: 8, alignment: WrapAlignment.center, children: [ - FilledButton( - onPressed: () async { - await widget.c.updateSettings((x) => x.network.mode = 'system'); - // 새 DNS 설정이 프록시에 걸린 뒤 다시 읽는다 - await Future.delayed(const Duration(milliseconds: 300)); - if (mounted) retry(); - }, - child: Text(tr('시스템 DNS 로 바꾸기')), - ), - OutlinedButton(onPressed: retry, child: Text(tr('다시 시도'))), - ]), - ]), - ), - ), - ), - ), - ), - ]); + return DnsBlockCover( + view: view, + blocked: _dns.blocked, + onRetry: retry, + onSystemDns: () async { + await widget.c.updateSettings((x) => x.network.mode = 'system'); + // 새 DNS 설정이 프록시에 걸린 뒤 다시 읽는다 + await Future.delayed(const Duration(milliseconds: 300)); + if (mounted) retry(); + }, + ); } + bool _hasVideo = false, _canBack = false, _canFwd = false, _panel = false; WebNav? _nav; @@ -202,21 +143,21 @@ class _BrowserPageState extends State with RouteAware { onUrl: (u) { if (!mounted || u.isEmpty) return; BrowserPage._lastUrl = u; - if (u != _url) _trFails = 0; // 다른 페이지: 번역을 다시 시도 + if (u != _url) _tr.resetFails(); // 다른 페이지: 번역을 다시 시도 setState(() => _url = u); if (!_addressFocus.hasFocus) _address.text = u; _applyAdSettings(); - _translateNow(); + _tr.now(); }, onTitle: (t) => mounted ? setState(() => _title = t) : null, onProgress: (p) { if (!mounted) return; // 250: 새로 읽기 시작 - 시작 시각을 적어 둔다 (덮개는 여기서 걷지 않는다 - [_userNavigated]) - if (p < 1 && _progress >= 1) _loadStartAt = DateTime.now(); + if (p < 1 && _progress >= 1) _dns.loadStartAt = DateTime.now(); setState(() => _progress = p); if (p >= 1) { _checkDnsBlocked(); - _translateNow(); // 다 읽었다: 바로 번역 (2초를 기다리지 않고) + _tr.now(); // 다 읽었다: 바로 번역 (2초를 기다리지 않고) } }, onVideo: (v) => mounted ? setState(() => _hasVideo = v) : null, @@ -243,16 +184,15 @@ class _BrowserPageState extends State with RouteAware { // ───────── 페이지 번역 (환경 설정 > 웹 브라우저 > 웹 페이지 자동 번역) ───────── - static final _translator = WebPageTranslator(); - - /// 이 화면에서 번역 중인지. 처음엔 설정을 따르고, 도구 막대의 [번역] 버튼으로 바꾼다. - late bool _trOn = widget.c.settings.browser.translate; - - /// 버튼으로 직접 켰다: 이미 화면 언어로 보이는 페이지도 번역한다 - bool _trForce = false; - bool _trBusy = false; - int _trFails = 0; - Timer? _trTimer; + late final _tr = PageTranslation( + on: widget.c.settings.browser.translate, + nav: () => _nav, + canRun: () => mounted && _visible && _url.startsWith('http'), + onGiveUp: (e) { + if (!mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('웹 페이지를 번역하지 못했습니다: {0}', [e])))); + }, + ); /// 브라우저가 다른 화면에 가려져 있지 않은지 bool _visible = true; @@ -272,67 +212,13 @@ class _BrowserPageState extends State with RouteAware { if (s.browser.translate != _lastTranslate) { _lastTranslate = s.browser.translate; _setTranslate(_lastTranslate, force: false); - } else if (s.display.language != _lastLang && _trOn) { - _translateNow(); // 다른 언어로 다시 번역 (스크립트가 앞의 번역을 되돌린 뒤 번역) + } else if (s.display.language != _lastLang && _tr.on) { + _tr.now(); // 다른 언어로 다시 번역 (스크립트가 앞의 번역을 되돌린 뒤 번역) } _lastLang = s.display.language; } - void _setTranslate(bool on, {required bool force}) { - setState(() { - _trOn = on; - _trForce = on && force; - _trFails = 0; - }); - if (on) { - _translateNow(); - } else { - _trTimer?.cancel(); - _trTimer = null; - _nav?.evaluate(webTranslateRestoreScript); - } - } - - /// 지금 번역하고, 켜져 있는 동안 2초마다 새로 나온 글자를 번역한다 (YouTube 처럼 글이 계속 바뀌는 페이지) - void _translateNow() { - if (!_trOn) return; - _trTimer ??= Timer.periodic(const Duration(seconds: 2), (_) => _translateTick()); - _translateTick(); - } - - Future _translateTick() async { - final nav = _nav; - if (!_trOn || _trBusy || nav == null || !_visible || !_url.startsWith('http') || _trFails >= 3) return; - _trBusy = true; - try { - final target = googleLang(uiLanguage); // 실제 화면 언어 (시스템 언어 따르기면 그 언어) - // 한 번에 300개씩, 페이지가 크면 몇 번 더 - for (var round = 0; round < 10 && mounted && _trOn; round++) { - final got = CollectedText.parse(await nav.evaluate(webTranslateCollectScript(target, force: _trForce))); - if (got == null || got.texts.isEmpty) break; - final out = await _translator.translate(got.texts, target); - // 대부분 이미 화면 언어면 (직접 켠 게 아니면) 이 페이지는 더 번역하지 않는다 - final chars = got.texts.fold(0, (a, t) => a + t.length); - var same = 0; - for (var i = 0; i < out.length; i++) { - if (out[i] == null) same += got.texts[i].length; - } - if (!mounted || !_trOn) break; - await nav.evaluate(webTranslateApplyScript({for (var i = 0; i < got.ids.length; i++) got.ids[i]: out[i]}, - same: round == 0 && chars > 0 && same >= chars * 0.8)); - if (!got.more) break; - } - _trFails = 0; - } catch (e) { - // 번역 서버에 닿지 않음 등: 세 번 실패하면 다른 페이지로 갈 때까지 쉰다 - if (++_trFails == 3 && mounted) { - ScaffoldMessenger.maybeOf(context) - ?.showSnackBar(SnackBar(content: Text(trf('웹 페이지를 번역하지 못했습니다: {0}', [e])))); - } - } finally { - _trBusy = false; - } - } + void _setTranslate(bool on, {required bool force}) => setState(() => _tr.setOn(on, force: force)); @override void initState() { @@ -344,7 +230,7 @@ class _BrowserPageState extends State with RouteAware { _lastLang = s.display.language; _applyCookieExport(); widget.c.addListener(_onSettings); - if (_trOn) _translateNow(); + _tr.now(); // 66-2: 브라우저 가운데는 페이지가 좌우로 밀리므로 화면 옮기기는 위쪽 막대에서 - 처음 한 번만 알린다. // 알림 (SnackBar) 은 다른 알림 (다운로드 등) 을 뒤로 밀어 기다리게 하므로 위쪽 막대 아래의 한 줄로 if (s.display.swipeNav && !s.browser.swipeHinted) { @@ -356,82 +242,7 @@ class _BrowserPageState extends State with RouteAware { /// 66-2: 화면 옮기기 안내 한 줄 (처음 한 번, ✕ 로 닫음) bool _swipeHint = false; - Widget _swipeHintBar() => Material( - color: JjColors.accent.withValues(alpha: 0.14), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 2, 4, 2), - child: Row(children: [ - Icon(Icons.swipe, size: 16, color: JjColors.accent), - const SizedBox(width: 8), - Expanded( - child: Text(tr('웹 브라우저에서는 위쪽 막대를 좌우로 밀면 다른 화면으로 옮깁니다 (가운데는 페이지가 밀립니다).'), - style: const TextStyle(fontSize: 12))), - IconButton( - tooltip: tr('안내 닫기'), - visualDensity: VisualDensity.compact, - iconSize: 16, - icon: const Icon(Icons.close), - onPressed: () => setState(() => _swipeHint = false), - ), - ]), - ), - ); - - Widget _torIdleBanner(DateTime at) => Material( - color: JjColors.panel, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 2, 4, 2), - child: Row(children: [ - Icon(Icons.timer_outlined, size: 16, color: JjColors.textDim), - const SizedBox(width: 8), - Expanded( - child: Text( - trf('Tor 대기 중 ({0}분 뒤 끔) - 이 브라우저는 Tor 를 쓰지 않습니다', [(at.difference(DateTime.now()).inSeconds / 60).ceil().clamp(0, 60)]), - style: TextStyle(fontSize: 12, color: JjColors.textDim), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - TextButton(onPressed: () => TorService.instance.stop(), child: Text(tr('지금 끄기'))), - ]), - ), - ); - - Widget _torBanner() => Material( - color: const Color(0xFF9C6ADE).withValues(alpha: 0.18), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 4), - child: Row(children: [ - const Icon(Icons.vpn_lock_outlined, size: 16, color: Color(0xFF9C6ADE)), - const SizedBox(width: 8), - Expanded(child: Text(tr('Tor 화면이 열려 있는 동안 이 브라우저도 Tor 로 나갑니다'), style: const TextStyle(fontSize: 12))), - ]), - ), - ); - - /// Tor 브라우저로: 켜져 있으면 Tor 화면, 아니면 짧은 안내와 [환경 설정 > 컴포넌트] - Future _openTor() async { - if (widget.c.settings.display.components.contains('tor')) { - await TorPage.open(Navigator.of(context), c: widget.c); - return; - } - final go = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - scrollable: true, - title: Text(tr('Tor 브라우저')), - content: Text(tr('Tor 브라우저는 페이지를 Tor 로 열어 사이트가 이 기기의 주소를 알 수 없게 합니다 (쿠키 · 기록은 따로, 닫으면 지움). ' - '환경 설정 > 컴포넌트 에서 켜면 이 브라우저 옆에 Tor 화면이 생깁니다.')), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('환경 설정 > 컴포넌트'))), - ], - ), - ); - if (go == true && mounted) { - await Navigator.push(context, MaterialPageRoute(builder: (_) => SettingsPage(c: widget.c, initialGroup: 'components'))); - } - } + Future _openTor() => openTorOrExplain(context, widget.c); /// 53: 쿠키를 쓰도록 정했을 때만 · 고른 사이트만 내보낸다 void _applyCookieExport() { @@ -440,24 +251,7 @@ class _BrowserPageState extends State with RouteAware { CookieExport.sites = s.download.loginCookieSites; } - /// 53: 쿠키 · 방문 기록 지우기 (확인 뒤). 다운로드용으로 내보낸 쿠키 파일도 지운다 - Future _clearData() async { - final ok = await confirmAction( - context, - title: tr('쿠키 · 방문 기록 지우기'), - body: tr('앱 안 브라우저의 쿠키 (로그인) · 사이트 데이터 · 캐시 · 방문 기록과, 다운로드용으로 내보낸 쿠키 파일을 지웁니다. ' - '사이트에 다시 로그인해야 합니다. 즐겨찾기는 그대로입니다.'), - ok: tr('지우기'), - ); - if (!ok || !mounted) return; - try { - await _nav?.clearData(); - } catch (_) {} - await deleteExportedCookies(widget.c.settings.webViewDataDir); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('쿠키 · 방문 기록을 지웠습니다.')))); - unawaited(_nav?.reload()); - } + Future _clearData() => confirmClearBrowserData(context, nav: _nav, dataDir: widget.c.settings.webViewDataDir); @override void didChangeDependencies() { @@ -479,7 +273,7 @@ class _BrowserPageState extends State with RouteAware { void didPopNext() { _visible = true; _applyAdSettings(); - _translateNow(); + _tr.now(); } /// YouTube 페이지면 광고 건너뛰기 · 숨기기 스크립트를 넣는다 (환경 설정 > 시작 · 웹 브라우저) @@ -498,11 +292,11 @@ class _BrowserPageState extends State with RouteAware { @override void dispose() { // 화면을 닫는 중에는 다른 위젯 (아래 알림) 을 바꾸지 않게 다음 차례에 - if (_dnsBlocked != null) scheduleMicrotask(() => NetGuard.instance.noticeBlockers.value--); + _dns.dispose(); browserRouteObserver.unsubscribe(this); BrowserPage._open--; widget.c.removeListener(_onSettings); - _trTimer?.cancel(); + _tr.dispose(); _address.dispose(); _addressFocus.dispose(); super.dispose(); @@ -524,41 +318,7 @@ class _BrowserPageState extends State with RouteAware { String get _downloadTip => _likelyVideo ? tr('다운로드') : tr('다운로드 (이 페이지에서 동영상을 찾지 못했지만 받아 볼 수 있습니다)'); - Future _download() async { - // 로그인 쿠키를 먼저 내보내 yt-dlp 가 같은 로그인으로 받도록 - try { - await _nav?.exportCookies(); - } catch (_) {} - if (!mounted) return; - final d = widget.downloads!; - final t = d.addPage(_url); - final m = ScaffoldMessenger.of(context); - if (t == null) { - m.showSnackBar(SnackBar(content: Text(tr('이미 받는 중이거나 받을 수 없는 주소입니다.')))); - return; - } - // 알림은 앱 전체에 떠 있으므로 이 브라우저 화면이 아니라 앱의 Navigator 로 연다 - // (화면을 오가 이 브라우저 화면이 닫혀도 "목록 보기" 가 된다) - final nav = Navigator.of(context); - // 받기 준비가 끝날 때까지 두고 아래 check 가 닫는다 ([✕] 로 바로 닫기) - final bar = showNotice(m, - life: NoticeLife.untilClosed, - text: trf('다운로드 추가: {0}', [_title.isEmpty ? _url : _title]), - action: (tr('목록 보기'), () => DownloadsPage.open(nav, d)))!; - // 받기 준비가 끝나면 (진행률이 나오거나 · 끝 · 실패 · 취소 · 목록에서 지움) 알림을 닫는다 - void check() { - final ready = !d.tasks.contains(t) || - t.progress != null || - (t.state != DownloadState.queued && t.state != DownloadState.downloading); - if (ready) { - d.removeListener(check); - bar.close(); - } - } - - d.addListener(check); - unawaited(bar.closed.then((_) => d.removeListener(check))); - } + Future _download() => addPageDownload(context, widget.downloads!, nav: _nav, url: _url, title: _title); void _openExternal(String url) => widget.c.services.shell.openUrl(url, browser: widget.c.settings.browser.external); @@ -622,16 +382,16 @@ class _BrowserPageState extends State with RouteAware { listenable: Listenable.merge([bm, widget.c]), builder: (context, _) => Column(children: [ _toolbar(), - if (_swipeHint) _swipeHintBar(), + if (_swipeHint) SwipeHintBar(onClose: () => setState(() => _swipeHint = false)), // Android: Tor 화면이 열려 있는 동안은 웹 뷰 프록시가 앱 전체라 이 브라우저도 Tor 로 (관리자 결정 10/10) ValueListenableBuilder( valueListenable: WebProxy.torOpen, - builder: (_, on, _) => on ? _torBanner() : const SizedBox(), + builder: (_, on, _) => on ? const TorBanner() : const SizedBox(), ), // 226: Tor 화면을 떠난 뒤 tor 를 잠시 켜 두는 중 (이 브라우저는 Tor 를 쓰지 않음) - 언제 끄는지 · 지금 끄기 ValueListenableBuilder( valueListenable: TorService.instance.idleStopAt, - builder: (_, at, _) => at == null || WebProxy.torOpen.value ? const SizedBox() : _torIdleBanner(at), + builder: (_, at, _) => at == null || WebProxy.torOpen.value ? const SizedBox() : TorIdleBanner(at: at), ), Expanded(child: Row(children: [ if (_panel) @@ -664,32 +424,12 @@ class _BrowserPageState extends State with RouteAware { ), Expanded( child: _withDnsBlock(widget.viewBuilder?.call(_host, _url) ?? - // 환경 설정에서 Chrome 을 고르고 엔진이 이번 실행에 준비되어 있으면 Chrome, 아니면 Edge - (widget.c.settings.browser.engine == 'chrome' && CefRuntime.readyThisRun - // 207: 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않는다 (보안) - 이유와 고를 것 - ? Elevation.elevated && !widget.c.settings.browser.cefWhenElevated - ? CefElevatedBlocked(c: widget.c) - // Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다 - : CefView( - key: ValueKey('${widget.c.settings.browser.javaScript}|$_cefTry'), - host: _host, - initialUrl: _url, - dataDir: widget.c.settings.webViewDataDir, - javaScript: widget.c.settings.browser.javaScript, - // 207: 시작하지 못하면 사용자가 바로 고를 수 있게 - onUseEdge: () => widget.c.updateSettings((x) => x.browser.engine = 'edge'), - onRetry: () => setState(() => _cefTry++), - cefWhenElevated: widget.c.settings.browser.cefWhenElevated) - : EdgeView( - host: _host, - initialUrl: _url, - dataDir: widget.c.settings.webViewDataDir, - javaScript: widget.c.settings.browser.javaScript))), + browserEngineView(widget.c, host: _host, url: _url, cefTry: _cefTry, onCefRetry: () => setState(() => _cefTry++))), ), ]), ), if (_work) ...[ - _SplitHandle(onDrag: (dx) => setState(() { + SplitHandle(onDrag: (dx) => setState(() { final max = MediaQuery.sizeOf(context).width - 400; _workWidth = (_workWidth - dx).clamp(260.0, max < 260 ? 260.0 : max); })), @@ -712,182 +452,32 @@ class _BrowserPageState extends State with RouteAware { ); } - /// 위쪽 막대: 여기서 좌우로 밀면 화면 이동 (웹 페이지 위에서는 웹 페이지의 스크롤과 다투어 위 · 아래가 이상했음) - Widget _toolbar() => SwipeNav(current: 'browser', anywhere: true, child: _toolbarBar()); - - Widget _toolbarBar() { - final starred = bm.tree.findByUrl(_url) != null; - IconButton btn(IconData i, String tip, VoidCallback? f, {Color? color}) => IconButton( - tooltip: tip, - visualDensity: VisualDensity.compact, - icon: Icon(i, size: 20, color: color), - onPressed: f, - ); - // 주소 칸이 좁아지면 (폰 세로 · 태블릿 세로 · 폰 가로 100%) 자주 안 쓰는 버튼은 ⋮ 메뉴로 - 주소가 "http" 만 보이거나 - // 아예 사라지던 것. 버튼을 없애지는 않는다. - // 201: 폰 가로처럼 넓어도 버튼이 폭을 다 차지하면 주소 칸이 사라졌다 - 그때는 두 줄 (아래 줄을 주소 칸이 다 씀) - return AppTopBar( - minMiddleWidth: 380, - nav: const AppNavButtons(onBrowserPage: true), - actions: AppActions(c: widget.c), - middle: LayoutBuilder(builder: (context, box) { - final compact = box.maxWidth < 640; - return Row(children: [ - if (!compact) ...[ - Container(width: 1, height: 24, color: JjColors.border), - const SizedBox(width: 4), - ], - // 웹 페이지 이동 (앱 화면 이동인 왼쪽 버튼과 구분되는 모양) - btn(Icons.arrow_back_ios_new, keyHint(tr('이전 페이지'), 'Alt+←'), _canBack ? () => _nav?.back() : null), - btn(Icons.arrow_forward_ios, keyHint(tr('다음 페이지'), 'Alt+→'), _canFwd ? () => _nav?.forward() : null), - _progress < 1 - ? btn(Icons.close, tr('중지'), () => _nav?.stop()) - : btn(Icons.refresh, keyHint(tr('새로고침'), 'F5'), () => _nav?.reload()), - if (!compact) btn(Icons.home_outlined, tr('홈'), () => _go(widget.c.settings.browser.homeUrl)), - const SizedBox(width: 6), - Expanded( - child: TextField( - controller: _address, - focusNode: _addressFocus, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - isDense: true, - filled: true, - fillColor: JjColors.bg, - hintText: keyHint(tr('주소 입력 또는 검색'), 'Ctrl+L'), - prefixIcon: Icon(_url.startsWith('https') ? Icons.lock_outline : Icons.public, size: 16), - prefixIconConstraints: const BoxConstraints(minWidth: 32), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none), - suffixIcon: IconButton( - tooltip: starred ? keyHint(tr('즐겨찾기 수정'), 'Ctrl+D') : keyHint(tr('즐겨찾기 추가'), 'Ctrl+D'), - icon: Icon(starred ? Icons.star : Icons.star_border, size: 18, - color: starred ? JjColors.amber : JjColors.textDim), - onPressed: _star, - ), - ), - onSubmitted: _go, - ), - ), - const SizedBox(width: 4), - if (!compact) - btn( - Icons.translate, - _trOn - ? tr('원문 보기 (번역 끄기)') - : trf('이 페이지를 {0}(으)로 번역', [I18nController.nativeName(widget.c.settings.display.language)]), - () => _setTranslate(!_trOn, force: true), - color: _trOn ? JjColors.accent : null), - const SizedBox(width: 4), - if (compact) - _likelyVideo - ? IconButton.filled( - tooltip: _downloadTip, - visualDensity: VisualDensity.compact, - onPressed: _canDownload ? _download : null, - icon: const Icon(Icons.download, size: 18), - ) - : IconButton.outlined( - tooltip: _downloadTip, - visualDensity: VisualDensity.compact, - onPressed: _canDownload ? _download : null, - icon: const Icon(Icons.download, size: 18), - ) - else - Tooltip( - message: _downloadTip, - child: _likelyVideo - ? FilledButton.icon( - onPressed: _canDownload ? _download : null, - icon: const Icon(Icons.download, size: 18), - label: Text(tr('다운로드')), - ) - : OutlinedButton.icon( - onPressed: _canDownload ? _download : null, - icon: const Icon(Icons.download, size: 18), - label: Text(tr('다운로드')), - ), - ), - const SizedBox(width: 4), - if (!compact) btn(Icons.vpn_lock_outlined, tr('Tor 브라우저'), _openTor), - if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)), - if (!compact) btn(Icons.cookie_outlined, tr('쿠키 · 방문 기록 지우기'), _clearData), - if (widget.c.busy && !_work && !compact) - InkWell( - onTap: _toggleWork, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - child: JobIndicator(c: widget.c, compact: true), - ), - ), - if (!compact) - btn(_work ? Icons.view_sidebar : Icons.view_sidebar_outlined, keyHint(tr('작업 현황 보기 · 화면 분할'), 'Ctrl+Shift+J'), - _toggleWork, color: widget.c.busy ? JjColors.accent : null), - if (!compact) - btn(_panel ? Icons.bookmarks : Icons.bookmarks_outlined, keyHint(tr('즐겨찾기 관리'), 'Ctrl+Shift+B'), - () => setState(() => _panel = !_panel)) - else - PopupMenuButton( - tooltip: tr('더 보기'), - icon: const Icon(Icons.more_vert, size: 20), - onSelected: (v) => switch (v) { - 'home' => _go(widget.c.settings.browser.homeUrl), - 'translate' => _setTranslate(!_trOn, force: true), - 'external' => _openExternal(_url), - 'work' => _toggleWork(), - 'clear' => _clearData(), - 'tor' => _openTor(), - _ => setState(() => _panel = !_panel), - }, - itemBuilder: (_) => [ - PopupMenuItem(value: 'home', child: ListTile(dense: true, leading: const Icon(Icons.home_outlined), title: Text(tr('홈')))), - PopupMenuItem( - value: 'translate', - child: ListTile( - dense: true, - leading: Icon(Icons.translate, color: _trOn ? JjColors.accent : null), - title: Text(_trOn ? tr('원문 보기 (번역 끄기)') : tr('이 페이지 번역')))), - PopupMenuItem( - value: 'bookmarks', - child: ListTile(dense: true, leading: const Icon(Icons.bookmarks_outlined), title: Text(tr('즐겨찾기 관리')))), - PopupMenuItem( - value: 'work', - child: ListTile(dense: true, leading: const Icon(Icons.view_sidebar_outlined), title: Text(tr('작업 현황')))), - PopupMenuItem( - value: 'tor', - child: ListTile(dense: true, leading: const Icon(Icons.vpn_lock_outlined), title: Text(tr('Tor 브라우저')))), - PopupMenuItem( - value: 'external', - child: ListTile(dense: true, leading: const Icon(Icons.open_in_new), title: Text(tr('외부 브라우저로 열기')))), - PopupMenuItem( - value: 'clear', - child: ListTile(dense: true, leading: const Icon(Icons.cookie_outlined), title: Text(tr('쿠키 · 방문 기록 지우기')))), - ], - ), - ]); - }), - ); - } -} - - -/// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대 -class _SplitHandle extends StatelessWidget { - final ValueChanged onDrag; - const _SplitHandle({required this.onDrag}); - - @override - Widget build(BuildContext context) => MouseRegion( - cursor: SystemMouseCursors.resizeColumn, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onHorizontalDragUpdate: (d) => onDrag(d.delta.dx), - child: Container( - width: 6, - color: JjColors.bg, - alignment: Alignment.center, - child: Container(width: 1, color: JjColors.border), - ), - ), + Widget _toolbar() => BrowserToolbar( + c: widget.c, + nav: _nav, + url: _url, + progress: _progress, + canBack: _canBack, + canForward: _canFwd, + starred: bm.tree.findByUrl(_url) != null, + translating: _tr.on, + likelyVideo: _likelyVideo, + canDownload: _canDownload, + downloadTip: _downloadTip, + workOpen: _work, + panelOpen: _panel, + address: _address, + addressFocus: _addressFocus, + onGo: _go, + onStar: _star, + onToggleTranslate: () => _setTranslate(!_tr.on, force: true), + onDownload: _download, + onTor: _openTor, + onExternal: () => _openExternal(_url), + onClearData: _clearData, + onToggleWork: _toggleWork, + onTogglePanel: () => setState(() => _panel = !_panel), ); } + + diff --git a/test/browser_modules_contract_test.dart b/test/browser_modules_contract_test.dart index a26aeb9..5bd26db 100644 --- a/test/browser_modules_contract_test.dart +++ b/test/browser_modules_contract_test.dart @@ -1,7 +1,11 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/core/download_detect.dart' show CookieExport, CookieRecord; +import 'package:jj_mkvmaker/core/web_translate.dart' show webTranslateRestoreScript; +import 'package:jj_mkvmaker/ui/browser/dns_block.dart'; +import 'package:jj_mkvmaker/ui/browser/page_translation.dart'; import 'package:jj_mkvmaker/ui/browser/web_nav.dart'; import 'package:path/path.dart' as p; @@ -73,4 +77,40 @@ void main() { expect([file.existsSync(), File('${file.path}.tmp').existsSync()], [false, false]); await deleteExportedCookies(''); // 데이터 폴더가 없으면 아무것도 안 함 }); + + test('약속 PageTranslation: 꺼져 있거나 할 수 없을 때는 페이지를 건드리지 않고, 끄면 원문으로 되돌린다', () async { + final nav = _Log(); + var can = false; + final gaveUp = []; + final t = PageTranslation(on: false, nav: () => nav, canRun: () => can, onGiveUp: gaveUp.add); + addTearDown(t.dispose); + t.now(); + expect(nav.calls, isEmpty, reason: '꺼져 있음'); + t.setOn(true, force: true); + expect([t.on, t.force], [true, true]); + await Future.delayed(Duration.zero); + expect(nav.calls, isEmpty, reason: '화면이 가려졌거나 웹 주소가 아님'); + can = true; + t.now(); + await Future.delayed(Duration.zero); + expect(nav.calls.where((c) => c.startsWith('eval ')).length, 1, reason: '모을 글자를 한 번 묻는다 (받은 값이 글자 묶음이 아니면 멈춤)'); + nav.calls.clear(); + t.setOn(false, force: true); + expect([t.on, t.force], [false, false]); + expect(nav.calls, ['eval $webTranslateRestoreScript']); + expect(gaveUp, isEmpty); + }); + + test('약속 DnsBlockWatch: 실패 기록이 없으면 막지 않고, 덮개가 있는 동안만 아래 알림을 막는다', () { + final w = DnsBlockWatch(); + final g = NetGuard.instance.noticeBlockers; + final before = g.value; + expect(w.find(host: ''), isNull); + expect(w.find(host: 'never-failed.example'), isNull); + w.set(('a.example', '8.8.8.8')); + w.set(('b.example', '8.8.8.8')); + expect([w.blocked, g.value], [('b.example', '8.8.8.8'), before + 1], reason: '덮개를 바꿔도 한 번만 막는다'); + w.set(null); + expect([w.blocked, g.value], [null, before]); + }); }