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
parent
ad9a12fbe1
commit
d031584e7a
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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('쿠키 · 방문 기록 지우기')))),
|
||||
],
|
||||
),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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('다시 시도'))),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
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('다시 시도'))),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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('쿠키 · 방문 기록 지우기')))),
|
||||
],
|
||||
),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 브라우저 ↔ 작업 현황 사이 끌어서 너비 바꾸는 막대
|
||||
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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue