next4: next2 (6278403) + next3 (63ef362) 합침 - 시험판 977

next4
RyuJongJin 2026-10-19 22:15:47 +09:00
commit 092b68a28f
50 changed files with 1260 additions and 513 deletions

View File

@ -1,5 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<!-- 28 APK 점검 · 관리자 판단 (10/19): Wi-Fi 공유 · SMB 찾기가 망 종류 (Wi-Fi · 유선 / 모바일 데이터 · VPN) 를 알기 위해. 일반 권한 (묻지 않음) -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- 동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위한 저장소 접근 -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
@ -135,13 +137,15 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/jj_file_paths"/>
</provider>
<!-- 작업 알림 서비스 (28 APK 점검 · 관리자 지시 10/19): specialUse 설명은 실제로 하는 일 모두를 적는다
(실시간 동기화 · 동영상 변환 · AI 자막 · 다운로드 · 복사/이동 · 원격 서버 전송 · Wi-Fi 공유) -->
<service
android:name=".KeepAliveService"
android:exported="false"
android:foregroundServiceType="dataSync|specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Keeps folder live sync, video conversion and downloads running while the app is closed (user setting: run in background)"/>
android:value="Keeps user-started work running while the app is in the background (user setting: run in background): real-time folder sync, video to MKV conversion and AI subtitles, downloads, file copy/move, transfers to the user's own SFTP/SMB/FTP/WebDAV servers, and the read-only Wi-Fi file sharing server. A notification shows what is running."/>
</service>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@ -170,6 +170,8 @@ class MainActivity : FlutterActivity() {
startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
result.success(null)
}
// 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다)
"netKinds" -> result.success(netKinds())
"diskSpace" -> result.success(
try {
val st = android.os.StatFs(call.argument<String>("path") ?: "")
@ -619,4 +621,29 @@ class MainActivity : FlutterActivity() {
)
}
}
/// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로)
@Suppress("DEPRECATION")
private fun netKinds(): Map<String, String>? = try {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
val out = HashMap<String, String>()
for (n in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(n) ?: continue
val lp = cm.getLinkProperties(n) ?: continue
val kind = when {
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
else -> "other"
}
for (la in lp.linkAddresses) {
val a = la.address
if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind }
}
}
out
} catch (e: Exception) {
null
}
}

View File

@ -59,11 +59,13 @@
"(덮어씀)": "(overwritten)",
"(동영상을 세 번 누르면 옮깁니다)": "(Tapping a video three times moves it)",
"(링크 · junction - 따라가지 않고 건너뜀)": "(link · junction - not followed, skipped)",
"(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(skipped - the destination has a folder with the same name; the folder is kept)",
"(받는 쪽에만 있음)": "(only on receiving side)",
"(받는 쪽이 더 새것 - 건너뜀)": "(receiving side is newer - skipped)",
"(비어 있음)": "(empty)",
"(설명 없음)": "(no description)",
"(지워짐)": "(deleted)",
"(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(folder and the {0} items inside are deleted and replaced by the file of the same name)",
"(현재 방식은 옵션 없음)": "(no options for the built-in method)",
"* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* matches any text, ? one character. Separate several with ; (e.g. *.srt; *.ass)",
"+ 여기에 새 줄": "+ New line here",
@ -545,6 +547,7 @@
"{0}개 고름": "{0} selected",
"{0}개 더 보기": "Show {0} more",
"{0}개 맞춤": "{0} synced",
"{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "{0} synced · skipped {1} (a folder with the same name exists)",
"{0}개 목록에서 숨기기": "Hide {0} items from list",
"{0}개 보임": "{0} shown",
"{0}개 삭제": "Delete {0} items",
@ -656,6 +659,8 @@
"같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.",
"같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "Can't create the folder because a file with the same name exists: {0}",
"같은 이름의 폴더가 있어 건너뜀 {0}": "Skipped {0} (a folder with the same name exists)",
"같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "Skipped {0} because a folder with the same name exists: {1}",
"같은 이름이 이미 있습니다": "An item with the same name already exists",
"같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}",
"같은 이름이 이미 있음": "Name already exists",
@ -907,6 +912,7 @@
"네트워크 공유 (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).",
"네트워크 설정 열기": "Open network settings",
"네팔어": "Nepali",
"노랑": "Yellow",
"노르웨이어": "Norwegian",
@ -926,6 +932,7 @@
"다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "{0} is already in the MKV list, so it was removed from the download list (the file is kept)",
"다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "Added {0} to the MKV list and removed it from the download list (the file is kept)",
"다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "It may have been deleted or moved elsewhere. Try the parent folder.",
"다른 기기에서 열리지 않으면 (Windows 방화벽)": "If other devices can't open it (Windows Firewall)",
"다른 기기의 브라우저에서 이 주소를 여세요": "Open this address in a browser on the other device",
"다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "You came back to v{1} after using another version ({0}). That version may not know some of this version's settings.\nRestore the settings kept when you last used v{1} (including the video list and bookmarks)?",
"다른 버전 고르기": "Choose another version",
@ -1217,6 +1224,7 @@
"목록": "list",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "Hides ad areas in lists · next to · on top of videos",
"목록 보기": "View list",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "If JJ_MKVMaker is not in the list, use [Allow another app] to pick JJ_MKVMaker.exe and check \"Private\". Turn sharing off and on again afterwards.",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "Tap a video in the list to see its details here",
"목록에서 빼기": "Remove from list",
"목록에서 빼기 (파일은 그대로)": "Remove from list (file is kept)",
@ -1333,6 +1341,8 @@
"밤 0~6시": "Night 0-6",
"방법: {0}": "Method: {0}",
"방식": "Type",
"방화벽 허용 앱 열기": "Open allowed apps",
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "Allow this app in the firewall: Control Panel > Windows Defender Firewall > Allow an app or feature through Windows Defender Firewall > [Change settings] > check \"Private\" for JJ_MKVMaker > [OK].",
"배율": "Scale",
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "Background: hides only the window and keeps downloading · converting. Reopen with {0} or the tray icon.\nTo quit completely: right-click the tray icon > Quit (always quits whatever the setting)",
"백그라운드로": "To background",
@ -1978,6 +1988,7 @@
"음성인식 중 {0}%": "Recognizing speech {0}%",
"음소거 (M)": "Mute (M)",
"음악": "Music",
"이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "Check that this PC's network is a \"Private network\": Settings > Network & internet > Wi-Fi (or Ethernet) > the connected network > Network profile type = Private. Only use Private on home or trusted networks.",
"이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "This PC's secure storage could not be decrypted. Please enter your WebDAV and OpenSubtitles passwords once more. The file that could not be decrypted was kept, not deleted: {0}",
"이 WebDAV 서버는 로그인이 필요합니다": "This WebDAV server requires sign-in",
"이 {0}의 설정 파일에 저장됩니다": "Saved in this {0}'s settings file",
@ -2530,6 +2541,7 @@
"컴포넌트 목록을 받을 수 없습니다 ({0})": "Could not get the component list ({0})",
"켜기": "On",
"켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "Turning it on downloads Tor Expert Bundle {0} (tor {1}) from the official Tor distribution (about {2} MB, SHA-256 verified). Turning it off deletes it.",
"켜면 Windows 방화벽 창이 뜰 수 있습니다: \"개인 네트워크\" 에 체크하고 [허용] 을 누르세요. \"공용 네트워크\" 는 체크하지 않는 것이 안전합니다.": "When you turn it on, a Windows Firewall window may appear: check \"Private networks\" and click [Allow]. It is safer not to check \"Public networks\".",
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "When on, double-tapping shows the images inside right away. When off, opens the file list (extract selected).",
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "Turning it on downloads and installs a converter (about 420MB). Turning it off deletes it.",
"켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "When on, a sort changed from a column header or Sort by applies to that folder only. When off, all folders share one sort",
@ -2798,6 +2810,7 @@
"환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "As set in Settings, it {0} (Settings > File explorer > When a file with the same name exists)",
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "Enter the master password to open settings. It won't be asked again until the app is closed.",
"환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)": "{0} from Settings is not used when copying (to move, use [Move])",
"회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.": "On work or school PCs, an administrator may have locked the firewall so you cannot change it.",
"회전": "Rotation",
"회전 없음": "No rotation",
"회차": "Episode",

View File

@ -59,11 +59,13 @@
"(덮어씀)": "(上書き)",
"(동영상을 세 번 누르면 옮깁니다)": "(動画を 3 回タップすると移動します)",
"(링크 · junction - 따라가지 않고 건너뜀)": "(リンク · ジャンクション - たどらずにスキップ)",
"(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(受け取る側に同名のフォルダーがあるためスキップ - フォルダーはそのまま)",
"(받는 쪽에만 있음)": "(受け取る側にだけある)",
"(받는 쪽이 더 새것 - 건너뜀)": "(受け取る側が新しい - スキップ)",
"(비어 있음)": "(空)",
"(설명 없음)": "(説明なし)",
"(지워짐)": "(削除)",
"(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(フォルダーと中の {0} 件を削除して同名のファイルに)",
"(현재 방식은 옵션 없음)": "(現在の方式にはオプションなし)",
"* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* は任意の文字列、? は 1 文字。複数は ; で区切る (例: *.srt; *.ass)",
"+ 여기에 새 줄": "+ ここに新しい行",
@ -545,6 +547,7 @@
"{0}개 고름": "{0} 件選択",
"{0}개 더 보기": "さらに {0} 件表示",
"{0}개 맞춤": "{0} 件を同期",
"{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "{0} 件同期 · 同名のフォルダーがあるため {1} 件スキップ",
"{0}개 목록에서 숨기기": "{0} 件をリストから隠す",
"{0}개 보임": "{0} 件表示",
"{0}개 삭제": "{0} 件を削除",
@ -656,6 +659,8 @@
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。",
"같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}",
"같은 이름의 폴더가 있어 건너뜀 {0}": "同名のフォルダーがあるためスキップ {0}",
"같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "同名のフォルダーがあるため {0} 件スキップ: {1}",
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
"같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}",
"같은 이름이 이미 있음": "同じ名前が既にあります",
@ -907,6 +912,7 @@
"네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다": "ネットワーク共有 (SMB) はパスでは直接開きません。[SMB サーバーを追加] でサーバーを登録するとタブで開けます",
"네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "ネットワーク共有 (SMB) にはまだ対応していません。WebDAV タブを使ってください",
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "ネットワーク共有は共有フォルダー名まで入力してください (例: \\\\NAS\\動画)。",
"네트워크 설정 열기": "ネットワーク設定を開く",
"네팔어": "ネパール語",
"노랑": "黄色",
"노르웨이어": "ノルウェー語",
@ -926,6 +932,7 @@
"다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "ダウンロードした {0} はすでに MKV 一覧にあるため、ダウンロード一覧から外しました (ファイルはそのまま)",
"다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "ダウンロードした {0} を MKV 一覧に入れ、ダウンロード一覧から外しました (ファイルはそのまま)",
"다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "別の場所で削除・移動された可能性があります。上のフォルダーへ移動してみてください。",
"다른 기기에서 열리지 않으면 (Windows 방화벽)": "他の端末で開けない場合 (Windows ファイアウォール)",
"다른 기기의 브라우저에서 이 주소를 여세요": "もう一方の端末のブラウザーでこのアドレスを開いてください",
"다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "別のバージョン ({0}) を使ったあと v{1} に戻りました。そのバージョンはこのバージョンの設定の一部を知らない可能性があります。\nv{1} を最後に使ったときに保管した設定 (動画リスト · ブックマークを含む) に戻しますか?",
"다른 버전 고르기": "別のバージョンを選ぶ",
@ -1217,6 +1224,7 @@
"목록": "一覧",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "リスト・動画の横・動画の上に出る広告エリアを隠します",
"목록 보기": "リストを見る",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "一覧に JJ_MKVMaker がなければ [別のアプリの許可] で JJ_MKVMaker.exe を選び「プライベート」にチェックします。変更後は共有をオフにして再びオンにしてください。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "リストの動画をタップすると、ここに詳細が表示されます",
"목록에서 빼기": "リストから外す",
"목록에서 빼기 (파일은 그대로)": "リストから外す (ファイルはそのまま)",
@ -1333,6 +1341,8 @@
"밤 0~6시": "夜 0~6 時",
"방법: {0}": "方法: {0}",
"방식": "方式",
"방화벽 허용 앱 열기": "許可されたアプリを開く",
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "ファイアウォールでこのアプリを許可します: コントロール パネル > Windows Defender ファイアウォール > Windows Defender ファイアウォールを介したアプリまたは機能を許可 > [設定の変更] > JJ_MKVMaker の「プライベート」にチェック > [OK]。",
"배율": "倍率",
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "バックグラウンド: ウィンドウだけ隠してダウンロード・変換は続けます。{0} またはトレイアイコンで再表示します。\n完全に終了するには トレイアイコンを右クリック > 終了(どの設定でも常に終了)",
"백그라운드로": "バックグラウンドへ",
@ -1978,6 +1988,7 @@
"음성인식 중 {0}%": "音声認識中 {0}%",
"음소거 (M)": "ミュート(M)",
"음악": "音楽",
"이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "この PC が接続しているネットワークが「プライベート ネットワーク」か確認します: 設定 > ネットワークとインターネット > Wi-Fi (またはイーサネット) > 接続中のネットワーク > ネットワーク プロファイルの種類 = プライベート。自宅や信頼できるネットワークでのみプライベートにしてください。",
"이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "この PC の安全な保存領域を復号できませんでした。WebDAV・OpenSubtitles のパスワードをもう一度だけ入力してください。復号できなかったファイルは削除せず保管しました: {0}",
"이 WebDAV 서버는 로그인이 필요합니다": "この WebDAV サーバーはログインが必要です",
"이 {0}의 설정 파일에 저장됩니다": "この{0}の設定ファイルに保存されます",
@ -2530,6 +2541,7 @@
"컴포넌트 목록을 받을 수 없습니다 ({0})": "コンポーネント一覧を取得できません ({0})",
"켜기": "オン",
"켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "オンにすると Tor 公式配布元から Tor Expert Bundle {0} (tor {1}) をダウンロードします (約 {2}MB、SHA-256 確認)。オフにすると削除します。",
"켜면 Windows 방화벽 창이 뜰 수 있습니다: \"개인 네트워크\" 에 체크하고 [허용] 을 누르세요. \"공용 네트워크\" 는 체크하지 않는 것이 안전합니다.": "オンにすると Windows ファイアウォールの画面が出ることがあります:「プライベート ネットワーク」にチェックして [許可] を押してください。「パブリック ネットワーク」はチェックしない方が安全です。",
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "オンにするとダブルタップで中の画像をすぐめくって表示します。オフにすると一覧 (選んで展開) を開きます。",
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "オンにすると変換ツールをダウンロードしてインストールします (約 420MB)。オフにすると削除します。",
"켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "オンにすると、列見出し・並べ替えで変えた並べ替えはそのフォルダーだけに使われます。オフではすべてのフォルダーが同じ並べ替えです",
@ -2798,6 +2810,7 @@
"환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "設定どおり{0} (環境設定 > ファイルエクスプローラー > 同じ名前があるとき)",
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "環境設定を開くにはマスターパスワードを入力してください。アプリを終了するまで再度尋ねません。",
"환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)": "環境設定の {0} はコピーでは使いません (移動は [移動] で)",
"회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.": "会社・学校の PC は管理者がファイアウォールを固定していて変更できないことがあります。",
"회전": "回転",
"회전 없음": "回転なし",
"회차": "話数",

View File

