From 620cd63e34db82fb52d372589960c6fe7fc66108 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:48:14 +0900 Subject: [PATCH] =?UTF-8?q?Chrome=20=EC=97=94=EC=A7=84:=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EA=B6=8C=ED=95=9C=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EC=95=88=20=ED=95=A8=20(=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9C=BC=EB=A1=9C=20=ED=97=88=EC=9A=A9)=20=C2=B7=2020?= =?UTF-8?q?=EC=B4=88=20=EC=95=88=EC=A0=84=EB=A7=9D=20=C2=B7=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=97=86=EB=8A=94=20=EC=88=A8=EA=B8=B4=20=EB=AC=B8?= =?UTF-8?q?=20(207)=20=C2=B7=20DNS=20=EA=B0=95=EC=A0=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=95=B1=20=EC=8B=9C=EC=9E=91=20=EB=A7=A8=20=EC=95=9E=EC=97=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 207 원인: Chromium 은 관리자 권한으로 실행되면 권한을 낮춰 다시 띄우려고 스스로 끝난다 (CefInitialize false, 종료 코드 38). 관리자 판단 (보안 우선): 관리자 권한이면 Chrome 엔진을 시작하지 않고 이유 · [Edge 엔진으로 바꾸기] · [그래도 Chrome 엔진 쓰기…] (경고 확인). 환경 설정 > 브라우저 "관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)" (처음 꺼짐) 를 켜면 --do-not-de-elevate (webview_cef 패치 7). - 20초 안에 준비되지 않으면 "Chrome 엔진을 시작하지 못했습니다" · [Edge 엔진으로 바꾸기] · [다시 시도]. 시작 중에 닫을 때의 LateInitializationError 고침. - Chrome 엔진은 프록시 인증에 답하지 않아 (CEF 149 windowless 에서 GetAuthCredentials 가 불리지 않음, 확인) 인증 없는 문을 실행마다 고른 127.x.y.z 의 빈 포트에, Chrome 엔진 웹 뷰가 있는 동안만 연다 (관리자 결정). 듣지 않던 옛 인증 패치는 걷어냄. - DNS 강제: HttpOverrides 를 main · runAndroid · runSecondWindow 맨 앞에서 건다. 그 전에 만들어 두던 HttpClient (새 버전 확인 · 구성 요소 · 도구 받기 · 자막 검색) 가 정한 DNS 를 거치지 않고 시스템 DNS 로 나가던 것 (닿지 않는 DNS 시험판에서 발견). - 안내 문구: DNS 서버에 닿지 않음은 예외 원문 대신 무엇을 확인할지, Tor 연결이 2분 넘게 멈추면 몇 % 에서 멈췄는지와 [다시 시도]. - 시험: cef_elevation_test (4), dns_proxy_test (Chrome 엔진 문 · 일찍 만든 HttpClient 도 정한 DNS). Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 18 +++ assets/l10n/ja.json | 18 +++ assets/l10n/zh-Hans.json | 18 +++ integration_test/cef_dns_real_test.dart | 7 +- integration_test/tor_dns_real_test.dart | 2 +- lib/app/net_guard.dart | 45 ++++++ lib/app/settings.dart | 5 + lib/app/tor_service.dart | 7 +- lib/app/web_proxy.dart | 6 +- lib/core/local_proxy.dart | 6 + lib/main.dart | 9 +- lib/platform/windows/elevation.dart | 33 +++++ lib/ui/browser_page.dart | 139 ++++++++++++++++-- lib/ui/cef_setup.dart | 31 ++++ lib/ui/settings_page.dart | 17 +++ packages/webview_cef/JJ_PATCH.md | 10 +- packages/webview_cef/common/webview_app.cc | 6 + packages/webview_cef/common/webview_app.h | 3 + .../webview_cef/common/webview_handler.cc | 22 --- packages/webview_cef/common/webview_handler.h | 28 ---- packages/webview_cef/common/webview_plugin.cc | 13 +- .../webview_cef/lib/src/webview_manager.dart | 7 +- test/cef_elevation_test.dart | 98 ++++++++++++ test/dns_proxy_test.dart | 60 ++++++++ 24 files changed, 523 insertions(+), 85 deletions(-) create mode 100644 lib/platform/windows/elevation.dart create mode 100644 test/cef_elevation_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 266d9cc..b4320dc 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -130,16 +130,21 @@ "Chrome 에서 가져오기": "Import from Chrome", "Chrome 에서 가져옴": "Imported from Chrome", "Chrome 엔진 (CEF {0}) 을 공식 배포처에서 내려받습니다.\n· 받을 크기: 약 {1}MB (설치 후 약 390MB)\n· 설치 위치: 프로그램 폴더의 Lib\\cef\n· 설치한 뒤 프로그램을 다시 시작하면 앱 안 브라우저가 Chrome 엔진으로 바뀝니다.\n\n참고: Google 은 이런 내장 브라우저의 로그인을 막을 수 있습니다. YouTube 로그인이 필요하면 Edge 엔진을 쓰세요 (언제든 다시 바꿀 수 있습니다).": "Downloads the Chrome engine (CEF {0}) from the official source.\n· Download size: about {1}MB (about 390MB installed)\n· Installed in: Lib\\cef in the program folder\n· After installing, restart the program and the in-app browser switches to the Chrome engine.\n\nNote: Google may block sign-in in embedded browsers like this. If you need to sign in to YouTube, use the Edge engine (you can switch back any time).", + "Chrome 엔진 프록시 문 닫힘: {0}": "Chrome engine proxy gate closed: {0}", + "Chrome 엔진 프록시 문 열림: {0}:{1}": "Chrome engine proxy gate opened: {0}:{1}", "Chrome 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.": "The Chrome engine uses the changed setting after the app restarts.", "Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "The Chrome engine can be used after restarting the program. Restart now?", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Cannot download the Chrome engine (HTTP {0})", + "Chrome 엔진을 시작하지 못했습니다": "Couldn't start the Chrome engine", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Cannot start the Chrome engine. Switch to Edge in Settings.\n{0}", + "Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "Couldn't reopen the Chrome engine's connection path. Restart the app.", "DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS-forcing proxy started: 127.0.0.1:{0} ({1})", "DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS proxy: rejected a connection without the secret ({0})", "DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS proxy: rejected a connection with a wrong secret ({0})", "DNS 강제 프록시를 시작하지 못했습니다: {0}": "Could not start the DNS-forcing proxy: {0}", "DNS 서버": "DNS server", "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "Cannot reach the DNS server ({0}): {1}", + "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "Couldn't reach the DNS server ({0}), so the address couldn't be found. Check the internet connection or the server address in Settings > Network · DNS.", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "Could not look up the address because the DNS server ({0}) is unreachable: {1}", "DNS 서버 주소가 없습니다 (환경 설정 > 네트워크 · DNS)": "No DNS server address (Settings > Network · DNS)", "DNS 설정: {0}": "DNS setting: {0}", @@ -154,6 +159,7 @@ "Edge (내장)": "Edge (built-in)", "Edge 에서 가져오기": "Import from Edge", "Edge 에서 가져옴": "Imported from Edge", + "Edge 엔진으로 바꾸기": "Switch to the Edge engine", "Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: part of Windows, nothing to install. If you sign in to YouTube here, downloads use that sign-in too.\nChrome: if chosen, about {0}MB is downloaded and it can be used after a restart (Google sign-in may be blocked).{1}{2}", "FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "Downloads and installs FFmpeg · yt-dlp · aria2 · Deno if missing", "FFmpeg 준비됨: {0}{1}": "FFmpeg ready: {0}{1}", @@ -419,6 +425,7 @@ "{0} 추가": "Add {0}", "{0} 파일": "{0} file", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "Delete page {0}? (Saved to the file only when you save)", + "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "Stopped because it made no progress past {0}% for over 2 minutes. The network may be blocking Tor or be slow. Press [Try again] in a moment.", "{0}. {1} (대기)": "{0}. {1} (waiting)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: moves this video to {1}", "{0}: 이미 이동 폴더에 있습니다": "{0}: already in the move folder", @@ -465,6 +472,7 @@ "{0}장을 올려 저장했습니다{1}": "Upscaled and saved {0}{1}", "{0}줄": "{0} lines", "{0}초": "{0} s", + "{0}초 안에 준비되지 않았습니다.": "It wasn't ready within {0} seconds.", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· yt-dlp update check on Android (connects separately inside the download tools)", "· 다운로드: yt-dlp, aria2 (http · https · HTTP 트래커는 바로, 토렌트의 UDP 트래커 · DHT 이름은 직접 지정일 때 - 끄기 ↔ 켜기는 다음 실행부터)": "· Downloads: yt-dlp, aria2 (http, https and HTTP trackers immediately; torrent UDP tracker and DHT names when set manually - switching off ↔ on applies from the next run)", @@ -556,6 +564,11 @@ "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "Cannot roll back: shared storage (Download) is not available.", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)", "관리 메뉴": "Manage menu", + "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "The Chrome engine can't be used because the app is running as administrator (security)", + "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "Only used when running as administrator. When off (default), the Chrome engine isn't started then and Edge is suggested.", + "관리자 권한일 때도 Chrome 엔진 쓰기": "Use the Chrome engine even as administrator", + "관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)": "Use the Chrome engine even as administrator (not recommended)", + "관리자 권한일 때도 Chrome 엔진 쓰기를 켰습니다 (권장하지 않음).": "Turned on using the Chrome engine even as administrator (not recommended).", "광고 건너뛰는 중…": "Skipping ad…", "권한 없음": "no permission", "권한 허용 화면 열기": "Open the permission screen", @@ -563,7 +576,9 @@ "규칙상 허용되지 않음": "not allowed by ruleset", "그 파일 하나만": "Only that file", "그대로 두기": "Keep", + "그래도 Chrome 엔진 쓰기…": "Use the Chrome engine anyway…", "그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})", + "그래도 쓰기": "Use anyway", "그리는 중 {0}/{1}": "Drawing {0}/{1}", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "To draw, {0} must be downloaded (the model stays on this device, and images and prompts never leave it).", "그리스어": "Greek", @@ -665,6 +680,7 @@ "내장 Chrome 엔진 내려받기 시작: {0}": "Built-in Chrome engine download started: {0}", "내장 Chrome 엔진 설치 실패: {0}": "Built-in Chrome engine install failed: {0}", "내장 Chrome 엔진 설치 완료 ({0}MB): {1}": "Built-in Chrome engine installed ({0}MB): {1}", + "내장 Chrome 엔진은 샌드박스가 꺼져 있어 관리자 권한으로 돌리면 위험합니다. Edge 엔진을 쓰거나, 관리자 권한 없이 프로그램을 실행하세요.": "The built-in Chrome engine runs without a sandbox, so running it as administrator is risky. Use the Edge engine, or run the app without administrator rights.", "내장 Chrome 엔진을 지우지 못했습니다 (사용 중).": "Could not delete the built-in Chrome engine (in use).", "내장 Chrome 엔진을 지웠습니다.": "Deleted the built-in Chrome engine.", "내장 저장소": "Internal storage", @@ -1554,6 +1570,7 @@ "이 폴더에 현재 페이지 추가": "Add the current page to this folder", "이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder", "이 폴더의 동영상 재생": "Play videos in this folder", + "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "This app is running as administrator. This is not recommended.\n\n· The built-in Chrome engine has its sandbox (the protection that confines web pages) turned off, so web pages run inside an administrator process. A flaw in a page could become administrator access to this PC.\n· The Edge engine is not affected.\n· If you run the app without administrator rights, you can use the Chrome engine without this setting.", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "No app opens this format. Long-press or right-click → [Play in built-in player] to try it.", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "This screen sends the in-app web view's connections through Tor; it is not the official Tor Browser.\n\n· All connections (including name lookups) go out through Tor, and cookies, history and cache are kept separate from the normal browser and cleared when closed (can be kept in Settings).\n· However, protection against browser fingerprinting (screen size, fonts, device information, etc.) is weaker than in the official Tor Browser. Sites may recognize this device.\n· If you need strong anonymity, use the official Tor Browser (https://www.torproject.org).", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "Double-tapping files with these extensions opens the image viewer (tap right for the next image, left for the previous).", @@ -1819,6 +1836,7 @@ "즐겨찾기 표시줄": "Bookmarks bar", "지금": "Current", "지금 {0}%": "Now {0}%", + "지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "The app is running as administrator now. When off (default), the Chrome engine isn't started and Edge is suggested. When on, web pages run as administrator without a sandbox.", "지금 끄기": "Stop now", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "The running copy uses the previous options. Stop it and run again with the new options?", "지금 마스터 (또는 최상) 비밀번호": "Current master (or top) password", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 4ed5d8d..507f866 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -130,16 +130,21 @@ "Chrome 에서 가져오기": "Chrome から取り込む", "Chrome 에서 가져옴": "Chrome から取り込み", "Chrome 엔진 (CEF {0}) 을 공식 배포처에서 내려받습니다.\n· 받을 크기: 약 {1}MB (설치 후 약 390MB)\n· 설치 위치: 프로그램 폴더의 Lib\\cef\n· 설치한 뒤 프로그램을 다시 시작하면 앱 안 브라우저가 Chrome 엔진으로 바뀝니다.\n\n참고: Google 은 이런 내장 브라우저의 로그인을 막을 수 있습니다. YouTube 로그인이 필요하면 Edge 엔진을 쓰세요 (언제든 다시 바꿀 수 있습니다).": "Chrome エンジン(CEF {0})を公式配布元からダウンロードします。\n· ダウンロードサイズ: 約 {1}MB(インストール後 約 390MB)\n· インストール先: プログラムフォルダーの Lib\\cef\n· インストール後にプログラムを再起動すると、アプリ内ブラウザーが Chrome エンジンに切り替わります。\n\n参考: Google はこのような内蔵ブラウザーでのログインをブロックすることがあります。YouTube へのログインが必要なら Edge エンジンを使ってください(いつでも戻せます)。", + "Chrome 엔진 프록시 문 닫힘: {0}": "Chrome エンジン用プロキシの入口を閉じました: {0}", + "Chrome 엔진 프록시 문 열림: {0}:{1}": "Chrome エンジン用プロキシの入口を開きました: {0}:{1}", "Chrome 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.": "Chrome エンジンはアプリを再起動すると変更した設定を使います。", "Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "Chrome エンジンはプログラムを再起動すると使えます。今すぐ再起動しますか?", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Chrome エンジンをダウンロードできません(HTTP {0})", + "Chrome 엔진을 시작하지 못했습니다": "Chrome エンジンを起動できませんでした", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Chrome エンジンを起動できません。環境設定で Edge に切り替えてください。\n{0}", + "Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "Chrome エンジンの接続経路を開き直せません。アプリを再起動してください。", "DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 強制プロキシ開始: 127.0.0.1:{0} ({1})", "DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS 強制プロキシ: 秘密値のない接続を拒否 ({0})", "DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS 強制プロキシ: 秘密値が違う接続を拒否 ({0})", "DNS 강제 프록시를 시작하지 못했습니다: {0}": "DNS 強制プロキシを開始できませんでした: {0}", "DNS 서버": "DNS サーバー", "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "DNS サーバー ({0}) に届きません: {1}", + "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "DNS サーバー ({0}) に届かず、アドレスを見つけられませんでした。インターネット接続か、環境設定 > ネットワーク · DNS のサーバーアドレスを確認してください。", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "DNS サーバー ({0}) に届かず、アドレスを解決できませんでした: {1}", "DNS 서버 주소가 없습니다 (환경 설정 > 네트워크 · DNS)": "DNS サーバーのアドレスがありません (環境設定 > ネットワーク・DNS)", "DNS 설정: {0}": "DNS 設定: {0}", @@ -154,6 +159,7 @@ "Edge (내장)": "Edge(内蔵)", "Edge 에서 가져오기": "Edge から取り込む", "Edge 에서 가져옴": "Edge から取り込み", + "Edge 엔진으로 바꾸기": "Edge エンジンに切り替える", "Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: Windows に含まれているので別途インストール不要です。ここで YouTube にログインすると、ダウンロードにもそのログインが使われます。\nChrome: 選ぶと約 {0}MB をダウンロードし、再起動後に使えます(Google ログインはブロックされることがあります)。{1}{2}", "FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "FFmpeg・yt-dlp・aria2・Deno がなければダウンロードしてインストールします", "FFmpeg 준비됨: {0}{1}": "FFmpeg 準備完了: {0}{1}", @@ -419,6 +425,7 @@ "{0} 추가": "{0} を追加", "{0} 파일": "{0} ファイル", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "{0} ページを削除しますか? (保存するとファイルに反映されます)", + "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "{0}% から 2 分以上進まなかったため停止しました。ネットワークが Tor をブロックしているか、遅い可能性があります。少し待ってから [再試行] してください。", "{0}. {1} (대기)": "{0}. {1}(待機)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: この動画を {1} に移動します", "{0}: 이미 이동 폴더에 있습니다": "{0}: 既に移動フォルダーにあります", @@ -465,6 +472,7 @@ "{0}장을 올려 저장했습니다{1}": "{0} ページを高解像度化して保存しました{1}", "{0}줄": "{0}行", "{0}초": "{0}秒", + "{0}초 안에 준비되지 않았습니다.": "{0} 秒以内に準備できませんでした。", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "{0} 秒ごとに元フォルダーを確認して対象フォルダーに合わせます (アプリの起動中)。", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android の yt-dlp 新バージョン確認 (ダウンロードツール内で別に接続)", "· 다운로드: yt-dlp, aria2 (http · https · HTTP 트래커는 바로, 토렌트의 UDP 트래커 · DHT 이름은 직접 지정일 때 - 끄기 ↔ 켜기는 다음 실행부터)": "· ダウンロード: yt-dlp、aria2 (http・https・HTTP トラッカーはすぐ、トレントの UDP トラッカー・DHT の名前は手動指定のとき - オフ ↔ オンは次回の実行から)", @@ -556,6 +564,11 @@ "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "共有ストレージ (Download) を使えないため戻せません。", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)", "관리 메뉴": "管理メニュー", + "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "管理者権限で実行中のため Chrome エンジンを使えません (セキュリティ)", + "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "管理者権限で実行するときだけ使われます。オフ (既定) の場合、そのときは Chrome エンジンを起動せず Edge をおすすめします。", + "관리자 권한일 때도 Chrome 엔진 쓰기": "管理者権限でも Chrome エンジンを使う", + "관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)": "管理者権限でも Chrome エンジンを使う (非推奨)", + "관리자 권한일 때도 Chrome 엔진 쓰기를 켰습니다 (권장하지 않음).": "管理者権限でも Chrome エンジンを使う設定をオンにしました (非推奨)。", "광고 건너뛰는 중…": "広告をスキップ中…", "권한 없음": "権限なし", "권한 허용 화면 열기": "権限の許可画面を開く", @@ -563,7 +576,9 @@ "규칙상 허용되지 않음": "ルールで許可されていません", "그 파일 하나만": "そのファイルだけ", "그대로 두기": "そのままにする", + "그래도 Chrome 엔진 쓰기…": "それでも Chrome エンジンを使う…", "그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)", + "그래도 쓰기": "それでも使う", "그리는 중 {0}/{1}": "描画中 {0}/{1}", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "描くには {0} のダウンロードが必要です (モデルはこの端末にだけ置かれ、画像・プロンプトは外に出ません)。", "그리스어": "ギリシャ語", @@ -665,6 +680,7 @@ "내장 Chrome 엔진 내려받기 시작: {0}": "内蔵 Chrome エンジンのダウンロード開始: {0}", "내장 Chrome 엔진 설치 실패: {0}": "内蔵 Chrome エンジンのインストール失敗: {0}", "내장 Chrome 엔진 설치 완료 ({0}MB): {1}": "内蔵 Chrome エンジンのインストール完了({0}MB): {1}", + "내장 Chrome 엔진은 샌드박스가 꺼져 있어 관리자 권한으로 돌리면 위험합니다. Edge 엔진을 쓰거나, 관리자 권한 없이 프로그램을 실행하세요.": "内蔵 Chrome エンジンはサンドボックスがオフのため、管理者権限で動かすと危険です。Edge エンジンを使うか、管理者権限なしでアプリを実行してください。", "내장 Chrome 엔진을 지우지 못했습니다 (사용 중).": "内蔵 Chrome エンジンを削除できませんでした(使用中)。", "내장 Chrome 엔진을 지웠습니다.": "内蔵 Chrome エンジンを削除しました。", "내장 저장소": "内部ストレージ", @@ -1554,6 +1570,7 @@ "이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加", "이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません", "이 폴더의 동영상 재생": "このフォルダーの動画を再生", + "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "このアプリは管理者権限で実行されています。おすすめしません。\n\n· 内蔵 Chrome エンジンはサンドボックス (Web ページを閉じ込める保護) がオフのため、Web ページが管理者権限のプロセス内で動きます。ページの脆弱性がそのままこの PC の管理者権限になるおそれがあります。\n· Edge エンジンにはこの影響がありません。\n· 管理者権限なしでアプリを実行すれば、この設定なしで Chrome エンジンを使えます。", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "この形式を開くアプリがありません。長押しまたは右クリック → [内蔵プレーヤーで再生] で試してください。", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "この画面はアプリ内 Web ビューの接続を Tor 経由にするもので、公式の Tor Browser ではありません。\n\n・接続 (名前解決を含む) はすべて Tor 経由になり、Cookie・履歴・キャッシュは通常のブラウザーとは別に保存し、閉じると消去します (環境設定で残すことも可能)。\n・ただし、ブラウザーのフィンガープリント (画面サイズ・フォント・端末情報など) を隠す保護は公式 Tor Browser より弱いです。サイトがこの端末を識別できる場合があります。\n・強い匿名性が必要な場合は公式 Tor Browser (https://www.torproject.org) を使ってください。", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "この拡張子のファイルをダブルタップすると画像表示で開きます (右をタップで次、左で前の画像)。", @@ -1819,6 +1836,7 @@ "즐겨찾기 표시줄": "ブックマークバー", "지금": "現在", "지금 {0}%": "現在 {0}%", + "지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "現在、管理者権限で実行中です。オフ (既定) の場合は Chrome エンジンを起動せず Edge をおすすめします。オンにすると、サンドボックスなしで管理者権限のまま Web ページを動かします。", "지금 끄기": "今すぐ終了", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "実行中のコピーは前のオプションで動いています。止めて新しいオプションで再実行しますか?", "지금 마스터 (또는 최상) 비밀번호": "現在のマスター (または最上位) パスワード", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 8aa89f4..20edd17 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -130,16 +130,21 @@ "Chrome 에서 가져오기": "从 Chrome 导入", "Chrome 에서 가져옴": "从 Chrome 导入", "Chrome 엔진 (CEF {0}) 을 공식 배포처에서 내려받습니다.\n· 받을 크기: 약 {1}MB (설치 후 약 390MB)\n· 설치 위치: 프로그램 폴더의 Lib\\cef\n· 설치한 뒤 프로그램을 다시 시작하면 앱 안 브라우저가 Chrome 엔진으로 바뀝니다.\n\n참고: Google 은 이런 내장 브라우저의 로그인을 막을 수 있습니다. YouTube 로그인이 필요하면 Edge 엔진을 쓰세요 (언제든 다시 바꿀 수 있습니다).": "从官方来源下载 Chrome 引擎(CEF {0})。\n· 下载大小: 约 {1}MB(安装后约 390MB)\n· 安装位置: 程序文件夹中的 Lib\\cef\n· 安装后重新启动程序,内置浏览器将切换为 Chrome 引擎。\n\n注意: Google 可能会阻止此类内置浏览器登录。如需登录 YouTube,请使用 Edge 引擎(可随时切换回来)。", + "Chrome 엔진 프록시 문 닫힘: {0}": "Chrome 引擎代理入口已关闭:{0}", + "Chrome 엔진 프록시 문 열림: {0}:{1}": "Chrome 引擎代理入口已打开:{0}:{1}", "Chrome 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.": "Chrome 引擎在重新启动应用后使用更改后的设置。", "Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "重新启动程序后即可使用 Chrome 引擎。现在重新启动吗?", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "无法下载 Chrome 引擎(HTTP {0})", + "Chrome 엔진을 시작하지 못했습니다": "无法启动 Chrome 引擎", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "无法启动 Chrome 引擎。请在设置中切换为 Edge。\n{0}", + "Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.": "无法重新打开 Chrome 引擎的连接通道。请重新启动应用。", "DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 强制代理已启动: 127.0.0.1:{0} ({1})", "DNS 강제 프록시: 비밀값이 없는 연결을 거절 ({0})": "DNS 强制代理:已拒绝没有密钥的连接({0})", "DNS 강제 프록시: 비밀값이 틀린 연결을 거절 ({0})": "DNS 强制代理:已拒绝密钥错误的连接({0})", "DNS 강제 프록시를 시작하지 못했습니다: {0}": "无法启动 DNS 强制代理: {0}", "DNS 서버": "DNS 服务器", "DNS 서버 ({0}) 에 닿지 않습니다: {1}": "无法连接 DNS 服务器 ({0}): {1}", + "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.": "无法连接 DNS 服务器({0}),未能找到地址。请检查网络连接,或检查 设置 > 网络 · DNS 中的服务器地址。", "DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "无法连接 DNS 服务器 ({0}),未能解析地址: {1}", "DNS 서버 주소가 없습니다 (환경 설정 > 네트워크 · DNS)": "没有 DNS 服务器地址 (设置 > 网络 · DNS)", "DNS 설정: {0}": "DNS 设置: {0}", @@ -154,6 +159,7 @@ "Edge (내장)": "Edge(内置)", "Edge 에서 가져오기": "从 Edge 导入", "Edge 에서 가져옴": "从 Edge 导入", + "Edge 엔진으로 바꾸기": "切换到 Edge 引擎", "Edge: Windows 에 들어 있어 따로 설치하지 않습니다. 여기서 YouTube 에 로그인하면 다운로드에도 그 로그인이 쓰입니다.\nChrome: 고르면 약 {0}MB 를 내려받고 다시 시작한 뒤 쓸 수 있습니다 (Google 로그인은 막힐 수 있음).{1}{2}": "Edge: Windows 自带,无需另外安装。在此登录 YouTube 后,下载也会使用该登录。\nChrome: 选择后会下载约 {0}MB,重新启动后可用(Google 登录可能被阻止)。{1}{2}", "FFmpeg · yt-dlp · aria2 · Deno 가 없으면 내려받아 설치합니다": "如缺少 FFmpeg · yt-dlp · aria2 · Deno,将下载并安装", "FFmpeg 준비됨: {0}{1}": "FFmpeg 已就绪: {0}{1}", @@ -419,6 +425,7 @@ "{0} 추가": "添加 {0}", "{0} 파일": "{0} 文件", "{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)": "删除第 {0} 页? (保存后才会写入文件)", + "{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.": "在 {0}% 停滞超过 2 分钟,已停止。网络可能在阻止 Tor 或速度较慢。请稍后点 [重试]。", "{0}. {1} (대기)": "{0}. {1}(等待)", "{0}: 이 동영상을 {1} 로 옮깁니다": "{0}: 将此视频移到 {1}", "{0}: 이미 이동 폴더에 있습니다": "{0}: 已在移动文件夹中", @@ -465,6 +472,7 @@ "{0}장을 올려 저장했습니다{1}": "已提高并保存 {0} 页{1}", "{0}줄": "{0} 行", "{0}초": "{0} 秒", + "{0}초 안에 준비되지 않았습니다.": "未能在 {0} 秒内准备好。", "{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "每 {0} 秒检查源文件夹并同步到目标文件夹 (应用运行期间)。", "· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)": "· Android 上的 yt-dlp 新版本检查 (在下载工具内部另行连接)", "· 다운로드: yt-dlp, aria2 (http · https · HTTP 트래커는 바로, 토렌트의 UDP 트래커 · DHT 이름은 직접 지정일 때 - 끄기 ↔ 켜기는 다음 실행부터)": "· 下载: yt-dlp、aria2 (http · https · HTTP Tracker 立即生效,种子的 UDP Tracker · DHT 名称在手动指定时 - 关闭 ↔ 开启从下次运行起生效)", @@ -556,6 +564,11 @@ "공용 저장소 (Download) 를 쓸 수 없어 되돌릴 수 없습니다.": "无法使用公共存储 (Download),因此无法回退。", "공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)", "관리 메뉴": "管理菜单", + "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "应用正以管理员权限运行,无法使用 Chrome 引擎(安全原因)", + "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "仅在以管理员权限运行时使用。关闭时(默认)届时不启动 Chrome 引擎,并建议使用 Edge。", + "관리자 권한일 때도 Chrome 엔진 쓰기": "以管理员权限运行时也使用 Chrome 引擎", + "관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)": "以管理员权限运行时也使用 Chrome 引擎(不推荐)", + "관리자 권한일 때도 Chrome 엔진 쓰기를 켰습니다 (권장하지 않음).": "已开启以管理员权限运行时也使用 Chrome 引擎(不推荐)。", "광고 건너뛰는 중…": "正在跳过广告…", "권한 없음": "无权限", "권한 허용 화면 열기": "打开权限授予界面", @@ -563,7 +576,9 @@ "규칙상 허용되지 않음": "规则不允许", "그 파일 하나만": "仅该文件", "그대로 두기": "保留", + "그래도 Chrome 엔진 쓰기…": "仍然使用 Chrome 引擎…", "그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)", + "그래도 쓰기": "仍然使用", "그리는 중 {0}/{1}": "正在绘制 {0}/{1}", "그리려면 {0} 를 받아야 합니다 (모델은 받은 뒤 이 기기에만 있고, 그림 · 프롬프트는 밖으로 나가지 않습니다).": "要绘制,需要下载 {0} (模型只保存在本设备上,图片和提示词不会离开设备)。", "그리스어": "希腊语", @@ -665,6 +680,7 @@ "내장 Chrome 엔진 내려받기 시작: {0}": "内置 Chrome 引擎开始下载: {0}", "내장 Chrome 엔진 설치 실패: {0}": "内置 Chrome 引擎安装失败: {0}", "내장 Chrome 엔진 설치 완료 ({0}MB): {1}": "内置 Chrome 引擎安装完成({0}MB): {1}", + "내장 Chrome 엔진은 샌드박스가 꺼져 있어 관리자 권한으로 돌리면 위험합니다. Edge 엔진을 쓰거나, 관리자 권한 없이 프로그램을 실행하세요.": "内置 Chrome 引擎未启用沙盒,以管理员权限运行有风险。请使用 Edge 引擎,或不以管理员权限运行本应用。", "내장 Chrome 엔진을 지우지 못했습니다 (사용 중).": "无法删除内置 Chrome 引擎(正在使用)。", "내장 Chrome 엔진을 지웠습니다.": "已删除内置 Chrome 引擎。", "내장 저장소": "内部存储", @@ -1554,6 +1570,7 @@ "이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹", "이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件", "이 폴더의 동영상 재생": "播放此文件夹中的视频", + "이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. 페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n· Edge 엔진은 이 영향이 없습니다.\n· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.": "本应用正以管理员权限运行,不推荐这样做。\n\n· 内置 Chrome 引擎的沙盒(限制网页的保护)已关闭,网页会在管理员权限进程中运行。网页中的漏洞可能直接变成此电脑的管理员权限。\n· Edge 引擎不受此影响。\n· 不以管理员权限运行本应用时,无需此设置即可使用 Chrome 引擎。", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "没有可打开此格式的应用。请长按或右键 → [用内置播放器播放] 试试。", "이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. 사이트가 이 기기를 알아볼 수 있습니다.\n· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.": "此画面将应用内网页视图的连接经由 Tor 发送,并非官方 Tor Browser。\n\n· 所有连接 (包括名称解析) 都经由 Tor,Cookie · 记录 · 缓存与普通浏览器分开保存,关闭时清除 (可在设置中保留)。\n· 但隐藏浏览器指纹 (屏幕尺寸 · 字体 · 设备信息等) 的保护比官方 Tor Browser 弱。网站可能识别出本设备。\n· 如需强匿名性,请使用官方 Tor Browser (https://www.torproject.org)。", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "双击这些扩展名的文件会用图片查看打开 (点右侧看下一张,点左侧看上一张)。", @@ -1819,6 +1836,7 @@ "즐겨찾기 표시줄": "书签栏", "지금": "当前", "지금 {0}%": "当前 {0}%", + "지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.": "当前正以管理员权限运行。关闭时(默认)不启动 Chrome 引擎并建议使用 Edge。开启后将在无沙盒的情况下以管理员权限运行网页。", "지금 끄기": "立即关闭", "지금 도는 복사는 앞의 옵션으로 돌고 있습니다. 멈추고 새 옵션으로 다시 실행할까요?": "正在运行的复制使用的是之前的选项。停止并用新选项重新运行吗?", "지금 마스터 (또는 최상) 비밀번호": "当前主密码 (或最高密码)", diff --git a/integration_test/cef_dns_real_test.dart b/integration_test/cef_dns_real_test.dart index b7388b1..c1967ab 100644 --- a/integration_test/cef_dns_real_test.dart +++ b/integration_test/cef_dns_real_test.dart @@ -8,6 +8,7 @@ import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; import 'package:jj_mkvmaker/app/net_guard.dart'; import 'package:jj_mkvmaker/platform/windows/cef_runtime.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/elevation.dart'; import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/ui/app_actions.dart'; @@ -37,7 +38,9 @@ void main() { final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); c.settings ..webViewDataDir = p.join(data, 'webview') - ..browserEngine = 'chrome'; + ..browserEngine = 'chrome' + // 207: 이 PC 처럼 관리자 권한으로 실행 중이면 설정을 켠 때만 Chrome 엔진이 돈다 (--do-not-de-elevate) + ..cefWhenElevated = true; final answered = []; final logs = []; final g = NetGuard.instance @@ -68,6 +71,8 @@ void main() { title = '${await tester.runAsync(() async => BrowserPage.debugNav?.evaluate('document.title'))}'; } // ignore: avoid_print + print('RESULT 관리자 권한: ${Elevation.elevated}'); + // ignore: avoid_print print('RESULT Chrome 엔진 example.com 제목: $title, 답한 DNS: ${answered.where((a) => a.startsWith('example.com'))}\n' 'RESULT 웹 뷰: ${BrowserPage.debugNav.runtimeType} / 기록: ${logs.take(8).join(' | ')}'); // ignore: avoid_print diff --git a/integration_test/tor_dns_real_test.dart b/integration_test/tor_dns_real_test.dart index ada4d20..2c137ac 100644 --- a/integration_test/tor_dns_real_test.dart +++ b/integration_test/tor_dns_real_test.dart @@ -138,7 +138,7 @@ void main() { } final rtc = await tester.runAsync(() async => TorPage.debugNav?.evaluate('typeof RTCPeerConnection')); // ignore: avoid_print - print('RESULT Tor 상태: ${TorService.instance.status.value.phase} / RTCPeerConnection: $rtc\n' + print('RESULT Tor 상태: ${TorService.instance.status.value.phase} ${TorService.instance.status.value.percent}% ${TorService.instance.status.value.message} / RTCPeerConnection: $rtc\n' 'RESULT check.torproject.org: ${body.split('\n').where((l) => l.trim().isNotEmpty).take(3).join(' | ')}'); // ignore: avoid_print print('RESULT 기록: ${logs.where((l) => l.contains('Tor')).join(' | ')}'); diff --git a/lib/app/net_guard.dart b/lib/app/net_guard.dart index 4bed1f6..52181a2 100644 --- a/lib/app/net_guard.dart +++ b/lib/app/net_guard.dart @@ -95,6 +95,51 @@ class NetGuard { /// 인증 (비밀값) 은 주소에 넣지 않고 브라우저가 물을 때 답한다 ([ProxyAuth]) String? get browserProxy => forcing ? 'http://127.0.0.1:$port' : null; + // ───────── 207: Chrome 엔진 (CEF) 문 ───────── + // Chrome 엔진은 프록시 인증 (407) 에 답하지 않는다 (CEF 149 windowless, 10/11 확인). 그래서 Chrome 엔진에만 인증 없는 문을 + // 실행마다 고른 127.x.y.z 의 빈 포트에, Chrome 엔진 웹 뷰가 있는 동안만 연다 (관리자 결정 10/11). 엔진의 프록시 주소는 + // 시작할 때 한 번 정해지므로 이번 실행 동안 같은 주소 · 포트로 다시 연다. + LocalProxy? _cefGate; + InternetAddress? _cefAddress; + int? _cefPort; + int _cefViews = 0; + + /// 지금 열린 Chrome 엔진 문 (시험 · 기록) + String? get cefGateUrl => _cefGate?.running == true ? 'http://${_cefGate!.host}:${_cefGate!.port}' : null; + + /// Chrome 엔진 웹 뷰가 생김: 문을 열고 그 주소 (DNS 강제가 아니면 null - 프록시 없음) + Future openCefGate() async { + if (!config().forced) return null; + _cefViews++; + final g = _cefGate ??= LocalProxy(DnsConnector(resolver), onError: reportError); + if (!g.running) { + final at = _cefAddress ??= randomLoopback(); + try { + await g.start(prefer: _cefPort, address: at); + } catch (_) { + // 127.x.y.z 에 묶을 수 없는 PC: 127.0.0.1 (처음 열 때만 - 엔진이 이미 다른 주소를 쓰고 있으면 바꾸지 않는다) + if (_cefPort != null) rethrow; + _cefAddress = InternetAddress.loopbackIPv4; + await g.start(address: _cefAddress); + } + _cefPort ??= g.port; + log?.call(trf('Chrome 엔진 프록시 문 열림: {0}:{1}', [g.host, g.port])); + } + return 'http://${g.host}:${g.port}'; + } + + /// Chrome 엔진 웹 뷰가 없어짐: 마지막이면 문을 닫는다 (그 사이 엔진이 나가려 하면 실패 - 직접 나가지 않음) + Future closeCefGate() async { + if (_cefViews > 0) _cefViews--; + if (_cefViews > 0) return; + final g = _cefGate; + _cefGate = null; + if (g == null || !g.running) return; + final at = '${g.host}:${g.port}'; + await g.close(); + log?.call(trf('Chrome 엔진 프록시 문 닫힘: {0}', [at])); + } + /// yt-dlp 인수 List ytDlpArgs() => forcing ? ['--proxy', ProxyAuth.instance.url(port!)] : const []; diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 510415c..821da47 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -309,6 +309,9 @@ class AppSettings { /// 앱 안 브라우저 엔진: 'edge' (WebView2, 기본). 'chrome' 은 내려받아 연결 (준비 중) String browserEngine = 'edge'; + /// 207: 관리자 권한으로 실행 중일 때도 내장 Chrome 엔진을 쓸지 (처음 꺼짐 - 관리자 판단: 보안이 먼저, CEF 는 샌드박스가 꺼져 있다) + bool cefWhenElevated = false; + /// "외부 브라우저로 열기" 에 쓸 브라우저: 'system' · 'chrome' · 'firefox' · 'edge' · 'whale' String externalBrowser = 'system'; @@ -648,6 +651,7 @@ class AppSettings { 'openFileWindow': openFileWindow, 'homeUrl': homeUrl, 'browserEngine': browserEngine, + 'cefWhenElevated': cefWhenElevated, 'externalBrowser': externalBrowser, 'webTranslate': webTranslate, 'webJavaScript': webJavaScript, @@ -829,6 +833,7 @@ class AppSettings { ..openFileWindow = j['openFileWindow'] == 'new' ? 'new' : 'same' ..homeUrl = j['homeUrl'] as String? ?? 'https://www.youtube.com/' ..browserEngine = j['browserEngine'] as String? ?? 'edge' + ..cefWhenElevated = j['cefWhenElevated'] == true ..externalBrowser = j['externalBrowser'] as String? ?? 'system' ..webTranslate = j['webTranslate'] as bool? ?? false ..webJavaScript = j['webJavaScript'] as bool? ?? true diff --git a/lib/app/tor_service.dart b/lib/app/tor_service.dart index 85e0461..143e4fb 100644 --- a/lib/app/tor_service.dart +++ b/lib/app/tor_service.dart @@ -166,7 +166,12 @@ class TorService { if (DateTime.now().difference(_lastProgress) > const Duration(minutes: 2)) { final why = _recent.lastWhere((l) => l.contains('[warn]'), orElse: () => ''); unawaited(stop(keepStatus: true)); - _set(TorStatus(TorPhase.failed, percent: status.value.percent, message: trf('Tor 연결 시간 초과 {0}', [why]))); + // 사용자에게: 무엇이 일어났고 무엇을 할지 ([다시 시도] 는 상태 줄에). 마지막 경고는 기록에도 남긴다 + log?.call(trf('Tor 연결 시간 초과 {0}', [why])); + _set(TorStatus(TorPhase.failed, + percent: status.value.percent, + message: trf('{0}% 에서 2분 넘게 나아가지 않아 멈췄습니다. 망이 Tor 를 막고 있거나 느릴 수 있습니다. 잠시 뒤 [다시 시도] 하세요.', + [status.value.percent]))); } }); } diff --git a/lib/app/web_proxy.dart b/lib/app/web_proxy.dart index aaeddb4..f3eabe4 100644 --- a/lib/app/web_proxy.dart +++ b/lib/app/web_proxy.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math' as math; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -72,10 +71,7 @@ class WebProxy { /// 이번 실행의 문 주소 (127.x.y.z). bind 가 안 되는 기기는 127.0.0.1 static InternetAddress? _gateAddress; - static InternetAddress _pickAddress() { - final r = math.Random.secure(); - return InternetAddress('127.${1 + r.nextInt(254)}.${1 + r.nextInt(254)}.${2 + r.nextInt(253)}'); - } + static InternetAddress _pickAddress() => randomLoopback(); /// 문이 닫혀 있을 때 웹 뷰에 거는 곳 (아무도 듣지 않는 포트 - 페이지를 열지 못하게) static const closedProxy = 'http://127.0.0.1:9'; diff --git a/lib/core/local_proxy.dart b/lib/core/local_proxy.dart index 0caeea4..3e917e8 100644 --- a/lib/core/local_proxy.dart +++ b/lib/core/local_proxy.dart @@ -194,6 +194,12 @@ class TorConnector implements ProxyConnector { } } +/// 실행마다 고르는 루프백 주소 127.x.y.z (같은 기기의 다른 앱이 찾기 어렵게 - 인증 없는 문에 쓴다: Android 웹 뷰 · Chrome 엔진) +InternetAddress randomLoopback() { + final r = math.Random.secure(); + return InternetAddress('127.${1 + r.nextInt(254)}.${1 + r.nextInt(254)}.${2 + r.nextInt(253)}'); +} + /// 앱 안 프록시의 인증 (관리자 결정 10/11): 같은 기기의 다른 앱 · 프로그램이 이 프록시 (DNS 강제 · Tor 다리) 를 쓰지 못하게 /// 앱을 켤 때마다 새로 만드는 무작위 비밀값으로 Basic 프록시 인증 (Proxy-Authorization) 을 요구한다. 어디에도 저장하지 않는다. class ProxyAuth { diff --git a/lib/main.dart b/lib/main.dart index 1d127f5..dca8984 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -81,6 +81,9 @@ Future _prepare() async { Future main(List args) async { WidgetsFlutterBinding.ensureInitialized(); + // DNS 강제: 앱의 HttpClient 는 만들 때 findProxy 를 받으므로, 다른 무엇보다 먼저 건다 (일찍 만들어 두는 업데이트 · 컴포넌트 · + // 도구 받기 · 자막 검색의 HttpClient 가 정한 DNS 를 거치지 않던 것, 10/12). 설정을 읽기 전에는 바로 연결 (그 사이 나가는 연결 없음) + HttpOverrides.global = JjHttpOverrides(); // 62 · 63 · 64 · 123: GPL · LGPL 원문 · 고지 문서 · AI 구성 요소 · 모델을 앱 안 라이선스 화면에 registerAppLicenses(); applyDataDirOverride(); // 시험판: 사용자 데이터와 다른 폴더 (JJ_MKVMAKER_DATA) @@ -491,7 +494,9 @@ void watchDnsFailure(GlobalKey messengerKey, AppControll duration: const Duration(seconds: 12), showCloseIcon: true, persist: false, - content: Text(trf('DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}', [f.server, f.reason])), + // 사용자에게는 무엇이 안 되고 무엇을 할지만 (예외 원문은 작업 기록에 - NetGuard.reportError) + content: Text(trf('DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다. 인터넷 연결이나 환경 설정 > 네트워크 · DNS 의 서버 주소를 확인하세요.', + [f.server])), action: SnackBarAction( label: tr('시스템 DNS 로 바꾸기'), onPressed: () => c.updateSettings((x) => x.dnsMode = 'system'), @@ -529,6 +534,7 @@ String lastWorkText(AppController c) { /// Android: 창 하나. 트레이 · 단축키 · 탐색기 연결 · 다운로드 (yt-dlp · aria2) · 업데이트는 없다. /// 화면 방향은 기본이 자동 (기기를 돌리는 대로). 환경 설정 > 화면 방향에서 가로 · 세로 고정. Future runAndroid(String dataDir) async { + HttpOverrides.global = JjHttpOverrides(); // DNS 강제: HttpClient 를 만들기 전에 (main 과 같은 까닭) final services = PlatformServices.create(); final logs = Directory(p.join(dataDir, 'Logs'))..createSync(recursive: true); final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log'); @@ -725,6 +731,7 @@ Future runAndroid(String dataDir) async { /// 동영상 목록은 원래 창과 같은 파일을 보므로 어느 창에서 넣거나 지워도 서로 맞춰진다. /// 설정은 읽기만 한다 (원래 창의 설정 파일을 덮어쓰지 않도록 저장하지 않음). Future runSecondWindow(List files, String dataDir) async { + HttpOverrides.global = JjHttpOverrides(); // DNS 강제: HttpClient 를 만들기 전에 (main 과 같은 까닭) await windowManager.ensureInitialized(); await windowManager.setTitle(appTitle); await WindowMemory(p.join(dataDir, 'window.json'), 'second').restoreAndWatch(); diff --git a/lib/platform/windows/elevation.dart b/lib/platform/windows/elevation.dart new file mode 100644 index 0000000..3ec797d --- /dev/null +++ b/lib/platform/windows/elevation.dart @@ -0,0 +1,33 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +typedef _IsAdminC = Int32 Function(); +typedef _IsAdminD = int Function(); + +/// 이 프로그램이 관리자 권한 (높은 무결성) 으로 실행 중인지 (207). +/// 내장 Chrome 엔진 (CEF) 은 관리자 권한이면 스스로 끝나고, 억지로 돌리면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌린다. +class Elevation { + Elevation._(); + + /// 시험용: 정해 두면 그 값 + @visibleForTesting + static bool? debugElevated; + + static bool? _cached; + + /// 관리자 권한으로 실행 중인지 (Windows 만, 모르면 false) + static bool get elevated { + if (debugElevated case final v?) return v; + if (!Platform.isWindows) return false; + return _cached ??= () { + try { + final f = DynamicLibrary.open('shell32.dll').lookupFunction<_IsAdminC, _IsAdminD>('IsUserAnAdmin'); + return f() != 0; + } catch (_) { + return false; + } + }(); + } +} diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index 4446b9a..9e95a82 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -15,7 +15,6 @@ import '../app/net_guard.dart'; import '../app/tor_service.dart'; import '../app/web_proxy.dart'; import '../core/bookmarks.dart'; -import '../core/local_proxy.dart' show ProxyAuth; import '../core/download_detect.dart' show CookieExport, CookieRecord, internalBrowserCookies, isLoginCookieDomain, toNetscapeCookies; import '../core/web_address.dart'; import '../core/web_translate.dart'; @@ -23,9 +22,11 @@ import '../app/i18n_controller.dart' show I18nController; import '../core/youtube_ads.dart'; import '../platform/windows/cef_runtime.dart'; import '../platform/windows/com_guard.dart'; +import '../platform/windows/elevation.dart'; import '../services/downloader.dart' show DownloadState; import 'app_actions.dart'; import 'bookmark_ui.dart'; +import 'cef_setup.dart' show confirmCefWhenElevated; import 'confirm.dart'; import 'downloads_page.dart'; import 'settings_page.dart'; @@ -120,6 +121,10 @@ class BrowserPage extends StatefulWidget { @visibleForTesting static WebNav? debugNav; + /// 207: 내장 Chrome 엔진이 이 시간 안에 준비되지 않으면 빙글이만 돌게 두지 않고 "시작하지 못했습니다" 와 고를 것을 보인다 + @visibleForTesting + static Duration cefStartTimeout = const Duration(seconds: 20); + /// 앱 안 웹뷰를 화면이 닫혀도 살려 둔다 (다시 열면 보던 페이지 · 재생 위치 · 뒤로 가기 기록 그대로) static final _keepAlive = InAppWebViewKeepAlive(); static bool _keepAliveUsed = false; @@ -144,6 +149,9 @@ class BrowserPage extends StatefulWidget { } class _BrowserPageState extends State with RouteAware { + /// 207: Chrome 엔진 [다시 시도] 때마다 새로 + int _cefTry = 0; + late String _url = widget.initialUrl ?? BrowserPage._lastUrl ?? widget.c.settings.homeUrl; String _title = ''; double _progress = 1; @@ -611,13 +619,20 @@ class _BrowserPageState extends State with RouteAware { child: widget.viewBuilder?.call(_host, _url) ?? // 환경 설정에서 Chrome 을 고르고 엔진이 이번 실행에 준비되어 있으면 Chrome, 아니면 Edge (widget.c.settings.browserEngine == 'chrome' && CefRuntime.readyThisRun + // 207: 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않는다 (보안) - 이유와 고를 것 + ? Elevation.elevated && !widget.c.settings.cefWhenElevated + ? _CefElevatedBlocked(c: widget.c) // Chrome 엔진은 JavaScript 설정을 만든 뒤에 바꿀 수 없어 바뀌면 새로 만든다 - ? _CefView( - key: ValueKey(widget.c.settings.webJavaScript), + : _CefView( + key: ValueKey('${widget.c.settings.webJavaScript}|$_cefTry'), host: _host, initialUrl: _url, dataDir: widget.c.settings.webViewDataDir, - javaScript: widget.c.settings.webJavaScript) + javaScript: widget.c.settings.webJavaScript, + // 207: 시작하지 못하면 사용자가 바로 고를 수 있게 + onUseEdge: () => widget.c.updateSettings((x) => x.browserEngine = 'edge'), + onRetry: () => setState(() => _cefTry++), + cefWhenElevated: widget.c.settings.cefWhenElevated) : _EdgeView( host: _host, initialUrl: _url, @@ -1119,8 +1134,19 @@ class _CefView extends StatefulWidget { final String initialUrl; final String dataDir; final bool javaScript; + final VoidCallback? onUseEdge; + final VoidCallback? onRetry; + final bool cefWhenElevated; const _CefView( - {super.key, required this.host, required this.initialUrl, required this.dataDir, required this.javaScript}); + {super.key, + required this.host, + required this.initialUrl, + required this.dataDir, + required this.javaScript, + this.onUseEdge, + this.onRetry, + this.cefWhenElevated = false}); + @override State<_CefView> createState() => _CefViewState(); @@ -1129,28 +1155,93 @@ class _CefView extends StatefulWidget { /// Chrome 엔진 시작은 프로그램이 켜져 있는 동안 한 번만 Future? _cefStarted; +/// Chrome 엔진을 시작할 때 준 프록시 주소 (문을 다시 열 때 같아야 한다) +String? _cefProxy; + +/// 207: 관리자 권한으로 실행 중이라 Chrome 엔진을 시작하지 않음 - 이유와 [Edge 엔진으로 바꾸기] [그래도 Chrome 엔진 쓰기…] +class _CefElevatedBlocked extends StatelessWidget { + final AppController c; + const _CefElevatedBlocked({required this.c}); + + @override + Widget build(BuildContext context) => Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.admin_panel_settings_outlined, size: 40, color: JjColors.textDim), + const SizedBox(height: 12), + Text(tr('관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center), + const SizedBox(height: 8), + Text( + tr('내장 Chrome 엔진은 샌드박스가 꺼져 있어 관리자 권한으로 돌리면 위험합니다. ' + 'Edge 엔진을 쓰거나, 관리자 권한 없이 프로그램을 실행하세요.'), + style: const TextStyle(color: JjColors.textDim), + textAlign: TextAlign.center), + const SizedBox(height: 16), + Wrap(spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ + FilledButton( + onPressed: () => c.updateSettings((x) => x.browserEngine = 'edge'), + child: Text(tr('Edge 엔진으로 바꾸기')), + ), + OutlinedButton( + onPressed: () => confirmCefWhenElevated(context, c), + child: Text(tr('그래도 Chrome 엔진 쓰기…')), + ), + ]), + ]), + ), + ), + ); +} + class _CefViewState extends State<_CefView> { late final cef.WebViewController _c = cef.WebviewManager().createWebView( loading: const Center(child: CircularProgressIndicator())); Object? _error; + /// 207: 시작이 끝나지 않을 때 + Timer? _startTimer; + bool _timedOut = false; + + /// 웹 뷰 만들기를 시작했는지 (엔진 시작을 기다리는 중에 닫으면 webview_cef 의 dispose 가 LateInitializationError 를 낸다) + bool _initCalled = false; + + /// Chrome 엔진 문을 열었는지 (닫을 때 짝을 맞추려고) + bool _gateOpened = false; + @override void initState() { super.initState(); + _startTimer = Timer(BrowserPage.cefStartTimeout, () { + if (!mounted || _c.value) return; + setState(() { + _timedOut = true; + _error = trf('{0}초 안에 준비되지 않았습니다.', [BrowserPage.cefStartTimeout.inSeconds]); + }); + }); _start(); } Future _start() async { final h = widget.host; try { + // DNS 강제: Chrome 엔진 문 (인증 없음, 숨긴 루프백 주소, 이 웹 뷰가 있는 동안만 - 207). 엔진이 시작할 때 정한 주소와 같아야 한다 + _gateOpened = true; + final gate = await NetGuard.instance.openCefGate(); + if (_cefStarted != null && gate != _cefProxy) { + throw StateError(tr('Chrome 엔진의 연결 통로를 다시 열 수 없습니다. 프로그램을 다시 시작하세요.')); + } // 로그인 · 쿠키를 Edge 와 따로 보관 (데이터 폴더 아래 cef) // DNS 강제: Chrome 엔진도 앱 안 프록시로 (packages/webview_cef/JJ_PATCH.md 6 - 시작할 때 정해짐) + _cefProxy ??= gate; _cefStarted ??= cef.WebviewManager().initialize( rootCachePath: widget.dataDir.isEmpty ? null : p.join(p.dirname(widget.dataDir), 'cef'), - proxyServer: NetGuard.instance.browserProxy, - // 앱 안 프록시의 인증 (JJ_PATCH.md 7 - 이 프록시에만 답함) - proxyUser: ProxyAuth.instance.user, - proxyPassword: ProxyAuth.instance.password); + proxyServer: gate, + // 207: 관리자 권한이면 Chromium 이 스스로 끝나므로, 사용자가 설정에서 고른 때만 그대로 돈다 (JJ_PATCH.md 7) + doNotDeElevate: Elevation.elevated && widget.cefWhenElevated); await _cefStarted; _c.setWebviewListener(cef.WebviewEventsListener( onUrlChanged: (u) { @@ -1176,7 +1267,10 @@ class _CefViewState extends State<_CefView> { } }, )); + _initCalled = true; await _c.initialize(normalizeAddress(widget.initialUrl), javaScript: widget.javaScript); + if (_timedOut) return; + _startTimer?.cancel(); h.attach(_CefNav(_c, widget.dataDir)); h.onHistory(true, true); // Chrome 엔진은 뒤로 · 앞으로 가능 여부를 알려 주지 않는다 if (mounted) setState(() {}); @@ -1195,16 +1289,37 @@ class _CefViewState extends State<_CefView> { @override void dispose() { - _c.dispose(); + _startTimer?.cancel(); + if (_initCalled) unawaited(_c.dispose().catchError((Object _) {})); + if (_gateOpened) unawaited(NetGuard.instance.closeCefGate()); super.dispose(); } @override Widget build(BuildContext context) { if (_error != null) { + // 207: 빙글이만 돌게 두지 않는다 - 이유와 바로 고를 것 return Center( - child: Text(trf('Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}', [_error]), - textAlign: TextAlign.center), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.error_outline, size: 40, color: JjColors.textDim), + const SizedBox(height: 12), + Text(tr('Chrome 엔진을 시작하지 못했습니다'), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center), + const SizedBox(height: 8), + Text('$_error', style: const TextStyle(color: JjColors.textDim), textAlign: TextAlign.center), + const SizedBox(height: 16), + Wrap(spacing: 12, runSpacing: 8, alignment: WrapAlignment.center, children: [ + if (widget.onUseEdge != null) + FilledButton(onPressed: widget.onUseEdge, child: Text(tr('Edge 엔진으로 바꾸기'))), + if (widget.onRetry != null) OutlinedButton(onPressed: widget.onRetry, child: Text(tr('다시 시도'))), + ]), + ]), + ), + ), ); } return ValueListenableBuilder( diff --git a/lib/ui/cef_setup.dart b/lib/ui/cef_setup.dart index cf9cc86..f7eff9a 100644 --- a/lib/ui/cef_setup.dart +++ b/lib/ui/cef_setup.dart @@ -120,3 +120,34 @@ class _InstallProgressState extends State<_InstallProgress> { ], ); } + +/// 207: 관리자 권한으로 실행 중에 내장 Chrome 엔진을 쓰겠다고 할 때 - 위험을 알리고 확인을 받은 뒤에만 설정을 켠다 +Future confirmCefWhenElevated(BuildContext context, AppController c) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + scrollable: true, + title: Text(tr('관리자 권한일 때도 Chrome 엔진 쓰기')), + content: SizedBox( + width: 480, + child: Text(tr('이 프로그램이 관리자 권한으로 실행 중입니다. 권장하지 않습니다.\n\n' + '· 내장 Chrome 엔진은 샌드박스 (웹 페이지를 가두는 보호) 가 꺼져 있어, 웹 페이지가 관리자 권한 프로세스 안에서 돕니다. ' + '페이지의 취약점이 곧 이 PC 의 관리자 권한이 될 수 있습니다.\n' + '· Edge 엔진은 이 영향이 없습니다.\n' + '· 관리자 권한 없이 프로그램을 실행하면 이 설정 없이도 Chrome 엔진을 쓸 수 있습니다.')), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: JjColors.danger), + onPressed: () => Navigator.pop(ctx, true), + child: Text(tr('그래도 쓰기')), + ), + ], + ), + ); + if (ok != true) return false; + await c.updateSettings((x) => x.cefWhenElevated = true); + c.note(tr('관리자 권한일 때도 Chrome 엔진 쓰기를 켰습니다 (권장하지 않음).')); + return true; +} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 93a2965..93434d2 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -16,6 +16,7 @@ import 'setup_dialog.dart'; import 'update_dialog.dart'; import '../app/settings.dart'; import '../platform/windows/cef_runtime.dart'; +import '../platform/windows/elevation.dart'; import '../platform/windows/desktop_shell.dart' show DesktopShell; import '../platform/windows/start_menu.dart'; import '../platform/android/android_shell.dart' show applyScreenOrientation; @@ -659,6 +660,22 @@ class _SettingsPageState extends State { ), ]), ), + // 207: 관리자 권한일 때도 Chrome 엔진 쓰기 (처음 꺼짐, 켤 때 경고) + SwitchListTile( + value: s.cefWhenElevated, + onChanged: (v) async { + if (v) { + await confirmCefWhenElevated(context, c); + } else { + await c.updateSettings((x) => x.cefWhenElevated = false); + } + if (mounted) setState(() {}); + }, + title: Text(tr('관리자 권한일 때도 Chrome 엔진 쓰기 (권장하지 않음)')), + subtitle: Text(Elevation.elevated + ? tr('지금 관리자 권한으로 실행 중입니다. 끄면 (기본) Chrome 엔진을 시작하지 않고 Edge 를 권합니다. 켜면 샌드박스 없이 관리자 권한으로 웹 페이지를 돌립니다.') + : tr('관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.')), + ), SettingTile( title: Text(tr('외부 브라우저')), subtitle: Text(tr('"외부 브라우저로 열기" 에 쓸 브라우저 (이 PC 에 설치된 것)')), diff --git a/packages/webview_cef/JJ_PATCH.md b/packages/webview_cef/JJ_PATCH.md index 4f36045..c9a69f5 100644 --- a/packages/webview_cef/JJ_PATCH.md +++ b/packages/webview_cef/JJ_PATCH.md @@ -29,8 +29,10 @@ CEF 실행 파일은 앱이 공식 배포처 (https://cef-builds.spotifycdn.com) 6. `common/webview_plugin.cc` · `common/webview_app.*` · `lib/src/webview_manager.dart`: 프록시 (DNS 강제). `initialize(proxyServer: ...)` 를 주면 브라우저 프로세스에 `--proxy-server` 와 `--dns-prefetch-disable` 을 붙인다 (CEF 를 시작할 때 한 번). 앱은 환경 설정 > 네트워크 · DNS 가 강제일 때 앱 안 프록시 주소를 준다. + Chrome 엔진은 프록시 인증 (407) 에 답하지 않으므로 (CEF 149 windowless 에서 GetAuthCredentials 가 불리지 않음, 10/11 확인) + 앱은 Chrome 엔진에만 인증 없는 문을 준다: 실행마다 고른 루프백 주소 127.x.y.z 의 빈 포트, Chrome 엔진 웹 뷰가 있는 동안만 연다. -7. `common/webview_handler.*` · `common/webview_plugin.cc` · `lib/src/webview_manager.dart`: 앱 안 프록시 인증 (10/11). - 앱 안 프록시는 같은 기기의 다른 앱이 쓰지 못하게 이번 실행의 비밀값으로 Basic 인증을 묻는다 (407, realm `JJ_MKVMaker`). - `initialize(proxyUser:, proxyPassword:)` 를 받아 `CefRequestHandler::GetAuthCredentials` 에서 **프록시 (isProxy) 이고 127.0.0.1 · realm JJ_MKVMaker 일 때만** 답한다. - 웹 사이트의 인증 요청은 원래대로 둔다. +7. `common/webview_app.*` · `common/webview_plugin.cc` · `lib/src/webview_manager.dart`: 관리자 권한 실행 (10/11, 207). + Chromium 은 관리자 권한 (높은 무결성) 으로 실행되면 권한을 낮춰 다시 띄우려고 스스로 끝난다 (CefInitialize 가 false, 종료 코드 38 + CEF_RESULT_CODE_NORMAL_EXIT_AUTO_DE_ELEVATED). 앱은 관리자 권한이면 처음 값으로 Chrome 엔진을 시작하지 않고 (보안: CEF 는 샌드박스가 꺼져 있다), + 사용자가 환경 설정에서 "관리자 권한일 때도 Chrome 엔진 쓰기" 를 켠 때만 `initialize(doNotDeElevate: true)` 로 `--do-not-de-elevate` 를 붙인다. diff --git a/packages/webview_cef/common/webview_app.cc b/packages/webview_cef/common/webview_app.cc index d3cdfcc..737c166 100644 --- a/packages/webview_cef/common/webview_app.cc +++ b/packages/webview_cef/common/webview_app.cc @@ -157,6 +157,12 @@ void WebviewApp::OnBeforeCommandLineProcessing(const CefString &process_type, Ce command_line->AppendSwitchWithValue("proxy-server", m_strProxyServer); command_line->AppendSwitch("dns-prefetch-disable"); } + // JJ_MKVMaker (JJ_PATCH.md 7): 관리자 권한으로 실행 중이면 Chromium 은 권한을 낮춰 다시 띄우려고 끝난다 + // (CefInitialize false, 종료 코드 38). 사용자가 설정에서 고른 때만 그대로 돈다 + if (m_bDoNotDeElevate) + { + command_line->AppendSwitch("do-not-de-elevate"); + } // for unsafe domain, add domain to whitelist if (!m_strFilterDomain.empty()) { diff --git a/packages/webview_cef/common/webview_app.h b/packages/webview_cef/common/webview_app.h index fc4f5f7..446de25 100644 --- a/packages/webview_cef/common/webview_app.h +++ b/packages/webview_cef/common/webview_app.h @@ -45,6 +45,8 @@ public: void SetUnSafelyTreatInsecureOriginAsSecure(const CefString& strFilterDomain); // JJ_MKVMaker: 모든 연결을 이 프록시로 (DNS 강제, JJ_PATCH.md 6). 빈 값이면 없음 void SetProxyServer(const std::string& proxy) { m_strProxyServer = proxy; } + // JJ_MKVMaker: 관리자 권한으로 실행 중이어도 권한을 낮춰 다시 띄우지 않는다 (사용자가 설정에서 고른 때만, JJ_PATCH.md 7) + void SetDoNotDeElevate(bool v) { m_bDoNotDeElevate = v; } // CefRenderProcessHandler methods. void OnWebKitInitialized() override; @@ -80,6 +82,7 @@ private: uint32_t m_uMode = 1; //process mode bool m_bEnableGPU = false; //enable gpu std::string m_strProxyServer; + bool m_bDoNotDeElevate = false; CefString m_strFilterDomain; //insecure domain whitelist CefRefPtr m_handler; //webview handler for main process diff --git a/packages/webview_cef/common/webview_handler.cc b/packages/webview_cef/common/webview_handler.cc index b7990b9..335d89d 100644 --- a/packages/webview_cef/common/webview_handler.cc +++ b/packages/webview_cef/common/webview_handler.cc @@ -49,28 +49,6 @@ std::string GetDataURI(const std::string& data, const std::string& mime_type) { } // namespace -// JJ_MKVMaker (JJ_PATCH.md 7) -namespace webview_cef_jj { - std::string proxyUser; - std::string proxyPassword; -} - -bool JjProxyAuthHandler::GetAuthCredentials(CefRefPtr browser, - const CefString& origin_url, - bool isProxy, - const CefString& host, - int port, - const CefString& realm, - const CefString& scheme, - CefRefPtr callback) { - // 앱 안 프록시 (127.0.0.1, realm JJ_MKVMaker) 가 묻는 것만. 웹 사이트의 인증은 원래대로 (묻지 않고 취소) - if (isProxy && host.ToString() == "127.0.0.1" && realm.ToString() == "JJ_MKVMaker" && !webview_cef_jj::proxyUser.empty()) { - callback->Continue(webview_cef_jj::proxyUser, webview_cef_jj::proxyPassword); - return true; - } - return false; -} - WebviewHandler::WebviewHandler() { } diff --git a/packages/webview_cef/common/webview_handler.h b/packages/webview_cef/common/webview_handler.h index c0b54f4..7918481 100644 --- a/packages/webview_cef/common/webview_handler.h +++ b/packages/webview_cef/common/webview_handler.h @@ -13,28 +13,6 @@ #include "webview_cookieVisitor.h" -// JJ_MKVMaker (JJ_PATCH.md 7): 앱 안 프록시 인증 (이번 실행의 비밀값, 저장하지 않음) -namespace webview_cef_jj { - extern std::string proxyUser; - extern std::string proxyPassword; -} - -// JJ_MKVMaker (JJ_PATCH.md 7): 앱 안 프록시의 인증에만 답하는 요청 처리기 -// (WebviewHandler 가 CefLifeSpanHandler 와 함께 이어받으면 WindowOpenDisposition 이 모호해져 따로 둔다) -class JjProxyAuthHandler : public CefRequestHandler { -public: - virtual bool GetAuthCredentials(CefRefPtr browser, - const CefString& origin_url, - bool isProxy, - const CefString& host, - int port, - const CefString& realm, - const CefString& scheme, - CefRefPtr callback) override; -private: - IMPLEMENT_REFCOUNTING(JjProxyAuthHandler); -}; - #define ColorUNDERLINE \ 0xFF000000 // Black SkColor value for underline, // same as Blink. @@ -98,12 +76,6 @@ public: return this; } virtual CefRefPtr GetLoadHandler() override { return this; } - // JJ_MKVMaker (JJ_PATCH.md 7): 앱 안 프록시의 인증에만 답한다 - virtual CefRefPtr GetRequestHandler() override { - if (!jj_request_handler_) jj_request_handler_ = new JjProxyAuthHandler(); - return jj_request_handler_; - } - CefRefPtr jj_request_handler_; virtual CefRefPtr GetRenderHandler() override { return this; } bool OnProcessMessageReceived( diff --git a/packages/webview_cef/common/webview_plugin.cc b/packages/webview_cef/common/webview_plugin.cc index 6b2e253..26fcf6b 100644 --- a/packages/webview_cef/common/webview_plugin.cc +++ b/packages/webview_cef/common/webview_plugin.cc @@ -20,6 +20,8 @@ namespace webview_cef { CefString rootCachePath; // JJ_MKVMaker: 프록시 (JJ_PATCH.md 6) CefString proxyServer; + // JJ_MKVMaker: 관리자 권한이어도 그대로 (JJ_PATCH.md 7) + bool doNotDeElevate = false; bool isCefInitialized = false; #ifdef OS_MAC std::string g_macSubprocessPath; @@ -288,12 +290,10 @@ namespace webview_cef { if(proxy != nullptr && webview_value_get_type(proxy) == Webview_Value_Type_String){ proxyServer = CefString(webview_value_get_string(proxy)); } - // JJ_MKVMaker (JJ_PATCH.md 7): 프록시 인증 - WValue* pu = webview_value_get_by_string(values, "proxyUser"); - WValue* pp = webview_value_get_by_string(values, "proxyPassword"); - if(pu != nullptr && pp != nullptr && webview_value_get_type(pu) == Webview_Value_Type_String && webview_value_get_type(pp) == Webview_Value_Type_String){ - webview_cef_jj::proxyUser = webview_value_get_string(pu); - webview_cef_jj::proxyPassword = webview_value_get_string(pp); + // JJ_MKVMaker (JJ_PATCH.md 7): 관리자 권한이어도 그대로 + WValue* dne = webview_value_get_by_string(values, "doNotDeElevate"); + if(dne != nullptr && webview_value_get_type(dne) == Webview_Value_Type_String){ + doNotDeElevate = std::string(webview_value_get_string(dne)) == "1"; } } else{ @@ -800,6 +800,7 @@ namespace webview_cef { if(!proxyServer.empty()){ app->SetProxyServer(proxyServer.ToString()); } + app->SetDoNotDeElevate(doNotDeElevate); CefInitialize(mainArgs, cefs, app.get(), nullptr); } diff --git a/packages/webview_cef/lib/src/webview_manager.dart b/packages/webview_cef/lib/src/webview_manager.dart index 375259c..9353be5 100644 --- a/packages/webview_cef/lib/src/webview_manager.dart +++ b/packages/webview_cef/lib/src/webview_manager.dart @@ -64,16 +64,15 @@ class WebviewManager extends ValueNotifier { /// relative one is resolved against the working directory of the process. /// /// [proxyServer] (JJ_MKVMaker, JJ_PATCH.md 6): every connection goes through this proxy (e.g. `http://127.0.0.1:1234`). - /// [proxyUser] · [proxyPassword] (JJ_PATCH.md 7): answer that proxy's Basic auth (127.0.0.1, realm JJ_MKVMaker) only. - Future initialize( - {String? userAgent, String? rootCachePath, String? proxyServer, String? proxyUser, String? proxyPassword}) async { + /// [doNotDeElevate] (JJ_MKVMaker, JJ_PATCH.md 7): keep running when the process is elevated (Chromium otherwise relaunches de-elevated and exits). + Future initialize({String? userAgent, String? rootCachePath, String? proxyServer, bool doNotDeElevate = false}) async { _creatingCompleter = Completer(); try { final Map settings = { if (userAgent != null && userAgent.isNotEmpty) 'userAgent': userAgent, if (rootCachePath != null && rootCachePath.isNotEmpty) 'rootCachePath': rootCachePath, if (proxyServer != null && proxyServer.isNotEmpty) 'proxyServer': proxyServer, - if (proxyUser != null && proxyPassword != null) ...{'proxyUser': proxyUser, 'proxyPassword': proxyPassword}, + if (doNotDeElevate) 'doNotDeElevate': '1', }; if (settings.isNotEmpty) { await pluginChannel.invokeMethod('init', settings); diff --git a/test/cef_elevation_test.dart b/test/cef_elevation_test.dart new file mode 100644 index 0000000..a044bd9 --- /dev/null +++ b/test/cef_elevation_test.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/app_controller.dart'; +import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/platform/windows/cef_runtime.dart'; +import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; +import 'package:jj_mkvmaker/platform/windows/elevation.dart'; +import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; +import 'package:jj_mkvmaker/services/platform_services.dart'; +import 'package:jj_mkvmaker/ui/app_actions.dart'; +import 'package:jj_mkvmaker/ui/browser_page.dart'; +import 'package:path/path.dart' as p; + +/// 207: 내장 Chrome 엔진 (CEF) 은 관리자 권한이면 스스로 끝난다 (종료 코드 38). 관리자 판단: 처음 값은 시작하지 않고 이유와 고를 것을 보인다. +/// 사용자가 "그래도 쓰기" 를 고르면 설정이 켜진다. 시작하지 못하면 빙글이만 돌게 두지 않는다 +void main() { + late Directory dir; + late AppController c; + late BookmarksController bm; + setUp(() { + dir = Directory.systemTemp.createTempSync('jj_cef_elev_'); + c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService())); + c.settings + ..browserEngine = 'chrome' + ..webViewDataDir = p.join(dir.path, 'webview'); + bm = BookmarksController(p.join(dir.path, 'bookmarks.json')); + CefRuntime.readyThisRun = true; + }); + tearDown(() { + Elevation.debugElevated = null; + CefRuntime.readyThisRun = false; + dir.deleteSync(recursive: true); + }); + + Future open(WidgetTester t) async { + t.view.physicalSize = const Size(1400, 900); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + await t.pumpWidget(MaterialApp( + builder: (context, child) => AppScope(controller: c, bookmarks: bm, child: child!), + home: BrowserPage(c: c, bookmarks: bm, initialUrl: 'https://example.com/'), + )); + await t.pump(); + } + + testWidgets('관리자 권한: Chrome 엔진을 시작하지 않고 이유 · [Edge 엔진으로 바꾸기]', (t) async { + Elevation.debugElevated = true; + await open(t); + expect(find.text('관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)'), findsOneWidget); + await t.tap(find.text('Edge 엔진으로 바꾸기')); + await t.pump(); + expect(c.settings.browserEngine, 'edge'); + await t.pumpWidget(const SizedBox()); + }); + + testWidgets('관리자 권한: [그래도 Chrome 엔진 쓰기…] 는 경고를 보이고, 취소하면 그대로 · 고르면 설정이 켜짐', (t) async { + Elevation.debugElevated = true; + await open(t); + await t.tap(find.text('그래도 Chrome 엔진 쓰기…')); + await t.pumpAndSettle(); + expect(find.textContaining('샌드박스 (웹 페이지를 가두는 보호) 가 꺼져'), findsOneWidget); + await t.tap(find.text('취소')); + await t.pumpAndSettle(); + expect(c.settings.cefWhenElevated, isFalse); + await t.tap(find.text('그래도 Chrome 엔진 쓰기…')); + await t.pumpAndSettle(); + await t.tap(find.text('그래도 쓰기')); + await t.pumpAndSettle(); + expect(c.settings.cefWhenElevated, isTrue); + expect(find.text('관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)'), findsNothing); + await t.pumpWidget(const SizedBox()); + }); + + testWidgets('시작하지 못하면 빙글이만 돌지 않고 "Chrome 엔진을 시작하지 못했습니다" · [Edge 엔진으로 바꾸기] · [다시 시도]', (t) async { + Elevation.debugElevated = false; + BrowserPage.cefStartTimeout = const Duration(seconds: 2); + addTearDown(() => BrowserPage.cefStartTimeout = const Duration(seconds: 20)); + await open(t); + // 시험에는 Chrome 엔진 (플러그인) 이 없어 끝나지 않거나 실패한다 - 정한 시간 (여기선 2초) 이 지나면 안내 + expect(find.text('Chrome 엔진을 시작하지 못했습니다'), findsNothing); + await t.pump(const Duration(seconds: 3)); + expect(find.text('Chrome 엔진을 시작하지 못했습니다'), findsOneWidget); + expect(find.text('다시 시도'), findsOneWidget); + await t.tap(find.text('Edge 엔진으로 바꾸기')); + await t.pump(); + expect(c.settings.browserEngine, 'edge'); + await t.pumpWidget(const SizedBox()); + }); + + test('설정: 관리자 권한일 때 Chrome 엔진 쓰기는 처음 꺼짐 · 저장 · 틀린 값은 꺼짐', () { + expect(AppSettings().cefWhenElevated, isFalse); + expect(AppSettings.fromJson((AppSettings()..cefWhenElevated = true).toJson()).cefWhenElevated, isTrue); + expect(AppSettings.fromJson({'cefWhenElevated': 'yes'}).cefWhenElevated, isFalse); + }); +} diff --git a/test/dns_proxy_test.dart b/test/dns_proxy_test.dart index a8ea5be..1581a43 100644 --- a/test/dns_proxy_test.dart +++ b/test/dns_proxy_test.dart @@ -363,6 +363,66 @@ void main() { }); }); + test('일찍 만든 HttpClient 도 나중에 정한 DNS 를 따른다 (업데이트 확인 등이 정한 DNS 를 거치지 않던 것)', () async { + final g = NetGuard.instance; + final old = HttpOverrides.current; + HttpOverrides.global = JjHttpOverrides(); + addTearDown(() { + HttpOverrides.global = old; + g.config = () => const DnsConfig.system(); + }); + g.config = () => const DnsConfig.system(); + // 앱이 시작할 때 (설정을 읽기 전) 만들어 두는 것처럼 + final early = HttpClient(); + addTearDown(early.close); + // 설정을 읽어 DNS 강제가 되면 같은 HttpClient 도 프록시로: 닿지 않는 DNS 라 프록시가 502 로 답한다 + // (프록시를 거치지 않았다면 이 기기의 DNS 로 이름을 찾다 SocketException) + g.config = () => custom('192.0.2.1'); + await g.ensure(); + final res = await (await early.getUrl(Uri.parse('http://jj-does-not-exist.example/'))).close(); + await res.drain(); + expect(res.statusCode, 502); + expect(res.headers.value('x-jj-proxy-error'), '1'); + }); + + test('207: Chrome 엔진 문 - 웹 뷰가 있을 때만 · 인증 없음 · 숨긴 루프백 주소 · 다시 열면 같은 주소 · 닫히면 접속 불가', () async { + final g = NetGuard.instance; + g.config = () => custom('8.8.8.8', '8.8.4.4'); + addTearDown(() => g.config = () => const DnsConfig.system()); + Future probe(String url) async { + final u = Uri.parse(url); + try { + final s = await Socket.connect(u.host, u.port, timeout: const Duration(seconds: 2)); + s.write('CONNECT 127.0.0.1:1 HTTP/1.1\r\nHost: 127.0.0.1:1\r\n\r\n'); + final first = await s.cast>().transform(latin1.decoder).first.timeout(const Duration(seconds: 10)); + s.destroy(); + return first.split('\r\n').first; + } on SocketException { + return null; + } + } + + final a = await g.openCefGate(); + expect(a, isNotNull); + expect(InternetAddress(Uri.parse(a!).host).isLoopback, isTrue); + // 인증을 묻지 않는다 (Chrome 엔진은 407 에 답하지 못함) - 위로 잇지 못해 502 + expect(await probe(a), startsWith('HTTP/1.1 502')); + // 웹 뷰가 둘이면 하나가 없어져도 열려 있다 + expect(await g.openCefGate(), a); + await g.closeCefGate(); + expect(g.cefGateUrl, a); + await g.closeCefGate(); + expect(g.cefGateUrl, isNull); + expect(await probe(a), isNull, reason: '닫히면 접속 불가 (직접 나가지 않음)'); + // 다시 열면 같은 주소 · 포트 (엔진은 시작할 때 정한 주소만 쓴다) + expect(await g.openCefGate(), a); + await g.closeCefGate(); + // DNS 강제가 아니면 문 없음 + g.config = () => const DnsConfig.system(); + expect(await g.openCefGate(), isNull); + await g.closeCefGate(); + }); + test('NetGuard: 앱의 HTTP · yt-dlp · aria2 가 프록시를 쓰는 조건', () async { final g = NetGuard.instance; var cfg = custom('8.8.8.8', '8.8.4.4');