From be0c08834d3e9aad695190b8642a7d886dd1183d Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:58:53 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=B9=20=EB=B8=8C=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=EC=A0=80:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=20=EB=B2=88=EC=97=AD=20=C2=B7=20JavaScript=20=EB=81=84?= =?UTF-8?q?=EA=B8=B0=20(=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95=20>=20?= =?UTF-8?q?=EC=9B=B9=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 웹 페이지 자동 번역 (기본 끔): 다른 언어 페이지를 화면 언어로 (Google 번역, 원문 언어 자동 감지). 주소창 옆 번역 버튼으로 원문 · 번역 바꾸기, 새로 생긴 글도 2초마다 번역 (lib/core/web_translate.dart) - JavaScript 사용 (기본 켬): Edge · Android 는 바로 다시 읽어 적용, Chrome (CEF) 은 웹뷰를 새로 만든다. Edge 는 JavaScript 를 꺼도 번역됨 (TreeWalker 거르기 함수를 쓰지 않음) - webview_cef: create 에 {url, javascript} 를 받아 CefBrowserSettings.javascript 를 끔 (JJ_PATCH.md 5) - 통합 테스트 (Windows Edge · Android WebView): integration_test/web_translate_test.dart debug 빌드만 평문 http 허용 (기기 안 시험 페이지) Co-Authored-By: Claude Opus 5.5 --- android/app/src/debug/AndroidManifest.xml | 2 + assets/l10n/en.json | 8 + assets/l10n/ja.json | 8 + assets/l10n/zh-Hans.json | 8 + integration_test/web_translate_test.dart | 160 ++++++++++ lib/app/settings.dart | 10 + lib/core/web_translate.dart | 281 ++++++++++++++++++ lib/ui/browser_page.dart | 213 ++++++++++++- lib/ui/settings_page.dart | 20 ++ packages/webview_cef/JJ_PATCH.md | 4 + .../webview_cef/common/webview_handler.cc | 5 +- packages/webview_cef/common/webview_handler.h | 3 +- packages/webview_cef/common/webview_plugin.cc | 14 +- packages/webview_cef/lib/src/webview.dart | 7 +- test/browser_test.dart | 9 + test/web_translate_test.dart | 108 +++++++ 16 files changed, 838 insertions(+), 22 deletions(-) create mode 100644 integration_test/web_translate_test.dart create mode 100644 lib/core/web_translate.dart create mode 100644 test/web_translate_test.dart diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..595585f 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,6 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 79475bc..160382b 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -80,6 +80,7 @@ "JJ_MKVMaker 로 자막 만들기": "Make subtitles with JJ_MKVMaker", "JJ_MKVMaker 로 재생": "Play with JJ_MKVMaker", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_bookmarks.html", + "JavaScript 사용": "Enable JavaScript", "MKV · 자막 (jj_mkv)": "MKV · subtitles (jj_mkv)", "MKV · 자막 저장 위치": "Where to save MKV · subtitles", "MKV 만들기": "Make MKV", @@ -230,6 +231,8 @@ "길이 {0}": "Length {0}", "끄기": "Off", "끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "When off, starts right away with the last settings: source {0}, languages {1}, {2}", + "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). With the Chrome engine, page translation · video detection also stop.", + "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "When off, page scripts do not run (fewer ads · trackers, but script-built sites such as YouTube will not show). Page translation · video detection also stop.", "끌어다 놓은 항목에 동영상이 없습니다.": "There are no videos in the dropped items.", "끝": "End", "끝 = 현재": "End = now", @@ -269,6 +272,7 @@ "다른 브라우저의 즐겨찾기 가져오기": "Import bookmarks from another browser", "다른 앱에서 동영상을 열 때": "When opening a video from another app", "다른 앱에서 열기: {0}": "Opened from another app: {0}", + "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "Translates pages in other languages into the display language ({0}). The page text is sent to Google Translate. Use the translate button next to the address bar to switch between original · translation.", "다른 언어로 번역 (AI · 이 {0}에서)": "Translate to another language (AI · on this {0})", "다른 이름으로 저장": "Save as", "다른 이름으로 저장: {0} ({1})": "Save as: {0} ({1})", @@ -535,6 +539,7 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.", "우르두어": "Urdu", "우크라이나어": "Ukrainian", + "원문 보기 (번역 끄기)": "Show original (turn off translation)", "원본 비율": "Original aspect", "원본 유지 (재인코딩 없음)": "Keep original (no re-encoding)", "원본 크기": "Original size", @@ -548,6 +553,8 @@ "웹 브라우저 (홈 주소)": "Web browser (home page)", "웹 브라우저(Edge WebView2)를 시작할 수 없습니다.\n{0}": "Cannot start the web browser (Edge WebView2).\n{0}", "웹 브라우저의 시작 · 🏠 주소": "Start · 🏠 page of the web browser", + "웹 페이지 자동 번역": "Translate web pages automatically", + "웹 페이지를 번역하지 못했습니다: {0}": "Could not translate the web page: {0}", "위 폴더": "Parent folder", "위로": "Up", "음성": "Audio", @@ -573,6 +580,7 @@ "이 이동 버튼 지우기": "Delete this move button", "이 파일에 맞는 자막 우선 (영상 해시)": "Prefer subtitles that match this file (video hash)", "이 파일용": "For this file", + "이 페이지를 {0}(으)로 번역": "Translate this page to {0}", "이 폴더 선택": "Choose this folder", "이 폴더 전체": "This whole folder", "이 폴더에 새 폴더": "New folder in this folder", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index daac76f..4db221d 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -80,6 +80,7 @@ "JJ_MKVMaker 로 자막 만들기": "JJ_MKVMaker で字幕を作成", "JJ_MKVMaker 로 재생": "JJ_MKVMaker で再生", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_ブックマーク.html", + "JavaScript 사용": "JavaScript を使う", "MKV · 자막 (jj_mkv)": "MKV・字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV・字幕の保存先", "MKV 만들기": "MKV 作成", @@ -230,6 +231,8 @@ "길이 {0}": "長さ {0}", "끄기": "オフ", "끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "オフにすると前回の設定ですぐ開始: 原語 {0}、言語 {1}、{2}", + "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。", + "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。", "끌어다 놓은 항목에 동영상이 없습니다.": "ドロップした項目に動画がありません。", "끝": "終了", "끝 = 현재": "終了 = 現在", @@ -269,6 +272,7 @@ "다른 브라우저의 즐겨찾기 가져오기": "他のブラウザーのブックマークを取り込む", "다른 앱에서 동영상을 열 때": "他のアプリで動画を開いたとき", "다른 앱에서 열기: {0}": "他のアプリから開く: {0}", + "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "ほかの言語のページを表示言語 ({0}) に翻訳します。ページの文章は Google 翻訳に送られます。アドレスバー横の翻訳ボタンで原文 · 翻訳を切り替えられます。", "다른 언어로 번역 (AI · 이 {0}에서)": "他の言語に翻訳(AI・この{0}で)", "다른 이름으로 저장": "名前を付けて保存", "다른 이름으로 저장: {0} ({1})": "名前を付けて保存: {0}({1})", @@ -535,6 +539,7 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。", "우르두어": "ウルドゥー語", "우크라이나어": "ウクライナ語", + "원문 보기 (번역 끄기)": "原文を表示 (翻訳をオフ)", "원본 비율": "元の比率", "원본 유지 (재인코딩 없음)": "元のまま(再エンコードなし)", "원본 크기": "元のサイズ", @@ -548,6 +553,8 @@ "웹 브라우저 (홈 주소)": "ウェブブラウザー(ホームページ)", "웹 브라우저(Edge WebView2)를 시작할 수 없습니다.\n{0}": "ウェブブラウザー(Edge WebView2)を起動できません。\n{0}", "웹 브라우저의 시작 · 🏠 주소": "ウェブブラウザーの開始・🏠 ページ", + "웹 페이지 자동 번역": "ウェブページを自動翻訳", + "웹 페이지를 번역하지 못했습니다: {0}": "ウェブページを翻訳できませんでした: {0}", "위 폴더": "上のフォルダー", "위로": "上へ", "음성": "音声", @@ -573,6 +580,7 @@ "이 이동 버튼 지우기": "この移動ボタンを削除", "이 파일에 맞는 자막 우선 (영상 해시)": "このファイルに合う字幕を優先(動画ハッシュ)", "이 파일용": "このファイル用", + "이 페이지를 {0}(으)로 번역": "このページを{0}に翻訳", "이 폴더 선택": "このフォルダーを選択", "이 폴더 전체": "このフォルダー全体", "이 폴더에 새 폴더": "このフォルダーに新しいフォルダー", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 030f5b1..9c31b07 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -80,6 +80,7 @@ "JJ_MKVMaker 로 자막 만들기": "用 JJ_MKVMaker 生成字幕", "JJ_MKVMaker 로 재생": "用 JJ_MKVMaker 播放", "JJ_MKVMaker_즐겨찾기.html": "JJ_MKVMaker_书签.html", + "JavaScript 사용": "启用 JavaScript", "MKV · 자막 (jj_mkv)": "MKV · 字幕(jj_mkv)", "MKV · 자막 저장 위치": "MKV · 字幕保存位置", "MKV 만들기": "制作 MKV", @@ -230,6 +231,8 @@ "길이 {0}": "时长 {0}", "끄기": "关闭", "끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "关闭后将直接使用上次的设置开始: 原语言 {0},语言 {1},{2}", + "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。", + "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。", "끌어다 놓은 항목에 동영상이 없습니다.": "拖入的项目中没有视频。", "끝": "结束", "끝 = 현재": "结束 = 当前", @@ -269,6 +272,7 @@ "다른 브라우저의 즐겨찾기 가져오기": "导入其他浏览器的书签", "다른 앱에서 동영상을 열 때": "从其他应用打开视频时", "다른 앱에서 열기: {0}": "从其他应用打开: {0}", + "다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. 주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.": "将其他语言的页面翻译为界面语言 ({0})。页面文字会发送到 Google 翻译。可用地址栏旁的翻译按钮切换原文 · 译文。", "다른 언어로 번역 (AI · 이 {0}에서)": "翻译为其他语言(AI · 在此{0}上)", "다른 이름으로 저장": "另存为", "다른 이름으로 저장: {0} ({1})": "另存为: {0}({1})", @@ -535,6 +539,7 @@ "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。", "우르두어": "乌尔都语", "우크라이나어": "乌克兰语", + "원문 보기 (번역 끄기)": "显示原文 (关闭翻译)", "원본 비율": "原始比例", "원본 유지 (재인코딩 없음)": "保持原样(不重新编码)", "원본 크기": "原始尺寸", @@ -548,6 +553,8 @@ "웹 브라우저 (홈 주소)": "网页浏览器(主页)", "웹 브라우저(Edge WebView2)를 시작할 수 없습니다.\n{0}": "无法启动网页浏览器(Edge WebView2)。\n{0}", "웹 브라우저의 시작 · 🏠 주소": "网页浏览器的起始 · 🏠 页面", + "웹 페이지 자동 번역": "自动翻译网页", + "웹 페이지를 번역하지 못했습니다: {0}": "无法翻译网页: {0}", "위 폴더": "上级文件夹", "위로": "向上", "음성": "音频", @@ -573,6 +580,7 @@ "이 이동 버튼 지우기": "删除此移动按钮", "이 파일에 맞는 자막 우선 (영상 해시)": "优先匹配此文件的字幕(视频哈希)", "이 파일용": "适用于此文件", + "이 페이지를 {0}(으)로 번역": "将此页面翻译为{0}", "이 폴더 선택": "选择此文件夹", "이 폴더 전체": "整个文件夹", "이 폴더에 새 폴더": "在此文件夹中新建文件夹", diff --git a/integration_test/web_translate_test.dart b/integration_test/web_translate_test.dart new file mode 100644 index 0000000..d2cd6f1 --- /dev/null +++ b/integration_test/web_translate_test.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'package:path/path.dart' as p; + +/// 실제 웹뷰 (Windows: Edge WebView2 · Android: 시스템 WebView) + 실제 Google 번역: +/// 페이지 자동 번역 · 원문 보기 · JavaScript 끄기. +/// Android 는 JavaScript 를 끄면 앱이 페이지를 읽을 수 없어, 페이지 스크립트가 돌면 서버로 보내는 신호 (/ran) 로 확인한다. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('웹 페이지 번역 · JavaScript 끄기', (tester) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + var pageLoads = 0, scriptRuns = 0; + server.listen((req) async { + if (req.uri.path == '/ran') { + scriptRuns++; + req.response.statusCode = 204; + await req.response.close(); + return; + } + if (req.uri.path != '/') { + req.response.statusCode = 404; + await req.response.close(); + return; + } + pageLoads++; + req.response + ..headers.contentType = ContentType.html + ..write('JJ' + '

Hello world

' + '

Download the video

' + 'Hello code' + '

안녕하세요 여러분

' + '' + '
no script
' + '' + ''); + await req.response.close(); + }); + final url = 'http://127.0.0.1:${server.port}/'; + final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path; + + final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..webViewDataDir = dataDir + ..uiLanguage = 'ko' + ..webTranslate = true; + final bm = BookmarksController(p.join(dataDir, 'bm.json')); + await tester.pumpWidget(MaterialApp(home: BrowserPage(c: c, bookmarks: bm, initialUrl: url))); + + Future> dom() async { + final r = await tester.runAsync(() async => BrowserPage.debugNav?.evaluate( + 'JSON.stringify({h: (document.getElementById("h")||{}).textContent, p: (document.getElementById("p")||{}).textContent,' + ' c: (document.getElementById("c")||{}).textContent, k: (document.getElementById("k")||{}).textContent,' + ' i: (document.getElementById("i")||{getAttribute:function(){}}).getAttribute("placeholder"),' + ' js: (document.getElementById("js")||{}).textContent, late: (document.getElementById("late")||{}).textContent})')); + if (r == null) return {}; + final v = jsonDecode(r is String ? r : jsonEncode(r)); + // 없는 요소는 JSON 에서 빠진다 → 빈 글로 + final m = (v is String ? jsonDecode(v) : v) as Map; + return {for (final k in const ['h', 'p', 'c', 'k', 'i', 'js', 'late']) k: '${m[k] ?? ''}'}; + } + + final hangul = RegExp('[가-힣]'); + Future> waitFor(bool Function(Map) ok, String what) async { + var d = {}; + for (var i = 0; i < 300; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + d = await dom(); + if (d.isNotEmpty && ok(d)) break; + } + // ignore: avoid_print + print('RESULT $what: $d'); + return d; + } + + // 1. 자동 번역 (JavaScript 켬): 영어 글 · 안내 글은 한국어로, 코드 · 한국어 글은 그대로, 나중에 생긴 글도 + var d = await waitFor((d) => hangul.hasMatch(d['h']!) && hangul.hasMatch(d['late']!), '자동 번역'); + expect(d['h'], matches(hangul)); + expect(d['p'], matches(hangul)); + expect(d['p'], startsWith(' ')); // 앞뒤 빈칸은 그대로 + expect(d['i'], matches(hangul)); + expect(d['late'], matches(hangul)); + expect(d['c'], 'Hello code'); + expect(d['k'], '안녕하세요 여러분'); + expect(d['js'], isNot('no script')); // 페이지 스크립트가 돌았다 (그 글도 번역됨) + + // 2. 번역 버튼 → 원문 + await tester.tap(find.byTooltip('원문 보기 (번역 끄기)')); + d = await waitFor((d) => d['h'] == 'Hello world', '원문 보기'); + expect(d['h'], 'Hello world'); + expect(d['p'], ' Download the video '); + expect(d['i'], 'Search here'); + expect(d['late'], 'Good morning'); + expect(d['js'], 'script ran'); + + Future waitServer(bool Function() ok) async { + for (var i = 0; i < 300 && !ok(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + } + } + + // 3. JavaScript 끄기 → 페이지를 다시 읽고, 페이지 스크립트는 돌지 않는다 + expect(scriptRuns, greaterThan(0)); + final loads = pageLoads, runs = scriptRuns; + await c.updateSettings((s) => s.webJavaScript = false); + await waitServer(() => pageLoads > loads); + await tester.runAsync(() => Future.delayed(const Duration(seconds: 3))); + // ignore: avoid_print + print('RESULT JavaScript 끔: 다시 읽음 ${pageLoads - loads}번, 스크립트 실행 ${scriptRuns - runs}번'); + expect(pageLoads, greaterThan(loads)); + expect(scriptRuns, runs); // 다시 읽은 페이지의 스크립트가 돌지 않았다 + + if (Platform.isAndroid) { + // 4. Android 웹뷰는 JavaScript 를 끄면 앱이 넣는 스크립트도 막는다 → 번역 · 페이지 읽기가 안 됨 (설정 설명대로) + expect(await dom(), isEmpty); + await tester.tap(find.byTooltip('이 페이지를 한국어(으)로 번역')); + await tester.runAsync(() => Future.delayed(const Duration(seconds: 3))); + await tester.pump(); + // ignore: avoid_print + print('RESULT JavaScript 끈 채 번역 (Android): 페이지 읽기 ${await dom()} → 번역 안 됨 (예상대로)'); + } else { + d = await waitFor((d) => d['js'] == 'no script', 'JavaScript 끔 (페이지)'); + expect(d['js'], 'no script'); + expect(d['late'], ''); // setTimeout 도 돌지 않음 + + // 4. Edge 는 JavaScript 를 꺼도 번역 버튼으로 번역된다 (앱이 넣는 스크립트는 실행) + await tester.tap(find.byTooltip('이 페이지를 한국어(으)로 번역')); + d = await waitFor((d) => hangul.hasMatch(d['h']!), 'JavaScript 끈 채 번역'); + expect(d['h'], matches(hangul)); + expect(d['js'], matches(hangul)); // "no script" 도 번역 + } + + // 5. 다시 켜기 → 다시 읽고 페이지 스크립트가 돈다, 번역도 다시 + final runs2 = scriptRuns; + await c.updateSettings((s) => s.webJavaScript = true); + await waitServer(() => scriptRuns > runs2); + expect(scriptRuns, greaterThan(runs2)); + d = await waitFor((d) => d['late']!.isNotEmpty && hangul.hasMatch(d['h']!), 'JavaScript 다시 켬'); + expect(d['late'], isNotEmpty); // setTimeout 이 다시 돈다 + expect(d['h'], matches(hangul)); + + await tester.pumpWidget(const SizedBox()); + await server.close(force: true); + }, timeout: const Timeout(Duration(minutes: 3))); +} diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 5c4e530..75a3bdc 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -127,6 +127,12 @@ class AppSettings { /// "외부 브라우저로 열기" 에 쓸 브라우저: 'system' · 'chrome' · 'firefox' · 'edge' · 'whale' String externalBrowser = 'system'; + /// 앱 안 브라우저: 다른 언어로 된 웹 페이지를 화면 언어 ([uiLanguage]) 로 자동 번역 (Google 번역) + bool webTranslate = false; + + /// 앱 안 브라우저: 페이지의 JavaScript 실행 (끄면 스크립트 없이 글 · 그림만) + bool webJavaScript = true; + /// 앱 안 브라우저의 데이터 폴더 (로그인 · 쿠키). 실행 중에 정해지며 저장하지 않음 String webViewDataDir = ''; @@ -188,6 +194,8 @@ class AppSettings { 'homeUrl': homeUrl, 'browserEngine': browserEngine, 'externalBrowser': externalBrowser, + 'webTranslate': webTranslate, + 'webJavaScript': webJavaScript, 'autoCheckUpdates': autoCheckUpdates, 'lastUpdateCheck': lastUpdateCheck, 'skippedVersion': skippedVersion, @@ -259,6 +267,8 @@ class AppSettings { ..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/' ..browserEngine = j['browserEngine'] as String? ?? 'edge' ..externalBrowser = j['externalBrowser'] as String? ?? 'system' + ..webTranslate = j['webTranslate'] as bool? ?? false + ..webJavaScript = j['webJavaScript'] as bool? ?? true ..autoCheckUpdates = j['autoCheckUpdates'] as bool? ?? true ..lastUpdateCheck = j['lastUpdateCheck'] as String? ?? '' ..skippedVersion = j['skippedVersion'] as String? ?? '' diff --git a/lib/core/web_translate.dart b/lib/core/web_translate.dart new file mode 100644 index 0000000..3df17e6 --- /dev/null +++ b/lib/core/web_translate.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// 앱 안 웹 브라우저의 페이지 번역 (환경 설정 > 웹 브라우저 > 웹 페이지 자동 번역). +/// +/// 엔진 (Edge · Chrome · Android) 과 상관없이 같은 방식으로 동작한다: +/// 1. [webTranslateCollectScript] 로 페이지의 글자 (텍스트 노드 · placeholder · title) 를 모은다. +/// 한 번 번역한 글자는 다시 모으지 않고, 페이지가 바뀌면 (YouTube 처럼 주소만 바뀌는 사이트 포함) 새 글자만 모은다. +/// 2. [WebPageTranslator] 가 Google 번역으로 화면 언어로 번역한다 (원문 언어는 자동 감지). +/// 3. [webTranslateApplyScript] 로 페이지에 넣는다. [webTranslateRestoreScript] 는 원문으로 되돌린다. +/// +/// 스크립트는 앱이 페이지에 넣는 것이라 페이지의 JavaScript 를 꺼도 Edge 에서는 동작한다. + +/// 화면 언어 코드 → Google 번역 언어 코드 +String googleLang(String uiLanguage) => switch (uiLanguage) { + 'zh-Hans' => 'zh-CN', + 'zh-Hant' => 'zh-TW', + 'he' => 'iw', + _ => uiLanguage, + }; + +/// 두 언어 코드가 같은 언어인지 (ko-KR = ko, zh-CN ≠ zh-TW, he = iw) +bool sameLanguage(String a, String b) { + String norm(String x) { + x = x.trim().toLowerCase().replaceAll('_', '-'); + if (x == 'iw' || x.startsWith('iw-')) return 'he'; + if (x.startsWith('zh')) return x.contains('tw') || x.contains('hk') || x.contains('hant') ? 'zh-tw' : 'zh-cn'; + final i = x.indexOf('-'); + return i < 0 ? x : x.substring(0, i); + } + + return a.isNotEmpty && b.isNotEmpty && norm(a) == norm(b); +} + +/// 페이지의 아직 번역하지 않은 글자를 모으는 스크립트. 결과는 JSON 문자열: +/// `{"items": [[번호, 글자], ...], "more": 남은 글자가 있는지, "same": 이미 화면 언어인 페이지}`. +/// +/// [force] 가 아니면 페이지가 이미 화면 언어 (`` 또는 앞서 번역해 보니 대부분 그 언어) 일 때 모으지 않는다. +String webTranslateCollectScript(String target, {bool force = false, int max = 300}) => + '(function(tl,force,max){$_init' + r''' + if (T.target && T.target !== tl) T.restore(); + T.target = tl; + if (force && !T.forced) { T.forced = true; T.same = false; T.dirty = true; } + if (!force && !T.forced) { + var lang = (document.documentElement.getAttribute('lang') || '').toLowerCase(); + if (lang && T.base(lang) === T.base(tl)) T.same = true; + } + if (T.same) return JSON.stringify({items: [], same: true}); + if (!T.dirty && T.obs) return JSON.stringify({items: []}); + T.dirty = false; + if (T.refs.length > 4000) T.refs = T.refs.filter(function(r){ return (r.n || r.e).isConnected; }); + var items = [], more = false, letter = /\p{L}/u; + function add(r, text) { + if (items.length >= max) { more = true; return false; } + r.i = T.refs.length; T.refs.push(r); + items.push([r.i, text]); + return true; + } + var root = document.body || document.documentElement; + // 건너뛸 요소 (스크립트 · 코드 · 입력칸 · translate="no" …) 아래의 글은 거른다. 요소마다 한 번만 판단해 기억. + // TreeWalker 의 거르기 함수 (acceptNode) 는 쓰지 않는다: 페이지 JavaScript 를 끄면 Edge 가 그 함수를 부르지 못한다. + var memo = new Map(); + function skipped(el) { + if (!el || el.nodeType !== 1) return false; + var s = memo.get(el); + if (s === undefined) { s = T.skipOne(el) || skipped(el.parentNode); memo.set(el, s); } + return s; + } + var w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT), n; + while ((n = w.nextNode())) { + var v = n.nodeValue; + if (n.__jjT === v || !letter.test(v) || skipped(n.parentNode)) continue; + if (!add({n: n, o: v}, v.replace(/\s+/g, ' ').trim())) break; + } + if (!more) { + var els = root.querySelectorAll('[placeholder],[title]'); + for (var i = 0; i < els.length && !more; i++) { + var e = els[i]; + // 입력칸 자체는 글자만 건너뛰고 안내 글 (placeholder) · 풍선 도움말 (title) 은 번역 + if (skipped(e.parentNode) || e.getAttribute('translate') === 'no' || e.classList.contains('notranslate')) continue; + ['placeholder', 'title'].forEach(function(a){ + var v = e.getAttribute(a); + if (v && e['__jjT_' + a] !== v && letter.test(v)) add({e: e, a: a, o: v}, v.trim()); + }); + } + } + if (more) T.dirty = true; + return JSON.stringify({items: items, more: more}); +})''' + '(${jsonEncode(target)},$force,$max)'; + +/// 번역한 글자를 페이지에 넣는 스크립트. [texts] 는 번호 → 번역 (null = 이미 화면 언어라 그대로 둠). +/// [same]: 이 페이지는 화면 언어로 보고 (직접 번역을 누르지 않았으면) 더 모으지 않는다. +String webTranslateApplyScript(Map texts, {bool same = false}) => + '(function(m,same){$_init' + r''' + var c = 0; + for (var k in m) { + var r = T.refs[k], t = m[k]; + if (!r) continue; + if (r.a) { + if (r.e.getAttribute(r.a) !== r.o) continue; + if (t !== null) { r.e.setAttribute(r.a, t); r.t = t; c++; } + r.e['__jjT_' + r.a] = t === null ? r.o : t; + } else { + if (r.n.nodeValue !== r.o) continue; + if (t !== null) { + r.t = r.o.match(/^\s*/)[0] + t + r.o.match(/\s*$/)[0]; + r.n.nodeValue = r.t; + c++; + } + r.n.__jjT = t === null ? r.o : r.t; + } + } + if (same && !T.forced) T.same = true; + if (T.obs) T.obs.takeRecords(); + return c; +})''' + '(${jsonEncode({for (final e in texts.entries) '${e.key}': e.value})},$same)'; + +/// 번역한 글자를 원문으로 되돌리는 스크립트 (번역 끄기) +const webTranslateRestoreScript = '(function(){var T = window.__jjTr; if (!T) return 0; return T.restore();})()'; + +/// 페이지에 번역 도구를 한 번만 만든다 (T). 페이지가 바뀌는 것은 MutationObserver 로 알아챈다. +const _init = r''' + var T = window.__jjTr; + if (!T) { + T = window.__jjTr = {refs: [], target: '', same: false, forced: false, dirty: true, obs: null}; + T.base = function(x){ x = x.toLowerCase(); if (x.indexOf('zh') === 0) return /tw|hk|hant/.test(x) ? 'zh-tw' : 'zh-cn'; if (x === 'iw') return 'he'; return x.split('-')[0]; }; + var SKIP = {SCRIPT:1, STYLE:1, NOSCRIPT:1, TEXTAREA:1, CODE:1, PRE:1, KBD:1, SAMP:1, VAR:1, TEMPLATE:1, INPUT:1, SELECT:1, OPTION:1}; + T.skipOne = function(el){ + return !!(SKIP[el.nodeName.toUpperCase()] || el.isContentEditable || el.getAttribute('translate') === 'no' || + (el.classList && el.classList.contains('notranslate'))); + }; + T.restore = function(){ + var c = 0; + for (var i = T.refs.length - 1; i >= 0; i--) { + var r = T.refs[i]; + if (r.a) { + if (r.t !== undefined && r.e.getAttribute(r.a) === r.t) { r.e.setAttribute(r.a, r.o); c++; } + r.e['__jjT_' + r.a] = undefined; + } else { + if (r.t !== undefined && r.n.nodeValue === r.t) { r.n.nodeValue = r.o; c++; } + r.n.__jjT = undefined; + } + } + T.refs = []; T.target = ''; T.same = false; T.forced = false; T.dirty = true; + if (T.obs) T.obs.takeRecords(); + return c; + }; + try { + T.obs = new MutationObserver(function(){ T.dirty = true; }); + T.obs.observe(document.documentElement, {childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['placeholder', 'title']}); + } catch (e) { T.obs = null; } + } +'''; + +/// [webTranslateCollectScript] 의 결과 +class CollectedText { + final List ids; + final List texts; + final bool more; + final bool same; + const CollectedText(this.ids, this.texts, {this.more = false, this.same = false}); + + /// 웹뷰가 돌려준 값 (JSON 문자열, 또는 엔진에 따라 이미 풀린 Map). 알 수 없으면 null. + static CollectedText? parse(Object? raw) { + try { + var v = raw; + if (v is String) v = jsonDecode(v); + // 엔진에 따라 JSON 문자열을 한 번 더 감싸서 준다 + if (v is String) v = jsonDecode(v); + if (v is! Map) return null; + final ids = [], texts = []; + for (final it in (v['items'] as List?) ?? const []) { + if (it is List && it.length == 2) { + ids.add((it[0] as num).toInt()); + texts.add('${it[1]}'); + } + } + return CollectedText(ids, texts, more: v['more'] == true, same: v['same'] == true); + } catch (_) { + return null; + } + } +} + +/// Google 번역 요청 (주소, 본문) → 응답 본문. 테스트에서 바꿔 끼운다. +typedef TranslatePost = Future Function(Uri url, String body); + +/// 여러 글자를 Google 번역으로 한 번에 번역한다 (원문 언어 자동 감지). 같은 글자는 다시 묻지 않는다. +class WebPageTranslator { + final TranslatePost _post; + WebPageTranslator({TranslatePost? post}) : _post = post ?? _httpPost; + + /// (대상 언어, 원문) → 번역. null = 이미 대상 언어 + final _cache = <(String, String), String?>{}; + static const _cacheMax = 20000; + + /// 한 번에 보내는 양 + static const _chunkChars = 3000, _chunkItems = 100; + + /// [texts] 를 [target] (Google 언어 코드) 으로. 결과의 null 은 이미 그 언어라 번역하지 않은 것. + Future> translate(List texts, String target) async { + final out = List.filled(texts.length, null); + final todo = []; + final asked = {}; + for (var i = 0; i < texts.length; i++) { + final key = (target, texts[i]); + if (_cache.containsKey(key)) { + out[i] = _cache[key]; + } else if (texts[i].trim().isNotEmpty && asked.add(texts[i])) { + todo.add(i); + } + } + // 같은 양으로 나눠 동시에 3개까지 + final chunks = >[]; + var cur = []; + var size = 0; + for (final i in todo) { + final t = texts[i]; + if (cur.isNotEmpty && (cur.length >= _chunkItems || size + t.length > _chunkChars)) { + chunks.add(cur); + cur = []; + size = 0; + } + cur.add(t); + size += t.length; + } + if (cur.isNotEmpty) chunks.add(cur); + for (var i = 0; i < chunks.length; i += 3) { + await Future.wait([for (final c in chunks.skip(i).take(3)) _ask(c, target)]); + } + if (_cache.length > _cacheMax) _cache.clear(); + for (var i = 0; i < texts.length; i++) { + final key = (target, texts[i]); + if (_cache.containsKey(key)) out[i] = _cache[key]; + } + return out; + } + + Future _ask(List texts, String target) async { + final url = Uri.https('translate.googleapis.com', '/translate_a/t', + {'client': 'gtx', 'sl': 'auto', 'tl': target, 'format': 'text'}); + final body = texts.map((t) => 'q=${Uri.encodeQueryComponent(t)}').join('&'); + final res = parseGoogleResponse(await _post(url, body), texts.length); + for (var i = 0; i < texts.length; i++) { + final (text, lang) = res[i]; + _cache[(target, texts[i])] = sameLanguage(lang, target) || text == texts[i] ? null : text; + } + } + + /// 응답: `[["번역","en"], ...]` (원문 언어 자동 감지) 또는 `["번역", ...]` + static List<(String, String)> parseGoogleResponse(String body, int count) { + final v = jsonDecode(body); + if (v is! List || v.length != count) { + throw FormatException('번역 응답의 개수가 다릅니다 (${v is List ? v.length : '?'} / $count)'); + } + return [ + for (final x in v) + x is List ? ('${x.isEmpty ? '' : x[0]}', x.length > 1 ? '${x[1]}' : '') : ('$x', ''), + ]; + } + + static Future _httpPost(Uri url, String body) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final req = await client.postUrl(url); + req.headers.contentType = ContentType('application', 'x-www-form-urlencoded', charset: 'utf-8'); + req.add(utf8.encode(body)); + final res = await req.close().timeout(const Duration(seconds: 20)); + final text = await res.transform(utf8.decoder).join().timeout(const Duration(seconds: 20)); + if (res.statusCode != 200) throw HttpException('번역 서버 응답 ${res.statusCode}', uri: url); + return text; + } finally { + client.close(force: true); + } + } +} diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index bc24c8e..64365b2 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -13,6 +13,8 @@ import '../app/download_manager.dart'; import '../core/bookmarks.dart'; import '../core/download_detect.dart' show CookieRecord, toNetscapeCookies; import '../core/web_address.dart'; +import '../core/web_translate.dart'; +import '../app/i18n_controller.dart' show I18nController; import '../core/youtube_ads.dart'; import '../platform/windows/cef_runtime.dart'; import '../platform/windows/com_guard.dart'; @@ -40,6 +42,12 @@ abstract class WebNav { /// 페이지에서 스크립트 실행 (YouTube 광고 건너뛰기 등) Future runScript(String js); + + /// 페이지에서 스크립트를 실행하고 결과를 받는다 (페이지 번역). 실패하면 null. + Future evaluate(String js); + + /// 페이지의 JavaScript 켜기 · 끄기 (바뀌면 지금 페이지를 다시 읽는다) + Future setJavaScript(bool on); } /// 페이지의 모든 동영상 · 소리를 멈추는 스크립트 @@ -97,6 +105,10 @@ class BrowserPage extends StatefulWidget { /// 마지막으로 보던 주소: MKV 화면으로 갔다가 브라우저를 다시 열면 홈이 아니라 이 주소로 static String? _lastUrl; + /// 마지막으로 붙은 웹뷰 (통합 테스트에서 페이지 내용을 읽는다) + @visibleForTesting + static WebNav? debugNav; + /// 앱 안 웹뷰를 화면이 닫혀도 살려 둔다 (다시 열면 보던 페이지 · 재생 위치 · 뒤로 가기 기록 그대로) static final _keepAlive = InAppWebViewKeepAlive(); static bool _keepAliveUsed = false; @@ -138,12 +150,18 @@ class _BrowserPageState extends State with RouteAware { onUrl: (u) { if (!mounted || u.isEmpty) return; BrowserPage._lastUrl = u; + if (u != _url) _trFails = 0; // 다른 페이지: 번역을 다시 시도 setState(() => _url = u); if (!_addressFocus.hasFocus) _address.text = u; _applyAdSettings(); + _translateNow(); }, onTitle: (t) => mounted ? setState(() => _title = t) : null, - onProgress: (p) => mounted ? setState(() => _progress = p) : null, + onProgress: (p) { + if (!mounted) return; + setState(() => _progress = p); + if (p >= 1) _translateNow(); // 다 읽었다: 바로 번역 (2초를 기다리지 않고) + }, onVideo: (v) => mounted ? setState(() => _hasVideo = v) : null, onHistory: (b, f) { if (!mounted) return; @@ -152,15 +170,113 @@ class _BrowserPageState extends State with RouteAware { _canFwd = f; }); }, - attach: (n) => _nav = n, + attach: (n) => _nav = BrowserPage.debugNav = n, ); BookmarksController get bm => widget.bookmarks; + // ───────── 페이지 번역 (환경 설정 > 웹 브라우저 > 웹 페이지 자동 번역) ───────── + + static final _translator = WebPageTranslator(); + + /// 이 화면에서 번역 중인지. 처음엔 설정을 따르고, 도구 막대의 [번역] 버튼으로 바꾼다. + late bool _trOn = widget.c.settings.webTranslate; + + /// 버튼으로 직접 켰다: 이미 화면 언어로 보이는 페이지도 번역한다 + bool _trForce = false; + bool _trBusy = false; + int _trFails = 0; + Timer? _trTimer; + + /// 브라우저가 다른 화면에 가려져 있지 않은지 + bool _visible = true; + + /// 바뀌었는지 비교할 설정 (initState 에서 정한다. late 초기화는 처음 읽을 때 - 이미 바뀐 뒤 - 정해져 안 됨) + late bool _lastTranslate, _lastJs; + late String _lastLang; + + void _onSettings() { + if (!mounted) return; + final s = widget.c.settings; + if (s.webJavaScript != _lastJs) { + _lastJs = s.webJavaScript; + _nav?.setJavaScript(_lastJs); + } + if (s.webTranslate != _lastTranslate) { + _lastTranslate = s.webTranslate; + _setTranslate(_lastTranslate, force: false); + } else if (s.uiLanguage != _lastLang && _trOn) { + _translateNow(); // 다른 언어로 다시 번역 (스크립트가 앞의 번역을 되돌린 뒤 번역) + } + _lastLang = s.uiLanguage; + } + + void _setTranslate(bool on, {required bool force}) { + setState(() { + _trOn = on; + _trForce = on && force; + _trFails = 0; + }); + if (on) { + _translateNow(); + } else { + _trTimer?.cancel(); + _trTimer = null; + _nav?.evaluate(webTranslateRestoreScript); + } + } + + /// 지금 번역하고, 켜져 있는 동안 2초마다 새로 나온 글자를 번역한다 (YouTube 처럼 글이 계속 바뀌는 페이지) + void _translateNow() { + if (!_trOn) return; + _trTimer ??= Timer.periodic(const Duration(seconds: 2), (_) => _translateTick()); + _translateTick(); + } + + Future _translateTick() async { + final nav = _nav; + if (!_trOn || _trBusy || nav == null || !_visible || !_url.startsWith('http') || _trFails >= 3) return; + _trBusy = true; + try { + final target = googleLang(widget.c.settings.uiLanguage); + // 한 번에 300개씩, 페이지가 크면 몇 번 더 + for (var round = 0; round < 10 && mounted && _trOn; round++) { + final got = CollectedText.parse(await nav.evaluate(webTranslateCollectScript(target, force: _trForce))); + if (got == null || got.texts.isEmpty) break; + final out = await _translator.translate(got.texts, target); + // 대부분 이미 화면 언어면 (직접 켠 게 아니면) 이 페이지는 더 번역하지 않는다 + final chars = got.texts.fold(0, (a, t) => a + t.length); + var same = 0; + for (var i = 0; i < out.length; i++) { + if (out[i] == null) same += got.texts[i].length; + } + if (!mounted || !_trOn) break; + await nav.evaluate(webTranslateApplyScript({for (var i = 0; i < got.ids.length; i++) got.ids[i]: out[i]}, + same: round == 0 && chars > 0 && same >= chars * 0.8)); + if (!got.more) break; + } + _trFails = 0; + } catch (e) { + // 번역 서버에 닿지 않음 등: 세 번 실패하면 다른 페이지로 갈 때까지 쉰다 + if (++_trFails == 3 && mounted) { + ScaffoldMessenger.maybeOf(context) + ?.showSnackBar(SnackBar(content: Text(trf('웹 페이지를 번역하지 못했습니다: {0}', [e])))); + } + } finally { + _trBusy = false; + } + } + @override void initState() { super.initState(); BrowserPage._open++; + final s = widget.c.settings; + _lastTranslate = s.webTranslate; + _lastJs = s.webJavaScript; + _lastLang = s.uiLanguage; + widget.c.addListener(_onSettings); + if (_trOn) _translateNow(); } @override @@ -173,12 +289,17 @@ class _BrowserPageState extends State with RouteAware { /// 다운로드 목록 · 플레이어 · 설정 등이 위에 올라왔다: 보던 동영상을 멈춘다 (가려진 채 소리만 나지 않게) @override void didPushNext() { + _visible = false; _nav?.pauseMedia(); } /// 설정 화면 등에서 돌아왔다: 바뀐 광고 설정을 지금 페이지에 적용 @override - void didPopNext() => _applyAdSettings(); + void didPopNext() { + _visible = true; + _applyAdSettings(); + _translateNow(); + } /// YouTube 페이지면 광고 건너뛰기 · 숨기기 스크립트를 넣는다 (환경 설정 > 시작 · 웹 브라우저) void _applyAdSettings() { @@ -197,6 +318,8 @@ class _BrowserPageState extends State with RouteAware { void dispose() { browserRouteObserver.unsubscribe(this); BrowserPage._open--; + widget.c.removeListener(_onSettings); + _trTimer?.cancel(); _address.dispose(); _addressFocus.dispose(); super.dispose(); @@ -337,8 +460,18 @@ class _BrowserPageState extends State with RouteAware { child: widget.viewBuilder?.call(_host, _url) ?? // 환경 설정에서 Chrome 을 고르고 엔진이 이번 실행에 준비되어 있으면 Chrome, 아니면 Edge (widget.c.settings.browserEngine == 'chrome' && CefRuntime.readyThisRun - ? _CefView(host: _host, initialUrl: _url, dataDir: widget.c.settings.webViewDataDir) - : _EdgeView(host: _host, initialUrl: _url, dataDir: widget.c.settings.webViewDataDir)), + // Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다 + ? _CefView( + key: ValueKey(widget.c.settings.webJavaScript), + host: _host, + initialUrl: _url, + dataDir: widget.c.settings.webViewDataDir, + javaScript: widget.c.settings.webJavaScript) + : _EdgeView( + host: _host, + initialUrl: _url, + dataDir: widget.c.settings.webViewDataDir, + javaScript: widget.c.settings.webJavaScript)), ), ]), ), @@ -413,7 +546,15 @@ class _BrowserPageState extends State with RouteAware { onSubmitted: _go, ), ), - const SizedBox(width: 8), + const SizedBox(width: 4), + btn( + Icons.translate, + _trOn + ? tr('원문 보기 (번역 끄기)') + : trf('이 페이지를 {0}(으)로 번역', [I18nController.nativeName(widget.c.settings.uiLanguage)]), + () => _setTranslate(!_trOn, force: true), + color: _trOn ? JjColors.accent : null), + const SizedBox(width: 4), FilledButton.icon( onPressed: _canDownload ? _download : null, icon: const Icon(Icons.download, size: 18), @@ -463,11 +604,19 @@ class _SplitHandle extends StatelessWidget { // ───────── 앱 안 Edge 웹뷰 ───────── +/// Edge · Android 웹뷰 설정 (처음 만들 때와 JavaScript 켜기 · 끄기에 같이 쓴다) +InAppWebViewSettings _webSettings({required bool javaScript}) => InAppWebViewSettings( + javaScriptEnabled: javaScript, + mediaPlaybackRequiresUserGesture: false, + supportMultipleWindows: false, + ); + class _EdgeView extends StatefulWidget { final BrowserHost host; final String initialUrl; final String dataDir; - const _EdgeView({required this.host, required this.initialUrl, required this.dataDir}); + final bool javaScript; + const _EdgeView({required this.host, required this.initialUrl, required this.dataDir, required this.javaScript}); @override State<_EdgeView> createState() => _EdgeViewState(); @@ -543,13 +692,12 @@ class _EdgeViewState extends State<_EdgeView> { keepAlive: _keepAlive, webViewEnvironment: snap.data, initialUrlRequest: URLRequest(url: WebUri(normalizeAddress(widget.initialUrl))), - initialSettings: InAppWebViewSettings( - javaScriptEnabled: true, - mediaPlaybackRequiresUserGesture: false, - supportMultipleWindows: false, - ), + initialSettings: _webSettings(javaScript: widget.javaScript), onWebViewCreated: (c) async { - h.attach(_InAppNav(c, snap.data, widget.dataDir)); + final nav = _InAppNav(c, snap.data, widget.dataDir); + h.attach(nav); + // 살려 둔 웹뷰는 처음 설정 그대로이므로 지금 설정에 맞춘다 + await nav.setJavaScript(widget.javaScript); // 살려 둔 웹뷰를 다시 붙였으면 보던 주소 · 제목 · 뒤로 가기 상태를 다시 알린다 final u = await c.getUrl(); if (u != null) h.onUrl(u.toString()); @@ -606,6 +754,25 @@ class _InAppNav implements WebNav { } catch (_) {} } + @override + Future evaluate(String js) async { + try { + return await c.evaluateJavascript(source: js); + } catch (_) { + return null; + } + } + + /// Edge 는 앱이 넣는 스크립트 (번역 · 동영상 찾기) 는 JavaScript 를 꺼도 실행된다. Android 는 꺼지면 안 된다. + @override + Future setJavaScript(bool on) async { + try { + if ((await c.getSettings())?.javaScriptEnabled == on) return; + await c.setSettings(settings: _webSettings(javaScript: on)); + await c.reload(); + } catch (_) {} + } + static const _cookieSites = [ 'https://www.youtube.com/', 'https://m.youtube.com/', @@ -662,7 +829,9 @@ class _CefView extends StatefulWidget { final BrowserHost host; final String initialUrl; final String dataDir; - const _CefView({required this.host, required this.initialUrl, required this.dataDir}); + final bool javaScript; + const _CefView( + {super.key, required this.host, required this.initialUrl, required this.dataDir, required this.javaScript}); @override State<_CefView> createState() => _CefViewState(); @@ -713,7 +882,7 @@ class _CefViewState extends State<_CefView> { } }, )); - await _c.initialize(normalizeAddress(widget.initialUrl)); + await _c.initialize(normalizeAddress(widget.initialUrl), javaScript: widget.javaScript); h.attach(_CefNav(_c, widget.dataDir)); h.onHistory(true, true); // Chrome 엔진은 뒤로 · 앞으로 가능 여부를 알려 주지 않는다 if (mounted) setState(() {}); @@ -770,6 +939,20 @@ class _CefNav implements WebNav { } catch (_) {} } + /// JavaScript 를 끈 Chrome 엔진에서는 결과를 받지 못한다 (null) + @override + Future evaluate(String js) async { + try { + return await c.evaluateJavascript(js).timeout(const Duration(seconds: 10)); + } catch (_) { + return null; + } + } + + /// Chrome 엔진은 만든 뒤에 바꿀 수 없다: 화면이 JavaScript 설정을 열쇠로 웹뷰를 새로 만든다 ([_CefView]) + @override + Future setJavaScript(bool on) async {} + /// YouTube · Google 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다). /// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다. @override diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 4ae5364..b1c8213 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../app/app_controller.dart'; +import '../app/i18n_controller.dart' show I18nController; import '../core/download_detect.dart'; import '../core/playlist.dart'; import '../core/subtitle_detector.dart'; @@ -476,6 +477,25 @@ class _SettingsPageState extends State { title: Text(tr('YouTube 광고 배너 숨기기')), subtitle: Text(tr('목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다')), ), + SwitchListTile( + value: s.webTranslate, + onChanged: (v) => c.updateSettings((x) => x.webTranslate = v), + title: Text(tr('웹 페이지 자동 번역')), + subtitle: Text(trf( + '다른 언어로 된 페이지를 화면 언어 ({0}) 로 번역합니다. 페이지의 글을 Google 번역으로 보냅니다. ' + '주소창 옆 번역 버튼으로 원문 · 번역을 바꿀 수 있습니다.', + [I18nController.nativeName(s.uiLanguage)])), + ), + SwitchListTile( + value: s.webJavaScript, + onChanged: (v) => c.updateSettings((x) => x.webJavaScript = v), + title: Text(tr('JavaScript 사용')), + subtitle: Text(desk + ? tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). ' + 'Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.') + : tr('끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). ' + '끄면 페이지 번역 · 동영상 찾기도 멈춥니다.')), + ), if (desk) ...[ ListTile( title: Text(tr('브라우저 엔진 (앱 안)')), diff --git a/packages/webview_cef/JJ_PATCH.md b/packages/webview_cef/JJ_PATCH.md index ae0297e..038e9a7 100644 --- a/packages/webview_cef/JJ_PATCH.md +++ b/packages/webview_cef/JJ_PATCH.md @@ -21,3 +21,7 @@ CEF 실행 파일은 앱이 공식 배포처 (https://cef-builds.spotifycdn.com) 4. `windows/webview_cef_plugin.cpp`: CEF 실행 파일이 없으면 화면 갱신 스레드 (vsync) 와 IME 가로채기를 켜지 않는다. CEF 가 있어도 IME 는 **Chrome 화면 안의 입력칸에 포커스가 있을 때만** 가로챈다. 원본은 늘 IME 조합 창을 막아서, 앱의 다른 입력칸 (주소창 · 검색 등) 의 한글 입력에 영향을 줄 수 있었다. + +5. `common/webview_plugin.cc` · `common/webview_handler.cc` · `lib/src/webview.dart`: JavaScript 끄기. + `create` 가 주소 대신 `{url, javascript: false}` 를 받으면 그 브라우저의 `CefBrowserSettings.javascript` 를 `STATE_DISABLED` 로 만든다 + (Dart: `WebViewController.initialize(url, javaScript: false)`). 이미 만든 브라우저는 바꿀 수 없어 앱이 새로 만든다. diff --git a/packages/webview_cef/common/webview_handler.cc b/packages/webview_cef/common/webview_handler.cc index b0a2ba0..335d89d 100644 --- a/packages/webview_cef/common/webview_handler.cc +++ b/packages/webview_cef/common/webview_handler.cc @@ -255,11 +255,11 @@ void WebviewHandler::closeBrowser(int browserId) } } -void WebviewHandler::createBrowser(std::string url, std::function callback) +void WebviewHandler::createBrowser(std::string url, bool javascript, std::function callback) { #ifndef OS_MAC if(!CefCurrentlyOn(TID_UI)) { - CefPostTask(TID_UI, base::BindOnce(&WebviewHandler::createBrowser, this, url, callback)); + CefPostTask(TID_UI, base::BindOnce(&WebviewHandler::createBrowser, this, url, javascript, callback)); return; } #endif @@ -267,6 +267,7 @@ void WebviewHandler::createBrowser(std::string url, std::function cal // Capped at 60 by CEF; ignored entirely when external begin frame drives the // frames (GPU path below). Kept for the software fallback path. browser_settings.windowless_frame_rate = 60; + if (!javascript) browser_settings.javascript = STATE_DISABLED; CefWindowInfo window_info; window_info.SetAsWindowless(0); #ifdef WEBVIEW_CEF_GPU_TEXTURE diff --git a/packages/webview_cef/common/webview_handler.h b/packages/webview_cef/common/webview_handler.h index 8e7ec62..7918481 100644 --- a/packages/webview_cef/common/webview_handler.h +++ b/packages/webview_cef/common/webview_handler.h @@ -153,7 +153,8 @@ public: void CloseAllBrowsers(bool force_close); void closeBrowser(int browserId); - void createBrowser(std::string url, std::function callback); + // javascript: false 면 그 브라우저에서 페이지 스크립트를 실행하지 않는다 (JJ_PATCH.md 5) + void createBrowser(std::string url, bool javascript, std::function callback); // Drives one external BeginFrame for every live browser (GPU path only). // Marshals to the CEF UI thread; safe to call from any thread. diff --git a/packages/webview_cef/common/webview_plugin.cc b/packages/webview_cef/common/webview_plugin.cc index aaef456..747bc3a 100644 --- a/packages/webview_cef/common/webview_plugin.cc +++ b/packages/webview_cef/common/webview_plugin.cc @@ -298,8 +298,18 @@ namespace webview_cef { result(1, nullptr); } else if (name.compare("create") == 0) { - std::string url = webview_value_get_string(values); - m_handler->createBrowser(url, [=, this](int browserId) { + // 주소만 오거나, {url, javascript} 묶음 (JJ_PATCH.md 5) + std::string url; + bool javascript = true; + if (webview_value_get_type(values) == Webview_Value_Type_Map) { + WValue* u = webview_value_get_by_string(values, "url"); + if (u != nullptr && webview_value_get_type(u) == Webview_Value_Type_String) url = webview_value_get_string(u); + WValue* js = webview_value_get_by_string(values, "javascript"); + if (js != nullptr && webview_value_get_type(js) == Webview_Value_Type_Bool) javascript = webview_value_get_bool(js); + } else { + url = webview_value_get_string(values); + } + m_handler->createBrowser(url, javascript, [=, this](int browserId) { std::shared_ptr renderer = m_createTextureFunc(); m_renderers[browserId] = renderer; WValue *response = webview_value_new_list(); diff --git a/packages/webview_cef/lib/src/webview.dart b/packages/webview_cef/lib/src/webview.dart index 8c54255..ab30865 100644 --- a/packages/webview_cef/lib/src/webview.dart +++ b/packages/webview_cef/lib/src/webview.dart @@ -74,14 +74,17 @@ class WebViewController extends ValueNotifier { _onImeCompositionRangeChangedMessage; /// Initializes the underlying platform view. - Future initialize(String url) async { + /// + /// [javaScript] false: 이 브라우저에서 페이지 스크립트를 실행하지 않는다 (JJ_PATCH.md 5). + Future initialize(String url, {bool javaScript = true}) async { if (_isDisposed) { return Future.value(); } _creatingCompleter = Completer(); try { await WebviewManager().ready; - List args = await _pluginChannel.invokeMethod('create', url); + List args = await _pluginChannel.invokeMethod( + 'create', javaScript ? url : {'url': url, 'javascript': false}); _browserId = args[0] as int; _textureId = args[1] as int; WebviewManager().onBrowserCreated(_index, _browserId); diff --git a/test/browser_test.dart b/test/browser_test.dart index fb10a67..343a050 100644 --- a/test/browser_test.dart +++ b/test/browser_test.dart @@ -61,6 +61,15 @@ class _Nav implements WebNav { var exported = 0; @override Future exportCookies() async => exported++; + @override + Future evaluate(String js) async { + scripts.add(js); + return null; + } + + final javaScript = []; + @override + Future setJavaScript(bool on) async => javaScript.add(on); } void main() { diff --git a/test/web_translate_test.dart b/test/web_translate_test.dart new file mode 100644 index 0000000..4d17a4d --- /dev/null +++ b/test/web_translate_test.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/web_translate.dart'; + +void main() { + test('화면 언어 → Google 언어 코드 · 같은 언어 판단', () { + expect(googleLang('ko'), 'ko'); + expect(googleLang('zh-Hans'), 'zh-CN'); + expect(googleLang('zh-Hant'), 'zh-TW'); + expect(googleLang('he'), 'iw'); + expect(sameLanguage('ko-KR', 'ko'), isTrue); + expect(sameLanguage('en', 'ko'), isFalse); + expect(sameLanguage('zh-CN', 'zh-TW'), isFalse); + expect(sameLanguage('zh', 'zh-CN'), isTrue); + expect(sameLanguage('iw', 'he'), isTrue); + expect(sameLanguage('', 'ko'), isFalse); + }); + + test('모은 글자 읽기: JSON 문자열 · 두 번 감싼 문자열 · Map · 잘못된 값', () { + const json = '{"items":[[0,"Hello"],[3,"Sign in"]],"more":true}'; + for (final raw in [json, jsonEncode(json), jsonDecode(json)]) { + final c = CollectedText.parse(raw)!; + expect(c.ids, [0, 3]); + expect(c.texts, ['Hello', 'Sign in']); + expect(c.more, isTrue); + } + expect(CollectedText.parse('{"items":[],"same":true}')!.same, isTrue); + expect(CollectedText.parse(null), isNull); + expect(CollectedText.parse('not json'), isNull); + }); + + test('Google 번역: 한 번에 보내고, 이미 그 언어인 글은 null, 같은 글은 다시 묻지 않음', () async { + final asked = >[]; + final urls = []; + final t = WebPageTranslator(post: (url, body) async { + urls.add(url); + final q = body.split('&').map((x) => Uri.decodeQueryComponent(x.substring(2))).toList(); + asked.add(q); + return jsonEncode([ + for (final x in q) + x == '안녕' ? [x, 'ko'] : ['[ko] $x', 'en'], + ]); + }); + final r = await t.translate(['Hello', '안녕', 'Hello', 'Sign in & go'], 'ko'); + expect(r, ['[ko] Hello', null, '[ko] Hello', '[ko] Sign in & go']); + expect(asked, [ + ['Hello', '안녕', 'Sign in & go'], // 같은 글은 한 번만 + ]); + expect(urls.single.queryParameters, containsPair('tl', 'ko')); + expect(urls.single.queryParameters, containsPair('sl', 'auto')); + expect(urls.single.queryParameters, containsPair('format', 'text')); + + // 두 번째는 기억해 둔 것으로 (서버에 묻지 않음) + expect(await t.translate(['Sign in & go', '안녕'], 'ko'), ['[ko] Sign in & go', null]); + expect(asked, hasLength(1)); + // 대상 언어가 바뀌면 다시 묻는다 + await t.translate(['Hello'], 'ja'); + expect(asked, hasLength(2)); + }); + + test('Google 번역: 많으면 나눠 보낸다 (100개 · 3000자)', () async { + final sizes = []; + final t = WebPageTranslator(post: (url, body) async { + final q = body.split('&').map((x) => Uri.decodeQueryComponent(x.substring(2))).toList(); + sizes.add(q.length); + return jsonEncode([for (final x in q) ['T$x', 'en']]); + }); + final texts = [for (var i = 0; i < 250; i++) 'line $i']; + final r = await t.translate(texts, 'ko'); + expect(r.first, 'Tline 0'); + expect(r.last, 'Tline 249'); + expect(sizes, [100, 100, 50]); + + sizes.clear(); + await t.translate([for (var i = 0; i < 4; i++) 'x' * 1000 + '$i'], 'ko'); + expect(sizes, [2, 2]); + }); + + test('Google 응답이 이상하면 오류 (개수가 다름)', () async { + final t = WebPageTranslator(post: (url, body) async => '[["a","en"]]'); + expect(() => t.translate(['a', 'b'], 'ko'), throwsFormatException); + expect(WebPageTranslator.parseGoogleResponse('["가","나"]', 2), [('가', ''), ('나', '')]); + }); + + test('스크립트: CEF 가 앞에 return 을 붙여도 되게 줄바꿈 없이 시작 · 대상 언어 · 번역 값이 들어간다', () { + final collect = webTranslateCollectScript('zh-CN', force: true); + expect(collect, startsWith('(function(tl,force,max){')); + expect(collect, endsWith('("zh-CN",true,300)')); + final apply = webTranslateApplyScript({0: '안녕 "세상"', 2: null}, same: true); + expect(apply, startsWith('(function(m,same){')); + expect(apply, endsWith('({"0":"안녕 \\"세상\\"","2":null},true)')); + expect(webTranslateRestoreScript, startsWith('(function(){')); + }); + + test('설정: 번역 (기본 끔) · JavaScript (기본 켬) 저장 · 읽기', () { + final d = AppSettings(); + expect(d.webTranslate, isFalse); + expect(d.webJavaScript, isTrue); + final s = AppSettings.fromJson(jsonDecode(jsonEncode((AppSettings() + ..webTranslate = true + ..webJavaScript = false) + .toJson())) as Map); + expect(s.webTranslate, isTrue); + expect(s.webJavaScript, isFalse); + }); +}