@ -59,11 +59,13 @@
"(덮어씀)": "(覆盖)",
"(동영상을 세 번 누르면 옮깁니다)": "(连按视频三次会移动它)",
"(링크 · junction - 따라가지 않고 건너뜀)": "(链接 · 联接 - 不跟随,已跳过)",
"(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)": "(已跳过 - 目标有同名文件夹,文件夹保持不变)",
"(받는 쪽에만 있음)": "(仅在接收方)",
"(받는 쪽이 더 새것 - 건너뜀)": "(接收端较新 - 已跳过)",
"(비어 있음)": "(空)",
"(설명 없음)": "(无说明)",
"(지워짐)": "(将删除)",
"(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)": "(删除文件夹及其中 {0} 项,换成同名文件)",
"(현재 방식은 옵션 없음)": "(当前方式没有选项)",
"* 는 아무 글자, ? 는 한 글자. 여러 개는 ; 로 (예: *.srt; *.ass)": "* 表示任意字符,? 表示一个字符。多个用 ; 分隔(例:*.srt; *.ass)",
"+ 여기에 새 줄": "+ 在此新增一行",
@ -545,6 +547,7 @@
"{0}개 고름": "已选 {0} 个",
"{0}개 더 보기": "再显示 {0} 个",
"{0}개 맞춤": "已同步 {0} 个",
"{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개": "已同步 {0} 项 · 因同名文件夹跳过 {1} 项",
"{0}개 목록에서 숨기기": "从列表中隐藏 {0} 项",
"{0}개 보임": "显示 {0} 项",
"{0}개 삭제": "删除 {0} 项",
@ -656,6 +659,8 @@
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。",
"같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}",
"같은 이름의 폴더가 있어 건너뜀 {0}": "因存在同名文件夹而跳过 {0}",
"같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "因存在同名文件夹跳过 {0} 项: {1}",
"같은 이름이 이미 있습니다": "已存在同名项目",
"같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}",
"같은 이름이 이미 있음": "同名项已存在",
@ -907,6 +912,7 @@
"네트워크 공유 (SMB) 는 경로로 바로 열지 않습니다. [SMB 서버 추가] 로 서버를 넣으면 탭으로 열립니다": "网络共享 (SMB) 不能直接按路径打开。用 [添加 SMB 服务器] 添加服务器后即可在标签中打开",
"네트워크 공유 (SMB) 는 아직 지원하지 않습니다. WebDAV 탭을 쓰세요": "尚不支持网络共享(SMB)。请使用 WebDAV 标签",
"네트워크 공유는 공유 폴더 이름까지 넣으세요 (예: \\\\NAS\\영상).": "网络共享请输入到共享文件夹名称 (例: \\\\NAS\\视频)。",
"네트워크 설정 열기": "打开网络设置",
"네팔어": "尼泊尔语",
"노랑": "黄色",
"노르웨이어": "挪威语",
@ -926,6 +932,7 @@
"다 받은 {0} 은 이미 MKV 목록에 있어 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "下载完成的 {0} 已在 MKV 列表中,已从下载列表移除(文件保留)",
"다 받은 {0} 을 MKV 목록에 넣고 다운로드 목록에서 뺐습니다 (받은 파일은 그대로)": "已将下载完成的 {0} 加入 MKV 列表并从下载列表移除(文件保留)",
"다른 곳에서 지웠거나 옮겼을 수 있습니다. 위 폴더로 가 보세요.": "可能已在别处被删除或移动。请返回上级文件夹。",
"다른 기기에서 열리지 않으면 (Windows 방화벽)": "其他设备无法打开时 (Windows 防火墙)",
"다른 기기의 브라우저에서 이 주소를 여세요": "在另一台设备的浏览器中打开此地址",
"다른 버전 ({0}) 을 쓰다가 v{1} 로 돌아왔습니다. 그 버전은 이 버전의 설정 일부를 모를 수 있습니다.\nv{1} 를 마지막으로 쓸 때 보관해 둔 설정 (동영상 목록 · 즐겨찾기 포함) 으로 되돌릴까요?": "使用其他版本 ({0}) 后回到了 v{1}。该版本可能不认识此版本的部分设置。\n要恢复最后一次使用 v{1} 时保存的设置 (包括视频列表 · 书签) 吗?",
"다른 버전 고르기": "选择其他版本",
@ -1217,6 +1224,7 @@
"목록": "列表",
"목록 · 영상 옆 · 영상 위에 나오는 광고 영역을 감춥니다": "隐藏列表 · 视频旁 · 视频上方出现的广告区域",
"목록 보기": "查看列表",
"목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 \"개인\" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.": "如果列表中没有 JJ_MKVMaker,请用 [允许其他应用] 选择 JJ_MKVMaker.exe 并勾选“专用”。更改后请关闭共享再重新开启。",
"목록에서 동영상을 누르면 여기에 자세히 보입니다": "点按列表中的视频即可在此查看详情",
"목록에서 빼기": "从列表中移除",
"목록에서 빼기 (파일은 그대로)": "从列表中移除 (保留文件)",
@ -1333,6 +1341,8 @@
"밤 0~6시": "夜间 0~6 点",
"방법: {0}": "方式: {0}",
"방식": "方式",
"방화벽 허용 앱 열기": "打开允许的应用",
"방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 \"개인\" 에 체크 > [확인].": "在防火墙中允许此应用:控制面板 > Windows Defender 防火墙 > 允许应用或功能通过 Windows Defender 防火墙 > [更改设置] > 勾选 JJ_MKVMaker 的“专用” > [确定]。",
"배율": "倍率",
"백그라운드: 창만 숨기고 다운로드 · 변환은 계속합니다. {0} 또는 트레이 아이콘으로 다시 엽니다.\n완전히 끝내려면 트레이 아이콘 오른쪽 클릭 > 종료 (어느 설정이든 항상 종료)": "后台: 仅隐藏窗口,下载 · 转换继续进行。用 {0} 或托盘图标重新打开。\n要完全退出: 右键托盘图标 > 退出(无论何种设置都会退出)",
"백그라운드로": "转到后台",
@ -1978,6 +1988,7 @@
"음성인식 중 {0}%": "正在识别语音 {0}%",
"음소거 (M)": "静音(M)",
"음악": "音乐",
"이 PC 가 연결한 망이 \"개인 네트워크\" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.": "确认这台电脑连接的网络是“专用网络”:设置 > 网络和 Internet > WLAN (或以太网) > 已连接的网络 > 网络配置文件类型 = 专用。仅在家庭或可信网络中设为专用。",
"이 PC 의 안전 저장소를 풀 수 없었습니다. WebDAV · OpenSubtitles 비밀번호를 한 번만 다시 넣어 주세요. 풀지 못한 파일은 지우지 않고 보관했습니다: {0}": "无法解密此电脑的安全存储。请再输入一次 WebDAV · OpenSubtitles 密码。无法解密的文件未删除,已保留: {0}",
"이 WebDAV 서버는 로그인이 필요합니다": "此 WebDAV 服务器需要登录",
"이 {0}의 설정 파일에 저장됩니다": "保存在此{0}的设置文件中",
@ -2530,6 +2541,7 @@
"컴포넌트 목록을 받을 수 없습니다 ({0})": "无法获取组件列表({0})",
"켜기": "开启",
"켜면 Tor 공식 배포처에서 Tor Expert Bundle {0} (tor {1}) 을 내려받습니다 (약 {2}MB, SHA-256 확인). 끄면 지웁니다.": "开启后从 Tor 官方发布处下载 Tor Expert Bundle {0} (tor {1}) (约 {2}MB,校验 SHA-256)。关闭时删除。",
"켜면 Windows 방화벽 창이 뜰 수 있습니다: \"개인 네트워크\" 에 체크하고 [허용] 을 누르세요. \"공용 네트워크\" 는 체크하지 않는 것이 안전합니다.": "开启时可能会出现 Windows 防火墙窗口:勾选“专用网络”并点击 [允许]。不勾选“公用网络”更安全。",
"켜면 두 번 눌렀을 때 안의 그림을 바로 넘겨 봅니다. 끄면 목록 (골라서 풀기) 을 엽니다.": "开启后双击会直接翻看其中的图片;关闭则打开文件列表 (选择解压)。",
"켜면 변환기를 내려받아 설치합니다 (약 420MB). 끄면 지웁니다.": "开启时会下载并安装转换器 (约 420MB)。关闭时会删除。",
"켜면 열 머리 · 정렬 기준에서 바꾼 정렬이 그 폴더에만 쓰입니다. 끄면 모든 폴더가 같은 정렬입니다": "开启后,通过列标题或排序方式更改的排序只用于该文件夹。关闭时所有文件夹使用同一排序",
@ -2798,6 +2810,7 @@
"환경 설정대로 {0} (환경 설정 > 파일 탐색기 > 같은 이름이 있을 때)": "按设置{0} (设置 > 文件资源管理器 > 存在同名项目时)",
"환경 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "要打开设置,请输入主密码。在关闭应用前不会再次询问。",
"환경 설정의 {0} 는 복사에서는 쓰지 않습니다 (이동은 [이동] 으로)": "设置中的 {0} 不用于复制(要移动请用 [移动])",
"회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.": "公司或学校的电脑可能由管理员锁定了防火墙,无法更改。",
"회전": "旋转",
"회전 없음": "不旋转",
"회차": "集数",

View File

