Tor 브라우저 (컴포넌트) · DNS 강제 (기본 8.8.8.8 / 8.8.4.4)

사용자 요청 (10/10): "Tor 브라우저를 현재 브라우저 옆에 추가 · 설정에서 컴포넌트처럼 추가 ·
DNS 주소를 강제로 내가 설정한 주소로, 기본 8.8.8.8" (보조 8.8.4.4 는 관리자 결정).

DNS 강제 (환경 설정 > 네트워크 · DNS: 시스템 DNS (끄기) / 직접 지정 (기본) / DNS over HTTPS)
- 앱 안 HTTP 프록시 (127.0.0.1, CONNECT + http) 가 정한 서버로 이름을 푼다 (UDP, 잘리면 TCP, DoH,
  TTL 동안 기억). 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 502 + 알림 [시스템 DNS 로 바꾸기].
- 따르는 것: 앱의 HttpClient (HttpOverrides: 업데이트 · 컴포넌트 · AI 모델 · WebDAV · 번역 · 자막),
  Edge (--proxy-server) · Chrome 엔진 (webview_cef 패치 6), Android 웹 뷰 (ProxyController),
  yt-dlp --proxy, aria2 --all-proxy (+ c-ares 가 있으면 --async-dns-server).
- IP 주소 · 같은 집 안 이름 (점 없는 이름, *.local, Tailscale *.ts.net 등) 은 기기 DNS (내 판단: NAS · WebDAV 가 그대로 열리게).

Tor 브라우저 (컴포넌트 'tor', 처음 꺼짐, 화면은 웹 브라우저 바로 뒤)
- Windows: 공식 Tor Expert Bundle 15.0.24 (tor 0.4.9.13) 을 받아 SHA-256 확인
  (e9dc6ccc…2b65) → 앱 데이터 components/tor. Android: 같은 판의 libTor.so 를 APK 에 (tool/fetch_tor_android.ps1).
- tor 를 자식 프로세스로 (SOCKS · 제어 포트는 빈 포트, 쿠키 인증, __OwningControllerProcess), Tor 화면을 닫으면 끔.
- 웹 뷰 → Tor 다리 (HTTP 프록시 → SOCKS5, 이름은 Tor 에 넘김). Tor 100% · 프록시 적용 · 전용 프로필이
  모두 된 뒤에만 웹 뷰를 만든다. WebRTC 끔, 권한 거절, 검색은 DuckDuckGo.
- Windows: 따로 WebView2 환경 (tor_webview, InPrivate). Android: 웹 뷰 프록시가 앱 전체라 Tor 화면이
  열려 있는 동안 일반 브라우저도 Tor (안내 띠, 관리자 결정), androidx.webkit 프로필 'jj_tor'
  (flutter_inappwebview_android 패치 jjProfile). 기능이 없으면 화면을 열지 않음.
- 상태 줄 (연결 중 % / 연결됨 / 실패 + 다시 시도), [새 경로] (NEWNYM), 처음 열 때 "공식 Tor Browser 아님" 안내.
- 라이선스: TOR_LICENSES.txt (Tor BSD-3 · Libevent · OpenSSL · zlib · zstd), tor 실행 파일은 GPL 빌드 → GPL-3.0.

시험: dns_proxy_test (가짜 DNS · 프록시 · SOCKS 다리), tor_test (새는 것 막기 · 번들 해시 · torrc),
integration_test/tor_dns_real_test (Windows 실제: 8.8.8.8 로 풀기, 닿지 않는 DNS 는 실패,
check.torproject.org "Congratulations").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tor_dns
RyuJongJin 2026-10-10 21:10:17 +09:00
parent f86697c634
commit 0d1ea93fd3
42 changed files with 4647 additions and 26 deletions

View File

@ -63,6 +63,8 @@ android {
sourceSets["main"].jniLibs.srcDir("../../third_party/rsync/android") sourceSets["main"].jniLibs.srcDir("../../third_party/rsync/android")
// 123 · 121: stable-diffusion.cpp 의 sd-cli (libsdcli.so 라는 이름의 실행 파일): tool/build_sdcpp_android.sh // 123 · 121: stable-diffusion.cpp 의 sd-cli (libsdcli.so 라는 이름의 실행 파일): tool/build_sdcpp_android.sh
sourceSets["main"].jniLibs.srcDir("../../third_party/sdcpp/android") sourceSets["main"].jniLibs.srcDir("../../third_party/sdcpp/android")
// Tor (libtor.so 라는 이름의 실행 파일): 공식 Tor Expert Bundle (Android aarch64) 의 libTor.so, tool/fetch_tor_android.ps1 (판 · SHA-256 고정)
sourceSets["main"].jniLibs.srcDir("../../third_party/tor/android")
// ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드) // ffmpeg · whisper 등 네이티브 라이브러리를 압축하지 않고 그대로 (설치 후 바로 로드)
packaging { packaging {
@ -92,4 +94,6 @@ dependencies {
implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1") implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1")
// 업데이트 APK 를 설치 화면에 넘기는 FileProvider // 업데이트 APK 를 설치 화면에 넘기는 FileProvider
implementation("androidx.core:core:1.16.0") implementation("androidx.core:core:1.16.0")
// Tor 화면: 웹 뷰 기능 확인 · 전용 프로필 지우기 (flutter_inappwebview_android 와 같은 판)
implementation("androidx.webkit:webkit:1.12.0")
} }

View File

@ -8,6 +8,8 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.Settings import android.provider.Settings
import androidx.webkit.ProfileStore
import androidx.webkit.WebViewFeature
import com.yausername.aria2c.Aria2c import com.yausername.aria2c.Aria2c
import com.yausername.ffmpeg.FFmpeg import com.yausername.ffmpeg.FFmpeg
import com.yausername.youtubedl_android.YoutubeDL import com.yausername.youtubedl_android.YoutubeDL
@ -136,6 +138,14 @@ class MainActivity : FlutterActivity() {
"storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path)
// 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳 // 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳
"nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir) "nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir)
// Tor 화면: 이 기기 웹 뷰가 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트를 지원하는지 (없으면 화면을 열지 않음)
"torWebSupport" -> result.success(mapOf(
"proxyOverride" to WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE),
"multiProfile" to WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE),
"documentStartScript" to WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT),
))
// Tor 화면 프로필 (쿠키 · 저장소 · 캐시) 지우기. 웹 뷰가 아직 쓰고 있으면 false (나중에 다시)
"torProfileClear" -> result.success(clearWebProfile(call.argument<String>("name") ?: "jj_tor"))
"storageVolumes" -> result.success(storageVolumes()) "storageVolumes" -> result.success(storageVolumes())
"openUrl" -> result.success(openUrl(call.argument<String>("url") ?: "")) "openUrl" -> result.success(openUrl(call.argument<String>("url") ?: ""))
"installApk" -> try { "installApk" -> try {
@ -525,6 +535,24 @@ class MainActivity : FlutterActivity() {
) )
} }
/// Tor 화면 전용 웹 뷰 프로필을 지운다 (없으면 true). 쓰는 웹 뷰가 남아 있으면 쿠키 · 저장소만 지우고 false
private fun clearWebProfile(name: String): Boolean {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return true
val store = ProfileStore.getInstance()
if (store.getProfile(name) == null) return true
return try {
store.deleteProfile(name)
} catch (e: IllegalStateException) {
try {
val profile = store.getProfile(name)
profile?.cookieManager?.removeAllCookies(null)
profile?.webStorage?.deleteAllData()
} catch (_: Exception) {
}
false
}
}
private fun hasAllFilesAccess(): Boolean = private fun hasAllFilesAccess(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager() Environment.isExternalStorageManager()

View File

@ -113,24 +113,41 @@
"API 키 또는 아이디:비밀번호 (선택)": "API key or user:password (optional)", "API 키 또는 아이디:비밀번호 (선택)": "API key or user:password (optional)",
"AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 video cannot be re-encoded on this device. Change the codec to \"Keep original\" to make the MKV. (The PC version can.)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 video cannot be re-encoded on this device. Change the codec to \"Keep original\" to make the MKV. (The PC version can.)",
"Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Since Android 11, no file app can open other apps' data folders (Android/data · Android/obb). Use export or share inside that app to get the files.", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Since Android 11, no file app can open other apps' data folders (Android/data · Android/obb). Use export or share inside that app to get the files.",
"Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView lacks a required feature ({0}). Update Android System WebView / Chrome in the Play Store and open this screen again. Because pages could open without going through Tor, this screen loads nothing.",
"Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "This folder is blocked by Android (such as Android/data), or \"All files access\" is not allowed.", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "This folder is blocked by Android (such as Android/data), or \"All files access\" is not allowed.",
"Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "rsync cannot be downloaded on Android. Specify it yourself.", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "rsync cannot be downloaded on Android. Specify it yourself.",
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android cannot install an older version over the current app. So the v{0} installer and the \"kept\" items below are saved to Download/JJ_MKVMaker, then this app is removed (Android confirmation), and you install it by tapping that APK in a file app.", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android cannot install an older version over the current app. So the v{0} installer and the \"kept\" items below are saved to Download/JJ_MKVMaker, then this app is removed (Android confirmation), and you install it by tapping that APK in a file app.",
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android cannot install a lower version over the current app. So:\n1. Save the v{0} install file and the current settings (including the video list and bookmarks) to Download/JJ_MKVMaker.\n2. Uninstall this app (Android confirmation).\n3. Tap the APK in Download/JJ_MKVMaker in a file manager to install it.\n4. Versions with this feature ask at startup whether to restore the saved settings. Older versions need to be set up again.\n* Subtitle API keys and passwords are not saved, so enter them again.", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android cannot install a lower version over the current app. So:\n1. Save the v{0} install file and the current settings (including the video list and bookmarks) to Download/JJ_MKVMaker.\n2. Uninstall this app (Android confirmation).\n3. Tap the APK in Download/JJ_MKVMaker in a file manager to install it.\n4. Versions with this feature ask at startup whether to restore the saved settings. Older versions need to be set up again.\n* Subtitle API keys and passwords are not saved, so enter them again.",
"Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Press [Install] on the Android install screen. Settings, downloaded files and AI models stay as they are.\n(The first time, you may need to turn on \"Allow from this source\")", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Press [Install] on the Android install screen. Settings, downloaded files and AI models stay as they are.\n(The first time, you may need to turn on \"Allow from this source\")",
"Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "On Android the app is removed before the older version is installed. Downloaded AI models, passwords, browser logins and more are lost (the next window shows what stays and what is lost).", "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "On Android the app is removed before the older version is installed. Downloaded AI models, passwords, browser logins and more are lost (the next window shows what stays and what is lost).",
"Android 웹 뷰의 프록시는 앱 전체에 걸려, Tor 화면이 열려 있는 동안 일반 웹 브라우저도 Tor 로 나갑니다 (일반 브라우저 위쪽에 안내). Tor 화면을 닫으면 원래대로 돌아갑니다.": "The Android web view proxy applies to the whole app, so while the Tor screen is open the normal web browser also goes out through Tor (a notice is shown at the top of the normal browser). Closing the Tor screen restores it.",
"Android 자동 백업에서 되살린 설정으로 시작합니다": "Starting with settings restored from Android Auto Backup", "Android 자동 백업에서 되살린 설정으로 시작합니다": "Starting with settings restored from Android Auto Backup",
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Restored previous settings from Android Auto Backup ({0} videos · {1} WebDAV servers)",
"Android: Tor 화면이 열려 있는 동안": "Android: while the Tor screen is open",
"BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}", "BPE 토크나이저가 아닙니다: {0}": "Not a BPE tokenizer: {0}",
"Chrome (내려받기)": "Chrome (download)", "Chrome (내려받기)": "Chrome (download)",
"Chrome (내장)": "Chrome (built-in)", "Chrome (내장)": "Chrome (built-in)",
"Chrome 에서 가져오기": "Import from Chrome", "Chrome 에서 가져오기": "Import from Chrome",
"Chrome 에서 가져옴": "Imported 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 엔진 (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 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.": "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 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "The Chrome engine can be used after restarting the program. Restart now?",
"Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Cannot download the Chrome engine (HTTP {0})", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Cannot download the Chrome engine (HTTP {0})",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Cannot start the Chrome engine. Switch to Edge in Settings.\n{0}", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Cannot start the Chrome engine. Switch to Edge in Settings.\n{0}",
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS-forcing proxy started: 127.0.0.1:{0} ({1})",
"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}) 에 닿지 않아 주소를 찾지 못했습니다: {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}",
"DNS 실패 ({0}): {1}": "DNS failure ({0}): {1}",
"DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "View DOC · DOCX · HWP · XLS · XLSX converted to PDF (downloads a converter)", "DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "View DOC · DOCX · HWP · XLS · XLSX converted to PDF (downloads a converter)",
"DoH 서버 ({0}) 가 HTTP {1} 로 답했습니다": "The DoH server ({0}) answered HTTP {1}",
"DoH 서버 ({0}) 에 닿지 않습니다: {1}": "Cannot reach the DoH server ({0}): {1}",
"DoH 서버 이름을 찾을 서버": "Server for looking up the DoH host",
"DoH 서버 이름을 찾을 수 없습니다: {0}": "Cannot look up the DoH server name: {0}",
"DoH 주소": "DoH address",
"Download/JJ_MKVMaker 에 보관한 설정 (v{0}{1}) 도 있습니다:\n· 동영상 목록 {2}개\n· WebDAV 서버: {3}": "There are also settings kept in Download/JJ_MKVMaker (v{0}{1}):\n· {2} videos in the list\n· WebDAV servers: {3}", "Download/JJ_MKVMaker 에 보관한 설정 (v{0}{1}) 도 있습니다:\n· 동영상 목록 {2}개\n· WebDAV 서버: {3}": "There are also settings kept in Download/JJ_MKVMaker (v{0}{1}):\n· {2} videos in the list\n· WebDAV servers: {3}",
"Edge (내장)": "Edge (built-in)", "Edge (내장)": "Edge (built-in)",
"Edge 에서 가져오기": "Import from Edge", "Edge 에서 가져오기": "Import from Edge",
@ -142,6 +159,8 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "Compares with the latest version on GitHub and downloads and installs it if newer", "GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "Compares with the latest version on GitHub and downloads and installs it if newer",
"HTML 파일로 내보내기": "Export to HTML file", "HTML 파일로 내보내기": "Export to HTML file",
"HTML 파일에서 가져오기": "Import from HTML file", "HTML 파일에서 가져오기": "Import from HTML file",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "Enter an IPv4 or IPv6 address (e.g. 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "Enter an IPv4 or IPv6 address (e.g. 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - {0} downloads", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - {0} downloads",
"JJ_MKVMaker 로 자막 만들기": "Make subtitles with JJ_MKVMaker", "JJ_MKVMaker 로 자막 만들기": "Make subtitles with JJ_MKVMaker",
"JJ_MKVMaker 로 재생": "Play with JJ_MKVMaker", "JJ_MKVMaker 로 재생": "Play with JJ_MKVMaker",
@ -209,6 +228,51 @@
"Stable Diffusion v1.5": "Stable Diffusion v1.5", "Stable Diffusion v1.5": "Stable Diffusion v1.5",
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 is licensed under CreativeML OpenRAIL-M. You may use it, including commercially, but not for: breaking the law, harming or exploiting minors, false information meant to harm others, harming people with personal information, discrimination or harassment, replacing medical or legal judgment, etc. (license Attachment A). You are responsible for the images you make.", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 is licensed under CreativeML OpenRAIL-M. You may use it, including commercially, but not for: breaking the law, harming or exploiting minors, false information meant to harm others, harming people with personal information, discrimination or harassment, replacing medical or legal judgment, etc. (license Attachment A). You are responsible for the images you make.",
"TAESD (빠른 디코더)": "TAESD (fast decoder)", "TAESD (빠른 디코더)": "TAESD (fast decoder)",
"TTL 만료": "TTL expired",
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor executable - GPL-enabled build)",
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS does not accept the connection",
"Tor SOCKS 포트를 찾지 못했습니다": "Could not find the Tor SOCKS port",
"Tor 가 {0}:{1} 에 연결하지 못했습니다 ({2})": "Tor could not connect to {0}:{1} ({2})",
"Tor 가 끝났습니다 (코드 {0}) {1}": "Tor exited (code {0}) {1}",
"Tor 가 설치되어 있지 않습니다 (환경 설정 > 컴포넌트)": "Tor is not installed (Settings > Components)",
"Tor 가 아직 연결되지 않았습니다": "Tor is not connected yet",
"Tor 기록 지금 지우기": "Clear Tor data now",
"Tor 꺼짐": "Tor off",
"Tor 끄기": "Stop Tor",
"Tor 끔": "Tor stopped",
"Tor 끝남 (코드 {0}): {1}": "Tor ended (code {0}): {1}",
"Tor 내려받기 시작: {0}": "Tor download started: {0}",
"Tor 로 나가는 앱 안 웹 브라우저 (쿠키 · 기록 따로, 닫으면 지움)": "In-app web browser that goes out through Tor (separate cookies and history, cleared when closed)",
"Tor 를 받을 수 없습니다: {0}": "Cannot download Tor: {0}",
"Tor 를 시작하는 중": "Starting Tor",
"Tor 를 실행할 수 없습니다: {0}": "Cannot run Tor: {0}",
"Tor 브라우저": "Tor browser",
"Tor 브라우저 (지금 여기)": "Tor browser (you are here)",
"Tor 브라우저 안내": "About the Tor browser",
"Tor 브라우저는 페이지를 Tor 로 열어 사이트가 이 기기의 주소를 알 수 없게 합니다 (쿠키 · 기록은 따로, 닫으면 지움). 환경 설정 > 컴포넌트 에서 켜면 이 브라우저 옆에 Tor 화면이 생깁니다.": "The Tor browser opens pages through Tor so that sites cannot see this device's address (separate cookies and history, cleared when closed). Turn it on in Settings > Components and a Tor screen appears next to this browser.",
"Tor 새 경로": "Tor new circuit",
"Tor 새 경로 실패 ({0}) - 다시 시작합니다": "Tor new circuit failed ({0}) - restarting",
"Tor 설정": "Tor settings",
"Tor 설치 실패: {0}": "Tor installation failed: {0}",
"Tor 설치 완료: {0}": "Tor installed: {0}",
"Tor 시작 ({0})": "Tor started ({0})",
"Tor 에 연결하는 중": "Connecting to Tor",
"Tor 연결 시간 초과 {0}": "Tor connection timed out {0}",
"Tor 연결 실패: {0}": "Tor connection failed: {0}",
"Tor 연결 중 ({0}%) {1}": "Connecting to Tor ({0}%) {1}",
"Tor 연결됨": "Connected to Tor",
"Tor 연결됨 (SOCKS 127.0.0.1:{0})": "Connected to Tor (SOCKS 127.0.0.1:{0})",
"Tor 연결이 끝나고 웹 뷰가 Tor 로만 나가게 된 뒤에 페이지를 엽니다. 그 전에는 아무것도 읽지 않습니다.": "Pages open only after the Tor connection is complete and the web view can go out only through Tor. Nothing is loaded before that.",
"Tor 제어 명령 실패: {0}": "Tor control command failed: {0}",
"Tor 제어 포트가 없습니다": "No Tor control port",
"Tor 화면 홈 주소": "Tor screen home address",
"Tor 화면을 닫아도 쿠키 · 기록 · 캐시 남기기": "Keep cookies, history and cache after closing the Tor screen",
"Tor 화면을 준비할 수 없습니다": "Cannot prepare the Tor screen",
"Tor 화면을 준비할 수 없습니다: {0}": "Cannot prepare the Tor screen: {0}",
"Tor 화면의 JavaScript 사용": "JavaScript on the Tor screen",
"Tor 화면의 쿠키 · 기록 · 캐시를 지웠습니다.": "Cleared the Tor screen's cookies, history and cache.",
"Tor 화면이 열려 있는 동안 이 브라우저도 Tor 로 나갑니다": "While the Tor screen is open, this browser also goes out through Tor",
"Tor 화면이 열려 있어 지금은 지울 수 없습니다. 닫은 뒤 다시 하세요.": "The Tor screen is open, so it cannot be cleared now. Close it and try again.",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC is not installed. Once installed it appears in the list (videolan.org).",
"VLC 로 열기": "Open with VLC", "VLC 로 열기": "Open with VLC",
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}", "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}",
@ -268,6 +332,7 @@
"ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}", "ffmpeg 를 실행할 수 없습니다: {0}": "Cannot run ffmpeg: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg error (code {0})\n{1}",
"http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "With an http:// address, the user name, password and files are sent unencrypted. Others on the same network can see them, so switch to https:// if the server supports it.", "http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "With an http:// address, the user name, password and files are sent unencrypted. Others on the same network can see them, so switch to https:// if the server supports it.",
"https:// 로 시작하는 주소를 넣으세요": "Enter an address starting with https://",
"lsync (실시간 동기화)": "lsync (live sync)", "lsync (실시간 동기화)": "lsync (live sync)",
"lsync 로 이동": "Move to lsync", "lsync 로 이동": "Move to lsync",
"quit 시작": "quit started", "quit 시작": "quit started",
@ -299,6 +364,7 @@
"v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} cannot restore the saved settings, so you will need to set things up again.", "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} cannot restore the saved settings, so you will need to set things up again.",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "Downloaded v{0} and verified the file (SHA256).\n{1}",
"whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml (inside whisper_ggml)", "whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml (inside whisper_ggml)",
"www.google.com → {0} (답한 서버: {1})": "www.google.com → {0} (answered by: {1})",
"yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp (executable yt-dlp.exe)", "yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp (executable yt-dlp.exe)",
"yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp (components bundled in the executable)", "yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp (components bundled in the executable)",
"yt-dlp 를 새 버전으로 바꿨습니다": "Updated yt-dlp to a new version", "yt-dlp 를 새 버전으로 바꿨습니다": "Updated yt-dlp to a new version",
@ -325,6 +391,7 @@
"{0} 안의 파일": "Files in {0}", "{0} 안의 파일": "Files in {0}",
"{0} 에 보내는 중": "Sending to {0}", "{0} 에 보내는 중": "Sending to {0}",
"{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})", "{0} 에 붙여넣기 ({1}개)": "Paste into {0} ({1})",
"{0} 에 연결할 수 없습니다": "Cannot connect to {0}",
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "Cannot connect to {0}. Check that the network and NAS are on.", "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "Cannot connect to {0}. Check that the network and NAS are on.",
"{0} 에서 그리는 중 ({1}초)": "Drawing on {0} ({1} s)", "{0} 에서 그리는 중 ({1}초)": "Drawing on {0} ({1} s)",
"{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}", "{0} 에서 받을 파일을 찾지 못했습니다": "Could not find a file to download from {0}",
@ -390,10 +457,15 @@
"{0}줄": "{0} lines", "{0}줄": "{0} lines",
"{0}초": "{0} s", "{0}초": "{0} s",
"{0}초마다 원본 폴더를 살펴 대상 폴더에 맞춥니다 (앱이 켜져 있는 동안).": "Checks the source folder every {0} s and syncs the target (while the app is running).", "{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)",
"· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· Downloaded AI models (about {0}, must be downloaded again)", "· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· Downloaded AI models (about {0}, must be downloaded again)",
"· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· Background: only hides the window and keeps downloading · converting (reopen with {0} or the tray icon)\n· Quit all: stops downloads · conversions and closes the program", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· Background: only hides the window and keeps downloading · converting (reopen with {0} or the tray icon)\n· Quit all: stops downloads · conversions and closes the program",
"· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· Passwords · API keys · master and super passwords", "· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· Passwords · API keys · master and super passwords",
"· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· In-app browser logins · cookies, activity log", "· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· In-app browser logins · cookies, activity log",
"· 앱의 인터넷 연결: 새 버전 확인 · 컴포넌트 · AI 모델 받기 · WebDAV · 웹 페이지 번역 · 자막 검색": "· The app's internet connections: update checks, components, AI model downloads, WebDAV, web page translation, subtitle search",
"· 웹 브라우저 (Edge · Chrome 엔진): 끄기 ↔ 켜기는 앱을 다시 시작한 뒤, 서버 주소는 바로": "· Web browser (Edge / Chrome engine): switching off ↔ on after restarting the app, server addresses immediately",
"· 웹 브라우저: 바로 (Tor 화면이 열려 있는 동안은 Tor)": "· Web browser: immediately (Tor while the Tor screen is open)",
"… 외 {0}개": "… and {0} more", "… 외 {0}개": "… and {0} more",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ If another job is running, this is queued and done in order. You can use the browser while translating.",
"※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ This takes a long time (1 hour of video ≈ 15–30 min speech recognition + 10–20 min translation per language)", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ This takes a long time (1 hour of video ≈ 15–30 min speech recognition + 10–20 min translation per language)",
@ -479,6 +551,7 @@
"권한 없음": "no permission", "권한 없음": "no permission",
"권한 허용 화면 열기": "Open the permission screen", "권한 허용 화면 열기": "Open the permission screen",
"권한이 없습니다": "No permission", "권한이 없습니다": "No permission",
"규칙상 허용되지 않음": "not allowed by ruleset",
"그 파일 하나만": "Only that file", "그 파일 하나만": "Only that file",
"그대로 두기": "Keep", "그대로 두기": "Keep",
"그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})", "그래도 맞추기 ({0}개 지움)": "Sync anyway (delete {0})",
@ -507,11 +580,13 @@
"금": "Fri", "금": "Fri",
"기계 번역": "Machine translation", "기계 번역": "Machine translation",
"기기": "device", "기기": "device",
"기기 DNS": "Device DNS",
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)", "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
"기능 버튼 줄": "Button bar", "기능 버튼 줄": "Button bar",
"기본": "Default", "기본": "Default",
"기본 DNS": "Primary DNS",
"기본 앱으로 열기": "Open with default app", "기본 앱으로 열기": "Open with default app",
"기본 연결 프로그램": "Default app", "기본 연결 프로그램": "Default app",
"기본 자막 언어": "Default subtitle language", "기본 자막 언어": "Default subtitle language",
@ -529,6 +604,8 @@
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "Long press · right click always opens the action menu (copy · move · delete · rename …). Ctrl + click: select several",
"길이 {0}": "Length {0}", "길이 {0}": "Length {0}",
"끄기": "Off", "끄기": "Off",
"끄면 (기본) Tor 화면을 닫을 때 지웁니다. 일반 브라우저와는 늘 따로입니다.": "When off (default), they are cleared when the Tor screen closes. They are always kept separate from the normal browser.",
"끄면 더 안전하지만 많은 사이트가 보이지 않습니다 (다음에 열 때부터).": "Off is safer, but many sites will not display (from the next time it opens).",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "When off, starts right away with the last settings: source {0}, languages {1}, {2}", "끄면 마지막 설정으로 바로 시작: 원어 {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 처럼 스크립트로 만든 사이트는 보이지 않음). 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.", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 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.",
@ -564,6 +641,7 @@
"내려받는 중": "Downloading", "내려받는 중": "Downloading",
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)", "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "Interrupted model downloads (.part) · leftovers from conversion (.tmp)",
"내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine", "내려받은 Chrome 엔진 지우기": "Delete the downloaded Chrome engine",
"내려받은 Tor 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "Deletes the downloaded Tor files. Turning it on again downloads them again.",
"내려받은 rsync 지우기": "Delete downloaded rsync", "내려받은 rsync 지우기": "Delete downloaded rsync",
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "Deletes the downloaded converter files. Turning it on again downloads them again.", "내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "Deletes the downloaded converter files. Turning it on again downloads them again.",
"내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "Deleted the exported cookie file. To also remove the browser's logins, tap [Clear cookies and history] in the web browser screen.", "내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "Deleted the exported cookie file. To also remove the browser's logins, tap [Clear cookies and history] in the web browser screen.",
@ -587,6 +665,7 @@
"넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right", "넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right",
"넣지 못했습니다: {0}": "Could not add: {0}", "넣지 못했습니다: {0}": "Could not add: {0}",
"네덜란드어": "Dutch", "네덜란드어": "Dutch",
"네트워크 · DNS": "Network · DNS",
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "For a network share, include the shared folder name (e.g. \\\\NAS\\Videos).", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "For a network share, include the shared folder name (e.g. \\\\NAS\\Videos).",
"네팔어": "Nepali", "네팔어": "Nepali",
"노랑": "Yellow", "노랑": "Yellow",
@ -667,6 +746,7 @@
"단축키 해제": "Unregister hotkey", "단축키 해제": "Unregister hotkey",
"닫기": "Close", "닫기": "Close",
"닫힘 (실행 안 함)": "Closed (not run)", "닫힘 (실행 안 함)": "Closed (not run)",
"답이 없습니다": "No answer",
"대기": "Waiting", "대기": "Waiting",
"대기 {0}개:": "{0} waiting:", "대기 {0}개:": "{0} waiting:",
"대기 · 다음 시작 {0}": "Waiting · next start {0}", "대기 · 다음 시작 {0}": "Waiting · next start {0}",
@ -753,6 +833,7 @@
"등록": "Register", "등록": "Register",
"등록 중 일부가 실패했습니다.": "Some registrations failed.", "등록 중 일부가 실패했습니다.": "Some registrations failed.",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "Registered: \"Play with JJ_MKVMaker\" · \"Make subtitles with JJ_MKVMaker\" (Windows 11: Show more options)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "Not covered: the Tor screen (names are looked up inside Tor), IP addresses, home-network names (NAS / PC names without a dot, *.local, Tailscale *.ts.net etc. use the device DNS)",
"라오어": "Lao", "라오어": "Lao",
"라이선스 원문 보기": "View full license text", "라이선스 원문 보기": "View full license text",
"러시아어": "Russian", "러시아어": "Russian",
@ -792,6 +873,7 @@
"만화 · 그림용은 가볍고 빠르며, 사진용은 사진의 질감을 더 잘 살리지만 그래픽카드가 없으면 느립니다.": "Comics · art is light and fast. Photos keeps photo texture better but is slow without a graphics card.", "만화 · 그림용은 가볍고 빠르며, 사진용은 사진의 질감을 더 잘 살리지만 그래픽카드가 없으면 느립니다.": "Comics · art is light and fast. Photos keeps photo texture better but is slow without a graphics card.",
"만화 보기": "Read as comic", "만화 보기": "Read as comic",
"말레이어": "Malay", "말레이어": "Malay",
"망에 닿을 수 없음": "network unreachable",
"맞추기 (검은 여백)": "Fit (black bars)", "맞추기 (검은 여백)": "Fit (black bars)",
"맞추기 (비율이 다를 때)": "Conform (when the aspect differs)", "맞추기 (비율이 다를 때)": "Conform (when the aspect differs)",
"맞추기 (흐린 배경)": "Fit (blurred background)", "맞추기 (흐린 배경)": "Fit (blurred background)",
@ -891,6 +973,7 @@
"받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "Temporary files left from installing updates · required programs · the Chrome engine", "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "Temporary files left from installing updates · required programs · the Chrome engine",
"받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "Translate received subtitles to Korean and add them too (when there are no Korean subtitles)", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "Translate received subtitles to Korean and add them too (when there are no Korean subtitles)",
"받은 파일에 libcef.dll 이 없습니다.": "The downloaded file has no libcef.dll.", "받은 파일에 libcef.dll 이 없습니다.": "The downloaded file has no libcef.dll.",
"받은 파일에 tor.exe 가 없습니다": "tor.exe is missing from the downloaded file",
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "The downloaded file is corrupt or a different file (SHA256 mismatch). Installation was stopped.",
"받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "The downloaded file is damaged (SHA-256 mismatch): {0}", "받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "The downloaded file is damaged (SHA-256 mismatch): {0}",
"받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}", "받을 수 없습니다 ({0}): {1}": "Can't download ({0}): {1}",
@ -938,11 +1021,13 @@
"보기": "View", "보기": "View",
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom", "보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page",
"보내지 못했습니다": "Could not send",
"보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}", "보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "The folder you were viewing is gone, so moved to its parent folder", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "The folder you were viewing is gone, so moved to its parent folder",
"보안": "Security", "보안": "Security",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "Choose the buttons to show and drag to reorder.",
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "Adjustments require re-encoding, so the codec was changed from \"Keep original\" to H.264.",
"보조 DNS (비워 둘 수 있음)": "Secondary DNS (may be empty)",
"복사": "Copy", "복사": "Copy",
"복사 (붙여넣기로)": "Copy (to paste)", "복사 (붙여넣기로)": "Copy (to paste)",
"복사 · rsync": "Copy · rsync", "복사 · rsync": "Copy · rsync",
@ -1015,6 +1100,9 @@
"삭제: {0}": "Deleted: {0}", "삭제: {0}": "Deleted: {0}",
"상위 폴더": "Parent folder", "상위 폴더": "Parent folder",
"상하 반전": "Flip vertically", "상하 반전": "Flip vertically",
"새 Tor 경로로 바꿨습니다. 페이지를 다시 읽습니다.": "Switched to a new Tor circuit. Reloading the page.",
"새 경로": "New circuit",
"새 경로: 앞으로의 연결을 새 Tor 회로로 (사이트가 다른 사람으로 봄)": "New circuit: future connections use a new Tor circuit (sites see you as someone else)",
"새 마스터 비밀번호 정하기": "Set a new master password", "새 마스터 비밀번호 정하기": "Set a new master password",
"새 버전 v{0}": "New version v{0}", "새 버전 v{0}": "New version v{0}",
"새 버전 v{0} 이 있습니다": "New version v{0} is available", "새 버전 v{0} 이 있습니다": "New version v{0} is available",
@ -1045,6 +1133,7 @@
"서버가 너무 오래 답하지 않습니다": "The server isn't responding for too long", "서버가 너무 오래 답하지 않습니다": "The server isn't responding for too long",
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "The server asks to move to another address ({0}). Check the server address.", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "The server asks to move to another address ({0}). Check the server address.",
"서버가 이 기능을 허용하지 않음": "the server does not allow this", "서버가 이 기능을 허용하지 않음": "the server does not allow this",
"서버에 닿을 수 없음": "host unreachable",
"서버에 닿지 않습니다": "Cannot reach the server", "서버에 닿지 않습니다": "Cannot reach the server",
"서버에서 이 계정의 권한을 확인하세요.": "Check this account's permissions on the server.", "서버에서 이 계정의 권한을 확인하세요.": "Check this account's permissions on the server.",
"서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "You need to be on the same network as the server or have a VPN (e.g. Tailscale) on. Turn it on, then tap [Try again].", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "You need to be on the same network as the server or have a VPN (e.g. Tailscale) on. Turn it on, then tap [Try again].",
@ -1088,6 +1177,7 @@
"설치 화면을 열 수 없습니다: {0}": "Cannot open the install screen: {0}", "설치 화면을 열 수 없습니다: {0}": "Cannot open the install screen: {0}",
"설치가 끝나면 프로그램이 다시 시작됩니다.": "The program restarts when the installation finishes.", "설치가 끝나면 프로그램이 다시 시작됩니다.": "The program restarts when the installation finishes.",
"설치됨": "Installed", "설치됨": "Installed",
"설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "Installed: Tor Expert Bundle {0} (official Tor release). Turning it off deletes it.",
"설치하는 중": "Installing", "설치하는 중": "Installing",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "Installing downloads the files it needs (coming soon)",
"설치하지 못했습니다.\n{0}": "Could not install.\n{0}", "설치하지 못했습니다.\n{0}": "Could not install.\n{0}",
@ -1121,6 +1211,9 @@
"시드 (-1 은 아무렇게나)": "Seed (-1 = random)", "시드 (-1 은 아무렇게나)": "Seed (-1 = random)",
"시드 {0}": "Seed {0}", "시드 {0}": "Seed {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "Series: files that differ only by number, like file_001 · file_002, S01E01 · S01E02",
"시스템 DNS": "System DNS",
"시스템 DNS (끄기)": "System DNS (off)",
"시스템 DNS 로 바꾸기": "Switch to system DNS",
"시스템 막대 보이기 (시계 · 알림 · 뒤로)": "Show system bars (clock, notifications, back)", "시스템 막대 보이기 (시계 · 알림 · 뒤로)": "Show system bars (clock, notifications, back)",
"시스템 언어 따르기 ({0})": "Follow system language ({0})", "시스템 언어 따르기 ({0})": "Follow system language ({0})",
"시작": "Start", "시작": "Start",
@ -1199,6 +1292,7 @@
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "In-app web browser: presses [Skip] for you, and mutes and fast-forwards ads that can't be skipped (\"Skipping ad…\" shows at the top while skipping)",
"앱 지우기": "Uninstall app", "앱 지우기": "Uninstall app",
"앱에 들어 있는 것 (기본)": "Built into the app (default)", "앱에 들어 있는 것 (기본)": "Built into the app (default)",
"앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "Built into the app (Tor {0}, tor {1}). Turning it on starts nothing until the Tor screen is opened.",
"앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "After reinstalling the app, Android Auto Backup (Google backup) restored your previous settings.\n· {0} videos in the list\n· WebDAV servers: {1}", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "After reinstalling the app, Android Auto Backup (Google backup) restored your previous settings.\n· {0} videos in the list\n· WebDAV servers: {1}",
"앱을 다시 켤 때 동기화": "Sync when the app restarts", "앱을 다시 켤 때 동기화": "Sync when the app restarts",
"앱을 닫아 진행 중이던 작업 (MKV · 다운로드 · 동기화) 이 멈췄습니다. 앱을 닫아도 계속하려면 환경 설정 > Rsync > '백그라운드로 실행' 을 켜세요.": "Closing the app stopped the running tasks (MKV · downloads · sync). To keep them going after closing the app, turn on Settings > Rsync > 'Run in background'.", "앱을 닫아 진행 중이던 작업 (MKV · 다운로드 · 동기화) 이 멈췄습니다. 앱을 닫아도 계속하려면 환경 설정 > Rsync > '백그라운드로 실행' 을 켜세요.": "Closing the app stopped the running tasks (MKV · downloads · sync). To keep them going after closing the app, turn on Settings > Rsync > 'Run in background'.",
@ -1233,11 +1327,14 @@
"여러 개를 골랐을 때": "When several are selected", "여러 개를 골랐을 때": "When several are selected",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "How many to process at once when making several videos into MKV (re-encoding uses a lot of CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s", "여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "Too many wrong tries. You can try again in {0} s",
"연결 거부": "connection refused",
"연결 실패: {0}": "Connection failed: {0}", "연결 실패: {0}": "Connection failed: {0}",
"연결 안 됨": "Not connected", "연결 안 됨": "Not connected",
"연결 확인": "Test connection", "연결 확인": "Test connection",
"연결됨": "Connected",
"연결됨: 항목 {0}개{1}": "Connected: {0} items{1}", "연결됨: 항목 {0}개{1}": "Connected: {0} items{1}",
"연결이 끊겨 다시 잇는 중 ({0}번째)": "Connection lost, reconnecting (attempt {0})", "연결이 끊겨 다시 잇는 중 ({0}번째)": "Connection lost, reconnecting (attempt {0})",
"연결이 끊겼습니다": "The connection was closed",
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "The connection was lost. Tap [Resume] to continue from where it stopped", "연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "The connection was lost. Tap [Resume] to continue from where it stopped",
"연결할 수 없습니다": "Cannot connect", "연결할 수 없습니다": "Cannot connect",
"연도": "Year", "연도": "Year",
@ -1301,6 +1398,7 @@
"왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "Choose one folder in each of the left and right panes.", "왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "Choose one folder in each of the left and right panes.",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "Reorder with the handle on the left · drag the icon onto a folder on the left or in the list to move it there · right-click menu", "왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "Reorder with the handle on the left · drag the icon onto a folder on the left or in the list to move it there · right-click menu",
"왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "Folders on the left, the files of the folder chosen on the left on the right (like Windows Explorer).", "왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "Folders on the left, the files of the folder chosen on the left on the right (like Windows Explorer).",
"요청 머리가 너무 깁니다": "The request header is too long",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "Too many requests. Try again in a moment.",
"우르두어": "Urdu", "우르두어": "Urdu",
"우크라이나어": "Ukrainian", "우크라이나어": "Ukrainian",
@ -1352,6 +1450,8 @@
"원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)", "원어: 자동 감지 (글자로 추정)": "Source: auto-detect (guessed from the text)",
"원인: {0}": "Cause: {0}", "원인: {0}": "Cause: {0}",
"월": "Mon", "월": "Mon",
"웹 뷰 프록시: {0}": "Web view proxy: {0}",
"웹 뷰 프록시를 걸지 못했습니다: {0}": "Could not set the web view proxy: {0}",
"웹 브라우저": "Web browser", "웹 브라우저": "Web browser",
"웹 브라우저 (지금 여기)": "Web browser (you are here)", "웹 브라우저 (지금 여기)": "Web browser (you are here)",
"웹 브라우저 (홈 주소)": "Web browser (home page)", "웹 브라우저 (홈 주소)": "Web browser (home page)",
@ -1397,6 +1497,7 @@
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device",
"이 기기용 파일 없음": "No file for this device", "이 기기용 파일 없음": "No file for this device",
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "Couldn't save the password because this device's secure storage is unavailable",
"이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "The Tor screen cannot be opened with this device's web view",
"이 기기의 폴더": "Folder on this device", "이 기기의 폴더": "Folder on this device",
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "Cannot register this hotkey (another program may be using it)",
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "These two folders are being synced with rsync right now. Try again when it finishes.", "이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "These two folders are being synced with rsync right now. Try again when it finishes.",
@ -1412,8 +1513,10 @@
"이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "Enter the master password to use this server's saved password.", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "Enter the master password to use this server's saved password.",
"이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "This service type doesn't support image → image", "이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "This service type doesn't support image → image",
"이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "Drawing with this service sends images and prompts off this device (to this address).", "이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "Drawing with this service sends images and prompts off this device (to this address).",
"이 설정을 따르는 것": "What follows this setting",
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "This install has no rsync (downloaded when copying with rsync · or use [Download] on the right)", "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "This install has no rsync (downloaded when copying with rsync · or use [Download] on the right)",
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.", "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.",
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "This app (APK) does not contain Tor.",
"이 언어 지우기": "Remove this language", "이 언어 지우기": "Remove this language",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.",
"이 이동 버튼 지우기": "Delete this move button", "이 이동 버튼 지우기": "Delete this move button",
@ -1437,6 +1540,7 @@
"이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder", "이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder",
"이 폴더의 동영상 재생": "Play videos in this folder", "이 폴더의 동영상 재생": "Play videos in this folder",
"이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "No app opens this format. Long-press or right-click → [Play in built-in player] to try it.", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "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).", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "Double-tapping files with these extensions opens the image viewer (tap right for the next image, left for the previous).",
"이동": "Move", "이동": "Move",
"이동 ({0})": "Move ({0})", "이동 ({0})": "Move ({0})",
@ -1462,6 +1566,7 @@
"이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}", "이름을 바꿀 수 없습니다: {0}": "Cannot rename: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.", "이름을 바꿀 항목 하나를 고르세요.": "Choose one item to rename.",
"이름을 바꿉니다": "renames it", "이름을 바꿉니다": "renames it",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "Name not found: {0} (DNS {1})",
"이미 그리는 중입니다": "Already drawing", "이미 그리는 중입니다": "Already drawing",
"이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).", "이미 동영상 목록에 있습니다 ({0}개).": "Already in the video list ({0}).",
"이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).", "이미 목록에 있는 동영상입니다 ({0}개).": "These videos are already in the list ({0}).",
@ -1492,6 +1597,7 @@
"인터넷 자막 찾기{0}": "Find subtitles online{0}", "인터넷 자막 찾기{0}": "Find subtitles online{0}",
"일": "Sun", "일": "Sun",
"일반": "General", "일반": "General",
"일반 오류": "general failure",
"일본어": "Japanese", "일본어": "Japanese",
"일시정지": "Pause", "일시정지": "Pause",
"읽기 전용 매체입니다": "Read-only media", "읽기 전용 매체입니다": "Read-only media",
@ -1665,7 +1771,10 @@
"주말 종일": "Weekends, all day", "주말 종일": "Weekends, all day",
"주소": "Address", "주소": "Address",
"주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "No address (http · https · magnet) found, or it's already downloading.", "주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "No address (http · https · magnet) found, or it's already downloading.",
"주소 이름 (예: youtube.com) 을 찾을 때 물어볼 서버. 직접 지정하면 기기의 DNS 대신 이 서버만 씁니다. 서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 알립니다.": "The server asked when looking up host names (e.g. youtube.com). When set manually, only this server is used instead of the device DNS. If it cannot be reached, you are told instead of silently switching to the device DNS.",
"주소 이름이 너무 깁니다: {0}": "Host name is too long: {0}",
"주소 입력 또는 검색 (Ctrl+L)": "Enter an address or search (Ctrl+L)", "주소 입력 또는 검색 (Ctrl+L)": "Enter an address or search (Ctrl+L)",
"주소 입력 또는 검색 (DuckDuckGo)": "Enter an address or search (DuckDuckGo)",
"주소로 열기": "Open by address", "주소로 열기": "Open by address",
"주소를 https 로 바꾸고 다시 확인": "Switch the address to https and check again", "주소를 https 로 바꾸고 다시 확인": "Switch the address to https and check again",
"주소에 영상이 없습니다 (404)": "No video at this address (404)", "주소에 영상이 없습니다 (404)": "No video at this address (404)",
@ -1708,6 +1817,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "Current version: v{0} → new version: v{1}",
"지금 작업 취소: {0}": "Cancelled current job: {0}", "지금 작업 취소: {0}": "Cancelled current job: {0}",
"지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)", "지금 작업만 (대기 {0}개는 계속)": "Current job only ({0} waiting continue)",
"지금 확인": "Check now",
"지금: {0}": "Now: {0}", "지금: {0}": "Now: {0}",
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "This is the Dual App (cloned app). Android may not grant this permission to dual apps. If you can't turn it on, open the original JJ_MKVMaker icon without the badge.",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "The last run did not end normally (last check {0}:{1}). It has been noted in the job log.",
@ -1727,8 +1837,10 @@
"지울 목록 보고 결정": "Review the list and decide", "지울 목록 보고 결정": "Review the list and decide",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to delete (tap, or mark with the circle on the right).", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "Choose items to delete (tap, or mark with the circle on the right).",
"지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "Unsupported protobuf format (wire type {0}, position {1})",
"지원하지 않는 명령": "command not supported",
"지원하지 않는 문자셋: {0}": "Unsupported charset: {0}", "지원하지 않는 문자셋: {0}": "Unsupported charset: {0}",
"지원하지 않는 사이트입니다": "This site is not supported", "지원하지 않는 사이트입니다": "This site is not supported",
"지원하지 않는 주소 형식": "address type not supported",
"지정한 파일이 없습니다": "The specified file does not exist", "지정한 파일이 없습니다": "The specified file does not exist",
"직접 고르기: {0}": "Choose: {0}", "직접 고르기: {0}": "Choose: {0}",
"직접 고르기…": "Choose…", "직접 고르기…": "Choose…",
@ -1738,6 +1850,7 @@
"직접 입력": "Custom", "직접 입력": "Custom",
"직접 입력…": "Custom…", "직접 입력…": "Custom…",
"직접 지정": "Specify", "직접 지정": "Specify",
"직접 지정 (기본)": "Manual (default)",
"진행 중인 작업 없음": "No jobs in progress", "진행 중인 작업 없음": "No jobs in progress",
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "A job is in progress: {0}\nStop it and quit?",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].", "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "If it uses a self-signed certificate (like a home NAS), turn on \"Don't verify certificate\" in [Edit server settings].",
@ -1763,6 +1876,7 @@
"채도": "Saturation", "채도": "Saturation",
"채우기 (가운데 잘라냄)": "Fill (crop the center)", "채우기 (가운데 잘라냄)": "Fill (crop the center)",
"처리 장치": "Processor", "처리 장치": "Processor",
"처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "Start / 🏠 address (default: the Tor connection check page)",
"처음 사용 시 내려받기 {0}": "Downloads {0} on first use", "처음 사용 시 내려받기 {0}": "Downloads {0} on first use",
"처음 시작 시": "At app start", "처음 시작 시": "At app start",
"처음 쓸 때 내려받기 (기본)": "Download on first use (default)", "처음 쓸 때 내려받기 (기본)": "Download on first use (default)",
@ -1809,6 +1923,7 @@
"컴포넌트": "Components", "컴포넌트": "Components",
"컴포넌트 목록을 받을 수 없습니다 ({0})": "Could not get the component list ({0})", "컴포넌트 목록을 받을 수 없습니다 ({0})": "Could not get the component list ({0})",
"켜기": "On", "켜기": "On",
"켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "Turning it on downloads Tor Expert Bundle {0} (tor {1}) from the official Tor distribution (about {2} MB, SHA-256 verified). Turning it off deletes it.",
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "When on, double-tapping shows the images inside right away. When off, opens the file list (extract selected).", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "When on, double-tapping shows the images inside right away. When off, opens the file list (extract selected).",
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "Turning it on downloads and installs a converter (about 420MB). Turning it off deletes it.", "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "Turning it on downloads and installs a converter (about 420MB). Turning it off deletes it.",
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "When on, items go to the Recycle Bin and can be restored (Shift+Delete deletes permanently). When off, it always asks to delete permanently. Network drives and USB memory have no Recycle Bin, so deletion there is always permanent", "켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "When on, items go to the Recycle Bin and can be restored (Shift+Delete deletes permanently). When off, it always asks to delete permanently. Network drives and USB memory have no Recycle Bin, so deletion there is always permanent",
@ -1994,12 +2109,14 @@
"확인 중…": "Checking…", "확인 중…": "Checking…",
"확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "Allowed after a notice in the confirmation. Always blocked with delete (--delete). When off, always blocked", "확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "Allowed after a notice in the confirmation. Always blocked with delete (--delete). When off, always blocked",
"확인됨": "Verified", "확인됨": "Verified",
"확인하는 중…": "Checking…",
"확장 설치": "Installing extension", "확장 설치": "Installing extension",
"확장 설치: {0}": "Installing extension: {0}", "확장 설치: {0}": "Installing extension: {0}",
"확장을 설치할 수 없습니다 ({0}): {1}": "Could not install the extension ({0}): {1}", "확장을 설치할 수 없습니다 ({0}): {1}": "Could not install the extension ({0}): {1}",
"확장자": "Ext", "확장자": "Ext",
"확장자별 재생 프로그램": "Player per file extension", "확장자별 재생 프로그램": "Player per file extension",
"환경 설정": "Settings", "환경 설정": "Settings",
"환경 설정 > 컴포넌트": "Settings > Components",
"환경 설정 열기": "Open settings", "환경 설정 열기": "Open settings",
"환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "As set in Settings, it {0} (Settings > File explorer > When a file with the same name exists)", "환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "As set in Settings, it {0} (Settings > File explorer > When a file with the same name exists)",
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "Enter the master password to open settings. It won't be asked again until the app is closed.", "환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "Enter the master password to open settings. It won't be asked again until the app is closed.",

View File

@ -113,24 +113,41 @@
"API 키 또는 아이디:비밀번호 (선택)": "API キーまたは ID:パスワード (任意)", "API 키 또는 아이디:비밀번호 (선택)": "API キーまたは ID:パスワード (任意)",
"AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "AV1 動画はこの端末では再エンコードできません。コーデックを「元のまま」に変えて MKV を作成してください。(PC 版はできます)",
"Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Android 11 以降、他のアプリのデータフォルダー (Android/data・Android/obb) はどのファイルアプリでも開けません。そのアプリ内のエクスポート・共有でファイルを取り出してください。", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "Android 11 以降、他のアプリのデータフォルダー (Android/data・Android/obb) はどのファイルアプリでも開けません。そのアプリ内のエクスポート・共有でファイルを取り出してください。",
"Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView に必要な機能がありません ({0})。Play ストアで Android System WebView・Chrome を更新してから開き直してください。Tor を通らずに開く恐れがあるため、この画面はページを読み込みません。",
"Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "Android が制限しているフォルダー (Android/data など) か、「すべてのファイルへのアクセス」権限がありません。", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "Android が制限しているフォルダー (Android/data など) か、「すべてのファイルへのアクセス」権限がありません。",
"Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android では rsync をダウンロードできません。直接指定してください。", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android では rsync をダウンロードできません。直接指定してください。",
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android では古いバージョンのアプリを今のアプリの上にインストールできません。そのため v{0} のインストールファイルと下の「残るもの」を Download/JJ_MKVMaker に保存してからこのアプリを削除し (Android の確認画面)、ファイルアプリでその APK をタップしてインストールします。", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android では古いバージョンのアプリを今のアプリの上にインストールできません。そのため v{0} のインストールファイルと下の「残るもの」を Download/JJ_MKVMaker に保存してからこのアプリを削除し (Android の確認画面)、ファイルアプリでその APK をタップしてインストールします。",
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android はバージョンの低いアプリを今のアプリの上にインストールできません。そこで:\n1. v{0} のインストールファイルと今の設定 (動画リスト · ブックマークを含む) を Download/JJ_MKVMaker に保存します。\n2. このアプリを削除します (Android の確認画面)。\n3. ファイルアプリで Download/JJ_MKVMaker の APK をタップしてインストールします。\n4. この機能があるバージョンなら起動時に保存した設定を復元するか尋ねます。それより古いバージョンは設定し直す必要があります。\n※ 字幕 API キー · パスワードは保存しないので再入力が必要です。", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android はバージョンの低いアプリを今のアプリの上にインストールできません。そこで:\n1. v{0} のインストールファイルと今の設定 (動画リスト · ブックマークを含む) を Download/JJ_MKVMaker に保存します。\n2. このアプリを削除します (Android の確認画面)。\n3. ファイルアプリで Download/JJ_MKVMaker の APK をタップしてインストールします。\n4. この機能があるバージョンなら起動時に保存した設定を復元するか尋ねます。それより古いバージョンは設定し直す必要があります。\n※ 字幕 API キー · パスワードは保存しないので再入力が必要です。",
"Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Android のインストール画面で[インストール]を押してください。設定・ダウンロードしたファイル・AI モデルはそのまま残ります。\n(初回は「この提供元のアプリを許可」をオンにする必要があるかもしれません)", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "Android のインストール画面で[インストール]を押してください。設定・ダウンロードしたファイル・AI モデルはそのまま残ります。\n(初回は「この提供元のアプリを許可」をオンにする必要があるかもしれません)",
"Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "Android ではアプリを削除してから古いバージョンをインストールします。ダウンロードした AI モデル・パスワード・ブラウザーのログインなどは消えます (残るもの・消えるものは次の画面で表示します)。", "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "Android ではアプリを削除してから古いバージョンをインストールします。ダウンロードした AI モデル・パスワード・ブラウザーのログインなどは消えます (残るもの・消えるものは次の画面で表示します)。",
"Android 웹 뷰의 프록시는 앱 전체에 걸려, Tor 화면이 열려 있는 동안 일반 웹 브라우저도 Tor 로 나갑니다 (일반 브라우저 위쪽에 안내). Tor 화면을 닫으면 원래대로 돌아갑니다.": "Android の Web ビューのプロキシはアプリ全体にかかるため、Tor 画面を開いている間は通常の Web ブラウザも Tor 経由になります (通常のブラウザ上部に表示)。Tor 画面を閉じると元に戻ります。",
"Android 자동 백업에서 되살린 설정으로 시작합니다": "Android 自動バックアップから復元した設定で起動します", "Android 자동 백업에서 되살린 설정으로 시작합니다": "Android 自動バックアップから復元した設定で起動します",
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "Android 自動バックアップから以前の設定を復元しました (動画リスト {0} 件・WebDAV サーバー {1} 件)",
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面を開いている間",
"BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}", "BPE 토크나이저가 아닙니다: {0}": "BPE トークナイザーではありません: {0}",
"Chrome (내려받기)": "Chrome(ダウンロード)", "Chrome (내려받기)": "Chrome(ダウンロード)",
"Chrome (내장)": "Chrome(内蔵)", "Chrome (내장)": "Chrome(内蔵)",
"Chrome 에서 가져오기": "Chrome から取り込む", "Chrome 에서 가져오기": "Chrome から取り込む",
"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 엔진 (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 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.": "Chrome エンジンはアプリを再起動すると変更した設定を使います。",
"Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "Chrome エンジンはプログラムを再起動すると使えます。今すぐ再起動しますか?", "Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "Chrome エンジンはプログラムを再起動すると使えます。今すぐ再起動しますか?",
"Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Chrome エンジンをダウンロードできません(HTTP {0})", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "Chrome エンジンをダウンロードできません(HTTP {0})",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Chrome エンジンを起動できません。環境設定で Edge に切り替えてください。\n{0}", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "Chrome エンジンを起動できません。環境設定で Edge に切り替えてください。\n{0}",
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 強制プロキシ開始: 127.0.0.1:{0} ({1})",
"DNS 강제 프록시를 시작하지 못했습니다: {0}": "DNS 強制プロキシを開始できませんでした: {0}",
"DNS 서버": "DNS サーバー",
"DNS 서버 ({0}) 에 닿지 않습니다: {1}": "DNS サーバー ({0}) に届きません: {1}",
"DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "DNS サーバー ({0}) に届かず、アドレスを解決できませんでした: {1}",
"DNS 서버 주소가 없습니다 (환경 설정 > 네트워크 · DNS)": "DNS サーバーのアドレスがありません (環境設定 > ネットワーク・DNS)",
"DNS 설정: {0}": "DNS 設定: {0}",
"DNS 실패 ({0}): {1}": "DNS 失敗 ({0}): {1}",
"DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "DOC・DOCX・HWP・XLS・XLSX を PDF に変換して表示 (変換ツールをダウンロード)", "DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "DOC・DOCX・HWP・XLS・XLSX を PDF に変換して表示 (変換ツールをダウンロード)",
"DoH 서버 ({0}) 가 HTTP {1} 로 답했습니다": "DoH サーバー ({0}) が HTTP {1} を返しました",
"DoH 서버 ({0}) 에 닿지 않습니다: {1}": "DoH サーバー ({0}) に届きません: {1}",
"DoH 서버 이름을 찾을 서버": "DoH サーバー名を解決するサーバー",
"DoH 서버 이름을 찾을 수 없습니다: {0}": "DoH サーバー名を解決できません: {0}",
"DoH 주소": "DoH アドレス",
"Download/JJ_MKVMaker 에 보관한 설정 (v{0}{1}) 도 있습니다:\n· 동영상 목록 {2}개\n· WebDAV 서버: {3}": "Download/JJ_MKVMaker に保管した設定 (v{0}{1}) もあります:\n・動画リスト {2} 件\n・WebDAV サーバー: {3}", "Download/JJ_MKVMaker 에 보관한 설정 (v{0}{1}) 도 있습니다:\n· 동영상 목록 {2}개\n· WebDAV 서버: {3}": "Download/JJ_MKVMaker に保管した設定 (v{0}{1}) もあります:\n・動画リスト {2} 件\n・WebDAV サーバー: {3}",
"Edge (내장)": "Edge(内蔵)", "Edge (내장)": "Edge(内蔵)",
"Edge 에서 가져오기": "Edge から取り込む", "Edge 에서 가져오기": "Edge から取り込む",
@ -142,6 +159,8 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "GitHub の最新版と比べ、新しい版があればダウンロードしてインストールします", "GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "GitHub の最新版と比べ、新しい版があればダウンロードしてインストールします",
"HTML 파일로 내보내기": "HTML ファイルに書き出す", "HTML 파일로 내보내기": "HTML ファイルに書き出す",
"HTML 파일에서 가져오기": "HTML ファイルから取り込む", "HTML 파일에서 가져오기": "HTML ファイルから取り込む",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "IPv4・IPv6 アドレスを入力してください (例: 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - ダウンロード {0}個", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - ダウンロード {0}個",
"JJ_MKVMaker 로 자막 만들기": "JJ_MKVMaker で字幕を作成", "JJ_MKVMaker 로 자막 만들기": "JJ_MKVMaker で字幕を作成",
"JJ_MKVMaker 로 재생": "JJ_MKVMaker で再生", "JJ_MKVMaker 로 재생": "JJ_MKVMaker で再生",
@ -209,6 +228,51 @@
"Stable Diffusion v1.5": "Stable Diffusion v1.5", "Stable Diffusion v1.5": "Stable Diffusion v1.5",
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 は CreativeML OpenRAIL-M ライセンスです。商用を含めて使えますが、次の用途には使えません: 法律に違反すること、未成年者を害したり利用したりすること、他人を害する目的の虚偽情報、個人情報で人を害すること、差別・嫌がらせ、医療・法律の判断の代わりにすること など (ライセンス付属書 A)。作成した画像の責任は使う人にあります。", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 は CreativeML OpenRAIL-M ライセンスです。商用を含めて使えますが、次の用途には使えません: 法律に違反すること、未成年者を害したり利用したりすること、他人を害する目的の虚偽情報、個人情報で人を害すること、差別・嫌がらせ、医療・法律の判断の代わりにすること など (ライセンス付属書 A)。作成した画像の責任は使う人にあります。",
"TAESD (빠른 디코더)": "TAESD (高速デコーダー)", "TAESD (빠른 디코더)": "TAESD (高速デコーダー)",
"TTL 만료": "TTL 期限切れ",
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor 実行ファイル - GPL を有効にしたビルド)",
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS が接続を受け付けません",
"Tor SOCKS 포트를 찾지 못했습니다": "Tor SOCKS ポートが見つかりませんでした",
"Tor 가 {0}:{1} 에 연결하지 못했습니다 ({2})": "Tor が {0}:{1} に接続できませんでした ({2})",
"Tor 가 끝났습니다 (코드 {0}) {1}": "Tor が終了しました (コード {0}) {1}",
"Tor 가 설치되어 있지 않습니다 (환경 설정 > 컴포넌트)": "Tor がインストールされていません (環境設定 > コンポーネント)",
"Tor 가 아직 연결되지 않았습니다": "Tor はまだ接続されていません",
"Tor 기록 지금 지우기": "Tor の記録を今すぐ消去",
"Tor 꺼짐": "Tor オフ",
"Tor 끄기": "Tor を停止",
"Tor 끔": "Tor を停止しました",
"Tor 끝남 (코드 {0}): {1}": "Tor 終了 (コード {0}): {1}",
"Tor 내려받기 시작: {0}": "Tor のダウンロード開始: {0}",
"Tor 로 나가는 앱 안 웹 브라우저 (쿠키 · 기록 따로, 닫으면 지움)": "Tor 経由で通信するアプリ内 Web ブラウザー (Cookie・履歴は別、閉じると消去)",
"Tor 를 받을 수 없습니다: {0}": "Tor をダウンロードできません: {0}",
"Tor 를 시작하는 중": "Tor を起動中",
"Tor 를 실행할 수 없습니다: {0}": "Tor を実行できません: {0}",
"Tor 브라우저": "Tor ブラウザー",
"Tor 브라우저 (지금 여기)": "Tor ブラウザー (現在地)",
"Tor 브라우저 안내": "Tor ブラウザーについて",
"Tor 브라우저는 페이지를 Tor 로 열어 사이트가 이 기기의 주소를 알 수 없게 합니다 (쿠키 · 기록은 따로, 닫으면 지움). 환경 설정 > 컴포넌트 에서 켜면 이 브라우저 옆에 Tor 화면이 생깁니다.": "Tor ブラウザーはページを Tor 経由で開き、サイトがこの端末のアドレスを知ることができないようにします (Cookie・履歴は別、閉じると消去)。環境設定 > コンポーネント でオンにすると、このブラウザーの隣に Tor 画面ができます。",
"Tor 새 경로": "Tor 新しい経路",
"Tor 새 경로 실패 ({0}) - 다시 시작합니다": "Tor 新しい経路に失敗 ({0}) - 再起動します",
"Tor 설정": "Tor 設定",
"Tor 설치 실패: {0}": "Tor のインストールに失敗: {0}",
"Tor 설치 완료: {0}": "Tor のインストール完了: {0}",
"Tor 시작 ({0})": "Tor 起動 ({0})",
"Tor 에 연결하는 중": "Tor に接続中",
"Tor 연결 시간 초과 {0}": "Tor 接続タイムアウト {0}",
"Tor 연결 실패: {0}": "Tor 接続失敗: {0}",
"Tor 연결 중 ({0}%) {1}": "Tor 接続中 ({0}%) {1}",
"Tor 연결됨": "Tor 接続済み",
"Tor 연결됨 (SOCKS 127.0.0.1:{0})": "Tor 接続済み (SOCKS 127.0.0.1:{0})",
"Tor 연결이 끝나고 웹 뷰가 Tor 로만 나가게 된 뒤에 페이지를 엽니다. 그 전에는 아무것도 읽지 않습니다.": "Tor の接続が完了し、Web ビューが Tor 経由でのみ通信するようになってからページを開きます。それまでは何も読み込みません。",
"Tor 제어 명령 실패: {0}": "Tor 制御コマンド失敗: {0}",
"Tor 제어 포트가 없습니다": "Tor 制御ポートがありません",
"Tor 화면 홈 주소": "Tor 画面のホームアドレス",
"Tor 화면을 닫아도 쿠키 · 기록 · 캐시 남기기": "Tor 画面を閉じても Cookie・履歴・キャッシュを残す",
"Tor 화면을 준비할 수 없습니다": "Tor 画面を準備できません",
"Tor 화면을 준비할 수 없습니다: {0}": "Tor 画面を準備できません: {0}",
"Tor 화면의 JavaScript 사용": "Tor 画面で JavaScript を使用",
"Tor 화면의 쿠키 · 기록 · 캐시를 지웠습니다.": "Tor 画面の Cookie・履歴・キャッシュを消去しました。",
"Tor 화면이 열려 있는 동안 이 브라우저도 Tor 로 나갑니다": "Tor 画面を開いている間、このブラウザーも Tor 経由になります",
"Tor 화면이 열려 있어 지금은 지울 수 없습니다. 닫은 뒤 다시 하세요.": "Tor 画面が開いているため今は消去できません。閉じてからもう一度行ってください。",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "VLC がインストールされていません。インストールするとリストに表示されます(videolan.org)。",
"VLC 로 열기": "VLC で開く", "VLC 로 열기": "VLC で開く",
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}", "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}",
@ -268,6 +332,7 @@
"ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}", "ffmpeg 를 실행할 수 없습니다: {0}": "ffmpeg を実行できません: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg エラー(コード {0})\n{1}",
"http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "http:// のアドレスでは ID・パスワードとファイルが暗号化されずに送られます。同じネットワークの他の人が見られるため、サーバーが https に対応していれば https:// に変えてください。", "http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "http:// のアドレスでは ID・パスワードとファイルが暗号化されずに送られます。同じネットワークの他の人が見られるため、サーバーが https に対応していれば https:// に変えてください。",
"https:// 로 시작하는 주소를 넣으세요": "https:// で始まるアドレスを入力してください",
"lsync (실시간 동기화)": "lsync (リアルタイム同期)", "lsync (실시간 동기화)": "lsync (リアルタイム同期)",
"lsync 로 이동": "lsync へ移動", "lsync 로 이동": "lsync へ移動",
"quit 시작": "終了開始", "quit 시작": "終了開始",
@ -299,6 +364,7 @@
"v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} は保存した設定を復元できないため、設定し直す必要があります。", "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} は保存した設定を復元できないため、設定し直す必要があります。",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "v{0} をダウンロードし、ファイル検証(SHA256)を終えました。\n{1}",
"whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml (whisper_ggml 内)", "whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml (whisper_ggml 内)",
"www.google.com → {0} (답한 서버: {1})": "www.google.com → {0} (応答したサーバー: {1})",
"yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp (実行ファイル yt-dlp.exe)", "yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp (実行ファイル yt-dlp.exe)",
"yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp (実行ファイルに同梱のコンポーネント)", "yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp (実行ファイルに同梱のコンポーネント)",
"yt-dlp 를 새 버전으로 바꿨습니다": "yt-dlp を新しいバージョンに更新しました", "yt-dlp 를 새 버전으로 바꿨습니다": "yt-dlp を新しいバージョンに更新しました",
@ -325,6 +391,7 @@
"{0} 안의 파일": "{0} 内のファイル", "{0} 안의 파일": "{0} 内のファイル",
"{0} 에 보내는 중": "{0} に送信中", "{0} 에 보내는 중": "{0} に送信中",
"{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)", "{0} 에 붙여넣기 ({1}개)": "{0} に貼り付け ({1} 件)",
"{0} 에 연결할 수 없습니다": "{0} に接続できません",
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "{0} に接続できません。ネットワーク・NAS がオンか確認してください。", "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "{0} に接続できません。ネットワーク・NAS がオンか確認してください。",
"{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)", "{0} 에서 그리는 중 ({1}초)": "{0} で描画中 ({1} 秒)",
"{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした", "{0} 에서 받을 파일을 찾지 못했습니다": "{0} からダウンロードするファイルが見つかりませんでした",
@ -390,10 +457,15 @@
"{0}줄": "{0}行", "{0}줄": "{0}行",
"{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 の名前は手動指定のとき - オフ ↔ オンは次回の実行から)",
"· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· ダウンロードした AI モデル (約 {0}、再ダウンロードが必要)", "· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· ダウンロードした AI モデル (約 {0}、再ダウンロードが必要)",
"· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· バックグラウンドへ: ウィンドウだけ隠してダウンロード・変換を続けます({0} またはトレイアイコンで再表示)\n· すべて終了: ダウンロード・変換を止めてプログラムを終了します", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· バックグラウンドへ: ウィンドウだけ隠してダウンロード・変換を続けます({0} またはトレイアイコンで再表示)\n· すべて終了: ダウンロード・変換を止めてプログラムを終了します",
"· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· パスワード・API キー・マスター / 最上位パスワード", "· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· パスワード・API キー・マスター / 最上位パスワード",
"· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· アプリ内ブラウザーのログイン・Cookie、作業記録", "· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· アプリ内ブラウザーのログイン・Cookie、作業記録",
"· 앱의 인터넷 연결: 새 버전 확인 · 컴포넌트 · AI 모델 받기 · WebDAV · 웹 페이지 번역 · 자막 검색": "· アプリのインターネット接続: 新バージョン確認・コンポーネント・AI モデルのダウンロード・WebDAV・Web ページ翻訳・字幕検索",
"· 웹 브라우저 (Edge · Chrome 엔진): 끄기 ↔ 켜기는 앱을 다시 시작한 뒤, 서버 주소는 바로": "· Web ブラウザー (Edge・Chrome エンジン): オフ ↔ オンはアプリ再起動後、サーバーアドレスはすぐ",
"· 웹 브라우저: 바로 (Tor 화면이 열려 있는 동안은 Tor)": "· Web ブラウザー: すぐ (Tor 画面を開いている間は Tor)",
"… 외 {0}개": "… ほか {0} 件", "… 외 {0}개": "… ほか {0} 件",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 他の作業中ならキューに入れて順番に行います。翻訳中もブラウザーを使えます。",
"※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 時間がかかります(動画 1時間 ≈ 音声認識 15〜30分 + 言語ごとの翻訳 10〜20分)", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 時間がかかります(動画 1時間 ≈ 音声認識 15〜30分 + 言語ごとの翻訳 10〜20分)",
@ -479,6 +551,7 @@
"권한 없음": "権限なし", "권한 없음": "権限なし",
"권한 허용 화면 열기": "権限の許可画面を開く", "권한 허용 화면 열기": "権限の許可画面を開く",
"권한이 없습니다": "権限がありません", "권한이 없습니다": "権限がありません",
"규칙상 허용되지 않음": "ルールで許可されていません",
"그 파일 하나만": "そのファイルだけ", "그 파일 하나만": "そのファイルだけ",
"그대로 두기": "そのままにする", "그대로 두기": "そのままにする",
"그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)", "그래도 맞추기 ({0}개 지움)": "それでも同期 ({0} 個を削除)",
@ -507,11 +580,13 @@
"금": "金", "금": "金",
"기계 번역": "機械翻訳", "기계 번역": "機械翻訳",
"기기": "端末", "기기": "端末",
"기기 DNS": "端末の DNS",
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)", "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
"기능 버튼 줄": "機能ボタンの列", "기능 버튼 줄": "機能ボタンの列",
"기본": "既定", "기본": "既定",
"기본 DNS": "優先 DNS",
"기본 앱으로 열기": "既定のアプリで開く", "기본 앱으로 열기": "既定のアプリで開く",
"기본 연결 프로그램": "既定のアプリ", "기본 연결 프로그램": "既定のアプリ",
"기본 자막 언어": "既定の字幕言語", "기본 자막 언어": "既定の字幕言語",
@ -529,6 +604,8 @@
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "長押し · 右クリックは常に機能メニュー (コピー · 移動 · 削除 · 名前の変更 …)。Ctrl + クリック: 複数選択",
"길이 {0}": "長さ {0}", "길이 {0}": "長さ {0}",
"끄기": "オフ", "끄기": "オフ",
"끄면 (기본) Tor 화면을 닫을 때 지웁니다. 일반 브라우저와는 늘 따로입니다.": "オフ (既定) の場合は Tor 画面を閉じるときに消去します。通常のブラウザーとは常に別です。",
"끄면 더 안전하지만 많은 사이트가 보이지 않습니다 (다음에 열 때부터).": "オフの方が安全ですが、多くのサイトが表示されません (次に開くときから)。",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "オフにすると前回の設定ですぐ開始: 原語 {0}、言語 {1}、{2}", "끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "オフにすると前回の設定ですぐ開始: 原語 {0}、言語 {1}、{2}",
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。Chrome エンジンではページ翻訳 · 動画の検出も止まります。",
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "オフにするとページのスクリプトを実行しません (広告 · 追跡は減りますが、YouTube のようにスクリプトで作られたサイトは表示されません)。ページ翻訳 · 動画の検出も止まります。",
@ -564,6 +641,7 @@
"내려받는 중": "ダウンロード中", "내려받는 중": "ダウンロード中",
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)", "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "ダウンロードが途切れたモデル(.part)・変換の残り(.tmp)",
"내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除", "내려받은 Chrome 엔진 지우기": "ダウンロードした Chrome エンジンを削除",
"내려받은 Tor 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "ダウンロードした Tor のファイルを削除します。再びオンにすると再ダウンロードします。",
"내려받은 rsync 지우기": "ダウンロードした rsync を削除", "내려받은 rsync 지우기": "ダウンロードした rsync を削除",
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "ダウンロードした変換ツールのファイルを削除します。再びオンにすると再ダウンロードします。", "내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "ダウンロードした変換ツールのファイルを削除します。再びオンにすると再ダウンロードします。",
"내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "書き出した Cookie ファイルを削除しました。ブラウザのログインも消すには、Web ブラウザ画面の [Cookie・閲覧履歴を消去] を押してください。", "내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "書き出した Cookie ファイルを削除しました。ブラウザのログインも消すには、Web ブラウザ画面の [Cookie・閲覧履歴を消去] を押してください。",
@ -587,6 +665,7 @@
"넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右", "넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右",
"넣지 못했습니다: {0}": "追加できませんでした: {0}", "넣지 못했습니다: {0}": "追加できませんでした: {0}",
"네덜란드어": "オランダ語", "네덜란드어": "オランダ語",
"네트워크 · DNS": "ネットワーク・DNS",
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。",
"네팔어": "ネパール語", "네팔어": "ネパール語",
"노랑": "黄色", "노랑": "黄色",
@ -667,6 +746,7 @@
"단축키 해제": "ショートカット解除", "단축키 해제": "ショートカット解除",
"닫기": "閉じる", "닫기": "閉じる",
"닫힘 (실행 안 함)": "閉じた (実行しない)", "닫힘 (실행 안 함)": "閉じた (実行しない)",
"답이 없습니다": "応答がありません",
"대기": "待機", "대기": "待機",
"대기 {0}개:": "待機 {0}個:", "대기 {0}개:": "待機 {0}個:",
"대기 · 다음 시작 {0}": "待機 · 次の開始 {0}", "대기 · 다음 시작 {0}": "待機 · 次の開始 {0}",
@ -753,6 +833,7 @@
"등록": "登録", "등록": "登録",
"등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。", "등록 중 일부가 실패했습니다.": "登録の一部に失敗しました。",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "登録済み:「JJ_MKVMaker で再生」・「JJ_MKVMaker で字幕を作成」(Windows 11: その他のオプションを表示)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "対象外: Tor 画面 (名前も Tor 内で解決)、IP アドレス、家庭内ネットワークの名前 (ドットのない NAS・PC 名、*.local、Tailscale の *.ts.net などは端末の DNS)",
"라오어": "ラオ語", "라오어": "ラオ語",
"라이선스 원문 보기": "ライセンス原文を見る", "라이선스 원문 보기": "ライセンス原文を見る",
"러시아어": "ロシア語", "러시아어": "ロシア語",
@ -792,6 +873,7 @@
"만화 · 그림용은 가볍고 빠르며, 사진용은 사진의 질감을 더 잘 살리지만 그래픽카드가 없으면 느립니다.": "漫画・イラスト用は軽くて速く、写真用は写真の質感をよく保ちますが、グラフィックカードがないと遅くなります。", "만화 · 그림용은 가볍고 빠르며, 사진용은 사진의 질감을 더 잘 살리지만 그래픽카드가 없으면 느립니다.": "漫画・イラスト用は軽くて速く、写真用は写真の質感をよく保ちますが、グラフィックカードがないと遅くなります。",
"만화 보기": "マンガ表示", "만화 보기": "マンガ表示",
"말레이어": "マレー語", "말레이어": "マレー語",
"망에 닿을 수 없음": "ネットワークに到達できません",
"맞추기 (검은 여백)": "フィット(黒い余白)", "맞추기 (검은 여백)": "フィット(黒い余白)",
"맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)", "맞추기 (비율이 다를 때)": "合わせ方(比率が違うとき)",
"맞추기 (흐린 배경)": "フィット(ぼかし背景)", "맞추기 (흐린 배경)": "フィット(ぼかし背景)",
@ -891,6 +973,7 @@
"받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "更新・必須プログラム・Chrome エンジンのインストールで残った一時ファイル", "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "更新・必須プログラム・Chrome エンジンのインストールで残った一時ファイル",
"받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "受け取った字幕を韓国語に翻訳して一緒に追加(韓国語字幕がないとき)", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "受け取った字幕を韓国語に翻訳して一緒に追加(韓国語字幕がないとき)",
"받은 파일에 libcef.dll 이 없습니다.": "ダウンロードしたファイルに libcef.dll がありません。", "받은 파일에 libcef.dll 이 없습니다.": "ダウンロードしたファイルに libcef.dll がありません。",
"받은 파일에 tor.exe 가 없습니다": "ダウンロードしたファイルに tor.exe がありません",
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "ダウンロードしたファイルが壊れているか別のファイルです(SHA256 不一致)。インストールを中止しました。",
"받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "ダウンロードしたファイルが破損しています (SHA-256 不一致): {0}", "받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "ダウンロードしたファイルが破損しています (SHA-256 不一致): {0}",
"받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}", "받을 수 없습니다 ({0}): {1}": "ダウンロードできません ({0}): {1}",
@ -938,11 +1021,13 @@
"보기": "表示", "보기": "表示",
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小", "보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔",
"보내지 못했습니다": "送信できませんでした",
"보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}", "보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "見ていたフォルダーがなくなったため上のフォルダーに移動しました", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "見ていたフォルダーがなくなったため上のフォルダーに移動しました",
"보안": "セキュリティ", "보안": "セキュリティ",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "表示するボタンを選び、ドラッグで順序を変えます。",
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "補正には再エンコードが必要なため、コーデックを「元のまま」から H.264 に変更しました。",
"보조 DNS (비워 둘 수 있음)": "代替 DNS (空欄可)",
"복사": "コピー", "복사": "コピー",
"복사 (붙여넣기로)": "コピー (貼り付け用)", "복사 (붙여넣기로)": "コピー (貼り付け用)",
"복사 · rsync": "コピー · rsync", "복사 · rsync": "コピー · rsync",
@ -1015,6 +1100,9 @@
"삭제: {0}": "削除: {0}", "삭제: {0}": "削除: {0}",
"상위 폴더": "上のフォルダー", "상위 폴더": "上のフォルダー",
"상하 반전": "上下反転", "상하 반전": "上下反転",
"새 Tor 경로로 바꿨습니다. 페이지를 다시 읽습니다.": "新しい Tor 経路に切り替えました。ページを再読み込みします。",
"새 경로": "新しい経路",
"새 경로: 앞으로의 연결을 새 Tor 회로로 (사이트가 다른 사람으로 봄)": "新しい経路: 今後の接続を新しい Tor 回線で (サイトからは別の人に見える)",
"새 마스터 비밀번호 정하기": "新しいマスターパスワードを設定", "새 마스터 비밀번호 정하기": "新しいマスターパスワードを設定",
"새 버전 v{0}": "新しいバージョン v{0}", "새 버전 v{0}": "新しいバージョン v{0}",
"새 버전 v{0} 이 있습니다": "新しいバージョン v{0} があります", "새 버전 v{0} 이 있습니다": "新しいバージョン v{0} があります",
@ -1045,6 +1133,7 @@
"서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません", "서버가 너무 오래 답하지 않습니다": "サーバーの応答が長時間ありません",
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "サーバーが別のアドレスへの移動を求めています ({0})。サーバー設定のアドレスを確認してください。",
"서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません", "서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません",
"서버에 닿을 수 없음": "ホストに到達できません",
"서버에 닿지 않습니다": "サーバーに接続できません", "서버에 닿지 않습니다": "サーバーに接続できません",
"서버에서 이 계정의 권한을 확인하세요.": "サーバーでこのアカウントの権限を確認してください。", "서버에서 이 계정의 권한을 확인하세요.": "サーバーでこのアカウントの権限を確認してください。",
"서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "サーバーと同じネットワークにいるか、VPN (例: Tailscale) をオンにする必要があります。オンにしてから [再試行] を押してください。", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "サーバーと同じネットワークにいるか、VPN (例: Tailscale) をオンにする必要があります。オンにしてから [再試行] を押してください。",
@ -1088,6 +1177,7 @@
"설치 화면을 열 수 없습니다: {0}": "インストール画面を開けません: {0}", "설치 화면을 열 수 없습니다: {0}": "インストール画面を開けません: {0}",
"설치가 끝나면 프로그램이 다시 시작됩니다.": "インストールが終わるとプログラムが再起動します。", "설치가 끝나면 프로그램이 다시 시작됩니다.": "インストールが終わるとプログラムが再起動します。",
"설치됨": "インストール済み", "설치됨": "インストール済み",
"설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "インストール済み: Tor Expert Bundle {0} (Tor 公式配布)。オフにすると削除します。",
"설치하는 중": "インストール中", "설치하는 중": "インストール中",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "インストールすると必要なファイルをダウンロードします (準備中)",
"설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}", "설치하지 못했습니다.\n{0}": "インストールできませんでした。\n{0}",
@ -1121,6 +1211,9 @@
"시드 (-1 은 아무렇게나)": "シード (-1 はランダム)", "시드 (-1 은 아무렇게나)": "シード (-1 はランダム)",
"시드 {0}": "シード {0}", "시드 {0}": "シード {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "シリーズ: file_001・file_002、S01E01・S01E02 のように番号だけ違うファイル",
"시스템 DNS": "システム DNS",
"시스템 DNS (끄기)": "システム DNS (オフ)",
"시스템 DNS 로 바꾸기": "システム DNS に切り替える",
"시스템 막대 보이기 (시계 · 알림 · 뒤로)": "システムバーを表示 (時計・通知・戻る)", "시스템 막대 보이기 (시계 · 알림 · 뒤로)": "システムバーを表示 (時計・通知・戻る)",
"시스템 언어 따르기 ({0})": "システムの言語に従う ({0})", "시스템 언어 따르기 ({0})": "システムの言語に従う ({0})",
"시작": "開始", "시작": "開始",
@ -1199,6 +1292,7 @@
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "アプリ内ウェブブラウザー:[スキップ]を代わりに押し、スキップできない広告は音を消して早送りします(スキップ中は画面上部に「広告をスキップ中…」)",
"앱 지우기": "アプリを削除", "앱 지우기": "アプリを削除",
"앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)", "앱에 들어 있는 것 (기본)": "アプリに内蔵 (既定)",
"앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "アプリに内蔵されています (Tor {0}、tor {1})。オンにしても Tor 画面を開くまで何も起動しません。",
"앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "アプリを再インストールしたため、Android 自動バックアップ (Google バックアップ) が以前の設定を復元しました。\n・動画リスト {0} 件\n・WebDAV サーバー: {1}", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "アプリを再インストールしたため、Android 自動バックアップ (Google バックアップ) が以前の設定を復元しました。\n・動画リスト {0} 件\n・WebDAV サーバー: {1}",
"앱을 다시 켤 때 동기화": "アプリ再起動時の同期", "앱을 다시 켤 때 동기화": "アプリ再起動時の同期",
"앱을 닫아 진행 중이던 작업 (MKV · 다운로드 · 동기화) 이 멈췄습니다. 앱을 닫아도 계속하려면 환경 설정 > Rsync > '백그라운드로 실행' 을 켜세요.": "アプリを閉じたため実行中の作業 (MKV · ダウンロード · 同期) が停止しました。アプリを閉じても続けるには、設定 > Rsync >「バックグラウンドで実行」をオンにしてください。", "앱을 닫아 진행 중이던 작업 (MKV · 다운로드 · 동기화) 이 멈췄습니다. 앱을 닫아도 계속하려면 환경 설정 > Rsync > '백그라운드로 실행' 을 켜세요.": "アプリを閉じたため実行中の作業 (MKV · ダウンロード · 同期) が停止しました。アプリを閉じても続けるには、設定 > Rsync >「バックグラウンドで実行」をオンにしてください。",
@ -1233,11 +1327,14 @@
"여러 개를 골랐을 때": "複数選んだとき", "여러 개를 골랐을 때": "複数選んだとき",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "複数の動画を MKV にするとき同時に処理する数(再エンコードは CPU を多く使います)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます", "여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "何度も間違えたため {0} 秒後に再入力できます",
"연결 거부": "接続拒否",
"연결 실패: {0}": "接続失敗: {0}", "연결 실패: {0}": "接続失敗: {0}",
"연결 안 됨": "未接続", "연결 안 됨": "未接続",
"연결 확인": "接続テスト", "연결 확인": "接続テスト",
"연결됨": "接続済み",
"연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}", "연결됨: 항목 {0}개{1}": "接続しました: 項目 {0} 個{1}",
"연결이 끊겨 다시 잇는 중 ({0}번째)": "接続が切れたため再接続中 ({0} 回目)", "연결이 끊겨 다시 잇는 중 ({0}번째)": "接続が切れたため再接続中 ({0} 回目)",
"연결이 끊겼습니다": "接続が切れました",
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "接続が切れました。[続きをダウンロード] を押すと続きからダウンロードします", "연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "接続が切れました。[続きをダウンロード] を押すと続きからダウンロードします",
"연결할 수 없습니다": "接続できません", "연결할 수 없습니다": "接続できません",
"연도": "年", "연도": "年",
@ -1301,6 +1398,7 @@
"왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "左右のウィンドウでフォルダーを 1 つずつ選んでください。", "왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "左右のウィンドウでフォルダーを 1 つずつ選んでください。",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "左のハンドルで並べ替え・アイコンを左のフォルダーかリスト内のフォルダーにドラッグするとそのフォルダーへ移動・右クリックメニュー", "왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "左のハンドルで並べ替え・アイコンを左のフォルダーかリスト内のフォルダーにドラッグするとそのフォルダーへ移動・右クリックメニュー",
"왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "左にフォルダー、右に左で選んだフォルダーのファイルを表示します (Windows エクスプローラーのように)。", "왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "左にフォルダー、右に左で選んだフォルダーのファイルを表示します (Windows エクスプローラーのように)。",
"요청 머리가 너무 깁니다": "リクエストヘッダーが長すぎます",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "リクエストが多すぎます。しばらくしてからもう一度お試しください。",
"우르두어": "ウルドゥー語", "우르두어": "ウルドゥー語",
"우크라이나어": "ウクライナ語", "우크라이나어": "ウクライナ語",
@ -1352,6 +1450,8 @@
"원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)", "원어: 자동 감지 (글자로 추정)": "原語: 自動検出(文字から推定)",
"원인: {0}": "原因: {0}", "원인: {0}": "原因: {0}",
"월": "月", "월": "月",
"웹 뷰 프록시: {0}": "Web ビューのプロキシ: {0}",
"웹 뷰 프록시를 걸지 못했습니다: {0}": "Web ビューのプロキシを設定できませんでした: {0}",
"웹 브라우저": "ウェブブラウザー", "웹 브라우저": "ウェブブラウザー",
"웹 브라우저 (지금 여기)": "ウェブブラウザー(現在地)", "웹 브라우저 (지금 여기)": "ウェブブラウザー(現在地)",
"웹 브라우저 (홈 주소)": "ウェブブラウザー(ホームページ)", "웹 브라우저 (홈 주소)": "ウェブブラウザー(ホームページ)",
@ -1397,6 +1497,7 @@
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです",
"이 기기용 파일 없음": "このデバイス用のファイルなし", "이 기기용 파일 없음": "このデバイス用のファイルなし",
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "この端末の安全な保存領域を使えないため、パスワードを保存できませんでした",
"이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "この端末の Web ビューでは Tor 画面を開けません",
"이 기기의 폴더": "この端末のフォルダー", "이 기기의 폴더": "この端末のフォルダー",
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "このショートカットを登録できません(他のプログラムが使用中の可能性があります)",
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "この 2 つのフォルダーは今 rsync 中です。終わってから行ってください。", "이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "この 2 つのフォルダーは今 rsync 中です。終わってから行ってください。",
@ -1412,8 +1513,10 @@
"이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "このサーバーの保存したパスワードを使うにはマスターパスワードを入力してください。", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "このサーバーの保存したパスワードを使うにはマスターパスワードを入力してください。",
"이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "このサービス方式は画像 → 画像に対応していません", "이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "このサービス方式は画像 → 画像に対応していません",
"이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "このサービスで描くと、画像・プロンプトが端末の外 (このアドレス) に送られます。", "이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "このサービスで描くと、画像・プロンプトが端末の外 (このアドレス) に送られます。",
"이 설정을 따르는 것": "この設定に従うもの",
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)", "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "このインストールには rsync が含まれていません (rsync でコピーするとき · 右の [ダウンロード] で取得します)",
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。", "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。",
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "このアプリ (APK) には Tor が入っていません。",
"이 언어 지우기": "この言語を削除", "이 언어 지우기": "この言語を削除",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。",
"이 이동 버튼 지우기": "この移動ボタンを削除", "이 이동 버튼 지우기": "この移動ボタンを削除",
@ -1437,6 +1540,7 @@
"이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません", "이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません",
"이 폴더의 동영상 재생": "このフォルダーの動画を再生", "이 폴더의 동영상 재생": "このフォルダーの動画を再生",
"이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "この形式を開くアプリがありません。長押しまたは右クリック → [内蔵プレーヤーで再生] で試してください。", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "この形式を開くアプリがありません。長押しまたは右クリック → [内蔵プレーヤーで再生] で試してください。",
"이 화면은 앱 안 웹 뷰의 연결을 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) を使ってください。",
"이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "この拡張子のファイルをダブルタップすると画像表示で開きます (右をタップで次、左で前の画像)。", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "この拡張子のファイルをダブルタップすると画像表示で開きます (右をタップで次、左で前の画像)。",
"이동": "移動", "이동": "移動",
"이동 ({0})": "移動({0})", "이동 ({0})": "移動({0})",
@ -1462,6 +1566,7 @@
"이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}", "이름을 바꿀 수 없습니다: {0}": "名前を変更できません: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。", "이름을 바꿀 항목 하나를 고르세요.": "名前を変更する項目を 1 つ選んでください。",
"이름을 바꿉니다": "名前を変えます", "이름을 바꿉니다": "名前を変えます",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "名前が見つかりません: {0} (DNS {1})",
"이미 그리는 중입니다": "すでに描画中です", "이미 그리는 중입니다": "すでに描画中です",
"이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。", "이미 동영상 목록에 있습니다 ({0}개).": "既に動画リストにあります({0}個)。",
"이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。", "이미 목록에 있는 동영상입니다 ({0}개).": "既にリストにある動画です({0}個)。",
@ -1492,6 +1597,7 @@
"인터넷 자막 찾기{0}": "インターネットで字幕を探す{0}", "인터넷 자막 찾기{0}": "インターネットで字幕を探す{0}",
"일": "日", "일": "日",
"일반": "一般", "일반": "一般",
"일반 오류": "一般エラー",
"일본어": "日本語", "일본어": "日本語",
"일시정지": "一時停止", "일시정지": "一時停止",
"읽기 전용 매체입니다": "読み取り専用メディアです", "읽기 전용 매체입니다": "読み取り専用メディアです",
@ -1665,7 +1771,10 @@
"주말 종일": "週末 終日", "주말 종일": "週末 終日",
"주소": "アドレス", "주소": "アドレス",
"주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "アドレス (http・https・マグネット) が見つからないか、すでにダウンロード中です。", "주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "アドレス (http・https・マグネット) が見つからないか、すでにダウンロード中です。",
"주소 이름 (예: youtube.com) 을 찾을 때 물어볼 서버. 직접 지정하면 기기의 DNS 대신 이 서버만 씁니다. 서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 알립니다.": "ホスト名 (例: youtube.com) を解決するときに問い合わせるサーバー。手動指定すると端末の DNS の代わりにこのサーバーだけを使います。サーバーに届かない場合は、黙って端末の DNS に切り替えずにお知らせします。",
"주소 이름이 너무 깁니다: {0}": "ホスト名が長すぎます: {0}",
"주소 입력 또는 검색 (Ctrl+L)": "アドレスを入力または検索(Ctrl+L)", "주소 입력 또는 검색 (Ctrl+L)": "アドレスを入力または検索(Ctrl+L)",
"주소 입력 또는 검색 (DuckDuckGo)": "アドレス入力または検索 (DuckDuckGo)",
"주소로 열기": "アドレスで開く", "주소로 열기": "アドレスで開く",
"주소를 https 로 바꾸고 다시 확인": "アドレスを https に変えてもう一度確認", "주소를 https 로 바꾸고 다시 확인": "アドレスを https に変えてもう一度確認",
"주소에 영상이 없습니다 (404)": "このアドレスに動画がありません (404)", "주소에 영상이 없습니다 (404)": "このアドレスに動画がありません (404)",
@ -1708,6 +1817,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "現在のバージョン: v{0} → 新しいバージョン: v{1}",
"지금 작업 취소: {0}": "現在の作業をキャンセル: {0}", "지금 작업 취소: {0}": "現在の作業をキャンセル: {0}",
"지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)", "지금 작업만 (대기 {0}개는 계속)": "現在の作業だけ (待機 {0} 件は続行)",
"지금 확인": "今すぐ確認",
"지금: {0}": "現在: {0}", "지금: {0}": "現在: {0}",
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "これはデュアルアプリ (複製したアプリ) です。Android はデュアルアプリにこの権限を与えない場合があります。許可画面でオンにできない場合は、バッジのない元の JJ_MKVMaker アイコンから開いてください。",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "前回の実行が正常に終了しませんでした(最終確認 {0}:{1})。作業記録に残しました。",
@ -1727,8 +1837,10 @@
"지울 목록 보고 결정": "削除する一覧を見て決める", "지울 목록 보고 결정": "削除する一覧を見て決める",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "削除する項目を選んでください (タップ、または右の丸でマーク)。",
"지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "対応していない protobuf 形式(wire type {0}、位置 {1})",
"지원하지 않는 명령": "サポートされていないコマンド",
"지원하지 않는 문자셋: {0}": "対応していない文字コード: {0}", "지원하지 않는 문자셋: {0}": "対応していない文字コード: {0}",
"지원하지 않는 사이트입니다": "対応していないサイトです", "지원하지 않는 사이트입니다": "対応していないサイトです",
"지원하지 않는 주소 형식": "サポートされていないアドレス形式",
"지정한 파일이 없습니다": "指定したファイルがありません", "지정한 파일이 없습니다": "指定したファイルがありません",
"직접 고르기: {0}": "選択: {0}", "직접 고르기: {0}": "選択: {0}",
"직접 고르기…": "選択…", "직접 고르기…": "選択…",
@ -1738,6 +1850,7 @@
"직접 입력": "直接入力", "직접 입력": "直接入力",
"직접 입력…": "直接入力…", "직접 입력…": "直接入力…",
"직접 지정": "直接指定", "직접 지정": "直接指定",
"직접 지정 (기본)": "手動指定 (既定)",
"진행 중인 작업 없음": "進行中の作業なし", "진행 중인 작업 없음": "進行中の作業なし",
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "進行中の作業があります: {0}\n中止して終了しますか?",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。", "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "自宅 NAS のように自己署名証明書なら、[サーバー設定を修正] で「証明書を検証しない」をオンにしてください。",
@ -1763,6 +1876,7 @@
"채도": "彩度", "채도": "彩度",
"채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)", "채우기 (가운데 잘라냄)": "フィル(中央を切り抜き)",
"처리 장치": "処理装置", "처리 장치": "処理装置",
"처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "最初・🏠 のアドレス (既定: Tor 接続確認ページ)",
"처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}", "처음 사용 시 내려받기 {0}": "初回使用時にダウンロード {0}",
"처음 시작 시": "起動時", "처음 시작 시": "起動時",
"처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)", "처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)",
@ -1809,6 +1923,7 @@
"컴포넌트": "コンポーネント", "컴포넌트": "コンポーネント",
"컴포넌트 목록을 받을 수 없습니다 ({0})": "コンポーネント一覧を取得できません ({0})", "컴포넌트 목록을 받을 수 없습니다 ({0})": "コンポーネント一覧を取得できません ({0})",
"켜기": "オン", "켜기": "オン",
"켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "オンにすると Tor 公式配布元から Tor Expert Bundle {0} (tor {1}) をダウンロードします (約 {2}MB、SHA-256 確認)。オフにすると削除します。",
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "オンにするとダブルタップで中の画像をすぐめくって表示します。オフにすると一覧 (選んで展開) を開きます。", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "オンにするとダブルタップで中の画像をすぐめくって表示します。オフにすると一覧 (選んで展開) を開きます。",
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "オンにすると変換ツールをダウンロードしてインストールします (約 420MB)。オフにすると削除します。", "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "オンにすると変換ツールをダウンロードしてインストールします (約 420MB)。オフにすると削除します。",
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "オンにするとごみ箱に移動し、元に戻せます (Shift+Delete は完全に削除)。オフにすると常に完全に削除として確認します。ネットワークドライブ・USB メモリにはごみ箱がないため常に完全に削除です", "켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "オンにするとごみ箱に移動し、元に戻せます (Shift+Delete は完全に削除)。オフにすると常に完全に削除として確認します。ネットワークドライブ・USB メモリにはごみ箱がないため常に完全に削除です",
@ -1994,12 +2109,14 @@
"확인 중…": "確認中…", "확인 중…": "確認中…",
"확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "確認ウィンドウで知らせたうえで許可します。削除 (--delete) があるときは常に禁止します。オフにすると常に禁止します", "확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "確認ウィンドウで知らせたうえで許可します。削除 (--delete) があるときは常に禁止します。オフにすると常に禁止します",
"확인됨": "確認済み", "확인됨": "確認済み",
"확인하는 중…": "確認中…",
"확장 설치": "拡張機能のインストール", "확장 설치": "拡張機能のインストール",
"확장 설치: {0}": "拡張機能のインストール: {0}", "확장 설치: {0}": "拡張機能のインストール: {0}",
"확장을 설치할 수 없습니다 ({0}): {1}": "拡張機能をインストールできません ({0}): {1}", "확장을 설치할 수 없습니다 ({0}): {1}": "拡張機能をインストールできません ({0}): {1}",
"확장자": "拡張子", "확장자": "拡張子",
"확장자별 재생 프로그램": "拡張子ごとの再生プログラム", "확장자별 재생 프로그램": "拡張子ごとの再生プログラム",
"환경 설정": "環境設定", "환경 설정": "環境設定",
"환경 설정 > 컴포넌트": "環境設定 > コンポーネント",
"환경 설정 열기": "環境設定を開く", "환경 설정 열기": "環境設定を開く",
"환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "設定どおり{0} (環境設定 > ファイルエクスプローラー > 同じ名前があるとき)", "환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "設定どおり{0} (環境設定 > ファイルエクスプローラー > 同じ名前があるとき)",
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "環境設定を開くにはマスターパスワードを入力してください。アプリを終了するまで再度尋ねません。", "환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "環境設定を開くにはマスターパスワードを入力してください。アプリを終了するまで再度尋ねません。",

View File

@ -113,24 +113,41 @@
"API 키 또는 아이디:비밀번호 (선택)": "API 密钥或 用户名:密码 (可选)", "API 키 또는 아이디:비밀번호 (선택)": "API 密钥或 用户名:密码 (可选)",
"AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)", "AV1 영상은 이 기기에서 다시 인코딩할 수 없습니다. 코덱을 \"원본 유지\" 로 바꿔 MKV 를 만드세요. (PC 판은 됩니다)": "此设备无法重新编码 AV1 视频。请将编解码器改为“保持原样”后制作 MKV。(PC 版可以)",
"Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "从 Android 11 起,任何文件应用都无法打开其他应用的数据文件夹 (Android/data · Android/obb)。请在该应用内使用导出或分享取出文件。", "Android 11 부터 다른 앱의 데이터 폴더 (Android/data · Android/obb) 는 어떤 파일 앱도 열 수 없습니다. 그 앱 안의 내보내기 · 공유로 파일을 꺼내세요.": "从 Android 11 起,任何文件应用都无法打开其他应用的数据文件夹 (Android/data · Android/obb)。请在该应用内使用导出或分享取出文件。",
"Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.": "Android System WebView 缺少所需功能 ({0})。请在 Play 商店更新 Android System WebView · Chrome 后重新打开。由于页面可能不经过 Tor 打开,此画面不会加载任何页面。",
"Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "这是 Android 限制的文件夹 (如 Android/data),或没有“所有文件访问”权限。", "Android 가 막은 폴더 (Android/data 등) 이거나 \"모든 파일에 대한 접근\" 권한이 없습니다.": "这是 Android 限制的文件夹 (如 Android/data),或没有“所有文件访问”权限。",
"Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android 无法下载 rsync,请自行指定。", "Android 는 rsync 를 내려받을 수 없습니다. 직접 지정하세요.": "Android 无法下载 rsync,请自行指定。",
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android 无法在当前应用之上安装较低版本。因此会先把 v{0} 安装文件和下面的“保留项”保存到 Download/JJ_MKVMaker,然后删除此应用(Android 确认窗口),再在文件应用中点按该 APK 进行安装。", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서 v{0} 설치 파일과 아래 \"남는 것\" 을 Download/JJ_MKVMaker 에 저장한 뒤 이 앱을 지우고 (Android 확인 창), 파일 앱에서 그 APK 를 눌러 설치합니다.": "Android 无法在当前应用之上安装较低版本。因此会先把 v{0} 安装文件和下面的“保留项”保存到 Download/JJ_MKVMaker,然后删除此应用(Android 确认窗口),再在文件应用中点按该 APK 进行安装。",
"Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android 无法在当前应用上安装版本更低的应用。因此:\n1. 将 v{0} 安装文件和当前设置 (包括视频列表 · 书签) 保存到 Download/JJ_MKVMaker。\n2. 卸载此应用 (Android 确认窗口)。\n3. 在文件应用中点按 Download/JJ_MKVMaker 中的 APK 进行安装。\n4. 带有此功能的版本会在启动时询问是否恢复保存的设置。更早的版本需要重新设置。\n※ 字幕 API 密钥 · 密码不会保存,需要重新输入。", "Android 는 버전이 낮은 앱을 지금 앱 위에 설치하지 못합니다. 그래서:\n1. v{0} 설치 파일과 지금 설정 (동영상 목록 · 즐겨찾기 포함) 을 Download/JJ_MKVMaker 에 저장합니다.\n2. 이 앱을 지웁니다 (Android 확인 창).\n3. 파일 앱에서 Download/JJ_MKVMaker 의 APK 를 눌러 설치합니다.\n4. 이 기능이 있는 버전이면 시작할 때 저장해 둔 설정을 되살릴지 묻습니다. 더 예전 버전은 설정을 다시 해야 합니다.\n※ 자막 API 키 · 비밀번호는 저장하지 않으므로 다시 넣어야 합니다.": "Android 无法在当前应用上安装版本更低的应用。因此:\n1. 将 v{0} 安装文件和当前设置 (包括视频列表 · 书签) 保存到 Download/JJ_MKVMaker。\n2. 卸载此应用 (Android 确认窗口)。\n3. 在文件应用中点按 Download/JJ_MKVMaker 中的 APK 进行安装。\n4. 带有此功能的版本会在启动时询问是否恢复保存的设置。更早的版本需要重新设置。\n※ 字幕 API 密钥 · 密码不会保存,需要重新输入。",
"Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "请在 Android 安装界面点击[安装]。设置、已下载的文件和 AI 模型都会保留。\n(首次可能需要开启“允许安装此来源的应用”)", "Android 설치 화면에서 [설치] 를 누르세요. 설정 · 받은 파일 · AI 모델은 그대로 남습니다.\n(처음이면 \"이 출처의 앱 설치 허용\" 을 켜야 할 수 있습니다)": "请在 Android 安装界面点击[安装]。设置、已下载的文件和 AI 模型都会保留。\n(首次可能需要开启“允许安装此来源的应用”)",
"Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "在 Android 上会先删除应用再安装旧版本。已下载的 AI 模型、密码、浏览器登录等将丢失 (下一个窗口会列出保留和丢失的内容)。", "Android 에서는 앱을 지운 뒤 예전 버전을 설치합니다. 받은 AI 모델 · 비밀번호 · 브라우저 로그인 등은 사라집니다 (남는 것과 사라지는 것은 다음 창에서 보여 줍니다).": "在 Android 上会先删除应用再安装旧版本。已下载的 AI 模型、密码、浏览器登录等将丢失 (下一个窗口会列出保留和丢失的内容)。",
"Android 웹 뷰의 프록시는 앱 전체에 걸려, Tor 화면이 열려 있는 동안 일반 웹 브라우저도 Tor 로 나갑니다 (일반 브라우저 위쪽에 안내). Tor 화면을 닫으면 원래대로 돌아갑니다.": "Android 网页视图的代理作用于整个应用,因此 Tor 画面打开期间普通网页浏览器也会经由 Tor (普通浏览器顶部会显示提示)。关闭 Tor 画面后恢复原状。",
"Android 자동 백업에서 되살린 설정으로 시작합니다": "使用从 Android 自动备份恢复的设置启动", "Android 자동 백업에서 되살린 설정으로 시작합니다": "使用从 Android 自动备份恢复的设置启动",
"Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)", "Android 자동 백업에서 이전 설정을 되살렸습니다 (동영상 목록 {0}개 · WebDAV 서버 {1}개)": "已从 Android 自动备份恢复之前的设置 (视频列表 {0} 个 · WebDAV 服务器 {1} 个)",
"Android: Tor 화면이 열려 있는 동안": "Android: Tor 画面打开期间",
"BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}", "BPE 토크나이저가 아닙니다: {0}": "不是 BPE 分词器: {0}",
"Chrome (내려받기)": "Chrome(下载)", "Chrome (내려받기)": "Chrome(下载)",
"Chrome (내장)": "Chrome(内置)", "Chrome (내장)": "Chrome(内置)",
"Chrome 에서 가져오기": "从 Chrome 导入", "Chrome 에서 가져오기": "从 Chrome 导入",
"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 엔진 (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 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.": "Chrome 引擎在重新启动应用后使用更改后的设置。",
"Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "重新启动程序后即可使用 Chrome 引擎。现在重新启动吗?", "Chrome 엔진은 프로그램을 다시 시작하면 쓸 수 있습니다. 지금 다시 시작할까요?": "重新启动程序后即可使用 Chrome 引擎。现在重新启动吗?",
"Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "无法下载 Chrome 引擎(HTTP {0})", "Chrome 엔진을 받을 수 없습니다 (HTTP {0})": "无法下载 Chrome 引擎(HTTP {0})",
"Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "无法启动 Chrome 引擎。请在设置中切换为 Edge。\n{0}", "Chrome 엔진을 시작할 수 없습니다. 환경 설정에서 Edge 로 바꿔 쓰세요.\n{0}": "无法启动 Chrome 引擎。请在设置中切换为 Edge。\n{0}",
"DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})": "DNS 强制代理已启动: 127.0.0.1:{0} ({1})",
"DNS 강제 프록시를 시작하지 못했습니다: {0}": "无法启动 DNS 强制代理: {0}",
"DNS 서버": "DNS 服务器",
"DNS 서버 ({0}) 에 닿지 않습니다: {1}": "无法连接 DNS 服务器 ({0}): {1}",
"DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}": "无法连接 DNS 服务器 ({0}),未能解析地址: {1}",
"DNS 서버 주소가 없습니다 (환경 설정 > 네트워크 · DNS)": "没有 DNS 服务器地址 (设置 > 网络 · DNS)",
"DNS 설정: {0}": "DNS 设置: {0}",
"DNS 실패 ({0}): {1}": "DNS 失败 ({0}): {1}",
"DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "将 DOC · DOCX · HWP · XLS · XLSX 转为 PDF 查看 (需下载转换器)", "DOC · DOCX · HWP · XLS · XLSX 를 PDF 로 바꿔 보기 (변환기를 받음)": "将 DOC · DOCX · HWP · XLS · XLSX 转为 PDF 查看 (需下载转换器)",
"DoH 서버 ({0}) 가 HTTP {1} 로 답했습니다": "DoH 服务器 ({0}) 返回 HTTP {1}",
"DoH 서버 ({0}) 에 닿지 않습니다: {1}": "无法连接 DoH 服务器 ({0}): {1}",
"DoH 서버 이름을 찾을 서버": "用于解析 DoH 服务器名的服务器",
"DoH 서버 이름을 찾을 수 없습니다: {0}": "无法解析 DoH 服务器名称: {0}",
"DoH 주소": "DoH 地址",
"Download/JJ_MKVMaker 에 보관한 설정 (v{0}{1}) 도 있습니다:\n· 동영상 목록 {2}개\n· WebDAV 서버: {3}": "Download/JJ_MKVMaker 中也有保存的设置 (v{0}{1}):\n· 视频列表 {2} 个\n· WebDAV 服务器: {3}", "Download/JJ_MKVMaker 에 보관한 설정 (v{0}{1}) 도 있습니다:\n· 동영상 목록 {2}개\n· WebDAV 서버: {3}": "Download/JJ_MKVMaker 中也有保存的设置 (v{0}{1}):\n· 视频列表 {2} 个\n· WebDAV 服务器: {3}",
"Edge (내장)": "Edge(内置)", "Edge (내장)": "Edge(内置)",
"Edge 에서 가져오기": "从 Edge 导入", "Edge 에서 가져오기": "从 Edge 导入",
@ -142,6 +159,8 @@
"GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "与 GitHub 上的最新版本比较,如有新版本则下载并安装", "GitHub 의 최신 버전과 비교해, 새 버전이 있으면 받아서 설치합니다": "与 GitHub 上的最新版本比较,如有新版本则下载并安装",
"HTML 파일로 내보내기": "导出为 HTML 文件", "HTML 파일로 내보내기": "导出为 HTML 文件",
"HTML 파일에서 가져오기": "从 HTML 文件导入", "HTML 파일에서 가져오기": "从 HTML 文件导入",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)": "请输入 IPv4 · IPv6 地址 (例: 8.8.4.4)",
"IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)": "请输入 IPv4 · IPv6 地址 (例: 8.8.8.8)",
"JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - 下载 {0} 个", "JJ_MKVMaker - 다운로드 {0}개": "JJ_MKVMaker - 下载 {0} 个",
"JJ_MKVMaker 로 자막 만들기": "用 JJ_MKVMaker 生成字幕", "JJ_MKVMaker 로 자막 만들기": "用 JJ_MKVMaker 生成字幕",
"JJ_MKVMaker 로 재생": "用 JJ_MKVMaker 播放", "JJ_MKVMaker 로 재생": "用 JJ_MKVMaker 播放",
@ -209,6 +228,51 @@
"Stable Diffusion v1.5": "Stable Diffusion v1.5", "Stable Diffusion v1.5": "Stable Diffusion v1.5",
"Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 采用 CreativeML OpenRAIL-M 许可证。可用于包括商业用途在内的场合,但不得用于: 违法行为、伤害或利用未成年人、意在伤害他人的虚假信息、利用个人信息伤害他人、歧视 · 骚扰、代替医疗 · 法律判断等 (许可证附件 A)。生成图片的责任由使用者承担。", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 采用 CreativeML OpenRAIL-M 许可证。可用于包括商业用途在内的场合,但不得用于: 违法行为、伤害或利用未成年人、意在伤害他人的虚假信息、利用个人信息伤害他人、歧视 · 骚扰、代替医疗 · 法律判断等 (许可证附件 A)。生成图片的责任由使用者承担。",
"TAESD (빠른 디코더)": "TAESD (快速解码器)", "TAESD (빠른 디코더)": "TAESD (快速解码器)",
"TTL 만료": "TTL 过期",
"Tor (tor 실행 파일 - GPL 을 켠 빌드)": "Tor (tor 可执行文件 - 启用 GPL 的构建)",
"Tor SOCKS 가 연결을 받지 않습니다": "Tor SOCKS 不接受连接",
"Tor SOCKS 포트를 찾지 못했습니다": "未找到 Tor SOCKS 端口",
"Tor 가 {0}:{1} 에 연결하지 못했습니다 ({2})": "Tor 无法连接到 {0}:{1} ({2})",
"Tor 가 끝났습니다 (코드 {0}) {1}": "Tor 已退出 (代码 {0}) {1}",
"Tor 가 설치되어 있지 않습니다 (환경 설정 > 컴포넌트)": "未安装 Tor (设置 > 组件)",
"Tor 가 아직 연결되지 않았습니다": "Tor 尚未连接",
"Tor 기록 지금 지우기": "立即清除 Tor 记录",
"Tor 꺼짐": "Tor 已关闭",
"Tor 끄기": "关闭 Tor",
"Tor 끔": "Tor 已关闭",
"Tor 끝남 (코드 {0}): {1}": "Tor 结束 (代码 {0}): {1}",
"Tor 내려받기 시작: {0}": "开始下载 Tor: {0}",
"Tor 로 나가는 앱 안 웹 브라우저 (쿠키 · 기록 따로, 닫으면 지움)": "经由 Tor 访问的应用内网页浏览器 (Cookie · 记录分开,关闭时清除)",
"Tor 를 받을 수 없습니다: {0}": "无法下载 Tor: {0}",
"Tor 를 시작하는 중": "正在启动 Tor",
"Tor 를 실행할 수 없습니다: {0}": "无法运行 Tor: {0}",
"Tor 브라우저": "Tor 浏览器",
"Tor 브라우저 (지금 여기)": "Tor 浏览器 (当前)",
"Tor 브라우저 안내": "关于 Tor 浏览器",
"Tor 브라우저는 페이지를 Tor 로 열어 사이트가 이 기기의 주소를 알 수 없게 합니다 (쿠키 · 기록은 따로, 닫으면 지움). 환경 설정 > 컴포넌트 에서 켜면 이 브라우저 옆에 Tor 화면이 생깁니다.": "Tor 浏览器经由 Tor 打开页面,使网站无法得知本设备的地址 (Cookie · 记录分开保存,关闭时清除)。在 设置 > 组件 中开启后,此浏览器旁会出现 Tor 画面。",
"Tor 새 경로": "Tor 新线路",
"Tor 새 경로 실패 ({0}) - 다시 시작합니다": "Tor 新线路失败 ({0}) - 正在重新启动",
"Tor 설정": "Tor 设置",
"Tor 설치 실패: {0}": "Tor 安装失败: {0}",
"Tor 설치 완료: {0}": "Tor 安装完成: {0}",
"Tor 시작 ({0})": "Tor 已启动 ({0})",
"Tor 에 연결하는 중": "正在连接 Tor",
"Tor 연결 시간 초과 {0}": "Tor 连接超时 {0}",
"Tor 연결 실패: {0}": "Tor 连接失败: {0}",
"Tor 연결 중 ({0}%) {1}": "正在连接 Tor ({0}%) {1}",
"Tor 연결됨": "已连接 Tor",
"Tor 연결됨 (SOCKS 127.0.0.1:{0})": "已连接 Tor (SOCKS 127.0.0.1:{0})",
"Tor 연결이 끝나고 웹 뷰가 Tor 로만 나가게 된 뒤에 페이지를 엽니다. 그 전에는 아무것도 읽지 않습니다.": "在 Tor 连接完成且网页视图只能经由 Tor 通信之后才打开页面。在此之前不加载任何内容。",
"Tor 제어 명령 실패: {0}": "Tor 控制命令失败: {0}",
"Tor 제어 포트가 없습니다": "没有 Tor 控制端口",
"Tor 화면 홈 주소": "Tor 画面主页地址",
"Tor 화면을 닫아도 쿠키 · 기록 · 캐시 남기기": "关闭 Tor 画面后仍保留 Cookie · 记录 · 缓存",
"Tor 화면을 준비할 수 없습니다": "无法准备 Tor 画面",
"Tor 화면을 준비할 수 없습니다: {0}": "无法准备 Tor 画面: {0}",
"Tor 화면의 JavaScript 사용": "Tor 画面使用 JavaScript",
"Tor 화면의 쿠키 · 기록 · 캐시를 지웠습니다.": "已清除 Tor 画面的 Cookie · 记录 · 缓存。",
"Tor 화면이 열려 있는 동안 이 브라우저도 Tor 로 나갑니다": "Tor 画面打开期间,此浏览器也经由 Tor 访问",
"Tor 화면이 열려 있어 지금은 지울 수 없습니다. 닫은 뒤 다시 하세요.": "Tor 画面正在打开,现在无法清除。请关闭后再试。",
"VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。", "VLC 가 설치되어 있지 않습니다. 설치하면 목록에 나타납니다 (videolan.org).": "未安装 VLC。安装后会出现在列表中(videolan.org)。",
"VLC 로 열기": "用 VLC 打开", "VLC 로 열기": "用 VLC 打开",
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}", "WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}",
@ -268,6 +332,7 @@
"ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}", "ffmpeg 를 실행할 수 없습니다: {0}": "无法运行 ffmpeg: {0}",
"ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}", "ffmpeg 오류 (코드 {0})\n{1}": "ffmpeg 错误(代码 {0})\n{1}",
"http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "http:// 地址会以未加密方式发送用户名、密码和文件。同一网络中的其他人可以看到,如果服务器支持 https,请改为 https://。", "http:// 주소는 아이디 · 비밀번호와 파일이 암호화되지 않고 그대로 갑니다. 같은 네트워크의 다른 사람이 볼 수 있으니 서버가 https 를 지원하면 https:// 로 바꾸세요.": "http:// 地址会以未加密方式发送用户名、密码和文件。同一网络中的其他人可以看到,如果服务器支持 https,请改为 https://。",
"https:// 로 시작하는 주소를 넣으세요": "请输入以 https:// 开头的地址",
"lsync (실시간 동기화)": "lsync (实时同步)", "lsync (실시간 동기화)": "lsync (实时同步)",
"lsync 로 이동": "移到 lsync", "lsync 로 이동": "移到 lsync",
"quit 시작": "开始退出", "quit 시작": "开始退出",
@ -299,6 +364,7 @@
"v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} 无法恢复已保存的设置,需要重新设置。", "v{0} 은 저장해 둔 설정을 되살리지 못해 설정을 다시 해야 합니다.": "v{0} 无法恢复已保存的设置,需要重新设置。",
"v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}", "v{0} 을 받았고 파일 검증(SHA256)을 마쳤습니다.\n{1}": "已下载 v{0} 并完成文件校验(SHA256)。\n{1}",
"whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml(whisper_ggml 内)", "whisper.cpp · ggml (whisper_ggml 안)": "whisper.cpp · ggml(whisper_ggml 内)",
"www.google.com → {0} (답한 서버: {1})": "www.google.com → {0} (应答的服务器: {1})",
"yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp(可执行文件 yt-dlp.exe)", "yt-dlp (실행 파일 yt-dlp.exe)": "yt-dlp(可执行文件 yt-dlp.exe)",
"yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp(可执行文件中打包的组件)", "yt-dlp (실행 파일에 묶인 구성 요소)": "yt-dlp(可执行文件中打包的组件)",
"yt-dlp 를 새 버전으로 바꿨습니다": "已将 yt-dlp 更新为新版本", "yt-dlp 를 새 버전으로 바꿨습니다": "已将 yt-dlp 更新为新版本",
@ -325,6 +391,7 @@
"{0} 안의 파일": "{0} 中的文件", "{0} 안의 파일": "{0} 中的文件",
"{0} 에 보내는 중": "正在发送到 {0}", "{0} 에 보내는 중": "正在发送到 {0}",
"{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)", "{0} 에 붙여넣기 ({1}개)": "粘贴到 {0} ({1} 个)",
"{0} 에 연결할 수 없습니다": "无法连接到 {0}",
"{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "无法连接到 {0}。请确认网络和 NAS 已开启。", "{0} 에 연결할 수 없습니다. 네트워크 · NAS 가 켜져 있는지 확인하세요.": "无法连接到 {0}。请确认网络和 NAS 已开启。",
"{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)", "{0} 에서 그리는 중 ({1}초)": "正在 {0} 上绘制 ({1} 秒)",
"{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件", "{0} 에서 받을 파일을 찾지 못했습니다": "未在 {0} 找到要下载的文件",
@ -390,10 +457,15 @@
"{0}줄": "{0} 行", "{0}줄": "{0} 行",
"{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 名称在手动指定时 - 关闭 ↔ 开启从下次运行起生效)",
"· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· 已下载的 AI 模型 (约 {0},需要重新下载)", "· 받은 AI 모델 (약 {0}, 다시 받아야 함)": "· 已下载的 AI 模型 (约 {0},需要重新下载)",
"· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· 转到后台: 仅隐藏窗口,继续下载 · 转换(用 {0} 或托盘图标重新打开)\n· 全部退出: 停止下载 · 转换并关闭程序", "· 백그라운드로: 창만 숨기고 다운로드 · 변환을 계속합니다 ({0} 또는 트레이 아이콘으로 다시 열기)\n· 모두 종료: 다운로드 · 변환을 멈추고 프로그램을 끝냅니다": "· 转到后台: 仅隐藏窗口,继续下载 · 转换(用 {0} 或托盘图标重新打开)\n· 全部退出: 停止下载 · 转换并关闭程序",
"· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· 密码 · API 密钥 · 主密码和最高密码", "· 비밀번호 · API 키 · 마스터 · 최상 비밀번호": "· 密码 · API 密钥 · 主密码和最高密码",
"· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· 应用内浏览器的登录 · Cookie、操作记录", "· 앱 안 브라우저의 로그인 · 쿠키, 작업 기록": "· 应用内浏览器的登录 · Cookie、操作记录",
"· 앱의 인터넷 연결: 새 버전 확인 · 컴포넌트 · AI 모델 받기 · WebDAV · 웹 페이지 번역 · 자막 검색": "· 应用的网络连接: 新版本检查 · 组件 · AI 模型下载 · WebDAV · 网页翻译 · 字幕搜索",
"· 웹 브라우저 (Edge · Chrome 엔진): 끄기 ↔ 켜기는 앱을 다시 시작한 뒤, 서버 주소는 바로": "· 网页浏览器 (Edge · Chrome 引擎): 关闭 ↔ 开启在重新启动应用后生效,服务器地址立即生效",
"· 웹 브라우저: 바로 (Tor 화면이 열려 있는 동안은 Tor)": "· 网页浏览器: 立即 (Tor 画面打开期间使用 Tor)",
"… 외 {0}개": "… 另外 {0} 个", "… 외 {0}개": "… 另外 {0} 个",
"※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。", "※ 다른 작업 중이면 대기열에 넣고 차례대로 합니다. 번역하는 동안 브라우저를 써도 됩니다.": "※ 如有其他任务正在进行,将加入队列依次处理。翻译期间可以使用浏览器。",
"※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 耗时较长(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)", "※ 시간이 오래 걸립니다 (영상 1시간 ≈ 음성인식 15~30분 + 언어당 번역 10~20분)": "※ 耗时较长(1 小时视频 ≈ 语音识别 15~30 分钟 + 每种语言翻译 10~20 分钟)",
@ -479,6 +551,7 @@
"권한 없음": "无权限", "권한 없음": "无权限",
"권한 허용 화면 열기": "打开权限授予界面", "권한 허용 화면 열기": "打开权限授予界面",
"권한이 없습니다": "没有权限", "권한이 없습니다": "没有权限",
"규칙상 허용되지 않음": "规则不允许",
"그 파일 하나만": "仅该文件", "그 파일 하나만": "仅该文件",
"그대로 두기": "保留", "그대로 두기": "保留",
"그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)", "그래도 맞추기 ({0}개 지움)": "仍然同步 (删除 {0} 个)",
@ -507,11 +580,13 @@
"금": "五", "금": "五",
"기계 번역": "机器翻译", "기계 번역": "机器翻译",
"기기": "设备", "기기": "设备",
"기기 DNS": "设备 DNS",
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)", "기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。", "기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})", "기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
"기능 버튼 줄": "功能按钮栏", "기능 버튼 줄": "功能按钮栏",
"기본": "默认", "기본": "默认",
"기본 DNS": "首选 DNS",
"기본 앱으로 열기": "用默认应用打开", "기본 앱으로 열기": "用默认应用打开",
"기본 연결 프로그램": "默认打开方式", "기본 연결 프로그램": "默认打开方式",
"기본 자막 언어": "默认字幕语言", "기본 자막 언어": "默认字幕语言",
@ -529,6 +604,8 @@
"길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选", "길게 누르기 · 오른쪽 클릭은 늘 기능 메뉴 (복사 · 이동 · 삭제 · 이름 변경 …). Ctrl + 클릭: 여러 개 고르기": "长按 · 右键总是打开功能菜单 (复制 · 移动 · 删除 · 重命名 …)。Ctrl + 点击: 多选",
"길이 {0}": "时长 {0}", "길이 {0}": "时长 {0}",
"끄기": "关闭", "끄기": "关闭",
"끄면 (기본) Tor 화면을 닫을 때 지웁니다. 일반 브라우저와는 늘 따로입니다.": "关闭时 (默认) 在关闭 Tor 画面时清除。始终与普通浏览器分开。",
"끄면 더 안전하지만 많은 사이트가 보이지 않습니다 (다음에 열 때부터).": "关闭更安全,但很多网站将无法显示 (从下次打开起)。",
"끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "关闭后将直接使用上次的设置开始: 原语言 {0},语言 {1},{2}", "끄면 마지막 설정으로 바로 시작: 원어 {0}, 언어 {1}, {2}": "关闭后将直接使用上次的设置开始: 原语言 {0},语言 {1},{2}",
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). Chrome 엔진은 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。使用 Chrome 引擎时,页面翻译 · 视频检测也会停止。",
"끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。", "끄면 페이지의 스크립트를 실행하지 않습니다 (광고 · 추적이 줄지만 YouTube 처럼 스크립트로 만든 사이트는 보이지 않음). 끄면 페이지 번역 · 동영상 찾기도 멈춥니다.": "关闭后不运行页面脚本 (广告 · 跟踪会减少,但 YouTube 等由脚本构建的网站将无法显示)。页面翻译 · 视频检测也会停止。",
@ -564,6 +641,7 @@
"내려받는 중": "正在下载", "내려받는 중": "正在下载",
"내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)", "내려받다 끊긴 모델 (.part) · 변환하다 남은 파일 (.tmp)": "下载中断的模型(.part)· 转换残留(.tmp)",
"내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎", "내려받은 Chrome 엔진 지우기": "删除已下载的 Chrome 引擎",
"내려받은 Tor 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "删除已下载的 Tor 文件。再次开启时会重新下载。",
"내려받은 rsync 지우기": "删除已下载的 rsync", "내려받은 rsync 지우기": "删除已下载的 rsync",
"내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "删除已下载的转换器文件。再次开启时会重新下载。", "내려받은 변환기 파일을 지웁니다. 다시 켜면 다시 내려받습니다.": "删除已下载的转换器文件。再次开启时会重新下载。",
"내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "已删除导出的 Cookie 文件。如需同时清除浏览器的登录,请在网页浏览器画面中点按 [清除 Cookie · 浏览记录]。", "내보낸 쿠키 파일을 지웠습니다. 브라우저의 로그인까지 지우려면 웹 브라우저 화면의 [쿠키 · 방문 기록 지우기] 를 누르세요.": "已删除导出的 Cookie 文件。如需同时清除浏览器的登录,请在网页浏览器画面中点按 [清除 Cookie · 浏览记录]。",
@ -587,6 +665,7 @@
"넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右", "넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右",
"넣지 못했습니다: {0}": "无法加入: {0}", "넣지 못했습니다: {0}": "无法加入: {0}",
"네덜란드어": "荷兰语", "네덜란드어": "荷兰语",
"네트워크 · DNS": "网络 · DNS",
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。",
"네팔어": "尼泊尔语", "네팔어": "尼泊尔语",
"노랑": "黄色", "노랑": "黄色",
@ -667,6 +746,7 @@
"단축키 해제": "注销快捷键", "단축키 해제": "注销快捷键",
"닫기": "关闭", "닫기": "关闭",
"닫힘 (실행 안 함)": "已关闭 (未执行)", "닫힘 (실행 안 함)": "已关闭 (未执行)",
"답이 없습니다": "没有应答",
"대기": "等待", "대기": "等待",
"대기 {0}개:": "等待 {0} 个:", "대기 {0}개:": "等待 {0} 个:",
"대기 · 다음 시작 {0}": "等待 · 下次开始 {0}", "대기 · 다음 시작 {0}": "等待 · 下次开始 {0}",
@ -753,6 +833,7 @@
"등록": "注册", "등록": "注册",
"등록 중 일부가 실패했습니다.": "部分注册失败。", "등록 중 일부가 실패했습니다.": "部分注册失败。",
"등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)", "등록됨: \"JJ_MKVMaker 로 재생\" · \"JJ_MKVMaker 로 자막 만들기\" (Windows 11: 더 많은 옵션 표시)": "已注册: “用 JJ_MKVMaker 播放” · “用 JJ_MKVMaker 生成字幕”(Windows 11: 显示更多选项)",
"따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)": "不适用: Tor 画面 (名称也在 Tor 内解析)、IP 地址、家庭网络内的名称 (不带点的 NAS · PC 名称、*.local、Tailscale 的 *.ts.net 等使用设备 DNS)",
"라오어": "老挝语", "라오어": "老挝语",
"라이선스 원문 보기": "查看许可证原文", "라이선스 원문 보기": "查看许可证原文",
"러시아어": "俄语", "러시아어": "俄语",
@ -792,6 +873,7 @@
"만화 · 그림용은 가볍고 빠르며, 사진용은 사진의 질감을 더 잘 살리지만 그래픽카드가 없으면 느립니다.": "漫画 · 插画模型轻巧快速;照片模型更能保留照片质感,但没有显卡时较慢。", "만화 · 그림용은 가볍고 빠르며, 사진용은 사진의 질감을 더 잘 살리지만 그래픽카드가 없으면 느립니다.": "漫画 · 插画模型轻巧快速;照片模型更能保留照片质感,但没有显卡时较慢。",
"만화 보기": "漫画阅读", "만화 보기": "漫画阅读",
"말레이어": "马来语", "말레이어": "马来语",
"망에 닿을 수 없음": "网络不可达",
"맞추기 (검은 여백)": "适应(黑边)", "맞추기 (검은 여백)": "适应(黑边)",
"맞추기 (비율이 다를 때)": "适配方式(比例不同时)", "맞추기 (비율이 다를 때)": "适配方式(比例不同时)",
"맞추기 (흐린 배경)": "适应(模糊背景)", "맞추기 (흐린 배경)": "适应(模糊背景)",
@ -891,6 +973,7 @@
"받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "安装更新 · 必需程序 · Chrome 引擎后残留的临时文件", "받은 업데이트 · 필수 프로그램 · Chrome 엔진을 설치하고 남은 임시 파일": "安装更新 · 必需程序 · Chrome 引擎后残留的临时文件",
"받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "将收到的字幕翻译成韩语并一并添加(没有韩语字幕时)", "받은 자막을 한국어로 번역해 함께 추가 (한국어 자막이 없을 때)": "将收到的字幕翻译成韩语并一并添加(没有韩语字幕时)",
"받은 파일에 libcef.dll 이 없습니다.": "下载的文件中没有 libcef.dll。", "받은 파일에 libcef.dll 이 없습니다.": "下载的文件中没有 libcef.dll。",
"받은 파일에 tor.exe 가 없습니다": "下载的文件中没有 tor.exe",
"받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。", "받은 파일이 손상되었거나 다른 파일입니다 (SHA256 불일치). 설치를 중단했습니다.": "下载的文件已损坏或不是正确的文件(SHA256 不匹配)。已停止安装。",
"받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "下载的文件已损坏 (SHA-256 不一致): {0}", "받은 파일이 손상되었습니다 (SHA-256 다름): {0}": "下载的文件已损坏 (SHA-256 不一致): {0}",
"받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}", "받을 수 없습니다 ({0}): {1}": "无法下载 ({0}): {1}",
@ -938,11 +1021,13 @@
"보기": "查看", "보기": "查看",
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放", "보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔",
"보내지 못했습니다": "无法发送",
"보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}", "보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}",
"보던 폴더가 없어져 상위 폴더로 옮겼습니다": "正在查看的文件夹已不存在,已转到上级文件夹", "보던 폴더가 없어져 상위 폴더로 옮겼습니다": "正在查看的文件夹已不存在,已转到上级文件夹",
"보안": "安全", "보안": "安全",
"보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。", "보일 버튼을 고르고, 끌어서 순서를 바꿉니다.": "选择要显示的按钮,拖动以调整顺序。",
"보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。", "보정은 다시 인코딩해야 해서 코덱을 \"원본 유지\" 에서 H.264 로 바꿨습니다.": "调整需要重新编码,因此编解码器已从“保持原样”改为 H.264。",
"보조 DNS (비워 둘 수 있음)": "备用 DNS (可留空)",
"복사": "复制", "복사": "复制",
"복사 (붙여넣기로)": "复制 (用于粘贴)", "복사 (붙여넣기로)": "复制 (用于粘贴)",
"복사 · rsync": "复制 · rsync", "복사 · rsync": "复制 · rsync",
@ -1015,6 +1100,9 @@
"삭제: {0}": "已删除: {0}", "삭제: {0}": "已删除: {0}",
"상위 폴더": "上级文件夹", "상위 폴더": "上级文件夹",
"상하 반전": "上下翻转", "상하 반전": "上下翻转",
"새 Tor 경로로 바꿨습니다. 페이지를 다시 읽습니다.": "已切换到新的 Tor 线路。正在重新加载页面。",
"새 경로": "新线路",
"새 경로: 앞으로의 연결을 새 Tor 회로로 (사이트가 다른 사람으로 봄)": "新线路: 之后的连接使用新的 Tor 线路 (网站会视为另一个人)",
"새 마스터 비밀번호 정하기": "设置新的主密码", "새 마스터 비밀번호 정하기": "设置新的主密码",
"새 버전 v{0}": "新版本 v{0}", "새 버전 v{0}": "新版本 v{0}",
"새 버전 v{0} 이 있습니다": "有新版本 v{0}", "새 버전 v{0} 이 있습니다": "有新版本 v{0}",
@ -1045,6 +1133,7 @@
"서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应", "서버가 너무 오래 답하지 않습니다": "服务器长时间没有响应",
"서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。", "서버가 다른 주소로 옮기라고 합니다 ({0}). 서버 설정의 주소를 확인하세요.": "服务器要求改用其他地址 ({0})。请检查服务器设置中的地址。",
"서버가 이 기능을 허용하지 않음": "服务器不允许此操作", "서버가 이 기능을 허용하지 않음": "服务器不允许此操作",
"서버에 닿을 수 없음": "主机不可达",
"서버에 닿지 않습니다": "无法连接服务器", "서버에 닿지 않습니다": "无法连接服务器",
"서버에서 이 계정의 권한을 확인하세요.": "请在服务器上检查此账户的权限。", "서버에서 이 계정의 권한을 확인하세요.": "请在服务器上检查此账户的权限。",
"서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "需要与服务器在同一网络,或打开 VPN (如 Tailscale)。打开后点 [重试]。", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "需要与服务器在同一网络,或打开 VPN (如 Tailscale)。打开后点 [重试]。",
@ -1088,6 +1177,7 @@
"설치 화면을 열 수 없습니다: {0}": "无法打开安装界面: {0}", "설치 화면을 열 수 없습니다: {0}": "无法打开安装界面: {0}",
"설치가 끝나면 프로그램이 다시 시작됩니다.": "安装完成后程序将重新启动。", "설치가 끝나면 프로그램이 다시 시작됩니다.": "安装完成后程序将重新启动。",
"설치됨": "已安装", "설치됨": "已安装",
"설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.": "已安装: Tor Expert Bundle {0} (Tor 官方发布)。关闭时删除。",
"설치하는 중": "正在安装", "설치하는 중": "正在安装",
"설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)", "설치하면 필요한 파일을 내려받습니다 (준비 중)": "安装时会下载所需文件 (准备中)",
"설치하지 못했습니다.\n{0}": "未能安装。\n{0}", "설치하지 못했습니다.\n{0}": "未能安装。\n{0}",
@ -1121,6 +1211,9 @@
"시드 (-1 은 아무렇게나)": "种子 (-1 为随机)", "시드 (-1 은 아무렇게나)": "种子 (-1 为随机)",
"시드 {0}": "种子 {0}", "시드 {0}": "种子 {0}",
"시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件", "시리즈: file_001 · file_002, S01E01 · S01E02 처럼 번호만 다른 파일": "系列: 像 file_001 · file_002、S01E01 · S01E02 这样仅编号不同的文件",
"시스템 DNS": "系统 DNS",
"시스템 DNS (끄기)": "系统 DNS (关闭)",
"시스템 DNS 로 바꾸기": "切换为系统 DNS",
"시스템 막대 보이기 (시계 · 알림 · 뒤로)": "显示系统栏 (时钟 · 通知 · 返回)", "시스템 막대 보이기 (시계 · 알림 · 뒤로)": "显示系统栏 (时钟 · 通知 · 返回)",
"시스템 언어 따르기 ({0})": "跟随系统语言 ({0})", "시스템 언어 따르기 ({0})": "跟随系统语言 ({0})",
"시작": "开始", "시작": "开始",
@ -1199,6 +1292,7 @@
"앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)", "앱 안 웹 브라우저: [건너뛰기] 를 대신 누르고, 건너뛸 수 없는 광고는 소리를 끄고 빨리 넘깁니다 (넘기는 동안 화면 위쪽에 \"광고 건너뛰는 중…\")": "应用内网页浏览器: 代为点击[跳过],无法跳过的广告会静音并快进(跳过时屏幕顶部显示“正在跳过广告…”)",
"앱 지우기": "卸载应用", "앱 지우기": "卸载应用",
"앱에 들어 있는 것 (기본)": "应用内置 (默认)", "앱에 들어 있는 것 (기본)": "应用内置 (默认)",
"앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.": "已内置于应用 (Tor {0},tor {1})。开启后在打开 Tor 画面之前不会启动任何内容。",
"앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "重新安装应用后,Android 自动备份 (Google 备份) 恢复了之前的设置。\n· 视频列表 {0} 个\n· WebDAV 服务器: {1}", "앱을 다시 설치하자 Android 자동 백업 (Google 백업) 이 이전 설정을 되살렸습니다.\n· 동영상 목록 {0}개\n· WebDAV 서버: {1}": "重新安装应用后,Android 自动备份 (Google 备份) 恢复了之前的设置。\n· 视频列表 {0} 个\n· WebDAV 服务器: {1}",
"앱을 다시 켤 때 동기화": "应用重新启动时同步", "앱을 다시 켤 때 동기화": "应用重新启动时同步",
"앱을 닫아 진행 중이던 작업 (MKV · 다운로드 · 동기화) 이 멈췄습니다. 앱을 닫아도 계속하려면 환경 설정 > Rsync > '백그라운드로 실행' 을 켜세요.": "关闭应用后,正在进行的任务(MKV · 下载 · 同步)已停止。如需关闭应用后继续,请开启 设置 > Rsync >“在后台运行”。", "앱을 닫아 진행 중이던 작업 (MKV · 다운로드 · 동기화) 이 멈췄습니다. 앱을 닫아도 계속하려면 환경 설정 > Rsync > '백그라운드로 실행' 을 켜세요.": "关闭应用后,正在进行的任务(MKV · 下载 · 同步)已停止。如需关闭应用后继续,请开启 设置 > Rsync >“在后台运行”。",
@ -1233,11 +1327,14 @@
"여러 개를 골랐을 때": "选择多个时", "여러 개를 골랐을 때": "选择多个时",
"여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)", "여러 동영상을 MKV 로 만들 때 동시에 처리할 개수 (재인코딩은 CPU 를 많이 씁니다)": "将多个视频制作成 MKV 时同时处理的数量(重新编码会占用大量 CPU)",
"여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入", "여러 번 틀려 {0}초 뒤에 다시 넣을 수 있습니다": "错误次数过多,{0} 秒后可再次输入",
"연결 거부": "连接被拒绝",
"연결 실패: {0}": "连接失败: {0}", "연결 실패: {0}": "连接失败: {0}",
"연결 안 됨": "未连接", "연결 안 됨": "未连接",
"연결 확인": "测试连接", "연결 확인": "测试连接",
"연결됨": "已连接",
"연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}", "연결됨: 항목 {0}개{1}": "已连接: {0} 个项目{1}",
"연결이 끊겨 다시 잇는 중 ({0}번째)": "连接中断,正在重新连接 (第 {0} 次)", "연결이 끊겨 다시 잇는 중 ({0}번째)": "连接中断,正在重新连接 (第 {0} 次)",
"연결이 끊겼습니다": "连接已断开",
"연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "连接已中断。点按 [继续下载] 将从中断处继续", "연결이 끊겼습니다. [이어 받기] 를 누르면 받은 곳부터 이어 받습니다": "连接已中断。点按 [继续下载] 将从中断处继续",
"연결할 수 없습니다": "无法连接", "연결할 수 없습니다": "无法连接",
"연도": "年份", "연도": "年份",
@ -1301,6 +1398,7 @@
"왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "请在左右窗口中各选择一个文件夹。", "왼쪽 · 오른쪽 창에서 폴더를 하나씩 고르세요.": "请在左右窗口中各选择一个文件夹。",
"왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "用左侧手柄调整顺序 · 将图标拖到左侧文件夹或列表中的文件夹即可移入 · 右键菜单", "왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴": "用左侧手柄调整顺序 · 将图标拖到左侧文件夹或列表中的文件夹即可移入 · 右键菜单",
"왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "左侧显示文件夹,右侧显示左侧所选文件夹中的文件 (类似 Windows 资源管理器)。", "왼쪽에 폴더, 오른쪽에 왼쪽에서 고른 폴더의 파일들을 보여 줍니다 (Windows 탐색기처럼).": "左侧显示文件夹,右侧显示左侧所选文件夹中的文件 (类似 Windows 资源管理器)。",
"요청 머리가 너무 깁니다": "请求头过长",
"요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。", "요청이 너무 많습니다. 잠시 후 다시 시도하세요.": "请求过多。请稍后再试。",
"우르두어": "乌尔都语", "우르두어": "乌尔都语",
"우크라이나어": "乌克兰语", "우크라이나어": "乌克兰语",
@ -1352,6 +1450,8 @@
"원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)", "원어: 자동 감지 (글자로 추정)": "原语言: 自动检测(根据文字推测)",
"원인: {0}": "原因: {0}", "원인: {0}": "原因: {0}",
"월": "一", "월": "一",
"웹 뷰 프록시: {0}": "网页视图代理: {0}",
"웹 뷰 프록시를 걸지 못했습니다: {0}": "无法设置网页视图代理: {0}",
"웹 브라우저": "网页浏览器", "웹 브라우저": "网页浏览器",
"웹 브라우저 (지금 여기)": "网页浏览器(当前位置)", "웹 브라우저 (지금 여기)": "网页浏览器(当前位置)",
"웹 브라우저 (홈 주소)": "网页浏览器(主页)", "웹 브라우저 (홈 주소)": "网页浏览器(主页)",
@ -1397,6 +1497,7 @@
"이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用",
"이 기기용 파일 없음": "没有此设备的文件", "이 기기용 파일 없음": "没有此设备的文件",
"이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码", "이 기기의 안전 저장소를 쓸 수 없어 비밀번호를 저장하지 못했습니다": "本设备的安全存储不可用,无法保存密码",
"이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다": "此设备的网页视图无法打开 Tor 画面",
"이 기기의 폴더": "本设备上的文件夹", "이 기기의 폴더": "本设备上的文件夹",
"이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)", "이 단축키를 등록할 수 없습니다 (다른 프로그램이 사용 중일 수 있음)": "无法注册此快捷键(可能已被其他程序使用)",
"이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "这两个文件夹正在进行 rsync。请等完成后再操作。", "이 두 폴더는 지금 rsync 하는 중입니다. 끝난 뒤에 하세요.": "这两个文件夹正在进行 rsync。请等完成后再操作。",
@ -1412,8 +1513,10 @@
"이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "要使用此服务器保存的密码,请输入主密码。", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "要使用此服务器保存的密码,请输入主密码。",
"이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "此服务方式不支持 图片 → 图片", "이 서비스 방식은 그림 → 그림을 지원하지 않습니다": "此服务方式不支持 图片 → 图片",
"이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "使用此服务绘制时,图片和提示词会发送到设备外 (此地址)。", "이 서비스로 그리면 그림 · 프롬프트가 기기 밖 (이 주소) 으로 나갑니다.": "使用此服务绘制时,图片和提示词会发送到设备外 (此地址)。",
"이 설정을 따르는 것": "遵循此设置的项目",
"이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)", "이 설치본에는 rsync 가 들어 있지 않습니다 (rsync 로 복사할 때 · 오른쪽 [내려받기] 로 받습니다)": "此安装中没有 rsync (使用 rsync 复制时 · 或用右侧 [下载] 获取)",
"이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。", "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。",
"이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "此应用 (APK) 未包含 Tor。",
"이 언어 지우기": "删除此语言", "이 언어 지우기": "删除此语言",
"이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。",
"이 이동 버튼 지우기": "删除此移动按钮", "이 이동 버튼 지우기": "删除此移动按钮",
@ -1437,6 +1540,7 @@
"이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件", "이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件",
"이 폴더의 동영상 재생": "播放此文件夹中的视频", "이 폴더의 동영상 재생": "播放此文件夹中的视频",
"이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "没有可打开此格式的应用。请长按或右键 → [用内置播放器播放] 试试。", "이 형식을 여는 앱이 없습니다. 길게 누르거나 오른쪽 클릭 → [내장 플레이어로 재생] 으로 들어 보세요.": "没有可打开此格式的应用。请长按或右键 → [用内置播放器播放] 试试。",
"이 화면은 앱 안 웹 뷰의 연결을 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)。",
"이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "双击这些扩展名的文件会用图片查看打开 (点右侧看下一张,点左侧看上一张)。", "이 확장자의 파일을 두 번 누르면 그림 보기로 엽니다 (오른쪽을 누르면 다음, 왼쪽을 누르면 이전 그림).": "双击这些扩展名的文件会用图片查看打开 (点右侧看下一张,点左侧看上一张)。",
"이동": "移动", "이동": "移动",
"이동 ({0})": "移动({0})", "이동 ({0})": "移动({0})",
@ -1462,6 +1566,7 @@
"이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}", "이름을 바꿀 수 없습니다: {0}": "无法重命名: {0}",
"이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。", "이름을 바꿀 항목 하나를 고르세요.": "请选择一个要重命名的项目。",
"이름을 바꿉니다": "重命名", "이름을 바꿉니다": "重命名",
"이름을 찾을 수 없습니다: {0} (DNS {1})": "找不到名称: {0} (DNS {1})",
"이미 그리는 중입니다": "已在绘制中", "이미 그리는 중입니다": "已在绘制中",
"이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。", "이미 동영상 목록에 있습니다 ({0}개).": "已在视频列表中({0} 个)。",
"이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。", "이미 목록에 있는 동영상입니다 ({0}개).": "这些视频已在列表中({0} 个)。",
@ -1492,6 +1597,7 @@
"인터넷 자막 찾기{0}": "在线查找字幕{0}", "인터넷 자막 찾기{0}": "在线查找字幕{0}",
"일": "日", "일": "日",
"일반": "常规", "일반": "常规",
"일반 오류": "一般错误",
"일본어": "日语", "일본어": "日语",
"일시정지": "暂停", "일시정지": "暂停",
"읽기 전용 매체입니다": "只读介质", "읽기 전용 매체입니다": "只读介质",
@ -1665,7 +1771,10 @@
"주말 종일": "周末 全天", "주말 종일": "周末 全天",
"주소": "地址", "주소": "地址",
"주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "未找到地址 (http · https · 磁力),或已在下载。", "주소 (http · https · 마그넷) 를 찾지 못했거나 이미 받는 중입니다.": "未找到地址 (http · https · 磁力),或已在下载。",
"주소 이름 (예: youtube.com) 을 찾을 때 물어볼 서버. 직접 지정하면 기기의 DNS 대신 이 서버만 씁니다. 서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 알립니다.": "解析主机名 (例: youtube.com) 时询问的服务器。手动指定时只使用此服务器而不使用设备 DNS。无法连接服务器时会提示,而不会悄悄切换到设备 DNS。",
"주소 이름이 너무 깁니다: {0}": "主机名过长: {0}",
"주소 입력 또는 검색 (Ctrl+L)": "输入地址或搜索(Ctrl+L)", "주소 입력 또는 검색 (Ctrl+L)": "输入地址或搜索(Ctrl+L)",
"주소 입력 또는 검색 (DuckDuckGo)": "输入地址或搜索 (DuckDuckGo)",
"주소로 열기": "按地址打开", "주소로 열기": "按地址打开",
"주소를 https 로 바꾸고 다시 확인": "将地址改为 https 并重新检查", "주소를 https 로 바꾸고 다시 확인": "将地址改为 https 并重新检查",
"주소에 영상이 없습니다 (404)": "此地址没有视频 (404)", "주소에 영상이 없습니다 (404)": "此地址没有视频 (404)",
@ -1708,6 +1817,7 @@
"지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}", "지금 쓰는 버전: v{0} → 새 버전: v{1}": "当前版本: v{0} → 新版本: v{1}",
"지금 작업 취소: {0}": "已取消当前任务: {0}", "지금 작업 취소: {0}": "已取消当前任务: {0}",
"지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)", "지금 작업만 (대기 {0}개는 계속)": "仅当前任务 (等待中的 {0} 个继续)",
"지금 확인": "立即检查",
"지금: {0}": "当前: {0}", "지금: {0}": "当前: {0}",
"지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。", "지금은 듀얼 앱 (복제한 앱) 입니다. Android 가 듀얼 앱에는 이 권한을 주지 않을 수 있습니다. 허용 화면에서 켤 수 없으면 배지 없는 원래 JJ_MKVMaker 아이콘으로 여세요.": "当前是双开应用 (复制的应用)。Android 可能不会给双开应用此权限。如果无法开启,请从没有徽标的原 JJ_MKVMaker 图标打开。",
"지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。", "지난 실행이 정상적으로 끝나지 않았습니다 (마지막 확인 {0}:{1}). 작업 기록에 남겨 두었습니다.": "上次运行未正常结束(最后检查 {0}:{1})。已记录在任务记录中。",
@ -1727,8 +1837,10 @@
"지울 목록 보고 결정": "查看要删除的列表并决定", "지울 목록 보고 결정": "查看要删除的列表并决定",
"지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。", "지울 항목을 고르세요 (누르거나 오른쪽 동그라미로 표시).": "请选择要删除的项目 (点按,或用右侧圆圈标记)。",
"지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})", "지원하지 않는 protobuf 형식 (wire type {0}, 위치 {1})": "不支持的 protobuf 格式(wire type {0},位置 {1})",
"지원하지 않는 명령": "不支持的命令",
"지원하지 않는 문자셋: {0}": "不支持的字符集: {0}", "지원하지 않는 문자셋: {0}": "不支持的字符集: {0}",
"지원하지 않는 사이트입니다": "不支持此网站", "지원하지 않는 사이트입니다": "不支持此网站",
"지원하지 않는 주소 형식": "不支持的地址类型",
"지정한 파일이 없습니다": "指定的文件不存在", "지정한 파일이 없습니다": "指定的文件不存在",
"직접 고르기: {0}": "自选: {0}", "직접 고르기: {0}": "自选: {0}",
"직접 고르기…": "自选…", "직접 고르기…": "自选…",
@ -1738,6 +1850,7 @@
"직접 입력": "手动输入", "직접 입력": "手动输入",
"직접 입력…": "手动输入…", "직접 입력…": "手动输入…",
"직접 지정": "手动指定", "직접 지정": "手动指定",
"직접 지정 (기본)": "手动指定 (默认)",
"진행 중인 작업 없음": "没有进行中的任务", "진행 중인 작업 없음": "没有进行中的任务",
"진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?", "진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?": "有正在进行的任务: {0}\n要停止并退出吗?",
"집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。", "집 NAS 처럼 자체 서명 인증서면 [서버 설정 고치기] 에서 \"인증서 확인 안 함\" 을 켜세요.": "如果是自签名证书 (如家用 NAS),请在 [修改服务器设置] 中开启“不验证证书”。",
@ -1763,6 +1876,7 @@
"채도": "饱和度", "채도": "饱和度",
"채우기 (가운데 잘라냄)": "填充(裁切中间)", "채우기 (가운데 잘라냄)": "填充(裁切中间)",
"처리 장치": "处理器", "처리 장치": "处理器",
"처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)": "起始 · 🏠 地址 (默认: Tor 连接检查页面)",
"처음 사용 시 내려받기 {0}": "首次使用时下载 {0}", "처음 사용 시 내려받기 {0}": "首次使用时下载 {0}",
"처음 시작 시": "启动时", "처음 시작 시": "启动时",
"처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)", "처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)",
@ -1809,6 +1923,7 @@
"컴포넌트": "组件", "컴포넌트": "组件",
"컴포넌트 목록을 받을 수 없습니다 ({0})": "无法获取组件列表({0})", "컴포넌트 목록을 받을 수 없습니다 ({0})": "无法获取组件列表({0})",
"켜기": "开启", "켜기": "开启",
"켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "开启后从 Tor 官方发布处下载 Tor Expert Bundle {0} (tor {1}) (约 {2}MB,校验 SHA-256)。关闭时删除。",
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "开启后双击会直接翻看其中的图片;关闭则打开文件列表 (选择解压)。", "켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "开启后双击会直接翻看其中的图片;关闭则打开文件列表 (选择解压)。",
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "开启时会下载并安装转换器 (约 420MB)。关闭时会删除。", "켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "开启时会下载并安装转换器 (约 420MB)。关闭时会删除。",
"켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "开启后移到回收站,可以恢复 (Shift+Delete 为永久删除)。关闭后始终按永久删除询问。网络驱动器 · U 盘没有回收站,始终永久删除", "켜면 휴지통으로 보내 되살릴 수 있습니다 (Shift+Delete 는 영구 삭제). 끄면 늘 영구 삭제로 묻습니다. 네트워크 드라이브 · USB 메모리는 휴지통이 없어 늘 영구 삭제입니다": "开启后移到回收站,可以恢复 (Shift+Delete 为永久删除)。关闭后始终按永久删除询问。网络驱动器 · U 盘没有回收站,始终永久删除",
@ -1994,12 +2109,14 @@
"확인 중…": "正在检查…", "확인 중…": "正在检查…",
"확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "在确认窗口中提示后允许。有删除 (--delete) 时始终阻止。关闭后始终阻止", "확인 창에서 알린 뒤 허용합니다. 지우기 (--delete) 가 있으면 늘 막습니다. 끄면 늘 막습니다": "在确认窗口中提示后允许。有删除 (--delete) 时始终阻止。关闭后始终阻止",
"확인됨": "已校验", "확인됨": "已校验",
"확인하는 중…": "正在检查…",
"확장 설치": "安装扩展", "확장 설치": "安装扩展",
"확장 설치: {0}": "安装扩展: {0}", "확장 설치: {0}": "安装扩展: {0}",
"확장을 설치할 수 없습니다 ({0}): {1}": "无法安装扩展({0}): {1}", "확장을 설치할 수 없습니다 ({0}): {1}": "无法安装扩展({0}): {1}",
"확장자": "扩展名", "확장자": "扩展名",
"확장자별 재생 프로그램": "按扩展名设置播放程序", "확장자별 재생 프로그램": "按扩展名设置播放程序",
"환경 설정": "设置", "환경 설정": "设置",
"환경 설정 > 컴포넌트": "设置 > 组件",
"환경 설정 열기": "打开设置", "환경 설정 열기": "打开设置",
"환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "按设置{0} (设置 > 文件资源管理器 > 存在同名项目时)", "환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "按设置{0} (设置 > 文件资源管理器 > 存在同名项目时)",
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "要打开设置,请输入主密码。在关闭应用前不会再次询问。", "환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "要打开设置,请输入主密码。在关闭应用前不会再次询问。",

View File

@ -7,7 +7,7 @@ JJ_MKVMaker - 포함된 오픈 소스 구성 요소 및 라이선스 고지
(GPL-2.0.txt · GPL-3.0.txt · LGPL-2.1.txt · PDFIUM_LICENSES.txt · YT-DLP_THIRD_PARTY_LICENSES.txt · (GPL-2.0.txt · GPL-3.0.txt · LGPL-2.1.txt · PDFIUM_LICENSES.txt · YT-DLP_THIRD_PARTY_LICENSES.txt ·
RSYNC_COPYING.txt · WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt · STABLE_DIFFUSION_CPP_LICENSES.txt · RSYNC_COPYING.txt · WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt · STABLE_DIFFUSION_CPP_LICENSES.txt ·
CREATIVEML_OPENRAIL_M.txt · CREATIVEML_OPENRAIL_PP_M.txt · TAESD_LICENSE.txt · REAL_ESRGAN_LICENSE.txt · CREATIVEML_OPENRAIL_M.txt · CREATIVEML_OPENRAIL_PP_M.txt · TAESD_LICENSE.txt · REAL_ESRGAN_LICENSE.txt ·
WHISPER_MODEL_LICENSE.txt · NLLB_CC_BY_NC_4.0.txt) 및 ffmpeg\, tools\, rsync\ 폴더의 *_LICENSE*, *_COPYING* 파일에서 볼 수 있습니다. WHISPER_MODEL_LICENSE.txt · NLLB_CC_BY_NC_4.0.txt · TOR_LICENSES.txt) 및 ffmpeg\, tools\, rsync\ 폴더의 *_LICENSE*, *_COPYING* 파일에서 볼 수 있습니다.
WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도 둡니다. WHISPER_CPP_LICENSE.txt · ONNXRUNTIME_LICENSES.txt 는 실행 파일 옆에도 둡니다.
[프로그램에 연결된 라이브러리] [프로그램에 연결된 라이브러리]
@ -113,6 +113,17 @@ Android 판은 위의 GPLv3 구성 요소를 포함하며, 앱 자체의 대응
이 프로그램의 GitHub 릴리스 (components) 에 고치지 않은 공식 파일을 올려 두고 받습니다. 이 프로그램의 GitHub 릴리스 (components) 에 고치지 않은 공식 파일을 올려 두고 받습니다.
소스: https://github.com/ebandal/H2Orestart/tree/v0.7.14 원문: GPL-3.0.txt 소스: https://github.com/ebandal/H2Orestart/tree/v0.7.14 원문: GPL-3.0.txt
[Tor 브라우저 - 환경 설정 > 컴포넌트 에서 켤 때 내려받는 구성 요소 (Windows)]
- Tor 0.4.9.13 (tor.exe, Tor Browser 15.0.24 의 Tor Expert Bundle) GPL-3.0 (GPL 을 켠 빌드, 소스는 BSD 3-Clause)
https://www.torproject.org
The Tor Project 공식 배포처 (archive.torproject.org) 에서 받으며 SHA-256 을 확인합니다. 앱 데이터 폴더의 components\tor 에 둡니다.
함께 묶인 구성 요소: Libevent (BSD 3-Clause), OpenSSL (Apache-2.0), zlib (zlib License)
소스: https://archive.torproject.org/tor-package-archive/tor-0.4.9.13.tar.gz
빌드: https://gitlab.torproject.org/tpo/applications/tor-browser-build (태그 tbb-15.0.24-build1)
원문: TOR_LICENSES.txt (components\tor\docs 에도), GPL-3.0.txt
Tor 브라우저 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며 공식 Tor Browser 가 아닙니다.
[처음 사용할 때 내려받는 AI 모델] [처음 사용할 때 내려받는 AI 모델]
- Whisper 모델 (ggml-base.bin 등) MIT (OpenAI Whisper) https://huggingface.co/ggerganov/whisper.cpp - Whisper 모델 (ggml-base.bin 등) MIT (OpenAI Whisper) https://huggingface.co/ggerganov/whisper.cpp

View File

@ -5,7 +5,7 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스
라이선스 원문 (GPL-2.0 · GPL-3.0 · LGPL-2.1 · PDFium · yt-dlp 등) 은 이 화면 (환경 설정 > 프로그램 정보 > 라이선스 원문 (GPL-2.0 · GPL-3.0 · LGPL-2.1 · PDFium · yt-dlp 등) 은 이 화면 (환경 설정 > 프로그램 정보 >
오픈 소스 라이선스) 의 [패키지 라이선스] 에서 구성 요소 이름을 눌러 볼 수 있습니다. 오픈 소스 라이선스) 의 [패키지 라이선스] 에서 구성 요소 이름을 눌러 볼 수 있습니다.
이 앱은 GPLv3 구성 요소 (FFmpegKit · youtubedl-android · rsync) 를 포함합니다. 앱 자체의 대응 소스는 이 앱은 GPLv3 구성 요소 (FFmpegKit · youtubedl-android · rsync · Tor) 를 포함합니다. 앱 자체의 대응 소스는
같은 판 번호의 GitHub 태그에 있습니다: https://github.com/RyuJongJin/JJ_MKVMaker/tags 같은 판 번호의 GitHub 태그에 있습니다: https://github.com/RyuJongJin/JJ_MKVMaker/tags
(예: v2026.10.09_001 판 → 태그 v2026.10.09_001) (예: v2026.10.09_001 판 → 태그 v2026.10.09_001)
@ -47,6 +47,15 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스
커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기, 빌드 방법: 소스의 tool/build_sdcpp_android.sh) 커밋 228c707 을 고치지 않고 Android NDK 로 빌드 (AI 그림 · 해상도 올리기, 빌드 방법: 소스의 tool/build_sdcpp_android.sh)
함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json · 함께 묶인 구성 요소: ggml (MIT) · libwebp · libwebm (BSD-3) · Oniguruma · Darts-clone (BSD-2) · utf8proc · nlohmann/json ·
miniz · stb (MIT) · zip (Unlicense). 원문: [패키지 라이선스] 의 "stable-diffusion.cpp" miniz · stb (MIT) · zip (Unlicense). 원문: [패키지 라이선스] 의 "stable-diffusion.cpp"
- Tor 0.4.9.13 (libtor.so 라는 이름의 실행 파일, Tor 브라우저 화면) GPL-3.0 (GPL 을 켠 빌드, 소스는 BSD 3-Clause)
https://www.torproject.org
Tor Browser 15.0.24 의 공식 Tor Expert Bundle (Android aarch64) 에 든 libTor.so 를 고치지 않고 넣었습니다
(판 · SHA-256 확인: 소스의 tool/fetch_tor_android.ps1). 환경 설정 > 컴포넌트 에서 켜고, Tor 화면을 열 때만 실행합니다.
함께 묶인 구성 요소: Libevent (BSD 3-Clause), OpenSSL (Apache-2.0), zlib (zlib License), Zstandard (BSD)
소스: https://archive.torproject.org/tor-package-archive/tor-0.4.9.13.tar.gz
빌드: https://gitlab.torproject.org/tpo/applications/tor-browser-build (태그 tbb-15.0.24-build1)
원문: [패키지 라이선스] 의 "Tor 0.4.9.13" (BSD · Apache · zlib 원문) 과 GPL-3.0
Tor 브라우저 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며 공식 Tor Browser 가 아닙니다.
[처음 사용할 때 내려받는 AI 모델] [처음 사용할 때 내려받는 AI 모델]

View File

@ -0,0 +1,801 @@
Tor 0.4.9.13 (Tor Browser 15.0.24 의 Tor Expert Bundle) - Tor 브라우저 화면
Windows: 켤 때 공식 배포처에서 받는 tor.exe, Android: 앱에 넣은 libtor.so (같은 판의 Android 번들, 고치지 않음)
이 tor 빌드는 GPL 을 켠 빌드라 (tor --version: "This build of Tor is covered by the GNU General Public License") 실행 파일은 GPLv3 로 배포된다. Tor 소스 자체는 BSD 3-Clause. GPL 원문: GPL-3.0.txt
소스: https://archive.torproject.org/tor-package-archive/tor-0.4.9.13.tar.gz , 빌드 방법: https://gitlab.torproject.org/tpo/applications/tor-browser-build (태그 tbb-15.0.24-build1)
==============================================================================
Tor - BSD 3-Clause License (함께 쓰는 구성 요소의 라이선스 포함)
출처: https://archive.torproject.org/tor-package-archive/torbrowser/15.0.24/tor-expert-bundle-android-aarch64-15.0.24.tar.gz 의 docs/tor.txt
==============================================================================
This file contains the license for Tor,
a free software project to provide anonymity on the Internet.
It also lists the licenses for other components used by Tor.
For more information about Tor, see https://www.torproject.org/.
If you got this file as a part of a larger bundle,
there may be other license terms that you should be aware of.
===============================================================================
Tor is distributed under the "3-clause BSD" license, a commonly used
software license that means Tor is both free software and open source:
Copyright (c) 2001-2004, Roger Dingledine
Copyright (c) 2004-2006, Roger Dingledine, Nick Mathewson
Copyright (c) 2007-2019, The Tor Project, Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the names of the copyright owners nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
src/ext/strlcat.c and src/ext/strlcpy.c by Todd C. Miller are licensed
under the following license:
* Copyright (c) 1998 Todd C. Miller <Todd.Miller@courtesan.com>
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. The name of the author may not be used to endorse or promote products
* derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
* THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
* OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
* OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
* ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
src/ext/tor_queue.h is licensed under the following license:
* Copyright (c) 1991, 1993
* The Regents of the University of California. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
* 3. Neither the name of the University nor the names of its contributors
* may be used to endorse or promote products derived from this software
* without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
===============================================================================
src/ext/csiphash.c is licensed under the following license:
Copyright (c) 2013 Marek Majkowski <marek@popcount.org>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
===============================================================================
Trunnel is distributed under this license:
Copyright 2014 The Tor Project, Inc.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the names of the copyright owners nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
getdelim.c is distributed under this license:
Copyright (c) 2011 The NetBSD Foundation, Inc.
All rights reserved.
This code is derived from software contributed to The NetBSD Foundation
by Christos Zoulas.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE NETBSD FOUNDATION, INC. AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR CONTRIBUTORS
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
===============================================================================
src/config/geoip and src/config/geoip6:
These files are based on the IPFire Location Database. For more
information, see https://location.ipfire.org/.
The data is distributed under a creative commons "BY-SA 4.0" license.
Find the full license terms at:
https://creativecommons.org/licenses/by-sa/4.0/
===============================================================================
m4/pc_from_ucontext.m4 is available under the following license. Note that
it is *not* built into the Tor software.
Copyright (c) 2005, Google Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
===============================================================================
m4/pkg.m4 is available under the following license. Note that
it is *not* built into the Tor software.
pkg.m4 - Macros to locate and utilise pkg-config. -*- Autoconf -*-
serial 1 (pkg-config-0.24)
Copyright © 2004 Scott James Remnant <scott@netsplit.com>.
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
As a special exception to the GNU General Public License, if you
distribute this file as part of a program that contains a
configuration script generated by Autoconf, you may include it under
the same distribution terms that you use for the rest of that program.
===============================================================================
src/ext/readpassphrase.[ch] are distributed under this license:
Copyright (c) 2000-2002, 2007 Todd C. Miller <Todd.Miller@courtesan.com>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
Sponsored in part by the Defense Advanced Research Projects
Agency (DARPA) and Air Force Research Laboratory, Air Force
Materiel Command, USAF, under agreement number F39502-99-1-0512.
===============================================================================
src/ext/mulodi4.c is distributed under this license:
=========================================================================
compiler_rt License
=========================================================================
The compiler_rt library is dual licensed under both the
University of Illinois "BSD-Like" license and the MIT license.
As a user of this code you may choose to use it under either
license. As a contributor, you agree to allow your code to be
used under both.
Full text of the relevant licenses is included below.
=========================================================================
University of Illinois/NCSA
Open Source License
Copyright (c) 2009-2016 by the contributors listed in CREDITS.TXT
All rights reserved.
Developed by:
LLVM Team
University of Illinois at Urbana-Champaign
http://llvm.org
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal with the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
* Redistributions of source code must retain the above
copyright notice, this list of conditions and the following
disclaimers.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimers in the documentation and/or other materials
provided with the distribution.
* Neither the names of the LLVM Team, University of Illinois
at Urbana-Champaign, nor the names of its contributors may
be used to endorse or promote products derived from this
Software without specific prior written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS WITH THE SOFTWARE.
=========================================================================
Copyright (c) 2009-2015 by the contributors listed in CREDITS.TXT
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
=========================================================================
Copyrights and Licenses for Third Party Software Distributed with LLVM:
=========================================================================
The LLVM software contains code written by third parties. Such
software will have its own individual LICENSE.TXT file in the
directory in which it appears. This file will describe the
copyrights, license, and restrictions which apply to that code.
The disclaimer of warranty in the University of Illinois Open
Source License applies to all code in the LLVM Distribution, and
nothing in any of the other licenses gives permission to use the
names of the LLVM Team or the University of Illinois to endorse
or promote products derived from this Software.
===============================================================================
Parts of src/ext/polyval are based on Thomas Pornin's GHASH implementation in
BearSSL, and distributed under the following license:
Copyright (c) 2016 Thomas Pornin <pornin@bolet.org>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===============================================================================
If you got Tor as a static binary with OpenSSL included, then you should know:
"This product includes software developed by the OpenSSL Project
for use in the OpenSSL Toolkit (http://www.openssl.org/)"
===============================================================================
==============================================================================
Libevent - BSD 3-Clause License
출처: https://archive.torproject.org/tor-package-archive/torbrowser/15.0.24/tor-expert-bundle-android-aarch64-15.0.24.tar.gz 의 docs/libevent.txt
==============================================================================
Libevent is available for use under the following license, commonly known
as the 3-clause (or "modified") BSD license:
==============================
Copyright (c) 2000-2007 Niels Provos <provos@citi.umich.edu>
Copyright (c) 2007-2012 Niels Provos and Nick Mathewson
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. The name of the author may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
==============================
Portions of Libevent are based on works by others, also made available by
them under the three-clause BSD license above. The copyright notices are
available in the corresponding source files; the license is as above. Here's
a list:
log.c:
Copyright (c) 2000 Dug Song <dugsong@monkey.org>
Copyright (c) 1993 The Regents of the University of California.
strlcpy.c:
Copyright (c) 1998 Todd C. Miller <Todd.Miller@courtesan.com>
win32select.c:
Copyright (c) 2003 Michael A. Davis <mike@datanerds.net>
evport.c:
Copyright (c) 2007 Sun Microsystems
ht-internal.h:
Copyright (c) 2002 Christopher Clark
minheap-internal.h:
Copyright (c) 2006 Maxim Yegorushkin <maxim.yegorushkin@gmail.com>
==============================
The arc4module is available under the following, sometimes called the
"OpenBSD" license:
Copyright (c) 1996, David Mazieres <dm@uun.org>
Copyright (c) 2008, Damien Miller <djm@openbsd.org>
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
==============================
The Windows timer code is based on code from libutp, which is
distributed under this license, sometimes called the "MIT" license.
Copyright (c) 2010 BitTorrent, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
==============================================================================
OpenSSL - Apache License 2.0
출처: https://archive.torproject.org/tor-package-archive/torbrowser/15.0.24/tor-expert-bundle-android-aarch64-15.0.24.tar.gz 의 docs/openssl.txt
==============================================================================
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
==============================================================================
zlib - zlib License
출처: https://archive.torproject.org/tor-package-archive/torbrowser/15.0.24/tor-expert-bundle-android-aarch64-15.0.24.tar.gz 의 docs/zlib.txt
==============================================================================
Copyright notice:
(C) 1995-2026 Jean-loup Gailly and Mark Adler
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
Jean-loup Gailly Mark Adler
jloup@gzip.org madler@alumni.caltech.edu
==============================================================================
Zstandard (Android 판) - BSD License
출처: https://archive.torproject.org/tor-package-archive/torbrowser/15.0.24/tor-expert-bundle-android-aarch64-15.0.24.tar.gz 의 docs/zstd-bsd.txt
==============================================================================
BSD License
For Zstandard software
Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook, nor Meta, nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -0,0 +1,137 @@
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/app/component_store.dart';
import 'package:jj_mkvmaker/app/net_guard.dart';
import 'package:jj_mkvmaker/app/tor_bundle.dart';
import 'package:jj_mkvmaker/app/tor_service.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/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:jj_mkvmaker/ui/tor_page.dart';
import 'package:path/path.dart' as p;
/// 실제 망 (Windows): DNS 강제 (8.8.8.8) 로 앱의 HTTP · 일반 브라우저 (Edge) 가 이름을 푸는지, 닿지 않는 DNS 는 실패로 알리는지,
/// Tor 컴포넌트 (공식 번들, SHA-256 확인) 를 받아 Tor 화면이 check.torproject.org 의 "Congratulations" 까지 가는지.
/// 데이터는 JJ_MKVMAKER_DATA (없으면 임시 폴더) 에만 - 사용자 앱 데이터를 건드리지 않는다.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_tor_real_').path;
Directory(data).createSync(recursive: true);
ComponentStore.dataDirectory = data;
ComponentStore.shared = ComponentStore(data);
TorService.dataDir = data;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.webViewDataDir = p.join(data, 'webview');
final logs = <String>[];
final answered = <String>[];
final g = NetGuard.instance
..config = (() => c.settings.dnsConfig)
..log = logs.add;
g.resolver.onAnswer = (h, s) => answered.add('$h@$s');
TorService.instance.log = logs.add;
expect(c.settings.dnsMode, 'custom');
expect(c.settings.dnsPrimary, '8.8.8.8');
await tester.runAsync(g.ensure);
HttpOverrides.global = JjHttpOverrides();
Future<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 200; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
await tester.pump();
}
}
// 1. 앱의 HTTP (HttpClient) → 앱 안 프록시 → 8.8.8.8
final code = await tester.runAsync(() async {
final cl = HttpClient();
final r = await (await cl.getUrl(Uri.parse('https://www.google.com/generate_204'))).close();
await r.drain<void>();
cl.close();
return r.statusCode;
});
// ignore: avoid_print
print('RESULT HttpClient google 204: $code, 답한 DNS: $answered');
expect(code, 204);
expect(answered, contains('www.google.com@8.8.8.8'));
// 2. 일반 브라우저 (Edge WebView2) 도 같은 프록시로
final bm = BookmarksController(p.join(data, 'bm.json'));
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, bookmarks: bm, child: child!),
home: BrowserPage(c: c, bookmarks: bm, initialUrl: 'https://example.com/'),
));
String title = '';
for (var i = 0; i < 60 && !title.contains('Example'); i++) {
await wait(500);
title = '${await tester.runAsync(() async => BrowserPage.debugNav?.evaluate('document.title'))}';
}
// ignore: avoid_print
print('RESULT Edge example.com 제목: $title, 답한 DNS: ${answered.where((a) => a.startsWith('example.com'))}');
expect(title, contains('Example'));
expect(answered, contains('example.com@8.8.8.8'));
await tester.pumpWidget(const SizedBox());
await wait(1000);
// 3. 닿지 않는 DNS (TEST-NET) → 기기 DNS 로 바꾸지 않고 실패 + 알림
c.settings
..dnsPrimary = '192.0.2.1'
..dnsSecondary = '';
g.resolver.clearCache();
final err = await tester.runAsync(() async {
final cl = HttpClient();
try {
final r = await (await cl.getUrl(Uri.parse('https://www.wikipedia.org/'))).close();
await r.drain<void>();
return 'HTTP ${r.statusCode}';
} catch (e) {
return '$e';
} finally {
cl.close(force: true);
}
});
// ignore: avoid_print
print('RESULT 닿지 않는 DNS: $err / 알림: ${g.failure.value?.reason}');
expect(err, contains('502'));
expect(g.failure.value, isNotNull);
c.settings
..dnsPrimary = '8.8.8.8'
..dnsSecondary = '8.8.4.4';
g.resolver.clearCache();
// 4. Tor 컴포넌트 받기 (공식 번들 · SHA-256) → Tor 화면 → check.torproject.org
await tester.runAsync(() => TorBundle.installWindows());
expect(TorBundle.windowsExe, isNotNull);
expect(TorBundle.installedVersion(), TorBundle.version);
c.settings
..components = [...c.settings.components, 'tor']
..torNoticeSeen = true;
await tester.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, bookmarks: bm, child: child!),
home: TorPage(c: c),
));
var body = '';
for (var i = 0; i < 360 && !body.contains('Congratulations'); i++) {
await wait(500);
if (TorPage.debugNav != null) body = '${await tester.runAsync(() async => TorPage.debugNav!.evaluate('document.body ? document.body.innerText : ""'))}';
}
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'
'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(' | ')}');
expect(body, contains('Congratulations'));
expect(rtc, 'undefined');
await tester.pumpWidget(const SizedBox());
await tester.runAsync(() => TorService.instance.stop());
await wait(1000);
}, timeout: const Timeout(Duration(minutes: 10)));
}

View File

@ -35,25 +35,36 @@ class AppComponent {
/// 123: 글 → 그림 · 그림 → 그림 (기기 안 SD1.5 + LCM, 또는 사용자가 추가한 서버 · 서비스). 모델은 환경 설정에서 받는다 /// 123: 글 → 그림 · 그림 → 그림 (기기 안 SD1.5 + LCM, 또는 사용자가 추가한 서버 · 서비스). 모델은 환경 설정에서 받는다
static const aiImage = AppComponent('aiimage', 'AI 그림', '글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)', Icons.auto_awesome_outlined); static const aiImage = AppComponent('aiimage', 'AI 그림', '글 → 그림 · 그림 → 그림 (기기 안에서, 모델은 환경 설정에서 받기)', Icons.auto_awesome_outlined);
static const all = [mkv, browser, explorer, rsync, downloads, aiImage, viewer, docs]; /// Tor 브라우저 (사용자 요청 10/10): 웹 브라우저 옆 화면. Windows 는 켜면 Tor 를 받고, Android 는 앱에 들어 있어 켜기만 (처음 꺼짐)
static const tor = AppComponent('tor', 'Tor 브라우저', 'Tor 로 나가는 앱 안 웹 브라우저 (쿠키 · 기록 따로, 닫으면 지움)', Icons.vpn_lock_outlined,
builtIn: false);
static const all = [mkv, browser, tor, explorer, rsync, downloads, aiImage, viewer, docs];
/// 문서 미리보기 대상 (PDF 로 바꿔 봄) /// 문서 미리보기 대상 (PDF 로 바꿔 봄)
static const docExtensions = ['doc', 'docx', 'hwp', 'hwpx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf']; static const docExtensions = ['doc', 'docx', 'hwp', 'hwpx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'rtf'];
/// 화면 있는 것의 기본 순서 (좌우로 밀기 · 위쪽 버튼) /// 화면 있는 것의 기본 순서 (좌우로 밀기 · 위쪽 버튼)
static const defaultOrder = ['mkv', 'browser', 'explorer', 'rsync', 'downloads', 'aiimage']; static const defaultOrder = ['mkv', 'browser', 'tor', 'explorer', 'rsync', 'downloads', 'aiimage'];
static AppComponent? byId(String id) => all.where((c) => c.id == id).firstOrNull; static AppComponent? byId(String id) => all.where((c) => c.id == id).firstOrNull;
/// 처음 설치 때 켜진 것 /// 처음 설치 때 켜진 것
static List<String> get defaultInstalled => [for (final c in all) if (c.builtIn) c.id]; static List<String> get defaultInstalled => [for (final c in all) if (c.builtIn) c.id];
/// 설치된 화면들을 [order] 순서로 (순서에 없는 것은 기본 순서대로 뒤에) /// 설치된 화면들을 [order] 순서로. 순서에 없는 것 (새로 생긴 화면) 은 기본 순서에서 바로 앞 화면의 뒤에
static List<AppComponent> pages(List<String> installed, List<String> order) { /// (예: 예전 순서에 없는 Tor 브라우저는 웹 브라우저 바로 뒤)
final ids = [ static List<AppComponent> pages(List<String> installed, List<String> order) =>
...order.where(defaultOrder.contains), [for (final id in fullOrder(order)) if (installed.contains(id)) byId(id)!];
...defaultOrder.where((x) => !order.contains(x)),
]; /// [order] 에 빠진 화면을 더한 전체 순서: 기본 순서대로 뒤에, 다만 Tor 브라우저는 웹 브라우저 바로 뒤 (사용자 요청 "현재 브라우저 옆")
return [for (final id in ids) if (installed.contains(id)) byId(id)!]; static List<String> fullOrder(List<String> order) {
final ids = [...order.where(defaultOrder.contains)];
for (final x in defaultOrder) {
if (ids.contains(x)) continue;
final at = x == tor.id ? ids.indexOf(browser.id) : -1;
at < 0 ? ids.add(x) : ids.insert(at + 1, x);
}
return ids;
} }
} }

111
lib/app/net_guard.dart Normal file
View File

@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../core/dns_resolver.dart';
import '../core/local_proxy.dart';
import '../l10n/tr.dart';
/// DNS 서버에 닿지 않았을 때 화면에 알릴 것
class DnsFailure {
final String server;
final String reason;
final DateTime at;
const DnsFailure(this.server, this.reason, this.at);
}
/// DNS 강제 (사용자 요청 10/10: "DNS 주소를 강제로 내가 설정한 주소로").
/// 앱 안 HTTP 프록시 ([LocalProxy] + [DnsConnector]) 를 127.0.0.1 에 띄우고, Tor 밖으로 나가는 앱의 연결을 그리로 보낸다:
/// - 앱의 HTTP (업데이트 확인 · 컴포넌트 · AI 모델 받기 · WebDAV · 번역 · 자막 검색): [JjHttpOverrides] 의 findProxy
/// - 웹 브라우저: Windows Edge · Chrome 엔진의 --proxy-server, Android 는 웹 뷰 프록시 (WebProxy)
/// - 다운로드: yt-dlp --proxy, aria2 --all-proxy (+ --async-dns-server)
/// 이름은 프록시가 정한 DNS 로 푼다. DNS 서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 [failure] 로 알린다.
class NetGuard {
NetGuard._();
static final instance = NetGuard._();
/// 지금 DNS 설정 (main 에서 환경 설정을 읽게 바꾼다)
DnsConfig Function() config = () => const DnsConfig.system();
late final DnsResolver resolver = DnsResolver(config: () => config());
LocalProxy? _proxy;
Future<int>? _starting;
/// DNS 서버에 닿지 않음 (화면에 알림 - [시스템 DNS 로 바꾸기])
final failure = ValueNotifier<DnsFailure?>(null);
/// 작업 기록
void Function(String)? log;
/// 프록시 포트 (아직 안 띄웠으면 null)
int? get port => _proxy?.running == true ? _proxy!.port : null;
/// 지금 DNS 를 강제하는지 (설정이 직접 지정 · DoH 이고 프록시가 떠 있음)
bool get forcing => config().forced && port != null;
/// 설정이 강제면 프록시를 띄운다 (이미 떠 있으면 그대로). 설정을 바꾼 뒤에도 부른다
Future<void> ensure() async {
resolver.clearCache();
if (!config().forced) return;
if (port != null) return;
_starting ??= () async {
final p = LocalProxy(DnsConnector(resolver), onError: _onError);
final n = await p.start();
_proxy = p;
log?.call(trf('DNS 강제 프록시 시작: 127.0.0.1:{0} ({1})', [n, config().label]));
return n;
}();
try {
await _starting;
} finally {
_starting = null;
}
}
DateTime? _lastAlert;
void _onError(String host, Object e) {
if (e is! DnsException || !e.unreachable) return;
log?.call(trf('DNS 실패 ({0}): {1}', [host, e]));
final now = DateTime.now();
// 같은 알림을 계속 띄우지 않게 (1분에 한 번)
if (_lastAlert != null && now.difference(_lastAlert!) < const Duration(minutes: 1)) return;
_lastAlert = now;
failure.value = DnsFailure(e.server.isEmpty ? config().label : e.server, e.message, now);
}
/// 이 주소는 프록시를 거치지 않는다: IP 주소 (DNS 를 묻지 않음) · 같은 집 안 이름 (LAN · NAS · Tailscale)
static bool isDirectHost(String host) => host.isEmpty || InternetAddress.tryParse(host) != null || isLocalName(host);
/// Dart HttpClient 의 findProxy
String findProxy(Uri u) {
if (!config().forced || isDirectHost(u.host)) return 'DIRECT';
final p = port;
// 강제인데 프록시가 아직 없으면 직접 잇지 않는다 (닫힌 포트로 보내 실패하게 - 몰래 기기 DNS 를 쓰지 않음)
return 'PROXY 127.0.0.1:${p ?? 9}';
}
/// 웹 브라우저 (Edge · Chrome 엔진) 에 줄 프록시 주소. 강제가 아니면 null
String? get browserProxy => forcing ? 'http://127.0.0.1:$port' : null;
/// yt-dlp 인수
List<String> ytDlpArgs() => forcing ? ['--proxy', 'http://127.0.0.1:$port'] : const [];
/// aria2 인수: http · https · ftp 와 HTTP 트래커는 프록시로, UDP 트래커 · DHT 의 이름은 정한 서버로 ([asyncDns] 기능이 있는 aria2 만)
List<String> aria2Args({required bool asyncDns}) {
if (!forcing) return const [];
final c = config();
final servers = c.mode == 'custom' ? c.servers.map((a) => a.address).toList() : const <String>[];
return [
'--all-proxy=http://127.0.0.1:$port',
if (asyncDns && servers.isNotEmpty) ...['--async-dns=true', '--async-dns-server=${servers.join(',')}'],
];
}
}
/// 앱의 모든 HttpClient 가 [NetGuard.findProxy] 를 따르게 (main 에서 HttpOverrides.global 로)
class JjHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) =>
super.createHttpClient(context)..findProxy = NetGuard.instance.findProxy;
}

View File

@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart';
import 'components.dart'; import 'components.dart';
import '../core/reader_sources.dart' show defaultImageExtensions; import '../core/reader_sources.dart' show defaultImageExtensions;
import '../core/dns_resolver.dart';
import '../core/download_detect.dart'; import '../core/download_detect.dart';
import '../core/encode_options.dart'; import '../core/encode_options.dart';
import '../core/playlist.dart'; import '../core/playlist.dart';
@ -317,6 +318,29 @@ class AppSettings {
/// 앱 안 브라우저: 페이지의 JavaScript 실행 (끄면 스크립트 없이 글 · 그림만) /// 앱 안 브라우저: 페이지의 JavaScript 실행 (끄면 스크립트 없이 글 · 그림만)
bool webJavaScript = true; bool webJavaScript = true;
// ── 네트워크 · DNS (사용자 요청 10/10: "DNS 주소를 강제로 내가 설정한 주소로", 기본 8.8.8.8 · 보조 8.8.4.4 - 관리자 판단) ──
/// 'system' 기기 DNS (끄기) · 'custom' 직접 지정 (기본) · 'doh' DNS over HTTPS
String dnsMode = DnsConfig.defaultMode;
String dnsPrimary = DnsConfig.defaultPrimary;
String dnsSecondary = DnsConfig.defaultSecondary;
String dnsDohUrl = DnsConfig.defaultDohUrl;
DnsConfig get dnsConfig => DnsConfig(mode: dnsMode, primary: dnsPrimary, secondary: dnsSecondary, dohUrl: dnsDohUrl);
// ── Tor 브라우저 (사용자 요청 10/10) ──
/// Tor 화면의 쿠키 · 기록 · 캐시를 닫은 뒤에도 남기기 (기본: 닫으면 지움)
bool torKeepData = false;
/// Tor 화면의 처음 · 🏠 주소
String torHomeUrl = defaultTorHome;
static const defaultTorHome = 'https://check.torproject.org/';
/// Tor 화면의 JavaScript (끄면 더 안전하지만 많은 사이트가 보이지 않음)
bool torJavaScript = true;
/// "공식 Tor Browser 가 아닙니다" 안내를 보았는지 (처음 열 때 한 번)
bool torNoticeSeen = false;
// ── 파일 탐색기 (X-plore 참고) ── // ── 파일 탐색기 (X-plore 참고) ──
/// 창: 'dual' 두 창 (기본) · 'split' 왼쪽 폴더 트리 + 오른쪽 그 폴더의 파일 목록 · 'single' 한 창 /// 창: 'dual' 두 창 (기본) · 'split' 왼쪽 폴더 트리 + 오른쪽 그 폴더의 파일 목록 · 'single' 한 창
String explorerLayout = 'auto'; String explorerLayout = 'auto';
@ -624,6 +648,14 @@ class AppSettings {
'externalBrowser': externalBrowser, 'externalBrowser': externalBrowser,
'webTranslate': webTranslate, 'webTranslate': webTranslate,
'webJavaScript': webJavaScript, 'webJavaScript': webJavaScript,
'dnsMode': dnsMode,
'dnsPrimary': dnsPrimary,
'dnsSecondary': dnsSecondary,
'dnsDohUrl': dnsDohUrl,
'torKeepData': torKeepData,
'torHomeUrl': torHomeUrl,
'torJavaScript': torJavaScript,
'torNoticeSeen': torNoticeSeen,
'explorerLayout': explorerLayout, 'explorerLayout': explorerLayout,
'explorerClick': explorerClick, 'explorerClick': explorerClick,
// 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다 // 예전 기본값 (두 창 · 한 번 누르면 선택) 은 사용자가 고른 값이 아니므로 한 번 새 기본값으로 옮긴다
@ -796,6 +828,25 @@ class AppSettings {
..externalBrowser = j['externalBrowser'] as String? ?? 'system' ..externalBrowser = j['externalBrowser'] as String? ?? 'system'
..webTranslate = j['webTranslate'] as bool? ?? false ..webTranslate = j['webTranslate'] as bool? ?? false
..webJavaScript = j['webJavaScript'] as bool? ?? true ..webJavaScript = j['webJavaScript'] as bool? ?? true
// 값이 없거나 틀리면 처음 값 (사용자가 정한 기본: 직접 지정 8.8.8.8 · 8.8.4.4). 보조는 비워 둘 수 있다
..dnsMode = DnsConfig.modes.contains(j['dnsMode']) ? j['dnsMode'] as String : DnsConfig.defaultMode
..dnsPrimary = j['dnsPrimary'] is String && parseDnsServer(j['dnsPrimary'] as String) != null
? (j['dnsPrimary'] as String).trim()
: DnsConfig.defaultPrimary
..dnsSecondary = j['dnsSecondary'] is String
? (parseDnsServer(j['dnsSecondary'] as String) != null || (j['dnsSecondary'] as String).trim().isEmpty
? (j['dnsSecondary'] as String).trim()
: DnsConfig.defaultSecondary)
: DnsConfig.defaultSecondary
..dnsDohUrl = j['dnsDohUrl'] is String && isValidDohUrl(j['dnsDohUrl'] as String)
? (j['dnsDohUrl'] as String).trim()
: DnsConfig.defaultDohUrl
..torKeepData = j['torKeepData'] == true
..torHomeUrl = j['torHomeUrl'] is String && (j['torHomeUrl'] as String).trim().isNotEmpty
? (j['torHomeUrl'] as String).trim()
: defaultTorHome
..torJavaScript = j['torJavaScript'] != false
..torNoticeSeen = j['torNoticeSeen'] == true
// 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로 // 'auto' = 넓은 화면은 두 창, 좁은 화면 (폰 세로) 은 한 창. 예전 기본 'dual' 은 한 번 'auto' 로
..explorerLayout = const ['single', 'split', 'auto'].contains(j['explorerLayout']) ..explorerLayout = const ['single', 'split', 'auto'].contains(j['explorerLayout'])
? j['explorerLayout'] as String ? j['explorerLayout'] as String

180
lib/app/tor_bundle.dart Normal file
View File

@ -0,0 +1,180 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
import 'component_store.dart';
/// Tor 컴포넌트 (환경 설정 > 컴포넌트 > Tor 브라우저).
/// - Windows: 공식 Tor Expert Bundle 을 받아 (판 · SHA-256 고정) 앱 데이터 폴더 components/tor 에 tor.exe · geoip 를 둔다
/// - Android: 받은 실행 파일은 실행할 수 없어 (W^X) 같은 판의 Android 번들에서 꺼낸 libTor.so (실행 파일) 를 APK 에 넣었다
/// (tool/fetch_tor_android.ps1 → third_party/tor/android/arm64-v8a/libtor.so). 켜기 · 끄기만 한다.
class TorBundle {
/// Tor Browser 15.0.24 의 Expert Bundle (tor 0.4.9.13). 올릴 때: 판 · 아래 SHA-256 (공식 sha256sums-signed-build.txt) ·
/// tool/fetch_tor_android.ps1 · tool/licenses/fetch_licenses.py 를 함께 바꾼다
static const version = '15.0.24';
static const torVersion = '0.4.9.13';
static const windowsFile = 'tor-expert-bundle-windows-x86_64-$version.tar.gz';
static const windowsSha256 = 'e9dc6ccc93cd6afa507193f4de284d6424233ff5102155cd2c94b259e8a22b65';
static const androidFile = 'tor-expert-bundle-android-aarch64-$version.tar.gz';
static const androidSha256 = '2a7a02484149b05f64b582a4a1e8eb8aabb9f6cd32daca4e03c58e09839bb7a8';
/// 받을 곳: 공식 보관소 (판이 지워지지 않음), 안 되면 공식 배포처
static List<String> urls(String file) => [
'https://archive.torproject.org/tor-package-archive/torbrowser/$version/$file',
'https://dist.torproject.org/torbrowser/$version/$file',
];
/// 받을 크기 (안내용)
static const approxDownloadMb = 22;
static const id = 'tor';
static Directory get dir => ComponentStore.shared.dirOf(id);
/// Windows: 설치한 tor.exe (없으면 null)
static String? get windowsExe {
final f = File(p.join(dir.path, 'tor.exe'));
return f.existsSync() && ComponentStore.shared.isInstalled(id) ? f.path : null;
}
/// 설치한 판 (installed.json)
static String? installedVersion() {
try {
final j = jsonDecode(File(p.join(dir.path, 'installed.json')).readAsStringSync()) as Map;
return j['version'] as String?;
} catch (_) {
return null;
}
}
/// Android: APK 에 들어 있는 tor (libtor.so). 없으면 (이 APK 에 넣지 않음) null
static Future<String?> androidExe() async {
try {
final d = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('nativeLibDir');
final f = d == null ? null : File(p.join(d, 'libtor.so'));
return f != null && f.existsSync() ? f.path : null;
} catch (_) {
return null;
}
}
/// 이 기기에서 쓸 tor 실행 파일
static Future<String?> executable() async => Platform.isAndroid ? androidExe() : windowsExe;
/// geoip 파일 (있으면 tor 에 알려 준다 - 없어도 동작)
static String? geoip(String name) {
final f = File(p.join(dir.path, name));
return f.existsSync() ? f.path : null;
}
/// Windows 설치: 받기 (SHA-256 확인) → 풀기 → tor.exe · geoip · 라이선스 문서만 두기.
/// [onProgress] (단계, 0~1 또는 null). [urlsOverride] · [sha256Override] 는 시험용
static Future<void> installWindows({
void Function(String step, double? progress)? onProgress,
bool Function()? isCancelled,
List<String>? urlsOverride,
String? sha256Override,
String? targetOverride,
}) async {
final target = Directory(targetOverride ?? dir.path);
final work = await Directory.systemTemp.createTemp('jj_tor_');
final client = HttpClient()..userAgent = 'JJMKVMaker';
try {
final archive = File(p.join(work.path, windowsFile));
Object? last;
var ok = false;
for (final url in urlsOverride ?? urls(windowsFile)) {
try {
await _download(client, url, archive, sha256Override ?? windowsSha256,
(d) => onProgress?.call(trf('받는 중: {0}', [windowsFile]), d), isCancelled);
ok = true;
break;
} on _Cancelled {
rethrow;
} on ComponentException {
rethrow; // 손상 (SHA-256 다름) 은 다른 곳에서 받아도 다시 시도하지 않는다
} catch (e) {
last = e;
}
}
if (!ok) throw ComponentException(trf('Tor 를 받을 수 없습니다: {0}', [last]));
onProgress?.call(tr('푸는 중'), null);
final x = Directory(p.join(work.path, 'x'))..createSync();
final r = await Process.run('tar', ['-xzf', archive.path, '-C', x.path]);
if (r.exitCode != 0) throw ComponentException(trf('압축을 풀 수 없습니다: {0}', [r.stderr]));
final exe = File(p.join(x.path, 'tor', 'tor.exe'));
if (!exe.existsSync()) throw ComponentException(tr('받은 파일에 tor.exe 가 없습니다'));
// 새 폴더에 모은 뒤 바꿔 넣는다 (중간에 실패해도 반쯤 깔린 것이 남지 않게)
final staging = Directory('${target.path}.new');
if (staging.existsSync()) staging.deleteSync(recursive: true);
Directory(p.join(staging.path, 'docs')).createSync(recursive: true);
exe.copySync(p.join(staging.path, 'tor.exe'));
for (final g in ['geoip', 'geoip6']) {
final f = File(p.join(x.path, 'data', g));
if (f.existsSync()) f.copySync(p.join(staging.path, g));
}
final docs = Directory(p.join(x.path, 'docs'));
if (docs.existsSync()) {
for (final f in docs.listSync().whereType<File>()) {
f.copySync(p.join(staging.path, 'docs', p.basename(f.path)));
}
}
File(p.join(staging.path, 'installed.json')).writeAsStringSync(
jsonEncode({'version': version, 'tor': torVersion, 'sha256': sha256Override ?? windowsSha256, 'installed': DateTime.now().toIso8601String()}));
if (target.existsSync()) target.deleteSync(recursive: true);
target.parent.createSync(recursive: true);
staging.renameSync(target.path);
onProgress?.call(tr('완료'), 1);
} on _Cancelled {
throw ComponentException(tr('취소했습니다'));
} finally {
client.close(force: true);
try {
work.deleteSync(recursive: true);
} catch (_) {}
}
}
static Future<void> _download(HttpClient client, String url, File out, String sha,
void Function(double?) onProgress, bool Function()? isCancelled) async {
final req = await client.getUrl(Uri.parse(url));
final res = await req.close();
if (res.statusCode != 200) throw HttpException(trf('받을 수 없습니다 ({0}): {1}', [res.statusCode, url]));
final total = res.contentLength;
final sink = out.openWrite();
final hash = AccumulatorSink<Digest>();
final hasher = sha256.startChunkedConversion(hash);
var got = 0;
try {
await for (final chunk in res) {
if (isCancelled?.call() ?? false) throw const _Cancelled();
sink.add(chunk);
hasher.add(chunk);
got += chunk.length;
onProgress(total > 0 ? got / total : null);
}
} finally {
await sink.close();
hasher.close();
}
if (hash.events.single.toString() != sha.toLowerCase()) {
await out.delete();
throw ComponentException(trf('파일이 손상되었습니다 (SHA-256 다름): {0}', [p.basename(out.path)]));
}
}
/// Windows 제거 (Tor 를 먼저 끈 뒤)
static Future<void> removeWindows() async {
if (dir.existsSync()) await dir.delete(recursive: true);
}
}
class _Cancelled implements Exception {
const _Cancelled();
}

193
lib/app/tor_service.dart Normal file
View File

@ -0,0 +1,193 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import '../core/local_proxy.dart';
import '../core/tor_control.dart';
import '../l10n/tr.dart';
import 'tor_bundle.dart';
/// Tor 실행 (Tor 화면이 열려 있는 동안). tor 를 자식 프로세스로 띄우고 (SOCKS · 제어 포트는 127.0.0.1 의 빈 포트),
/// 웹 뷰가 쓸 HTTP 프록시 다리 ([LocalProxy] + [TorConnector]) 를 연다. 다리는 이름을 Tor 에 넘긴다 (이 기기의 DNS 를 쓰지 않음).
class TorService {
TorService._();
static final instance = TorService._();
/// 앱 데이터 폴더 (main 에서 정함). tor 의 DataDirectory 는 그 아래 tor_data
static String dataDir = Directory.systemTemp.path;
final status = ValueNotifier<TorStatus>(TorStatus.off);
/// 작업 기록
void Function(String)? log;
/// 시험용: 실행 파일 · 인수를 바꾼다
@visibleForTesting
Future<String?> Function() executable = TorBundle.executable;
Process? _proc;
int? _socksPort;
int? _controlPort;
LocalProxy? _bridge;
Timer? _watchdog;
DateTime _lastProgress = DateTime.now();
final _recent = <String>[];
Future<void>? _starting;
/// 지금 Tor SOCKS 포트 (준비되지 않았으면 null)
int? get socksPort => status.value.ready ? _socksPort : null;
/// 웹 뷰가 쓸 HTTP 프록시 (127.0.0.1:포트). 앱이 켜져 있는 동안 같은 포트 (Windows 웹 뷰 환경은 만든 뒤 바꿀 수 없다)
Future<int> bridgePort() async {
final b = _bridge ??= LocalProxy(TorConnector(() => socksPort));
return b.start();
}
bool get running => _proc != null;
/// 켜기 (이미 켜져 있거나 켜는 중이면 그것을 기다린다)
Future<void> start() {
if (status.value.ready && _proc != null) return Future.value();
return _starting ??= _start().whenComplete(() => _starting = null);
}
Future<void> _start() async {
final exe = await executable();
if (exe == null) {
_set(TorStatus(TorPhase.failed, message: tr('Tor 가 설치되어 있지 않습니다 (환경 설정 > 컴포넌트)')));
return;
}
await bridgePort();
final data = Directory(p.join(dataDir, 'tor_data'))..createSync(recursive: true);
final portFile = File(p.join(data.path, 'control-port.txt'));
if (portFile.existsSync()) portFile.deleteSync();
final torrc = File(p.join(data.path, 'torrc'));
torrc.writeAsStringSync(buildTorrc(
dataDir: data.path,
controlPortFile: portFile.path,
ownerPid: pid,
geoip: Platform.isWindows ? TorBundle.geoip('geoip') : null,
geoip6: Platform.isWindows ? TorBundle.geoip('geoip6') : null,
));
final defaults = File(p.join(data.path, 'torrc-defaults'))..writeAsStringSync('');
_recent.clear();
_socksPort = null;
_controlPort = null;
_set(TorStatus(TorPhase.starting, message: tr('Tor 를 시작하는 중')));
final Process proc;
try {
proc = await Process.start(exe, ['-f', torrc.path, '--defaults-torrc', defaults.path],
workingDirectory: data.path, environment: Platform.isAndroid ? {'HOME': data.path} : null);
} catch (e) {
_set(TorStatus(TorPhase.failed, message: trf('Tor 를 실행할 수 없습니다: {0}', [e])));
return;
}
_proc = proc;
log?.call(trf('Tor 시작 ({0})', [exe]));
_lastProgress = DateTime.now();
void onLine(String line) {
if (_recent.length > 20) _recent.removeAt(0);
_recent.add(line);
final port = parseSocksPort(line);
if (port != null) _socksPort = port;
final b = parseBootstrap(line);
if (b != null) {
_lastProgress = DateTime.now();
if (b.$1 >= 100) {
unawaited(_becomeReady(portFile));
} else {
_set(TorStatus(TorPhase.starting, percent: b.$1, message: b.$2));
}
}
}
proc.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen(onLine);
proc.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen(onLine);
unawaited(proc.exitCode.then((code) {
if (!identical(_proc, proc)) return;
_proc = null;
_watchdog?.cancel();
if (status.value.phase != TorPhase.off) {
final why = _recent.lastWhere((l) => l.contains('[err]') || l.contains('[warn]'), orElse: () => '');
_set(TorStatus(TorPhase.failed, message: trf('Tor 가 끝났습니다 (코드 {0}) {1}', [code, why])));
log?.call(trf('Tor 끝남 (코드 {0}): {1}', [code, _recent.join(' | ')]));
}
}));
// 2분 동안 진행이 없으면 실패로 (망이 막혀 있는 등)
_watchdog?.cancel();
_watchdog = Timer.periodic(const Duration(seconds: 5), (_) {
if (status.value.phase != TorPhase.starting) return;
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])));
}
});
}
Future<void> _becomeReady(File portFile) async {
// 제어 포트 (새 경로에 씀) 와 SOCKS 포트 확인
for (var i = 0; i < 50 && !portFile.existsSync(); i++) {
await Future<void>.delayed(const Duration(milliseconds: 100));
}
try {
final m = RegExp(r'PORT=127\.0\.0\.1:(\d+)').firstMatch(portFile.readAsStringSync());
_controlPort = m == null ? null : int.parse(m.group(1)!);
} catch (_) {}
if (_socksPort == null && _controlPort != null) {
try {
final r = await _control(['GETINFO net/listeners/socks']);
final m = RegExp(r'127\.0\.0\.1:(\d+)').firstMatch(r.join(' '));
if (m != null) _socksPort = int.parse(m.group(1)!);
} catch (_) {}
}
if (_socksPort == null) {
_set(TorStatus(TorPhase.failed, message: tr('Tor SOCKS 포트를 찾지 못했습니다')));
await stop(keepStatus: true);
return;
}
_set(TorStatus(TorPhase.ready, percent: 100, message: tr('연결됨')));
log?.call(trf('Tor 연결됨 (SOCKS 127.0.0.1:{0})', [_socksPort]));
}
Future<List<String>> _control(List<String> commands) async {
final port = _controlPort;
if (port == null) throw SocketException(tr('Tor 제어 포트가 없습니다'));
final cookie = await File(p.join(dataDir, 'tor_data', 'control_auth_cookie')).readAsBytes();
return torControl(port, cookie, commands);
}
/// [새 경로]: 앞으로의 연결은 새 Tor 회로로 (NEWNYM)
Future<void> newIdentity() async {
try {
await _control(['SIGNAL NEWNYM']);
log?.call(tr('Tor 새 경로'));
} catch (e) {
// 제어 포트를 쓸 수 없으면 다시 시작
log?.call(trf('Tor 새 경로 실패 ({0}) - 다시 시작합니다', [e]));
await stop();
await start();
}
}
/// 끄기 (Tor 화면을 닫을 때 · 앱이 끝날 때 · 제거할 때)
Future<void> stop({bool keepStatus = false}) async {
_watchdog?.cancel();
final proc = _proc;
_proc = null;
_socksPort = null;
if (!keepStatus) _set(TorStatus.off);
if (proc != null) {
try {
await _control(['SIGNAL SHUTDOWN']).timeout(const Duration(seconds: 2));
} catch (_) {}
proc.kill();
log?.call(tr('Tor 끔'));
}
}
void _set(TorStatus s) => status.value = s;
}

89
lib/app/web_proxy.dart Normal file
View File

@ -0,0 +1,89 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../l10n/tr.dart';
import 'net_guard.dart';
import 'tor_service.dart';
/// Android 웹 뷰 프록시 (androidx.webkit ProxyController - 앱의 모든 웹 뷰에 한꺼번에 걸린다).
/// - Tor 화면이 열려 있으면: 모든 웹 뷰가 Tor 다리로 (관리자 결정 10/10 - 일반 브라우저에는 안내 띠)
/// - 아니면: DNS 강제면 DNS 프록시로, 아니면 프록시 없음
/// 바꿀 때는 끝날 때까지 기다린다 (그 전에 페이지를 읽지 않게). Windows 는 웹 뷰마다 따로라 여기서 하지 않는다.
class WebProxy {
WebProxy._();
static const _ch = MethodChannel('jj_mkvmaker/android');
/// Tor 화면이 열려 있는지 (일반 브라우저의 안내 띠도 이것을 본다)
static final torOpen = ValueNotifier<bool>(false);
/// 지금 걸린 프록시 ('' = 없음)
static String _applied = '';
static String get applied => _applied;
static Future<void> _chain = Future.value();
/// 이 기기 웹 뷰가 Tor 화면에 필요한 기능을 갖췄는지: 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트 (WebRTC 끄기).
/// 없는 것의 이름 목록 (모두 있으면 빈 목록)
static Future<List<String>> torMissingFeatures() async {
if (!Platform.isAndroid) return const [];
try {
final m = await _ch.invokeMapMethod<String, bool>('torWebSupport') ?? const {};
return [
if (m['proxyOverride'] != true) 'PROXY_OVERRIDE',
if (m['multiProfile'] != true) 'MULTI_PROFILE',
if (m['documentStartScript'] != true) 'DOCUMENT_START_SCRIPT',
];
} catch (e) {
return ['$e'];
}
}
/// 지금 상태에 맞게 다시 건다 (차례로 - 겹치지 않게). 끝나면 걸린 프록시
static Future<String> apply() {
if (!Platform.isAndroid) return Future.value('');
final f = _chain.then((_) => _apply());
_chain = f.then((_) {}, onError: (_) {});
return f;
}
static Future<String> _apply() async {
String want = '';
if (torOpen.value) {
want = 'http://127.0.0.1:${await TorService.instance.bridgePort()}';
} else if (NetGuard.instance.forcing) {
want = NetGuard.instance.browserProxy ?? '';
}
if (want == _applied) return _applied;
final pc = ProxyController.instance();
if (want.isEmpty) {
await pc.clearProxyOverride();
} else {
await pc.setProxyOverride(
settings: ProxySettings(
proxyRules: [ProxyRule(url: want)],
// Tor 화면: localhost 까지 Tor 로 (이 기기 · 집 안을 들여다보지 못하게). DNS 강제: 기본 (이 기기 주소는 직접)
removeImplicitRules: torOpen.value,
));
}
_applied = want;
debugPrint(trf('웹 뷰 프록시: {0}', [want.isEmpty ? tr('없음') : want]));
return want;
}
/// Tor 화면 전용 프로필 지우기 (쿠키 · 저장소 · 캐시). 웹 뷰가 없어진 뒤에 부른다
static Future<bool> clearTorProfile() async {
if (!Platform.isAndroid) return true;
try {
return await _ch.invokeMethod<bool>('torProfileClear', {'name': torProfileName}) ?? false;
} catch (_) {
return false;
}
}
/// Tor 화면 웹 뷰의 프로필 이름 (androidx.webkit Profile)
static const torProfileName = 'jj_tor';
}

336
lib/core/dns_resolver.dart Normal file
View File

@ -0,0 +1,336 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import '../l10n/tr.dart';
/// DNS 서버 설정 (환경 설정 > 네트워크 · DNS).
/// - 'system': 기기 (Windows · Android) 의 DNS 를 그대로 (끄기)
/// - 'custom': 직접 지정한 서버 ([primary] · [secondary], 처음 8.8.8.8 · 8.8.4.4 - 사용자 결정 10/10)
/// - 'doh': DNS over HTTPS ([dohUrl], 처음 https://dns.google/dns-query). DoH 서버 이름은 [primary] · [secondary] 로 찾는다
class DnsConfig {
final String mode;
final String primary;
final String secondary;
final String dohUrl;
const DnsConfig({required this.mode, this.primary = '', this.secondary = '', this.dohUrl = ''});
static const modes = ['system', 'custom', 'doh'];
static const defaultMode = 'custom';
static const defaultPrimary = '8.8.8.8';
static const defaultSecondary = '8.8.4.4';
static const defaultDohUrl = 'https://dns.google/dns-query';
const DnsConfig.system() : this(mode: 'system');
bool get forced => mode != 'system';
/// 쓸 수 있는 직접 지정 서버 (적힌 순서대로, 틀린 값은 뺀다)
List<InternetAddress> get servers => [
for (final s in [primary, secondary]) ?parseDnsServer(s),
];
/// 캐시 열쇠 (설정이 바뀌면 예전 답을 쓰지 않게)
String get key => '$mode|$primary|$secondary|${mode == 'doh' ? dohUrl : ''}';
/// 화면에 보일 이름: "8.8.8.8, 8.8.4.4" · DoH 주소
String get label => mode == 'doh' ? dohUrl : [primary, secondary].where((s) => s.trim().isNotEmpty).join(', ');
}
/// DNS 서버 주소 (IPv4 · IPv6) 를 읽는다. 틀리면 null
InternetAddress? parseDnsServer(String s) {
var t = s.trim();
if (t.startsWith('[') && t.endsWith(']')) t = t.substring(1, t.length - 1);
if (t.isEmpty) return null;
final a = InternetAddress.tryParse(t);
if (a == null || a.type == InternetAddressType.unix) return null;
// 0.0.0.0 · :: 는 서버가 아니다
if (a.rawAddress.every((b) => b == 0)) return null;
return a;
}
/// DoH 주소가 쓸 수 있는 모양인지 (https:// 와 서버 이름)
bool isValidDohUrl(String s) {
final u = Uri.tryParse(s.trim().replaceAll('{?dns}', ''));
return u != null && u.isScheme('https') && u.host.isNotEmpty;
}
/// 이름 풀기 실패. [unreachable] 은 DNS 서버에 닿지 않은 것 (서버가 "그런 이름 없음" 이라고 답한 것과 구분)
class DnsException implements Exception {
final String message;
final bool unreachable;
/// 물어본 서버 (화면 · 작업 기록에 보임)
final String server;
const DnsException(this.message, {this.unreachable = false, this.server = ''});
@override
String toString() => message;
}
/// DNS 응답에서 꺼낸 주소들과 가장 짧은 TTL (초)
class DnsAnswer {
final List<InternetAddress> addresses;
final int ttl;
final int rcode;
/// 답이 잘렸다 (TC) - TCP 로 다시 묻는다
final bool truncated;
const DnsAnswer(this.addresses, this.ttl, this.rcode, {this.truncated = false});
}
/// DNS 질문 (RFC 1035) 하나: [type] 1 = A, 28 = AAAA
Uint8List buildDnsQuery(String host, int type, {int? id}) {
final b = BytesBuilder();
final qid = id ?? math.Random.secure().nextInt(0x10000);
b.add([qid >> 8, qid & 0xff, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0]); // RD, 질문 1
for (final label in host.split('.')) {
if (label.isEmpty) continue;
final bytes = label.codeUnits.any((c) => c > 127) ? Uri.encodeComponent(label).codeUnits : label.codeUnits;
if (bytes.length > 63) throw DnsException(trf('주소 이름이 너무 깁니다: {0}', [host]));
b.addByte(bytes.length);
b.add(bytes);
}
b.add([0, type >> 8, type & 0xff, 0, 1]);
return b.toBytes();
}
/// DNS 응답 읽기 (이름 줄임 · CNAME 사슬 포함). 응답이 질문 [id] 와 다르거나 깨졌으면 FormatException
DnsAnswer parseDnsResponse(Uint8List m, {int? id}) {
if (m.length < 12) throw const FormatException('short');
if (id != null && ((m[0] << 8) | m[1]) != id) throw const FormatException('id');
if (m[2] & 0x80 == 0) throw const FormatException('not a response');
final rcode = m[3] & 0x0f;
final qd = (m[4] << 8) | m[5];
final an = (m[6] << 8) | m[7];
var i = 12;
int skipName(int at) {
var j = at;
while (true) {
if (j >= m.length) throw const FormatException('name');
final len = m[j];
if (len == 0) return j + 1;
if (len & 0xc0 == 0xc0) return j + 2; // 줄임 표시
j += len + 1;
}
}
for (var q = 0; q < qd; q++) {
i = skipName(i) + 4;
}
final out = <InternetAddress>[];
var ttl = 1 << 30;
for (var a = 0; a < an; a++) {
i = skipName(i);
if (i + 10 > m.length) throw const FormatException('answer');
final type = (m[i] << 8) | m[i + 1];
final t = (m[i + 4] << 24) | (m[i + 5] << 16) | (m[i + 6] << 8) | m[i + 7];
final len = (m[i + 8] << 8) | m[i + 9];
i += 10;
if (i + len > m.length) throw const FormatException('rdata');
if ((type == 1 && len == 4) || (type == 28 && len == 16)) {
out.add(InternetAddress.fromRawAddress(Uint8List.fromList(m.sublist(i, i + len))));
ttl = math.min(ttl, t);
}
i += len;
}
return DnsAnswer(out, out.isEmpty ? 0 : ttl, rcode, truncated: m[2] & 0x02 != 0);
}
/// 정한 DNS 서버로 이름을 푼다 (UDP, 잘리면 TCP · 또는 DoH). TTL 동안 기억한다.
/// 서버에 닿지 않으면 [DnsException] (unreachable) - 기기 DNS 로 몰래 바꾸지 않는다.
class DnsResolver {
final DnsConfig Function() config;
/// 시험용: DNS 서버 포트 (보통 53)
final int port;
final Duration timeout;
/// 시험용: DoH 에 쓸 HttpClient
final HttpClient Function()? dohClient;
/// 어느 서버가 답했는지 (작업 기록 · 시험)
void Function(String host, String server)? onAnswer;
DnsResolver({required this.config, this.port = 53, this.timeout = const Duration(milliseconds: 2500), this.dohClient});
final _cache = <String, (List<InternetAddress>, DateTime)>{};
final _pending = <String, Future<List<InternetAddress>>>{};
void clearCache() => _cache.clear();
/// [host] 의 주소들 (IPv4 먼저)
Future<List<InternetAddress>> lookup(String host) {
final c = config();
final name = host.toLowerCase().replaceAll(RegExp(r'\.$'), '');
final key = '${c.key}|$name';
final hit = _cache[key];
if (hit != null && hit.$2.isAfter(DateTime.now())) return Future.value(hit.$1);
return _pending[key] ??= _resolve(c, name).then((r) {
if (_cache.length > 2000) _cache.clear();
_cache[key] = (r.$1, DateTime.now().add(Duration(seconds: r.$2.clamp(5, 3600))));
return r.$1;
}).whenComplete(() {
// 지운 값 (이 Future 자신) 을 돌려주면 whenComplete 가 자기를 기다린다
_pending.remove(key);
});
}
Future<(List<InternetAddress>, int)> _resolve(DnsConfig c, String name) async {
final results = await Future.wait([_ask(c, name, 1), _ask(c, name, 28)].map((f) => f.then<Object>((v) => v, onError: (Object e) => e)));
final ok = results.whereType<(DnsAnswer, String)>().toList();
final addrs = [for (final r in ok) ...r.$1.addresses];
if (addrs.isNotEmpty) {
onAnswer?.call(name, ok.first.$2);
final ttl = ok.where((r) => r.$1.addresses.isNotEmpty).map((r) => r.$1.ttl).reduce(math.min);
return (addrs, ttl);
}
// 둘 다 닿지 않음 → 서버 문제. 답은 왔는데 주소가 없음 → 그런 이름 없음
final errors = results.whereType<DnsException>().toList();
if (ok.isEmpty && errors.isNotEmpty) throw errors.first;
final server = ok.isNotEmpty ? ok.first.$2 : c.label;
throw DnsException(trf('이름을 찾을 수 없습니다: {0} (DNS {1})', [name, server]), server: server);
}
/// 서버들에 차례로 묻는다 (첫 서버가 답하지 않으면 다음 서버)
Future<(DnsAnswer, String)> _ask(DnsConfig c, String name, int type) async {
if (c.mode == 'doh') return (await _doh(c, name, type), c.dohUrl);
final servers = c.servers;
if (servers.isEmpty) {
throw DnsException(tr('DNS 서버 주소가 없습니다 (환경 설정 > 네트워크 · DNS)'), unreachable: true);
}
Object? last;
for (final s in servers) {
try {
return (await _udp(s, name, type), s.address);
} catch (e) {
last = e;
}
}
throw DnsException(trf('DNS 서버 ({0}) 에 닿지 않습니다: {1}', [c.label, last]), unreachable: true, server: c.label);
}
Future<DnsAnswer> _udp(InternetAddress server, String name, int type) async {
final id = math.Random.secure().nextInt(0x10000);
final q = buildDnsQuery(name, type, id: id);
final sock = await RawDatagramSocket.bind(
server.type == InternetAddressType.IPv6 ? InternetAddress.anyIPv6 : InternetAddress.anyIPv4, 0);
try {
final done = Completer<DnsAnswer>();
sock.listen((ev) {
if (ev != RawSocketEvent.read) return;
final d = sock.receive();
if (d == null || d.address != server || done.isCompleted) return;
try {
done.complete(parseDnsResponse(d.data, id: id));
} catch (_) {} // 다른 질문의 답 · 깨진 답은 버리고 기다린다
});
if (sock.send(q, server, port) <= 0) throw SocketException(tr('보내지 못했습니다'));
final r = await done.future.timeout(timeout, onTimeout: () => throw TimeoutException(tr('답이 없습니다')));
// 잘린 답 (TC) 은 TCP 로 다시
return r.truncated ? await _tcp(server, name, type) : r;
} finally {
sock.close();
}
}
Future<DnsAnswer> _tcp(InternetAddress server, String name, int type) async {
final id = math.Random.secure().nextInt(0x10000);
final q = buildDnsQuery(name, type, id: id);
final s = await Socket.connect(server, port, timeout: timeout);
try {
s.add([q.length >> 8, q.length & 0xff, ...q]);
final buf = BytesBuilder();
await for (final chunk in s.timeout(timeout)) {
buf.add(chunk);
final b = buf.toBytes();
if (b.length >= 2 && b.length >= 2 + ((b[0] << 8) | b[1])) {
return parseDnsResponse(Uint8List.sublistView(b, 2, 2 + ((b[0] << 8) | b[1])), id: id);
}
}
throw const FormatException('closed');
} finally {
s.destroy();
}
}
/// DNS over HTTPS (RFC 8484, POST application/dns-message). DoH 서버 이름은 직접 지정 서버로 찾는다
Future<DnsAnswer> _doh(DnsConfig c, String name, int type) async {
final url = Uri.parse(c.dohUrl.trim().replaceAll('{?dns}', ''));
final client = dohClient?.call() ?? HttpClient();
client
..findProxy = ((_) => 'DIRECT')
..connectionTimeout = timeout;
if (dohClient == null && InternetAddress.tryParse(url.host) == null) {
final servers = c.servers;
client.connectionFactory = (uri, _, _) async {
final addrs = <InternetAddress>[];
if (servers.isEmpty) {
// 직접 지정 서버가 없으면 DoH 서버 이름만 기기 DNS 로 (그 뒤의 이름은 모두 DoH)
addrs.addAll(await InternetAddress.lookup(uri.host));
} else {
for (final s in servers) {
try {
addrs.addAll((await _udp(s, uri.host, 1)).addresses);
if (addrs.isNotEmpty) break;
} catch (_) {}
}
}
if (addrs.isEmpty) throw DnsException(trf('DoH 서버 이름을 찾을 수 없습니다: {0}', [uri.host]), unreachable: true);
final raw = await Socket.connect(addrs.first, uri.port, timeout: timeout);
return ConnectionTask.fromSocket(SecureSocket.secure(raw, host: uri.host), raw.destroy);
};
}
try {
final req = await client.postUrl(url);
req.headers
..contentType = ContentType('application', 'dns-message')
..set(HttpHeaders.acceptHeader, 'application/dns-message');
final q = buildDnsQuery(name, type, id: 0); // RFC 8484: id 0
req.contentLength = q.length;
req.add(q);
final res = await req.close().timeout(timeout * 2);
final body = BytesBuilder();
await for (final chunk in res.timeout(timeout * 2)) {
body.add(chunk);
}
if (res.statusCode != 200) {
throw DnsException(trf('DoH 서버 ({0}) 가 HTTP {1} 로 답했습니다', [c.dohUrl, res.statusCode]),
unreachable: true, server: c.dohUrl);
}
return parseDnsResponse(body.toBytes());
} on DnsException {
rethrow;
} catch (e) {
throw DnsException(trf('DoH 서버 ({0}) 에 닿지 않습니다: {1}', [c.dohUrl, e]), unreachable: true, server: c.dohUrl);
} finally {
client.close(force: true);
}
}
}
/// 같은 집 안 (LAN) · 이 기기 이름: 정한 DNS 서버가 아니라 기기 DNS 로 푼다
/// (예: NAS 이름 ds1815, *.local, Tailscale 의 *.ts.net - 공용 DNS 는 이런 이름을 모른다)
bool isLocalName(String host) {
final h = host.toLowerCase().replaceAll(RegExp(r'\.$'), '');
if (h.isEmpty || h == 'localhost' || h.endsWith('.localhost')) return true;
if (InternetAddress.tryParse(h) != null) return false;
if (!h.contains('.')) return true; // 점 없는 이름 (NAS · PC 이름)
const suffixes = ['.local', '.lan', '.home', '.home.arpa', '.internal', '.intranet', '.corp', '.ts.net'];
return suffixes.any(h.endsWith);
}
/// 이 기기 · 같은 집 안 주소 (127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 100.64/10 (Tailscale), ::1, fc00::/7, fe80::/10)
bool isPrivateAddress(InternetAddress a) {
final b = a.rawAddress;
if (a.type == InternetAddressType.IPv4) {
return b[0] == 127 ||
b[0] == 10 ||
(b[0] == 172 && b[1] >= 16 && b[1] < 32) ||
(b[0] == 192 && b[1] == 168) ||
(b[0] == 169 && b[1] == 254) ||
(b[0] == 100 && b[1] >= 64 && b[1] < 128);
}
if (a.isLoopback) return true;
return (b[0] & 0xfe) == 0xfc || (b[0] == 0xfe && (b[1] & 0xc0) == 0x80);
}

316
lib/core/local_proxy.dart Normal file
View File

@ -0,0 +1,316 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import '../l10n/tr.dart';
import 'dns_resolver.dart';
/// 소켓에서 필요한 만큼 읽고 (머리 · SOCKS 답), 남은 것은 그대로 흘려보내기 위한 그릇.
/// 소켓은 한 번만 들을 수 있으므로 처음부터 이것이 듣고, 다 읽은 뒤에는 [rest] 로 넘긴다.
class SocketReader {
final Socket socket;
final _buf = BytesBuilder(copy: false);
late final StreamSubscription<Uint8List> _sub;
Completer<void>? _more;
bool _closed = false;
Object? _error;
StreamController<List<int>>? _rest;
SocketReader(this.socket) {
_sub = socket.listen(_onData, onError: _onError, onDone: _onDone, cancelOnError: true);
}
void _onData(Uint8List d) {
final r = _rest;
if (r != null) {
r.add(d);
return;
}
_buf.add(d);
_wake();
}
void _onError(Object e) {
_error = e;
_closed = true;
_rest?.addError(e);
_rest?.close();
_wake();
}
void _onDone() {
_closed = true;
_rest?.close();
_wake();
}
void _wake() {
final c = _more;
_more = null;
c?.complete();
}
Future<void> _wait() {
if (_closed) throw _error ?? SocketException(tr('연결이 끊겼습니다'));
return (_more ??= Completer<void>()).future;
}
/// 정확히 [n] 바이트
Future<Uint8List> read(int n) async {
while (_buf.length < n) {
await _wait();
}
final all = _buf.takeBytes();
if (all.length > n) _buf.add(Uint8List.sublistView(all, n));
return Uint8List.sublistView(all, 0, n);
}
/// HTTP 머리 (빈 줄까지, 최대 [max] 바이트)
Future<Uint8List> readHead({int max = 64 * 1024}) async {
while (true) {
final b = _buf.toBytes();
for (var i = 3; i < b.length; i++) {
if (b[i] == 10 && b[i - 1] == 13 && b[i - 2] == 10 && b[i - 3] == 13) {
_buf.clear();
if (b.length > i + 1) _buf.add(Uint8List.sublistView(b, i + 1));
return Uint8List.sublistView(b, 0, i + 1);
}
}
if (b.length > max) throw FormatException(tr('요청 머리가 너무 깁니다'));
await _wait();
}
}
/// 아직 읽지 않은 것 + 앞으로 오는 것 (받는 쪽이 느리면 소켓 읽기를 멈춘다)
Stream<List<int>> rest() {
final c = _rest = StreamController<List<int>>(
onPause: _sub.pause,
onResume: _sub.resume,
onCancel: () => _sub.cancel(),
);
if (_buf.isNotEmpty) c.add(_buf.takeBytes());
if (_closed) {
if (_error != null) c.addError(_error!);
c.close();
}
return c.stream;
}
}
/// 위쪽 (실제 서버 쪽) 연결을 만드는 방법
abstract class ProxyConnector {
/// [host]:[port] 로 가는 연결. 읽기는 [SocketReader] 로 (SOCKS 는 답을 먼저 읽는다)
Future<SocketReader> connect(String host, int port);
}
/// 정한 DNS 로 이름을 풀어 직접 잇는다 (DNS 강제). 같은 집 안 이름 · 주소는 기기 DNS 로 (LAN · NAS · Tailscale)
class DnsConnector implements ProxyConnector {
final DnsResolver resolver;
final Duration timeout;
DnsConnector(this.resolver, {this.timeout = const Duration(seconds: 15)});
@override
Future<SocketReader> connect(String host, int port) async {
final ip = InternetAddress.tryParse(host);
if (ip != null) return SocketReader(await Socket.connect(ip, port, timeout: timeout));
if (!resolver.config().forced || isLocalName(host)) {
return SocketReader(await Socket.connect(host, port, timeout: timeout));
}
final addrs = await resolver.lookup(host);
Object? last;
// IPv4 를 먼저 (IPv6 가 없는 망이 많다)
for (final a in [...addrs.where((a) => a.type == InternetAddressType.IPv4), ...addrs.where((a) => a.type != InternetAddressType.IPv4)]) {
try {
return SocketReader(await Socket.connect(a, port, timeout: timeout));
} catch (e) {
last = e;
}
}
throw last ?? SocketException(trf('{0} 에 연결할 수 없습니다', [host]));
}
}
/// Tor 의 SOCKS5 로 잇는다. 이름은 늘 Tor 에 넘겨 Tor 출구에서 푼다 (socks5h - 이 기기에서 DNS 를 묻지 않는다)
class TorConnector implements ProxyConnector {
/// 지금 Tor SOCKS 포트 (Tor 가 아직 준비되지 않았으면 null → 연결하지 않는다)
final int? Function() socksPort;
final Duration timeout;
TorConnector(this.socksPort, {this.timeout = const Duration(seconds: 60)});
static const _replies = {
1: '일반 오류',
2: '규칙상 허용되지 않음',
3: '망에 닿을 수 없음',
4: '서버에 닿을 수 없음',
5: '연결 거부',
6: 'TTL 만료',
7: '지원하지 않는 명령',
8: '지원하지 않는 주소 형식',
};
@override
Future<SocketReader> connect(String host, int port) async {
final sp = socksPort();
if (sp == null) throw SocketException(tr('Tor 가 아직 연결되지 않았습니다'));
final s = await Socket.connect(InternetAddress.loopbackIPv4, sp, timeout: const Duration(seconds: 5));
final r = SocketReader(s);
try {
s.add([5, 1, 0]); // 인증 없음
final hello = await r.read(2).timeout(timeout);
if (hello[0] != 5 || hello[1] != 0) throw SocketException(tr('Tor SOCKS 가 연결을 받지 않습니다'));
final ip = InternetAddress.tryParse(host);
final req = BytesBuilder()..add([5, 1, 0]);
if (ip != null && ip.type == InternetAddressType.IPv4) {
req
..addByte(1)
..add(ip.rawAddress);
} else if (ip != null && ip.type == InternetAddressType.IPv6) {
req
..addByte(4)
..add(ip.rawAddress);
} else {
final name = utf8.encode(host);
if (name.length > 255) throw SocketException(trf('주소 이름이 너무 깁니다: {0}', [host]));
req
..addByte(3) // 이름 그대로 (Tor 가 푼다)
..addByte(name.length)
..add(name);
}
req.add([port >> 8, port & 0xff]);
s.add(req.toBytes());
final head = await r.read(4).timeout(timeout);
if (head[1] != 0) {
throw SocketException(trf('Tor 가 {0}:{1} 에 연결하지 못했습니다 ({2})', [host, port, tr(_replies[head[1]] ?? '${head[1]}')]));
}
final skip = switch (head[3]) { 1 => 4, 4 => 16, 3 => (await r.read(1))[0], _ => 0 };
await r.read(skip + 2);
return r;
} catch (_) {
s.destroy();
rethrow;
}
}
}
/// 앱 안 HTTP 프록시 (127.0.0.1 의 빈 포트). CONNECT (https) 와 http 요청을 받아 [connector] 로 잇는다.
/// - DNS 강제: [DnsConnector] - 웹 브라우저 · yt-dlp · aria2 · 앱의 HTTP 가 이리로
/// - Tor 화면: [TorConnector] - 웹 뷰가 이리로 (이름 풀기까지 Tor 안에서)
/// 연결하지 못하면 직접 잇지 않고 502 로 답한다 ([onError] 로 알린다).
class LocalProxy {
final ProxyConnector connector;
/// 위쪽 연결 실패 (호스트, 오류). DNS 서버에 닿지 않음 등을 화면에 알리려고
void Function(String host, Object error)? onError;
/// 시험용: 받은 요청 (메서드, 호스트, 포트)
void Function(String method, String host, int port)? onRequest;
LocalProxy(this.connector, {this.onError});
ServerSocket? _server;
final _open = <Socket>{};
int get port => _server?.port ?? 0;
bool get running => _server != null;
Future<int> start() async {
if (_server != null) return _server!.port;
final s = _server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
s.listen((c) => unawaited(_handle(c)), onError: (_) {});
return s.port;
}
Future<void> close() async {
final s = _server;
_server = null;
await s?.close();
for (final c in [..._open]) {
c.destroy();
}
_open.clear();
}
Future<void> _handle(Socket client) async {
_open.add(client);
final cr = SocketReader(client);
SocketReader? up;
try {
final head = latin1.decode(await cr.readHead().timeout(const Duration(seconds: 30)));
final lines = head.split('\r\n');
final parts = lines.first.split(' ');
if (parts.length < 3) throw const FormatException('request line');
final method = parts[0].toUpperCase();
final target = parts[1];
String host;
int port;
if (method == 'CONNECT') {
final hp = _splitHostPort(target, 443);
host = hp.$1;
port = hp.$2;
} else {
final u = Uri.tryParse(target);
if (u == null || !u.hasScheme || u.host.isEmpty) {
client.add(latin1.encode('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'));
await client.flush();
return;
}
host = u.host;
port = u.hasPort ? u.port : (u.isScheme('https') ? 443 : 80);
}
onRequest?.call(method, host, port);
try {
up = await connector.connect(host, port);
} catch (e) {
onError?.call(host, e);
final body = utf8.encode('<html><body><h3>JJ_MKVMaker</h3><p>${const HtmlEscape().convert('$e')}</p></body></html>');
client.add(latin1.encode('HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/html; charset=utf-8\r\n'
'Content-Length: ${body.length}\r\nConnection: close\r\nX-JJ-Proxy-Error: 1\r\n\r\n'));
client.add(body);
await client.flush();
return;
}
final upSock = up.socket;
_open.add(upSock);
if (method == 'CONNECT') {
client.add(latin1.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
} else {
// http: 요청 줄을 원래 서버용 (경로만) 으로, 한 연결에 요청 하나 (Connection: close) - 다음 요청은 새 연결
final u = Uri.parse(target);
final path = '${u.path.isEmpty ? '/' : u.path}${u.hasQuery ? '?${u.query}' : ''}';
final out = StringBuffer('$method $path ${parts[2]}\r\n');
for (final l in lines.skip(1)) {
if (l.isEmpty) continue;
final name = l.split(':').first.trim().toLowerCase();
if (const ['proxy-connection', 'proxy-authorization', 'connection', 'keep-alive'].contains(name)) continue;
out.write('$l\r\n');
}
out.write('Connection: close\r\n\r\n');
upSock.add(latin1.encode(out.toString()));
}
// 양쪽으로 흘려보낸다 (한쪽이 끝나면 다른 쪽도 닫는다)
final toUp = upSock.addStream(cr.rest()).then((_) => upSock.close(), onError: (_) => upSock.destroy());
final toClient = client.addStream(up.rest()).then((_) => client.close(), onError: (_) => client.destroy());
await Future.any([toClient, toUp.then((_) => toClient)]);
} catch (_) {
// 머리가 깨졌거나 한쪽이 끊김
} finally {
client.destroy();
up?.socket.destroy();
_open.remove(client);
if (up != null) _open.remove(up.socket);
}
}
static (String, int) _splitHostPort(String s, int def) {
if (s.startsWith('[')) {
final end = s.indexOf(']');
final host = s.substring(1, end);
final rest = s.substring(end + 1);
return (host, rest.startsWith(':') ? int.parse(rest.substring(1)) : def);
}
final i = s.lastIndexOf(':');
if (i < 0) return (s, def);
return (s.substring(0, i), int.parse(s.substring(i + 1)));
}
}

104
lib/core/tor_control.dart Normal file
View File

@ -0,0 +1,104 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../l10n/tr.dart';
/// Tor 화면의 연결 상태
enum TorPhase { off, starting, ready, failed }
class TorStatus {
final TorPhase phase;
/// 연결 (bootstrap) 진행률 0 ~ 100
final int percent;
/// 지금 하는 일 · 실패 이유
final String message;
const TorStatus(this.phase, {this.percent = 0, this.message = ''});
static const off = TorStatus(TorPhase.off);
bool get ready => phase == TorPhase.ready;
}
/// 새는 것 막기 (사용자 결정 10/10): Tor 화면의 웹 뷰는 이 셋이 모두 맞을 때만 만든다 (그 전에는 아무것도 읽지 않는다).
/// - Tor 연결이 100% 끝났다
/// - 웹 뷰가 Tor 로 나가도록 프록시를 걸었다 (Android 는 앱 전체 웹 뷰 프록시를 걸고 끝날 때까지 기다림)
/// - Tor 화면 전용 프로필 (쿠키 · 기록 · 캐시를 일반 브라우저와 따로) 을 준비했다
bool torViewMayLoad({required TorStatus status, required bool proxyApplied, required bool profileReady}) =>
status.ready && proxyApplied && profileReady;
/// tor 의 알림 줄에서 연결 진행 ("Bootstrapped 45% (loading_descriptors): Loading relay descriptors") 을 읽는다
(int, String)? parseBootstrap(String line) {
final m = RegExp(r'Bootstrapped (\d+)%(?: \(([^)]*)\))?:?\s*(.*)$').firstMatch(line);
if (m == null) return null;
return (int.parse(m.group(1)!), m.group(3)?.trim() ?? '');
}
/// tor 가 연 SOCKS 포트 ("Opened Socks listener connection (ready) on 127.0.0.1:9150")
int? parseSocksPort(String line) {
final m = RegExp(r'Opened Socks listener.* on 127\.0\.0\.1:(\d+)').firstMatch(line);
return m == null ? null : int.parse(m.group(1)!);
}
/// tor 설정 (torrc). 경로는 앱 데이터 폴더 아래 (사용자 기본 torrc 를 읽지 않게 모두 적는다)
String buildTorrc({
required String dataDir,
required String controlPortFile,
required int ownerPid,
String? geoip,
String? geoip6,
}) {
final lines = [
'DataDirectory $dataDir',
// 127.0.0.1 의 빈 포트 (다른 프로그램 · 공식 Tor Browser 와 겹치지 않게)
'SocksPort 127.0.0.1:auto',
'ControlPort 127.0.0.1:auto',
'CookieAuthentication 1',
'ControlPortWriteToFile $controlPortFile',
'ClientOnly 1',
'AvoidDiskWrites 1',
'Log notice stdout',
// 앱이 끝나면 (비정상 종료 포함) tor 도 끝난다
'__OwningControllerProcess $ownerPid',
if (geoip != null) 'GeoIPFile $geoip',
if (geoip6 != null) 'GeoIPv6File $geoip6',
];
return '${lines.join('\n')}\n';
}
/// Tor 제어 포트에 명령 보내기 (쿠키 인증). [commands] 의 답 (250 줄들) 을 돌려준다
Future<List<String>> torControl(int port, List<int> cookie, List<String> commands,
{Duration timeout = const Duration(seconds: 10)}) async {
final s = await Socket.connect(InternetAddress.loopbackIPv4, port, timeout: timeout);
final lines = StreamIterator(s.cast<List<int>>().transform(utf8.decoder).transform(const LineSplitter()));
Future<List<String>> reply() async {
final out = <String>[];
while (await lines.moveNext().timeout(timeout)) {
final l = lines.current;
out.add(l);
// "250 OK" 처럼 넷째 글자가 빈칸이면 답의 끝
if (l.length >= 4 && l[3] == ' ') break;
}
if (out.isEmpty || !out.last.startsWith('250')) {
throw SocketException(trf('Tor 제어 명령 실패: {0}', [out.join(' / ')]));
}
return out;
}
try {
final hex = cookie.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
s.write('AUTHENTICATE $hex\r\n');
await reply();
final all = <String>[];
for (final c in commands) {
s.write('$c\r\n');
all.addAll(await reply());
}
s.write('QUIT\r\n');
return all;
} finally {
await lines.cancel();
s.destroy();
}
}

View File

@ -16,6 +16,9 @@ import 'app/model_backup.dart';
import 'app/version_snapshot.dart'; import 'app/version_snapshot.dart';
import 'app/install_marker.dart'; import 'app/install_marker.dart';
import 'app/component_store.dart'; import 'app/component_store.dart';
import 'app/net_guard.dart';
import 'app/tor_service.dart';
import 'app/web_proxy.dart';
import 'ui/settings_problem.dart'; import 'ui/settings_problem.dart';
import 'ui/exit_guard.dart' show confirmStopCopies; import 'ui/exit_guard.dart' show confirmStopCopies;
import 'app/copy_center.dart'; import 'app/copy_center.dart';
@ -121,6 +124,7 @@ Future<void> main(List<String> args) async {
AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai) AiStore.instance = AiStore(dataDir); // 123 · 121: AI 모델 · 엔진 (앱 데이터 폴더의 ai)
VersionSnapshot.instance = VersionSnapshot(dataDir); VersionSnapshot.instance = VersionSnapshot(dataDir);
await controller.init(); await controller.init();
await setupNetwork(controller, dataDir); // DNS 강제 · Tor (인터넷을 쓰는 다른 것보다 먼저)
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저) await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync await findBundledRsync(); // 앱에 들어 있는 rsync
@ -187,6 +191,7 @@ Future<void> main(List<String> args) async {
ExitStep(job == null ? tr('작업 확인 (진행 중인 작업 없음)') : trf('작업 중지: {0}{1}', [job, wait > 0 ? trf(' · 대기 {0}개', [wait]) : '']), ExitStep(job == null ? tr('작업 확인 (진행 중인 작업 없음)') : trf('작업 중지: {0}{1}', [job, wait > 0 ? trf(' · 대기 {0}개', [wait]) : '']),
() async => controller.cancel()), () async => controller.cancel()),
ExitStep(tr('다운로드 정리 (yt-dlp · aria2 종료)'), downloads.shutdown), ExitStep(tr('다운로드 정리 (yt-dlp · aria2 종료)'), downloads.shutdown),
ExitStep(tr('Tor 끄기'), () => TorService.instance.stop()),
ExitStep(tr('쓰레드 확인 (AI · 변환 작업이 멈출 때까지)'), () async { ExitStep(tr('쓰레드 확인 (AI · 변환 작업이 멈출 때까지)'), () async {
while (controller.busy) { while (controller.busy) {
await Future<void>.delayed(const Duration(milliseconds: 100)); await Future<void>.delayed(const Duration(milliseconds: 100));
@ -250,6 +255,7 @@ Future<void> main(List<String> args) async {
await memory.restoreAndWatch(); await memory.restoreAndWatch();
unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json'))); unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json')));
watchDnsFailure(messengerKey, controller);
// 클립보드로 추가된 다운로드 알림 · 트레이 글 // 클립보드로 추가된 다운로드 알림 · 트레이 글
downloads.onAdded.listen((t) => messengerKey.currentState downloads.onAdded.listen((t) => messengerKey.currentState
?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source]))))); ?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source])))));
@ -430,6 +436,57 @@ Future<void> openFromOtherApp(
} }
} }
/// DNS 강제 (사용자 요청 10/10) · Tor 준비: 앱의 HTTP 가 정한 DNS 를 쓰게 하고, 설정이 바뀌면 다시 건다.
/// [second] (재생만 하는 새 창) 은 설정을 읽기만 한다
Future<void> setupNetwork(AppController c, String dataDir, {bool second = false}) async {
final g = NetGuard.instance
..config = (() => c.settings.dnsConfig)
..log = c.note;
try {
await g.ensure();
} catch (e) {
c.note(trf('DNS 강제 프록시를 시작하지 못했습니다: {0}', [e]));
}
HttpOverrides.global = JjHttpOverrides();
if (second) return;
TorService.dataDir = dataDir;
TorService.instance.log = c.note;
if (Platform.isAndroid) {
try {
await WebProxy.apply();
} catch (e) {
c.note(trf('웹 뷰 프록시를 걸지 못했습니다: {0}', [e]));
}
}
var last = c.settings.dnsConfig.key;
c.addListener(() async {
final now = c.settings.dnsConfig.key;
if (now == last) return;
last = now;
c.note(trf('DNS 설정: {0}', [c.settings.dnsMode == 'system' ? tr('시스템 DNS') : c.settings.dnsConfig.label]));
await g.ensure();
if (Platform.isAndroid) await WebProxy.apply();
});
}
/// DNS 서버에 닿지 않으면 알린다 (기기 DNS 로 몰래 바꾸지 않음). [시스템 DNS 로 바꾸기] 를 함께
void watchDnsFailure(GlobalKey<ScaffoldMessengerState> messengerKey, AppController c) {
NetGuard.instance.failure.addListener(() {
final f = NetGuard.instance.failure.value;
if (f == null) return;
messengerKey.currentState?.showSnackBar(SnackBar(
duration: const Duration(seconds: 12),
showCloseIcon: true,
persist: false,
content: Text(trf('DNS 서버 ({0}) 에 닿지 않아 주소를 찾지 못했습니다: {1}', [f.server, f.reason])),
action: SnackBarAction(
label: tr('시스템 DNS 로 바꾸기'),
onPressed: () => c.updateSettings((x) => x.dnsMode = 'system'),
),
));
});
}
/// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서") /// 탐색기에서 연 동영상을 새 재생 창으로 띄울지 (재생 요청 + 설정 "새 창에서")
bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) => bool wantsNewPlayerWindow(LaunchRequest r, AppSettings s) =>
s.openFileWindow == 'new' && s.openFileWindow == 'new' &&
@ -486,6 +543,7 @@ Future<void> runAndroid(String dataDir) async {
} catch (_) {} } catch (_) {}
VersionSnapshot.instance = VersionSnapshot(dataDir, sharedDir: sharedSnapshots); VersionSnapshot.instance = VersionSnapshot(dataDir, sharedDir: sharedSnapshots);
await controller.init(); await controller.init();
await setupNetwork(controller, dataDir); // DNS 강제 · Tor · 웹 뷰 프록시 (인터넷을 쓰는 다른 것보다 먼저)
await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저) await setupMasterLock(controller); // 124: 저장된 비밀번호를 쓰기 전에 (실시간 동기화보다 먼저)
// 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함 // 실시간 동기화 (환경 설정 > 파일 탐색기): 앱이 켜져 있는 동안. 다시 켤 때 바로 / 골라서 / 시작 안 함
await findBundledRsync(); // 앱에 들어 있는 rsync await findBundledRsync(); // 앱에 들어 있는 rsync
@ -510,6 +568,7 @@ Future<void> runAndroid(String dataDir) async {
); );
downloads.onAdded.listen((t) => messengerKey.currentState downloads.onAdded.listen((t) => messengerKey.currentState
?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source]))))); ?.showSnackBar(SnackBar(content: Text(trf('다운로드 추가: {0}', [t.source])))));
watchDnsFailure(messengerKey, controller);
// 다 받은 동영상 → 편집 목록 (설정에서 끌 수 있음) // 다 받은 동영상 → 편집 목록 (설정에서 끌 수 있음)
downloads.onFinished.listen((t) async { downloads.onFinished.listen((t) async {
if (!controller.settings.addFinishedDownloads) return; if (!controller.settings.addFinishedDownloads) return;
@ -641,6 +700,7 @@ Future<void> runAndroid(String dataDir) async {
} }
controller.cancel(); controller.cancel();
await downloads.shutdown(); await downloads.shutdown();
await TorService.instance.stop();
controller.note(tr('── 정상 종료 ──')); controller.note(tr('── 정상 종료 ──'));
await services.shell.quit(); await services.shell.quit();
}, },
@ -657,6 +717,7 @@ Future<void> runSecondWindow(List<String> files, String dataDir) async {
await WindowMemory(p.join(dataDir, 'window.json'), 'second').restoreAndWatch(); await WindowMemory(p.join(dataDir, 'window.json'), 'second').restoreAndWatch();
final controller = AppController(PlatformServices.create()); final controller = AppController(PlatformServices.create());
await controller.init(); await controller.init();
await setupNetwork(controller, dataDir, second: true);
// 화면 언어 (환경 설정 > 화면 언어) // 화면 언어 (환경 설정 > 화면 언어)
i18n.init(controller, dataDir); i18n.init(controller, dataDir);
await i18n.apply(controller.settings.uiLanguage, save: false); await i18n.apply(controller.settings.uiLanguage, save: false);

View File

@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
import '../../core/download_detect.dart'; import '../../core/download_detect.dart';
import '../../services/downloader.dart'; import '../../services/downloader.dart';
import '../../l10n/tr.dart'; import '../../l10n/tr.dart';
import '../../app/net_guard.dart';
import 'ytdlp_backend.dart' show locateTool; import 'ytdlp_backend.dart' show locateTool;
/// aria2 로 토렌트 · 마그넷 다운로드. /// aria2 로 토렌트 · 마그넷 다운로드.
@ -47,6 +48,8 @@ class Aria2Backend implements DownloadBackend {
final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
_port = s.port; _port = s.port;
await s.close(); await s.close();
// DNS 강제 (환경 설정 > 네트워크 · DNS): http · 트래커는 앱 안 프록시로, UDP 트래커 · DHT 의 이름은 정한 서버로
final dns = NetGuard.instance.aria2Args(asyncDns: await _hasAsyncDns());
_daemon = await Process.start(aria2c, [ _daemon = await Process.start(aria2c, [
'--enable-rpc', '--rpc-listen-all=false', '--rpc-listen-port=$_port', '--enable-rpc', '--rpc-listen-all=false', '--rpc-listen-port=$_port',
'--rpc-secret=$_secret', '--rpc-secret=$_secret',
@ -56,6 +59,7 @@ class Aria2Backend implements DownloadBackend {
'--console-log-level=warn', '--summary-interval=0', '--console-log-level=warn', '--summary-interval=0',
'--file-allocation=none', '--auto-file-renaming=false', '--file-allocation=none', '--auto-file-renaming=false',
...extraArgs, ...extraArgs,
...dns,
], environment: environment.isEmpty ? null : environment); ], environment: environment.isEmpty ? null : environment);
_daemon!.stdout.drain<void>(); _daemon!.stdout.drain<void>();
_daemon!.stderr.drain<void>(); _daemon!.stderr.drain<void>();
@ -72,6 +76,16 @@ class Aria2Backend implements DownloadBackend {
throw SocketException(tr('aria2 RPC 에 연결할 수 없습니다.')); throw SocketException(tr('aria2 RPC 에 연결할 수 없습니다.'));
} }
/// 이 aria2c 가 자체 DNS (c-ares, --async-dns-server) 를 갖췄는지 (없는 옵션을 주면 시작하지 못한다)
Future<bool> _hasAsyncDns() async {
try {
final r = await Process.run(aria2c, ['-v'], environment: environment.isEmpty ? null : environment);
return '${r.stdout}'.contains('Async DNS');
} catch (_) {
return false;
}
}
Future<dynamic> _call(String method, List<Object?> params) async { Future<dynamic> _call(String method, List<Object?> params) async {
final payload = utf8.encode(jsonEncode({ final payload = utf8.encode(jsonEncode({
'jsonrpc': '2.0', 'id': 'jj', 'method': method, 'jsonrpc': '2.0', 'id': 'jj', 'method': method,

View File

@ -7,6 +7,7 @@ import 'package:path/path.dart' as p;
import '../../core/download_detect.dart'; import '../../core/download_detect.dart';
import '../../services/downloader.dart'; import '../../services/downloader.dart';
import '../../l10n/tr.dart'; import '../../l10n/tr.dart';
import '../../app/net_guard.dart';
/// 실행 파일 찾기: 앱 옆 tools\ → 앱 폴더 → PATH /// 실행 파일 찾기: 앱 옆 tools\ → 앱 폴더 → PATH
String locateTool(String name) { String locateTool(String name) {
@ -88,6 +89,7 @@ class YtDlpBackend implements DownloadBackend {
...prefixArgs, ...prefixArgs,
'--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8', '--flat-playlist', '-J', '--no-warnings', '--encoding', 'utf-8',
if (jsRuntime != null) ...['--js-runtimes', jsRuntime!], if (jsRuntime != null) ...['--js-runtimes', jsRuntime!],
...NetGuard.instance.ytDlpArgs(), // DNS 강제 (환경 설정 > 네트워크 · DNS)
...cookies, ...cookies,
'https://www.youtube.com/playlist?list=$listId', 'https://www.youtube.com/playlist?list=$listId',
], ],
@ -145,6 +147,8 @@ class YtDlpBackend implements DownloadBackend {
..._cookiesFor(t), ..._cookiesFor(t),
if (ffmpegDir != null) ...['--ffmpeg-location', ffmpegDir!], if (ffmpegDir != null) ...['--ffmpeg-location', ffmpegDir!],
if (jsRuntime != null) ...['--js-runtimes', jsRuntime!], if (jsRuntime != null) ...['--js-runtimes', jsRuntime!],
// DNS 강제 (환경 설정 > 네트워크 · DNS): 이름은 앱 안 프록시가 정한 DNS 로 푼다
...NetGuard.instance.ytDlpArgs(),
t.source, t.source,
]; ];

View File

@ -12,6 +12,7 @@ import 'downloads_page.dart';
import 'ai_image_page.dart'; import 'ai_image_page.dart';
import 'explorer_page.dart'; import 'explorer_page.dart';
import 'settings_page.dart'; import 'settings_page.dart';
import 'tor_page.dart';
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -99,6 +100,9 @@ class AppNavButtons extends StatelessWidget {
/// AI 그림 화면 자신 (123) /// AI 그림 화면 자신 (123)
final bool onAiPage; final bool onAiPage;
/// Tor 브라우저 화면 자신
final bool onTorPage;
/// 174: MKV 화면 자신 - 홈 화면이 탐색기 등이면 MKV 화면이 맨 처음 화면이 아닐 수 있어 따로 알린다 /// 174: MKV 화면 자신 - 홈 화면이 탐색기 등이면 MKV 화면이 맨 처음 화면이 아닐 수 있어 따로 알린다
final bool onMkvPage; final bool onMkvPage;
const AppNavButtons( const AppNavButtons(
@ -108,6 +112,7 @@ class AppNavButtons extends StatelessWidget {
this.onExplorerPage = false, this.onExplorerPage = false,
this.onRsyncPage = false, this.onRsyncPage = false,
this.onAiPage = false, this.onAiPage = false,
this.onTorPage = false,
this.onMkvPage = false}); this.onMkvPage = false});
/// 버튼 줄의 가장 넓은 폭 (JJ · 화면 6개 · 뒤로) /// 버튼 줄의 가장 넓은 폭 (JJ · 화면 6개 · 뒤로)
@ -118,7 +123,7 @@ class AppNavButtons extends StatelessWidget {
/// 홈 화면 (환경 설정의 "홈 화면") 의 컴포넌트 id. 그 컴포넌트를 제거했으면 설치된 첫 화면. /// 홈 화면 (환경 설정의 "홈 화면") 의 컴포넌트 id. 그 컴포넌트를 제거했으면 설치된 첫 화면.
static String homeId(AppSettings s) { static String homeId(AppSettings s) {
final want = switch (s.startScreen) { 'browser' => 'browser', 'files' => 'explorer', _ => 'mkv' }; final want = switch (s.startScreen) { 'browser' => 'browser', 'files' => 'explorer', 'tor' => 'tor', _ => 'mkv' };
final pages = AppComponent.pages(s.components, s.navOrder); final pages = AppComponent.pages(s.components, s.navOrder);
if (pages.any((p) => p.id == want)) return want; if (pages.any((p) => p.id == want)) return want;
return pages.isEmpty ? 'mkv' : pages.first.id; return pages.isEmpty ? 'mkv' : pages.first.id;
@ -140,6 +145,8 @@ class AppNavButtons extends StatelessWidget {
if (downloads != null) await DownloadsPage.open(nav, downloads); if (downloads != null) await DownloadsPage.open(nav, downloads);
case 'aiimage': case 'aiimage':
await AiImagePage.open(nav, c: c); await AiImagePage.open(nav, c: c);
case 'tor':
await TorPage.open(nav, c: c);
} }
} }
@ -218,6 +225,8 @@ class AppNavButtons extends StatelessWidget {
? 'rsync' ? 'rsync'
: onAiPage : onAiPage
? 'aiimage' ? 'aiimage'
: onTorPage
? 'tor'
: onMkvPage || atRoot : onMkvPage || atRoot
? 'mkv' ? 'mkv'
: ''; : '';
@ -227,6 +236,7 @@ class AppNavButtons extends StatelessWidget {
'explorer' => tr('파일 탐색기'), 'explorer' => tr('파일 탐색기'),
'rsync' => 'Rsync', 'rsync' => 'Rsync',
'downloads' => tr('다운로드 목록'), 'downloads' => tr('다운로드 목록'),
'tor' => tr('Tor 브라우저'),
_ => tr('MKV 화면'), _ => tr('MKV 화면'),
}; };
String tip(String id, bool here) => switch (id) { String tip(String id, bool here) => switch (id) {
@ -236,6 +246,7 @@ class AppNavButtons extends StatelessWidget {
'rsync' => here ? tr('Rsync (지금 여기)') : 'Rsync', 'rsync' => here ? tr('Rsync (지금 여기)') : 'Rsync',
'downloads' => here ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'), 'downloads' => here ? tr('다운로드 목록 (지금 여기)') : tr('다운로드 목록'),
'aiimage' => here ? tr('AI 그림 (지금 여기)') : tr('AI 그림'), 'aiimage' => here ? tr('AI 그림 (지금 여기)') : tr('AI 그림'),
'tor' => here ? tr('Tor 브라우저 (지금 여기)') : tr('Tor 브라우저'),
_ => id, _ => id,
}; };
// 좁은 화면 (폰): 아이콘 아래에 짧은 이름 - 글자 없는 작은 아이콘만 늘어서 무엇인지 몰랐던 것 // 좁은 화면 (폰): 아이콘 아래에 짧은 이름 - 글자 없는 작은 아이콘만 늘어서 무엇인지 몰랐던 것
@ -307,6 +318,7 @@ class AppNavButtons extends StatelessWidget {
'rsync' => 'Rsync', 'rsync' => 'Rsync',
'downloads' => tr('다운로드'), 'downloads' => tr('다운로드'),
'aiimage' => tr('AI 그림'), 'aiimage' => tr('AI 그림'),
'tor' => 'Tor',
_ => id, _ => id,
}; };
return Row(mainAxisSize: MainAxisSize.min, children: [ return Row(mainAxisSize: MainAxisSize.min, children: [
@ -329,7 +341,7 @@ class AppNavButtons extends StatelessWidget {
btn(const Icon(Icons.arrow_back), tr('뒤로'), btn(const Icon(Icons.arrow_back), tr('뒤로'),
// 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다 // 브라우저 화면: 뒤로 키는 웹 페이지 뒤로 (PopScope) 이지만 이 버튼은 화면 이동이라 바로 닫는다
// 위쪽 ← 는 늘 화면 닫기 (브라우저 · 탐색기는 뒤로 키가 웹 뒤로 · 선택 풀기 · 상위 폴더를 먼저 하므로 그것을 거치지 않게 - 49) // 위쪽 ← 는 늘 화면 닫기 (브라우저 · 탐색기는 뒤로 키가 웹 뒤로 · 선택 풀기 · 상위 폴더를 먼저 하므로 그것을 거치지 않게 - 49)
atRoot ? null : () => onBrowserPage || onExplorerPage || onRsyncPage ? Navigator.pop(context) : Navigator.maybePop(context), atRoot ? null : () => onBrowserPage || onTorPage || onExplorerPage || onRsyncPage ? Navigator.pop(context) : Navigator.maybePop(context),
label: tr('뒤로')), label: tr('뒤로')),
]); ]);
} }

View File

@ -11,6 +11,8 @@ import 'package:webview_cef/webview_cef.dart' as cef;
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../app/bookmarks_controller.dart'; import '../app/bookmarks_controller.dart';
import '../app/download_manager.dart'; import '../app/download_manager.dart';
import '../app/net_guard.dart';
import '../app/web_proxy.dart';
import '../core/bookmarks.dart'; import '../core/bookmarks.dart';
import '../core/download_detect.dart' show CookieExport, CookieRecord, internalBrowserCookies, isLoginCookieDomain, toNetscapeCookies; import '../core/download_detect.dart' show CookieExport, CookieRecord, internalBrowserCookies, isLoginCookieDomain, toNetscapeCookies;
import '../core/web_address.dart'; import '../core/web_address.dart';
@ -24,6 +26,8 @@ import 'app_actions.dart';
import 'bookmark_ui.dart'; import 'bookmark_ui.dart';
import 'confirm.dart'; import 'confirm.dart';
import 'downloads_page.dart'; import 'downloads_page.dart';
import 'settings_page.dart';
import 'tor_page.dart';
import 'theme.dart'; import 'theme.dart';
import 'work_panel.dart'; import 'work_panel.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -316,6 +320,42 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
), ),
); );
Widget _torBanner() => Material(
color: const Color(0xFF9C6ADE).withValues(alpha: 0.18),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 4),
child: Row(children: [
const Icon(Icons.vpn_lock_outlined, size: 16, color: Color(0xFF9C6ADE)),
const SizedBox(width: 8),
Expanded(child: Text(tr('Tor 화면이 열려 있는 동안 이 브라우저도 Tor 로 나갑니다'), style: const TextStyle(fontSize: 12))),
]),
),
);
/// Tor 브라우저로: 켜져 있으면 Tor 화면, 아니면 짧은 안내와 [환경 설정 > 컴포넌트]
Future<void> _openTor() async {
if (widget.c.settings.components.contains('tor')) {
await TorPage.open(Navigator.of(context), c: widget.c);
return;
}
final go = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('Tor 브라우저')),
content: Text(tr('Tor 브라우저는 페이지를 Tor 로 열어 사이트가 이 기기의 주소를 알 수 없게 합니다 (쿠키 · 기록은 따로, 닫으면 지움). '
'환경 설정 > 컴포넌트 에서 켜면 이 브라우저 옆에 Tor 화면이 생깁니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('환경 설정 > 컴포넌트'))),
],
),
);
if (go == true && mounted) {
await Navigator.push(context, MaterialPageRoute<void>(builder: (_) => SettingsPage(c: widget.c, initialGroup: 'components')));
}
}
/// 53: 쿠키를 쓰도록 정했을 때만 · 고른 사이트만 내보낸다 /// 53: 쿠키를 쓰도록 정했을 때만 · 고른 사이트만 내보낸다
void _applyCookieExport() { void _applyCookieExport() {
final s = widget.c.settings; final s = widget.c.settings;
@ -506,6 +546,11 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
builder: (context, _) => Column(children: [ builder: (context, _) => Column(children: [
_toolbar(), _toolbar(),
if (_swipeHint) _swipeHintBar(), if (_swipeHint) _swipeHintBar(),
// Android: Tor 화면이 열려 있는 동안은 웹 뷰 프록시가 앱 전체라 이 브라우저도 Tor 로 (관리자 결정 10/10)
ValueListenableBuilder<bool>(
valueListenable: WebProxy.torOpen,
builder: (_, on, _) => on ? _torBanner() : const SizedBox(),
),
Expanded(child: Row(children: [ Expanded(child: Row(children: [
if (_panel) if (_panel)
SizedBox( SizedBox(
@ -672,6 +717,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
if (!compact) btn(Icons.vpn_lock_outlined, tr('Tor 브라우저'), _openTor),
if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)), if (!compact) btn(Icons.open_in_new, tr('외부 브라우저로 열기'), () => _openExternal(_url)),
if (!compact) btn(Icons.cookie_outlined, tr('쿠키 · 방문 기록 지우기'), _clearData), if (!compact) btn(Icons.cookie_outlined, tr('쿠키 · 방문 기록 지우기'), _clearData),
if (widget.c.busy && !_work && !compact) if (widget.c.busy && !_work && !compact)
@ -699,6 +745,7 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
'external' => _openExternal(_url), 'external' => _openExternal(_url),
'work' => _toggleWork(), 'work' => _toggleWork(),
'clear' => _clearData(), 'clear' => _clearData(),
'tor' => _openTor(),
_ => setState(() => _panel = !_panel), _ => setState(() => _panel = !_panel),
}, },
itemBuilder: (_) => [ itemBuilder: (_) => [
@ -715,6 +762,9 @@ class _BrowserPageState extends State<BrowserPage> with RouteAware {
PopupMenuItem( PopupMenuItem(
value: 'work', value: 'work',
child: ListTile(dense: true, leading: const Icon(Icons.view_sidebar_outlined), title: Text(tr('작업 현황')))), child: ListTile(dense: true, leading: const Icon(Icons.view_sidebar_outlined), title: Text(tr('작업 현황')))),
PopupMenuItem(
value: 'tor',
child: ListTile(dense: true, leading: const Icon(Icons.vpn_lock_outlined), title: Text(tr('Tor 브라우저')))),
PopupMenuItem( PopupMenuItem(
value: 'external', value: 'external',
child: ListTile(dense: true, leading: const Icon(Icons.open_in_new), title: Text(tr('외부 브라우저로 열기')))), child: ListTile(dense: true, leading: const Icon(Icons.open_in_new), title: Text(tr('외부 브라우저로 열기')))),
@ -786,13 +836,20 @@ class _EdgeView extends StatefulWidget {
Future<WebViewEnvironment?>? _env; Future<WebViewEnvironment?>? _env;
/// 로그인 · 쿠키가 유지되는 데이터 폴더를 쓰는 브라우저 환경 (한 번만 만들어 재사용) /// 로그인 · 쿠키가 유지되는 데이터 폴더를 쓰는 브라우저 환경 (한 번만 만들어 재사용).
/// Android 는 웹 뷰 프록시 (DNS 강제 · Tor 화면) 를 건 뒤에 웹 뷰를 만든다
Future<WebViewEnvironment?> browserEnvironment(String dir) => Future<WebViewEnvironment?> browserEnvironment(String dir) =>
_env ??= Platform.isWindows ? _createEnvironment(dir) : Future.value(null); _env ??= Platform.isWindows ? _createEnvironment(dir) : WebProxy.apply().then<WebViewEnvironment?>((_) => null, onError: (_) => null);
/// DNS 강제 (환경 설정 > 네트워크 · DNS): Edge 의 모든 연결을 앱 안 프록시로 (이름을 정한 DNS 로 풂). 환경을 만들 때 정해진다
String? edgeBrowserArguments() {
final proxy = NetGuard.instance.browserProxy;
return proxy == null ? null : '--proxy-server=$proxy --dns-prefetch-disable';
}
Future<WebViewEnvironment?> _createEnvironment(String dir) async { Future<WebViewEnvironment?> _createEnvironment(String dir) async {
Future<WebViewEnvironment> create() => Future<WebViewEnvironment> create() => WebViewEnvironment.create(
WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: dir.isEmpty ? null : dir)); settings: WebViewEnvironmentSettings(userDataFolder: dir.isEmpty ? null : dir, additionalBrowserArguments: edgeBrowserArguments()));
try { try {
// COM 이 풀려 있으면 "CoInitialize 가 호출되지 않았습니다" 로 실패한다 → 만들기 전에 다시 준비 // COM 이 풀려 있으면 "CoInitialize 가 호출되지 않았습니다" 로 실패한다 → 만들기 전에 다시 준비
ComGuard.ensure(); ComGuard.ensure();
@ -1041,8 +1098,10 @@ class _CefViewState extends State<_CefView> {
final h = widget.host; final h = widget.host;
try { try {
// 로그인 · 쿠키를 Edge 와 따로 보관 (데이터 폴더 아래 cef) // 로그인 · 쿠키를 Edge 와 따로 보관 (데이터 폴더 아래 cef)
_cefStarted ??= cef.WebviewManager() // DNS 강제: Chrome 엔진도 앱 안 프록시로 (packages/webview_cef/JJ_PATCH.md 6 - 시작할 때 정해짐)
.initialize(rootCachePath: widget.dataDir.isEmpty ? null : p.join(p.dirname(widget.dataDir), 'cef')); _cefStarted ??= cef.WebviewManager().initialize(
rootCachePath: widget.dataDir.isEmpty ? null : p.join(p.dirname(widget.dataDir), 'cef'),
proxyServer: NetGuard.instance.browserProxy);
await _cefStarted; await _cefStarted;
_c.setWebviewListener(cef.WebviewEventsListener( _c.setWebviewListener(cef.WebviewEventsListener(
onUrlChanged: (u) { onUrlChanged: (u) {

View File

@ -5,6 +5,8 @@ import 'dart:io';
import '../app/app_controller.dart'; import '../app/app_controller.dart';
import '../app/component_store.dart'; import '../app/component_store.dart';
import '../app/components.dart'; import '../app/components.dart';
import '../app/tor_bundle.dart';
import '../app/tor_service.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'setting_tile.dart'; import 'setting_tile.dart';
import 'theme.dart'; import 'theme.dart';
@ -160,6 +162,7 @@ class ComponentSettings extends StatelessWidget {
Future<void> setInstalled(AppComponent x, bool on) async { Future<void> setInstalled(AppComponent x, bool on) async {
if (x.needsDownload) return _setDownloaded(context, x, on); if (x.needsDownload) return _setDownloaded(context, x, on);
if (x.id == TorBundle.id) return _setTor(context, c, on);
// 화면 있는 것이 하나도 남지 않게는 못 한다 // 화면 있는 것이 하나도 남지 않게는 못 한다
if (!on && x.page && pages.length <= 1 && pages.first.id == x.id) { if (!on && x.page && pages.length <= 1 && pages.first.id == x.id) {
ScaffoldMessenger.maybeOf(context) ScaffoldMessenger.maybeOf(context)
@ -176,7 +179,11 @@ class ComponentSettings extends StatelessWidget {
void move(int from, int to) { void move(int from, int to) {
final ids = [for (final p in pages) p.id]; final ids = [for (final p in pages) p.id];
ids.insert(to, ids.removeAt(from)); ids.insert(to, ids.removeAt(from));
c.updateSettings((v) => v.navOrder = [...ids, ...AppComponent.defaultOrder.where((x) => !ids.contains(x))]); // 아직 켜지 않은 Tor 는 순서에 넣지 않는다 (켜면 웹 브라우저 바로 뒤에 - AppComponent.fullOrder)
c.updateSettings((v) => v.navOrder = [
...ids,
...AppComponent.defaultOrder.where((x) => !ids.contains(x) && x != AppComponent.tor.id),
]);
} }
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
@ -199,6 +206,8 @@ class ComponentSettings extends StatelessWidget {
title: Text(tr(x.name)), title: Text(tr(x.name)),
subtitle: Text(x.needsDownload subtitle: Text(x.needsDownload
? '${tr(x.description)}\n${Platform.isWindows ? tr('켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.') : tr('Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.')}' ? '${tr(x.description)}\n${Platform.isWindows ? tr('켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.') : tr('Windows 에서만 쓸 수 있습니다. 이 기기에서는 문서를 다른 앱으로 엽니다.')}'
: x.id == TorBundle.id
? '${tr(x.description)}\n${_torNote(installed.contains(x.id))}'
: tr(x.description)), : tr(x.description)),
trailing: Switch( trailing: Switch(
value: installed.contains(x.id), value: installed.contains(x.id),
@ -233,3 +242,103 @@ class ComponentSettings extends StatelessWidget {
]); ]);
} }
} }
/// Tor 컴포넌트 안내 줄: 판 · 받을 크기 (Windows) · 앱에 들어 있음 (Android)
String _torNote(bool on) {
if (Platform.isWindows) {
final v = TorBundle.installedVersion();
return on && v != null
? trf('설치됨: Tor Expert Bundle {0} (Tor 공식 배포). 끄면 지웁니다.', [v])
: trf('켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.',
[TorBundle.version, TorBundle.torVersion, TorBundle.approxDownloadMb]);
}
return trf('앱에 들어 있습니다 (Tor {0}, tor {1}). 켜도 Tor 화면을 열 때까지 아무것도 시작하지 않습니다.',
[TorBundle.version, TorBundle.torVersion]);
}
/// Tor 켜기 · 끄기. Windows 는 켤 때 공식 번들을 받아 설치 (진행 창 · 취소), 끌 때 Tor 를 멈추고 지운다.
/// Android 는 APK 에 들어 있는 tor 가 있는지만 확인하고 켠다 (처음 꺼짐)
Future<void> _setTor(BuildContext context, AppController c, bool on) async {
final name = tr(AppComponent.tor.name);
if (!on) {
await TorService.instance.stop();
if (Platform.isWindows) {
if (!context.mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('{0} 제거', [name])),
content: Text(tr('내려받은 Tor 파일을 지웁니다. 다시 켜면 다시 내려받습니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
],
),
);
if (ok != true) return;
try {
await TorBundle.removeWindows();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('지우지 못했습니다: {0}', [e]))));
}
return;
}
}
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != TorBundle.id) id]);
return;
}
if (Platform.isAndroid) {
if (await TorBundle.androidExe() == null) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)
?.showSnackBar(SnackBar(content: Text(tr('이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.'))));
}
return;
}
} else if (TorBundle.windowsExe == null || TorBundle.installedVersion() != TorBundle.version) {
final step = ValueNotifier<(String, double?)>((tr('준비 중'), null));
var cancel = false;
Object? error;
if (!context.mounted) return;
final dialog = showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('{0} 설치', [name])),
content: ValueListenableBuilder<(String, double?)>(
valueListenable: step,
builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(v.$1),
const SizedBox(height: 10),
LinearProgressIndicator(value: v.$2),
]),
),
actions: [TextButton(onPressed: () => cancel = true, child: Text(tr('취소')))],
),
);
try {
c.note(trf('Tor 내려받기 시작: {0}', [TorBundle.urls(TorBundle.windowsFile).first]));
await TorBundle.installWindows(onProgress: (s, d) => step.value = (s, d), isCancelled: () => cancel);
c.note(trf('Tor 설치 완료: {0}', [TorBundle.dir.path]));
} catch (e) {
error = e;
c.note(trf('Tor 설치 실패: {0}', [e]));
}
if (context.mounted) Navigator.of(context).pop();
await dialog;
step.dispose();
if (error != null) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('설치하지 못했습니다: {0}', [error]))));
}
return;
}
}
await c.updateSettings((v) => v.components = [for (final id in v.components) if (id != TorBundle.id) id, TorBundle.id]);
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(trf('{0} 을(를) 설치했습니다.', [name]))));
}
}

View File

@ -24,6 +24,7 @@ List<(List<String>, String)> get _licenseAssets => [
'FFmpegKit (ffmpeg_kit_flutter_new_min_gpl)', 'FFmpegKit (ffmpeg_kit_flutter_new_min_gpl)',
'youtubedl-android', 'youtubedl-android',
'H2Orestart 0.7.14', 'H2Orestart 0.7.14',
tr('Tor (tor 실행 파일 - GPL 을 켠 빌드)'),
], ],
'assets/licenses/GPL-3.0.txt' 'assets/licenses/GPL-3.0.txt'
), ),
@ -32,6 +33,8 @@ List<(List<String>, String)> get _licenseAssets => [
([tr('yt-dlp (실행 파일에 묶인 구성 요소)')], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'), ([tr('yt-dlp (실행 파일에 묶인 구성 요소)')], 'assets/licenses/YT-DLP_THIRD_PARTY_LICENSES.txt'),
(['rsync'], 'assets/licenses/RSYNC_COPYING.txt'), (['rsync'], 'assets/licenses/RSYNC_COPYING.txt'),
(['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'), (['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'),
// Tor 브라우저: tor 와 함께 묶인 Libevent · OpenSSL · zlib · zstd (공식 번들의 docs)
(['Tor 0.4.9.13 (Libevent · OpenSSL · zlib · Zstandard)'], 'assets/licenses/TOR_LICENSES.txt'),
// 175: 함께 빌드한 네이티브 라이브러리 (Dart 쪽 패키지 라이선스와 따로) // 175: 함께 빌드한 네이티브 라이브러리 (Dart 쪽 패키지 라이선스와 따로)
([tr('whisper.cpp · ggml (whisper_ggml 안)')], 'assets/licenses/WHISPER_CPP_LICENSE.txt'), ([tr('whisper.cpp · ggml (whisper_ggml 안)')], 'assets/licenses/WHISPER_CPP_LICENSE.txt'),
(['ONNX Runtime 1.15.1 (Microsoft)'], 'assets/licenses/ONNXRUNTIME_LICENSES.txt'), (['ONNX Runtime 1.15.1 (Microsoft)'], 'assets/licenses/ONNXRUNTIME_LICENSES.txt'),

View File

@ -0,0 +1,219 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../app/settings.dart';
import '../app/tor_service.dart';
import '../app/web_proxy.dart';
import '../core/dns_resolver.dart';
import '../l10n/tr.dart';
import '../platform/windows/cef_runtime.dart';
import 'setting_tile.dart';
import 'theme.dart';
import 'tor_page.dart' show showTorNotice;
/// 환경 설정 > 네트워크 · DNS (사용자 요청 10/10): DNS 서버 강제 (처음: 직접 지정 8.8.8.8 · 8.8.4.4)
class DnsSettings extends StatefulWidget {
final AppController c;
const DnsSettings({super.key, required this.c});
@override
State<DnsSettings> createState() => _DnsSettingsState();
}
class _DnsSettingsState extends State<DnsSettings> {
AppController get c => widget.c;
String? _primaryError, _secondaryError, _dohError;
String? _test;
bool _testing = false;
Future<void> _save(void Function(AppSettings x) f) => c.updateSettings(f);
/// [지금 확인]: 지금 설정으로 이름 하나를 찾아 본다 (어느 서버가 답했는지)
Future<void> _check() async {
setState(() {
_testing = true;
_test = null;
});
final r = DnsResolver(config: () => c.settings.dnsConfig);
String? server;
r.onAnswer = (_, s) => server = s;
try {
final a = c.settings.dnsMode == 'system'
? await InternetAddress.lookup('www.google.com').then((v) {
server = tr('기기 DNS');
return v;
})
: await r.lookup('www.google.com');
_test = trf('www.google.com → {0} (답한 서버: {1})', [a.map((x) => x.address).take(2).join(', '), server]);
} catch (e) {
_test = trf('실패: {0}', [e]);
}
if (mounted) setState(() => _testing = false);
}
Widget _field(String label, String value, String? error, ValueChanged<String> onChanged, {String? hint}) => SizedBox(
width: 300,
child: TextFormField(
initialValue: value,
decoration: InputDecoration(isDense: true, border: const OutlineInputBorder(), labelText: label, hintText: hint, errorText: error),
onChanged: onChanged,
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
);
@override
Widget build(BuildContext context) {
final s = c.settings;
final desk = Platform.isWindows;
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SettingTile(
leading: const Icon(Icons.dns_outlined),
title: Text(tr('DNS 서버')),
subtitle: Text(tr('주소 이름 (예: youtube.com) 을 찾을 때 물어볼 서버. 직접 지정하면 기기의 DNS 대신 이 서버만 씁니다. '
'서버에 닿지 않으면 기기 DNS 로 몰래 바꾸지 않고 알립니다.')),
trailing: DropdownButton<String>(
value: s.dnsMode,
items: [
DropdownMenuItem(value: 'system', child: Text(tr('시스템 DNS (끄기)'))),
DropdownMenuItem(value: 'custom', child: Text(tr('직접 지정 (기본)'))),
DropdownMenuItem(value: 'doh', child: Text(tr('DNS over HTTPS'))),
],
onChanged: (v) => _save((x) => x.dnsMode = v!),
),
),
if (s.dnsMode != 'system')
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Wrap(spacing: 12, runSpacing: 12, children: [
if (s.dnsMode == 'doh')
_field(tr('DoH 주소'), s.dnsDohUrl, _dohError, hint: DnsConfig.defaultDohUrl, (v) {
final ok = isValidDohUrl(v);
setState(() => _dohError = ok ? null : tr('https:// 로 시작하는 주소를 넣으세요'));
if (ok) _save((x) => x.dnsDohUrl = v.trim());
}),
_field(s.dnsMode == 'doh' ? tr('DoH 서버 이름을 찾을 서버') : tr('기본 DNS'), s.dnsPrimary, _primaryError, hint: DnsConfig.defaultPrimary, (v) {
final ok = parseDnsServer(v) != null;
setState(() => _primaryError = ok ? null : tr('IPv4 · IPv6 주소를 넣으세요 (예: 8.8.8.8)'));
if (ok) _save((x) => x.dnsPrimary = v.trim());
}),
_field(tr('보조 DNS (비워 둘 수 있음)'), s.dnsSecondary, _secondaryError, hint: DnsConfig.defaultSecondary, (v) {
final ok = v.trim().isEmpty || parseDnsServer(v) != null;
setState(() => _secondaryError = ok ? null : tr('IPv4 · IPv6 주소를 넣으세요 (예: 8.8.4.4)'));
if (ok) _save((x) => x.dnsSecondary = v.trim());
}),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Row(children: [
OutlinedButton.icon(
onPressed: _testing ? null : _check,
icon: const Icon(Icons.network_check, size: 18),
label: Text(tr('지금 확인')),
),
const SizedBox(width: 12),
Expanded(child: Text(_testing ? tr('확인하는 중…') : _test ?? '', style: const TextStyle(fontSize: 12, color: JjColors.textDim))),
]),
),
SettingTile(
leading: const Icon(Icons.info_outline),
title: Text(tr('이 설정을 따르는 것')),
subtitle: Text([
desk
? tr('· 웹 브라우저 (Edge · Chrome 엔진): 끄기 ↔ 켜기는 앱을 다시 시작한 뒤, 서버 주소는 바로')
: tr('· 웹 브라우저: 바로 (Tor 화면이 열려 있는 동안은 Tor)'),
tr('· 다운로드: yt-dlp, aria2 (http · https · HTTP 트래커는 바로, 토렌트의 UDP 트래커 · DHT 이름은 직접 지정일 때 - 끄기 ↔ 켜기는 다음 실행부터)'),
tr('· 앱의 인터넷 연결: 새 버전 확인 · 컴포넌트 · AI 모델 받기 · WebDAV · 웹 페이지 번역 · 자막 검색'),
tr('따르지 않는 것: Tor 화면 (이름도 Tor 안에서 찾음), IP 주소, 같은 집 안 이름 (점 없는 NAS · PC 이름, *.local, Tailscale 의 *.ts.net 등은 기기 DNS)'),
if (!desk) tr('· Android 의 yt-dlp 새 버전 확인 (다운로드 도구 안에서 따로 연결)'),
].join('\n')),
),
if (desk && s.browserEngine == 'chrome' && CefRuntime.readyThisRun)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(tr('Chrome 엔진은 앱을 다시 시작하면 바뀐 설정을 씁니다.'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
]);
}
}
/// 환경 설정 > Tor 브라우저: 기록 남기기 · 홈 주소 · JavaScript · 안내 다시 보기 · 지금 지우기
class TorSettings extends StatelessWidget {
final AppController c;
const TorSettings({super.key, required this.c});
@override
Widget build(BuildContext context) {
final s = c.settings;
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
SwitchListTile(
value: s.torKeepData,
onChanged: (v) => c.updateSettings((x) => x.torKeepData = v),
title: Text(tr('Tor 화면을 닫아도 쿠키 · 기록 · 캐시 남기기')),
subtitle: Text(tr('끄면 (기본) Tor 화면을 닫을 때 지웁니다. 일반 브라우저와는 늘 따로입니다.')),
),
SwitchListTile(
value: s.torJavaScript,
onChanged: (v) => c.updateSettings((x) => x.torJavaScript = v),
title: Text(tr('Tor 화면의 JavaScript 사용')),
subtitle: Text(tr('끄면 더 안전하지만 많은 사이트가 보이지 않습니다 (다음에 열 때부터).')),
),
SettingTile(
title: Text(tr('Tor 화면 홈 주소')),
subtitle: Text(tr('처음 · 🏠 주소 (기본: Tor 연결 확인 페이지)')),
trailing: SizedBox(
width: 300,
child: TextFormField(
initialValue: s.torHomeUrl,
decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()),
onChanged: (v) => c.updateSettings((x) => x.torHomeUrl = v.trim().isEmpty ? AppSettings.defaultTorHome : v.trim()),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
if (Platform.isAndroid)
SettingTile(
leading: const Icon(Icons.info_outline),
title: Text(tr('Android: Tor 화면이 열려 있는 동안')),
subtitle: Text(tr('Android 웹 뷰의 프록시는 앱 전체에 걸려, Tor 화면이 열려 있는 동안 일반 웹 브라우저도 Tor 로 나갑니다 (일반 브라우저 위쪽에 안내). '
'Tor 화면을 닫으면 원래대로 돌아갑니다.')),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Wrap(spacing: 8, runSpacing: 8, children: [
OutlinedButton.icon(
onPressed: () => showTorNotice(context),
icon: const Icon(Icons.info_outline, size: 18),
label: Text(tr('Tor 브라우저 안내')),
),
OutlinedButton.icon(
onPressed: () async {
final ok = await clearTorData();
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(
content: Text(ok ? tr('Tor 화면의 쿠키 · 기록 · 캐시를 지웠습니다.') : tr('Tor 화면이 열려 있어 지금은 지울 수 없습니다. 닫은 뒤 다시 하세요.'))));
}
},
icon: const Icon(Icons.cookie_outlined, size: 18),
label: Text(tr('Tor 기록 지금 지우기')),
),
]),
),
]);
}
/// Tor 화면의 저장된 것 지우기 (화면이 닫혀 있을 때)
Future<bool> clearTorData() async {
if (WebProxy.torOpen.value || TorService.instance.running) return false;
if (Platform.isAndroid) return WebProxy.clearTorProfile();
try {
final d = Directory('${Directory(c.settings.webViewDataDir).parent.path}${Platform.pathSeparator}tor_webview');
if (d.existsSync()) d.deleteSync(recursive: true);
return true;
} catch (_) {
return false; // 이번 실행에서 Tor 화면을 열어 웹 뷰가 폴더를 쥐고 있음
}
}
}

View File

@ -33,6 +33,7 @@ import 'setting_tile.dart';
import 'component_settings.dart'; import 'component_settings.dart';
import 'browser_page.dart' show deleteExportedCookies; import 'browser_page.dart' show deleteExportedCookies;
import 'security_settings.dart'; import 'security_settings.dart';
import 'network_settings.dart';
import 'ai_settings.dart'; import 'ai_settings.dart';
import '../app/master_lock.dart'; import '../app/master_lock.dart';
import 'toast_status.dart'; import 'toast_status.dart';
@ -226,7 +227,10 @@ class _ContextMenuTileState extends State<_ContextMenuTile> {
/// 환경 설정 /// 환경 설정
class SettingsPage extends StatefulWidget { class SettingsPage extends StatefulWidget {
final AppController c; final AppController c;
const SettingsPage({super.key, required this.c});
/// 열자마자 이 묶음으로 스크롤 (예: Tor 화면의 [Tor 설정] → 'tor')
final String? initialGroup;
const SettingsPage({super.key, required this.c, this.initialGroup});
@override @override
State<SettingsPage> createState() => _SettingsPageState(); State<SettingsPage> createState() => _SettingsPageState();
@ -244,6 +248,13 @@ class _SettingsPageState extends State<SettingsPage> {
void initState() { void initState() {
super.initState(); super.initState();
if (_locked) WidgetsBinding.instance.addPostFrameCallback((_) => _unlock()); if (_locked) WidgetsBinding.instance.addPostFrameCallback((_) => _unlock());
final g = widget.initialGroup;
if (g != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _groupKeys[g]?.currentContext;
if (ctx != null) Scrollable.ensureVisible(ctx, alignment: 0);
});
}
} }
Future<void> _unlock() async { Future<void> _unlock() async {
@ -376,6 +387,8 @@ class _SettingsPageState extends State<SettingsPage> {
DropdownMenuItem(value: 'home', child: Text(tr('MKV 화면 (기본)'))), DropdownMenuItem(value: 'home', child: Text(tr('MKV 화면 (기본)'))),
DropdownMenuItem(value: 'browser', child: Text(tr('웹 브라우저 (홈 주소)'))), DropdownMenuItem(value: 'browser', child: Text(tr('웹 브라우저 (홈 주소)'))),
DropdownMenuItem(value: 'files', child: Text(tr('파일 탐색기'))), DropdownMenuItem(value: 'files', child: Text(tr('파일 탐색기'))),
if (s.components.contains('tor') || s.startScreen == 'tor')
DropdownMenuItem(value: 'tor', child: Text(tr('Tor 브라우저'))),
], ],
onChanged: (v) => c.updateSettings((x) => x.startScreen = v!), onChanged: (v) => c.updateSettings((x) => x.startScreen = v!),
), ),
@ -667,6 +680,10 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
], ],
]), ]),
// 사용자 요청 10/10: Tor 브라우저 · DNS 강제
if (s.components.contains('tor'))
_group('tor', Icons.vpn_lock_outlined, tr('Tor 브라우저'), [TorSettings(c: c)]),
_group('network', Icons.dns_outlined, tr('네트워크 · DNS'), [DnsSettings(c: c)]),
_group('files', Icons.folder_copy_outlined, tr('파일 탐색기'), [ _group('files', Icons.folder_copy_outlined, tr('파일 탐색기'), [
if (desk) if (desk)
SwitchListTile( SwitchListTile(
@ -1073,7 +1090,7 @@ class _SettingsPageState extends State<SettingsPage> {
final _groupKeys = <String, GlobalKey>{}; final _groupKeys = <String, GlobalKey>{};
static const _groupIds = [ static const _groupIds = [
'general', 'security', 'components', 'display', 'mkv', 'subtitle', 'ai', 'play', 'browser', 'files', 'rsync', 'download', 'run', // 'general', 'security', 'components', 'display', 'mkv', 'subtitle', 'ai', 'play', 'browser', 'tor', 'network', 'files', 'rsync', 'download', 'run', //
'cleanup', 'about', 'cleanup', 'about',
]; ];
@ -1087,6 +1104,8 @@ class _SettingsPageState extends State<SettingsPage> {
'ai' => tr('AI 그림'), 'ai' => tr('AI 그림'),
'play' => tr('재생'), 'play' => tr('재생'),
'browser' => tr('웹 브라우저'), 'browser' => tr('웹 브라우저'),
'tor' => tr('Tor 브라우저'),
'network' => tr('네트워크 · DNS'),
'files' => tr('파일 탐색기'), 'files' => tr('파일 탐색기'),
'rsync' => 'Rsync', 'rsync' => 'Rsync',
'download' => tr('다운로드'), 'download' => tr('다운로드'),
@ -1100,7 +1119,9 @@ class _SettingsPageState extends State<SettingsPage> {
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Wrap(spacing: 8, runSpacing: 8, children: [ child: Wrap(spacing: 8, runSpacing: 8, children: [
for (final id in _groupIds) for (final id in _groupIds)
if ((id != 'run' || desk) && (id != 'ai' || c.settings.components.contains('aiimage'))) if ((id != 'run' || desk) &&
(id != 'ai' || c.settings.components.contains('aiimage')) &&
(id != 'tor' || c.settings.components.contains('tor')))
ActionChip( ActionChip(
label: Text(_groupTitle(id), style: const TextStyle(fontSize: 13)), label: Text(_groupTitle(id), style: const TextStyle(fontSize: 13)),
onPressed: () { onPressed: () {

552
lib/ui/tor_page.dart Normal file
View File

@ -0,0 +1,552 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/tor_service.dart';
import '../app/web_proxy.dart';
import '../core/tor_control.dart';
import '../core/web_address.dart';
import '../platform/windows/com_guard.dart';
import '../l10n/tr.dart';
import 'app_actions.dart';
import 'settings_page.dart';
import 'theme.dart';
/// Tor 화면의 웹 뷰 조작
abstract class TorNav {
Future<void> load(String url);
Future<void> back();
Future<void> forward();
Future<void> reload();
Future<void> stop();
/// 페이지에서 스크립트를 실행하고 결과를 받는다 (실패하면 null)
Future<Object?> evaluate(String js);
}
/// 웹 뷰 → 화면으로 알리는 통로
class TorViewHost {
final void Function(String url) onUrl;
final void Function(double progress) onProgress;
final void Function(bool back, bool forward) onHistory;
final void Function(TorNav nav) attach;
const TorViewHost({required this.onUrl, required this.onProgress, required this.onHistory, required this.attach});
}
/// WebRTC 끄기 (모든 틀 · 페이지 스크립트보다 먼저): STUN 으로 진짜 주소가 새지 않게.
/// Windows 는 실행 인수 (--force-webrtc-ip-handling-policy) 로도 막는다
const torWebRtcBlockScript = r'''
(function(){
var names=['RTCPeerConnection','webkitRTCPeerConnection','mozRTCPeerConnection','RTCDataChannel','RTCIceCandidate','RTCSessionDescription','RTCRtpSender','RTCRtpReceiver','RTCRtpTransceiver'];
function strip(w){
try{ for(var i=0;i<names.length;i++){ try{ Object.defineProperty(w,names[i],{value:undefined,writable:false,configurable:false}); }catch(e){ try{ delete w[names[i]]; w[names[i]]=undefined; }catch(_){} } } }catch(e){}
try{ if(w.navigator && w.navigator.mediaDevices){ Object.defineProperty(w.navigator,'mediaDevices',{value:undefined,configurable:false}); } }catch(e){}
}
strip(window);
// 스크립트가 먼저 돌지 않는 빈 틀 (about:blank) 에서 꺼내 쓰지 못하게: 틀의 창을 건네기 전에 지운다
try{
var d=Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,'contentWindow');
Object.defineProperty(HTMLIFrameElement.prototype,'contentWindow',{configurable:false,get:function(){ var w=d.get.call(this); if(w){ try{ strip(w); }catch(e){} } return w; }});
var dd=Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,'contentDocument');
Object.defineProperty(HTMLIFrameElement.prototype,'contentDocument',{configurable:false,get:function(){ var doc=dd.get.call(this); if(doc && doc.defaultView){ try{ strip(doc.defaultView); }catch(e){} } return doc; }});
}catch(e){}
})();
''';
/// Windows: Tor 화면 전용 WebView2 환경 (데이터 폴더를 일반 브라우저와 따로, 모든 연결을 Tor 다리로)
Future<WebViewEnvironment>? _torEnv;
/// Tor 화면 Edge 실행 인수: 프록시 (localhost 까지), QUIC 끄기, WebRTC 는 프록시를 거치지 않는 UDP 금지, SmartScreen (주소를 Microsoft 로 보냄) 끄기
String torBrowserArguments(int bridgePort) => [
'--proxy-server=http://127.0.0.1:$bridgePort',
'--proxy-bypass-list=<-loopback>',
'--disable-quic',
'--webrtc-ip-handling-policy=disable_non_proxied_udp',
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
'--dns-prefetch-disable',
'--disable-features=msSmartScreenProtection,NetworkPrediction,msEdgeShoppingAssistant',
].join(' ');
Future<WebViewEnvironment> torEnvironment(String dataDir, int bridgePort) {
return _torEnv ??= () async {
try {
ComGuard.ensure();
return await WebViewEnvironment.create(
settings: WebViewEnvironmentSettings(
userDataFolder: p.join(dataDir, 'tor_webview'), additionalBrowserArguments: torBrowserArguments(bridgePort)));
} catch (_) {
_torEnv = null;
rethrow;
}
}();
}
/// Android 웹 뷰를 Tor 화면 전용 프로필로 만들게 하는 설정 (packages/flutter_inappwebview_android/JJ_PATCH.md: jjProfile)
class _TorWebSettings extends InAppWebViewSettings {
_TorWebSettings({required bool javaScript, required bool keepData})
: super(
javaScriptEnabled: javaScript,
mediaPlaybackRequiresUserGesture: true,
supportMultipleWindows: false,
useHybridComposition: false,
thirdPartyCookiesEnabled: false,
geolocationEnabled: false,
saveFormData: false,
// Windows: 남기지 않으면 InPrivate (닫으면 쿠키 · 캐시가 없어진다)
incognito: Platform.isWindows && !keepData,
);
@override
Map<String, dynamic> toMap() => {
...super.toMap(),
if (Platform.isAndroid) 'jjProfile': WebProxy.torProfileName,
};
}
/// Tor 브라우저 화면 (사용자 요청 10/10): 웹 브라우저 옆. Tor 로만 나가고 쿠키 · 기록 · 캐시는 일반 브라우저와 따로.
/// Tor 연결 100% · 프록시 적용 · 전용 프로필이 모두 된 뒤에야 웹 뷰를 만든다 ([torViewMayLoad]).
class TorPage extends StatefulWidget {
final AppController c;
/// 시험용 웹 뷰 대체 (불렸다 = 페이지를 읽기 시작했다)
final Widget Function(TorViewHost host, String url)? viewBuilder;
/// 시험용: Tor 시작 · 기능 확인 · 프록시 걸기를 바꾼다
final Future<void> Function()? startTor;
final Future<List<String>> Function()? missingFeatures;
final Future<bool> Function()? applyProxy;
const TorPage({super.key, required this.c, this.viewBuilder, this.startTor, this.missingFeatures, this.applyProxy});
static const routeName = 'tor';
static int _open = 0;
/// 마지막으로 붙은 웹 뷰 (통합 시험에서 페이지 내용을 읽는다)
@visibleForTesting
static TorNav? debugNav;
/// 이미 열려 있으면 그 화면으로, 없으면 새로
static Future<void> open(NavigatorState nav, {required AppController c}) async {
if (_open > 0) {
var found = false;
nav.popUntil((r) {
if (r.settings.name == routeName) found = true;
return found || r.isFirst;
});
if (found) return;
}
await nav.push(MaterialPageRoute<void>(settings: const RouteSettings(name: routeName), builder: (_) => TorPage(c: c)));
}
@override
State<TorPage> createState() => _TorPageState();
}
/// 처음 열 때 · 환경 설정에서: 공식 Tor Browser 가 아니라는 안내
Future<void> showTorNotice(BuildContext context) => showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('Tor 브라우저 안내')),
content: SizedBox(
width: 480,
child: Text(tr('이 화면은 앱 안 웹 뷰의 연결을 Tor 로 보내는 것이며, 공식 Tor Browser 가 아닙니다.\n\n'
'· 연결 (이름 찾기 포함) 은 모두 Tor 로 나가고, 쿠키 · 기록 · 캐시는 일반 브라우저와 따로 두며 닫으면 지웁니다 (환경 설정에서 남기기 가능).\n'
'· 하지만 브라우저 지문 (화면 크기 · 글꼴 · 기기 정보 등) 을 감추는 보호는 공식 Tor Browser 보다 약합니다. '
'사이트가 이 기기를 알아볼 수 있습니다.\n'
'· 강한 익명성이 필요하면 공식 Tor Browser (https://www.torproject.org) 를 쓰세요.')),
),
actions: [FilledButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('확인')))],
),
);
class _TorPageState extends State<TorPage> {
final _tor = TorService.instance;
late String _url = widget.c.settings.torHomeUrl;
late final _address = TextEditingController(text: _url);
final _addressFocus = FocusNode();
TorNav? _nav;
double _progress = 1;
bool _canBack = false, _canFwd = false;
/// 이 기기 웹 뷰에 없는 기능 (있으면 화면을 열지 않는다 - Tor 를 거치지 않을 수 있으므로)
List<String>? _missing;
bool _proxyApplied = false;
bool _profileReady = false;
String? _error;
WebViewEnvironment? _env;
bool _begun = false;
late final TorViewHost _host = TorViewHost(
onUrl: (u) {
if (!mounted || u.isEmpty) return;
setState(() => _url = u);
if (!_addressFocus.hasFocus) _address.text = u;
},
onProgress: (v) => mounted ? setState(() => _progress = v) : null,
onHistory: (b, f) {
if (!mounted) return;
setState(() {
_canBack = b;
_canFwd = f;
});
},
attach: (n) => _nav = TorPage.debugNav = n,
);
@override
void initState() {
super.initState();
TorPage._open++;
_tor.status.addListener(_onStatus);
WidgetsBinding.instance.addPostFrameCallback((_) => _begin());
}
Future<void> _begin() async {
if (_begun) return;
_begun = true;
final c = widget.c;
if (!c.settings.torNoticeSeen && mounted) {
await showTorNotice(context);
await c.updateSettings((x) => x.torNoticeSeen = true);
}
final missing = await (widget.missingFeatures ?? WebProxy.torMissingFeatures)();
if (!mounted) return;
if (missing.isNotEmpty) {
setState(() => _missing = missing);
return;
}
await _startTor();
}
Future<void> _startTor() async {
setState(() => _error = null);
await (widget.startTor ?? _tor.start)();
_onStatus();
}
/// Tor 가 준비되면 프록시를 건다 (그 다음에야 웹 뷰를 만든다)
void _onStatus() {
if (!mounted) return;
setState(() {});
if (_tor.status.value.ready && !_proxyApplied) unawaited(_applyProxy());
}
bool _applying = false;
Future<void> _applyProxy() async {
if (_applying) return;
_applying = true;
try {
if (widget.applyProxy != null) {
final ok = await widget.applyProxy!();
if (mounted) setState(() => _proxyApplied = _profileReady = ok);
return;
}
final port = await _tor.bridgePort();
if (Platform.isAndroid) {
WebProxy.torOpen.value = true;
final applied = await WebProxy.apply();
// 걸린 프록시가 Tor 다리인지 확인한 뒤에만
if (mounted) setState(() => _proxyApplied = _profileReady = applied == 'http://127.0.0.1:$port');
} else {
final env = await torEnvironment(p.dirname(widget.c.settings.webViewDataDir), port);
if (mounted) {
setState(() {
_env = env;
_proxyApplied = _profileReady = true;
});
}
}
} catch (e) {
if (mounted) setState(() => _error = trf('Tor 화면을 준비할 수 없습니다: {0}', [e]));
} finally {
_applying = false;
}
}
@override
void dispose() {
TorPage._open--;
_tor.status.removeListener(_onStatus);
final nav = _nav;
_nav = null;
unawaited(nav?.stop());
_address.dispose();
_addressFocus.dispose();
if (widget.viewBuilder == null && widget.startTor == null) unawaited(_closeDown(widget.c.settings.torKeepData));
super.dispose();
}
/// 닫은 뒤: 웹 뷰가 없어질 시간을 두고 Tor 를 끄고, 프록시를 되돌리고, 남기지 않으면 Tor 프로필을 지운다
static Future<void> _closeDown(bool keep) async {
await Future<void>.delayed(const Duration(milliseconds: 1500));
if (TorPage._open > 0) return; // 그 사이 다시 열었다
if (Platform.isAndroid) {
WebProxy.torOpen.value = false;
try {
await WebProxy.apply();
} catch (_) {}
}
await TorService.instance.stop();
if (!keep && Platform.isAndroid) {
for (var i = 0; i < 5 && !await WebProxy.clearTorProfile(); i++) {
await Future<void>.delayed(const Duration(seconds: 1));
}
}
}
void _go(String input) {
final url = torAddress(input);
_address.text = url;
_addressFocus.unfocus();
_nav?.load(url);
}
@override
Widget build(BuildContext context) {
final status = _tor.status.value;
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.keyL, control: true): () => _addressFocus.requestFocus(),
const SingleActivator(LogicalKeyboardKey.f5): () => _nav?.reload(),
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _nav?.back(),
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _nav?.forward(),
},
child: PopScope(
canPop: !(Platform.isAndroid && _canBack),
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _nav?.back();
},
child: Focus(
autofocus: true,
child: Scaffold(
body: Column(children: [
SwipeNav(current: 'tor', anywhere: true, child: _toolbar()),
_statusBar(status),
SizedBox(height: 2, child: _progress < 1 ? LinearProgressIndicator(value: _progress) : null),
Expanded(child: _body(status)),
]),
),
),
),
);
}
Widget _toolbar() {
IconButton btn(IconData i, String tip, VoidCallback? f) =>
IconButton(tooltip: tip, visualDensity: VisualDensity.compact, icon: Icon(i, size: 20), onPressed: f);
final ready = _nav != null;
return AppTopBar(
nav: const AppNavButtons(onTorPage: true),
actions: AppActions(c: widget.c),
middle: LayoutBuilder(builder: (context, box) {
final compact = box.maxWidth < 560;
return Row(children: [
btn(Icons.arrow_back_ios_new, tr('이전 페이지 (Alt+←)'), ready && _canBack ? () => _nav?.back() : null),
btn(Icons.arrow_forward_ios, tr('다음 페이지 (Alt+→)'), ready && _canFwd ? () => _nav?.forward() : null),
_progress < 1
? btn(Icons.close, tr('중지'), ready ? () => _nav?.stop() : null)
: btn(Icons.refresh, tr('새로고침 (F5)'), ready ? () => _nav?.reload() : null),
if (!compact) btn(Icons.home_outlined, tr('홈'), ready ? () => _go(widget.c.settings.torHomeUrl) : null),
const SizedBox(width: 6),
Expanded(
// 일반 브라우저의 기록 · 키보드 학습을 쓰지 않는다 (제안 없음)
child: TextField(
controller: _address,
focusNode: _addressFocus,
enabled: ready,
enableSuggestions: false,
autocorrect: false,
enableIMEPersonalizedLearning: false,
keyboardType: TextInputType.url,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: JjColors.bg,
hintText: tr('주소 입력 또는 검색 (DuckDuckGo)'),
prefixIcon: const Icon(Icons.vpn_lock_outlined, size: 16, color: Color(0xFF9C6ADE)),
prefixIconConstraints: const BoxConstraints(minWidth: 32),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none),
),
onSubmitted: _go,
),
),
const SizedBox(width: 4),
Tooltip(
message: tr('새 경로: 앞으로의 연결을 새 Tor 회로로 (사이트가 다른 사람으로 봄)'),
child: compact
? IconButton(
icon: const Icon(Icons.alt_route, size: 20),
onPressed: _tor.status.value.ready ? _newIdentity : null,
)
: OutlinedButton.icon(
onPressed: _tor.status.value.ready ? _newIdentity : null,
icon: const Icon(Icons.alt_route, size: 18),
label: Text(tr('새 경로')),
),
),
PopupMenuButton<String>(
tooltip: tr('더 보기'),
icon: const Icon(Icons.more_vert, size: 20),
onSelected: (v) => switch (v) {
'home' => _go(widget.c.settings.torHomeUrl),
'notice' => showTorNotice(context),
_ => Navigator.push(context, MaterialPageRoute<void>(builder: (_) => SettingsPage(c: widget.c, initialGroup: 'tor'))),
},
itemBuilder: (_) => [
PopupMenuItem(value: 'home', child: ListTile(dense: true, leading: const Icon(Icons.home_outlined), title: Text(tr('홈')))),
PopupMenuItem(value: 'notice', child: ListTile(dense: true, leading: const Icon(Icons.info_outline), title: Text(tr('Tor 브라우저 안내')))),
PopupMenuItem(value: 'settings', child: ListTile(dense: true, leading: const Icon(Icons.settings_outlined), title: Text(tr('Tor 설정')))),
],
),
]);
}),
);
}
Future<void> _newIdentity() async {
await _tor.newIdentity();
if (!mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(tr('새 Tor 경로로 바꿨습니다. 페이지를 다시 읽습니다.'))));
unawaited(_nav?.reload());
}
/// 연결 상태 줄: 연결 중 (n%) · 연결됨 · 실패 (이유 + [다시 시도])
Widget _statusBar(TorStatus s) {
final (Color color, String text) = switch (s.phase) {
TorPhase.ready => (JjColors.success, tr('Tor 연결됨')),
TorPhase.failed => (JjColors.danger, trf('Tor 연결 실패: {0}', [s.message])),
TorPhase.starting => (JjColors.accent, trf('Tor 연결 중 ({0}%) {1}', [s.percent, s.message])),
TorPhase.off => (JjColors.textDim, tr('Tor 꺼짐')),
};
return Material(
color: color.withValues(alpha: 0.14),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 6, 4),
child: Row(children: [
Icon(s.phase == TorPhase.ready ? Icons.verified_user_outlined : Icons.vpn_lock_outlined, size: 16, color: color),
const SizedBox(width: 8),
Expanded(child: Text(text, style: const TextStyle(fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis)),
if (s.phase == TorPhase.starting)
SizedBox(width: 80, child: LinearProgressIndicator(value: s.percent / 100)),
if (s.phase == TorPhase.failed && _missing == null)
TextButton(onPressed: _startTor, child: Text(tr('다시 시도'))),
]),
),
);
}
Widget _body(TorStatus status) {
if (_missing != null) {
return _message(Icons.block, tr('이 기기의 웹 뷰로는 Tor 화면을 열 수 없습니다'),
trf('Android System WebView 에 필요한 기능이 없습니다 ({0}). Play 스토어에서 Android System WebView · Chrome 을 업데이트한 뒤 다시 여세요. '
'Tor 를 거치지 않고 열릴 수 있으므로 이 화면은 페이지를 읽지 않습니다.', [_missing!.join(', ')]));
}
if (_error != null) return _message(Icons.error_outline, tr('Tor 화면을 준비할 수 없습니다'), _error!);
if (!torViewMayLoad(status: status, proxyApplied: _proxyApplied, profileReady: _profileReady)) {
return _message(Icons.vpn_lock_outlined, tr('Tor 에 연결하는 중'),
tr('Tor 연결이 끝나고 웹 뷰가 Tor 로만 나가게 된 뒤에 페이지를 엽니다. 그 전에는 아무것도 읽지 않습니다.'),
busy: status.phase == TorPhase.starting || status.ready);
}
final b = widget.viewBuilder;
if (b != null) return b(_host, _url);
return _TorWebView(host: _host, url: _url, env: _env, javaScript: widget.c.settings.torJavaScript, keepData: widget.c.settings.torKeepData);
}
Widget _message(IconData icon, String title, String body, {bool busy = false}) => Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(mainAxisSize: MainAxisSize.min, children: [
busy ? const SizedBox(width: 40, height: 40, child: CircularProgressIndicator()) : Icon(icon, size: 40, color: JjColors.textDim),
const SizedBox(height: 12),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), textAlign: TextAlign.center),
const SizedBox(height: 8),
Text(body, style: const TextStyle(color: JjColors.textDim), textAlign: TextAlign.center),
]),
),
),
);
}
/// Tor 화면의 주소창 입력 → 주소 (주소가 아니면 DuckDuckGo 검색 - Google 로 보내지 않음)
String torAddress(String input) {
final u = normalizeAddress(input);
if (u.startsWith('https://www.google.com/search?q=')) {
return 'https://duckduckgo.com/?q=${u.substring('https://www.google.com/search?q='.length)}';
}
return u;
}
class _TorWebView extends StatelessWidget {
final TorViewHost host;
final String url;
final WebViewEnvironment? env;
final bool javaScript;
final bool keepData;
const _TorWebView({required this.host, required this.url, required this.env, required this.javaScript, required this.keepData});
@override
Widget build(BuildContext context) {
Future<void> history(InAppWebViewController c) async => host.onHistory(await c.canGoBack(), await c.canGoForward());
return InAppWebView(
webViewEnvironment: env,
initialUrlRequest: URLRequest(url: WebUri(torAddress(url))),
initialSettings: _TorWebSettings(javaScript: javaScript, keepData: keepData),
initialUserScripts: UnmodifiableListView([
UserScript(source: torWebRtcBlockScript, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, forMainFrameOnly: false),
]),
onWebViewCreated: (c) => host.attach(_InAppTorNav(c)),
onLoadStart: (c, u) => host.onUrl(u?.toString() ?? ''),
onLoadStop: (c, u) async {
host.onUrl(u?.toString() ?? '');
host.onProgress(1);
await history(c);
},
onUpdateVisitedHistory: (c, u, _) async {
host.onUrl(u?.toString() ?? '');
await history(c);
},
onProgressChanged: (c, v) => host.onProgress(v / 100),
// 카메라 · 마이크 · 위치는 묻지 않고 거절 (WebRTC 와 함께 진짜 기기 정보가 나가지 않게)
onPermissionRequest: (c, r) async => PermissionResponse(resources: r.resources, action: PermissionResponseAction.DENY),
onGeolocationPermissionsShowPrompt: (c, o) async => GeolocationPermissionShowPromptResponse(origin: o, allow: false, retain: false),
);
}
}
class _InAppTorNav implements TorNav {
final InAppWebViewController c;
_InAppTorNav(this.c);
@override
Future<void> load(String url) => c.loadUrl(urlRequest: URLRequest(url: WebUri(url)));
@override
Future<void> back() => c.goBack();
@override
Future<void> forward() => c.goForward();
@override
Future<void> reload() => c.reload();
@override
Future<void> stop() async {
try {
await c.stopLoading();
} catch (_) {}
}
@override
Future<Object?> evaluate(String js) async {
try {
return await c.evaluateJavascript(source: js);
} catch (_) {
return null;
}
}
}

View File

@ -8,3 +8,8 @@
## 수정 ## 수정
- android/build.gradle: `proguard-android.txt` → `proguard-android-optimize.txt` - android/build.gradle: `proguard-android.txt` → `proguard-android-optimize.txt`
- example 폴더 제거 - example 폴더 제거
## Tor 화면 전용 프로필 (JJ_MKVMaker, 10/10)
- `InAppWebViewSettings.java`: 설정 `jjProfile` (문자열) 을 읽는다. 앱은 Dart 의 InAppWebViewSettings 를 이어받아 toMap 에 넣는다 (lib/ui/tor_page.dart).
- `InAppWebView.java`: 만들 때 바로 `WebViewCompat.setProfile(this, jjProfile)` (androidx.webkit MULTI_PROFILE) - 쿠키 · 저장소 · 캐시를 일반 브라우저와 따로.
정하지 못하면 (`jjProfileFailed`) 그 웹 뷰는 loadUrl · postUrl · loadData · reload 를 하지 않는다 (기본 프로필로 새지 않게).

View File

@ -188,6 +188,45 @@ final public class InAppWebView extends InputAwareWebView implements InAppWebVie
super(context, attrs, defaultStyle); super(context, attrs, defaultStyle);
} }
/** JJ_MKVMaker: 따로 프로필을 정하지 못했다 → 어떤 페이지도 읽지 않는다 (기본 프로필로 새지 않게) */
public boolean jjProfileFailed = false;
@Override
public void loadUrl(@NonNull String url) {
if (jjProfileFailed) return;
super.loadUrl(url);
}
@Override
public void loadUrl(@NonNull String url, @NonNull Map<String, String> additionalHttpHeaders) {
if (jjProfileFailed) return;
super.loadUrl(url, additionalHttpHeaders);
}
@Override
public void postUrl(@NonNull String url, @NonNull byte[] postData) {
if (jjProfileFailed) return;
super.postUrl(url, postData);
}
@Override
public void loadData(@NonNull String data, @Nullable String mimeType, @Nullable String encoding) {
if (jjProfileFailed) return;
super.loadData(data, mimeType, encoding);
}
@Override
public void loadDataWithBaseURL(@Nullable String baseUrl, @NonNull String data, @Nullable String mimeType, @Nullable String encoding, @Nullable String historyUrl) {
if (jjProfileFailed) return;
super.loadDataWithBaseURL(baseUrl, data, mimeType, encoding, historyUrl);
}
@Override
public void reload() {
if (jjProfileFailed) return;
super.reload();
}
public InAppWebView(Context context, @NonNull InAppWebViewFlutterPlugin plugin, public InAppWebView(Context context, @NonNull InAppWebViewFlutterPlugin plugin,
@NonNull Object id, @Nullable Integer windowId, InAppWebViewSettings customSettings, @NonNull Object id, @Nullable Integer windowId, InAppWebViewSettings customSettings,
@Nullable Map<String, Object> contextMenu, View containerView, @Nullable Map<String, Object> contextMenu, View containerView,
@ -201,6 +240,16 @@ final public class InAppWebView extends InputAwareWebView implements InAppWebVie
this.customSettings = customSettings; this.customSettings = customSettings;
this.contextMenu = contextMenu; this.contextMenu = contextMenu;
this.initialUserOnlyScripts = userScripts; this.initialUserOnlyScripts = userScripts;
// JJ_MKVMaker (JJ_PATCH.md): 따로 프로필 - 다른 무엇보다 먼저 (페이지 · 쿠키를 쓰기 전에). 못 하면 이 웹 뷰는 아무것도 읽지 않는다
if (customSettings.jjProfile != null) {
try {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) throw new IllegalStateException("MULTI_PROFILE unsupported");
WebViewCompat.setProfile(this, customSettings.jjProfile);
} catch (Exception e) {
jjProfileFailed = true;
Log.e(LOG_TAG, "jjProfile: " + e);
}
}
if (plugin != null && plugin.activity != null) { if (plugin != null && plugin.activity != null) {
plugin.activity.registerForContextMenu(this); plugin.activity.registerForContextMenu(this);
} }

View File

@ -51,6 +51,9 @@ public class InAppWebViewSettings implements ISettings<InAppWebViewInterface> {
public Boolean interceptOnlyAsyncAjaxRequests = true; public Boolean interceptOnlyAsyncAjaxRequests = true;
public Boolean useShouldInterceptFetchRequest = false; public Boolean useShouldInterceptFetchRequest = false;
public Boolean incognito = false; public Boolean incognito = false;
/** JJ_MKVMaker (JJ_PATCH.md): 이 웹 뷰를 이 이름의 androidx.webkit 프로필로 (쿠키 · 저장소 · 캐시를 따로). null 이면 기본 프로필 */
@Nullable
public String jjProfile = null;
public Boolean cacheEnabled = true; public Boolean cacheEnabled = true;
public Boolean transparentBackground = false; public Boolean transparentBackground = false;
public Boolean disableVerticalScroll = false; public Boolean disableVerticalScroll = false;
@ -202,6 +205,9 @@ public class InAppWebViewSettings implements ISettings<InAppWebViewInterface> {
case "incognito": case "incognito":
incognito = (Boolean) value; incognito = (Boolean) value;
break; break;
case "jjProfile":
jjProfile = (String) value;
break;
case "cacheEnabled": case "cacheEnabled":
cacheEnabled = (Boolean) value; cacheEnabled = (Boolean) value;
break; break;
@ -441,6 +447,7 @@ public class InAppWebViewSettings implements ISettings<InAppWebViewInterface> {
settings.put("interceptOnlyAsyncAjaxRequests", interceptOnlyAsyncAjaxRequests); settings.put("interceptOnlyAsyncAjaxRequests", interceptOnlyAsyncAjaxRequests);
settings.put("useShouldInterceptFetchRequest", useShouldInterceptFetchRequest); settings.put("useShouldInterceptFetchRequest", useShouldInterceptFetchRequest);
settings.put("incognito", incognito); settings.put("incognito", incognito);
settings.put("jjProfile", jjProfile);
settings.put("cacheEnabled", cacheEnabled); settings.put("cacheEnabled", cacheEnabled);
settings.put("transparentBackground", transparentBackground); settings.put("transparentBackground", transparentBackground);
settings.put("disableVerticalScroll", disableVerticalScroll); settings.put("disableVerticalScroll", disableVerticalScroll);

View File

@ -25,3 +25,7 @@ CEF 실행 파일은 앱이 공식 배포처 (https://cef-builds.spotifycdn.com)
5. `common/webview_plugin.cc` · `common/webview_handler.cc` · `lib/src/webview.dart`: JavaScript 끄기. 5. `common/webview_plugin.cc` · `common/webview_handler.cc` · `lib/src/webview.dart`: JavaScript 끄기.
`create` 가 주소 대신 `{url, javascript: false}` 를 받으면 그 브라우저의 `CefBrowserSettings.javascript` 를 `STATE_DISABLED` 로 만든다 `create` 가 주소 대신 `{url, javascript: false}` 를 받으면 그 브라우저의 `CefBrowserSettings.javascript` 를 `STATE_DISABLED` 로 만든다
(Dart: `WebViewController.initialize(url, javaScript: false)`). 이미 만든 브라우저는 바꿀 수 없어 앱이 새로 만든다. (Dart: `WebViewController.initialize(url, javaScript: false)`). 이미 만든 브라우저는 바꿀 수 없어 앱이 새로 만든다.
6. `common/webview_plugin.cc` · `common/webview_app.*` · `lib/src/webview_manager.dart`: 프록시 (DNS 강제).
`initialize(proxyServer: ...)` 를 주면 브라우저 프로세스에 `--proxy-server` 와 `--dns-prefetch-disable` 을 붙인다 (CEF 를 시작할 때 한 번).
앱은 환경 설정 > 네트워크 · DNS 가 강제일 때 앱 안 프록시 주소를 준다.

View File

@ -151,6 +151,12 @@ void WebviewApp::OnBeforeCommandLineProcessing(const CefString &process_type, Ce
} }
command_line->AppendSwitchWithValue("disable-features", values); command_line->AppendSwitchWithValue("disable-features", values);
// JJ_MKVMaker: DNS 강제 - 모든 연결을 앱 안 프록시로 (이름은 프록시가 정한 DNS 로 풂), DNS 미리 찾기 끄기
if (!m_strProxyServer.empty())
{
command_line->AppendSwitchWithValue("proxy-server", m_strProxyServer);
command_line->AppendSwitch("dns-prefetch-disable");
}
// for unsafe domain, add domain to whitelist // for unsafe domain, add domain to whitelist
if (!m_strFilterDomain.empty()) if (!m_strFilterDomain.empty())
{ {

View File

@ -43,6 +43,8 @@ public:
// CefRefPtr<CefClient> GetDefaultClient() override; // CefRefPtr<CefClient> GetDefaultClient() override;
void OnBeforeChildProcessLaunch(CefRefPtr<CefCommandLine> command_line) override; void OnBeforeChildProcessLaunch(CefRefPtr<CefCommandLine> command_line) override;
void SetUnSafelyTreatInsecureOriginAsSecure(const CefString& strFilterDomain); void SetUnSafelyTreatInsecureOriginAsSecure(const CefString& strFilterDomain);
// JJ_MKVMaker: 모든 연결을 이 프록시로 (DNS 강제, JJ_PATCH.md 6). 빈 값이면 없음
void SetProxyServer(const std::string& proxy) { m_strProxyServer = proxy; }
// CefRenderProcessHandler methods. // CefRenderProcessHandler methods.
void OnWebKitInitialized() override; void OnWebKitInitialized() override;
@ -77,6 +79,7 @@ public:
private: private:
uint32_t m_uMode = 1; //process mode uint32_t m_uMode = 1; //process mode
bool m_bEnableGPU = false; //enable gpu bool m_bEnableGPU = false; //enable gpu
std::string m_strProxyServer;
CefString m_strFilterDomain; //insecure domain whitelist CefString m_strFilterDomain; //insecure domain whitelist
CefRefPtr<WebviewHandler> m_handler; //webview handler for main process CefRefPtr<WebviewHandler> m_handler; //webview handler for main process

View File

@ -18,6 +18,8 @@ namespace webview_cef {
CefRefPtr<WebviewApp> app; CefRefPtr<WebviewApp> app;
CefString userAgent; CefString userAgent;
CefString rootCachePath; CefString rootCachePath;
// JJ_MKVMaker: 프록시 (JJ_PATCH.md 6)
CefString proxyServer;
bool isCefInitialized = false; bool isCefInitialized = false;
#ifdef OS_MAC #ifdef OS_MAC
std::string g_macSubprocessPath; std::string g_macSubprocessPath;
@ -282,6 +284,10 @@ namespace webview_cef {
if(cachePath != nullptr && webview_value_get_type(cachePath) == Webview_Value_Type_String){ if(cachePath != nullptr && webview_value_get_type(cachePath) == Webview_Value_Type_String){
rootCachePath = CefString(webview_value_get_string(cachePath)); rootCachePath = CefString(webview_value_get_string(cachePath));
} }
WValue* proxy = webview_value_get_by_string(values, "proxyServer");
if(proxy != nullptr && webview_value_get_type(proxy) == Webview_Value_Type_String){
proxyServer = CefString(webview_value_get_string(proxy));
}
} }
else{ else{
userAgent = CefString(webview_value_get_string(values)); userAgent = CefString(webview_value_get_string(values));
@ -784,6 +790,9 @@ namespace webview_cef {
//cef message run in another thread on windows/linux //cef message run in another thread on windows/linux
cefs.multi_threaded_message_loop = true; cefs.multi_threaded_message_loop = true;
#endif #endif
if(!proxyServer.empty()){
app->SetProxyServer(proxyServer.ToString());
}
CefInitialize(mainArgs, cefs, app.get(), nullptr); CefInitialize(mainArgs, cefs, app.get(), nullptr);
} }

View File

@ -62,12 +62,15 @@ class WebviewManager extends ValueNotifier<bool> {
/// the platform default, where it also keeps the process singleton lock that lets a second /// the platform default, where it also keeps the process singleton lock that lets a second
/// instance hand its request to the first one and exit. CEF only accepts an absolute path, a /// instance hand its request to the first one and exit. CEF only accepts an absolute path, a
/// relative one is resolved against the working directory of the process. /// relative one is resolved against the working directory of the process.
Future<void> initialize({String? userAgent, String? rootCachePath}) async { ///
/// [proxyServer] (JJ_MKVMaker, JJ_PATCH.md 6): every connection goes through this proxy (e.g. `http://127.0.0.1:1234`).
Future<void> initialize({String? userAgent, String? rootCachePath, String? proxyServer}) async {
_creatingCompleter = Completer<void>(); _creatingCompleter = Completer<void>();
try { try {
final Map<String, String> settings = <String, String>{ final Map<String, String> settings = <String, String>{
if (userAgent != null && userAgent.isNotEmpty) 'userAgent': userAgent, if (userAgent != null && userAgent.isNotEmpty) 'userAgent': userAgent,
if (rootCachePath != null && rootCachePath.isNotEmpty) 'rootCachePath': rootCachePath, if (rootCachePath != null && rootCachePath.isNotEmpty) 'rootCachePath': rootCachePath,
if (proxyServer != null && proxyServer.isNotEmpty) 'proxyServer': proxyServer,
}; };
if (settings.isNotEmpty) { if (settings.isNotEmpty) {
await pluginChannel.invokeMethod('init', settings); await pluginChannel.invokeMethod('init', settings);

324
test/dns_proxy_test.dart Normal file
View File

@ -0,0 +1,324 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/net_guard.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/dns_resolver.dart';
import 'package:jj_mkvmaker/core/local_proxy.dart';
import 'support/fake_dns.dart';
/// 정한 DNS 서버로 이름을 푸는지 · 앱 안 프록시가 그 이름으로 잇는지 · 같은 집 안 이름은 기기 DNS 로 · Tor 다리는 이름을 Tor 에 넘기는지
void main() {
DnsConfig custom(String primary, [String secondary = '']) =>
DnsConfig(mode: 'custom', primary: primary, secondary: secondary);
group('DNS 풀기 (가짜 서버)', () {
late FakeDns dns;
setUp(() async {
dns = FakeDns({'example.test': '10.1.2.3', 'up.test': '127.0.0.1'});
await dns.start();
});
tearDown(() => dns.close());
test('정한 서버에 묻고 답을 TTL 동안 기억한다', () async {
final r = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port);
String? server;
r.onAnswer = (_, s) => server = s;
final a = await r.lookup('example.test');
expect(a.map((x) => x.address), ['10.1.2.3']);
expect(server, '127.0.0.1');
final n = dns.queries.length;
expect(await r.lookup('EXAMPLE.test.'), a); // 대소문자 · 끝 점은 같은 이름
expect(dns.queries.length, n, reason: '기억한 답');
});
test('없는 이름은 "찾을 수 없음" (서버에 닿지 않음이 아님)', () async {
final r = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port);
final e = await r.lookup('nope.test').then<Object?>((_) => null, onError: (Object e) => e);
expect(e, isA<DnsException>());
expect((e as DnsException).unreachable, isFalse);
});
test('첫 서버가 답하지 않으면 보조 서버', () async {
// 127.0.0.2 에는 아무도 없다
final r = DnsResolver(config: () => custom('127.0.0.2', '127.0.0.1'), port: dns.port, timeout: const Duration(milliseconds: 400));
final a = await r.lookup('example.test');
expect(a.single.address, '10.1.2.3');
});
test('잘린 답 (TC) 은 TCP 로 다시 묻는다', () async {
final t = FakeDns({'big.test': '10.9.9.9'}, truncateUdp: true);
await t.start();
addTearDown(t.close);
final r = DnsResolver(config: () => custom('127.0.0.1'), port: t.port);
expect((await r.lookup('big.test')).single.address, '10.9.9.9');
expect(t.tcpQueries, greaterThan(0));
});
test('서버에 닿지 않으면 unreachable 오류 (기기 DNS 로 바꾸지 않음)', () async {
final r = DnsResolver(config: () => custom('127.0.0.2'), port: dns.port, timeout: const Duration(milliseconds: 300));
final e = await r.lookup('www.google.com').then<Object?>((_) => null, onError: (Object e) => e);
expect(e, isA<DnsException>());
expect((e as DnsException).unreachable, isTrue);
expect(e.server, '127.0.0.2');
});
test('DNS over HTTPS (RFC 8484 POST)', () async {
final http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => http.close(force: true));
String? contentType;
http.listen((req) async {
contentType = req.headers.contentType?.mimeType;
final q = Uint8List.fromList(await req.fold<List<int>>([], (a, b) => a..addAll(b)));
// 받은 질문을 가짜 DNS 서버에 그대로 넘겨 답을 돌려준다
final s = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
final got = Completer<Uint8List>();
s.listen((e) {
final d = s.receive();
if (d != null && !got.isCompleted) got.complete(d.data);
});
s.send(q, InternetAddress.loopbackIPv4, dns.port);
final a = await got.future;
s.close();
req.response.headers.contentType = ContentType('application', 'dns-message');
req.response.add(a);
await req.response.close();
});
final r = DnsResolver(
config: () => DnsConfig(mode: 'doh', dohUrl: 'http://127.0.0.1:${http.port}/dns-query', primary: '127.0.0.2'),
dohClient: HttpClient.new);
expect((await r.lookup('example.test')).single.address, '10.1.2.3');
expect(contentType, 'application/dns-message');
});
});
test('DNS 질문 · 답 읽기 (줄임 표시)', () {
final q = buildDnsQuery('a.example.com', 1, id: 0x1234);
expect(q.sublist(0, 2), [0x12, 0x34]);
// 답: 질문 + 줄임 표시 (0xc00c) A 1.2.3.4 TTL 60
final res = Uint8List.fromList([
0x12, 0x34, 0x81, 0x80, 0, 1, 0, 1, 0, 0, 0, 0, ...q.sublist(12), //
0xc0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 1, 2, 3, 4,
]);
final a = parseDnsResponse(res, id: 0x1234);
expect(a.addresses.single.address, '1.2.3.4');
expect(a.ttl, 60);
expect(() => parseDnsResponse(res, id: 1), throwsFormatException);
});
test('같은 집 안 이름 · 주소 · DNS 서버 주소 확인', () {
for (final h in ['localhost', 'ds1815', 'nas.local', 'pc.lan', 'tab-s10.tail1234.ts.net', 'printer.home.arpa']) {
expect(isLocalName(h), isTrue, reason: h);
}
for (final h in ['www.youtube.com', 'github.com', '8.8.8.8']) {
expect(isLocalName(h), isFalse, reason: h);
}
for (final a in ['192.168.0.10', '10.0.0.1', '172.20.1.1', '100.80.195.34', '127.0.0.1', '::1', 'fe80::1']) {
expect(isPrivateAddress(InternetAddress(a)), isTrue, reason: a);
}
expect(isPrivateAddress(InternetAddress('8.8.8.8')), isFalse);
expect(parseDnsServer('8.8.8.8')?.address, '8.8.8.8');
expect(parseDnsServer(' 2001:4860:4860::8888 '), isNotNull);
expect(parseDnsServer('[2001:4860:4860::8844]'), isNotNull);
for (final bad in ['', '8.8.8', 'dns.google', '0.0.0.0', '::', '256.1.1.1']) {
expect(parseDnsServer(bad), isNull, reason: bad);
}
expect(isValidDohUrl('https://dns.google/dns-query'), isTrue);
expect(isValidDohUrl('https://cloudflare-dns.com/dns-query{?dns}'), isTrue);
expect(isValidDohUrl('http://dns.google/dns-query'), isFalse);
});
group('앱 안 프록시', () {
late FakeDns dns;
late HttpServer up;
late DnsResolver resolver;
late LocalProxy proxy;
final seen = <String>[];
setUp(() async {
dns = FakeDns({'up.test': '127.0.0.1'});
await dns.start();
up = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
up.listen((req) async {
seen.add('${req.method} ${req.uri} ${req.headers.value('connection')}');
req.response.write('hello ${req.uri.path}');
await req.response.close();
});
resolver = DnsResolver(config: () => custom('127.0.0.1'), port: dns.port, timeout: const Duration(milliseconds: 500));
proxy = LocalProxy(DnsConnector(resolver));
await proxy.start();
seen.clear();
});
tearDown(() async {
await proxy.close();
await up.close(force: true);
await dns.close();
});
HttpClient client() => HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${proxy.port}');
test('http: 이름을 정한 DNS 로 풀어 잇고, 요청 줄은 경로만 · 한 연결에 요청 하나', () async {
final c = client();
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://up.test:${up.port}/a?b=1'))).close();
expect(await res.transform(utf8.decoder).join(), 'hello /a');
expect(seen.single, 'GET /a?b=1 close');
expect(dns.queries, contains('up.test/1'));
});
test('CONNECT: 터널 (https 와 같은 길) 을 정한 DNS 의 주소로', () async {
final s = await Socket.connect(InternetAddress.loopbackIPv4, proxy.port);
addTearDown(s.destroy);
final lines = StreamIterator(s.cast<List<int>>().transform(latin1.decoder));
s.write('CONNECT up.test:${up.port} HTTP/1.1\r\nHost: up.test:${up.port}\r\n\r\n');
var got = '';
while (!got.contains('\r\n\r\n') && await lines.moveNext()) {
got += lines.current;
}
expect(got, startsWith('HTTP/1.1 200'));
// 터널 안에서 원래 서버에 직접 말한다
s.write('GET /tunnel HTTP/1.1\r\nHost: up.test\r\nConnection: close\r\n\r\n');
var body = got.substring(got.indexOf('\r\n\r\n') + 4);
while (await lines.moveNext()) {
body += lines.current;
}
expect(body, contains('hello /tunnel'));
expect(dns.queries, contains('up.test/1'));
});
test('DNS 서버에 닿지 않으면 직접 잇지 않고 502 + 알림', () async {
final bad = LocalProxy(DnsConnector(DnsResolver(config: () => custom('127.0.0.2'), port: dns.port, timeout: const Duration(milliseconds: 300))));
Object? err;
bad.onError = (_, e) => err = e;
await bad.start();
addTearDown(bad.close);
final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bad.port}');
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://up.test:${up.port}/x'))).close();
expect(res.statusCode, 502);
await res.drain<void>();
expect(seen, isEmpty, reason: '원래 서버에 닿지 않았다');
expect(err, isA<DnsException>().having((e) => e.unreachable, 'unreachable', isTrue));
});
test('같은 집 안 이름 (localhost) 은 정한 DNS 에 묻지 않고 기기 DNS 로', () async {
final c = client();
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://localhost:${up.port}/lan'))).close();
expect(await res.transform(utf8.decoder).join(), 'hello /lan');
expect(dns.queries, isEmpty);
});
test('시스템 DNS (끄기) 면 정한 서버에 묻지 않는다', () async {
final sys = LocalProxy(DnsConnector(DnsResolver(config: () => const DnsConfig.system(), port: dns.port)));
await sys.start();
addTearDown(sys.close);
final s = await Socket.connect(InternetAddress.loopbackIPv4, sys.port);
addTearDown(s.destroy);
s.write('CONNECT 127.0.0.1:${up.port} HTTP/1.1\r\n\r\n');
final first = await s.cast<List<int>>().transform(latin1.decoder).first;
expect(first, startsWith('HTTP/1.1 200'));
expect(dns.queries, isEmpty);
});
});
group('Tor 다리 (SOCKS5)', () {
late ServerSocket socks;
late HttpServer up;
final asked = <String>[];
setUp(() async {
asked.clear();
up = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
up.listen((req) async {
req.response.write('via tor');
await req.response.close();
});
// 가짜 Tor SOCKS5: 이름 (ATYP 3) 을 받아 적고 원래 서버로 잇는다
socks = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
socks.listen((c) async {
final r = SocketReader(c);
await r.read(3);
c.add([5, 0]);
final h = await r.read(4);
String host;
if (h[3] == 3) {
final n = (await r.read(1))[0];
host = 'name:${latin1.decode(await r.read(n))}';
} else {
host = 'ip:${InternetAddress.fromRawAddress(await r.read(h[3] == 1 ? 4 : 16)).address}';
}
final pb = await r.read(2);
asked.add('$host:${(pb[0] << 8) | pb[1]}');
final u = await Socket.connect(InternetAddress.loopbackIPv4, up.port);
c.add([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]);
unawaited(u.addStream(r.rest()).catchError((_) {}));
unawaited(c.addStream(u).then((_) => c.close()).catchError((_) {}));
});
});
tearDown(() async {
await socks.close();
await up.close(force: true);
});
test('이름을 그대로 Tor 에 넘긴다 (이 기기에서 DNS 를 묻지 않음)', () async {
final bridge = LocalProxy(TorConnector(() => socks.port));
await bridge.start();
addTearDown(bridge.close);
final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bridge.port}');
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://some-site.example:${up.port}/'))).close();
expect(await res.transform(utf8.decoder).join(), 'via tor');
expect(asked.single, 'name:some-site.example:${up.port}');
});
test('Tor 가 준비되지 않았으면 502 (직접 잇지 않음)', () async {
final bridge = LocalProxy(TorConnector(() => null));
await bridge.start();
addTearDown(bridge.close);
final c = HttpClient()..findProxy = ((_) => 'PROXY 127.0.0.1:${bridge.port}');
addTearDown(c.close);
final res = await (await c.getUrl(Uri.parse('http://some-site.example:${up.port}/'))).close();
expect(res.statusCode, 502);
await res.drain<void>();
expect(asked, isEmpty);
});
});
test('NetGuard: 앱의 HTTP · yt-dlp · aria2 가 프록시를 쓰는 조건', () async {
final g = NetGuard.instance;
var cfg = custom('8.8.8.8', '8.8.4.4');
g.config = () => cfg;
await g.ensure();
final port = g.port!;
expect(g.findProxy(Uri.parse('https://www.youtube.com/')), 'PROXY 127.0.0.1:$port');
expect(g.findProxy(Uri.parse('http://192.168.0.5:5005/')), 'DIRECT');
expect(g.findProxy(Uri.parse('https://ds1815:5006/')), 'DIRECT');
expect(g.findProxy(Uri.parse('http://127.0.0.1:6800/jsonrpc')), 'DIRECT');
expect(g.ytDlpArgs(), ['--proxy', 'http://127.0.0.1:$port']);
expect(g.aria2Args(asyncDns: true), ['--all-proxy=http://127.0.0.1:$port', '--async-dns=true', '--async-dns-server=8.8.8.8,8.8.4.4']);
expect(g.aria2Args(asyncDns: false), ['--all-proxy=http://127.0.0.1:$port']);
cfg = const DnsConfig.system();
expect(g.findProxy(Uri.parse('https://www.youtube.com/')), 'DIRECT');
expect(g.ytDlpArgs(), isEmpty);
expect(g.aria2Args(asyncDns: true), isEmpty);
expect(g.browserProxy, isNull);
});
test('설정: 처음은 직접 지정 8.8.8.8 · 8.8.4.4, 틀린 값은 처음 값, 사용자가 고른 값은 그대로', () {
final s = AppSettings();
expect((s.dnsMode, s.dnsPrimary, s.dnsSecondary, s.dnsDohUrl), ('custom', '8.8.8.8', '8.8.4.4', 'https://dns.google/dns-query'));
expect(AppSettings.fromJson({}).dnsMode, 'custom');
final bad = AppSettings.fromJson({'dnsMode': 'x', 'dnsPrimary': 'dns.google', 'dnsSecondary': '1.2.3', 'dnsDohUrl': 'ftp://x'});
expect((bad.dnsMode, bad.dnsPrimary, bad.dnsSecondary, bad.dnsDohUrl), ('custom', '8.8.8.8', '8.8.4.4', 'https://dns.google/dns-query'));
final mine = AppSettings.fromJson(
AppSettings().toJson()..addAll({'dnsMode': 'system', 'dnsPrimary': '1.1.1.1', 'dnsSecondary': '', 'dnsDohUrl': 'https://cloudflare-dns.com/dns-query'}));
expect((mine.dnsMode, mine.dnsPrimary, mine.dnsSecondary, mine.dnsDohUrl), ('system', '1.1.1.1', '', 'https://cloudflare-dns.com/dns-query'));
final back = AppSettings.fromJson(mine.toJson());
expect(back.dnsConfig.key, mine.dnsConfig.key);
// Tor 설정 처음 값
expect((s.torKeepData, s.torHomeUrl, s.torJavaScript, s.torNoticeSeen), (false, 'https://check.torproject.org/', true, false));
});
}

View File

@ -89,6 +89,24 @@ void main() {
} }
}); });
test('Tor 브라우저: tor (BSD-3 · GPL 빌드) 와 함께 묶인 Libevent · OpenSSL · zlib · zstd 원문, 고지 문서 · 소스 주소', () async {
registerAppLicenses();
final entries = await LicenseRegistry.licenses.toList();
final tor = entries.firstWhere((e) => e.packages.any((p) => p.startsWith('Tor 0.4.9.13'))).paragraphs.map((p) => p.text).join('\n');
for (final s in ['Roger Dingledine', 'Redistribution and use', 'Niels Provos', 'Apache License', 'Jean-loup Gailly', 'Zstandard',
'This build of Tor is covered by the GNU General Public License', 'tor-0.4.9.13.tar.gz']) {
expect(tor, contains(s), reason: s);
}
final gpl = entries.firstWhere((e) => e.packages.any((p) => p.startsWith('Tor (tor 실행 파일')));
expect(gpl.paragraphs.map((p) => p.text).join('\n'), contains('Version 3, 29 June 2007'));
for (final f in ['THIRD_PARTY_NOTICES.txt', 'THIRD_PARTY_NOTICES_ANDROID.txt']) {
final t = File('assets/licenses/$f').readAsStringSync();
expect(t, contains('Tor 0.4.9.13'), reason: f);
expect(t, contains('tor-0.4.9.13.tar.gz'), reason: f);
expect(t, contains('공식 Tor Browser 가 아닙니다'), reason: f);
}
});
test('64: Android 고지 문서에는 Windows 파일 · 경로가 없고 [패키지 라이선스] 로 안내', () { test('64: Android 고지 문서에는 Windows 파일 · 경로가 없고 [패키지 라이선스] 로 안내', () {
final a = File('assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt').readAsStringSync(); final a = File('assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt').readAsStringSync();
for (final w in ['libmpv-2.dll', 'flutter_assets', '.exe', r'tools\', r'ffmpeg\']) { for (final w in ['libmpv-2.dll', 'flutter_assets', '.exe', r'tools\', r'ffmpeg\']) {

View File

@ -0,0 +1,87 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
/// 시험용 DNS 서버 (127.0.0.1, UDP · TCP 같은 포트). [records] 의 이름이면 A 로 답하고, 없으면 NXDOMAIN.
/// [truncateUdp] 면 UDP 는 TC 표시만 하고 TCP 로 답한다.
class FakeDns {
final Map<String, String> records;
final bool truncateUdp;
final int ttl;
late RawDatagramSocket _udp;
ServerSocket? _tcp;
final queries = <String>[];
int tcpQueries = 0;
FakeDns(this.records, {this.truncateUdp = false, this.ttl = 300});
int get port => _udp.port;
Future<void> start() async {
if (!truncateUdp) {
_udp = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
} else {
// TCP 로 다시 묻는 시험: UDP 와 TCP 가 같은 포트 (Windows 는 막아 둔 포트가 있어 몇 번 시도)
for (var i = 0;; i++) {
_udp = await RawDatagramSocket.bind(InternetAddress.loopbackIPv4, 0);
try {
_tcp = await ServerSocket.bind(InternetAddress.loopbackIPv4, _udp.port);
break;
} catch (_) {
_udp.close();
if (i > 50) rethrow;
}
}
}
_udp.listen((e) {
if (e != RawSocketEvent.read) return;
final d = _udp.receive();
if (d == null) return;
_udp.send(_answer(d.data, tc: truncateUdp), d.address, d.port);
});
_tcp?.listen((s) async {
final buf = BytesBuilder();
await for (final c in s) {
buf.add(c);
final b = buf.toBytes();
if (b.length >= 2 && b.length >= 2 + ((b[0] << 8) | b[1])) {
tcpQueries++;
final a = _answer(Uint8List.sublistView(b, 2), tc: false);
s.add([a.length >> 8, a.length & 0xff, ...a]);
await s.flush();
await s.close();
break;
}
}
});
}
Future<void> close() async {
_udp.close();
await _tcp?.close();
}
Uint8List _answer(Uint8List q, {required bool tc}) {
// 질문 이름 · 형식
var i = 12;
final labels = <String>[];
while (q[i] != 0) {
labels.add(String.fromCharCodes(q.sublist(i + 1, i + 1 + q[i])));
i += q[i] + 1;
}
final type = (q[i + 1] << 8) | q[i + 2];
final qEnd = i + 5;
final name = labels.join('.');
queries.add('$name/$type');
final ip = records[name];
final b = BytesBuilder()
..add([q[0], q[1], 0x81 | (tc ? 0x02 : 0), ip == null ? 0x83 : 0x80, 0, 1, 0, (!tc && ip != null && type == 1) ? 1 : 0, 0, 0, 0, 0])
..add(q.sublist(12, qEnd));
if (!tc && ip != null && type == 1) {
b
..add([0xc0, 12, 0, 1, 0, 1, (ttl >> 24) & 0xff, (ttl >> 16) & 0xff, (ttl >> 8) & 0xff, ttl & 0xff, 0, 4])
..add(InternetAddress(ip).rawAddress);
}
return b.toBytes();
}
}

237
test/tor_test.dart Normal file
View File

@ -0,0 +1,237 @@
import 'dart:async';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/component_store.dart';
import 'package:jj_mkvmaker/app/components.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/app/tor_bundle.dart';
import 'package:jj_mkvmaker/app/tor_service.dart';
import 'package:jj_mkvmaker/core/tor_control.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/app_actions.dart';
import 'package:jj_mkvmaker/ui/tor_page.dart';
void main() {
test('tor 알림 줄 읽기 · torrc', () {
expect(parseBootstrap('Oct 10 19:16:01.000 [notice] Bootstrapped 45% (loading_descriptors): Loading relay descriptors'),
(45, 'Loading relay descriptors'));
expect(parseBootstrap('[notice] Bootstrapped 100% (done): Done')?.$1, 100);
expect(parseBootstrap('[notice] Opening Socks listener on 127.0.0.1:0'), isNull);
expect(parseSocksPort('[notice] Opened Socks listener connection (ready) on 127.0.0.1:53017'), 53017);
final rc = buildTorrc(dataDir: r'C:\d\tor_data', controlPortFile: r'C:\d\tor_data\cp.txt', ownerPid: 1234, geoip: r'C:\g\geoip');
expect(rc, contains('SocksPort 127.0.0.1:auto'));
expect(rc, contains('ControlPort 127.0.0.1:auto'));
expect(rc, contains('CookieAuthentication 1'));
expect(rc, contains('__OwningControllerProcess 1234'));
expect(rc, contains(r'GeoIPFile C:\g\geoip'));
expect(rc, isNot(contains('GeoIPv6File')));
});
test('새는 것 막기: Tor 100% · 프록시 · 전용 프로필이 모두 되어야 웹 뷰를 만든다', () {
const ready = TorStatus(TorPhase.ready, percent: 100);
expect(torViewMayLoad(status: ready, proxyApplied: true, profileReady: true), isTrue);
expect(torViewMayLoad(status: ready, proxyApplied: false, profileReady: true), isFalse);
expect(torViewMayLoad(status: ready, proxyApplied: true, profileReady: false), isFalse);
for (final s in [TorStatus.off, const TorStatus(TorPhase.starting, percent: 99), const TorStatus(TorPhase.failed)]) {
expect(torViewMayLoad(status: s, proxyApplied: true, profileReady: true), isFalse, reason: '${s.phase}');
}
});
test('Tor 검색은 DuckDuckGo (Google 로 보내지 않음), 주소는 그대로', () {
expect(torAddress('tor project'), 'https://duckduckgo.com/?q=tor+project');
expect(torAddress('check.torproject.org'), 'https://check.torproject.org');
expect(torAddress('http://example.onion/'), 'http://example.onion/');
});
test('Edge 실행 인수: 모든 연결 (localhost 포함) 을 Tor 다리로 · WebRTC UDP 막기 · QUIC 끄기', () {
final a = torBrowserArguments(41234);
expect(a, contains('--proxy-server=http://127.0.0.1:41234'));
expect(a, contains('--proxy-bypass-list=<-loopback>'));
expect(a, contains('--force-webrtc-ip-handling-policy=disable_non_proxied_udp'));
expect(a, contains('--disable-quic'));
expect(torWebRtcBlockScript, contains('RTCPeerConnection'));
});
test('컴포넌트: Tor 는 처음 꺼짐 · 화면은 웹 브라우저 바로 뒤 (예전 순서에도)', () {
expect(AppComponent.tor.builtIn, isFalse);
expect(AppComponent.defaultInstalled, isNot(contains('tor')));
expect(AppComponent.defaultOrder.indexOf('tor'), AppComponent.defaultOrder.indexOf('browser') + 1);
final ids = [for (final p in AppComponent.pages(['mkv', 'browser', 'tor', 'explorer'], ['explorer', 'mkv', 'browser'])) p.id];
expect(ids, ['explorer', 'mkv', 'browser', 'tor']);
expect([for (final p in AppComponent.pages(['mkv', 'browser', 'tor'], ['browser', 'mkv'])) p.id], ['browser', 'tor', 'mkv']);
final s = AppSettings()..startScreen = 'tor';
expect(AppNavButtons.homeId(s), 'mkv', reason: '설치하지 않았으면 첫 화면');
s.components = [...s.components, 'tor'];
expect(AppNavButtons.homeId(s), 'tor');
});
group('Tor 컴포넌트 (Windows 번들)', () {
test('판 · SHA-256 고정 (공식 sha256sums-signed-build.txt)', () {
final hex = RegExp(r'^[0-9a-f]{64}$');
expect(TorBundle.windowsSha256, matches(hex));
expect(TorBundle.androidSha256, matches(hex));
expect(TorBundle.windowsSha256, 'e9dc6ccc93cd6afa507193f4de284d6424233ff5102155cd2c94b259e8a22b65');
expect(TorBundle.windowsFile, 'tor-expert-bundle-windows-x86_64-${TorBundle.version}.tar.gz');
for (final u in TorBundle.urls(TorBundle.windowsFile)) {
expect(u, startsWith('https://'));
expect(u, contains('torproject.org/'));
expect(u, endsWith('/${TorBundle.version}/${TorBundle.windowsFile}'));
}
// Android 준비 스크립트도 같은 판 · 같은 SHA-256
final ps = File('tool/fetch_tor_android.ps1').readAsStringSync();
expect(ps, contains("'${TorBundle.version}'"));
expect(ps, contains(TorBundle.androidSha256));
});
late HttpServer server;
late Directory tmp;
late List<int> bundle;
setUp(() async {
// 이 파일의 testWidgets 가 HttpClient 를 막아 둔다 - 이 시험은 실제 (로컬) 서버에 연결
HttpOverrides.global = null;
tmp = Directory.systemTemp.createTempSync('jj_tor_test_');
final a = Archive()
..addFile(ArchiveFile.bytes('tor/tor.exe', [77, 90, 1, 2]))
..addFile(ArchiveFile.bytes('data/geoip', [1]))
..addFile(ArchiveFile.bytes('docs/tor.txt', 'Tor license'.codeUnits));
bundle = GZipEncoder().encode(TarEncoder().encode(a));
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((req) async {
req.response.add(bundle);
await req.response.close();
});
});
tearDown(() async {
await server.close(force: true);
tmp.deleteSync(recursive: true);
});
test('SHA-256 이 다르면 설치하지 않는다', () async {
final target = '${tmp.path}${Platform.pathSeparator}tor';
await expectLater(
TorBundle.installWindows(
urlsOverride: ['http://127.0.0.1:${server.port}/b.tar.gz'], sha256Override: '0' * 64, targetOverride: target),
throwsA(isA<ComponentException>()),
);
expect(Directory(target).existsSync(), isFalse);
});
test('맞으면 tor.exe · geoip · 라이선스 문서 · 설치 표시 (판)', () async {
final target = '${tmp.path}${Platform.pathSeparator}tor';
await TorBundle.installWindows(
urlsOverride: ['http://127.0.0.1:${server.port}/b.tar.gz'],
sha256Override: sha256.convert(bundle).toString(),
targetOverride: target);
for (final f in ['tor.exe', 'geoip', 'docs/tor.txt', 'installed.json']) {
expect(File('$target/$f').existsSync(), isTrue, reason: f);
}
expect(File('$target/installed.json').readAsStringSync(), contains('"version":"${TorBundle.version}"'));
}, skip: Platform.isWindows ? false : 'Windows tar');
});
group('Tor 화면: 준비되기 전에는 아무것도 읽지 않는다', () {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_tor_page_'));
tearDown(() {
TorService.instance.status.value = TorStatus.off;
tmp.deleteSync(recursive: true);
});
Future<AppController> open(WidgetTester t,
{required Future<void> Function() startTor,
Future<List<String>> Function()? missing,
required Future<bool> Function() applyProxy,
required List<String> loads}) async {
t.view.physicalSize = const Size(1400, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
const pp = MethodChannel('plugins.flutter.io/path_provider');
t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, (_) async => tmp.path);
addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(pp, null));
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings
..torNoticeSeen = true
..components = [...c.settings.components, 'tor'];
await t.pumpWidget(MaterialApp(
builder: (context, child) => AppScope(controller: c, child: child!),
home: TorPage(
c: c,
startTor: startTor,
missingFeatures: missing ?? () async => const [],
applyProxy: applyProxy,
viewBuilder: (host, url) {
loads.add(url);
return Text('VIEW $url');
},
),
));
await t.pump();
return c;
}
testWidgets('연결 중 → 100% → 프록시 적용 뒤에야 웹 뷰', (t) async {
final loads = <String>[];
final proxy = Completer<bool>();
final st = TorService.instance.status;
await open(t,
startTor: () async => st.value = const TorStatus(TorPhase.starting, percent: 10, message: 'x'),
applyProxy: () => proxy.future,
loads: loads);
await t.pump();
expect(find.textContaining('Tor 연결 중 (10%)'), findsOneWidget);
expect(loads, isEmpty);
st.value = const TorStatus(TorPhase.starting, percent: 90);
await t.pump();
expect(loads, isEmpty);
st.value = const TorStatus(TorPhase.ready, percent: 100);
await t.pump();
expect(loads, isEmpty, reason: '프록시를 걸기 전');
proxy.complete(true);
await t.pump();
await t.pump();
expect(loads, ['https://check.torproject.org/']);
expect(find.text('Tor 연결됨'), findsOneWidget);
// Tor 가 끊기면 웹 뷰를 치운다 (다리는 Tor 없이 잇지 않음)
st.value = const TorStatus(TorPhase.failed, message: 'x');
await t.pump();
expect(find.textContaining('VIEW'), findsNothing);
expect(find.text('다시 시도'), findsOneWidget);
});
testWidgets('프록시를 걸지 못하면 (Android 웹 뷰 프록시 실패) 읽지 않는다', (t) async {
final loads = <String>[];
await open(t,
startTor: () async => TorService.instance.status.value = const TorStatus(TorPhase.ready, percent: 100),
applyProxy: () async => false,
loads: loads);
await t.pump();
await t.pump();
expect(loads, isEmpty);
});
testWidgets('웹 뷰에 필요한 기능이 없으면 Tor 를 켜지 않고 안내만', (t) async {
final loads = <String>[];
var started = false;
await open(t,
startTor: () async {
started = true;
TorService.instance.status.value = const TorStatus(TorPhase.ready, percent: 100);
},
missing: () async => ['PROXY_OVERRIDE'],
applyProxy: () async => true,
loads: loads);
await t.pump();
expect(started, isFalse);
expect(loads, isEmpty);
expect(find.textContaining('PROXY_OVERRIDE'), findsOneWidget);
});
});
}

View File

@ -0,0 +1,44 @@
# Android 용 Tor 준비: 공식 Tor Expert Bundle (Android aarch64) 을 받아 SHA-256 을 확인하고
# tor/libTor.so (실행 파일) 를 third_party/tor/android/arm64-v8a/libtor.so 로 둔다 (APK 의 jniLibs, android/app/build.gradle.kts).
# 판 · SHA-256 은 lib/app/tor_bundle.dart 의 TorBundle 과 같아야 한다 (공식 sha256sums-signed-build.txt 에서 확인).
# 사용 (app 폴더에서): powershell -File tool\fetch_tor_android.ps1
param(
[string]$Version = '15.0.24',
[string]$Sha256 = '2a7a02484149b05f64b582a4a1e8eb8aabb9f6cd32daca4e03c58e09839bb7a8'
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$out = Join-Path $root 'third_party\tor\android\arm64-v8a'
$file = "tor-expert-bundle-android-aarch64-$Version.tar.gz"
$work = Join-Path ([System.IO.Path]::GetTempPath()) ("jj_tor_" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $work | Out-Null
try {
$archive = Join-Path $work $file
$ok = $false
foreach ($base in @("https://archive.torproject.org/tor-package-archive/torbrowser/$Version", "https://dist.torproject.org/torbrowser/$Version")) {
try {
Write-Host "받는 중: $base/$file"
Invoke-WebRequest -UseBasicParsing -Uri "$base/$file" -OutFile $archive
$ok = $true
break
} catch {
Write-Host "실패: $_"
}
}
if (-not $ok) { throw "받을 수 없습니다: $file" }
$got = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLower()
if ($got -ne $Sha256.ToLower()) { throw "SHA-256 이 다릅니다: $got (기대 $Sha256)" }
$x = Join-Path $work 'x'
New-Item -ItemType Directory -Force $x | Out-Null
tar -xzf $archive -C $x
if ($LASTEXITCODE -ne 0) { throw "압축을 풀 수 없습니다" }
New-Item -ItemType Directory -Force $out | Out-Null
Copy-Item (Join-Path $x 'tor\libTor.so') (Join-Path $out 'libtor.so') -Force
# 라이선스 원문 (앱의 assets/licenses/TOR_LICENSES.txt 는 tool/licenses/fetch_licenses.py 가 만든다)
$docs = Join-Path $root 'third_party\tor\android\docs'
New-Item -ItemType Directory -Force $docs | Out-Null
Copy-Item (Join-Path $x 'docs\*') $docs -Force
Write-Host ("완료: {0} ({1:N0} 바이트)" -f (Join-Path $out 'libtor.so'), (Get-Item (Join-Path $out 'libtor.so')).Length)
} finally {
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}

View File

@ -1,6 +1,6 @@
"""175: 네이티브 구성 요소 · 받는 AI 모델의 라이선스 원문을 상류에서 그 판 그대로 받아 assets/licenses 에 쓴다. """175: 네이티브 구성 요소 · 받는 AI 모델의 라이선스 원문을 상류에서 그 판 그대로 받아 assets/licenses 에 쓴다.
사용법 (app 폴더에서): python tool/licenses/fetch_licenses.py 사용법 (app 폴더에서): python tool/licenses/fetch_licenses.py (Tor 만: --only-tor)
- git · 인터넷이 필요하다. stable-diffusion.cpp 는 고정 커밋을 하위 모듈까지 받아 실제로 sd-cli 에 들어가는 것의 원문을 모은다. - git · 인터넷이 필요하다. stable-diffusion.cpp 는 고정 커밋을 하위 모듈까지 받아 실제로 sd-cli 에 들어가는 것의 원문을 모은다.
- stable-diffusion.cpp (lib/core/ai_catalog.dart 의 sdCppVersion · tool/build_sdcpp_android.sh 의 COMMIT) 나 - stable-diffusion.cpp (lib/core/ai_catalog.dart 의 sdCppVersion · tool/build_sdcpp_android.sh 의 COMMIT) 나
onnxruntime · whisper.cpp 판을 올리면 아래 판 번호를 같이 고치고 다시 돌린다. 그다음 flutter test test/licenses_test.dart. onnxruntime · whisper.cpp 판을 올리면 아래 판 번호를 같이 고치고 다시 돌린다. 그다음 flutter test test/licenses_test.dart.
@ -21,6 +21,10 @@ WHISPER_VERSION = 'v1.9.1' # packages/whisper_ggml 에 넣어 둔 whisper.cpp
JSON_VERSION = 'v3.11.2' # sd.cpp thirdparty/json.hpp JSON_VERSION = 'v3.11.2' # sd.cpp thirdparty/json.hpp
ZIP_UNLICENSE_COMMIT = 'e5f376e4ee' # sd.cpp 의 zip.c 는 Unlicense 시절 판 (지금 kuba--/zip 은 MIT) ZIP_UNLICENSE_COMMIT = 'e5f376e4ee' # sd.cpp 의 zip.c 는 Unlicense 시절 판 (지금 kuba--/zip 은 MIT)
ESRGAN_TAG = 'v0.3.0' ESRGAN_TAG = 'v0.3.0'
# Tor (Tor 브라우저 컴포넌트): lib/app/tor_bundle.dart 의 TorBundle 과 같은 판 · SHA-256 (공식 sha256sums-signed-build.txt)
TOR_BUNDLE = '15.0.24'
TOR_VERSION = '0.4.9.13'
TOR_ANDROID_SHA256 = '2a7a02484149b05f64b582a4a1e8eb8aabb9f6cd32daca4e03c58e09839bb7a8'
APP = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) APP = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
OUT = os.path.join(APP, 'assets', 'licenses') OUT = os.path.join(APP, 'assets', 'licenses')
@ -71,7 +75,42 @@ def fetch_sdcpp(work):
return src, sha return src, sha
def fetch_tor():
"""공식 Tor Expert Bundle (Android aarch64 - Windows 판의 문서에 zstd 를 더한 것) 의 docs 로 TOR_LICENSES.txt 를 만든다"""
import hashlib
import tarfile
import io
name = f'tor-expert-bundle-android-aarch64-{TOR_BUNDLE}.tar.gz'
url = f'https://archive.torproject.org/tor-package-archive/torbrowser/{TOR_BUNDLE}/{name}'
req = urllib.request.Request(url, headers={'User-Agent': 'jj_mkvmaker-licenses'})
with urllib.request.urlopen(req, timeout=120) as r:
data = r.read()
if hashlib.sha256(data).hexdigest() != TOR_ANDROID_SHA256:
raise SystemExit('Tor 번들 SHA-256 이 다릅니다: ' + name)
docs = {}
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as t:
for m in t.getmembers():
if m.isfile() and m.name.startswith('docs/'):
docs[m.name[5:]] = norm(t.extractfile(m).read().decode('utf-8', errors='replace'))
src = f'{url} 의 docs/'
wr('TOR_LICENSES.txt',
f'Tor {TOR_VERSION} (Tor Browser {TOR_BUNDLE} 의 Tor Expert Bundle) - Tor 브라우저 화면\n'
'Windows: 켤 때 공식 배포처에서 받는 tor.exe, Android: 앱에 넣은 libtor.so (같은 판의 Android 번들, 고치지 않음)\n'
'이 tor 빌드는 GPL 을 켠 빌드라 (tor --version: "This build of Tor is covered by the GNU General Public License") 실행 파일은 GPLv3 로 배포된다. '
'Tor 소스 자체는 BSD 3-Clause. GPL 원문: GPL-3.0.txt\n'
f'소스: https://archive.torproject.org/tor-package-archive/tor-{TOR_VERSION}.tar.gz , 빌드 방법: https://gitlab.torproject.org/tpo/applications/tor-browser-build (태그 tbb-{TOR_BUNDLE}-build1)\n', [
sec('Tor - BSD 3-Clause License (함께 쓰는 구성 요소의 라이선스 포함)', src + 'tor.txt', docs['tor.txt']),
sec('Libevent - BSD 3-Clause License', src + 'libevent.txt', docs['libevent.txt']),
sec('OpenSSL - Apache License 2.0', src + 'openssl.txt', docs['openssl.txt']),
sec('zlib - zlib License', src + 'zlib.txt', docs['zlib.txt']),
sec('Zstandard (Android 판) - BSD License', src + 'zstd-bsd.txt', docs['zstd-bsd.txt']),
])
def main(): def main():
if '--only-tor' in sys.argv:
return fetch_tor()
fetch_tor()
with tempfile.TemporaryDirectory() as work: with tempfile.TemporaryDirectory() as work:
sd, sub = fetch_sdcpp(work) sd, sub = fetch_sdcpp(work)
ggml, webp, webm = sub['ggml'], sub['thirdparty/libwebp'][:7], sub['thirdparty/libwebm'][:7] ggml, webp, webm = sub['ggml'], sub['thirdparty/libwebp'][:7], sub['thirdparty/libwebm'][:7]