웹 브라우저: 페이지 자동 번역 · JavaScript 끄기 (환경 설정 > 웹 브라우저)

- 웹 페이지 자동 번역 (기본 끔): 다른 언어 페이지를 화면 언어로 (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 <noreply@anthropic.com>
main
RyuJongJin 2026-10-05 01:58:53 +09:00
parent 0f5c1e8391
commit be0c08834d
16 changed files with 838 additions and 22 deletions

View File

@ -4,4 +4,6 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 통합 테스트가 기기 안에 띄운 http://127.0.0.1 시험 페이지를 열 수 있게 (debug 빌드만) -->
<application android:usesCleartextTraffic="true"/>
</manifest>

View File

@ -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",

View File

@ -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}に翻訳",
"이 폴더 선택": "このフォルダーを選択",
"이 폴더 전체": "このフォルダー全体",
"이 폴더에 새 폴더": "このフォルダーに新しいフォルダー",

View File

@ -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}",
"이 폴더 선택": "选择此文件夹",
"이 폴더 전체": "整个文件夹",
"이 폴더에 새 폴더": "在此文件夹中新建文件夹",

View File

@ -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('<html lang="en"><head><meta charset="utf-8"><title>JJ</title></head><body>'
'<h1 id="h">Hello world</h1>'
'<p id="p"> Download the video </p>'
'<code id="c">Hello code</code>'
'<p id="k">안녕하세요 여러분</p>'
'<input id="i" placeholder="Search here">'
'<div id="js">no script</div>'
'<script>document.getElementById("js").textContent = "script ran"; fetch("/ran");'
'setTimeout(function(){var d=document.createElement("p");d.id="late";d.textContent="Good morning";'
'document.body.appendChild(d);}, 1500);</script>'
'</body></html>');
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<Map<String, String>> 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<Map<String, String>> waitFor(bool Function(Map<String, String>) ok, String what) async {
var d = <String, String>{};
for (var i = 0; i < 300; i++) {
await tester.runAsync(() => Future<void>.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<void> waitServer(bool Function() ok) async {
for (var i = 0; i < 300 && !ok(); i++) {
await tester.runAsync(() => Future<void>.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<void>.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<void>.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)));
}

View File

@ -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? ?? ''

281
lib/core/web_translate.dart Normal file
View File

@ -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] 가 아니면 페이지가 이미 화면 언어 (`<html lang>` 또는 앞서 번역해 보니 대부분 그 언어) 일 때 모으지 않는다.
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<int, String?> 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<int> ids;
final List<String> 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 = <int>[], texts = <String>[];
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<String> 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<List<String?>> translate(List<String> texts, String target) async {
final out = List<String?>.filled(texts.length, null);
final todo = <int>[];
final asked = <String>{};
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 = <List<String>>[];
var cur = <String>[];
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<void> _ask(List<String> 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<String> _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);
}
}
}

View File

@ -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<void> runScript(String js);
/// 페이지에서 스크립트를 실행하고 결과를 받는다 (페이지 번역). 실패하면 null.
Future<Object?> evaluate(String js);
/// 페이지의 JavaScript 켜기 · 끄기 (바뀌면 지금 페이지를 다시 읽는다)
Future<void> 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<BrowserPage> 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<BrowserPage> 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<void> _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<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;
}
}
@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<BrowserPage> 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<BrowserPage> 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<BrowserPage> 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<BrowserPage> 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<Object?> evaluate(String js) async {
try {
return await c.evaluateJavascript(source: js);
} catch (_) {
return null;
}
}
/// Edge 는 앱이 넣는 스크립트 (번역 · 동영상 찾기) 는 JavaScript 를 꺼도 실행된다. Android 는 꺼지면 안 된다.
@override
Future<void> 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<Object?> evaluate(String js) async {
try {
return await c.evaluateJavascript(js).timeout(const Duration(seconds: 10));
} catch (_) {
return null;
}
}
/// Chrome 엔진은 만든 뒤에 바꿀 수 없다: 화면이 JavaScript 설정을 열쇠로 웹뷰를 새로 만든다 ([_CefView])
@override
Future<void> setJavaScript(bool on) async {}
/// YouTube · Google 쿠키를 yt-dlp 용 cookies.txt 로 (Edge 와 같은 파일 - 지금 쓰는 엔진의 로그인이 쓰인다).
/// Chrome 엔진은 쿠키의 이름 · 값만 알려 주므로 만료 · 보안 표시는 기본값으로 적는다.
@override

View File

@ -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<SettingsPage> {
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('브라우저 엔진 (앱 안)')),

View File

@ -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)`). 이미 만든 브라우저는 바꿀 수 없어 앱이 새로 만든다.

View File

@ -255,11 +255,11 @@ void WebviewHandler::closeBrowser(int browserId)
}
}
void WebviewHandler::createBrowser(std::string url, std::function<void(int)> callback)
void WebviewHandler::createBrowser(std::string url, bool javascript, std::function<void(int)> 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<void(int)> 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

View File

@ -153,7 +153,8 @@ public:
void CloseAllBrowsers(bool force_close);
void closeBrowser(int browserId);
void createBrowser(std::string url, std::function<void(int)> callback);
// javascript: false 면 그 브라우저에서 페이지 스크립트를 실행하지 않는다 (JJ_PATCH.md 5)
void createBrowser(std::string url, bool javascript, std::function<void(int)> callback);
// Drives one external BeginFrame for every live browser (GPU path only).
// Marshals to the CEF UI thread; safe to call from any thread.

View File

@ -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<WebviewTexture> renderer = m_createTextureFunc();
m_renderers[browserId] = renderer;
WValue *response = webview_value_new_list();

View File

@ -74,14 +74,17 @@ class WebViewController extends ValueNotifier<bool> {
_onImeCompositionRangeChangedMessage;
/// Initializes the underlying platform view.
Future<void> initialize(String url) async {
///
/// [javaScript] false: 이 브라우저에서 페이지 스크립트를 실행하지 않는다 (JJ_PATCH.md 5).
Future<void> initialize(String url, {bool javaScript = true}) async {
if (_isDisposed) {
return Future<void>.value();
}
_creatingCompleter = Completer<void>();
try {
await WebviewManager().ready;
List args = await _pluginChannel.invokeMethod('create', url);
List args = await _pluginChannel.invokeMethod(
'create', javaScript ? url : <String, Object>{'url': url, 'javascript': false});
_browserId = args[0] as int;
_textureId = args[1] as int;
WebviewManager().onBrowserCreated(_index, _browserId);

View File

@ -61,6 +61,15 @@ class _Nav implements WebNav {
var exported = 0;
@override
Future<void> exportCookies() async => exported++;
@override
Future<Object?> evaluate(String js) async {
scripts.add(js);
return null;
}
final javaScript = <bool>[];
@override
Future<void> setJavaScript(bool on) async => javaScript.add(on);
}
void main() {

View File

@ -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 = <List<String>>[];
final urls = <Uri>[];
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 = <int>[];
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<String, dynamic>);
expect(s.webTranslate, isTrue);
expect(s.webJavaScript, isFalse);
});
}