@ -7,6 +7,7 @@ import 'package:jj_mkvmaker/core/languages.dart';
import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 Whisper + NLLB 로 AI 자막 → MKV (모델이 JJ_MKVMAKER_MODELS 에 있어야 함)
@ -16,7 +17,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('영어 음성 영상 → AI 자막(ko/en/ja) → MKV', (tester) async {
final dir = Directory.systemTemp.createTempSync('jj_e2e_');
final dir = testTempDir('jj_e2e_');
final video = p.join(dir.path, '영어 강의.mp4');
final speech = p.join(Directory.current.path, 'test_assets', 'speech_en.wav');
final r = await Process.run('ffmpeg', [

View File

@ -10,6 +10,7 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 Edge(WebView2): MKV 화면 ↔ 브라우저를 여러 번 오가도 보던 페이지를 다시 읽지 않고 그대로 이어진다
@ -27,7 +28,7 @@ void main() {
await req.response.close();
});
final url = 'http://127.0.0.1:${server.port}/page';
final dataDir = Directory.systemTemp.createTempSync('jj_keep_').path;
final dataDir = testTempDir('jj_keep_').path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings

View File

@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/platform/windows/ytdlp_backend.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 Edge(WebView2): 페이지 열기 · 동영상 감지 · 쿠키 저장 → yt-dlp 가 같은 쿠키를 읽는지
@ -31,7 +32,7 @@ void main() {
await req.response.close();
});
final url = 'http://127.0.0.1:${server.port}/';
final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
final dataDir = testTempDir('jj_webview_').path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.webViewDataDir = dataDir;

View File

@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 망 (Windows): DNS 강제 (8.8.8.8) 상태에서 Chrome 엔진 (CEF) 이 앱 안 프록시의 인증 (비밀값) 에 답해 페이지를 여는지.
@ -33,7 +34,7 @@ void main() {
}
CefRuntime.rememberStartState();
expect(CefRuntime.readyThisRun, isTrue);
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_cef_real_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_cef_real_').path;
Directory(data).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings

View File

@ -1,11 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/platform/windows/com_guard.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'support/temp_dir.dart';
/// 실제 Edge(WebView2): 다른 구성 요소가 COM 준비 상태를 풀어 버린 상황을 만들고
/// (브라우저가 "CoInitialize 가 호출되지 않았습니다" 로 안 뜨던 문제) 그래도 브라우저 환경이 만들어지는지
@ -26,7 +25,7 @@ void main() {
for (var i = 0; i < 8; i++) {
ComGuard.releaseForTest();
}
final dir = Directory.systemTemp.createTempSync('jj_com_').path;
final dir = testTempDir('jj_com_').path;
final raw = await tester.runAsync<Object?>(() async {
try {
await WebViewEnvironment.create(settings: WebViewEnvironmentSettings(userDataFolder: '${dir}_raw'));

View File

@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 250 실제 엔진 (Windows Edge WebView2): DNS 강제 서버에 닿지 않으면 (TEST-NET 192.0.2.1) 엔진의 오류 화면
@ -20,7 +21,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('250: 닿지 않는 DNS → 브라우저에 앱 덮개 (Edge 실제)', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_dns_block_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_dns_block_').path;
Directory(data).createSync(recursive: true);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings

View File

@ -9,6 +9,7 @@ import 'package:jj_mkvmaker/platform/windows/desktop_shell.dart';
import 'package:jj_mkvmaker/services/media_player.dart';
import 'package:path/path.dart' as p;
import 'package:window_manager/window_manager.dart';
import 'support/temp_dir.dart';
/// 실제 mpv 플레이어 + 창 모드
void main() {
@ -16,7 +17,7 @@ void main() {
testWidgets('재생 목록 · 자막 트랙 · 음량 · 전체 화면 · 팝업 · 창 크기', (tester) async {
MediaKitPreviewPlayer.ensureInitialized();
final dir = Directory.systemTemp.createTempSync('jj_fp_');
final dir = testTempDir('jj_fp_');
final srt = p.join(dir.path, 's.srt');
File(srt).writeAsStringSync('1\n00:00:00,000 --> 00:00:05,000\n내장 자막\n');
final ext = p.join(dir.path, 'ep1_ko.srt');

View File

@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:jj_mkvmaker/ui/schedule_editor.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 모니터링 화면 (복사 진행 중 · lsync · 일정 격자) 을 실제 글꼴로 그려 PNG 로 (JJ_SHOT_DIR). 화면 배치 확인용.
@ -21,7 +22,7 @@ void main() {
testWidgets('모니터링 화면 그림', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_monshot_');
final tmp = testTempDir('jj_monshot_');
final src = p.join(tmp.path, '영상 원본'), dst = p.join(tmp.path, '백업');
for (var i = 0; i < 6; i++) {
File(p.join(src, i < 3 ? 'A' : 'B', 'clip$i.mkv'))

View File

@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/core/reader_sources.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 pdfium: 그림 → PDF, 열기 · 그리기, 페이지 지우기 · 그림 넣기 · 저장
@ -12,8 +13,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('PDF: 만들기 · 열기 · 지우기 · 넣기 · 저장', (tester) async {
final dir = Directory.systemTemp.createTempSync('jj_pdf_');
addTearDown(() => dir.deleteSync(recursive: true));
final dir = testTempDir('jj_pdf_');
String image(String name, int w, int h, {bool jpg = false}) {
final im = img.Image(width: w, height: h);
img.fill(im, color: img.ColorRgb8(200, 50, 50));

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/platform/common/media_kit_player.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 Windows 앱에서 libmpv 재생 확인
@ -14,7 +15,7 @@ void main() {
testWidgets('media_kit: 영상 열기 · 재생 · 이동', (tester) async {
MediaKitPreviewPlayer.ensureInitialized();
final dir = Directory.systemTemp.createTempSync('jj_play_');
final dir = testTempDir('jj_play_');
final video = p.join(dir.path, '재생 테스트.mp4');
final r = await Process.run('ffmpeg', [
'-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=25',

View File

@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/monitor_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// Rsync 화면을 실제 rsync (앱에 들어 있는 것) 로: → 한 방향, ⇄ 양쪽 (-u), 모니터링에 남는지. 화면은 JJ_SHOT_DIR 에 PNG.
@ -20,7 +21,7 @@ void main() {
testWidgets('Rsync 화면: → · ⇄ 실제 실행', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_rsyncpage_');
final tmp = testTempDir('jj_rsyncpage_');
final a = p.join(tmp.path, '왼쪽 폴더'), b = p.join(tmp.path, 'right');
File(p.join(a, 'sub', '한글 파일.txt'))
..createSync(recursive: true)
@ -178,7 +179,7 @@ void main() {
testWidgets('원본 파일 지우기 (--remove-source-files) · 빈 폴더 정리 · lsync 최종 정리', (tester) async {
final out = Platform.environment['JJ_SHOT_DIR'] ?? Directory.systemTemp.path;
final tmp = Directory.systemTemp.createTempSync('jj_rsyncmove_');
final tmp = testTempDir('jj_rsyncmove_');
final a = p.join(tmp.path, 'srcA'), b = p.join(tmp.path, 'dstB');
final cDir = p.join(tmp.path, 'liveC'), d = p.join(tmp.path, 'liveD');
for (final (dir, name) in [(a, 'x'), (cDir, 'y')]) {

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'support/temp_dir.dart';
/// 54: 이 기기의 안전 저장소 (Windows DPAPI · Android Keystore) 에 실제로 쓰고 읽고 지운다.
/// 시험용 열쇠 하나만 쓰고 끝에 지운다 (사용자가 저장한 값은 건드리지 않음).
@ -13,7 +14,7 @@ void main() {
Directory? tmp;
SecretStore store() {
if (!Platform.isWindows) return SecretStore.platform();
return SecretStore.dpapiAt((tmp ??= Directory.systemTemp.createTempSync('jj_secretit_')).path);
return SecretStore.dpapiAt((tmp ??= testTempDir('jj_secretit_')).path);
}
tearDownAll(() {

View File

@ -0,0 +1,29 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// 관리자 지시 (10/19, 28 이 찾음): 통합 시험 · 캡처가 %TEMP%\jj_* 시험 폴더를 남겼다 - 시험 끝 (실패해도) 에 지운다.
/// [base] 가 있으면 그 아래 (짧은 경로가 필요한 캡처). 반드시 시험 (testWidgets · test) 안에서 부른다 (addTearDown).
Directory testTempDir(String prefix, {String? base}) {
final root = base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true));
final dir = root.createTempSync(prefix);
addTearDown(() => deleteTestTemp(dir));
return dir;
}
/// 시험 폴더 지우기 - Windows 에서 앱 · WebView · 백신이 파일을 잠깐 잡고 있으면 몇 번 다시 해 본다 (기다림은 실제 시간: 가짜 시계 밖).
/// 긴 경로는 \\?\ 로. 끝내 못 지우면 알리기만 하고 시험은 실패시키지 않는다 (tool/test_temp_check.ps1 이 남은 것을 잡는다)
void deleteTestTemp(Directory dir) {
final path = dir.absolute.path;
final target = Platform.isWindows && !path.startsWith(r'\\?\') ? Directory('\\\\?\\$path') : dir;
for (var i = 0; i < 20; i++) {
try {
if (!target.existsSync()) return;
target.deleteSync(recursive: true);
return;
} on FileSystemException {
sleep(const Duration(milliseconds: 250));
}
}
stderr.writeln('시험 임시 폴더를 지우지 못함: $path');
}

View File

@ -17,6 +17,7 @@ import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:jj_mkvmaker/ui/tor_page.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 망 (Windows): DNS 강제 (8.8.8.8) 로 앱의 HTTP · 일반 브라우저 (Edge) 가 이름을 푸는지, 닿지 않는 DNS 는 실패로 알리는지,
@ -26,7 +27,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('DNS 강제 · Tor 화면 실제 연결', (tester) async {
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? Directory.systemTemp.createTempSync('jj_tor_real_').path;
final data = Platform.environment['JJ_MKVMAKER_DATA'] ?? testTempDir('jj_tor_real_').path;
Directory(data).createSync(recursive: true);
ComponentStore.dataDirectory = data;
ComponentStore.shared = ComponentStore(data);

View File

@ -11,6 +11,7 @@ import 'package:jj_mkvmaker/core/models.dart';
import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 NLLB + 실제 Edge(WebView2): 브라우저를 보면서 영어 자막 → 한국어 번역 → MKV 를
@ -19,7 +20,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('브라우저 + 작업 현황: 영어 자막 → 한국어 번역 → MKV (대기열)', (tester) async {
final dir = Directory.systemTemp.createTempSync('jj_split_');
final dir = testTempDir('jj_split_');
final video = p.join(dir.path, 'movie.mp4');
final r = await Process.run('ffmpeg', [
'-y', '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', '-f', 'lavfi', '-i', 'sine',
@ -42,7 +43,7 @@ void main() {
await c.init();
await c.addVideos([video]);
});
c.settings.webViewDataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
c.settings.webViewDataDir = testTempDir('jj_webview_').path;
final v = c.videos.single;
// 같은 폴더의 movie.en.srt 가 자동으로 잡힘
final s = v.subtitles.firstWhere((x) => x.path != null && p.equals(x.path!, en));

View File

@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/core/output_paths.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:jj_mkvmaker/ui/video_adjust_dialog.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 FFmpeg: 가로 영상 → 화면 · 색 보정 창 미리보기 → 세로 9:16 + 채도 MKV 만들기
@ -19,7 +20,7 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('화면 · 색 보정: 미리보기와 세로 9:16 MKV', (tester) async {
final dir = Directory.systemTemp.createTempSync('jj_adj_e2e_');
final dir = testTempDir('jj_adj_e2e_');
final video = p.join(dir.path, '가로 영상.mp4');
final ffmpeg = p.join(Directory.current.path, 'third_party', 'ffmpeg', 'windows', 'ffmpeg.exe');
final r = await Process.run(ffmpeg, [

View File

@ -10,6 +10,7 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 실제 웹뷰 (Windows: Edge WebView2 · Android: 시스템 WebView) + 실제 Google 번역:
@ -50,7 +51,7 @@ void main() {
await req.response.close();
});
final url = 'http://127.0.0.1:${server.port}/';
final dataDir = Directory.systemTemp.createTempSync('jj_webview_').path;
final dataDir = testTempDir('jj_webview_').path;
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings

View File

@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:whisper_ggml/whisper_ggml.dart';
import 'support/temp_dir.dart';
/// whisper.cpp 동작 확인 (스마트 앱 컨트롤·속도)
void main() {
@ -14,7 +15,7 @@ void main() {
'whisper', 'ggml-base.bin');
final assets = p.join(Directory.current.path, 'test_assets');
for (final name in ['speech_en.wav', 'speech_ko.wav']) {
final tmp = Directory.systemTemp.createTempSync('jj_w_');
final tmp = testTempDir('jj_w_');
final wav = File(p.join(assets, name)).copySync(p.join(tmp.path, name)).path;
final sw = Stopwatch()..start();
final r = await tester.runAsync(() => const Whisper(model: WhisperModel.base).transcribe(

View File

@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/ui/app_actions.dart';
import 'package:jj_mkvmaker/ui/browser_page.dart';
import 'package:jj_mkvmaker/ui/home_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 66-2 기기 감독 확인용 (Windows): 지금 화면 아이콘 (넓은 · 좁은 화면) · 처음으로 알림 · 브라우저 안내 한 줄을 PNG (JJ_SHOT_DIR) 로.
@ -83,9 +84,7 @@ void main() {
..components = ['mkv', 'browser', 'explorer', 'rsync']
..navOrder = ['mkv', 'browser', 'explorer', 'rsync'];
final base = Platform.environment['JJ_SHOT_TMP'];
final dir = (base == null || base.isEmpty ? Directory.systemTemp : (Directory(base)..createSync(recursive: true)))
.createTempSync('jj_shot_662_');
addTearDown(() => dir.deleteSync(recursive: true));
final dir = testTempDir('jj_shot_662_', base: base);
final bm = BookmarksController(p.join(dir.path, 'bookmarks.json'));
t.view.physicalSize = const Size(1280, 800);
t.view.devicePixelRatio = 1;

View File

@ -14,6 +14,7 @@ import 'package:path/path.dart' as p;
import '../test/support/fake_recycle_bin.dart';
import '../test/support/recycle_leftovers.dart';
import 'support/temp_dir.dart';
/// 묶음 3 (데이터 쪽) 기기 감독 확인용 (Windows): 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다.
/// 165 (드라이브 · 연결 안 됨) · 48 (같은 이름) · 52 (돌아와도 진행 막대) · 148 (휴지통 되돌리기) · 144 (긴 경로).
@ -23,9 +24,7 @@ void main() {
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_b3');
Directory tempDir(String prefix) {
final base = Platform.environment['JJ_SHOT_TMP'];
if (base == null || base.isEmpty) return Directory.systemTemp.createTempSync(prefix);
Directory(base).createSync(recursive: true);
return Directory(base).createTempSync(prefix);
return testTempDir(prefix, base: base);
}
final key = GlobalKey();

View File

@ -12,6 +12,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 9 (10/17): 82 끌어 놓기 · 75 마우스 4 · 5 번을 Windows 실제 빌드에서 가짜 포인터로 (실제 마우스 · 키보드는 쓰지 않음).
@ -26,7 +27,7 @@ void main() {
setUp(() async {
await i18n.apply('ko', save: false);
tmp = Directory.systemTemp.createTempSync('jj_b6input_');
tmp = testTempDir('jj_b6input_');
left = p.join(tmp.path, 'left');
right = p.join(tmp.path, 'right');
File(p.join(left, 'doc.txt'))

View File

@ -21,6 +21,7 @@ import 'package:jj_mkvmaker/ui/explorer_search.dart';
import 'package:jj_mkvmaker/ui/quick_look.dart';
import 'package:jj_mkvmaker/ui/storage_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 5 (10/17): 기기 없이 15 가 볼 수 있게 묶음6 새 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더).
@ -38,8 +39,7 @@ void main() {
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = Directory.systemTemp.createTempSync('jj_b6shot_');
addTearDown(() => tmp.deleteSync(recursive: true));
final tmp = testTempDir('jj_b6shot_');
// 시험 파일
File(p.join(tmp.path, 'Show', 'ep01.mkv'))
..createSync(recursive: true)

View File

@ -20,6 +20,7 @@ import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import '../test/support/fake_recycle_bin.dart';
import 'support/temp_dir.dart';
/// 기기 감독 (Windows) 확인용: 실제 앱 화면을 띄워 누르고 단계마다 PNG (JJ_SHOT_DIR) 로 남긴다.
/// 65 · 94 · 98 (휴지통 · 휴지통 없는 위치 · 오류 문구), 37 (경로 입력), 124 · 130 (마스터 · 최상), 123 (AI 그림).
@ -30,9 +31,7 @@ void main() {
// 시험 폴더: 항목이 아주 많은 시스템 임시 폴더 대신 (JJ_SHOT_TMP, 없으면 시스템 임시 폴더)
Directory tempDir(String prefix) {
final base = Platform.environment['JJ_SHOT_TMP'];
if (base == null || base.isEmpty) return Directory.systemTemp.createTempSync(prefix);
Directory(base).createSync(recursive: true);
return Directory(base).createTempSync(prefix);
return testTempDir(prefix, base: base);
}
final key = GlobalKey();
const sizes = [('narrow', Size(700, 900)), ('wide', Size(1280, 800))];

View File

@ -8,6 +8,7 @@ import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/ui/file_tools.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 (10/18): X-plore P3 화면을 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더) - 해시 창 · 폴더 속성 · 16진 보기.
@ -23,8 +24,7 @@ void main() {
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = Directory.systemTemp.createTempSync('jj_p3shot_');
addTearDown(() => tmp.deleteSync(recursive: true));
final tmp = testTempDir('jj_p3shot_');
final file = File(p.join(tmp.path, '영상 폴더', 'ep01 긴 이름의 동영상 파일.mkv'))
..createSync(recursive: true)
..writeAsBytesSync(List.generate(5000, (i) => i % 256));

View File

@ -0,0 +1,51 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/ui/text_viewer.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 관리자 지시 (10/19): 글 뷰어가 줄바꿈 없는 긴 줄 (영문 · 한글 섞임) 을 나눠 그릴 때 실제 Windows 글꼴에서 줄 끝이 비지 않는지 PNG 로.
/// 크기 412 · 960. 실행: flutter test integration_test\windows_textview_shot_test.dart -d windows
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? p.join(Directory.systemTemp.path, 'jj_shots_textview');
for (final (w, h) in [(412, 900), (960, 600)]) {
for (final (name, line) in [
('ascii', List.generate(3000, (i) => String.fromCharCode(0x61 + i % 26)).join()),
('mixed', List.generate(3000, (i) => i.isEven ? '한' : 'x').join()),
('korean', '가나다라마바사아자차카타파하' * 200),
]) {
testWidgets('글 뷰어 긴 줄 $name ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
final dir = testTempDir('jj_textview_');
final f = File(p.join(dir.path, '$name.txt'))..writeAsStringSync('$line\nend');
final key = GlobalKey();
await t.pumpWidget(RepaintBoundary(
key: key,
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: TextViewerPage(path: f.path)),
));
for (var i = 0; i < 40 && find.text('end').evaluate().isEmpty && find.textContaining(line.substring(0, 6)).evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
await t.pump();
}
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
await t.pump();
final b = key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'textview_${name}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
await t.pumpWidget(const SizedBox());
});
}
}
}

View File

@ -13,6 +13,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:jj_mkvmaker/ui/wifi_share_page.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// 관리자 지시 (10/18): 89 Wi-Fi 공유 화면을 PNG 로 (JJ_SHOT_DIR) - 켜기 전 · 켠 뒤 (코드 · 주소 · QR · 남은 시간 · 접속 기록).
@ -37,8 +38,7 @@ void main() {
WifiShare.debugLoopbackOnly = false;
WifiShare.debugAddresses = null;
});
final tmp = Directory.systemTemp.createTempSync('jj_wifishot_');
addTearDown(() => tmp.deleteSync(recursive: true));
final tmp = testTempDir('jj_wifishot_');
final dir = Directory(p.join(tmp.path, '여행 사진'))..createSync();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final shotKey = GlobalKey();

View File

@ -14,6 +14,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 P1 (관리자 지시 10/18): X-plore 스타일 탐색기를 PNG 로 (JJ_SHOT_DIR, 없으면 임시 폴더).
@ -31,8 +32,7 @@ void main() {
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = Directory.systemTemp.createTempSync('jj_xpshot_');
addTearDown(() => tmp.deleteSync(recursive: true));
final tmp = testTempDir('jj_xpshot_');
final a = Directory(p.join(tmp.path, 'Videos'))..createSync();
final b = Directory(p.join(tmp.path, 'Backup'))..createSync();
for (final n in ['Show S01', 'Movies']) {

View File

@ -15,6 +15,7 @@ import 'package:jj_mkvmaker/ui/explorer_grid.dart';
import 'package:jj_mkvmaker/ui/storage_page.dart';
import 'package:jj_mkvmaker/ui/text_viewer.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'support/temp_dir.dart';
import 'package:path/path.dart' as p;
/// X-plore 비교 g · h · i · P3 (관리자 지시 10/18): 격자 보기 · 글 뷰어 · 일괄 이름 (날짜) · 저장 공간 그림을 PNG 로
@ -31,8 +32,7 @@ void main() {
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = Directory.systemTemp.createTempSync('jj_xptshot_');
addTearDown(() => tmp.deleteSync(recursive: true));
final tmp = testTempDir('jj_xptshot_');
// 그림 (색이 다른 작은 그림) · 동영상 이름 · 글 (CP949)
final pics = <String>[];
for (var i = 0; i < 7; i++) {

View File

@ -416,15 +416,18 @@ class LiveSync extends ChangeNotifier {
if (x.delete) await ensureSourceForDelete(x.source, x.target, allowEmpty: allowEmpty);
// 한쪽이라도 WebDAV 면 rsync · robocopy 대신 앱이 맞춘다 (크기 · 시각 비교)
final method = isDav(x.source) || isDav(x.target) ? CopyMethod.builtin : CopyMethod.of(x.method);
// M1 (10/19): 원격 쌍에서 받는 쪽의 같은 이름 폴더는 지우기 포함 (--delete) 이 아니면 지우지 않고 건너뛴다 - 카드에 알린다
final ops = FileOps(bandwidthKBps: s.copyBandwidthKBps);
final n = switch (method) {
CopyMethod.builtin => await FileOps(bandwidthKBps: s.copyBandwidthKBps)
.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty),
CopyMethod.builtin => await ops.mirror(x.source, x.target, delete: x.delete, allowEmptySource: allowEmpty),
CopyMethod.rsync => await _rsync(x, s),
CopyMethod.robocopy => await _robocopy(x, s),
};
status[k] = toDelete[k] != null
? (DateTime.now(), trf('지우지 않고 맞춤 · 대상에만 있는 {0}개를 지울지 확인 필요', [toDelete[k]!.length]))
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
: ops.dirConflicts.isNotEmpty
? (DateTime.now(), trf('{0}개 맞춤 · 같은 이름의 폴더가 있어 건너뜀 {1}개', [n < 0 ? 0 : n, ops.dirConflicts.length]))
: (DateTime.now(), n < 0 ? tr('맞춤') : trf('{0}개 맞춤', [n]));
problems.remove(k);
emptyDeletes.remove(k);
pending[k] = await diff(asked);

View File

@ -100,6 +100,9 @@ class TransferJob extends ChangeNotifier {
/// 같은 이름이라 건너뛴 항목 (원본 경로)
final skipped = <String>[];
/// M1 (10/19): 원격 맞추기에서 받는 쪽에 같은 이름의 폴더가 있어 건너뛴 것 (--delete 가 없을 때)
final dirConflicts = <String>[];
TransferJob({
required this.sources,
required this.dest,
@ -341,6 +344,8 @@ class TransferJob extends ChangeNotifier {
// 폴더 "안의 것" 맞추기 (rsync 원본/ 대상/ 과 같은 뜻): -u · --delete · 원본 파일 지우기 (--remove-source-files) 도
settled.clear();
await _ops!.mirror(sources[i], dest, update: _update || skipNewer, delete: _delete, strict: move);
dirConflicts.addAll(_ops!.dirConflicts);
_ops!.dirConflicts.clear();
if (move && !cancelled) await _deleteSettled();
made.add(dest);
} else {

View File

@ -381,6 +381,10 @@ class FileOps {
/// 건너뛴 항목 (원본 경로)
final skipped = <String>[];
/// M1 (관리자 P0, 10/19): 원격 맞추기에서 원본의 파일과 같은 이름의 **폴더** 가 받는 쪽에 있어 건너뛴 것.
/// --delete 가 없으면 그 폴더를 지우지 않는다 (rsync 도 --delete · --force 없이는 비어 있지 않은 폴더를 지우지 않음)
final dirConflicts = <String>[];
FileOps({
this.bandwidthKBps = 0,
this.onFileDone,
@ -950,7 +954,15 @@ class FileOps {
}
}
if (update && t != null && t.modified.isAfter(e.modified.add(const Duration(seconds: 2)))) continue;
if (t != null && t.isDir) await vDelete(target);
// M1 (관리자 P0, 10/19): 예전에는 --delete 가 없어도 같은 이름의 대상 폴더를 통째로 지우고 파일로 바꿨다 (미리 보기에는 "바뀜" 만).
// 이제 --delete 일 때만 (미리 보기에 "폴더를 지우고 파일로"), 아니면 건너뛰고 알린다
if (t != null && t.isDir) {
if (!delete) {
dirConflicts.add(target);
continue;
}
await vDelete(target);
}
await _copyFileV(e.path, target, e.size, e.modified, null);
n++;
onSettled?.call(e.path, e.size, e.modified);

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
/// X-plore 비교 j: 같은 망에서 SMB (445) 서버 찾기 - 이 기기의 IPv4 망 (/24) 을 짧은 연결 시도로 훑는다.
/// 연결만 해 보고 바로 닫는다 (아무것도 보내지 않음). 찾은 것은 이름 (역방향 조회, 모르면 null) 과 함께.
/// SMB 서버 추가 창 (88 Android SMB) 의 [찾기] 에서 쓴다.
@ -24,6 +26,27 @@ final _mobileIf = RegExp(r'^(v4-)?(rmnet|ccmni|pdp|seth|wwan|usb_rmnet|rev_rmnet
final _virtualIf =
RegExp(r'vethernet|hyper-v|virtualbox|vbox|vmware|vmnet|wsl|docker|^br-|^veth|virbr|^tun|^tap|^ppp|^ipsec|wireguard|^wg|zerotier|openvpn|tailscale');
/// 28 APK 점검 · 관리자 판단 (10/19): Android 의 망 종류 (ConnectivityManager · NetworkCapabilities) - 주소 → "wifi" · "ethernet" ·
/// "cellular" · "vpn" · "other". 판단하지 못하면 (Android 가 아님 · 오류) null → 연결 이름으로 (예전 방식). 시험에서 바꾼다
Future<Map<String, String>?> Function() netKindsOf = _androidNetKinds;
Future<Map<String, String>?> _androidNetKinds() async {
if (!Platform.isAndroid) return null;
try {
final m = await const MethodChannel('jj_mkvmaker/android').invokeMethod<Map<Object?, Object?>>('netKinds');
return m?.map((k, v) => MapEntry('$k', '$v'));
} catch (_) {
return null;
}
}
/// 집 안 망 (Wi-Fi · 유선) 의 주소인지: Android 가 알려 준 망 종류가 있으면 그것으로, 없으면 연결 이름으로
bool isLanAddress(String ip, String interfaceName, Map<String, String>? kinds) {
final k = kinds?[ip];
if (k != null) return k == 'wifi' || k == 'ethernet';
return isLanInterfaceName(interfaceName);
}
/// 사설망 IPv4 (10/8 · 172.16/12 · 192.168/16) 인지
bool isPrivateLanAddress(String ip) {
final v = ip.split('.').map(int.tryParse).toList();
@ -34,9 +57,10 @@ bool isPrivateLanAddress(String ip) {
/// 이 기기의 사설 IPv4 주소들 (Wi-Fi · 유선만 - Tailscale 100.64/10 은 넓어 훑지 않고, 모바일 데이터 · 가상 어댑터 · VPN 도 뺀다)
Future<List<InternetAddress>> localPrivateIPv4() async {
final out = <InternetAddress>[];
final kinds = await netKindsOf();
for (final ni in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
if (!isLanInterfaceName(ni.name)) continue;
for (final a in ni.addresses) {
if (!isLanAddress(a.address, ni.name, kinds)) continue;
final b = a.rawAddress;
final private = b[0] == 10 || (b[0] == 172 && b[1] >= 16 && b[1] <= 31) || (b[0] == 192 && b[1] == 168);
if (private && !a.isLoopback) out.add(a);

View File

@ -26,6 +26,9 @@ enum PreviewAction {
/// 231: 원본의 링크 · junction - 앱이 직접 맞출 때는 따라가지 않고 건너뛴다 (214)
skipLink,
/// M1 (10/19): 원본은 파일인데 받는 쪽에 같은 이름의 폴더가 있어 건너뜀 (--delete 가 없을 때 - 폴더를 지우지 않는다)
skipDir,
}
class PreviewItem {
@ -37,7 +40,12 @@ class PreviewItem {
final bool toRight;
final bool isDir;
final int size;
const PreviewItem(this.rel, this.action, {required this.toRight, this.isDir = false, this.size = 0, this.newer = false});
const PreviewItem(this.rel, this.action,
{required this.toRight, this.isDir = false, this.size = 0, this.newer = false, this.replacedByFile = false, this.inner = 0});
/// M1 (10/19): [PreviewAction.delete] 인 폴더가 원본의 같은 이름 파일로 바뀜 (--delete) - 그 폴더 안의 항목 수 [inner]
final bool replacedByFile;
final int inner;
/// 229: [PreviewAction.update] 인데 받는 쪽이 더 새것 (예전 원본으로 새 파일을 덮어씀)
final bool newer;
@ -165,6 +173,17 @@ Future<List<PreviewItem>> previewSync(String src, String dst,
await walk(e.path, vJoin(d, name), r);
continue;
}
// M1 (관리자 P0, 10/19): 받는 쪽의 같은 이름이 폴더 - --delete 면 폴더 (안의 것 모두) 를 지우고 파일로, 아니면 건너뜀 (앱 맞추기 · rsync 같음)
if (t != null && t.isDir) {
if (delete) {
out.add(PreviewItem(r, PreviewAction.delete,
toRight: toRight, isDir: true, replacedByFile: true, inner: await _countInside(t.path)));
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size));
} else {
out.add(PreviewItem(r, PreviewAction.skipDir, toRight: toRight, size: e.size));
}
continue;
}
if (t == null) {
out.add(PreviewItem(r, PreviewAction.add, toRight: toRight, size: e.size));
} else if (t.size != e.size || t.modified.difference(e.modified).inSeconds.abs() > 2) {
@ -241,3 +260,24 @@ Future<List<PreviewItem>> previewBoth(String left, String right,
x,
];
}
/// M1: 폴더 [dir] 안의 모든 항목 수 (지우고 파일로 바뀔 때 보이려고, [limit] 에서 멈춤)
Future<int> _countInside(String dir, {int limit = 100000}) async {
var n = 0;
Future<void> go(String d) async {
List<({String path, bool isDir, int size, DateTime modified})> items;
try {
items = await vList(d);
} catch (_) {
return;
}
for (final x in items) {
if (n >= limit) return;
n++;
if (x.isDir) await go(x.path);
}
}
await go(dir);
return n;
}

View File

@ -8,7 +8,7 @@ import 'package:path/path.dart' as p;
import '../l10n/tr.dart';
import 'file_ops.dart' show formatSize, isAppTempName, isPartialFile;
import 'lan_scan.dart' show isLanInterfaceName, isMobileInterfaceName;
import 'lan_scan.dart' show isLanAddress, isMobileInterfaceName, netKindsOf;
/// 89: Wi-Fi 공유 - 앱 안 작은 HTTP 서버로 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용).
/// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 접속 비밀번호. 관리자 지시 (10/17):
@ -160,10 +160,11 @@ class WifiShare extends ChangeNotifier {
static Future<List<String>> localAddresses({bool tailscale = true}) async {
try {
final list = await NetworkInterface.list(type: InternetAddressType.IPv4, includeLoopback: false);
final kinds = await netKindsOf();
final all = [
for (final i in list)
for (final a in i.addresses)
if (shareInterface(i.name, a.address, tailscale: tailscale)) a.address
if (shareInterface(i.name, a.address, tailscale: tailscale, kinds: kinds)) a.address
];
int rank(String a) => a.startsWith('192.168.') ? 0 : a.startsWith('10.') ? 1 : a.startsWith('172.') ? 2 : 3;
return (all.where(isLocalNetwork).toSet().toList()..sort((a, b) => rank(a).compareTo(rank(b))));
@ -174,12 +175,14 @@ class WifiShare extends ChangeNotifier {
/// 공유를 열 연결인지 (연결 이름 [name] · 주소 [ip]). Tailscale (100.64/10) 은 연결 이름과 상관없이 연다 (Android 는 tun0)
/// [tailscale] 이 거짓이면 Tailscale 주소는 열지 않는다 (설정 "Tailscale 에서도 열기")
static bool shareInterface(String name, String ip, {bool tailscale = true}) {
/// [kinds]: Android 가 알려 준 주소별 망 종류 (있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 뺀다)
static bool shareInterface(String name, String ip, {bool tailscale = true, Map<String, String>? kinds}) {
final v = ip.split('.');
final a = int.tryParse(v.first) ?? 0, b = v.length > 1 ? int.tryParse(v[1]) ?? 0 : 0;
if (a == 100 && b >= 64 && b <= 127) return tailscale && !isMobileInterfaceName(name);
// Tailscale 은 VPN 이지만 설정대로 (통신사 CGNAT 100.x 는 모바일 데이터라 뺀다)
if (a == 100 && b >= 64 && b <= 127) return tailscale && kinds?[ip] != 'cellular' && !isMobileInterfaceName(name);
if (a == 169 && b == 254) return false; // 주소를 못 받은 연결
return isLanInterfaceName(name);
return isLanAddress(ip, name, kinds);
}
/// 사설망 · 링크 로컬 · Tailscale (100.64/10) · 이 기기 주소인지

View File

@ -909,11 +909,19 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _load(_Pane pane, String dir, {bool force = false}) async {
if (!force && pane.cache.containsKey(dir)) return;
// X-plore 비교 f: 압축 안 (읽어 둔 목록에서)
if (_archiveOf(dir) != null) {
pane.cache[dir] = _sorted(_archiveList(dir), dir);
pane.changed();
return;
// X-plore 비교 f: 압축 안 (읽어 둔 목록에서). 새로 고침이면 압축 파일이 바뀌었는지 보고 바뀌었으면 다시 읽는다
final arc = _archiveOf(dir);
if (arc != null) {
if (force && !await _readArchive(arc)) {
pane.cache[dir] = const [];
pane.changed();
return;
}
if (_archives.containsKey(arc)) {
pane.cache[dir] = _sorted(_archiveList(dir), dir);
pane.changed();
return;
}
}
// 80: 다시 읽는 폴더는 안이 바뀌었을 수 있다 - 그 폴더와 위 폴더들의 크기를 잊는다
if (force) _sizes.forget(dir);
@ -2174,6 +2182,28 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 펼쳐 본 압축의 목록 (압축 경로 → 항목)
final _archives = <String, List<ArchiveEntry>>{};
/// 압축을 읽은 때의 (크기, 바뀐 때) - 바뀌었으면 다음에 펼치거나 새로 고칠 때 다시 읽는다 (관리자 지시 10/19)
final _archiveStamps = <String, (int, DateTime)>{};
static Future<(int, DateTime)?> _stampOf(String path) async {
try {
final st = await File(path).stat();
if (st.type == FileSystemEntityType.notFound) return null;
return (st.size, st.modified);
} catch (_) {
return null;
}
}
/// [arc] 의 읽어 둔 목록과 창들의 그 안 목록을 잊는다 (다시 읽게)
void _forgetArchive(String arc) {
_archives.remove(arc);
_archiveStamps.remove(arc);
for (final q in _panes) {
q.cache.removeWhere((k, _) => samePath(k, arc) || isSameOrInside(k, arc));
}
}
/// 트리에서 펼칠 수 있는 압축 (이 기기의 파일 · 탐색기 화면)
bool _canBrowseArchive(FileEntry e) => !widget.rsync && !e.isDir && !isDav(e.path) && archiveKindOf(e.path) != null;
@ -2197,7 +2227,13 @@ class _ExplorerPageState extends State<ExplorerPage> {
/// 압축의 목록을 읽어 둔다 (비밀번호가 걸린 7z 등은 [압축 풀기] 로 안내). 읽었으면 true
Future<bool> _readArchive(String path) async {
if (_archives.containsKey(path)) return true;
final stamp = await _stampOf(path);
if (_archives.containsKey(path)) {
// 펼친 뒤 압축 파일이 바뀌었으면 (크기 · 바뀐 때) 다시 읽는다. 없어졌으면 잊는다
if (stamp != null && stamp == _archiveStamps[path]) return true;
_forgetArchive(path);
if (stamp == null) return false;
}
final kind = archiveKindOf(path)!;
try {
if (kind == ArchiveKind.zip) {
@ -2210,6 +2246,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
}
_archives[path] = await list7z(exe, path);
}
if (stamp != null) _archiveStamps[path] = stamp;
return true;
} on ArchivePasswordException {
_snack(tr('비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요'));
@ -3270,6 +3307,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
if (move && job.leftInSource > 0) trf('{0}개는 원본에 남음 (건너뜀)', [job.leftInSource]),
if (!move && job.keptNewer > 0) trf('받는 쪽이 더 새것 {0}개 건너뜀', [job.keptNewer]),
if (job.skipped.isNotEmpty) trf('같은 이름이라 건너뜀: {0}', [_namesOf(job.skipped)]),
// M1 (10/19): 원격 맞추기에서 받는 쪽의 같은 이름 폴더는 --delete 없이 지우지 않는다
if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 건너뜀 {0}개: {1}', [job.dirConflicts.length, _namesOf(job.dirConflicts)]),
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뜀: {0}', [_namesOf(skippedHere)]),
].join(' · ');
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)

View File

@ -103,6 +103,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
final overNewer = items.where((x) => x.action == PreviewAction.update && x.newer).length;
final skipped = n(PreviewAction.skipNewer);
final links = n(PreviewAction.skipLink);
final dirSkips = n(PreviewAction.skipDir);
final more = items.length >= widget.limit;
final parts = [
for (final right in [true, false])
@ -111,6 +112,7 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
if (n(PreviewAction.onlyTarget) > 0) trf('받는 쪽에만 있음 {0} (그대로 둠)', [n(PreviewAction.onlyTarget)]),
if (skipped > 0) trf('받는 쪽이 더 새것 {0} (건너뜀)', [skipped]),
if (links > 0) trf('링크 {0} (건너뜀)', [links]),
if (dirSkips > 0) trf('같은 이름의 폴더가 있어 건너뜀 {0}', [dirSkips]),
];
return Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Row(children: [
@ -159,14 +161,17 @@ class _SyncPreviewViewState extends State<SyncPreviewView> {
PreviewAction.skipNewer => ' · ${x.rel} ${tr('(받는 쪽이 더 새것 - 건너뜀)')}',
PreviewAction.skipLink => ' · ${x.rel}${x.isDir ? '/' : ''} ${tr('(링크 · junction - 따라가지 않고 건너뜀)')}',
PreviewAction.onlyTarget => ' = ${x.rel}${x.isDir ? '/' : ''} ${tr('(받는 쪽에만 있음)')}',
PreviewAction.delete => '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}',
PreviewAction.delete => x.replacedByFile
? '${_arrow(x)} − ${x.rel}/ ${trf('(폴더 · 안의 {0}개를 지우고 같은 이름의 파일로)', [x.inner])}'
: '${_arrow(x)} − ${x.rel}${x.isDir ? '/' : ''} ${tr('(지워짐)')}',
PreviewAction.skipDir => ' · ${x.rel} ${tr('(받는 쪽에 같은 이름의 폴더가 있어 건너뜀 - 폴더는 그대로)')}',
},
style: TextStyle(
fontSize: 11.5,
fontFamily: 'monospace',
color: switch (x.action) {
PreviewAction.delete => Colors.redAccent,
PreviewAction.update => Colors.orangeAccent,
PreviewAction.update || PreviewAction.skipDir => Colors.orangeAccent,
PreviewAction.onlyTarget || PreviewAction.skipNewer || PreviewAction.skipLink => JjColors.textDim,
_ => null,
},

View File

@ -84,9 +84,101 @@ class _TextViewerPageState extends State<TextViewerPage> {
_text = t;
}
/// 28 지적 (10/19): Scrollbar 에 컨트롤러를 붙인다 (데스크톱에서 "ScrollPosition 없음" assert · 막대가 안 그려짐)
final _scroll = ScrollController();
/// 28 지적 (10/19, 6366a2f 의 회귀): 긴 줄을 나눠 그리면 화면 밖 조각은 고르기에 들지 않아 Ctrl+A → Ctrl+C 가 보이는 조각만 복사했다.
/// Ctrl+A 를 누른 뒤 (다른 키 · 누르기 전까지) 의 Ctrl+C 는 읽은 글 전체를 복사한다 - 고르기 · 복사는 원래 줄 기준
bool _allSelected = false;
KeyEventResult _onKey(FocusNode _, KeyEvent e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
final k = e.logicalKey;
if (k == LogicalKeyboardKey.controlLeft || k == LogicalKeyboardKey.controlRight || k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight || k == LogicalKeyboardKey.shiftLeft || k == LogicalKeyboardKey.shiftRight) {
return KeyEventResult.ignored;
}
final ctrl = HardwareKeyboard.instance.isControlPressed || HardwareKeyboard.instance.isMetaPressed;
if (ctrl && k == LogicalKeyboardKey.keyA) {
_allSelected = true; // 화면의 고르기 (강조) 는 그대로 앱의 단축키가 한다
return KeyEventResult.ignored;
}
if (ctrl && k == LogicalKeyboardKey.keyC && _allSelected) {
Clipboard.setData(ClipboardData(text: _text));
return KeyEventResult.handled;
}
_allSelected = false;
return KeyEventResult.ignored;
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
/// 고정폭 글자 하나의 실제 폭 (글자 크기 · 기기 글자 배율) - 나누는 폭을 실제 줄바꿈 폭에 맞추려고 (28 지적: 4줄마다 줄 끝이 비었다)
double _charWidth(BuildContext context) => _measure(context, 'M') ?? _font * 0.62;
/// 넓은 글자 (한글 · 한자 · 가나 · 전각) 하나가 고정폭 글자 몇 개 폭인지 (관리자 지시 10/19: 넓은 글자가 섞인 줄도 줄 끝이 비지 않게)
double _wideRatio(BuildContext context) {
final m = _measure(context, 'M'), w = _measure(context, '가');
return m == null || w == null ? 2 : w / m;
}
double? _measure(BuildContext context, String ch) {
final tp = TextPainter(
text: TextSpan(text: ch * 40, style: DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'))),
textDirection: TextDirection.ltr,
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
)..layout();
final w = tp.width / 40;
tp.dispose();
return w > 0 ? w : null;
}
/// 그릴 줄 (줄 번호 · 글) - 글 · 폭 · 글자 크기가 같으면 다시 만들지 않는다
List<(int, String)> _rows = const [];
String? _rowsText;
String _rowsKey = '';
/// [room] 폭에 [style] 로 그릴 때의 줄. 아주 긴 줄은 화면에서 실제로 네 줄이 끝나는 자리 (TextPainter 로 그 글꼴 그대로 줄을 바꿔 봄) 에서 자른다 -
/// 관리자 지시 (10/19): Windows 의 'monospace' 는 고정폭이 아니라 글자 수로 세면 넷째 줄 끝이 비었다 (영문 · 한글 섞인 줄 모두)
List<(int, String)> _rowsFor(BuildContext context, double room) {
final style = DefaultTextStyle.of(context).style.merge(TextStyle(fontSize: _font, fontFamily: 'monospace'));
final scaler = MediaQuery.textScalerOf(context);
final perRow = (room / _charWidth(context)).floor().clamp(20, 4000);
final key = '$room|$_font|${scaler.scale(100)}|$perRow';
if (identical(_rowsText, _text) && _rowsKey == key) return _rows;
_rowsText = _text;
_rowsKey = key;
final tp = TextPainter(textDirection: Directionality.of(context), textScaler: scaler);
try {
_rows = viewerRows(_text, perRow, wide: _wideRatio(context), cut: (line, from) {
var size = perRow * 8;
while (true) {
final end = (from + size).clamp(0, line.length);
tp.text = TextSpan(text: line.substring(from, end), style: style);
tp.layout(maxWidth: room);
final m = tp.computeLineMetrics();
if (m.length > 4) {
// 다섯째 줄의 시작 = 네 줄의 끝
final at = tp.getPositionForOffset(Offset(0, m[4].baseline - m[4].ascent / 2)).offset;
if (at > 0) return from + at;
}
if (end >= line.length) return line.length;
size *= 2;
}
});
} finally {
tp.dispose();
}
return _rows;
}
@override
Widget build(BuildContext context) {
final lines = _text.split('\n');
// 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (412 에서 파일 이름이 다 가려졌다)
final narrow = MediaQuery.sizeOf(context).width < 600;
return Scaffold(
@ -149,23 +241,39 @@ class _TextViewerPageState extends State<TextViewerPage> {
: Column(children: [
Expanded(
// 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다)
child: SelectionArea(
child: Scrollbar(
child: Focus(
onKeyEvent: _onKey,
canRequestFocus: false,
skipTraversal: true,
child: Listener(
onPointerDown: (_) => _allSelected = false,
child: SelectionArea(
child: LayoutBuilder(builder: (context, box) {
// 관리자 지시 (10/19): 줄바꿈 없는 큰 파일 (한 줄이 수십만 자) 을 한 Text 로 그리면 잠깐 멈췄다 -
// 화면 한 줄에 드는 글자 수 (고정폭 글꼴) 를 보고 아주 긴 줄은 나눠 그린다
final room = box.maxWidth - 16 - (_lineNumbers ? 56 : 0);
final rows = _rowsFor(context, room);
return Scrollbar(
controller: _scroll,
child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemCount: lines.length,
itemCount: rows.length,
itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (_lineNumbers)
SizedBox(
width: 48,
child: Text('${i + 1}',
child: Text(rows[i].$1 > 0 ? '${rows[i].$1}' : '',
textAlign: TextAlign.right,
style: TextStyle(fontSize: _font - 2, color: JjColors.textDim, fontFamily: 'monospace')),
),
if (_lineNumbers) const SizedBox(width: 8),
Expanded(child: Text(lines[i], style: TextStyle(fontSize: _font, fontFamily: 'monospace'))),
Expanded(child: Text(rows[i].$2, style: TextStyle(fontSize: _font, fontFamily: 'monospace'))),
]),
),
);
}),
),
),
),
),
@ -187,3 +295,56 @@ class _TextViewerPageState extends State<TextViewerPage> {
);
}
}
/// 글 뷰어가 그릴 줄: (줄 번호 - 나눈 조각의 둘째부터는 0, 글). 화면 한 줄에 고정폭 글자 [perRow] 개가 들 때,
/// 그보다 8배 넘게 긴 줄은 화면 네 줄 분량씩 나눈다 - 보통 줄은 그대로 (화면에서 저절로 줄바꿈).
/// 넓은 글자 (한글 · 한자 · 가나 · 전각 · 이모지) 는 고정폭 글자 [wide] 개 폭으로 세어 (관리자 지시 10/19) 넓은 글자가 섞여도 네 줄이 꽉 차게.
/// [cut] 이 있으면 (화면) 조각의 끝을 그것이 정한다 - 글꼴로 실제 줄을 바꿔 본 자리 (글자 수 어림보다 정확)
List<(int, String)> viewerRows(String text, int perRow, {double wide = 2, int Function(String line, int from)? cut}) {
final out = <(int, String)>[];
final lines = text.split('\n');
final budget = perRow * 4.0;
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.length <= perRow * 8) {
out.add((i + 1, line));
continue;
}
var at = 0;
while (at < line.length) {
final byLayout = cut?.call(line, at);
if (byLayout != null && byLayout > at) {
var end = byLayout.clamp(at + 1, line.length);
// 두 단위 글자 (이모지 등) 를 반으로 자르지 않게
if (end < line.length && (line.codeUnitAt(end) & 0xFC00) == 0xDC00) end++;
out.add((at == 0 ? i + 1 : 0, line.substring(at, end)));
at = end;
continue;
}
var end = at;
var used = 0.0;
while (end < line.length) {
final c = line.codeUnitAt(end);
// 두 단위 글자 (이모지 등) 는 한 덩어리로 - 반으로 자르지 않게
final pair = (c & 0xFC00) == 0xD800 && end + 1 < line.length && (line.codeUnitAt(end + 1) & 0xFC00) == 0xDC00;
final w = pair || isWideChar(c) ? wide : 1.0;
if (used + w > budget + 1e-6 && end > at) break;
used += w;
end += pair ? 2 : 1;
}
out.add((at == 0 ? i + 1 : 0, line.substring(at, end)));
at = end;
}
}
return out;
}
/// 고정폭 글꼴에서 두 칸을 쓰는 글자인지 (한글 자모 · 음절, 한자 · 가나 · 한중일 기호, 전각)
bool isWideChar(int c) =>
(c >= 0x1100 && c <= 0x115F) ||
(c >= 0x2E80 && c <= 0xA4CF) ||
(c >= 0xAC00 && c <= 0xD7A3) ||
(c >= 0xF900 && c <= 0xFAFF) ||
(c >= 0xFE30 && c <= 0xFE4F) ||
(c >= 0xFF00 && c <= 0xFF60) ||
(c >= 0xFFE0 && c <= 0xFFE6);

View File

@ -20,6 +20,11 @@ class WifiSharePage extends StatefulWidget {
final String folder;
const WifiSharePage({super.key, required this.c, required this.folder});
/// 시험: Windows 설정 · 제어판 열기를 바꾼다
@visibleForTesting
static Future<void> Function(String exe, List<String> args) debugLaunch =
(exe, args) async => Process.start(exe, args, mode: ProcessStartMode.detached);
static Future<void> open(BuildContext context, AppController c, String folder) =>
Navigator.of(context).push(MaterialPageRoute(builder: (_) => WifiSharePage(c: c, folder: folder)));
@ -139,7 +144,11 @@ class _WifiSharePageState extends State<WifiSharePage> {
const SizedBox(height: 12),
_note(Icons.lock_outline, tr('켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.')),
_note(Icons.visibility_outlined, tr('읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.')),
if (Platform.isWindows) _note(Icons.shield_outlined, tr('처음 켤 때 Windows 방화벽 창이 뜨면 "개인 네트워크" 에 [허용] 을 누르세요.')),
if (Platform.isWindows) ...[
_note(Icons.shield_outlined,
tr('켜면 Windows 방화벽 창이 뜰 수 있습니다: "개인 네트워크" 에 체크하고 [허용] 을 누르세요. "공용 네트워크" 는 체크하지 않는 것이 안전합니다.')),
_firewallHelp(),
],
if (_lastReason != null) _note(Icons.info_outline, _lastReason!, color: JjColors.danger),
if (_error != null) _note(Icons.error_outline, _error!, color: JjColors.danger),
const SizedBox(height: 16),
@ -193,6 +202,8 @@ class _WifiSharePageState extends State<WifiSharePage> {
const SizedBox(height: 8),
Center(child: QrView(data: s.urlWithCode(hosts.first), size: 200)),
],
// 관리자 지시 (10/19): 공유 중에도 다른 기기에서 안 열릴 때 볼 곳
if (Platform.isWindows) _firewallHelp(),
const SizedBox(height: 12),
Row(children: [
Expanded(
@ -223,6 +234,40 @@ class _WifiSharePageState extends State<WifiSharePage> {
static String _hm(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
/// 관리자 지시 (10/19): Windows 방화벽이 막았을 때 "개인 네트워크 허용" 하는 법 (펼쳐 봄) · 그 화면을 바로 여는 버튼
Widget _firewallHelp() => ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(left: 8, bottom: 8),
leading: const Icon(Icons.help_outline, size: 20),
title: Text(tr('다른 기기에서 열리지 않으면 (Windows 방화벽)'), style: const TextStyle(fontSize: 14)),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
children: [
_note(Icons.looks_one_outlined,
tr('이 PC 가 연결한 망이 "개인 네트워크" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.')),
Padding(
padding: const EdgeInsets.only(left: 26),
child: TextButton.icon(
onPressed: () => WifiSharePage.debugLaunch('explorer.exe', ['ms-settings:network-status']),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(tr('네트워크 설정 열기')),
),
),
_note(Icons.looks_two_outlined,
tr('방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 "개인" 에 체크 > [확인].')),
Padding(
padding: const EdgeInsets.only(left: 26),
child: TextButton.icon(
onPressed: () => WifiSharePage.debugLaunch('control.exe', ['/name', 'Microsoft.WindowsFirewall', '/page', 'pageConfigureApps']),
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(tr('방화벽 허용 앱 열기')),
),
),
_note(Icons.looks_3_outlined,
tr('목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 "개인" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.')),
_note(Icons.info_outline, tr('회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.')),
],
);
Widget _note(IconData icon, String text, {Color color = JjColors.textDim}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [

View File

@ -359,6 +359,28 @@ void main() {
await calm(tester);
});
testWidgets('X-plore f 보충: 펼친 뒤 압축 파일이 바뀌면 새로 고칠 때 목록을 다시 읽는다 (관리자 지시 10/19)', (tester) async {
final zip = p.join(left, 'pack.zip');
File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()..addFile(ArchiveFile.string('old.txt', 'old'))));
await open(tester);
await settle(tester, () => find.text('pack.zip').evaluate().isNotEmpty);
await act(tester, () => tester.tap(chevronOf('pack.zip').first));
await settle(tester, () => find.text('old.txt').evaluate().isNotEmpty);
expect(find.text('old.txt'), findsOneWidget);
// 다른 프로그램이 압축을 바꿈 (크기 · 바뀐 때가 달라짐)
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 1100));
File(zip).writeAsBytesSync(ZipEncoder().encode(Archive()
..addFile(ArchiveFile.string('new.txt', 'new content'))
..addFile(ArchiveFile.string('more.txt', 'more'))));
});
await act(tester, () => tester.sendKeyEvent(LogicalKeyboardKey.f5));
await settle(tester, () => find.text('new.txt').evaluate().isNotEmpty);
expect(find.text('new.txt'), findsOneWidget);
expect(find.text('more.txt'), findsOneWidget);
expect(find.text('old.txt'), findsNothing);
});
testWidgets('X-plore g · h: 폴더 메뉴 [격자로 보기] (길게 = 그 자리로) · 파일 메뉴 [글 뷰어로 보기]', (tester) async {
File(p.join(left, 'sub', 'pic.png')).writeAsBytesSync([0x89, 0x50]);
await open(tester);

View File

@ -95,4 +95,27 @@ void main() {
}
}
});
test('관리자 판단 (10/19): Android 가 알려 준 망 종류가 있으면 연결 이름보다 먼저 - Wi-Fi · 유선만, 모바일 데이터 · VPN 은 빼고 Tailscale 은 설정대로', () {
const kinds = {
'192.168.0.5': 'wifi',
'10.1.2.3': 'ethernet',
'10.20.30.40': 'cellular',
'10.8.0.2': 'vpn',
'100.80.1.2': 'vpn',
'100.70.1.2': 'cellular',
};
// 이름이 Wi-Fi 같아도 종류가 모바일 · VPN 이면 뺀다 (이름이 낯선 기기 - 예: 제조사마다 다른 이름)
expect(isLanAddress('192.168.0.5', 'oddname0', kinds), isTrue);
expect(isLanAddress('10.1.2.3', 'eth9', kinds), isTrue);
expect(isLanAddress('10.20.30.40', 'wlan0', kinds), isFalse);
expect(isLanAddress('10.8.0.2', 'wlan1', kinds), isFalse);
// 종류를 모르는 주소 (예: 휴대폰 핫스팟 · Android 가 아님) 는 이름으로
expect(isLanAddress('192.168.43.1', 'wlan0', kinds), isTrue);
expect(isLanAddress('192.168.43.1', 'rmnet0', null), isFalse);
expect(WifiShare.shareInterface('oddname0', '192.168.0.5', kinds: kinds), isTrue);
expect(WifiShare.shareInterface('wlan0', '10.20.30.40', kinds: kinds), isFalse);
expect(WifiShare.shareInterface('tun0', '100.80.1.2', kinds: kinds), isTrue, reason: 'Tailscale (VPN) 은 설정대로');
expect(WifiShare.shareInterface('tun0', '100.80.1.2', kinds: kinds, tailscale: false), isFalse);
expect(WifiShare.shareInterface('oddname1', '100.70.1.2', kinds: kinds), isFalse, reason: '통신사 CGNAT');
});
}

View File

@ -1,248 +1,271 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/sftp.dart';
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:path/path.dart' as p;
import 'support/sftp_server.dart';
/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 -
/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다.
void main() {
final hasServer = sftpTestServerAvailable;
final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen';
group('설정 · 경로 (서버 없이)', () {
test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async {
final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_');
addTearDown(() => tmp.deleteSync(recursive: true));
final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = AppSettings()
..sftpServers = [
const SftpServer(
id: 's1',
name: 'NAS',
host: 'nas.local',
port: 2222,
user: 'jj',
password: 'pw-secret',
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----',
keyPassphrase: 'kp-secret',
legacyRsa: true,
hostKeyType: 'ssh-ed25519',
hostKeyFingerprint: 'SHA256:abc'),
];
await store.save(s);
final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync();
expect(text, isNot(contains('pw-secret')));
expect(text, isNot(contains('kp-secret')));
expect(text, isNot(contains('OPENSSH PRIVATE KEY')));
expect(secrets.values['sftp.pw.s1'], 'pw-secret');
expect(secrets.values['sftp.kp.s1'], 'kp-secret');
expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY'));
final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load();
final x = back.sftpServers.single;
expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc'));
expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret'));
expect(x.privateKey, contains('OPENSSH PRIVATE KEY'));
// .bak · 모르는 항목 보존용 JSON 에서도 빠진다
final stripped = AppSettings.stripSecrets({
'sftpServers': [
{'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'}
]
});
expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"'))));
expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}');
});
test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () {
SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]);
addTearDown(() => SftpRegistry.configure(const []));
final d = DavPath.parse(r'sftp://s1/a\b/');
expect(d.full, 'sftp://s1/a/b');
expect(d.isSftp, isTrue);
expect(d.parent.full, 'sftp://s1/a');
expect(d.parent.parent.full, 'sftp://s1/');
expect(d.child('c').full, 'sftp://s1/a/b/c');
expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x');
expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a');
expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)');
expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true));
expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b');
expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다');
expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse);
});
});
group('실제 SFTP 서버 (paramiko)', () {
late Directory tmp;
late Directory root;
SftpTestServer? server;
late int port;
late String fingerprint;
Future<void> start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async {
final s = await SftpTestServer.start(
root: root.path,
hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'),
password: password,
pubkey: pubkey,
keytype: keytype,
legacyOnly: legacyOnly);
server = s;
port = s.port;
fingerprint = s.fingerprint;
}
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_sftp88_');
root = Directory(p.join(tmp.path, 'root'))..createSync();
});
tearDown(() async {
SftpRegistry.configure(const []);
final s = server;
server = null;
await s?.stop();
for (var i = 0;; i++) {
try {
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
break;
} on FileSystemException {
if (i > 30) rethrow;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
});
SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) =>
SftpServer(
id: 's1',
name: 'test',
host: '127.0.0.1',
port: port,
user: 'jj',
password: password,
privateKey: key,
keyPassphrase: kp,
legacyRsa: legacy,
hostKeyFingerprint: fp,
root: rootDir);
Future<Object?> err(Future<void> Function() f) async {
try {
await f();
return null;
} catch (e) {
return e;
}
}
test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async {
await start();
final e = await err(() => SftpRemote(srv()).list('/'));
expect(e, isA<SftpHostKeyException>());
final hk = e! as SftpHostKeyException;
expect(hk.kind, SftpFailure.hostKeyUnknown);
expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문');
expect(hk.type, 'ssh-ed25519');
expect(hk.changed, isFalse);
// 믿음 → 연결
final ok = SftpRemote(srv(fp: fingerprint));
addTearDown(ok.close);
expect(await ok.list('/'), isEmpty);
// 바뀐 키 → 막고 예전 · 지금 지문을 알림
final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/'));
expect(e2, isA<SftpHostKeyException>());
expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged);
expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키');
expect('$e2', contains('바뀌었습니다'));
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async {
await start();
final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/'));
expect((bad! as SftpException).kind, SftpFailure.auth);
final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/'));
expect((none! as SftpException).kind, SftpFailure.noSecret);
expect('$none', '저장된 비밀번호가 없습니다');
// 닫힌 포트
final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final closedPort = free.port;
await free.close();
final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'),
timeout: const Duration(seconds: 3))
.list('/'));
expect((off! as SftpException).kind, SftpFailure.unreachable);
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async {
await start();
SftpRegistry.configure([srv(fp: fingerprint)]);
const base = 'sftp://s1/';
await vMkdirs('${base}a/b');
expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue);
Stream<List<int>> bytes(String s) => Stream.value(utf8.encode(s));
await vWrite('${base}a/b/f.txt', bytes('hello'));
await vWrite('${base}a/b/f.txt', bytes('hello again'));
expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)');
expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음');
final list = await vList('${base}a/b');
expect(list.single.path, 'sftp://s1/a/b/f.txt');
expect((list.single.isDir, list.single.size), (false, 11));
final st = await vStat('${base}a');
expect(st!.isDir, isTrue);
expect(await vStat('$base없음'), isNull);
expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again');
await vRename('${base}a/b/f.txt', '${base}a/g.txt');
expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue);
// 받아서 열기 (임시 폴더)
final local = await vLocalCopy('${base}a/g.txt', tmp.path);
expect(File(local).readAsStringSync(), 'hello again');
expect(local, contains('jj_sftp'));
await vDelete('${base}a');
expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse);
// 시작 폴더: 그 안이 탭의 맨 위
Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true);
File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m');
SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]);
expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']);
final (free, _) = await DavPath.parse(base).client.quota();
expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null');
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async {
final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}');
final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]);
if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음');
addTearDown(() {
for (final f in [key, '$key.pub']) {
if (File(f).existsSync()) File(f).deleteSync();
}
});
await start(password: null, pubkey: '$key.pub');
final pem = File(key).readAsStringSync();
final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint));
addTearDown(c.close);
expect(await c.list('/'), isEmpty);
final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/'));
expect((bad! as SftpException).kind, SftpFailure.auth);
expect('$bad', contains('개인 키의 암호'));
}, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음');
test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async {
await start(keytype: 'rsa', legacyOnly: true);
final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/'));
expect(e, isA<SftpException>());
expect('$e', contains('ssh-rsa'));
final c = SftpRemote(srv(fp: fingerprint, legacy: true));
addTearDown(c.close);
expect(await c.list('/'), isEmpty);
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
});
}
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/sftp.dart';
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/services/secret_store.dart';
import 'package:path/path.dart' as p;
import 'support/sftp_server.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 -
/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다.
void main() {
final hasServer = sftpTestServerAvailable;
final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen';
group('설정 · 경로 (서버 없이)', () {
test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async {
final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_');
addTearDown(() => tmp.deleteSync(recursive: true));
final secrets = MemorySecretStore();
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
final s = AppSettings()
..sftpServers = [
const SftpServer(
id: 's1',
name: 'NAS',
host: 'nas.local',
port: 2222,
user: 'jj',
password: 'pw-secret',
privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----',
keyPassphrase: 'kp-secret',
legacyRsa: true,
hostKeyType: 'ssh-ed25519',
hostKeyFingerprint: 'SHA256:abc'),
];
await store.save(s);
final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync();
expect(text, isNot(contains('pw-secret')));
expect(text, isNot(contains('kp-secret')));
expect(text, isNot(contains('OPENSSH PRIVATE KEY')));
expect(secrets.values['sftp.pw.s1'], 'pw-secret');
expect(secrets.values['sftp.kp.s1'], 'kp-secret');
expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY'));
final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load();
final x = back.sftpServers.single;
expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc'));
expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret'));
expect(x.privateKey, contains('OPENSSH PRIVATE KEY'));
// .bak · 모르는 항목 보존용 JSON 에서도 빠진다
final stripped = AppSettings.stripSecrets({
'sftpServers': [
{'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'}
]
});
expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"'))));
expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}');
});
test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () {
SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]);
addTearDown(() => SftpRegistry.configure(const []));
final d = DavPath.parse(r'sftp://s1/a\b/');
expect(d.full, 'sftp://s1/a/b');
expect(d.isSftp, isTrue);
expect(d.parent.full, 'sftp://s1/a');
expect(d.parent.parent.full, 'sftp://s1/');
expect(d.child('c').full, 'sftp://s1/a/b/c');
expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x');
expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a');
expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)');
expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true));
expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b');
expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다');
expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse);
});
});
group('실제 SFTP 서버 (paramiko)', () {
late Directory tmp;
late Directory root;
SftpTestServer? server;
late int port;
late String fingerprint;
Future<void> start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async {
final s = await SftpTestServer.start(
root: root.path,
hostKeyFile: p.join(tmp.path, 'hostkey_$keytype'),
password: password,
pubkey: pubkey,
keytype: keytype,
legacyOnly: legacyOnly);
server = s;
port = s.port;
fingerprint = s.fingerprint;
}
setUp(() {
tmp = Directory.systemTemp.createTempSync('jj_sftp88_');
root = Directory(p.join(tmp.path, 'root'))..createSync();
});
tearDown(() async {
SftpRegistry.configure(const []);
final s = server;
server = null;
await s?.stop();
for (var i = 0;; i++) {
try {
if (tmp.existsSync()) tmp.deleteSync(recursive: true);
break;
} on FileSystemException {
if (i > 30) rethrow;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
});
SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) =>
SftpServer(
id: 's1',
name: 'test',
host: '127.0.0.1',
port: port,
user: 'jj',
password: password,
privateKey: key,
keyPassphrase: kp,
legacyRsa: legacy,
hostKeyFingerprint: fp,
root: rootDir);
Future<Object?> err(Future<void> Function() f) async {
try {
await f();
return null;
} catch (e) {
return e;
}
}
test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async {
await start();
final e = await err(() => SftpRemote(srv()).list('/'));
expect(e, isA<SftpHostKeyException>());
final hk = e! as SftpHostKeyException;
expect(hk.kind, SftpFailure.hostKeyUnknown);
expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문');
expect(hk.type, 'ssh-ed25519');
expect(hk.changed, isFalse);
// 믿음 → 연결
final ok = SftpRemote(srv(fp: fingerprint));
addTearDown(ok.close);
expect(await ok.list('/'), isEmpty);
// 바뀐 키 → 막고 예전 · 지금 지문을 알림
final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/'));
expect(e2, isA<SftpHostKeyException>());
expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged);
expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키');
expect('$e2', contains('바뀌었습니다'));
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async {
await start();
final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/'));
expect((bad! as SftpException).kind, SftpFailure.auth);
final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/'));
expect((none! as SftpException).kind, SftpFailure.noSecret);
expect('$none', '저장된 비밀번호가 없습니다');
// 닫힌 포트
final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
final closedPort = free.port;
await free.close();
final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'),
timeout: const Duration(seconds: 3))
.list('/'));
expect((off! as SftpException).kind, SftpFailure.unreachable);
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('M1 (관리자 P0): SFTP 로 맞출 때 받는 쪽 같은 이름 폴더 - 지우기 없으면 그대로 · 지우기면 지우고 파일로 (미리 보기 같음)', () async {
await start();
SftpRegistry.configure([srv(fp: fingerprint)]);
final local = Directory(p.join(tmp.path, 'local', 'src'))..createSync(recursive: true);
File(p.join(local.path, 'x')).writeAsStringSync('file');
Directory(p.join(root.path, 'dst', 'x', 'sub')).createSync(recursive: true);
File(p.join(root.path, 'dst', 'x', 'keep.txt')).writeAsStringSync('k');
const dst = 'sftp://s1/dst';
var pv = await previewSync(local.path, dst, toRight: true);
expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']);
final ops = FileOps();
await ops.mirror(local.path, dst);
expect(File(p.join(root.path, 'dst', 'x', 'keep.txt')).existsSync(), isTrue);
expect(ops.dirConflicts, hasLength(1));
pv = await previewSync(local.path, dst, toRight: true, delete: true);
final del = pv.singleWhere((x) => x.action == PreviewAction.delete);
expect((del.replacedByFile, del.inner), (true, 2));
await FileOps().mirror(local.path, dst, delete: true);
expect(File(p.join(root.path, 'dst', 'x')).readAsStringSync(), 'file');
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async {
await start();
SftpRegistry.configure([srv(fp: fingerprint)]);
const base = 'sftp://s1/';
await vMkdirs('${base}a/b');
expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue);
Stream<List<int>> bytes(String s) => Stream.value(utf8.encode(s));
await vWrite('${base}a/b/f.txt', bytes('hello'));
await vWrite('${base}a/b/f.txt', bytes('hello again'));
expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)');
expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음');
final list = await vList('${base}a/b');
expect(list.single.path, 'sftp://s1/a/b/f.txt');
expect((list.single.isDir, list.single.size), (false, 11));
final st = await vStat('${base}a');
expect(st!.isDir, isTrue);
expect(await vStat('$base없음'), isNull);
expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again');
await vRename('${base}a/b/f.txt', '${base}a/g.txt');
expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue);
// 받아서 열기 (임시 폴더)
final local = await vLocalCopy('${base}a/g.txt', tmp.path);
expect(File(local).readAsStringSync(), 'hello again');
expect(local, contains('jj_sftp'));
await vDelete('${base}a');
expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse);
// 시작 폴더: 그 안이 탭의 맨 위
Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true);
File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m');
SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]);
expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']);
final (free, _) = await DavPath.parse(base).client.quota();
expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null');
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async {
final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}');
final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]);
if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음');
addTearDown(() {
for (final f in [key, '$key.pub']) {
if (File(f).existsSync()) File(f).deleteSync();
}
});
await start(password: null, pubkey: '$key.pub');
final pem = File(key).readAsStringSync();
final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint));
addTearDown(c.close);
expect(await c.list('/'), isEmpty);
final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/'));
expect((bad! as SftpException).kind, SftpFailure.auth);
expect('$bad', contains('개인 키의 암호'));
}, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음');
test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async {
await start(keytype: 'rsa', legacyOnly: true);
final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/'));
expect(e, isA<SftpException>());
expect('$e', contains('ssh-rsa'));
final c = SftpRemote(srv(fp: fingerprint, legacy: true));
addTearDown(c.close);
expect(await c.list('/'), isEmpty);
}, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음');
});
}

View File

@ -1,193 +1,220 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
import 'support/dav_server.dart';
void main() {
late Directory tmp, remote, local;
late TestDavServer server;
const id = 'nas';
setUp(() async {
tmp = Directory.systemTemp.createTempSync('jj_dav_');
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
local = Directory(p.join(tmp.path, 'local'))..createSync();
server = TestDavServer(remote);
await server.start();
DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]);
});
tearDown(() async {
DavRegistry.configure([]);
await server.stop();
tmp.deleteSync(recursive: true);
});
File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(text);
File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(text);
test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () {
expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b');
expect(DavPath.parse('dav://nas').full, 'dav://nas/');
expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더');
expect(vDirname('dav://nas/a/b'), 'dav://nas/a');
expect(vDirname('dav://nas/a'), 'dav://nas/');
expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv');
expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue);
expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue);
expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse);
expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse);
expect(samePath('dav://nas/a', r'C:\a'), isFalse);
});
test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async {
rf('영상/a.mkv', 'aaaa');
rf('영상/sub/b.txt', 'b');
final top = await listEntries('dav://$id/');
expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]);
final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name);
expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]);
final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더');
expect(made, 'dav://$id/영상/새 폴더');
expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue);
final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv');
expect(renamed, 'dav://$id/영상/c.mkv');
expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa');
await FileOps().delete(['dav://$id/영상/sub']);
expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse);
// 잘못된 비밀번호
DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]);
expect(() => listEntries('dav://$id/'), throwsA(isA<DavException>().having((e) => e.status, 'status', 401)));
});
test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async {
lf('up/x.txt', 'xx');
lf('up/deep/y.bin', 'yyy');
var bytes = 0;
final ops = FileOps(onBytes: (n) => bytes += n);
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']);
expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy');
expect(bytes, 5);
// 같은 이름이면 "이름 (2)"
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']);
// WebDAV → 로컬
final down = Directory(p.join(local.path, 'down'))..createSync();
await ops.copy(['dav://$id/up'], down.path);
expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx');
// 같은 서버 안 이동 (서버가 MOVE)
await FileOps.makeFolder('dav://$id/', 'box');
await ops.move(['dav://$id/up (2)'], 'dav://$id/box');
expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue);
expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse);
expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty);
// 로컬 → WebDAV 이동 (복사 후 원본 지움)
lf('mv/m.txt', 'm');
await ops.move([p.join(local.path, 'mv')], 'dav://$id/box');
expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm');
expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse);
});
test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async {
lf('src/a.txt', 'a');
lf('src/sub/b.txt', 'b');
final ops = FileOps();
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2);
expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b');
// 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것)
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0);
// 원본에서 내용 (크기) 이 바뀐 것만
lf('src/a.txt', 'aaa');
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1);
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa');
// 지우기 포함: 대상에만 있는 것
rf('dst/extra.txt', 'e');
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true);
expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse);
// -u: 대상이 더 새것이면 덮어쓰지 않음
rf('dst/a.txt', 'remote newer!!');
final old = DateTime.now().subtract(const Duration(days: 1));
File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old);
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true);
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!');
// 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음)
final back = p.join(local.path, 'back');
expect(await ops.mirror('dav://$id/dst', back), 2);
expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b');
expect(await ops.mirror('dav://$id/dst', back), 0);
});
test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async {
rf('p/e1/e2/.keep', '');
File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync();
rf('p/f/z.txt', 'z');
expect(await vCountFiles('dav://$id/p'), 1);
expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1
expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue);
expect(await DavRegistry.client(id).quota(), (1000000, 500));
final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache'));
expect(File(got).readAsStringSync(), 'z');
expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로'));
});
test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async {
lf('live/a.txt', 'a');
lf('live/sub/b.txt', 'bb');
rf('mirror/old.txt', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');
expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse);
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
live.dispose();
});
test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async {
rf('영상/1화.mkv', 'v1');
rf('영상/2화.mkv', 'v2');
rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
rf('영상/메모.txt', 't');
final s = davStream('dav://$id/영상/1화.mkv');
expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}');
expect(s.headers['Authorization'], startsWith('Basic '));
// 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다
expect(vPlayable('dav://$id/영상/1화.mkv'), s.url);
expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass')));
expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv');
// 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청)
final http = HttpClient();
final req = await http.getUrl(Uri.parse(s.url));
s.headers.forEach(req.headers.set);
final res = await req.close();
expect(await res.transform(const SystemEncoding().decoder).join(), 'v1');
http.close();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
final before = server.requests.where((r) => r.startsWith('GET')).length;
final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']);
expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']);
expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음
final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv');
expect(subs.map(p.basename), ['1화.ko.srt']);
expect(File(subs.single).readAsStringSync(), contains('안녕'));
});
}
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/live_sync.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/playlist.dart' show PlaylistMode;
import 'package:jj_mkvmaker/core/vfs.dart';
import 'package:jj_mkvmaker/core/webdav.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:path/path.dart' as p;
import 'support/dav_server.dart';
import 'package:jj_mkvmaker/core/sync_preview.dart';
void main() {
late Directory tmp, remote, local;
late TestDavServer server;
const id = 'nas';
setUp(() async {
tmp = Directory.systemTemp.createTempSync('jj_dav_');
remote = Directory(p.join(tmp.path, 'remote'))..createSync();
local = Directory(p.join(tmp.path, 'local'))..createSync();
server = TestDavServer(remote);
await server.start();
DavRegistry.configure([DavServer(id: id, name: '집 NAS', url: server.url, user: 'user', password: 'pass')]);
});
tearDown(() async {
DavRegistry.configure([]);
await server.stop();
tmp.deleteSync(recursive: true);
});
File lf(String rel, [String text = 'x']) => File(p.join(local.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(text);
File rf(String rel, [String text = 'x']) => File(p.join(remote.path, rel))
..parent.createSync(recursive: true)
..writeAsStringSync(text);
test('경로: dav://서버/경로 정리 · 이어 붙이기 · 같은지 · 안에 있는지', () {
expect(DavPath.parse(r'dav://nas/a\b/').full, 'dav://nas/a/b');
expect(DavPath.parse('dav://nas').full, 'dav://nas/');
expect(vJoin('dav://nas/', '한글 폴더'), 'dav://nas/한글 폴더');
expect(vDirname('dav://nas/a/b'), 'dav://nas/a');
expect(vDirname('dav://nas/a'), 'dav://nas/');
expect(vBasename('dav://nas/a/b.mkv'), 'b.mkv');
expect(samePath('dav://nas/a/', r'dav://nas/a'), isTrue);
expect(isSameOrInside('dav://nas/a/b', 'dav://nas/a'), isTrue);
expect(isSameOrInside('dav://nas/ab', 'dav://nas/a'), isFalse);
expect(isSameOrInside('dav://nas/a', 'dav://other/a'), isFalse);
expect(samePath('dav://nas/a', r'C:\a'), isFalse);
});
test('목록 · 폴더 만들기 · 이름 바꾸기 · 지우기 (한글 이름 · 인증)', () async {
rf('영상/a.mkv', 'aaaa');
rf('영상/sub/b.txt', 'b');
final top = await listEntries('dav://$id/');
expect(top.map((e) => (e.name, e.isDir)), [('영상', true)]);
final inside = sortEntries(await listEntries('dav://$id/영상'), SortBy.name);
expect(inside.map((e) => (e.name, e.isDir, e.size)), [('sub', true, 0), ('a.mkv', false, 4)]);
final made = await FileOps.makeFolder('dav://$id/영상', '새 폴더');
expect(made, 'dav://$id/영상/새 폴더');
expect(Directory(p.join(remote.path, '영상', '새 폴더')).existsSync(), isTrue);
final renamed = await FileOps.rename('dav://$id/영상/a.mkv', 'c.mkv');
expect(renamed, 'dav://$id/영상/c.mkv');
expect(File(p.join(remote.path, '영상', 'c.mkv')).readAsStringSync(), 'aaaa');
await FileOps().delete(['dav://$id/영상/sub']);
expect(Directory(p.join(remote.path, '영상', 'sub')).existsSync(), isFalse);
// 잘못된 비밀번호
DavRegistry.configure([DavServer(id: id, name: 'x', url: server.url, user: 'user', password: 'wrong')]);
expect(() => listEntries('dav://$id/'), throwsA(isA<DavException>().having((e) => e.status, 'status', 401)));
});
test('복사 · 이동: 로컬 → WebDAV · WebDAV → 로컬 · 같은 서버 안 이동 (속도 알림)', () async {
lf('up/x.txt', 'xx');
lf('up/deep/y.bin', 'yyy');
var bytes = 0;
final ops = FileOps(onBytes: (n) => bytes += n);
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up']);
expect(File(p.join(remote.path, 'up', 'deep', 'y.bin')).readAsStringSync(), 'yyy');
expect(bytes, 5);
// 같은 이름이면 "이름 (2)"
expect(await ops.copy([p.join(local.path, 'up')], 'dav://$id/'), ['dav://$id/up (2)']);
// WebDAV → 로컬
final down = Directory(p.join(local.path, 'down'))..createSync();
await ops.copy(['dav://$id/up'], down.path);
expect(File(p.join(down.path, 'up', 'x.txt')).readAsStringSync(), 'xx');
// 같은 서버 안 이동 (서버가 MOVE)
await FileOps.makeFolder('dav://$id/', 'box');
await ops.move(['dav://$id/up (2)'], 'dav://$id/box');
expect(Directory(p.join(remote.path, 'box', 'up (2)')).existsSync(), isTrue);
expect(Directory(p.join(remote.path, 'up (2)')).existsSync(), isFalse);
expect(server.requests.where((r) => r.startsWith('MOVE')), isNotEmpty);
// 로컬 → WebDAV 이동 (복사 후 원본 지움)
lf('mv/m.txt', 'm');
await ops.move([p.join(local.path, 'mv')], 'dav://$id/box');
expect(File(p.join(remote.path, 'box', 'mv', 'm.txt')).readAsStringSync(), 'm');
expect(Directory(p.join(local.path, 'mv')).existsSync(), isFalse);
});
test('동기화 (mirror): 바뀐 것만 · 지우기 · -u · 원격 → 로컬도', () async {
lf('src/a.txt', 'a');
lf('src/sub/b.txt', 'b');
final ops = FileOps();
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 2);
expect(File(p.join(remote.path, 'dst', 'sub', 'b.txt')).readAsStringSync(), 'b');
// 다시: 바뀐 것 없음 (올린 파일은 서버 시각이 원본보다 새것)
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 0);
// 원본에서 내용 (크기) 이 바뀐 것만
lf('src/a.txt', 'aaa');
expect(await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst'), 1);
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'aaa');
// 지우기 포함: 대상에만 있는 것
rf('dst/extra.txt', 'e');
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', delete: true);
expect(File(p.join(remote.path, 'dst', 'extra.txt')).existsSync(), isFalse);
// -u: 대상이 더 새것이면 덮어쓰지 않음
rf('dst/a.txt', 'remote newer!!');
final old = DateTime.now().subtract(const Duration(days: 1));
File(p.join(local.path, 'src', 'a.txt')).setLastModifiedSync(old);
await ops.mirror(p.join(local.path, 'src'), 'dav://$id/dst', update: true);
expect(File(p.join(remote.path, 'dst', 'a.txt')).readAsStringSync(), 'remote newer!!');
// 원격 → 로컬 (받은 파일은 원격의 바뀐 때로 맞춰져 다시 받지 않음)
final back = p.join(local.path, 'back');
expect(await ops.mirror('dav://$id/dst', back), 2);
expect(File(p.join(back, 'sub', 'b.txt')).readAsStringSync(), 'b');
expect(await ops.mirror('dav://$id/dst', back), 0);
});
test('빈 폴더 지우기 · 파일 수 · 용량 · 임시로 받기', () async {
rf('p/e1/e2/.keep', '');
File(p.join(remote.path, 'p', 'e1', 'e2', '.keep')).deleteSync();
rf('p/f/z.txt', 'z');
expect(await vCountFiles('dav://$id/p'), 1);
expect(await removeEmptyDirs('dav://$id/p'), 2); // e1/e2, e1
expect(Directory(p.join(remote.path, 'p', 'f')).existsSync(), isTrue);
expect(await DavRegistry.client(id).quota(), (1000000, 500));
final got = await vLocalCopy('dav://$id/p/f/z.txt', p.join(local.path, 'cache'));
expect(File(got).readAsStringSync(), 'z');
expect(transferProblem(['dav://$id/p'], 'dav://$id/p/f', move: false), contains('자기 자신 안으로'));
});
test('실시간 동기화 (lsync): 로컬 → WebDAV 쌍은 rsync 로 정해 두어도 앱이 맞추고 남은 것 · 지우기', () async {
lf('live/a.txt', 'a');
lf('live/sub/b.txt', 'bb');
rf('mirror/old.txt', 'o');
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
final pair = LiveSyncPair(p.join(local.path, 'live'), 'dav://$id/mirror', method: 'rsync', delete: true, deleteConfirmed: true);
c.settings.liveSyncPairs = [pair];
final live = LiveSync(c);
expect(await LiveSync.diff(pair), unorderedEquals(['a.txt', 'sub/b.txt', '− old.txt']));
await live.syncNow(pair);
expect(live.status[LiveSync.keyOf(pair)]!.$2, isNot(contains('실패')));
expect(File(p.join(remote.path, 'mirror', 'sub', 'b.txt')).readAsStringSync(), 'bb');
expect(File(p.join(remote.path, 'mirror', 'old.txt')).existsSync(), isFalse);
expect(live.pending[LiveSync.keyOf(pair)], isEmpty);
live.dispose();
});
test('스트리밍 재생: 주소 · 인증 헤더, 같은 폴더 동영상 목록 · 자막 (받지 않고)', () async {
rf('영상/1화.mkv', 'v1');
rf('영상/2화.mkv', 'v2');
rf('영상/1화.ko.srt', '1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
rf('영상/메모.txt', 't');
final s = davStream('dav://$id/영상/1화.mkv');
expect(s.url, '${server.url}/${Uri.encodeComponent('영상')}/${Uri.encodeComponent('1화.mkv')}');
expect(s.headers['Authorization'], startsWith('Basic '));
// 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다
expect(vPlayable('dav://$id/영상/1화.mkv'), s.url);
expect(vPlayable('dav://$id/영상/1화.mkv'), isNot(contains('pass')));
expect(vPlayable(r'C:\a.mkv'), r'C:\a.mkv');
// 주소 + 헤더로 실제로 받을 수 있다 (플레이어와 같은 요청)
final http = HttpClient();
final req = await http.getUrl(Uri.parse(s.url));
s.headers.forEach(req.headers.set);
final res = await req.close();
expect(await res.transform(const SystemEncoding().decoder).join(), 'v1');
http.close();
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
final before = server.requests.where((r) => r.startsWith('GET')).length;
final plan = await c.preparePlayback(['dav://$id/영상/1화.mkv']);
expect(plan!.$1, ['dav://$id/영상/1화.mkv', 'dav://$id/영상/2화.mkv']);
expect(server.requests.where((r) => r.startsWith('GET')).length, before); // 동영상은 받지 않음
final subs = await c.externalSubtitlesFor('dav://$id/영상/1화.mkv');
expect(subs.map(p.basename), ['1화.ko.srt']);
expect(File(subs.single).readAsStringSync(), contains('안녕'));
});
test('M1 (관리자 P0): 원본 파일 · 받는 쪽 같은 이름 폴더 - 지우기 없으면 폴더를 지우지 않고 건너뜀, 지우기면 지우고 파일로 · 미리 보기도 같음', () async {
lf('src/x', 'file');
lf('src/ok.txt', 'ok');
rf('dst/x/keep.txt', 'k');
rf('dst/x/sub/deep.txt', 'd');
final src = p.join(local.path, 'src');
const dst = 'dav://$id/dst';
// 지우기 없음: 미리 보기 "건너뜀" · 지울 것 없음 → 실제로도 폴더 그대로
var pv = await previewSync(src, dst, toRight: true);
expect(pv.where((x) => x.action == PreviewAction.skipDir).map((x) => x.rel), ['x']);
expect(pv.where((x) => x.action == PreviewAction.delete), isEmpty);
final ops = FileOps();
await ops.mirror(src, dst);
expect(File(p.join(remote.path, 'dst', 'x', 'keep.txt')).readAsStringSync(), 'k', reason: '예전에는 이 폴더를 통째로 지웠다');
expect(File(p.join(remote.path, 'dst', 'x', 'sub', 'deep.txt')).existsSync(), isTrue);
expect(File(p.join(remote.path, 'dst', 'ok.txt')).readAsStringSync(), 'ok', reason: '다른 것은 그대로 맞춤');
expect(ops.dirConflicts.map(vBasename), ['x']);
// 지우기 포함: 미리 보기에 "폴더 · 안의 3개를 지우고 파일로" → 실제로 폴더가 파일로
pv = await previewSync(src, dst, toRight: true, delete: true);
final del = pv.singleWhere((x) => x.action == PreviewAction.delete);
expect((del.rel, del.isDir, del.replacedByFile, del.inner), ('x', true, true, 3));
final ops2 = FileOps();
await ops2.mirror(src, dst, delete: true);
expect(File(p.join(remote.path, 'dst', 'x')).readAsStringSync(), 'file');
expect(ops2.dirConflicts, isEmpty);
});
}

View File

@ -57,4 +57,25 @@ void main() {
expect(WifiShare.instance, isNull);
expect(find.text('켜기'), findsOneWidget);
});
testWidgets('Windows: 켜기 전에 방화벽 안내 · 펼치면 "개인 네트워크 허용" 하는 법과 설정 · 제어판을 여는 버튼 (관리자 지시 10/19)', (tester) async {
if (!Platform.isWindows) return;
final launched = <String>[];
final old = WifiSharePage.debugLaunch;
WifiSharePage.debugLaunch = (exe, args) async => launched.add('$exe ${args.join(' ')}');
addTearDown(() => WifiSharePage.debugLaunch = old);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
tester.view.physicalSize = const Size(412, 1600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MaterialApp(home: WifiSharePage(c: c, folder: tmp.path)));
expect(find.textContaining('"개인 네트워크" 에 체크하고 [허용]'), findsOneWidget, reason: '켜기 전에 미리');
expect(find.text('방화벽 허용 앱 열기'), findsNothing, reason: '도움말은 접혀 있음');
await tester.tap(find.text('다른 기기에서 열리지 않으면 (Windows 방화벽)'));
await tester.pumpAndSettle();
expect(find.textContaining('네트워크 프로필 유형 = 개인'), findsOneWidget);
await tester.tap(find.text('네트워크 설정 열기'));
await tester.tap(find.text('방화벽 허용 앱 열기'));
await tester.pump();
expect(launched, ['explorer.exe ms-settings:network-status', 'control.exe /name Microsoft.WindowsFirewall /page pageConfigureApps']);
});
}

