From e5a115d45012338eb36a30a1f9adcd5c7e1f9b04 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:55:59 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2088=20SMB=203/3:=20Android=20SMB=20(smbj?= =?UTF-8?q?=200.15.0,=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EC=B1=84=EB=84=90)?= =?UTF-8?q?=20-=20=EC=84=9C=EB=B2=84=20=EC=84=A4=EC=A0=95=20=C2=B7=20?= =?UTF-8?q?=ED=83=AD=20=C2=B7=20=EB=B0=9B=EC=95=84=EC=84=9C=20=EC=97=B4?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B3=A0=EC=A7=80=20(=EB=AF=B8=EC=99=84?= =?UTF-8?q?=20-=20=EA=B8=B0=EA=B8=B0=20=ED=99=95=EC=9D=B8=20=EC=95=88=20?= =?UTF-8?q?=EB=90=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 결정 88-1 (Windows 는 운영체제 그대로) · 88-4 (SMB3 암호화는 허용 + 안 되면 경고) 대로 Android 만. - android/.../SmbBridge.kt (채널 jj_mkvmaker/smb, 뒤 스레드): smbj (SMB2 · SMB3, SMB1 없음) · Bouncy Castle 보안 제공자 (Android 기본에는 NTLM 의 MD4 가 없음) · 암호화 요청 (withEncryptData). 서버마다 연결 하나를 다시 씀, 파일은 손잡이로 1MB 조각. 오류를 종류로 (auth · unreachable · noShare · smb1 · permission · notFound). R8 keep 규칙 (smbj · mbassador · bouncycastle). - lib/core/smb.dart: SmbServer (비밀번호는 안전 저장소 smb.pw. 에만) · SmbRemote (RemoteClient) · SmbRegistry. 저장된 비밀번호 없음은 서버에 묻지 않고 알림, 게스트 (아이디 없음) 허용, info() 로 암호화 여부. - vfs: smb:// 를 같은 원격 길로 (🗄), 재생 · 다른 앱은 받아서 (noStreamUrl = SFTP · SMB). - 화면: 환경 설정 > 파일 탐색기 > SMB (Android 만) - 서버 · 포트 · 공유 · 시작 폴더 · 아이디 (비우면 게스트) · 비밀번호 · 도메인, [연결 확인] 에서 암호화 안 되면 경고. 탐색기 탭 · ⋮ 메뉴 [SMB 서버 추가] · 탭 길게 누르기 = 고치기 · 폴더 고르는 곳. 179 바꿈: Android 에서 smb:// · \서버\공유 를 넣으면 "SMB 서버 추가로 넣으면 탭으로" 안내와 [SMB 서버 추가] (넣은 주소로 채움) · [WebDAV 서버 추가]. - 최상 · 마스터 초기화의 저장된 비밀번호 지우기에 SMB 도. - 라이선스: SMBJ_LICENSES.txt (smbj LICENSE_HEADER · Bouncy Castle · SLF4J · MBassador 원문 + Apache 2.0 전문, smbj 는 NOTICE 파일이 없음), 라이선스 화면 · Android 고지, 판이 바뀌면 시험이 알림. - 시험: smb_test (가짜 채널 - 1MB 조각 쓰기 · 읽기 · 목록 · 이름 바꾸기 · 지우기 · 시작 폴더 · 오류 글 · 저장된 비밀번호 없음 · 설정 · 주소 풀기), 179 시험 고침. 전체 837 통과 · 17 건너뜀, 새로 남은 임시 폴더 0. APK 빌드 (Kotlin 컴파일 · R8) 통과. - 시험이 잡은 버그: 큰 조각 하나가 오면 1MB 로 나누지 않고 통째로 보냈다 → 나눔. - 미완: 실제 smbj 연결 (Android 기기 + SMB 서버) 은 확인하지 못함 - 노트 10+ 등에서 PC 공유 폴더로 확인 필요. Co-Authored-By: Claude Opus 5.5 --- android/app/build.gradle.kts | 2 + android/app/proguard-rules.pro | 8 + .../kotlin/com/jj/jj_mkvmaker/MainActivity.kt | 2 + .../kotlin/com/jj/jj_mkvmaker/SmbBridge.kt | 244 ++++++++++++++ assets/l10n/en.json | 19 ++ assets/l10n/ja.json | 19 ++ assets/l10n/zh-Hans.json | 19 ++ assets/licenses/SMBJ_LICENSES.txt | 281 ++++++++++++++++ .../licenses/THIRD_PARTY_NOTICES_ANDROID.txt | 3 + lib/app/app_controller.dart | 3 + lib/app/settings.dart | 23 +- lib/core/smb.dart | 304 ++++++++++++++++++ lib/core/vfs.dart | 46 ++- .../common/media_kit_full_player.dart | 2 +- lib/ui/dav_external.dart | 6 +- lib/ui/explorer_page.dart | 69 +++- lib/ui/folder_picker.dart | 2 + lib/ui/license_texts.dart | 2 + lib/ui/master_prompt.dart | 1 + lib/ui/path_label.dart | 2 +- lib/ui/player_page.dart | 4 +- lib/ui/settings_page.dart | 3 + lib/ui/smb_settings.dart | 249 ++++++++++++++ test/explorer_conflict_test.dart | 18 +- test/licenses_test.dart | 8 + test/smb_test.dart | 192 +++++++++++ 26 files changed, 1505 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/SmbBridge.kt create mode 100644 assets/licenses/SMBJ_LICENSES.txt create mode 100644 lib/core/smb.dart create mode 100644 lib/ui/smb_settings.dart create mode 100644 test/smb_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f8c580a..2583013 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -105,4 +105,6 @@ dependencies { implementation("androidx.core:core:1.16.0") // Tor 화면: 웹 뷰 기능 확인 · 전용 프로필 지우기 (flutter_inappwebview_android 와 같은 판) implementation("androidx.webkit:webkit:1.12.0") + // 88: Android SMB (SMB2 · SMB3, SMB1 없음) - Apache-2.0. 고지: THIRD_PARTY_NOTICES_ANDROID · 라이선스 화면 + implementation("com.hierynomus:smbj:0.15.0") } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index ae174cf..b3215fa 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -5,3 +5,11 @@ -keep class org.apache.commons.compress.archivers.zip.** { *; } -dontwarn com.fasterxml.jackson.** -dontwarn org.apache.commons.** +# 88: smbj (SMB) - 메시지 · 보안 제공자를 이름으로 찾는다 (축소가 지우면 연결 · 로그인이 실패) +-keep class com.hierynomus.** { *; } +-keep class net.engio.mbassy.** { *; } +-keep class org.bouncycastle.** { *; } +-dontwarn com.hierynomus.** +-dontwarn net.engio.mbassy.** +-dontwarn org.bouncycastle.** +-dontwarn org.slf4j.** diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index 7ec8ff8..1f7b66c 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -74,6 +74,8 @@ class MainActivity : FlutterActivity() { pendingShowJobs = intent?.action == ACTION_SHOW_JOBS val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android") channel = ch + // 88: SMB (smbj) - 탐색기의 SMB 서버 탭 + SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger) if (reusedEngine) { // Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다 if (pendingShowJobs) ch.invokeMethod("showJobs", null) diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SmbBridge.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SmbBridge.kt new file mode 100644 index 0000000..7864141 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SmbBridge.kt @@ -0,0 +1,244 @@ +package com.jj.jj_mkvmaker + +import android.os.Handler +import android.os.Looper +import com.hierynomus.msdtyp.AccessMask +import com.hierynomus.mserref.NtStatus +import com.hierynomus.msfscc.FileAttributes +import com.hierynomus.mssmb2.SMB2CreateDisposition +import com.hierynomus.mssmb2.SMB2ShareAccess +import com.hierynomus.mssmb2.SMBApiException +import com.hierynomus.security.bc.BCSecurityProvider +import com.hierynomus.smbj.SMBClient +import com.hierynomus.smbj.SmbConfig +import com.hierynomus.smbj.auth.AuthenticationContext +import com.hierynomus.smbj.connection.Connection +import com.hierynomus.smbj.session.Session +import com.hierynomus.smbj.share.DiskShare +import com.hierynomus.smbj.share.File as SmbFile +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.EnumSet +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/// 88: Android SMB (smbj, Apache-2.0). Windows 는 운영체제가 연다 (사용자 결정 88-1) - 이것은 Android 에만. +/// Dart (lib/core/smb.dart) 가 부른다: 서버 설정은 부를 때마다 함께 받고 (비밀번호는 저장하지 않음), 연결은 서버마다 하나를 다시 쓴다. +/// SMB1 은 쓰지 않는다 (smbj 는 SMB2 부터). 암호화: 서버가 되면 SMB3 암호화 (사용자 결정 88-4 - 안 되면 Dart 가 경고). +object SmbBridge { + private val pool = Executors.newCachedThreadPool() + private val main = Handler(Looper.getMainLooper()) + + private class Conn(val key: String, val client: SMBClient, val connection: Connection, val session: Session, val share: DiskShare) + + /// 서버 id → 연결 (설정이 바뀌면 key 가 달라 새로 연다) + private val conns = ConcurrentHashMap() + + /// 열어 둔 파일 (조각으로 읽기 · 쓰기) + private val files = ConcurrentHashMap() + private val nextHandle = AtomicInteger(1) + + fun attach(messenger: BinaryMessenger) { + MethodChannel(messenger, "jj_mkvmaker/smb").setMethodCallHandler { call, result -> + pool.execute { + try { + val value = handle(call) + main.post { result.success(value) } + } catch (e: Throwable) { + val (code, message) = explain(e) + main.post { result.error(code, message, null) } + } + } + } + } + + private fun handle(call: MethodCall): Any? { + when (call.method) { + "close" -> { + conns.remove(call.argument("id")!!)?.let { closeQuietly(it) } + return null + } + "readChunk" -> { + val f = files[call.argument("handle")!!] ?: throw IllegalStateException("handle") + val len = call.argument("length")!! + val buf = ByteArray(len) + val n = f.read(buf, (call.argument("offset")!!).toLong()) + return if (n <= 0) ByteArray(0) else buf.copyOf(n) + } + "writeChunk" -> { + val f = files[call.argument("handle")!!] ?: throw IllegalStateException("handle") + val data = call.argument("data")!! + f.write(data, (call.argument("offset")!!).toLong()) + return null + } + "closeHandle" -> { + files.remove(call.argument("handle")!!)?.let { runCatching { it.close() } } + return null + } + } + val c = conn(call) + val path = smbPath(call.argument("path") ?: "") + return when (call.method) { + "info" -> mapOf( + "encrypted" to (c.connection.connectionContext.supportsEncryption()), + "dialect" to c.connection.connectionContext.negotiatedProtocol.dialect.name, + ) + "list" -> c.share.list(path).filter { it.fileName != "." && it.fileName != ".." }.map { + mapOf( + "name" to it.fileName, + "dir" to ((it.fileAttributes and FileAttributes.FILE_ATTRIBUTE_DIRECTORY.value) != 0L), + "size" to it.endOfFile, + "mtime" to it.lastWriteTime.toEpochMillis(), + ) + } + "stat" -> { + if (path.isEmpty()) return mapOf("dir" to true, "size" to 0L, "mtime" to 0L) + if (!c.share.fileExists(path) && !c.share.folderExists(path)) return null + val info = c.share.getFileInformation(path) + mapOf( + "dir" to info.standardInformation.isDirectory, + "size" to info.standardInformation.endOfFile, + "mtime" to info.basicInformation.lastWriteTime.toEpochMillis(), + ) + } + "mkdir" -> { + c.share.mkdir(path) + null + } + "delete" -> { + if (c.share.folderExists(path)) c.share.rmdir(path, true) else if (c.share.fileExists(path)) c.share.rm(path) + null + } + "move" -> { + val to = smbPath(call.argument("to")!!) + val overwrite = call.argument("overwrite") ?: false + val entry = c.share.open( + path, + EnumSet.of(AccessMask.DELETE, AccessMask.FILE_READ_ATTRIBUTES), + null, + SMB2ShareAccess.ALL, + SMB2CreateDisposition.FILE_OPEN, + null, + ) + entry.use { it.rename(to, overwrite) } + null + } + "openRead" -> { + val f = c.share.openFile( + path, + EnumSet.of(AccessMask.GENERIC_READ), + null, + SMB2ShareAccess.ALL, + SMB2CreateDisposition.FILE_OPEN, + null, + ) + val h = nextHandle.getAndIncrement() + files[h] = f + mapOf("handle" to h, "size" to f.fileInformation.standardInformation.endOfFile) + } + "openWrite" -> { + val f = c.share.openFile( + path, + EnumSet.of(AccessMask.GENERIC_WRITE), + null, + SMB2ShareAccess.ALL, + SMB2CreateDisposition.FILE_OVERWRITE_IF, + null, + ) + val h = nextHandle.getAndIncrement() + files[h] = f + h + } + "quota" -> { + val s = c.share.shareInformation + listOf(s.freeSpace, s.totalSpace - s.freeSpace) + } + else -> throw UnsupportedOperationException(call.method) + } + } + + /// 서버 설정 → 연결 (같은 설정이면 다시 씀, 끊겼으면 새로) + private fun conn(call: MethodCall): Conn { + val id = call.argument("id")!! + val host = call.argument("host")!! + val port = call.argument("port") ?: 445 + val shareName = call.argument("share")!! + val user = call.argument("user") ?: "" + val domain = call.argument("domain") ?: "" + val password = call.argument("password") ?: "" + val key = listOf(host, port, shareName, user, domain, password.hashCode()).joinToString("|") + conns[id]?.let { + if (it.key == key && it.connection.isConnected) return it + conns.remove(id) + closeQuietly(it) + } + val config = SmbConfig.builder() + // Android 의 기본 보안 제공자에는 NTLM 에 필요한 MD4 가 없다 - Bouncy Castle 로 + .withSecurityProvider(BCSecurityProvider()) + .withEncryptData(true) + .withTimeout(30, TimeUnit.SECONDS) + .withSoTimeout(60, TimeUnit.SECONDS) + .build() + val client = SMBClient(config) + try { + val connection = client.connect(host, port) + val auth = if (user.isEmpty()) AuthenticationContext.guest() + else AuthenticationContext(user, password.toCharArray(), domain.ifEmpty { null }) + val session = connection.authenticate(auth) + val share = session.connectShare(shareName) as? DiskShare + ?: throw SmbBridgeException("noShare", "공유 폴더가 아닙니다: $shareName") + val c = Conn(key, client, connection, session, share) + conns[id] = c + return c + } catch (e: Throwable) { + runCatching { client.close() } + throw e + } + } + + private fun closeQuietly(c: Conn) { + runCatching { c.share.close() } + runCatching { c.session.close() } + runCatching { c.connection.close() } + runCatching { c.client.close() } + } + + /// "/a/b" → "a\\b" (공유 안의 경로, 맨 앞 구분자 없음) + private fun smbPath(rel: String): String = rel.trim('/').replace('/', '\\') + + class SmbBridgeException(val code: String, message: String) : Exception(message) + + /// 오류 → (종류, 원문). 종류는 Dart 가 사람 말로 바꾼다 + private fun explain(e: Throwable): Pair { + val root = generateSequence(e) { it.cause }.last() + if (e is SmbBridgeException) return e.code to (e.message ?: "") + if (e is SMBApiException) { + val code = when (e.status) { + NtStatus.STATUS_LOGON_FAILURE, NtStatus.STATUS_ACCOUNT_DISABLED, NtStatus.STATUS_PASSWORD_EXPIRED, + NtStatus.STATUS_LOGON_TYPE_NOT_GRANTED -> "auth" + NtStatus.STATUS_ACCESS_DENIED -> "permission" + NtStatus.STATUS_OBJECT_NAME_NOT_FOUND, NtStatus.STATUS_OBJECT_PATH_NOT_FOUND, NtStatus.STATUS_NO_SUCH_FILE -> "notFound" + NtStatus.STATUS_BAD_NETWORK_NAME -> "noShare" + NtStatus.STATUS_OBJECT_NAME_COLLISION -> "exists" + else -> "other" + } + return code to "${e.status}: ${e.message}" + } + if (root is UnknownHostException || root is ConnectException || root is NoRouteToHostException || root is SocketTimeoutException) { + return "unreachable" to "${root.javaClass.simpleName}: ${root.message}" + } + val text = "${e.javaClass.simpleName}: ${e.message} / ${root.javaClass.simpleName}: ${root.message}" + // SMB1 만 되는 서버 (smbj 는 SMB2 부터 - 협상 실패) + if (text.contains("SMB1", ignoreCase = true) || text.contains("dialect", ignoreCase = true) || text.contains("negotiat", ignoreCase = true)) { + return "smb1" to text + } + return "other" to text + } +} diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 7388fd0..503c831 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -236,6 +236,7 @@ "MKV 화면으로": "To the MKV screen", "MKV 화면으로 (전체 화면)": "To the MKV screen (full screen)", "MP4 (동영상)": "MP4 (video)", + "NAS · PC 의 공유 폴더 (SMB2 · SMB3) 를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호는 이 기기의 안전 저장소에만 둡니다. 보안 문제로 오래된 SMB1 은 쓰지 않습니다.": "Open, copy and sync shared folders (SMB2 · SMB3) on a NAS or PC in the File explorer and Rsync screens (tabs at the top). Passwords are kept only in this device's secure storage. The old SMB1 isn't used for security reasons.", "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "Open, copy and sync WebDAV folders on a NAS or cloud in the file explorer and the Rsync screen (tab next to \"SD card\" at the top). The password is stored only in this device's settings file.", "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "Open, copy and sync WebDAV folders on a NAS or cloud in the file explorer and the Rsync screen (tab next to \"SD card\" at the top). The password is kept only in this device's secure storage, never in the settings file, and stays after updates.", "NLLB 언어 코드가 아닙니다: {0}": "Not an NLLB language code: {0}", @@ -269,6 +270,12 @@ "SFTP 서버 추가": "Add SFTP server", "SFTP 에서 받는 중": "Downloading from SFTP", "SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "Uses SHA-1 signatures and key exchange. Turn on only when an old NAS supports nothing else (weaker security)", + "SMB 서버 \"{0}\" 을(를) 지울까요?": "Delete SMB server \"{0}\"?", + "SMB 서버 고치기": "Edit SMB server", + "SMB 서버 설정이 없습니다 ({0})": "There are no settings for this SMB server ({0})", + "SMB 서버 주소 · 공유 이름이 없습니다": "The SMB server address or share name is empty", + "SMB 서버 추가": "Add SMB server", + "SMB 에서 받는 중": "Downloading from SMB", "SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.", "SSH 서버 (NAS · 리눅스) 의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호 · 개인 키 · 키 암호는 이 기기의 안전 저장소에만 둡니다. 처음 연결할 때 서버의 키 지문을 확인합니다.": "Open, copy and sync folders on SSH servers (NAS, Linux) in the File explorer and Rsync screens (tabs at the top). Passwords, private keys and key passphrases are kept only in this device's secure storage. The server's key fingerprint is checked on the first connection.", "Stable Diffusion v1.5": "Stable Diffusion v1.5", @@ -639,6 +646,7 @@ "검색": "Search", "검색 지우기": "Clear search", "검정": "Black", + "게스트": "Guest", "결과 폴더": "Output folder", "결과 폴더 열기": "Open output folder", "결과 폴더 열기 (동영상을 고르세요)": "Open output folder (choose a video)", @@ -683,7 +691,9 @@ "공용 폴더에 받은 파일 (동영상 · 그림 등)": "Files downloaded to shared folders (videos, images, etc.)", "공유": "Share", "공유 (다른 앱으로 보내기)": "Share (send to another app)", + "공유 이름": "Share name", "공유 창을 열 수 없습니다.": "Can't open the share sheet.", + "공유 폴더 \"{0}\" 이(가) 없습니다. 공유 이름을 확인하세요": "The shared folder \"{0}\" doesn't exist. Check the share name", "관리 메뉴": "Manage menu", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "The Chrome engine can't be used because the app is running as administrator (security)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "Only used when running as administrator. When off (default), the Chrome engine isn't started then and Edge is suggested.", @@ -825,6 +835,7 @@ "네덜란드어": "Dutch", "네트워크 · DNS": "Network · DNS", "네트워크 경로 (UNC)": "Network path (UNC)", + "네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다": "Network shares (SMB) aren't opened by path. Add the server with [Add SMB server] and it opens as a tab", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "Network shares (SMB) aren't supported yet. Use the WebDAV tab", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "For a network share, include the shared folder name (e.g. \\\\NAS\\Videos).", "네팔어": "Nepali", @@ -956,6 +967,7 @@ "덮어쓴 파일은 되돌릴 수 없습니다.": "Overwritten files can't be restored.", "덮어씁니다": "overwrites it", "덴마크어": "Danish", + "도메인 · 작업 그룹 (보통 비움)": "Domain / workgroup (usually empty)", "독일어": "German", "돌아가기": "Go back", "돌아오면 홈부터 다시 엽니다.": "When you come back, it starts again from the home page.", @@ -1499,6 +1511,7 @@ "시작 · 웹 브라우저": "Startup · web browser", "시작 메뉴에 등록": "Add to Start menu", "시작 안 함 (모니터링에서 시작)": "Don't start (start from Monitor)", + "시작 폴더 (공유 안, 비우면 맨 위)": "Start folder (inside the share, top if empty)", "시작 폴더 (비우면 홈 폴더)": "Start folder (home folder if empty)", "시작: {0} → {1}{2}": "Started: {0} → {1}{2}", "시작하는 중…": "Starting…", @@ -1545,11 +1558,13 @@ "아래에 줄 추가": "Add line below", "아무것도 안 함": "Do nothing", "아이디": "User name", + "아이디 (비우면 게스트)": "User name (guest if empty)", "아이디 (선택)": "Username (optional)", "아이디 · 비밀번호를 확인하세요": "check the user name and password", "아이디 또는 비밀번호가 맞지 않습니다": "The user name or password is wrong", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).", "아이디나 비밀번호 (또는 키) 가 맞지 않습니다": "The user name or password (or key) is incorrect", + "아이디나 비밀번호가 맞지 않습니다": "The user name or password is incorrect", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "Icons · list look: X-plore (thumbnails · two lines) · Windows Explorer (color icons · columns) · Total Commander (dense list · [folders])", "아직 만든 MKV · 자막이 없습니다: {0}": "No MKV · subtitles made yet: {0}", "아직 실행 안 함": "Not run yet", @@ -1671,6 +1686,7 @@ "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "e.g. https://api.openai.com (called with the path /v1/images/generations)", "예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "e.g. https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/username", "예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)": "e.g. nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)", + "예: video · home (\\\\서버\\공유 의 공유)": "e.g. video · home (the share in \\\\server\\share)", "예상 시간": "Estimated time", "예전": "Before", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "Cleaned up program files of the old layout ({0}): {1}", @@ -1824,6 +1840,7 @@ "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "About {0} per image, {1} in total on this device (based on last time)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "AI translation is not available on this device.", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "The AI engine was not found on this device. Try reinstalling the app", + "이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)": "SMB can't be opened inside the app on this device (on Windows, open the \\\\server\\share path)", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "This component is not available on this device", "이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "7z and RAR can't be extracted in the app on this device yet. Open it with another app that extracts archives.", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "On this device Low · Normal · High are all the same: {0} (smallest tile because memory is small)", @@ -1842,6 +1859,7 @@ "이 버전으로 업데이트": "Update to this version", "이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options are used the next time you do this copy. Run now with the new options?", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "Could not find this browser's bookmarks file.", + "이 서버는 오래된 SMB1 만 지원합니다 - 보안 문제로 연결하지 않습니다. NAS 설정에서 SMB2 · SMB3 을 켜세요": "This server only supports the old SMB1, so it isn't connected for security reasons. Turn on SMB2 / SMB3 in the NAS settings", "이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "{0} items use this server (they stay in their lists; running them reports that the server is missing):", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "The master password is needed to use this server's saved password. Enter it and syncing continues. Target files were not touched.", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "Enter the master password to use this server's saved password.", @@ -1852,6 +1870,7 @@ "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "This install has no rsync. Choose one yourself.", "이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "This app (APK) does not contain Tor.", "이 언어 지우기": "Remove this language", + "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "This connection isn't encrypted ({0}). Others on the same network could see file contents, so it's best to turn on SMB3 encryption in the NAS settings", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "This location (network drive, network share, USB memory etc.) has no Recycle Bin, so items are deleted right away.", "이 이동 버튼 지우기": "Delete this move button", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "These options will be used the next time this move runs. Run it now with the new options?", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index e3d7f26..6698091 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -236,6 +236,7 @@ "MKV 화면으로": "MKV 画面へ", "MKV 화면으로 (전체 화면)": "MKV 画面へ(全画面)", "MP4 (동영상)": "MP4(動画)", + "NAS · PC 의 공유 폴더 (SMB2 · SMB3) 를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호는 이 기기의 안전 저장소에만 둡니다. 보안 문제로 오래된 SMB1 은 쓰지 않습니다.": "NAS・PC の共有フォルダー (SMB2・SMB3) をファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部のタブ)。パスワードはこの端末の安全なストレージにのみ保存します。セキュリティ上の理由で古い SMB1 は使いません。", "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "NAS・クラウドの WebDAV フォルダーをファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部の「SD カード」の隣のタブ)。パスワードはこの端末の設定ファイルにのみ保存されます。", "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "NAS・クラウドの WebDAV フォルダーをファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部の「SD カード」の隣のタブ)。パスワードはこの端末の安全な保存領域にだけ保存し、設定ファイルには書きません。一度入力すればアップデート後も残ります。", "NLLB 언어 코드가 아닙니다: {0}": "NLLB の言語コードではありません: {0}", @@ -269,6 +270,12 @@ "SFTP 서버 추가": "SFTP サーバーを追加", "SFTP 에서 받는 중": "SFTP から受信中", "SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "SHA-1 の署名・鍵交換を使います。古い NAS が他の方式に対応していない場合のみオンにしてください (セキュリティが弱い)", + "SMB 서버 \"{0}\" 을(를) 지울까요?": "SMB サーバー「{0}」を削除しますか?", + "SMB 서버 고치기": "SMB サーバーを編集", + "SMB 서버 설정이 없습니다 ({0})": "この SMB サーバーの設定がありません ({0})", + "SMB 서버 주소 · 공유 이름이 없습니다": "SMB サーバーのアドレス・共有名がありません", + "SMB 서버 추가": "SMB サーバーを追加", + "SMB 에서 받는 중": "SMB から受信中", "SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。", "SSH 서버 (NAS · 리눅스) 의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호 · 개인 키 · 키 암호는 이 기기의 안전 저장소에만 둡니다. 처음 연결할 때 서버의 키 지문을 확인합니다.": "SSH サーバー (NAS・Linux) のフォルダーをファイルエクスプローラー・Rsync 画面で開き、コピー・同期します (上部のタブ)。パスワード・秘密鍵・鍵のパスフレーズはこの端末の安全なストレージにのみ保存します。初回接続時にサーバーの鍵のフィンガープリントを確認します。", "Stable Diffusion v1.5": "Stable Diffusion v1.5", @@ -639,6 +646,7 @@ "검색": "検索", "검색 지우기": "検索をクリア", "검정": "黒", + "게스트": "ゲスト", "결과 폴더": "出力フォルダー", "결과 폴더 열기": "出力フォルダーを開く", "결과 폴더 열기 (동영상을 고르세요)": "出力フォルダーを開く(動画を選んでください)", @@ -683,7 +691,9 @@ "공용 폴더에 받은 파일 (동영상 · 그림 등)": "共有フォルダーにダウンロードしたファイル (動画・画像など)", "공유": "共有", "공유 (다른 앱으로 보내기)": "共有 (他のアプリに送る)", + "공유 이름": "共有名", "공유 창을 열 수 없습니다.": "共有画面を開けません。", + "공유 폴더 \"{0}\" 이(가) 없습니다. 공유 이름을 확인하세요": "共有フォルダー「{0}」がありません。共有名を確認してください", "관리 메뉴": "管理メニュー", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "管理者権限で実行中のため Chrome エンジンを使えません (セキュリティ)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "管理者権限で実行するときだけ使われます。オフ (既定) の場合、そのときは Chrome エンジンを起動せず Edge をおすすめします。", @@ -825,6 +835,7 @@ "네덜란드어": "オランダ語", "네트워크 · DNS": "ネットワーク・DNS", "네트워크 경로 (UNC)": "ネットワークパス (UNC)", + "네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다": "ネットワーク共有 (SMB) はパスでは直接開きません。[SMB サーバーを追加] でサーバーを登録するとタブで開けます", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "ネットワーク共有 (SMB) にはまだ対応していません。WebDAV タブを使ってください", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。", "네팔어": "ネパール語", @@ -956,6 +967,7 @@ "덮어쓴 파일은 되돌릴 수 없습니다.": "上書きしたファイルは元に戻せません。", "덮어씁니다": "上書きします", "덴마크어": "デンマーク語", + "도메인 · 작업 그룹 (보통 비움)": "ドメイン・ワークグループ (通常は空欄)", "독일어": "ドイツ語", "돌아가기": "戻る", "돌아오면 홈부터 다시 엽니다.": "戻るとホームから開き直します。", @@ -1499,6 +1511,7 @@ "시작 · 웹 브라우저": "起動・ウェブブラウザー", "시작 메뉴에 등록": "スタートメニューに登録", "시작 안 함 (모니터링에서 시작)": "開始しない (モニタリングで開始)", + "시작 폴더 (공유 안, 비우면 맨 위)": "開始フォルダー (共有内、空欄なら最上位)", "시작 폴더 (비우면 홈 폴더)": "開始フォルダー (空欄ならホームフォルダー)", "시작: {0} → {1}{2}": "開始: {0} → {1}{2}", "시작하는 중…": "開始中…", @@ -1545,11 +1558,13 @@ "아래에 줄 추가": "下に行を追加", "아무것도 안 함": "何もしない", "아이디": "ユーザー名", + "아이디 (비우면 게스트)": "ユーザー名 (空欄ならゲスト)", "아이디 (선택)": "ユーザー名(任意)", "아이디 · 비밀번호를 확인하세요": "ID とパスワードを確認してください", "아이디 또는 비밀번호가 맞지 않습니다": "ユーザー名またはパスワードが正しくありません", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。", "아이디나 비밀번호 (또는 키) 가 맞지 않습니다": "ユーザー名またはパスワード (または鍵) が正しくありません", + "아이디나 비밀번호가 맞지 않습니다": "ユーザー名またはパスワードが正しくありません", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])", "아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}", "아직 실행 안 함": "まだ実行していません", @@ -1671,6 +1686,7 @@ "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "例: https://api.openai.com (パス /v1/images/generations を付けて呼び出し)", "예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "例: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/ユーザー名", "예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)": "例: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)", + "예: video · home (\\\\서버\\공유 의 공유)": "例: video · home (\\\\サーバー\\共有 の共有)", "예상 시간": "予想時間", "예전": "以前", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "旧構成のプログラムファイルを整理({0}個): {1}", @@ -1824,6 +1840,7 @@ "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "この端末で 1 枚約 {0}・全部で約 {1} (前回の所要時間から)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "この端末では AI 翻訳を使えません。", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "この端末で AI エンジンが見つかりませんでした。アプリを再インストールしてください", + "이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)": "この端末ではアプリ内で SMB を開けません (Windows では \\\\サーバー\\共有 のパスで開いてください)", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "この端末では使えないコンポーネントです", "이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "この端末ではまだアプリ内で 7z・RAR を展開できません。展開できる別のアプリで開いてください。", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "この機器では少なめ · 普通 · 多めはすべて同じです: {0} (メモリが少ないため最小のタイル)", @@ -1842,6 +1859,7 @@ "이 버전으로 업데이트": "このバージョンに更新", "이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "このコピーを次に行うときこのオプションを使います。今、新しいオプションで実行しますか?", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "このブラウザーのブックマークファイルが見つかりませんでした。", + "이 서버는 오래된 SMB1 만 지원합니다 - 보안 문제로 연결하지 않습니다. NAS 설정에서 SMB2 · SMB3 을 켜세요": "このサーバーは古い SMB1 のみ対応しています。セキュリティ上の理由で接続しません。NAS の設定で SMB2・SMB3 をオンにしてください", "이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "このサーバーを使うもの {0} 件 (削除しても一覧には残り、実行するとサーバーがないと通知します):", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "このサーバーの保存したパスワードを使うにはマスターパスワードが必要です。入力すると同期を続けます。対象のファイルには触れていません。", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "このサーバーの保存したパスワードを使うにはマスターパスワードを入力してください。", @@ -1852,6 +1870,7 @@ "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "このインストールには rsync が含まれていません。直接指定してください。", "이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "このアプリ (APK) には Tor が入っていません。", "이 언어 지우기": "この言語を削除", + "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "この接続は暗号化されていません ({0})。同じネットワークの他の人がファイルの内容を見られるため、NAS の設定で SMB3 暗号化をオンにすることをお勧めします", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "この場所 (ネットワークドライブ・ネットワーク共有・USB メモリなど) にはごみ箱がないため、すぐに削除されます。", "이 이동 버튼 지우기": "この移動ボタンを削除", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "この移動を次に行うときにこのオプションを使います。今すぐ新しいオプションで実行しますか?", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 5920652..341d4a6 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -236,6 +236,7 @@ "MKV 화면으로": "转到 MKV 界面", "MKV 화면으로 (전체 화면)": "转到 MKV 界面(全屏)", "MP4 (동영상)": "MP4(视频)", + "NAS · PC 의 공유 폴더 (SMB2 · SMB3) 를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호는 이 기기의 안전 저장소에만 둡니다. 보안 문제로 오래된 SMB1 은 쓰지 않습니다.": "在文件资源管理器和 Rsync 界面中打开、复制和同步 NAS · PC 的共享文件夹 (SMB2 · SMB3) (顶部标签)。密码仅保存在本设备的安全存储中。出于安全原因不使用旧的 SMB1。", "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 설정 파일에만 저장합니다.": "在文件浏览器和 Rsync 界面中打开、复制、同步 NAS·云端的 WebDAV 文件夹 (顶部“SD 卡”旁的标签)。密码仅保存在本设备的设置文件中。", "NAS · 클라우드의 WebDAV 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 \"SD 카드\" 옆 탭). 비밀번호는 이 기기의 안전 저장소에만 두고 설정 파일에는 쓰지 않습니다. 한 번 넣으면 업데이트 뒤에도 남습니다.": "在文件浏览器和 Rsync 界面中打开、复制和同步 NAS · 云端的 WebDAV 文件夹 (顶部“SD 卡”旁的标签)。密码只保存在本设备的安全存储中,不写入设置文件,更新后仍会保留。", "NLLB 언어 코드가 아닙니다: {0}": "不是 NLLB 语言代码: {0}", @@ -269,6 +270,12 @@ "SFTP 서버 추가": "添加 SFTP 服务器", "SFTP 에서 받는 중": "正在从 SFTP 下载", "SHA-1 서명 · 키 교환을 씁니다. 오래된 NAS 가 다른 방식을 모를 때만 켜세요 (보안이 약함)": "使用 SHA-1 签名和密钥交换。仅当旧 NAS 不支持其他方式时开启 (安全性较弱)", + "SMB 서버 \"{0}\" 을(를) 지울까요?": "要删除 SMB 服务器\"{0}\"吗?", + "SMB 서버 고치기": "编辑 SMB 服务器", + "SMB 서버 설정이 없습니다 ({0})": "没有此 SMB 服务器的设置 ({0})", + "SMB 서버 주소 · 공유 이름이 없습니다": "SMB 服务器地址或共享名称为空", + "SMB 서버 추가": "添加 SMB 服务器", + "SMB 에서 받는 중": "正在从 SMB 下载", "SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。", "SSH 서버 (NAS · 리눅스) 의 폴더를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). 비밀번호 · 개인 키 · 키 암호는 이 기기의 안전 저장소에만 둡니다. 처음 연결할 때 서버의 키 지문을 확인합니다.": "在文件资源管理器和 Rsync 界面中打开、复制和同步 SSH 服务器 (NAS · Linux) 上的文件夹 (顶部标签)。密码、私钥和私钥密码仅保存在本设备的安全存储中。首次连接时会确认服务器的密钥指纹。", "Stable Diffusion v1.5": "Stable Diffusion v1.5", @@ -639,6 +646,7 @@ "검색": "搜索", "검색 지우기": "清除搜索", "검정": "黑色", + "게스트": "访客", "결과 폴더": "输出文件夹", "결과 폴더 열기": "打开输出文件夹", "결과 폴더 열기 (동영상을 고르세요)": "打开输出文件夹(请选择视频)", @@ -683,7 +691,9 @@ "공용 폴더에 받은 파일 (동영상 · 그림 등)": "下载到公共文件夹的文件 (视频、图片等)", "공유": "分享", "공유 (다른 앱으로 보내기)": "分享 (发送到其他应用)", + "공유 이름": "共享名称", "공유 창을 열 수 없습니다.": "无法打开分享窗口。", + "공유 폴더 \"{0}\" 이(가) 없습니다. 공유 이름을 확인하세요": "共享文件夹\"{0}\"不存在。请检查共享名称", "관리 메뉴": "管理菜单", "관리자 권한으로 실행 중이라 Chrome 엔진을 쓸 수 없습니다 (보안)": "应用正以管理员权限运行,无法使用 Chrome 引擎(安全原因)", "관리자 권한으로 실행할 때만 쓰입니다. 끄면 (기본) 그때 Chrome 엔진을 시작하지 않고 Edge 를 권합니다.": "仅在以管理员权限运行时使用。关闭时(默认)届时不启动 Chrome 引擎,并建议使用 Edge。", @@ -825,6 +835,7 @@ "네덜란드어": "荷兰语", "네트워크 · DNS": "网络 · DNS", "네트워크 경로 (UNC)": "网络路径 (UNC)", + "네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다": "网络共享 (SMB) 不能直接按路径打开。用 [添加 SMB 服务器] 添加服务器后即可在标签中打开", "네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "尚不支持网络共享(SMB)。请使用 WebDAV 标签", "네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。", "네팔어": "尼泊尔语", @@ -956,6 +967,7 @@ "덮어쓴 파일은 되돌릴 수 없습니다.": "被覆盖的文件无法恢复。", "덮어씁니다": "覆盖", "덴마크어": "丹麦语", + "도메인 · 작업 그룹 (보통 비움)": "域 · 工作组 (通常留空)", "독일어": "德语", "돌아가기": "返回", "돌아오면 홈부터 다시 엽니다.": "返回后将从主页重新打开。", @@ -1499,6 +1511,7 @@ "시작 · 웹 브라우저": "启动 · 网页浏览器", "시작 메뉴에 등록": "添加到开始菜单", "시작 안 함 (모니터링에서 시작)": "不开始 (在监控中开始)", + "시작 폴더 (공유 안, 비우면 맨 위)": "起始文件夹 (共享内,留空则为最上层)", "시작 폴더 (비우면 홈 폴더)": "起始文件夹 (留空则为主目录)", "시작: {0} → {1}{2}": "开始: {0} → {1}{2}", "시작하는 중…": "正在开始…", @@ -1545,11 +1558,13 @@ "아래에 줄 추가": "在下方添加一行", "아무것도 안 함": "不执行任何操作", "아이디": "用户名", + "아이디 (비우면 게스트)": "用户名 (留空则为访客)", "아이디 (선택)": "用户名(可选)", "아이디 · 비밀번호를 확인하세요": "请检查用户名和密码", "아이디 또는 비밀번호가 맞지 않습니다": "用户名或密码错误", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。", "아이디나 비밀번호 (또는 키) 가 맞지 않습니다": "用户名或密码 (或密钥) 不正确", + "아이디나 비밀번호가 맞지 않습니다": "用户名或密码不正确", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])", "아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}", "아직 실행 안 함": "尚未运行", @@ -1671,6 +1686,7 @@ "예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)": "例: https://api.openai.com (调用时附加路径 /v1/images/generations)", "예: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/아이디": "例: https://nas.local:5006/home · https://cloud.example.com/remote.php/dav/files/用户名", "예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)": "例如: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)", + "예: video · home (\\\\서버\\공유 의 공유)": "例如: video · home (\\\\服务器\\共享 中的共享)", "예상 시간": "预计时间", "예전": "之前", "예전 구조의 프로그램 파일 정리 ({0}개): {1}": "已清理旧结构的程序文件({0} 个): {1}", @@ -1824,6 +1840,7 @@ "이 기기에서 한 장 약 {0} · 모두 약 {1} (지난번 걸린 시간 기준)": "本设备每张约 {0} · 共约 {1} (按上次耗时)", "이 기기에서는 AI 번역을 쓸 수 없습니다.": "此设备无法使用 AI 翻译。", "이 기기에서는 AI 엔진을 찾지 못했습니다. 앱을 다시 설치해 보세요": "在此设备上找不到 AI 引擎。请尝试重新安装应用", + "이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)": "此设备无法在应用内打开 SMB (Windows 请使用 \\\\服务器\\共享 路径打开)", "이 기기에서는 쓸 수 없는 컴포넌트입니다": "此组件在本设备上不可用", "이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.": "本设备暂时无法在应用内解压 7z 和 RAR。请用其他解压应用打开。", "이 기기에서는 적게 · 보통 · 많이 가 모두 같습니다: {0} (메모리가 작아 가장 작은 타일)": "在此设备上,少 · 普通 · 多 都相同:{0}(内存较小,使用最小的分块)", @@ -1842,6 +1859,7 @@ "이 버전으로 업데이트": "更新到此版本", "이 복사를 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次进行此复制时将使用这些选项。现在用新选项运行吗?", "이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.": "未找到此浏览器的书签文件。", + "이 서버는 오래된 SMB1 만 지원합니다 - 보안 문제로 연결하지 않습니다. NAS 설정에서 SMB2 · SMB3 을 켜세요": "此服务器仅支持旧的 SMB1,出于安全原因不予连接。请在 NAS 设置中开启 SMB2 · SMB3", "이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):": "使用此服务器的项目 {0} 个 (删除后仍保留在列表中,运行时会提示服务器不存在):", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호가 필요합니다. 넣으면 이어서 맞춥니다. 대상 파일은 건드리지 않았습니다.": "使用此服务器保存的密码需要主密码。输入后将继续同步。未改动目标文件。", "이 서버의 저장된 비밀번호를 쓰려면 마스터 비밀번호를 넣으세요.": "要使用此服务器保存的密码,请输入主密码。", @@ -1852,6 +1870,7 @@ "이 설치본에는 rsync 가 들어 있지 않습니다. 직접 지정하세요.": "此安装中没有 rsync。请自行指定。", "이 앱 (APK) 에는 Tor 가 들어 있지 않습니다.": "此应用 (APK) 未包含 Tor。", "이 언어 지우기": "删除此语言", + "이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다": "此连接未加密 ({0})。同一网络中的其他人可能看到文件内容,建议在 NAS 设置中开启 SMB3 加密", "이 위치 (네트워크 드라이브 · 네트워크 공유 · USB 메모리 등) 에는 휴지통이 없어 바로 지워집니다.": "此位置 (网络驱动器 · 网络共享 · U 盘等) 没有回收站,会直接删除。", "이 이동 버튼 지우기": "删除此移动按钮", "이 이동을 다시 할 때 이 옵션을 씁니다. 지금 새 옵션으로 실행할까요?": "下次执行此移动时将使用这些选项。现在用新选项执行吗?", diff --git a/assets/licenses/SMBJ_LICENSES.txt b/assets/licenses/SMBJ_LICENSES.txt new file mode 100644 index 0000000..8ab2ca7 --- /dev/null +++ b/assets/licenses/SMBJ_LICENSES.txt @@ -0,0 +1,281 @@ +SMB (Android) - smbj 와 함께 쓰는 라이브러리 (88). Android 판에만 들어 있습니다. + +- smbj 0.15.0 Apache License 2.0 https://github.com/hierynomus/smbj +- asn-one 0.6.0 Apache License 2.0 https://github.com/hierynomus/asn-one +- Bouncy Castle (bcprov-jdk18on) 1.85.2 MIT https://www.bouncycastle.org +- SLF4J API 2.0.18 MIT https://www.slf4j.org +- MBassador 1.3.2 MIT https://github.com/bennidi/mbassador +smbj 는 NOTICE 파일을 두지 않습니다 (저장소의 LICENSE_HEADER 가 아래 그대로). + +==== smbj (LICENSE_HEADER) ==== +Copyright (C)2016 - SMBJ Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==== Bouncy Castle ==== +MIT License +Copyright (c) 2000-2026 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.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. + +==== SLF4J ==== +Copyright (c) 2004-2022 QOS.ch Sarl (Switzerland) +All rights reserved. + +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. + +==== MBassador ==== +MIT License + +Copyright (c) 2012 Benjamin Diedrichsen + +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. + +==== Apache License 2.0 (smbj · asn-one) ==== +Apache License + Version 2.0, January 2004 + http://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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt b/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt index 2f370ec..7addbcb 100644 --- a/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt +++ b/assets/licenses/THIRD_PARTY_NOTICES_ANDROID.txt @@ -32,6 +32,9 @@ JJ_MKVMaker (Android) - 포함된 오픈 소스 구성 요소 및 라이선스 - archive 4.3.0 (ZIP 풀기 · 묶기) MIT https://pub.dev/packages/archive Copyright (c) 2013-2021 Brendan Duncan. - dartssh2 4.1.0 (SFTP) MIT https://pub.dev/packages/dartssh2 +- smbj 0.15.0 (SMB, SMB2 · SMB3) Apache-2.0 https://github.com/hierynomus/smbj + 함께: asn-one 0.6.0 (Apache-2.0) · Bouncy Castle bcprov 1.85.2 (MIT) · SLF4J API 2.0.18 (MIT) · MBassador 1.3.2 (MIT). + 원문: 앱 안 "오픈 소스 라이선스" 의 smbj (SMBJ_LICENSES.txt - Apache License 2.0 전문 포함) Copyright (c) 2022 xuty. 함께 쓰는 pointycastle (MIT) · pinenacl (MIT) · asn1lib (BSD-3-Clause) 의 원문은 [패키지 라이선스] - charset (Apache-2.0) · cp949_codec (BSD) · 그 밖의 Dart 패키지: [패키지 라이선스] diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 80f924a..b579b5a 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -12,6 +12,7 @@ import '../core/languages.dart'; import '../core/mkv_command_builder.dart'; import '../core/vfs.dart'; import '../core/sftp.dart' show SftpRegistry; +import '../core/smb.dart' show SmbRegistry; import '../core/webdav.dart'; import '../core/models.dart'; import '../core/output_paths.dart'; @@ -154,6 +155,8 @@ class AppController extends ChangeNotifier { DavRegistry.configure(settings.webdavServers); // 88: SFTP 서버 (sftp:// 경로) SftpRegistry.configure(settings.sftpServers); + // 88: Android SMB 서버 (smb:// 경로) + SmbRegistry.configure(settings.smbServers); outputRootOverride = (settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null; } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index e7e4c23..36de895 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -12,6 +12,7 @@ import '../core/download_detect.dart'; import '../core/encode_options.dart'; import '../core/playlist.dart'; import '../core/sftp.dart'; +import '../core/smb.dart'; import '../core/webdav.dart'; import '../services/image_ai.dart' show AiService; import '../services/app_shell.dart' show appIconOf; @@ -556,6 +557,9 @@ class AppSettings { /// 88: SFTP 서버 (WebDAV 옆 탭). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) List sftpServers = []; + /// 88: Android SMB 서버 (smbj). 비밀번호는 안전 저장소에만. Windows 는 운영체제의 \서버\공유 를 쓴다 + List smbServers = []; + // ───────── 123 · 121: AI 그림 · 해상도 올리기 ───────── /// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스) @@ -837,6 +841,7 @@ class AppSettings { ], // 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음 'sftpServers': [for (final x in sftpServers) x.toJson()], + 'smbServers': [for (final x in smbServers) x.toJson()], 'rsyncOptions': rsyncOptions, 'robocopyOptions': robocopyOptions, 'copyRunMode': copyRunMode, @@ -1083,6 +1088,10 @@ class AppSettings { for (final x in (j['sftpServers'] as List?) ?? const []) if (x is Map) SftpServer.fromJson(x), ] + ..smbServers = [ + for (final x in (j['smbServers'] as List?) ?? const []) + if (x is Map) SmbServer.fromJson(x), + ] ..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog' ..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' ..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each' @@ -1158,6 +1167,12 @@ class AppSettings { for (final x in sftp) x is Map ? (Map.of(x)..remove('password')..remove('privateKey')..remove('keyPassphrase')) : x, ]; } + final smb = r['smbServers']; + if (smb is List) { + r['smbServers'] = [ + for (final x in smb) x is Map ? (Map.of(x)..remove('password')) : x, + ]; + } final ai = r['aiServices']; if (ai is List) { r['aiServices'] = [ @@ -1278,6 +1293,7 @@ class SettingsStore { static String _sftpPw(String id) => 'sftp.pw.$id'; static String _sftpKey(String id) => 'sftp.key.$id'; static String _sftpKp(String id) => 'sftp.kp.$id'; + static String _smbPw(String id) => 'smb.pw.$id'; static String _aiKey(String id) => 'ai.key.$id'; static const _davServers = 'dav.servers'; @@ -1300,6 +1316,8 @@ class SettingsStore { if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey, if (x.keyPassphrase.isNotEmpty) _sftpKp(x.id): x.keyPassphrase, }, + for (final x in s.smbServers) + if (x.password.isNotEmpty) _smbPw(x.id): x.password, // 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40) if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]), }; @@ -1349,6 +1367,9 @@ class SettingsStore { keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '', ), ]; + s.smbServers = [ + for (final x in s.smbServers) x.password.isNotEmpty ? x : x.copyWith(password: stored[_smbPw(x.id)] ?? ''), + ]; } /// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54). @@ -1553,7 +1574,7 @@ class SettingsStore { if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])}; final unsaved = {}; for (final k in {..._known.keys, ...values.keys}) { - if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.')) continue; + if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.') && !k.startsWith('smb.')) continue; final v = values[k]; if (v == _known[k]) continue; try { diff --git a/lib/core/smb.dart b/lib/core/smb.dart new file mode 100644 index 0000000..0854541 --- /dev/null +++ b/lib/core/smb.dart @@ -0,0 +1,304 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; + +import '../l10n/tr.dart'; +import 'remote_fs.dart'; +import 'secret_gate.dart'; +import 'webdav.dart' show DavException, DavItem, DavLockedException; + +/// 88: SMB 서버 설정 (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다, 사용자 결정 88-1). +/// 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). +class SmbServer { + final String id; + final String name; + final String host; + final int port; + + /// 공유 이름 (예: video · home) + final String share; + final String user; + + /// 도메인 · 작업 그룹 (보통 비움) + final String domain; + + /// 시작 폴더 (공유 안, 비우면 공유의 맨 위) + final String root; + final String password; + + const SmbServer({ + required this.id, + required this.name, + required this.host, + this.port = 445, + required this.share, + this.user = '', + this.domain = '', + this.root = '', + this.password = '', + }); + + /// 탐색기 위쪽에 보일 이름 + String get label => name.trim().isNotEmpty ? name.trim() : '$host/$share'; + + SmbServer copyWith({String? name, String? host, int? port, String? share, String? user, String? domain, String? root, String? password}) => + SmbServer( + id: id, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + share: share ?? this.share, + user: user ?? this.user, + domain: domain ?? this.domain, + root: root ?? this.root, + password: password ?? this.password, + ); + + /// 비밀번호는 넣지 않는다 (안전 저장소). 저장해 두었는지만 + Map toJson() => { + 'id': id, + 'name': name, + 'host': host, + 'port': port, + 'share': share, + 'user': user, + 'domain': domain, + 'root': root, + 'hasPassword': password.isNotEmpty, + }; + + factory SmbServer.fromJson(Map j) => SmbServer( + id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', + name: j['name'] as String? ?? '', + host: j['host'] as String? ?? '', + port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => 445 }, + share: j['share'] as String? ?? '', + user: j['user'] as String? ?? '', + domain: j['domain'] as String? ?? '', + root: j['root'] as String? ?? '', + ); +} + +enum SmbFailure { unreachable, auth, noPassword, noShare, smb1, permission, notFound, other } + +/// SMB 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다 +class SmbException extends DavException { + final SmbFailure kind; + const SmbException(super.message, this.kind); +} + +/// 88: Android SMB 클라이언트 - smbj (Kotlin, android/.../SmbBridge.kt) 를 플랫폼 채널로 부른다. +/// 파일 내용은 1MB 조각으로 오간다 (열어 둔 파일 손잡이로). +class SmbRemote implements RemoteClient { + final SmbServer server; + SmbRemote(this.server); + + /// 시험에서 가짜로 바꾼다 + static MethodChannel channel = const MethodChannel('jj_mkvmaker/smb'); + static const chunk = 1 << 20; + + Map get _cfg => { + 'id': server.id, + 'host': server.host.trim(), + 'port': server.port, + 'share': server.share.trim(), + 'user': server.user.trim(), + 'domain': server.domain.trim(), + 'password': server.password, + }; + + /// 서버 기준 경로 → 공유 안의 경로 (시작 폴더 포함) + String _path(String rel) { + final root = server.root.trim().replaceAll('\\', '/').replaceAll(RegExp(r'^/+|/+$'), ''); + final r = rel == '/' ? '' : rel; + return root.isEmpty ? (r.isEmpty ? '/' : r) : '/$root$r'; + } + + Future _call(String method, [Map args = const {}, String rel = '']) async { + if (server.host.trim().isEmpty || server.share.trim().isEmpty) { + throw SmbException(tr('SMB 서버 주소 · 공유 이름이 없습니다'), SmbFailure.other); + } + if (server.password.isNotEmpty && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); + if (server.user.isNotEmpty && server.password.isEmpty) throw SmbException(tr('저장된 비밀번호가 없습니다'), SmbFailure.noPassword); + try { + return await channel.invokeMethod(method, {..._cfg, ...args}); + } on PlatformException catch (e) { + throw _explain(e, rel); + } on MissingPluginException { + throw SmbException(tr('이 기기에서는 SMB 를 앱 안에서 열 수 없습니다 (Windows 는 \\\\서버\\공유 경로로 여세요)'), SmbFailure.other); + } + } + + SmbException _explain(PlatformException e, String rel) { + final s = server; + return switch (e.code) { + 'unreachable' => SmbException( + trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), SmbFailure.unreachable), + 'auth' => SmbException(tr('아이디나 비밀번호가 맞지 않습니다'), SmbFailure.auth), + 'noShare' => SmbException(trf('공유 폴더 "{0}" 이(가) 없습니다. 공유 이름을 확인하세요', [s.share]), SmbFailure.noShare), + // 88: SMB1 은 보안 문제로 쓰지 않는다 (설계안 5) + 'smb1' => SmbException(tr('이 서버는 오래된 SMB1 만 지원합니다 - 보안 문제로 연결하지 않습니다. NAS 설정에서 SMB2 · SMB3 을 켜세요'), + SmbFailure.smb1), + 'permission' => SmbException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [rel]), SmbFailure.permission), + 'notFound' => SmbException(trf('없는 경로입니다: {0}', [rel]), SmbFailure.notFound), + _ => SmbException(e.message ?? e.code, SmbFailure.other), + }; + } + + static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; + static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); + + DavItem _item(String rel, Map m) => DavItem(rel, + isDir: m['dir'] == true, + size: m['dir'] == true ? 0 : (m['size'] as num?)?.toInt() ?? 0, + modified: DateTime.fromMillisecondsSinceEpoch((m['mtime'] as num?)?.toInt() ?? 0)); + + /// 연결이 암호화되는지 · 어떤 SMB 판인지 (88-4: 암호화되지 않으면 화면이 경고) + Future<({bool encrypted, String dialect})> info() async { + final m = await _call>('info', const {}, '/'); + return (encrypted: m?['encrypted'] == true, dialect: '${m?['dialect'] ?? ''}'); + } + + @override + Future> list(String rel) async { + final out = await _call>('list', {'path': _path(rel)}, rel) ?? const []; + return [ + for (final x in out) + if (x is Map) _item(_child(rel, '${x['name']}'), x), + ]; + } + + @override + Future stat(String rel) async { + try { + final m = await _call>('stat', {'path': _path(rel)}, rel); + return m == null ? null : _item(rel, m); + } on SmbException catch (e) { + if (e.kind == SmbFailure.notFound) return null; + rethrow; + } + } + + @override + Future mkdirs(String rel) async { + if (rel == '/' || rel.isEmpty) return; + final st = await stat(rel); + if (st != null) { + if (st.isDir) return; + throw SmbException(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]), SmbFailure.other); + } + await mkdirs(_parent(rel)); + await _call('mkdir', {'path': _path(rel)}, rel); + } + + @override + Future delete(String rel) => _call('delete', {'path': _path(rel)}, rel); + + @override + Future move(String from, String to, {bool overwrite = false}) => + _call('move', {'path': _path(from), 'to': _path(to), 'overwrite': overwrite}, from); + + @override + Future>> openRead(String rel) async { + final m = await _call>('openRead', {'path': _path(rel)}, rel); + final handle = m!['handle'] as int; + Stream> read() async* { + var offset = 0; + try { + while (true) { + final Uint8List? data; + try { + data = await channel.invokeMethod('readChunk', {'handle': handle, 'offset': offset, 'length': chunk}); + } on PlatformException catch (e) { + throw _explain(e, rel); + } + if (data == null || data.isEmpty) break; + offset += data.length; + yield data; + } + } finally { + await channel.invokeMethod('closeHandle', {'handle': handle}).catchError((_) {}); + } + } + + return read(); + } + + @override + Future write(String rel, Stream> data, {int? length}) async { + final handle = await _call('openWrite', {'path': _path(rel)}, rel); + var offset = 0; + final buf = BytesBuilder(copy: false); + // 모인 것을 [chunk] 크기로 잘라 보낸다 (큰 조각 하나가 와도 채널로 한 번에 수백 MB 를 보내지 않게). [all] 이면 남은 것까지 + Future flush({bool all = false}) async { + if (buf.isEmpty || (!all && buf.length < chunk)) return; + final bytes = buf.takeBytes(); + var at = 0; + while (bytes.length - at >= chunk || (all && at < bytes.length)) { + final end = (at + chunk).clamp(0, bytes.length); + try { + await channel.invokeMethod( + 'writeChunk', {'handle': handle, 'offset': offset, 'data': Uint8List.sublistView(bytes, at, end)}); + } on PlatformException catch (e) { + throw _explain(e, rel); + } + offset += end - at; + at = end; + } + if (at < bytes.length) buf.add(Uint8List.sublistView(bytes, at)); + } + + try { + await for (final x in data) { + buf.add(x); + await flush(); + } + await flush(all: true); + } finally { + await channel.invokeMethod('closeHandle', {'handle': handle}).catchError((_) {}); + } + } + + @override + Future<(int?, int?)> quota() async { + try { + final v = await _call>('quota', const {}, '/'); + return ((v?[0] as num?)?.toInt(), (v?[1] as num?)?.toInt()); + } catch (_) { + return (null, null); + } + } + + @override + void close() => unawaited(channel.invokeMethod('close', {'id': server.id}).catchError((_) {})); +} + +/// 설정의 SMB 서버 목록과 클라이언트 (서버마다 하나) +class SmbRegistry { + static final _clients = {}; + static final _servers = {}; + + static void configure(List servers) { + final keep = {for (final s in servers) s.id: s}; + for (final id in _clients.keys.toList()) { + final old = _servers[id], now = keep[id]; + if (now == null || old == null || old.password != now.password || jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { + _clients.remove(id)?.close(); + } + } + _servers + ..clear() + ..addAll(keep); + } + + static List get servers => _servers.values.toList(); + static SmbServer? server(String id) => _servers[id]; + + static SmbRemote client(String id) { + final s = _servers[id]; + if (s == null) throw SmbException(trf('SMB 서버 설정이 없습니다 ({0})', [id]), SmbFailure.other); + return _clients.putIfAbsent(id, () => SmbRemote(s)); + } +} diff --git a/lib/core/vfs.dart b/lib/core/vfs.dart index 2f9da3b..eed844d 100644 --- a/lib/core/vfs.dart +++ b/lib/core/vfs.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart' as p; import 'remote_fs.dart'; import 'sftp.dart'; +import 'smb.dart'; import 'webdav.dart'; import '../l10n/tr.dart'; @@ -13,12 +14,19 @@ import '../l10n/tr.dart'; const davScheme = 'dav://'; const sftpScheme = 'sftp://'; -/// 원격 (WebDAV · SFTP) 경로인지. 이름은 예전 그대로 (원격이면 같은 길 - 복사 · 동기화 · 임시로 받아 열기) -bool isDav(String path) => path.startsWith(davScheme) || path.startsWith(sftpScheme); +/// 88: Android SMB (smbj) `smb://<서버 id>/<경로>` - Windows 는 운영체제의 \\서버\공유 를 그대로 쓴다 +const smbScheme = 'smb://'; + +/// 원격 (WebDAV · SFTP · SMB) 경로인지. 이름은 예전 그대로 (원격이면 같은 길 - 복사 · 동기화 · 임시로 받아 열기) +bool isDav(String path) => path.startsWith(davScheme) || path.startsWith(sftpScheme) || path.startsWith(smbScheme); /// WebDAV 만 (HTTP 로 바로 재생 · 서버 고치기 창 등 WebDAV 에만 있는 것) bool isWebDav(String path) => path.startsWith(davScheme); bool isSftp(String path) => path.startsWith(sftpScheme); +bool isSmb(String path) => path.startsWith(smbScheme); + +/// 스트리밍 주소가 없는 원격 (SFTP · SMB) - 재생 · 다른 앱으로 열기는 받아서 +bool noStreamUrl(String path) => isSftp(path) || isSmb(path); /// 원격 경로: 서버 종류 ([scheme]) · 서버 id · 서버 기준 경로 ("/" 로 시작, 끝 / 없음, 맨 위는 "/") class DavPath { @@ -29,7 +37,11 @@ class DavPath { /// "dav://id/a/b" · "dav://id/a\b" (p.join 이 붙인 \ ) · "dav://id" · "sftp://id/…" → 정리 static DavPath parse(String path) { - final scheme = path.startsWith(sftpScheme) ? sftpScheme : davScheme; + final scheme = path.startsWith(sftpScheme) + ? sftpScheme + : path.startsWith(smbScheme) + ? smbScheme + : davScheme; var rest = path.substring(scheme.length).replaceAll('\\', '/'); final slash = rest.indexOf('/'); final server = slash < 0 ? rest : rest.substring(0, slash); @@ -42,19 +54,35 @@ class DavPath { bool get isRoot => rel == '/'; bool get isSftp => scheme == sftpScheme; + bool get isSmb => scheme == smbScheme; String get full => isRoot ? '$scheme$server/' : '$scheme$server$rel'; String get name => isRoot ? '' : rel.substring(rel.lastIndexOf('/') + 1); DavPath get parent => isRoot ? this : DavPath(server, rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')), scheme); DavPath child(String n) => DavPath(server, isRoot ? '/$n' : '$rel/$n', scheme); - /// 이 서버의 클라이언트 (WebDAV · SFTP) - RemoteClient get client => isSftp ? SftpRegistry.client(server) : DavRegistry.client(server); + /// 이 서버의 클라이언트 (WebDAV · SFTP · SMB) + RemoteClient get client => isSftp + ? SftpRegistry.client(server) + : isSmb + ? SmbRegistry.client(server) + : DavRegistry.client(server); /// 서버 이름 (화면 표시) - String get serverLabel => isSftp ? SftpRegistry.server(server)?.label ?? 'SFTP' : DavRegistry.server(server)?.label ?? 'WebDAV'; + String get serverLabel => isSftp + ? SftpRegistry.server(server)?.label ?? 'SFTP' + : isSmb + ? SmbRegistry.server(server)?.label ?? 'SMB' + : DavRegistry.server(server)?.label ?? 'WebDAV'; /// 설정에 이 서버가 있는지 (지운 서버의 경로 · 즐겨찾기 등) - bool get serverKnown => isSftp ? SftpRegistry.server(server) != null : DavRegistry.server(server) != null; + bool get serverKnown => isSftp + ? SftpRegistry.server(server) != null + : isSmb + ? SmbRegistry.server(server) != null + : DavRegistry.server(server) != null; + + /// 화면 표시 아이콘 글자: WebDAV ☁ · SFTP 🔑 · SMB 🗄 + String get mark => isSftp ? '🔑' : isSmb ? '🗄' : '☁'; } /// WebDAV 파일을 받지 않고 바로 재생 (스트리밍) 할 주소 · 헤더 (인증은 헤더로만 - 주소에 비밀번호를 넣지 않는다) @@ -243,7 +271,7 @@ Future vLocalCopy(String path, String tempDir, {void Function(int done, if (!isDav(path)) return path; final d = DavPath.parse(path); final st = await d.client.stat(d.rel); - final dir = Directory(p.join(tempDir, d.isSftp ? 'jj_sftp' : 'jj_webdav', d.server, p.dirname(d.rel.substring(1)))); + final dir = Directory(p.join(tempDir, d.isSftp ? 'jj_sftp' : d.isSmb ? 'jj_smb' : 'jj_webdav', d.server, p.dirname(d.rel.substring(1)))); await dir.create(recursive: true); final out = File(p.join(dir.path, d.name)); // 이미 받아 둔 것 (크기 · 바뀐 때 같음) 은 다시 받지 않는다 @@ -275,7 +303,7 @@ Future vCountFiles(String path) async { } /// 화면에 보일 경로: 원격은 내부 id 대신 "☁ 서버 이름/경로" (WebDAV) · "🔑 서버 이름/경로" (SFTP) -String vDisplay(String path) => isDav(path) ? '${isSftp(path) ? '🔑' : '☁'} ${vVolumeLabel(path)}' : path; +String vDisplay(String path) => isDav(path) ? '${DavPath.parse(path).mark} ${vVolumeLabel(path)}' : path; /// 저장소 이름 (화면 표시): 원격은 서버 이름 String vVolumeLabel(String path) { diff --git a/lib/platform/common/media_kit_full_player.dart b/lib/platform/common/media_kit_full_player.dart index f12161e..33064da 100644 --- a/lib/platform/common/media_kit_full_player.dart +++ b/lib/platform/common/media_kit_full_player.dart @@ -136,7 +136,7 @@ class MediaKitFullPlayer implements MediaPlayer { Future _media(String f) async { final native = _p.platform; // 88: SFTP 는 스트리밍 주소가 없어 받아서 튼다 (보통은 playFiles 가 미리 받아 둔다 - 여기는 안전망) - if (isSftp(f)) return mk.Media(await vLocalCopy(f, Directory.systemTemp.path)); + if (noStreamUrl(f)) return mk.Media(await vLocalCopy(f, Directory.systemTemp.path)); final dav = isWebDav(f) ? davStream(f) : null; if (native is mk.NativePlayer) { _tlsDefault ??= await native.getProperty('tls-verify'); diff --git a/lib/ui/dav_external.dart b/lib/ui/dav_external.dart index 3ba6f5e..cb82765 100644 --- a/lib/ui/dav_external.dart +++ b/lib/ui/dav_external.dart @@ -21,7 +21,7 @@ Future fetchDav(BuildContext context, AppController c, String path) asy barrierDismissible: false, builder: (ctx) => AlertDialog( scrollable: true, - title: Text(isSftp(path) ? tr('SFTP 에서 받는 중') : tr('WebDAV 에서 받는 중')), + title: Text(isSftp(path) ? tr('SFTP 에서 받는 중') : isSmb(path) ? tr('SMB 에서 받는 중') : tr('WebDAV 에서 받는 중')), content: ValueListenableBuilder<(int, int)>( valueListenable: progress, builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -69,11 +69,11 @@ bool davNeedsLogin(String path) { /// 조용히 실패하지 않게 먼저 묻는다: [받아서 열기] (임시로 받은 뒤 열기 - 어느 앱이든 열림) · [주소로 열기] (받는 앱이 아이디를 물어야 열림). Future openExternalPlayable(BuildContext context, AppController c, String program, List files) async { // 88: SFTP 는 다른 앱에 넘길 주소가 없다 - 늘 받아서 연다 (묻지 않음) - if (files.any(isSftp)) { + if (files.any(noStreamUrl)) { final local = []; for (final f in files) { if (!context.mounted) return; - final l = isSftp(f) ? await fetchDav(context, c, f) : vPlayable(f); + final l = noStreamUrl(f) ? await fetchDav(context, c, f) : vPlayable(f); if (l == null) return; // 취소 · 실패 local.add(l); } diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 1d361fe..5ecb338 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -21,6 +21,7 @@ import '../core/file_ops.dart'; import '../core/playlist.dart' show isAudioFile, isVideoFile; import '../core/vfs.dart'; import '../core/sftp.dart' show SftpHostKeyException, SftpRegistry; +import '../core/smb.dart' show SmbRegistry; import '../core/webdav.dart' show DavRegistry; import '../platform/android/android_storage.dart'; import 'app_actions.dart'; @@ -37,6 +38,7 @@ import '../core/sync_preview.dart'; import 'path_label.dart'; import 'sync_preview_view.dart'; import 'sftp_settings.dart'; +import 'smb_settings.dart'; import 'webdav_settings.dart'; import 'reader_page.dart'; import '../app/component_store.dart'; @@ -324,6 +326,8 @@ class _ExplorerPageState extends State { for (final s in c.settings.webdavServers) ('$davScheme${s.id}/', s.label), // 88: SFTP 서버 for (final s in c.settings.sftpServers) ('$sftpScheme${s.id}/', s.label), + // 88: SMB 서버 (Android) + for (final s in c.settings.smbServers) ('$smbScheme${s.id}/', s.label), ]; late final List<_Pane> _panes = [_Pane(''), _Pane('')]; int _active = 0; @@ -640,6 +644,7 @@ class _ExplorerPageState extends State { final dav = isDav(where); final server = isWebDav(where) ? DavRegistry.server(DavPath.parse(where).server) : null; final sftp = isSftp(where) ? SftpRegistry.server(DavPath.parse(where).server) : null; + final smb = isSmb(where) ? SmbRegistry.server(DavPath.parse(where).server) : null; // 88: 처음 보는 SFTP 서버 키 - 지문을 보이고 [지문 확인하고 연결] (바뀐 키는 막고 서버 설정으로) final hostKey = pane.hostKeyErrors[where] ?? pane.hostKeyErrors[pane.root]; if (sftp != null && hostKey != null) { @@ -667,7 +672,8 @@ class _ExplorerPageState extends State { final (title, body) = explainFileError(err, dav: dav, noPassword: (server != null && server.password.isEmpty) || - (sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty)); + (sftp != null && sftp.password.isEmpty && sftp.privateKey.isEmpty) || + (smb != null && smb.user.isNotEmpty && smb.password.isEmpty)); final locked = isLockedError(err); // 50: 막힌 폴더는 다시 해도 같으니 [다시 시도] 없이 [위 폴더로] if (err == androidRestrictedError) { @@ -706,7 +712,12 @@ class _ExplorerPageState extends State { ), if (dav && !locked) TextButton( - onPressed: () => sftp != null ? _editSftp(pane, pane.root) : _editDav(pane, pane.root), child: Text(tr('서버 설정 고치기'))), + onPressed: () => sftp != null + ? _editSftp(pane, pane.root) + : smb != null + ? _editSmb(pane, pane.root) + : _editDav(pane, pane.root), + child: Text(tr('서버 설정 고치기'))), ], ); } @@ -722,6 +733,13 @@ class _ExplorerPageState extends State { final base = s.root.isEmpty ? '/~' : (s.root.startsWith('/') ? s.root : '/${s.root}'); return 'sftp://${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}$base${d.isRoot ? '' : d.rel}'; } + // 88: SMB 는 smb://서버/공유/경로 (아이디 · 비밀번호 없음) + if (d.isSmb) { + final s = SmbRegistry.server(d.server); + if (s == null) return _displayPath(path); + final r = s.root.trim().replaceAll(r'\', '/').replaceAll(RegExp(r'^/+|/+$'), ''); + return 'smb://${s.host}${s.port == 445 ? '' : ':${s.port}'}/${s.share}${r.isEmpty ? '' : '/$r'}${d.isRoot ? '' : d.rel}'; + } try { return DavRegistry.client(d.server).uriOf(d.rel).replace(userInfo: '').toString(); } catch (_) { @@ -733,7 +751,7 @@ class _ExplorerPageState extends State { String _displayPath(String path) { if (isDav(path)) { final d = DavPath.parse(path); - return ['${d.isSftp ? '🔑' : '☁'} ${d.serverLabel}', ...d.rel.split('/').where((s) => s.isNotEmpty)].join(' › '); + return ['${d.mark} ${d.serverLabel}', ...d.rel.split('/').where((s) => s.isNotEmpty)].join(' › '); } if (!Platform.isAndroid) return path; String? best; @@ -4058,7 +4076,12 @@ class _ExplorerPageState extends State { tooltip: tr('경로 입력 · 네트워크 서버'), icon: const Icon(Icons.more_vert, size: 18), padding: EdgeInsets.zero, - onSelected: (v) => switch (v) { 'path' => _enterPath(pane), 'sftp' => _editSftp(pane, null), _ => _editDav(pane, null) }, + onSelected: (v) => switch (v) { + 'path' => _enterPath(pane), + 'sftp' => _editSftp(pane, null), + 'smb' => _editSmb(pane, null), + _ => _editDav(pane, null), + }, itemBuilder: (_) => [ PopupMenuItem( value: 'path', @@ -4067,6 +4090,10 @@ class _ExplorerPageState extends State { value: 'dav', child: ListTile(dense: true, leading: const Icon(Icons.cloud_outlined), title: Text(tr('WebDAV 서버 추가')))), PopupMenuItem( value: 'sftp', child: ListTile(dense: true, leading: const Icon(Icons.vpn_key_outlined), title: Text(tr('SFTP 서버 추가')))), + // 88: SMB 는 Android 만 (Windows 는 경로 입력에 \\서버\공유) + if (ExplorerPage.debugAndroidPaths ?? Platform.isAndroid) + PopupMenuItem( + value: 'smb', child: ListTile(dense: true, leading: const Icon(Icons.dns_outlined), title: Text(tr('SMB 서버 추가')))), ], ); @@ -4150,6 +4177,8 @@ class _ExplorerPageState extends State { icon: Icon( isSftp(path) ? Icons.vpn_key_outlined + : isSmb(path) + ? Icons.dns_outlined : isDav(path) ? Icons.cloud_outlined : _networkDrives.contains(path) @@ -4171,7 +4200,13 @@ class _ExplorerPageState extends State { _goTo(pane, path); } }, - onLongPress: isSftp(path) ? () => _editSftp(pane, path) : isDav(path) ? () => _editDav(pane, path) : null, + onLongPress: isSftp(path) + ? () => _editSftp(pane, path) + : isSmb(path) + ? () => _editSmb(pane, path) + : isDav(path) + ? () => _editDav(pane, path) + : null, ), ), ], @@ -4222,6 +4257,8 @@ class _ExplorerPageState extends State { if (path == null || !mounted) return; // 179: SMB 를 넣었다가 [WebDAV 서버 추가] 를 누름 if (path == _PathDialog.addDav) return _editDav(pane, null); + // 88: Android 에서 smb:// · \\서버\공유 를 넣고 [SMB 서버 추가] + if (path.startsWith(_PathDialog.addSmb)) return _editSmb(pane, null, from: path.substring(_PathDialog.addSmb.length)); await _goTo(pane, path, fresh: true); } @@ -4275,6 +4312,18 @@ class _ExplorerPageState extends State { await _goTo(pane, '$sftpScheme${saved.id}/'); } + /// 88: SMB 서버 추가 (없으면 [root] 는 null, [from] 은 경로 입력에 넣은 주소) · 고치기 (탭 길게 누르기). 저장하면 그 서버로 간다. + Future _editSmb(_Pane pane, String? root, {String? from}) async { + setState(() => _active = _panes.indexOf(pane)); + final old = root == null ? null : SmbRegistry.server(DavPath.parse(root).server); + final saved = await editSmbServer(context, c, old: old, from: from); + if (saved == null || !mounted) return; + for (final x in _panes) { + x.cache.removeWhere((k, _) => isSmb(k) && DavPath.parse(k).server == saved.id); + } + await _goTo(pane, '$smbScheme${saved.id}/'); + } + /// 한 줄. [list]: "폴더 + 파일 목록" 의 오른쪽 목록 (트리 아님). [compact]: 폴더 트리 (열 · 표시 버튼 없이) Widget _rowView(_Pane pane, _Row row, {bool list = false, bool compact = false}) { final e = row.entry; @@ -4967,7 +5016,7 @@ String _decodeSmbPart(String s) { } } -String get smbNotSupported => tr('네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요'); +String get smbNotSupported => tr('네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다'); class _PathDialog extends StatefulWidget { const _PathDialog({required this.initial}); @@ -4976,6 +5025,9 @@ class _PathDialog extends StatefulWidget { /// 창이 이것을 돌려주면 WebDAV 서버 추가 창을 연다 static const addDav = '\u0000webdav'; + /// 88: 이것 + 넣은 주소를 돌려주면 SMB 서버 추가 창 (주소로 채움) + static const addSmb = '\u0000smb:'; + @override State<_PathDialog> createState() => _PathDialogState(); } @@ -5019,8 +5071,11 @@ class _PathDialogState extends State<_PathDialog> { ), actions: [ // 179: SMB 를 넣었으면 바로 WebDAV 서버 추가로 - if (_error == smbNotSupported) + if (_error == smbNotSupported) ...[ TextButton(onPressed: () => Navigator.pop(context, _PathDialog.addDav), child: Text(tr('WebDAV 서버 추가'))), + TextButton( + onPressed: () => Navigator.pop(context, '${_PathDialog.addSmb}${_ctl.text}'), child: Text(tr('SMB 서버 추가'))), + ], TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))), FilledButton(onPressed: _checking ? null : _submit, child: Text(tr('확인'))), ], diff --git a/lib/ui/folder_picker.dart b/lib/ui/folder_picker.dart index 17fd4b5..ef65349 100644 --- a/lib/ui/folder_picker.dart +++ b/lib/ui/folder_picker.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../core/secret_gate.dart'; import '../core/sftp.dart' show SftpRegistry; +import '../core/smb.dart' show SmbRegistry; import '../core/vfs.dart'; import '../core/webdav.dart'; import '../l10n/tr.dart'; @@ -24,6 +25,7 @@ Future pickFolderOrDav(BuildContext context, String title, [String? ini for (final s in DavRegistry.servers) ('$davScheme${s.id}/', s.label, s.url, false), for (final s in SftpRegistry.servers) ('$sftpScheme${s.id}/', s.label, '${s.user.isEmpty ? '' : '${s.user}@'}${s.host}${s.port == 22 ? '' : ':${s.port}'}', true), + for (final s in SmbRegistry.servers) ('$smbScheme${s.id}/', s.label, 'smb://${s.host}/${s.share}', true), ]; if (servers.isEmpty) return pickFolder(context, title, initial); final where = await showDialog( diff --git a/lib/ui/license_texts.dart b/lib/ui/license_texts.dart index 089f028..0084bea 100644 --- a/lib/ui/license_texts.dart +++ b/lib/ui/license_texts.dart @@ -36,6 +36,8 @@ List<(List, String)> get _licenseAssets => [ (['7-Zip 25.01 (7z.exe · 7z.dll)'], 'assets/licenses/7ZIP_LICENSE.txt'), (['7-Zip 25.01 (GNU LGPL 2.1+)'], 'assets/licenses/LGPL-2.1.txt'), (['PDFium'], 'assets/licenses/PDFIUM_LICENSES.txt'), + // 88: Android SMB - smbj (Apache-2.0) · asn-one · Bouncy Castle · SLF4J · MBassador + (['smbj 0.15.0 · asn-one · Bouncy Castle · SLF4J · MBassador (Android SMB)'], 'assets/licenses/SMBJ_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 쪽 패키지 라이선스와 따로) diff --git a/lib/ui/master_prompt.dart b/lib/ui/master_prompt.dart index e4388dc..c48696d 100644 --- a/lib/ui/master_prompt.dart +++ b/lib/ui/master_prompt.dart @@ -142,6 +142,7 @@ Future clearStoredSecrets(AppController c) => c.updateSettings((s) => s ..webdavServers = [for (final x in s.webdavServers) x.copyWith(password: '')] // 88: SFTP 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김) ..sftpServers = [for (final x in s.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')] + ..smbServers = [for (final x in s.smbServers) x.copyWith(password: '')] // AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17) ..aiServices = [for (final x in s.aiServices) x.copyWith(apiKey: '')] ..openSubtitlesKey = '' diff --git a/lib/ui/path_label.dart b/lib/ui/path_label.dart index 891db5c..cd6cb72 100644 --- a/lib/ui/path_label.dart +++ b/lib/ui/path_label.dart @@ -7,7 +7,7 @@ import '../l10n/tr.dart'; String friendlyPath(String path) { if (isDav(path)) { final d = DavPath.parse(path); - return ['${d.isSftp ? '🔑' : '☁'} ${d.serverLabel}', ...d.rel.split('/').where((s) => s.isNotEmpty)].join(' › '); + return ['${d.mark} ${d.serverLabel}', ...d.rel.split('/').where((s) => s.isNotEmpty)].join(' › '); } if (Platform.isAndroid || path.startsWith('/storage/')) { final m = RegExp(r'^/storage/(emulated/\d+|[^/]+)(/.*)?$').firstMatch(path); diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index 85091a7..8315b4a 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -11,7 +11,7 @@ import '../app/app_controller.dart'; import '../core/languages.dart'; import '../core/playlist.dart'; import '../core/srt.dart' show formatSrtTime; -import '../core/vfs.dart' show isSftp; +import '../core/vfs.dart' show noStreamUrl; import '../services/media_player.dart'; import 'app_actions.dart'; import 'audio_now_playing.dart'; @@ -33,7 +33,7 @@ Future playFiles(BuildContext context, AppController c, List files var (list, start) = plan; // 88: SFTP 는 바로 재생 (스트리밍) 할 주소가 없다 - 고른 파일만 받아서 (진행 창 · 취소) 그 파일만 튼다. // 목록 전체를 받지 않게 다음 파일로 넘어가지 않는다 (1차, 사용자 결정 88 설계안) - if (isSftp(list[start])) { + if (noStreamUrl(list[start])) { final remote = list[start]; final local = await fetchDav(context, c, remote); if (local == null || !context.mounted) return; diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index bf7376f..9dd5f2b 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -27,6 +27,7 @@ import 'cef_setup.dart'; import 'cleanup_dialog.dart'; import 'copy_sync_settings.dart'; import 'sftp_settings.dart'; +import 'smb_settings.dart'; import 'webdav_settings.dart'; import 'explorer_look.dart' show ExplorerStyle; import 'theme.dart'; @@ -845,6 +846,8 @@ class _SettingsPageState extends State { WebDavSettings(c: c), // 88: SFTP 서버 (WebDAV 옆 탭) SftpSettings(c: c), + // 88: SMB (Android 만 - Windows 는 운영체제가 \\서버\공유 를 연다) + if (Platform.isAndroid) SmbSettings(c: c), ViewerSettings(c: c), ]), // Rsync 화면: rsync 옵션 · 가져오기 · 실시간 동기화 (lsync) · 백그라운드로 실행 diff --git a/lib/ui/smb_settings.dart b/lib/ui/smb_settings.dart new file mode 100644 index 0000000..cf50899 --- /dev/null +++ b/lib/ui/smb_settings.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller.dart'; +import '../core/secret_gate.dart'; +import '../core/smb.dart'; +import '../l10n/tr.dart'; +import 'confirm.dart'; +import 'setting_tile.dart'; +import 'theme.dart'; +import 'webdav_settings.dart' show davServerUsers; + +/// 88: 환경 설정 > 파일 탐색기 > SMB (Android 만): 서버 목록 (추가 · 고치기 · 지우기 · 연결 확인). +/// Windows 는 운영체제가 \\서버\공유 를 연다 (경로 입력 · 네트워크 드라이브) - 사용자 결정 88-1. +class SmbSettings extends StatelessWidget { + final AppController c; + const SmbSettings({super.key, required this.c}); + + @override + Widget build(BuildContext context) { + final servers = c.settings.smbServers; + return Column(children: [ + SettingTile( + leading: const Icon(Icons.dns_outlined), + title: const Text('SMB'), + subtitle: Text(tr('NAS · PC 의 공유 폴더 (SMB2 · SMB3) 를 파일 탐색기 · Rsync 화면에서 열고 복사 · 동기화합니다 (위쪽 탭). ' + '비밀번호는 이 기기의 안전 저장소에만 둡니다. 보안 문제로 오래된 SMB1 은 쓰지 않습니다.')), + trailing: TextButton.icon( + onPressed: () => editSmbServer(context, c), + icon: const Icon(Icons.add, size: 18), + label: Text(tr('추가')), + ), + ), + for (final s in servers) + Padding( + padding: const EdgeInsets.only(left: 16), + child: SettingTile( + dense: true, + leading: const Icon(Icons.dns, color: JjColors.accent), + title: Text(s.label), + subtitle: Text( + [ + '\\\\${s.host}${s.port == 445 ? '' : ':${s.port}'}\\${s.share}${s.root.isEmpty ? '' : '\\${s.root}'}', + if (s.user.isNotEmpty) s.user else tr('게스트'), + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: tr('고치기'), + icon: const Icon(Icons.edit_outlined, size: 18), + onPressed: () => editSmbServer(context, c, old: s), + ), + IconButton( + tooltip: tr('지우기'), + icon: const Icon(Icons.delete_outline, size: 18, color: JjColors.textDim), + onPressed: () => deleteSmbServer(context, c, s), + ), + ]), + ), + ), + ]); + } +} + +/// 서버 지우기 (쓰는 곳을 알린 뒤 확인). 지웠으면 true +Future deleteSmbServer(BuildContext context, AppController c, SmbServer s) async { + final users = davServerUsers(c.settings, s.id); + final ok = await confirmAction( + context, + title: trf('SMB 서버 "{0}" 을(를) 지울까요?', [s.label]), + body: tr('서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. ' + '이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.') + + (users.isEmpty + ? '' + : '\n\n${trf('이 서버를 쓰는 것 {0}개 (지워도 목록에는 남고, 실행하면 서버가 없다고 알립니다):', [users.length])}\n' + '${users.take(8).map((u) => '• $u').join('\n')}${users.length > 8 ? '\n…' : ''}'), + ok: tr('지우기'), + ); + if (!ok) return false; + await c.updateSettings((x) => x.smbServers = [for (final y in x.smbServers) if (y.id != s.id) y]); + return true; +} + +/// smb://서버/공유/폴더 · \\서버\공유\폴더 → (서버, 공유, 폴더). 아니면 null (경로 입력에서 넣은 것을 서버 추가 창에 채운다) +(String, String, String)? parseSmbAddress(String text) { + final t = text.trim(); + final rest = RegExp(r'^(smb|cifs)://(.+)$', caseSensitive: false).firstMatch(t)?.group(2) ?? (t.startsWith(r'\\') ? t.substring(2) : null); + if (rest == null) return null; + final parts = rest.split(RegExp(r'[/\\]+')).where((s) => s.isNotEmpty).map((s) { + try { + return Uri.decodeComponent(s); + } catch (_) { + return s; + } + }).toList(); + if (parts.isEmpty) return null; + return (parts[0], parts.length > 1 ? parts[1] : '', parts.length > 2 ? parts.sublist(2).join('/') : ''); +} + +/// SMB 서버 추가 · 고치기 창. 저장한 서버를 돌려준다 (취소면 null). [from]: 경로 입력에 넣은 smb:// · \\서버\공유 (채워 둠) +Future editSmbServer(BuildContext context, AppController c, {SmbServer? old, String? from}) async { + if (old != null && old.password.isNotEmpty && !await SecretGate.pass(force: true)) return null; + if (!context.mounted) return null; + final pre = from == null ? null : parseSmbAddress(from); + final name = TextEditingController(text: old?.name ?? ''); + final host = TextEditingController(text: old?.host ?? pre?.$1 ?? ''); + final port = TextEditingController(text: '${old?.port ?? 445}'); + final share = TextEditingController(text: old?.share ?? pre?.$2 ?? ''); + final root = TextEditingController(text: old?.root ?? pre?.$3 ?? ''); + final user = TextEditingController(text: old?.user ?? ''); + final domain = TextEditingController(text: old?.domain ?? ''); + final pass = TextEditingController(text: old?.password ?? ''); + var hide = true; + String? result; + String? warning; + var passed = false; + var testing = false; + + SmbServer current() => SmbServer( + id: old?.id ?? 'smb${DateTime.now().microsecondsSinceEpoch}', + name: name.text.trim(), + host: host.text.trim(), + port: int.tryParse(port.text.trim()) ?? 445, + share: share.text.trim(), + user: user.text.trim(), + domain: domain.text.trim(), + root: root.text.trim(), + password: pass.text, + ); + + final saved = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) { + Future test() async { + FocusScope.of(ctx).unfocus(); + set(() { + testing = true; + result = null; + warning = null; + }); + final client = SmbRemote(current()); + try { + final items = await client.list('/'); + final info = await client.info(); + final (free, _) = await client.quota(); + passed = true; + result = trf('연결됨: 항목 {0}개{1}', [items.length, free == null ? '' : ' · ${tr('남은 용량')} ${(free / (1 << 30)).toStringAsFixed(1)}GB']); + // 88-4: 암호화는 허용 (서버가 되면 SMB3 암호화) - 안 되면 경고 + if (!info.encrypted) { + warning = trf('이 연결은 암호화되지 않습니다 ({0}). 같은 네트워크의 다른 사람이 파일 내용을 볼 수 있으니, ' + 'NAS 설정에서 SMB3 암호화를 켜는 것이 좋습니다', [info.dialect]); + } + } catch (e) { + passed = false; + result = '$e'; + } finally { + client.close(); + } + if (ctx.mounted) set(() => testing = false); + } + + final ok = host.text.trim().isNotEmpty && share.text.trim().isNotEmpty && (int.tryParse(port.text.trim()) ?? 0) > 0; + return AlertDialog( + scrollable: true, + title: Text(old == null ? tr('SMB 서버 추가') : tr('SMB 서버 고치기')), + content: SizedBox( + width: 520, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextField(controller: name, decoration: InputDecoration(labelText: tr('이름 (탭에 보임, 비우면 주소)'))), + Row(children: [ + Expanded( + child: TextField( + controller: host, + keyboardType: TextInputType.url, + decoration: InputDecoration(labelText: tr('서버 주소'), helperText: tr('예: nas.local · 192.168.0.10 · 100.x.y.z (Tailscale)')), + onChanged: (_) => set(() {}), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 80, + child: TextField( + controller: port, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: tr('포트')), + onChanged: (_) => set(() {}), + ), + ), + ]), + TextField( + controller: share, + decoration: InputDecoration(labelText: tr('공유 이름'), helperText: tr('예: video · home (\\\\서버\\공유 의 공유)')), + onChanged: (_) => set(() {}), + ), + TextField(controller: root, decoration: InputDecoration(labelText: tr('시작 폴더 (공유 안, 비우면 맨 위)'))), + TextField(controller: user, decoration: InputDecoration(labelText: tr('아이디 (비우면 게스트)'))), + TextField( + controller: pass, + obscureText: hide, + decoration: InputDecoration( + labelText: tr('비밀번호'), + suffixIcon: IconButton( + tooltip: hide ? tr('보이기') : tr('가리기'), + icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18), + onPressed: () => set(() => hide = !hide), + ), + ), + ), + TextField(controller: domain, decoration: InputDecoration(labelText: tr('도메인 · 작업 그룹 (보통 비움)'))), + const SizedBox(height: 8), + if (testing) const LinearProgressIndicator(), + if (result != null) + Text(result!, style: TextStyle(fontSize: 12, color: passed ? Colors.greenAccent : Colors.redAccent)), + if (warning != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Icon(Icons.warning_amber_rounded, size: 18, color: Colors.orangeAccent), + const SizedBox(width: 6), + Expanded(child: Text(warning!, style: const TextStyle(fontSize: 12, color: Colors.orangeAccent))), + ]), + ), + ]), + ), + actions: [ + if (old != null) + TextButton( + onPressed: () async { + if (await deleteSmbServer(ctx, c, old) && ctx.mounted) Navigator.pop(ctx); + }, + style: TextButton.styleFrom(foregroundColor: Colors.redAccent), + child: Text(tr('지우기')), + ), + TextButton(onPressed: !ok || testing ? null : test, child: Text(tr('연결 확인'))), + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), + FilledButton(onPressed: ok ? () => Navigator.pop(ctx, current()) : null, child: Text(tr('저장'))), + ], + ); + }, + ), + ); + if (saved == null) return null; + await c.updateSettings((x) => x.smbServers = [ + for (final y in x.smbServers) y.id == saved.id ? saved : y, + if (!x.smbServers.any((y) => y.id == saved.id)) saved, + ]); + return saved; +} diff --git a/test/explorer_conflict_test.dart b/test/explorer_conflict_test.dart index b3bae2a..f70edd7 100644 --- a/test/explorer_conflict_test.dart +++ b/test/explorer_conflict_test.dart @@ -454,7 +454,7 @@ void main() { expect(error, contains('형식')); }); - testWidgets('179: Android 에서 SMB 경로를 넣으면 "아직 지원하지 않음 · WebDAV" 안내와 [WebDAV 서버 추가]', (tester) async { + testWidgets('179 · 88: Android 에서 SMB 경로를 넣으면 안내와 [SMB 서버 추가] (넣은 주소로 채움) · [WebDAV 서버 추가]', (tester) async { ExplorerPage.debugAndroidPaths = true; addTearDown(() => ExplorerPage.debugAndroidPaths = null); expect([for (final x in ['smb://nas/v', r'\\nas\v', 'SMB://x', '/storage/x', 'dav://a/']) looksLikeSmb(x)], [true, true, true, false, false]); @@ -469,8 +469,22 @@ void main() { await tester.enterText(find.byType(TextField).last, 'smb://nas/영상'); await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '확인'))); await settle(tester, () => find.textContaining('SMB').evaluate().isNotEmpty); - expect(find.text('네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요'), findsOneWidget); + expect(find.text('네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다'), findsOneWidget); expect(find.text('폴더가 없거나 열 수 없습니다'), findsNothing); + // 88: [SMB 서버 추가] → 넣은 주소 (서버 nas · 공유 영상) 로 채운 서버 추가 창 + await tester.tap(find.widgetWithText(TextButton, 'SMB 서버 추가')); + await settle(tester, () => find.byType(AlertDialog).evaluate().length == 1 && find.text('공유 이름').evaluate().isNotEmpty, rounds: 80); + expect(find.widgetWithText(AlertDialog, 'SMB 서버 추가'), findsOneWidget); + expect(find.widgetWithText(TextField, 'nas'), findsOneWidget); + expect(find.widgetWithText(TextField, '영상'), findsOneWidget); + await tester.tap(find.text('취소').last); + await calm(tester); + // 다시 경로 입력 → [WebDAV 서버 추가] + await tester.tap(find.byTooltip('경로 입력').first); + await calm(tester); + await tester.enterText(find.byType(TextField).last, 'smb://nas/영상'); + await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '확인'))); + await settle(tester, () => find.textContaining('SMB').evaluate().isNotEmpty); // [WebDAV 서버 추가] → 서버 추가 창 await tester.tap(find.widgetWithText(TextButton, 'WebDAV 서버 추가')); await settle(tester, () => find.text('서버 이름').evaluate().isNotEmpty || find.textContaining('주소').evaluate().isNotEmpty, rounds: 50); diff --git a/test/licenses_test.dart b/test/licenses_test.dart index d223549..0f96e5f 100644 --- a/test/licenses_test.dart +++ b/test/licenses_test.dart @@ -109,6 +109,14 @@ void main() { // 88: SFTP (dartssh2, MIT) expect(t, contains('dartssh2 4.1.0')); } + // 88: Android SMB (smbj Apache-2.0 + 함께 쓰는 것) - Android 고지 · 원문 (Apache 전문) + expect(android, contains('smbj 0.15.0')); + final smbj = File('assets/licenses/SMBJ_LICENSES.txt').readAsStringSync(); + for (final s in ['SMBJ Contributors', 'Legion of the Bouncy Castle', 'QOS.ch', 'Benjamin Diedrichsen', 'Apache License', 'TERMS AND CONDITIONS FOR USE']) { + expect(smbj, contains(s), reason: s); + } + expect(File('android/app/build.gradle.kts').readAsStringSync(), contains('com.hierynomus:smbj:0.15.0'), + reason: 'smbj 판이 바뀌면 고지 · 원문도 고친다'); expect(File('pubspec.lock').readAsStringSync(), matches(RegExp(r'\n dartssh2:\r?\n(?: .*\r?\n){1,8}? version: "4\.1\.0"')), reason: 'dartssh2 판이 바뀌면 고지의 판 번호도 고친다'); expect(File('pubspec.lock').readAsStringSync(), matches(RegExp(r'\n archive:\r?\n(?: .*\r?\n){1,8}? version: "4\.3\.0"')), diff --git a/test/smb_test.dart b/test/smb_test.dart new file mode 100644 index 0000000..ff95ae2 --- /dev/null +++ b/test/smb_test.dart @@ -0,0 +1,192 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/smb.dart'; +import 'package:jj_mkvmaker/core/vfs.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; +import 'package:jj_mkvmaker/ui/smb_settings.dart'; +import 'package:path/path.dart' as p; + +/// 88: Android SMB (smbj) 의 Dart 쪽 - 플랫폼 채널을 가짜 서버로 (실제 smbj · 서버 연결은 Android 기기 확인 몫) +class _FakeSmb { + /// 공유 안 경로 ("a\\b") → 내용 (null 이면 폴더) + final files = ?>{}; + final calls = []; + String? failWith; + bool encrypted = true; + final _open = {}; + var _next = 1; + + String _key(Object? path) => '$path'.replaceAll(RegExp(r'^/+'), '').replaceAll('/', r'\'); + + Future handle(MethodCall call) async { + calls.add(call.method); + final a = (call.arguments as Map?) ?? const {}; + if (failWith != null && call.method == 'list') throw PlatformException(code: failWith!, message: 'fake $failWith'); + switch (call.method) { + case 'info': + return {'encrypted': encrypted, 'dialect': 'SMB_3_1_1'}; + case 'list': + final dir = _key(a['path']); + final prefix = dir.isEmpty ? '' : '$dir\\'; + if (dir.isNotEmpty && !files.containsKey(dir)) throw PlatformException(code: 'notFound', message: dir); + return [ + for (final e in files.entries) + if (e.key.startsWith(prefix) && e.key.length > prefix.length && !e.key.substring(prefix.length).contains(r'\')) + {'name': e.key.substring(prefix.length), 'dir': e.value == null, 'size': e.value?.length ?? 0, 'mtime': 1700000000000}, + ]; + case 'stat': + final k = _key(a['path']); + if (k.isEmpty) return {'dir': true, 'size': 0, 'mtime': 0}; + if (!files.containsKey(k)) return null; + return {'dir': files[k] == null, 'size': files[k]?.length ?? 0, 'mtime': 1700000000000}; + case 'mkdir': + files[_key(a['path'])] = null; + return null; + case 'delete': + final k = _key(a['path']); + files.removeWhere((x, _) => x == k || x.startsWith('$k\\')); + return null; + case 'move': + final from = _key(a['path']), to = _key(a['to']); + if (files.containsKey(to) && a['overwrite'] != true) throw PlatformException(code: 'exists', message: to); + files[to] = files.remove(from); + return null; + case 'openRead': + final k = _key(a['path']); + if (!files.containsKey(k)) throw PlatformException(code: 'notFound', message: k); + final h = _next++; + _open[h] = (k, false); + return {'handle': h, 'size': files[k]!.length}; + case 'readChunk': + final (k, _) = _open[a['handle']]!; + final data = files[k]!; + final off = a['offset'] as int; + if (off >= data.length) return Uint8List(0); + return Uint8List.fromList(data.sublist(off, (off + (a['length'] as int)).clamp(0, data.length))); + case 'openWrite': + final k = _key(a['path']); + files[k] = []; + final h = _next++; + _open[h] = (k, true); + return h; + case 'writeChunk': + final (k, _) = _open[a['handle']]!; + final off = a['offset'] as int; + final data = a['data'] as Uint8List; + final cur = files[k]!; + expect(off, cur.length, reason: '조각은 차례로'); + files[k] = [...cur, ...data]; + return null; + case 'closeHandle': + _open.remove(a['handle']); + return null; + case 'quota': + return [5 << 30, 1 << 30]; + case 'close': + return null; + } + throw PlatformException(code: 'other', message: call.method); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late _FakeSmb fake; + setUp(() { + fake = _FakeSmb(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SmbRemote.channel, fake.handle); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(SmbRemote.channel, null); + SmbRegistry.configure(const []); + }); + + const server = SmbServer(id: 'm1', name: 'NAS', host: 'nas', share: 'video', user: 'jj', password: 'pw'); + + test('smb:// 로 폴더 · 쓰기 (1MB 조각) · 읽기 · 목록 · 이름 바꾸기 (덮어쓰기) · 지우기 · 화면 이름', () async { + SmbRegistry.configure([server]); + await vMkdirs('smb://m1/a/b'); + expect(fake.files.keys, containsAll([r'a', r'a\b'])); + final big = List.generate((SmbRemote.chunk * 2.5).toInt(), (i) => i % 251); + await vWrite('smb://m1/a/b/big.bin', Stream.fromIterable([big.sublist(0, 1000), big.sublist(1000)])); + expect(fake.files[r'a\b\big.bin']!.length, big.length); + expect(fake.calls.where((c) => c == 'writeChunk').length, 3, reason: '1MB 조각'); + expect(fake.files.keys.where((k) => k.endsWith('.jjpart')), isEmpty, reason: '.jjpart 로 쓴 뒤 이름 바꿈'); + final back = await (await vOpenRead('smb://m1/a/b/big.bin')).expand((x) => x).toList(); + expect(back, big); + final list = await vList('smb://m1/a/b'); + expect(list.single.path, 'smb://m1/a/b/big.bin'); + expect((list.single.isDir, list.single.size), (false, big.length)); + await vWrite('smb://m1/a/x.txt', Stream.value(utf8.encode('x'))); + await vRename('smb://m1/a/x.txt', 'smb://m1/a/y.txt'); + expect(fake.files.containsKey(r'a\y.txt'), isTrue); + expect(await vStat('smb://m1/a/없음'), isNull); + await vDelete('smb://m1/a'); + expect(fake.files, isEmpty); + expect(vDisplay('smb://m1/a/b'), '🗄 NAS/a/b'); + expect(noStreamUrl('smb://m1/a.mkv'), isTrue, reason: '재생 · 다른 앱은 받아서'); + final (free, used) = await DavPath.parse('smb://m1/').client.quota(); + expect((free, used), (5 << 30, 1 << 30)); + }); + + test('시작 폴더: 공유 안의 그 폴더가 탭의 맨 위', () async { + SmbRegistry.configure([server.copyWith(root: r'\movies\')]); + fake.files + ..['movies'] = null + ..[r'movies\m.mkv'] = [1, 2]; + expect((await vList('smb://m1/')).map((e) => e.path), ['smb://m1/m.mkv']); + }); + + test('실패는 이유를 사람 말로 (SMB1 은 보안 문제로 연결하지 않음) · 저장된 비밀번호 없음은 묻지 않고 알림 · 암호화 여부', () async { + Future err(SmbRemote r) async { + try { + await r.list('/'); + return ''; + } catch (e) { + return '$e'; + } + } + + for (final (code, want) in [ + ('auth', '아이디나 비밀번호가 맞지 않습니다'), + ('unreachable', 'Tailscale'), + ('noShare', '공유 폴더 "video"'), + ('smb1', 'SMB1'), + ('permission', '권한이 없습니다'), + ]) { + fake.failWith = code; + expect(await err(SmbRemote(server)), contains(want), reason: code); + } + fake.failWith = null; + fake.calls.clear(); + expect(await err(SmbRemote(server.copyWith(password: ''))), '저장된 비밀번호가 없습니다'); + expect(fake.calls, isEmpty, reason: '비밀번호가 없으면 서버에 묻지 않는다'); + // 게스트 (아이디 없음) 는 비밀번호 없이 + expect(await err(SmbRemote(server.copyWith(user: '', password: ''))), ''); + fake.encrypted = false; + expect((await SmbRemote(server).info()).encrypted, isFalse); + }); + + test('설정: 비밀번호는 안전 저장소 smb.pw. 에만 · 다시 읽으면 채움', () async { + final tmp = Directory.systemTemp.createTempSync('jj_smb88_set_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final secrets = MemorySecretStore(); + final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + await store.save(AppSettings()..smbServers = [server]); + expect(File(p.join(tmp.path, 'settings.json')).readAsStringSync(), isNot(contains('"pw"'))); + expect(secrets.values['smb.pw.m1'], 'pw'); + final back = (await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load()).smbServers.single; + expect((back.host, back.share, back.user, back.password), ('nas', 'video', 'jj', 'pw')); + }); + + test('경로 입력의 smb:// · \\\\서버\\공유 → 서버 추가 창에 채울 (서버, 공유, 폴더)', () { + expect(parseSmbAddress('smb://nas/video/a%20b/c'), ('nas', 'video', 'a b/c')); + expect(parseSmbAddress(r'\\nas\home'), ('nas', 'home', '')); + expect(parseSmbAddress('CIFS://x'), ('x', '', '')); + expect(parseSmbAddress('/storage/x'), isNull); + }); +}