browser_page 나누기 2: DNS 덮개 · 페이지 번역 · 도구 막대 · 안내 줄 · 브라우저 동작 · 엔진 고르기를 모듈로 (2006 → 483줄)

- dns_block (DnsBlockWatch · DnsBlockCover), page_translation (PageTranslation), browser_toolbar, browser_banners,
  browser_actions (Tor 안내 · 쿠키 지우기 · 페이지 다운로드), cef_view 의 browserEngineView
- 화면은 상태만 갖고 모듈을 부른다. 약속 시험: PageTranslation · DnsBlockWatch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
modular
RyuJongJin 2026-10-23 00:51:06 +09:00
parent ad9a12fbe1
commit d031584e7a
8 changed files with 732 additions and 491 deletions

View File

@ -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<void> 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<bool>(
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<void>(builder: (_) => SettingsPage(c: c, initialGroup: 'components')));
}
}
/// 53: 쿠키 · 방문 기록 지우기 (확인 뒤). 다운로드용으로 내보낸 쿠키 파일도 지우고 지금 페이지를 다시 읽는다
Future<void> 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<void> 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)));
}

View File

@ -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<double> 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),
),
),
);
}

View File

@ -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<String>(
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('쿠키 · 방문 기록 지우기')))),
],
),
]);
}),
);
}
}

View File

@ -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 {

View File

@ -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('다시 시도'))),
]),
]),
),
),
),
),
),
]);
}
}

View File

@ -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<void> _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<int>(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();
}
}

View File

@ -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<BrowserPage> 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 {
return DnsBlockCover(
view: view,
blocked: _dns.blocked,
onRetry: retry,
onSystemDns: () async {
await widget.c.updateSettings((x) => x.network.mode = 'system');
// 새 DNS 설정이 프록시에 걸린 뒤 다시 읽는다
await Future<void>.delayed(const Duration(milliseconds: 300));
if (mounted) retry();
},
child: Text(tr('시스템 DNS 로 바꾸기')),
),
OutlinedButton(onPressed: retry, child: Text(tr('다시 시도'))),
]),
]),
),
),
),
),
),
]);
);
}
bool _hasVideo = false, _canBack = false, _canFwd = false, _panel = false;
WebNav? _nav;
@ -202,21 +143,21 @@ class _BrowserPageState extends State<BrowserPage> 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<BrowserPage> 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<BrowserPage> 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<void> _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<int>(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<BrowserPage> 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<BrowserPage> 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<void> _openTor() async {
if (widget.c.settings.display.components.contains('tor')) {
await TorPage.open(Navigator.of(context), c: widget.c);
return;
}
final go = await showDialog<bool>(
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<void>(builder: (_) => SettingsPage(c: widget.c, initialGroup: 'components')));
}
}
Future<void> _openTor() => openTorOrExplain(context, widget.c);
/// 53: 쿠키를 쓰도록 정했을 때만 · 고른 사이트만 내보낸다
void _applyCookieExport() {
@ -440,24 +251,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
CookieExport.sites = s.download.loginCookieSites;
}
/// 53: 쿠키 · 방문 기록 지우기 (확인 뒤). 다운로드용으로 내보낸 쿠키 파일도 지운다
Future<void> _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<void> _clearData() => confirmClearBrowserData(context, nav: _nav, dataDir: widget.c.settings.webViewDataDir);
@override
void didChangeDependencies() {
@ -479,7 +273,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
void didPopNext() {
_visible = true;
_applyAdSettings();
_translateNow();
_tr.now();
}
/// YouTube 페이지면 광고 건너뛰기 · 숨기기 스크립트를 넣는다 (환경 설정 > 시작 · 웹 브라우저)
@ -498,11 +292,11 @@ class _BrowserPageState extends State<BrowserPage> 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<BrowserPage> with RouteAware {
String get _downloadTip =>
_likelyVideo ? tr('다운로드') : tr('다운로드 (이 페이지에서 동영상을 찾지 못했지만 받아 볼 수 있습니다)');
Future<void> _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<void> _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<BrowserPage> 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<bool>(
valueListenable: WebProxy.torOpen,
builder: (_, on, _) => on ? _torBanner() : const SizedBox(),
builder: (_, on, _) => on ? const TorBanner() : const SizedBox(),
),
// 226: Tor 화면을 떠난 뒤 tor 를 잠시 켜 두는 중 (이 브라우저는 Tor 를 쓰지 않음) - 언제 끄는지 · 지금 끄기
ValueListenableBuilder<DateTime?>(
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<BrowserPage> 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<BrowserPage> 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<String>(
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('쿠키 · 방문 기록 지우기')))),
],
),
]);
}),
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),
);
}
}
/// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대
class _SplitHandle extends StatelessWidget {
final ValueChanged<double> 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),
),
),
);
}

View File

@ -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 = <Object>[];
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<void>.delayed(Duration.zero);
expect(nav.calls, isEmpty, reason: '화면이 가려졌거나 웹 주소가 아님');
can = true;
t.now();
await Future<void>.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]);
});
}