View File

@ -1,6 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/file_ops.dart';
import 'package:jj_mkvmaker/core/text_codec.dart';
@ -35,6 +37,112 @@ void main() {
expect(find.text('CP949 (EUC-KR)'), findsOneWidget);
});
test('h 글 뷰어: 아주 긴 줄만 나눠 그린다 (줄 번호는 첫 조각에만, 이모지를 반으로 자르지 않음)', () {
expect(viewerRows('a\nbb', 10), [(1, 'a'), (2, 'bb')]);
final long = 'x' * 1000;
final rows = viewerRows('first\n$long\nlast', 10);
expect(rows.first, (1, 'first'));
expect(rows.last, (3, 'last'));
final mid = rows.sublist(1, rows.length - 1);
expect(mid.map((r) => r.$2).join(), long, reason: '글은 그대로 (빠지거나 겹치지 않음)');
expect(mid.first.$1, 2);
expect(mid.skip(1).every((r) => r.$1 == 0), isTrue);
expect(mid.every((r) => r.$2.length <= 40), isTrue, reason: '화면 네 줄쯤씩');
expect(viewerRows('y' * 80, 10).length, 1, reason: '8배까지는 그대로 (화면에서 줄바꿈)');
// 나누는 자리에 두 단위 글자 (😀) 가 걸리면 앞으로 당긴다
final emoji = '${'z' * 39}😀${'z' * 100}';
final cut = viewerRows(emoji, 10);
expect(cut.map((r) => r.$2).join(), emoji);
expect(cut.every((r) => !r.$2.endsWith('\uD83D')), isTrue);
});
testWidgets('h 글 뷰어: 줄바꿈 없는 큰 파일 (200KB 한 줄) 도 나눠 그려 멈추지 않는다', (t) async {
final f = File(p.join(tmp.path, 'one.txt'))..writeAsStringSync('${'0123456789' * 20000}END');
t.view.physicalSize = const Size(412, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path)));
for (var i = 0; i < 40 && find.textContaining('0123456789').evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump();
}
final texts = t.widgetList<Text>(find.textContaining('0123456789')).toList();
expect(texts.length, greaterThan(1), reason: '한 줄을 여러 조각으로');
expect(texts.every((x) => (x.data ?? '').length < 2000), isTrue, reason: '조각마다 짧게');
expect(find.text('1'), findsOneWidget, reason: '줄 번호는 첫 조각에만');
});
testWidgets('h 글 뷰어 (28 지적 · 6366a2f 회귀): 긴 줄을 나눠 그려도 Ctrl+A → Ctrl+C 는 줄 전체 · 막대 컨트롤러', (t) async {
final line = List.generate(1109, (i) => 'abcdefghi${i % 10}').join();
final f = File(p.join(tmp.path, 'long.txt'))..writeAsStringSync('$line\nend');
t.view.physicalSize = const Size(412, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
String? copied;
t.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'Clipboard.setData') copied = (call.arguments as Map)['text'] as String?;
return null;
});
addTearDown(() => t.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null));
await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path)));
for (var i = 0; i < 40 && find.textContaining('abcdefghi0').evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump();
}
expect(t.widgetList<Text>(find.textContaining('abcdefghi')).length, greaterThan(1), reason: '화면에는 나눠 그림');
await t.tapAt(const Offset(150, 140));
await t.pump();
await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft);
await t.sendKeyEvent(LogicalKeyboardKey.keyA);
await t.sendKeyEvent(LogicalKeyboardKey.keyC);
await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft);
await t.pump();
expect(copied, isNotNull);
expect(copied!.length, line.length + 4, reason: '줄 전체 + "\nend" (보이는 조각만이 아님)');
expect(copied, '$line\nend');
// 막대에 컨트롤러가 붙어 있다 (데스크톱에서 ScrollPosition 없음 assert 가 나지 않게)
final bar = t.widget<Scrollbar>(find.byType(Scrollbar));
expect(bar.controller, isNotNull);
expect(bar.controller!.hasClients, isTrue);
});
test('h 글 뷰어 (관리자 지시 10/19): 넓은 글자 (한글 · 한자) 는 두 칸으로 세어 네 줄 분량이 꽉 차게 나눈다', () {
final line = List.generate(600, (i) => i.isEven ? '가' : 'a').join();
final rows = viewerRows(line, 10, wide: 2);
expect(rows.map((r) => r.$2).join(), line);
double units(String s) => s.runes.fold(0.0, (a, c) => a + (isWideChar(c) ? 2 : 1));
for (final r in rows.take(rows.length - 1)) {
expect(units(r.$2), inInclusiveRange(39, 40), reason: '네 줄 (40칸) 에서 한 칸 넘게 비지 않음: ${r.$2}');
}
expect(isWideChar('漢'.codeUnitAt(0)), isTrue);
expect(isWideChar('ア'.codeUnitAt(0)), isTrue);
expect(isWideChar('A'.codeUnitAt(0)), isTrue);
expect(isWideChar('a'.codeUnitAt(0)), isFalse);
// 비율이 2 가 아닌 글꼴 (잰 값) 도 그대로
final r15 = viewerRows('가' * 400, 10, wide: 1.5);
expect(r15.first.$2.length, 26, reason: '40 / 1.5 = 26.6 → 26자');
});
testWidgets('h 글 뷰어: 한글이 섞인 긴 줄도 나눈 조각마다 화면 네 줄 (다섯째 반쪽 줄 없음)', (t) async {
// 한 글자씩 바뀌어 어디서든 줄을 바꿀 수 있는 글 (영문 낱말이 이어지면 낱말 단위로 줄이 바뀌어 보통 글처럼 줄 끝이 조금 빈다)
final line = List.generate(3000, (i) => i.isEven ? '한' : 'x').join();
final f = File(p.join(tmp.path, 'ko.txt'))..writeAsStringSync(line);
t.view.physicalSize = const Size(412, 900);
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.pumpWidget(MaterialApp(home: TextViewerPage(path: f.path)));
for (var i = 0; i < 40 && find.textContaining('한x한x').evaluate().isEmpty; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await t.pump();
}
final pieces = find.textContaining('한x한x');
expect(pieces.evaluate().length, greaterThan(1));
final first = t.renderObject<RenderParagraph>(find.descendant(of: pieces.first, matching: find.byType(RichText)));
final txt = t.widget<Text>(pieces.first).data!;
final lines = {for (final bx in first.getBoxesForSelection(TextSelection(baseOffset: 0, extentOffset: txt.length))) bx.top}.length;
expect(lines, 4, reason: '네 줄 분량으로 나눔 (다섯째 반쪽 줄 없음)');
});
testWidgets('g 격자 보기: 그림 · 동영상만, 누르면 열기', (t) async {
final png = File(p.join(tmp.path, 'a.png'))..writeAsBytesSync([0x89, 0x50]);
final opened = <String>[];

View File

@ -5,15 +5,33 @@
# - 다른 worktree · 세션이 같은 시험을 동시에 돌리면 그쪽 폴더가 섞일 수 있다 (이름으로 확인).
# - 이미 있던 폴더는 건드리지 않는다 (지우지 않음).
# 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\test_temp_check.ps1 [flutter test 인자…]
# 통합 시험 · 캡처도 (관리자 지시 10/19, 28 이 찾음): … test_temp_check.ps1 integration_test\windows_b6_shot_test.dart -d windows
# - 폴더 이름 앞부분은 test 와 integration_test 두 곳의 시험 소스에서 모은다
$ErrorActionPreference = 'Stop'
$app = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$temp = [System.IO.Path]::GetTempPath()
# 시험 소스가 쓰는 임시 폴더 이름 앞부분 (createTemp('jj_x_') · createTempSync('jj_x_'))
$prefixes = @(Get-ChildItem (Join-Path $app 'test') -Recurse -Filter *.dart |
ForEach-Object { [regex]::Matches((Get-Content $_.FullName -Raw -Encoding utf8), 'createTemp(?:Sync)?\(''(jj[^''$]*)''') } |
# 넘겨받은 시험 파일 (.dart) 이 있으면 그 파일들과 support 폴더만 본다 - 다른 세션 · worktree 가 같은 때 돌린 시험의 폴더를 세지 않게
# (10/19: b6 캡처 앞뒤에 다른 세션의 단위 시험 폴더 jj_explorer_ · jj_sync_ 가 섞여 거짓 실패)
$named = @($args | Where-Object { "$_" -like '*.dart' } | ForEach-Object { Join-Path $app "$_" } | Where-Object { Test-Path $_ })
$sources = if ($named.Count -gt 0) {
# 그 파일들이 가져다 쓰는 support 파일만 함께 (import 'support/…') - 다른 세션의 시험과 이름이 겹치지 않게 (10/19: jj_fake_recycle_)
$support = @($named | ForEach-Object {
$dir = Split-Path $_
[regex]::Matches((Get-Content $_ -Raw -Encoding utf8), "import '(support/[^']+\.dart)'") |
ForEach-Object { Join-Path $dir $_.Groups[1].Value } })
@($named) + @($support | Where-Object { Test-Path $_ })
} else {
@(@('test', 'integration_test') | ForEach-Object { Join-Path $app $_ } | Where-Object { Test-Path $_ } |
ForEach-Object { Get-ChildItem $_ -Recurse -Filter *.dart | ForEach-Object { $_.FullName } })
}
# 이름이 하나도 없으면 (임시 폴더를 만들지 않는 시험) 새 jj 폴더는 참고로만 보인다
# 이름 앞부분: createTemp('jj_x_') · createTempSync('jj_x_') · testTempDir('jj_x_') (integration_test/support/temp_dir.dart)
$prefixes = @($sources |
ForEach-Object { [regex]::Matches((Get-Content $_ -Raw -Encoding utf8), '(?:createTemp(?:Sync)?|testTempDir)\(''(jj[^''$]*)''') } |
ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
if ($prefixes.Count -eq 0) { throw '시험 소스에서 임시 폴더 이름을 찾지 못했습니다' }
if ($prefixes.Count -eq 0 -and $named.Count -eq 0) { throw '시험 소스에서 임시 폴더 이름을 찾지 못했습니다' }
function Get-TestTemp {
@(Get-ChildItem $temp -Directory -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name })
@ -34,6 +52,12 @@ $left = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) } | Where-O
$n = $_
@($prefixes | Where-Object { $n.StartsWith($_) }).Count -gt 0
})
# 참고: 시험 소스의 이름이 아닌 새 jj 폴더 (앱 코드가 만든 것일 수 있음 - 실패로 세지 않고 보이기만)
$other = @(Get-TestTemp | Where-Object { -not $before.ContainsKey($_) -and $left -notcontains $_ })
if ($other.Count -gt 0) {
Write-Host ("참고 - 시험 소스에 없는 이름의 새 jj 폴더 {0}개:" -f $other.Count)
$other | Sort-Object | ForEach-Object { Write-Host " $_" }
}
if ($left.Count -gt 0) {
Write-Host ("시험이 남긴 임시 폴더 {0}개 (%TEMP%):" -f $left.Count)
$left | Sort-Object | ForEach-Object { Write-Host " $_" }