rc2: after_1020 f67e1e3 (15) + xplore_p3b 4ebc4f2 (d3) - 10.20_001 다음 정식 판 후보 (시험판 · 태그 없음, 관리자 지시 10/21)
둘 다 1b31609 (v2026.10.20_001) 바탕. 충돌:
- assets/l10n en · ja · zh-Hans: 열쇠별 3방향 합치기 (양쪽이 같은 열쇠를 다르게 바꾼 것 0)
- lib/ui/home_page.dart 자막 줄: after_1020 의 새 배치 (좁으면 두 줄) 를 따르고, xplore_p3b 의 JjColors (테마 색) 쪽 const 빼기를 다시 적용
- lib/ui/settings_page.dart 설명 글: after_1020 의 테마 글자색 (bodyMedium · onSurfaceVariant - 밝은 화면에서도 맞음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rc2
commit
a907b3d25e
|
|
@ -142,9 +142,8 @@
|
|||
"AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "When a long task such as AI upscaling finishes",
|
||||
"AI 해상도 올리기: {0}": "AI upscaling: {0}",
|
||||
"API 키": "API key",
|
||||
"API 키 (선택)": "API key (optional)",
|
||||
"API 키 (필수, 무료)": "API key (required, free)",
|
||||
"API 키 또는 아이디:비밀번호 (선택)": "API key or user:password (optional)",
|
||||
"API 키 또는 아이디:비밀번호": "API key or user:password",
|
||||
"APK 를 저장하지 못했습니다: {0}": "Could not save the APK: {0}",
|
||||
"APK 를 저장했습니다: {0}": "APK saved: {0}",
|
||||
"APK 를 저장했습니다: {0} - 나눠진 앱 (조각 {1}개) 이라 이 파일 하나로는 설치되지 않을 수 있습니다": "APK saved: {0} - this app is split ({1} parts), so this file alone may not install",
|
||||
|
|
@ -286,6 +285,7 @@
|
|||
"PDF 를 만들지 못했습니다: {0}": "Could not make the PDF: {0}",
|
||||
"PDF 를 열 수 없습니다: {0}": "Cannot open the PDF: {0}",
|
||||
"PDF 에 넣을 그림 선택": "Choose images to add to the PDF",
|
||||
"PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "PuTTY keys (.ppk) can't be used. Choose a file converted with PuTTYgen [Conversions] > [Export OpenSSH key]",
|
||||
"Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (photos)",
|
||||
"Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (comics · art, light and fast)",
|
||||
"Rsync (지금 여기)": "Rsync (you are here)",
|
||||
|
|
@ -379,7 +379,7 @@
|
|||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} failed: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "Keeps WebDAV and OpenSubtitles passwords in this device's secure storage. When off, nothing is saved (existing ones are removed) and you enter them each time the app starts",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "If nothing is sent or received for this long while downloading or uploading over WebDAV · SFTP · FTP · SMB, it fails with a notice (so it does not wait forever when the server stops)",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.": "WebDAV, SFTP, SMB, FTP and OpenSubtitles passwords, SFTP keys and API keys. Server lists and user names are kept.",
|
||||
"WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다": "Shows small previews of pictures and videos on WebDAV, SFTP, SMB and FTP in the X-plore style list. Only visible rows, at most 2 at a time",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete, --remove-source-files and the filters --exclude, --include, --filter, --delete-excluded from the options).",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP: the app syncs these itself instead of rsync (compares size and modified time; follows -u, --delete and --remove-source-files from the options).",
|
||||
|
|
@ -455,6 +455,10 @@
|
|||
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] original name · [N2-5] its characters 2–5 · [C] counter · [D] date · [T] time · ♪ music tags · extension kept e.g. Show S01E[C]",
|
||||
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "Enter the password in [Edit server settings]. It is kept in this device's secure storage, so you won't need to enter it again.",
|
||||
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "Check the user name and password in [Edit server settings].",
|
||||
"[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[Test path] Updates from a zip on this PC instead of GitHub (JJ_UPDATE_LOCAL_ZIP). Used only when started with a test data folder.",
|
||||
"[시험 길] JJ_UPDATE_LOCAL_ZIP 에 sha256 이 없습니다 (zip 경로;sha256)": "[Test path] JJ_UPDATE_LOCAL_ZIP has no sha256 (zip path;sha256)",
|
||||
"[시험 길] zip 을 읽을 수 없습니다: {0}": "[Test path] Cannot read the zip: {0}",
|
||||
"[시험 길] 시험용 데이터 폴더 · sha256 없이는 이 PC 의 zip 을 쓰지 않습니다": "[Test path] A zip on this PC is used only with a test data folder and a sha256",
|
||||
"[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "Tap [Allow] to open Android settings. After you allow JJ_MKVMaker and come back, videos that couldn't be read are analyzed again automatically.",
|
||||
"aria2 RPC 에 연결할 수 없습니다.": "Cannot connect to aria2 RPC.",
|
||||
"aria2 오류: {0}": "aria2 error: {0}",
|
||||
|
|
@ -607,6 +611,7 @@
|
|||
"{0}개 항목을 휴지통으로 보낼까요?": "Move {0} items to the Recycle Bin?",
|
||||
"{0}개 항목을 휴지통으로 보냈습니다.": "Moved {0} items to the Recycle Bin.",
|
||||
"{0}개는 더 새 파일이라 그대로 둠": "{0} kept (existing file is newer)",
|
||||
"{0}개는 원본에 남았습니다 (건너뜀).": "{0} left in the source (skipped).",
|
||||
"{0}개는 원본에 남음 (건너뜀)": "{0} left in the source (skipped)",
|
||||
"{0}개는 지우지 않았습니다 (취소).": "{0} were not deleted (cancelled).",
|
||||
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} were too large for the Recycle Bin and were deleted permanently (as Windows asked).",
|
||||
|
|
@ -703,6 +708,7 @@
|
|||
"같은 시리즈 (번호만 다른 파일)": "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}개를 건너뛰었습니다: {1}.": "Skipped {0} because a folder with the same name exists: {1}.",
|
||||
"같은 이름의 폴더가 있어 건너뜀 {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",
|
||||
|
|
@ -712,6 +718,7 @@
|
|||
"같은 이름이 있어 \"{0}\" 로 옮겼습니다": "The same name exists, so it was moved as \"{0}\"",
|
||||
"같은 이름이 있으면": "If the name exists",
|
||||
"같은 이름이 있을 때": "When a file with the same name exists",
|
||||
"같은 이름이라 건너뛰었습니다: {0}.": "Skipped (same name): {0}.",
|
||||
"같은 이름이라 건너뜀 {0}개": "{0} skipped (same name)",
|
||||
"같은 이름이라 건너뜀: {0}": "Skipped (same name): {0}",
|
||||
"같은 조건으로 다시 (시드 그대로)": "Again with the same settings (same seed)",
|
||||
|
|
@ -728,13 +735,15 @@
|
|||
"개인 키": "Private key",
|
||||
"개인 키 바꾸기": "Change private key",
|
||||
"개인 키 빼기": "Remove private key",
|
||||
"개인 키 암호 (없으면 비움)": "Private key passphrase (leave empty if none)",
|
||||
"개인 키 암호": "Private key passphrase",
|
||||
"개인 키 있음": "Private key set",
|
||||
"개인 키 파일 고르기": "Choose private key file",
|
||||
"개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "This isn't a private key file (choose an OpenSSH or PEM private key, not .pub)",
|
||||
"개인 키 파일이 아닙니다 (너무 큼)": "This isn't a private key file (too large)",
|
||||
"개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "Private key added (the original file path isn't saved)",
|
||||
"개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "Can't read the private key (OpenSSH or PEM format)",
|
||||
"개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "Private key added. It is protected by a passphrase, so enter it in the [{0}] field above (the original file path is not saved)",
|
||||
"개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "Can't read the private key. Choose an OpenSSH or PEM (RSA · EC) private key (ed25519 · rsa · ecdsa) (.pub is the public key)",
|
||||
"개인 키만 쓰면 비웁니다": "Leave empty if you use only a private key",
|
||||
"개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "The private key is protected by a passphrase. Enter the key passphrase",
|
||||
"개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect",
|
||||
"갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)": "Show in gallery and music apps again (remove .nomedia)",
|
||||
"갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.": "Hidden from gallery and music apps (.nomedia). The files are unchanged.",
|
||||
|
|
@ -974,6 +983,7 @@
|
|||
"넣을 것을 세는 중…": "Counting items to add…",
|
||||
"넣을 곳: {0}": "Destination: {0}",
|
||||
"넣지 못했습니다: {0}": "Could not add: {0}",
|
||||
"넣지 않아도 됩니다": "Optional",
|
||||
"네덜란드어": "Dutch",
|
||||
"네트워크 · DNS": "Network · DNS",
|
||||
"네트워크 경로 (UNC)": "Network path (UNC)",
|
||||
|
|
@ -1028,6 +1038,8 @@
|
|||
"다른 창으로 복사": "Copy to other pane",
|
||||
"다른 창으로 옮겨 가기": "Switch to the other pane",
|
||||
"다른 창으로 이동": "Move to other pane",
|
||||
"다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "Passwords and keys saved by other versions are deleted too.",
|
||||
"다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).": "Settings made by other versions are deleted too (passwords saved by other versions are deleted only if you also choose \"Saved actual passwords and API keys\").",
|
||||
"다른 폴더": "Other folder",
|
||||
"다른 폴더로 이동": "Move to another folder",
|
||||
"다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "Another program may be using them, or you may lack permission. The rest were deleted.",
|
||||
|
|
@ -1424,6 +1436,7 @@
|
|||
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "{0} files on the receiving side will be overwritten (same name, different content - the original is lost)",
|
||||
"받는 쪽이 더 새것 {0} (건너뜀)": "Newer on receiving side {0} (skipped)",
|
||||
"받는 쪽이 더 새것 {0}개 건너뜀": "{0} skipped (newer on the receiving side)",
|
||||
"받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.": "Skipped {0} because the receiving side is newer.",
|
||||
"받는 쪽이 더 새것이면 건너뛰기": "Skip when the receiving side is newer",
|
||||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "Download size: about {0}MB · downloaded files · AI models · settings are kept",
|
||||
"받다 만 AI 모델": "Unfinished AI models",
|
||||
|
|
@ -1585,7 +1598,6 @@
|
|||
"비교할 값 붙여넣기 (받은 곳의 해시)": "Paste a value to compare (hash from the source)",
|
||||
"비교할 해시": "Hash to compare",
|
||||
"비밀번호": "Password",
|
||||
"비밀번호 (개인 키만 쓰면 비움)": "Password (leave empty if using only a private key)",
|
||||
"비밀번호 (선택)": "Password (optional)",
|
||||
"비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "This version has no 7-Zip to make password ZIPs. Make the ZIP without a password or reinstall the app.",
|
||||
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)",
|
||||
|
|
@ -1680,7 +1692,7 @@
|
|||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "Can't connect to the server ({0}): {1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "The connection to the server ({0}) was lost",
|
||||
"서버 · 서비스 (기기 밖)": "Servers · services (off device)",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "Server lists (WebDAV, SFTP, SMB, FTP) with their passwords and API keys, and file explorer favorites, are deleted too. The video list, web bookmarks and files are kept",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.": "Server lists (WebDAV, SFTP, SMB, FTP) with their passwords and API keys, and file explorer favorites, are deleted too. The video list, web bookmarks and files are kept.",
|
||||
"서버 설정 고치기": "Edit server settings",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "Deletes the server settings and the password, private key and trusted server key saved on this device. Files on the server stay as they are. Syncs and copies that use this server won't connect until you add it again.",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "Deletes the server settings and the password and trusted certificate saved on this device. Files on the server are not touched.",
|
||||
|
|
@ -1702,6 +1714,7 @@
|
|||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "The server sent an unexpected reply before starting encryption (FTPS). The connection was closed",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "The server doesn't support encryption (FTPS) ({0} {1}). Turn on FTPS on the server, or choose \"Plain FTP\" knowing the risk",
|
||||
"서버가 이 기능을 허용하지 않음": "the server does not allow this",
|
||||
"서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "The server doesn't accept this key. Check that the matching public key (.pub) is in the server's ~/.ssh/authorized_keys and that the user name is right",
|
||||
"서버에 닿을 수 없음": "host unreachable",
|
||||
"서버에 닿지 않습니다": "Cannot reach the server",
|
||||
"서버에서 목록을 읽지 못했습니다: {0}": "Could not read the list from the server: {0}",
|
||||
|
|
@ -1830,6 +1843,8 @@
|
|||
"시작할 때마다 설정 창 보기": "Show the setup window at every start",
|
||||
"시즌": "Season",
|
||||
"시험": "Beta",
|
||||
"시험 길": "Test path",
|
||||
"시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "Started with a test data folder (JJ_MKVMAKER_DATA), so the Start menu shortcut and the jjmkvmaker:// link are left unchanged",
|
||||
"시험판": "Pre-release",
|
||||
"실시간 동기화 (lsyncd 처럼)": "Live sync (like lsyncd)",
|
||||
"실시간 동기화 ({0}): {1} → {2}": "Live sync ({0}): {1} → {2}",
|
||||
|
|
@ -1874,6 +1889,7 @@
|
|||
"아이디 (비우면 게스트)": "User name (guest if empty)",
|
||||
"아이디 (비우면 익명)": "User name (empty = anonymous)",
|
||||
"아이디 (선택)": "Username (optional)",
|
||||
"아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "The user name, password and key were all rejected by the server",
|
||||
"아이디 · 비밀번호를 확인하세요": "check the user name and password",
|
||||
"아이디 또는 비밀번호가 맞지 않습니다": "The user name or password is wrong",
|
||||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).",
|
||||
|
|
@ -1977,6 +1993,7 @@
|
|||
"없는 경로: {0}": "path not found: {0}",
|
||||
"없는 경로입니다: {0}": "The path doesn't exist: {0}",
|
||||
"없는 항목입니다": "No such item",
|
||||
"없으면 비웁니다": "Leave empty if there is none",
|
||||
"없음": "None",
|
||||
"없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details",
|
||||
"엔진": "Engine",
|
||||
|
|
@ -2332,6 +2349,7 @@
|
|||
"이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "A window that is already running is not responding.\n\n[Yes] Open anyway - two windows will share the same data, and settings or lists may overwrite each other.\n[No] Close - end the unresponsive window in Task Manager, then run the app again.",
|
||||
"이미 이 폴더에 있습니다": "Already in this folder",
|
||||
"이미 이 폴더에 있습니다: {0}": "Already in this folder: {0}",
|
||||
"이미 이 폴더에 있어 건너뛰었습니다: {0}.": "Skipped, already in this folder: {0}.",
|
||||
"이미 이 폴더에 있어 건너뜀: {0}": "Skipped, already in this folder: {0}",
|
||||
"이미 있는 폴더입니다: {0}": "The folder already exists: {0}",
|
||||
"이미 있어 건너뜀": "Skipped - already exists",
|
||||
|
|
|
|||
|
|
@ -142,9 +142,8 @@
|
|||
"AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "AI 高解像度化など時間のかかる作業が終わったとき",
|
||||
"AI 해상도 올리기: {0}": "AI 高解像度化: {0}",
|
||||
"API 키": "API キー",
|
||||
"API 키 (선택)": "API キー (任意)",
|
||||
"API 키 (필수, 무료)": "API キー(必須・無料)",
|
||||
"API 키 또는 아이디:비밀번호 (선택)": "API キーまたは ID:パスワード (任意)",
|
||||
"API 키 또는 아이디:비밀번호": "API キーまたは ID:パスワード",
|
||||
"APK 를 저장하지 못했습니다: {0}": "APK を保存できませんでした: {0}",
|
||||
"APK 를 저장했습니다: {0}": "APK を保存しました: {0}",
|
||||
"APK 를 저장했습니다: {0} - 나눠진 앱 (조각 {1}개) 이라 이 파일 하나로는 설치되지 않을 수 있습니다": "APK を保存しました: {0} - 分割されたアプリ (分割 {1} 個) のため、このファイルだけではインストールできない場合があります",
|
||||
|
|
@ -286,6 +285,7 @@
|
|||
"PDF 를 만들지 못했습니다: {0}": "PDF を作成できませんでした: {0}",
|
||||
"PDF 를 열 수 없습니다: {0}": "PDF を開けません: {0}",
|
||||
"PDF 에 넣을 그림 선택": "PDF に追加する画像を選択",
|
||||
"PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "PuTTY のキー (.ppk) は使えません。PuTTYgen の [Conversions] > [Export OpenSSH key] で変換したファイルを選んでください",
|
||||
"Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (写真)",
|
||||
"Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (マンガ・イラスト、軽くて速い)",
|
||||
"Rsync (지금 여기)": "Rsync (現在地)",
|
||||
|
|
@ -379,7 +379,7 @@
|
|||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失敗: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "WebDAV・OpenSubtitles のパスワードをこの端末の安全な保存領域に保存します。オフにすると保存せず (既存のものも削除)、アプリを起動するたびに入力します",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "WebDAV · SFTP · FTP · SMB でダウンロード・アップロード中に、この時間何もやり取りがなければ失敗として知らせます (サーバーが止まってもずっと待たないように)",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.": "WebDAV・SFTP・SMB・FTP・OpenSubtitles のパスワード、SFTP キー、API キー。サーバー一覧と ID は残します。",
|
||||
"WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다": "WebDAV・SFTP・SMB・FTP の画像・動画を X-plore 形式の一覧で小さな画像として表示します。表示中の行だけ、同時に 2 件まで取得します",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files・フィルター (--exclude・--include・--filter・--delete-excluded) に従う)。",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV・SFTP・SMB・FTP: rsync の代わりにアプリが直接同期します (サイズ・更新日時で比較、オプションのうち -u・--delete・--remove-source-files に従う)。",
|
||||
|
|
@ -455,6 +455,10 @@
|
|||
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] 元の名前・[N2-5] その 2~5 文字目・[C] 番号・[D] 日付・[T] 時刻・♪ 音楽タグ・拡張子はそのまま 例: Show S01E[C]",
|
||||
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "[サーバー設定を修正] でパスワードを入力すると、この端末の安全な保存領域に残り、次回からは入力不要です。",
|
||||
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "[サーバー設定を修正] でユーザー名・パスワードを確認してください。",
|
||||
"[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[テスト経路] GitHub ではなくこの PC の zip で更新します (JJ_UPDATE_LOCAL_ZIP)。テスト用データフォルダーで起動したときだけ使われます。",
|
||||
"[시험 길] JJ_UPDATE_LOCAL_ZIP 에 sha256 이 없습니다 (zip 경로;sha256)": "[テスト経路] JJ_UPDATE_LOCAL_ZIP に sha256 がありません (zip のパス;sha256)",
|
||||
"[시험 길] zip 을 읽을 수 없습니다: {0}": "[テスト経路] zip を読めません: {0}",
|
||||
"[시험 길] 시험용 데이터 폴더 · sha256 없이는 이 PC 의 zip 을 쓰지 않습니다": "[テスト経路] テスト用データフォルダーと sha256 がなければこの PC の zip は使いません",
|
||||
"[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "[許可] を押すと Android の設定が開きます。JJ_MKVMaker を許可して戻ると、読み取れなかった動画を自動的に再分析します。",
|
||||
"aria2 RPC 에 연결할 수 없습니다.": "aria2 RPC に接続できません。",
|
||||
"aria2 오류: {0}": "aria2 エラー: {0}",
|
||||
|
|
@ -607,6 +611,7 @@
|
|||
"{0}개 항목을 휴지통으로 보낼까요?": "{0} 個の項目をごみ箱に移動しますか?",
|
||||
"{0}개 항목을 휴지통으로 보냈습니다.": "{0} 個の項目をごみ箱に移動しました。",
|
||||
"{0}개는 더 새 파일이라 그대로 둠": "{0} 件は新しいファイルなのでそのまま",
|
||||
"{0}개는 원본에 남았습니다 (건너뜀).": "{0} 件は元の場所に残りました (スキップ)。",
|
||||
"{0}개는 원본에 남음 (건너뜀)": "{0} 件は元の場所に残っています (スキップ)",
|
||||
"{0}개는 지우지 않았습니다 (취소).": "{0} 個は削除しませんでした (キャンセル)。",
|
||||
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 個はごみ箱より大きいため完全に削除しました (Windows の確認どおり)。",
|
||||
|
|
@ -703,6 +708,7 @@
|
|||
"같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)",
|
||||
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。",
|
||||
"같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}",
|
||||
"같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.": "同名のフォルダーがあるため {0} 件をスキップしました: {1}。",
|
||||
"같은 이름의 폴더가 있어 건너뜀 {0}": "同名のフォルダーがあるためスキップ {0}",
|
||||
"같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "同名のフォルダーがあるため {0} 件スキップ: {1}",
|
||||
"같은 이름이 이미 있습니다": "同じ名前のものが既にあります",
|
||||
|
|
@ -712,6 +718,7 @@
|
|||
"같은 이름이 있어 \"{0}\" 로 옮겼습니다": "同じ名前があるため \"{0}\" として移動しました",
|
||||
"같은 이름이 있으면": "同じ名前がある場合",
|
||||
"같은 이름이 있을 때": "同じ名前があるとき",
|
||||
"같은 이름이라 건너뛰었습니다: {0}.": "同じ名前のためスキップしました: {0}。",
|
||||
"같은 이름이라 건너뜀 {0}개": "同じ名前のためスキップ {0} 件",
|
||||
"같은 이름이라 건너뜀: {0}": "同じ名前のためスキップ: {0}",
|
||||
"같은 조건으로 다시 (시드 그대로)": "同じ条件でもう一度 (シードはそのまま)",
|
||||
|
|
@ -728,13 +735,15 @@
|
|||
"개인 키": "秘密鍵",
|
||||
"개인 키 바꾸기": "秘密鍵を変更",
|
||||
"개인 키 빼기": "秘密鍵を外す",
|
||||
"개인 키 암호 (없으면 비움)": "秘密鍵のパスフレーズ (なければ空欄)",
|
||||
"개인 키 암호": "秘密鍵のパスフレーズ",
|
||||
"개인 키 있음": "秘密鍵あり",
|
||||
"개인 키 파일 고르기": "秘密鍵ファイルを選択",
|
||||
"개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "秘密鍵ファイルではありません (OpenSSH・PEM 形式の秘密鍵を選んでください。.pub ではありません)",
|
||||
"개인 키 파일이 아닙니다 (너무 큼)": "秘密鍵ファイルではありません (大きすぎます)",
|
||||
"개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を登録しました (元のファイルのパスは保存しません)",
|
||||
"개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "秘密鍵を読み込めません (OpenSSH・PEM 形式)",
|
||||
"개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "秘密鍵を入れました。パスフレーズ付きのキーなので、上の [{0}] 欄にキーのパスフレーズを入れてください (元のファイルの場所は保存しません)",
|
||||
"개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "秘密鍵を読めません。OpenSSH 形式か PEM (RSA・EC) 形式の秘密鍵 (ed25519・rsa・ecdsa) を選んでください (.pub は公開鍵)",
|
||||
"개인 키만 쓰면 비웁니다": "秘密鍵だけを使うなら空にします",
|
||||
"개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "秘密鍵にパスフレーズがかかっています。キーのパスフレーズを入れてください",
|
||||
"개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません",
|
||||
"갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)": "ギャラリー・音楽アプリに再表示 (.nomedia を削除)",
|
||||
"갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.": "ギャラリー・音楽アプリから隠しました (.nomedia)。ファイルはそのままです。",
|
||||
|
|
@ -974,6 +983,7 @@
|
|||
"넣을 것을 세는 중…": "入れるものを数えています…",
|
||||
"넣을 곳: {0}": "入れる場所: {0}",
|
||||
"넣지 못했습니다: {0}": "追加できませんでした: {0}",
|
||||
"넣지 않아도 됩니다": "入れなくてもかまいません",
|
||||
"네덜란드어": "オランダ語",
|
||||
"네트워크 · DNS": "ネットワーク・DNS",
|
||||
"네트워크 경로 (UNC)": "ネットワークパス (UNC)",
|
||||
|
|
@ -1028,6 +1038,8 @@
|
|||
"다른 창으로 복사": "もう一方のペインへコピー",
|
||||
"다른 창으로 옮겨 가기": "もう一方のペインへ移動",
|
||||
"다른 창으로 이동": "もう一方のペインへ移動",
|
||||
"다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "他のバージョンで保存したパスワード・キーも消します。",
|
||||
"다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).": "他のバージョンで作った設定も消します (他のバージョンが保存したパスワードは「保存した実際のパスワード・API キー」も選んだときだけ消えます)。",
|
||||
"다른 폴더": "別のフォルダー",
|
||||
"다른 폴더로 이동": "他のフォルダーに移動",
|
||||
"다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "他のプログラムが使用中か、権限がない可能性があります。残りは削除しました。",
|
||||
|
|
@ -1424,6 +1436,7 @@
|
|||
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "受け取る側の {0} 件を上書きします (同じ名前で内容が違うもの - 元のものはなくなります)",
|
||||
"받는 쪽이 더 새것 {0} (건너뜀)": "受け取る側が新しい {0} (スキップ)",
|
||||
"받는 쪽이 더 새것 {0}개 건너뜀": "受け取る側が新しい {0} 件をスキップ",
|
||||
"받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.": "受け取る側が新しいため {0} 件をスキップしました。",
|
||||
"받는 쪽이 더 새것이면 건너뛰기": "受け取る側が新しければスキップ",
|
||||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "ダウンロードサイズ: 約 {0}MB・ダウンロードしたファイル・AI モデル・設定はそのまま",
|
||||
"받다 만 AI 모델": "ダウンロード途中の AI モデル",
|
||||
|
|
@ -1585,7 +1598,6 @@
|
|||
"비교할 값 붙여넣기 (받은 곳의 해시)": "比較する値を貼り付け (入手元のハッシュ)",
|
||||
"비교할 해시": "比較するハッシュ",
|
||||
"비밀번호": "パスワード",
|
||||
"비밀번호 (개인 키만 쓰면 비움)": "パスワード (秘密鍵だけを使う場合は空欄)",
|
||||
"비밀번호 (선택)": "パスワード(任意)",
|
||||
"비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "このバージョンにはパスワード付き ZIP を作る 7-Zip がありません。パスワードなしでまとめるか、アプリを再インストールしてください。",
|
||||
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)",
|
||||
|
|
@ -1680,7 +1692,7 @@
|
|||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "サーバー ({0}) に接続できません: {1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "サーバー ({0}) との接続が切れました",
|
||||
"서버 · 서비스 (기기 밖)": "サーバー・サービス (端末外)",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "サーバー一覧 (WebDAV・SFTP・SMB・FTP) とそのパスワード・API キー、ファイルエクスプローラーのお気に入りも消えます。動画リスト・ウェブのお気に入り・ファイルはそのままです",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.": "サーバー一覧 (WebDAV・SFTP・SMB・FTP) とそのパスワード・API キー、ファイルエクスプローラーのお気に入りも消えます。動画リスト・ウェブのお気に入り・ファイルはそのままです。",
|
||||
"서버 설정 고치기": "サーバー設定を修正",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "サーバー設定と、この端末に保存されたパスワード・秘密鍵・信頼したサーバー鍵を削除します。サーバーのファイルはそのままです。このサーバーを使う同期・コピーは、再登録するまで接続されません。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "サーバー設定と、この端末に保存したパスワード · 信頼した証明書を削除します。サーバーのファイルはそのままです。",
|
||||
|
|
@ -1702,6 +1714,7 @@
|
|||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "サーバーが暗号化 (FTPS) の開始前に予期しない応答を送りました。接続を切りました",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "サーバーが暗号化 (FTPS) に対応していません ({0} {1})。サーバー側で FTPS を有効にするか、リスクを理解した上で「平文 FTP」を選んでください",
|
||||
"서버가 이 기능을 허용하지 않음": "サーバーがこの機能を許可していません",
|
||||
"서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "サーバーがこのキーを受け付けません。サーバーの ~/.ssh/authorized_keys に対になる公開鍵 (.pub) があるか、ユーザー名が合っているか確認してください",
|
||||
"서버에 닿을 수 없음": "ホストに到達できません",
|
||||
"서버에 닿지 않습니다": "サーバーに接続できません",
|
||||
"서버에서 목록을 읽지 못했습니다: {0}": "サーバーから一覧を読み込めませんでした: {0}",
|
||||
|
|
@ -1830,6 +1843,8 @@
|
|||
"시작할 때마다 설정 창 보기": "起動するたびに設定ウィンドウを表示",
|
||||
"시즌": "シーズン",
|
||||
"시험": "試験中",
|
||||
"시험 길": "テスト経路",
|
||||
"시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "テスト用データフォルダー (JJ_MKVMAKER_DATA) で起動したため、スタートメニューのショートカットと jjmkvmaker:// の関連付けは変更しません",
|
||||
"시험판": "プレリリース",
|
||||
"실시간 동기화 (lsyncd 처럼)": "リアルタイム同期 (lsyncd のように)",
|
||||
"실시간 동기화 ({0}): {1} → {2}": "リアルタイム同期 ({0}): {1} → {2}",
|
||||
|
|
@ -1874,6 +1889,7 @@
|
|||
"아이디 (비우면 게스트)": "ユーザー名 (空欄ならゲスト)",
|
||||
"아이디 (비우면 익명)": "ユーザー名 (空欄なら匿名)",
|
||||
"아이디 (선택)": "ユーザー名(任意)",
|
||||
"아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "ユーザー名・パスワード・キーのすべてがサーバーに拒否されました",
|
||||
"아이디 · 비밀번호를 확인하세요": "ID とパスワードを確認してください",
|
||||
"아이디 또는 비밀번호가 맞지 않습니다": "ユーザー名またはパスワードが正しくありません",
|
||||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。",
|
||||
|
|
@ -1977,6 +1993,7 @@
|
|||
"없는 경로: {0}": "存在しないパス: {0}",
|
||||
"없는 경로입니다: {0}": "存在しないパスです: {0}",
|
||||
"없는 항목입니다": "存在しない項目です",
|
||||
"없으면 비웁니다": "なければ空にします",
|
||||
"없음": "なし",
|
||||
"없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します",
|
||||
"엔진": "エンジン",
|
||||
|
|
@ -2332,6 +2349,7 @@
|
|||
"이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "すでに実行中のウィンドウが応答しません。\n\n[はい] それでも開く - 同じデータの上にウィンドウが 2 つになり、設定 · 一覧が互いに上書きされることがあります。\n[いいえ] 閉じる - 応答しないウィンドウをタスク マネージャーで終了してから、もう一度実行してください。",
|
||||
"이미 이 폴더에 있습니다": "既にこのフォルダーにあります",
|
||||
"이미 이 폴더에 있습니다: {0}": "すでにこのフォルダーにあります: {0}",
|
||||
"이미 이 폴더에 있어 건너뛰었습니다: {0}.": "既にこのフォルダーにあるためスキップしました: {0}。",
|
||||
"이미 이 폴더에 있어 건너뜀: {0}": "既にこのフォルダーにあるためスキップ: {0}",
|
||||
"이미 있는 폴더입니다: {0}": "既にあるフォルダーです: {0}",
|
||||
"이미 있어 건너뜀": "すでにあるためスキップ",
|
||||
|
|
|
|||
|
|
@ -142,9 +142,8 @@
|
|||
"AI 해상도 올리기 등 오래 걸린 작업이 끝났을 때": "AI 提升分辨率等耗时任务完成时",
|
||||
"AI 해상도 올리기: {0}": "AI 提升分辨率: {0}",
|
||||
"API 키": "API 密钥",
|
||||
"API 키 (선택)": "API 密钥 (可选)",
|
||||
"API 키 (필수, 무료)": "API 密钥(必填,免费)",
|
||||
"API 키 또는 아이디:비밀번호 (선택)": "API 密钥或 用户名:密码 (可选)",
|
||||
"API 키 또는 아이디:비밀번호": "API 密钥或 用户名:密码",
|
||||
"APK 를 저장하지 못했습니다: {0}": "无法保存 APK: {0}",
|
||||
"APK 를 저장했습니다: {0}": "已保存 APK: {0}",
|
||||
"APK 를 저장했습니다: {0} - 나눠진 앱 (조각 {1}개) 이라 이 파일 하나로는 설치되지 않을 수 있습니다": "已保存 APK: {0} - 此应用已拆分 ({1} 个部分),仅此文件可能无法安装",
|
||||
|
|
@ -286,6 +285,7 @@
|
|||
"PDF 를 만들지 못했습니다: {0}": "无法制作 PDF: {0}",
|
||||
"PDF 를 열 수 없습니다: {0}": "无法打开 PDF: {0}",
|
||||
"PDF 에 넣을 그림 선택": "选择要加入 PDF 的图片",
|
||||
"PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요": "无法使用 PuTTY 密钥 (.ppk)。请选择用 PuTTYgen 的 [Conversions] > [Export OpenSSH key] 转换的文件",
|
||||
"Real-ESRGAN x4plus (사진)": "Real-ESRGAN x4plus (照片)",
|
||||
"Real-ESRGAN x4plus anime 6B (만화 · 그림, 가볍고 빠름)": "Real-ESRGAN x4plus anime 6B (漫画 · 插画,轻量快速)",
|
||||
"Rsync (지금 여기)": "Rsync (当前位置)",
|
||||
|
|
@ -379,7 +379,7 @@
|
|||
"WebDAV {0} 실패: {1} {2}{3}": "WebDAV {0} 失败: {1} {2}{3}",
|
||||
"WebDAV · OpenSubtitles 비밀번호를 이 기기의 안전 저장소에 둡니다. 끄면 저장하지 않고 (있던 것도 지움) 앱을 켤 때마다 다시 넣습니다": "将 WebDAV · OpenSubtitles 密码保存在本设备的安全存储中。关闭后不保存 (已有的也会删除),每次启动应用时重新输入",
|
||||
"WebDAV · SFTP · FTP · SMB 에서 받거나 올리는 중에 이 시간 동안 아무것도 오가지 않으면 실패로 알립니다 (서버가 멈춰도 계속 기다리지 않게)": "通过 WebDAV · SFTP · FTP · SMB 下载或上传时,若这段时间内没有任何数据往来,将提示失败 (服务器停止时不会一直等待)",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名",
|
||||
"WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.": "WebDAV · SFTP · SMB · FTP · OpenSubtitles 密码、SFTP 密钥和 API 密钥。保留服务器列表和用户名。",
|
||||
"WebDAV · SFTP · SMB · FTP 의 그림 · 동영상을 X-plore 모양 목록에서 작은 그림으로. 보이는 줄만, 동시에 2개까지 받습니다": "在 X-plore 样式列表中将 WebDAV、SFTP、SMB、FTP 上的图片和视频显示为小图。仅可见行,最多同时 2 个",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files · 거르기 (--exclude · --include · --filter · --delete-excluded) 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files · 过滤 (--exclude · --include · --filter · --delete-excluded))。",
|
||||
"WebDAV · SFTP · SMB · FTP: rsync 대신 앱이 직접 맞춥니다 (크기 · 바뀐 때 비교, 옵션 중 -u · --delete · --remove-source-files 를 따름).": "WebDAV · SFTP · SMB · FTP:由应用代替 rsync 直接同步 (比较大小和修改时间,遵循选项中的 -u · --delete · --remove-source-files)。",
|
||||
|
|
@ -455,6 +455,10 @@
|
|||
"[N] 원래 이름 · [N2-5] 그 2~5 번째 글자 · [C] 번호 · [D] 날짜 · [T] 시각 · ♪ 음악 태그 · 확장자는 그대로 예: Show S01E[C]": "[N] 原名 · [N2-5] 其第 2~5 个字 · [C] 编号 · [D] 日期 · [T] 时间 · ♪ 音乐标签 · 扩展名不变 例: Show S01E[C]",
|
||||
"[서버 설정 고치기] 에서 비밀번호를 넣으면 이 기기의 안전 저장소에 남아 다음부터는 다시 넣지 않아도 됩니다.": "在 [修改服务器设置] 中输入密码后会保存在本设备的安全存储中,以后无需再次输入。",
|
||||
"[서버 설정 고치기] 에서 아이디 · 비밀번호를 확인하세요.": "请在 [修改服务器设置] 中检查用户名和密码。",
|
||||
"[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.": "[测试路径] 不从 GitHub,而是用本机的 zip 更新 (JJ_UPDATE_LOCAL_ZIP)。仅在以测试数据文件夹启动时使用。",
|
||||
"[시험 길] JJ_UPDATE_LOCAL_ZIP 에 sha256 이 없습니다 (zip 경로;sha256)": "[测试路径] JJ_UPDATE_LOCAL_ZIP 中没有 sha256 (zip 路径;sha256)",
|
||||
"[시험 길] zip 을 읽을 수 없습니다: {0}": "[测试路径] 无法读取 zip: {0}",
|
||||
"[시험 길] 시험용 데이터 폴더 · sha256 없이는 이 PC 의 zip 을 쓰지 않습니다": "[测试路径] 没有测试数据文件夹和 sha256 时不使用本机的 zip",
|
||||
"[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.": "点按 [允许] 将打开 Android 设置。允许 JJ_MKVMaker 后返回,无法读取的视频会自动重新分析。",
|
||||
"aria2 RPC 에 연결할 수 없습니다.": "无法连接到 aria2 RPC。",
|
||||
"aria2 오류: {0}": "aria2 错误: {0}",
|
||||
|
|
@ -607,6 +611,7 @@
|
|||
"{0}개 항목을 휴지통으로 보낼까요?": "将 {0} 个项目移到回收站吗?",
|
||||
"{0}개 항목을 휴지통으로 보냈습니다.": "已将 {0} 个项目移到回收站。",
|
||||
"{0}개는 더 새 파일이라 그대로 둠": "{0} 个因现有文件更新而保留",
|
||||
"{0}개는 원본에 남았습니다 (건너뜀).": "{0} 个留在源中(已跳过)。",
|
||||
"{0}개는 원본에 남음 (건너뜀)": "{0} 个留在源中(已跳过)",
|
||||
"{0}개는 지우지 않았습니다 (취소).": "{0} 个未删除 (已取消)。",
|
||||
"{0}개는 휴지통보다 커서 영구 삭제했습니다 (Windows 가 물은 대로).": "{0} 个超出回收站容量,已永久删除 (按 Windows 的提示)。",
|
||||
|
|
@ -703,6 +708,7 @@
|
|||
"같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)",
|
||||
"같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。",
|
||||
"같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}",
|
||||
"같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.": "因存在同名文件夹,已跳过 {0} 项: {1}。",
|
||||
"같은 이름의 폴더가 있어 건너뜀 {0}": "因存在同名文件夹而跳过 {0}",
|
||||
"같은 이름의 폴더가 있어 건너뜀 {0}개: {1}": "因存在同名文件夹跳过 {0} 项: {1}",
|
||||
"같은 이름이 이미 있습니다": "已存在同名项目",
|
||||
|
|
@ -712,6 +718,7 @@
|
|||
"같은 이름이 있어 \"{0}\" 로 옮겼습니다": "已有同名项,已移动为 \"{0}\"",
|
||||
"같은 이름이 있으면": "如果同名",
|
||||
"같은 이름이 있을 때": "存在同名项目时",
|
||||
"같은 이름이라 건너뛰었습니다: {0}.": "因同名已跳过: {0}。",
|
||||
"같은 이름이라 건너뜀 {0}개": "因同名跳过 {0} 个",
|
||||
"같은 이름이라 건너뜀: {0}": "因同名已跳过: {0}",
|
||||
"같은 조건으로 다시 (시드 그대로)": "以相同条件再来一次 (种子不变)",
|
||||
|
|
@ -728,13 +735,15 @@
|
|||
"개인 키": "私钥",
|
||||
"개인 키 바꾸기": "更换私钥",
|
||||
"개인 키 빼기": "移除私钥",
|
||||
"개인 키 암호 (없으면 비움)": "私钥密码 (没有则留空)",
|
||||
"개인 키 암호": "私钥密码",
|
||||
"개인 키 있음": "已设置私钥",
|
||||
"개인 키 파일 고르기": "选择私钥文件",
|
||||
"개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)": "这不是私钥文件 (请选择 OpenSSH · PEM 格式的私钥,而不是 .pub)",
|
||||
"개인 키 파일이 아닙니다 (너무 큼)": "这不是私钥文件 (文件过大)",
|
||||
"개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥 (不保存原文件路径)",
|
||||
"개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "无法读取私钥 (OpenSSH · PEM 格式)",
|
||||
"개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)": "已添加私钥。此密钥设有密码,请在上方的 [{0}] 栏输入私钥密码 (不保存原文件路径)",
|
||||
"개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)": "无法读取私钥。请选择 OpenSSH 或 PEM (RSA · EC) 格式的私钥 (ed25519 · rsa · ecdsa) (.pub 是公钥)",
|
||||
"개인 키만 쓰면 비웁니다": "仅使用私钥时留空",
|
||||
"개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요": "私钥设有密码。请输入私钥密码",
|
||||
"개인 키의 암호가 맞지 않습니다": "私钥密码不正确",
|
||||
"갤러리 · 음악 앱에 다시 보이기 (.nomedia 지우기)": "在图库和音乐应用中重新显示 (删除 .nomedia)",
|
||||
"갤러리 · 음악 앱에서 숨겼습니다 (.nomedia). 파일은 그대로입니다.": "已在图库和音乐应用中隐藏 (.nomedia)。文件保持不变。",
|
||||
|
|
@ -974,6 +983,7 @@
|
|||
"넣을 것을 세는 중…": "正在统计要放入的项目…",
|
||||
"넣을 곳: {0}": "放入位置: {0}",
|
||||
"넣지 못했습니다: {0}": "无法加入: {0}",
|
||||
"넣지 않아도 됩니다": "可以不填",
|
||||
"네덜란드어": "荷兰语",
|
||||
"네트워크 · DNS": "网络 · DNS",
|
||||
"네트워크 경로 (UNC)": "网络路径 (UNC)",
|
||||
|
|
@ -1028,6 +1038,8 @@
|
|||
"다른 창으로 복사": "复制到另一窗格",
|
||||
"다른 창으로 옮겨 가기": "切换到另一窗格",
|
||||
"다른 창으로 이동": "移动到另一窗格",
|
||||
"다른 판에서 넣은 비밀번호 · 키도 지웁니다.": "其他版本保存的密码和密钥也会删除。",
|
||||
"다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).": "其他版本创建的设置也会删除 (其他版本保存的密码只有同时选择“已保存的实际密码 · API 密钥”时才会删除)。",
|
||||
"다른 폴더": "其他文件夹",
|
||||
"다른 폴더로 이동": "移动到其他文件夹",
|
||||
"다른 프로그램이 쓰고 있거나 권한이 없을 수 있습니다. 나머지는 지웠습니다.": "可能有其他程序正在使用或没有权限。其余已删除。",
|
||||
|
|
@ -1424,6 +1436,7 @@
|
|||
"받는 쪽에서 {0}개를 덮어씁니다 (내용이 다른 같은 이름 - 원래 것은 없어짐)": "将覆盖接收端的 {0} 个文件(同名但内容不同 - 原文件将丢失)",
|
||||
"받는 쪽이 더 새것 {0} (건너뜀)": "接收端较新 {0}(跳过)",
|
||||
"받는 쪽이 더 새것 {0}개 건너뜀": "已跳过接收端较新的 {0} 个",
|
||||
"받는 쪽이 더 새것이라 {0}개를 건너뛰었습니다.": "因接收端较新,已跳过 {0} 个。",
|
||||
"받는 쪽이 더 새것이면 건너뛰기": "接收端较新时跳过",
|
||||
"받는 크기: 약 {0}MB · 받은 파일 · AI 모델 · 설정은 그대로 둡니다": "下载大小: 约 {0}MB · 已下载的文件 · AI 模型 · 设置均保留",
|
||||
"받다 만 AI 모델": "未下载完的 AI 模型",
|
||||
|
|
@ -1585,7 +1598,6 @@
|
|||
"비교할 값 붙여넣기 (받은 곳의 해시)": "粘贴要比较的值 (来源处的哈希)",
|
||||
"비교할 해시": "要比较的哈希",
|
||||
"비밀번호": "密码",
|
||||
"비밀번호 (개인 키만 쓰면 비움)": "密码 (仅使用私钥时留空)",
|
||||
"비밀번호 (선택)": "密码(可选)",
|
||||
"비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "此版本没有用于创建加密 ZIP 的 7-Zip。请不设密码压缩,或重新安装应用。",
|
||||
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)",
|
||||
|
|
@ -1680,7 +1692,7 @@
|
|||
"서버 ({0}) 에 연결할 수 없습니다: {1}": "无法连接服务器 ({0}):{1}",
|
||||
"서버 ({0}) 와의 연결이 끊겼습니다": "与服务器 ({0}) 的连接已断开",
|
||||
"서버 · 서비스 (기기 밖)": "服务器 · 服务 (设备外)",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다": "服务器列表 (WebDAV · SFTP · SMB · FTP) 及其密码 · API 密钥、文件管理器收藏也会删除。视频列表 · 网页书签 · 文件保持不变",
|
||||
"서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.": "服务器列表 (WebDAV · SFTP · SMB · FTP) 及其密码 · API 密钥、文件管理器收藏也会删除。视频列表 · 网页书签 · 文件保持不变。",
|
||||
"서버 설정 고치기": "修改服务器设置",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 개인 키 · 믿은 서버 키를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "删除服务器设置以及本设备保存的密码、私钥和已信任的服务器密钥。服务器上的文件保持不变。使用此服务器的同步 · 复制在重新添加前无法连接。",
|
||||
"서버 설정과 이 기기에 저장된 비밀번호 · 믿은 인증서를 지웁니다. 서버의 파일은 그대로입니다.": "删除服务器设置以及本设备上保存的密码和已信任的证书。服务器上的文件不受影响。",
|
||||
|
|
@ -1702,6 +1714,7 @@
|
|||
"서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다": "服务器在开始加密 (FTPS) 前发送了意外的回复。已断开连接",
|
||||
"서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 \"평문 FTP\" 를 고르세요": "服务器不支持加密 (FTPS) ({0} {1})。请在服务器上启用 FTPS,或在了解风险后选择\"明文 FTP\"",
|
||||
"서버가 이 기능을 허용하지 않음": "服务器不允许此操作",
|
||||
"서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요": "服务器不接受此密钥。请检查服务器的 ~/.ssh/authorized_keys 中是否有对应的公钥 (.pub),以及用户名是否正确",
|
||||
"서버에 닿을 수 없음": "主机不可达",
|
||||
"서버에 닿지 않습니다": "无法连接服务器",
|
||||
"서버에서 목록을 읽지 못했습니다: {0}": "无法从服务器读取列表: {0}",
|
||||
|
|
@ -1830,6 +1843,8 @@
|
|||
"시작할 때마다 설정 창 보기": "每次启动时显示设置窗口",
|
||||
"시즌": "季",
|
||||
"시험": "测试",
|
||||
"시험 길": "测试路径",
|
||||
"시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다": "以测试数据文件夹 (JJ_MKVMAKER_DATA) 启动,因此不更改开始菜单快捷方式和 jjmkvmaker:// 关联",
|
||||
"시험판": "预发布",
|
||||
"실시간 동기화 (lsyncd 처럼)": "实时同步 (类似 lsyncd)",
|
||||
"실시간 동기화 ({0}): {1} → {2}": "实时同步 ({0}): {1} → {2}",
|
||||
|
|
@ -1874,6 +1889,7 @@
|
|||
"아이디 (비우면 게스트)": "用户名 (留空则为访客)",
|
||||
"아이디 (비우면 익명)": "用户名 (留空则匿名)",
|
||||
"아이디 (선택)": "用户名(可选)",
|
||||
"아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다": "用户名、密码和密钥都被服务器拒绝",
|
||||
"아이디 · 비밀번호를 확인하세요": "请检查用户名和密码",
|
||||
"아이디 또는 비밀번호가 맞지 않습니다": "用户名或密码错误",
|
||||
"아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。",
|
||||
|
|
@ -1977,6 +1993,7 @@
|
|||
"없는 경로: {0}": "路径不存在: {0}",
|
||||
"없는 경로입니다: {0}": "路径不存在: {0}",
|
||||
"없는 항목입니다": "项目不存在",
|
||||
"없으면 비웁니다": "没有则留空",
|
||||
"없음": "无",
|
||||
"없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建",
|
||||
"엔진": "引擎",
|
||||
|
|
@ -2332,6 +2349,7 @@
|
|||
"이미 실행 중인 창이 응답하지 않습니다.\n\n[예] 그래도 열기 - 같은 데이터 위에 창이 두 개가 되어 설정 · 목록이 서로 덮어쓸 수 있습니다.\n[아니요] 닫기 - 응답하지 않는 창을 작업 관리자에서 끝낸 뒤 다시 실행하세요.": "已在运行的窗口没有响应。\n\n[是] 仍然打开 - 同一数据上将有两个窗口,设置和列表可能互相覆盖。\n[否] 关闭 - 请在任务管理器中结束无响应的窗口后重新运行。",
|
||||
"이미 이 폴더에 있습니다": "已在此文件夹中",
|
||||
"이미 이 폴더에 있습니다: {0}": "已在此文件夹中: {0}",
|
||||
"이미 이 폴더에 있어 건너뛰었습니다: {0}.": "已在此文件夹中,已跳过: {0}。",
|
||||
"이미 이 폴더에 있어 건너뜀: {0}": "已在此文件夹中,已跳过: {0}",
|
||||
"이미 있는 폴더입니다: {0}": "文件夹已存在: {0}",
|
||||
"이미 있어 건너뜀": "已存在,已跳过",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:jj_mkvmaker/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/app/version_snapshot.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/data_dir_override.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/github_updater.dart';
|
||||
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/update_dialog.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// 관리자 지시 (10/20): 정식 판 업데이트 길을 사용자 데이터 없이 끝까지 - 앱 안의 [지금 설치] 를 가짜 누르기로.
|
||||
/// 바깥 스크립트 (tool/update_path_check.ps1) 가 준비 · 확인 · 정리를 하고, 이 시험은 그 사이의 앱 쪽만 한다:
|
||||
/// 예전 판 배포 폴더 (JJ_UPD_APP) · 예전 판 데이터 (JJ_MKVMAKER_DATA) 에서 시험 길 (JJ_UPDATE_LOCAL_ZIP) 로 최신 확인 → 받기 ·
|
||||
/// SHA256 · 풀기 → 설정 보관 → 교체 예약 (이 시험 프로세스가 끝나면 교체하고 새 판을 띄움).
|
||||
/// 실제 GitHub · 사용자 %APPDATA% · 시작 메뉴는 건드리지 않는다. 그 환경 변수가 없으면 건너뜀.
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final env = Platform.environment;
|
||||
final app = env['JJ_UPD_APP'] ?? '';
|
||||
final from = env['JJ_UPD_FROM'] ?? '';
|
||||
final skip = !Platform.isWindows || app.isEmpty || from.isEmpty || dataDirOverride == null || (env['JJ_UPDATE_LOCAL_ZIP'] ?? '').isEmpty
|
||||
? 'JJ_UPD_APP · JJ_UPD_FROM · JJ_MKVMAKER_DATA · JJ_UPDATE_LOCAL_ZIP 이 없음 (tool/update_path_check.ps1 이 준다)'
|
||||
: null;
|
||||
|
||||
testWidgets('업데이트 길: 시험 길로 [지금 설치] → 설정 보관 → 교체 예약', (t) async {
|
||||
applyDataDirOverride();
|
||||
final data = dataDirOverride!;
|
||||
VersionSnapshot.instance = VersionSnapshot(data);
|
||||
final work = Directory(p.join(p.dirname(app), 'upd_tmp'))..createSync(recursive: true);
|
||||
final up = GitHubUpdater(appDir: app, versionOverride: from, tempRoot: work.path);
|
||||
final c = AppController(PlatformServices(
|
||||
mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService(), updater: up),
|
||||
settingsStore: SettingsStore());
|
||||
await t.runAsync(c.init);
|
||||
expect(c.settings.lastRunVersion, from, reason: '예전 판이 쓴 데이터를 읽음');
|
||||
final r = (await t.runAsync(up.latest))!;
|
||||
expect(r.name, contains('시험 길'));
|
||||
t.view.physicalSize = const Size(1200, 900);
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.pumpWidget(MaterialApp(
|
||||
theme: buildTheme(),
|
||||
home: Builder(builder: (ctx) => Scaffold(body: Center(child: FilledButton(onPressed: () => installRelease(ctx, c, r, from), child: const Text('go'))))),
|
||||
));
|
||||
await t.tap(find.text('go'));
|
||||
Future<void> until(bool Function() done, {int ms = 60000}) async {
|
||||
for (var i = 0; i < ms ~/ 100 && !done(); i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
// 받기 · SHA256 · 풀기 (실제 시간) → "설치 준비 완료"
|
||||
await until(() => find.text('지금 설치').evaluate().isNotEmpty);
|
||||
expect(find.text('지금 설치'), findsOneWidget, reason: '받기 · 검증 끝');
|
||||
await t.tap(find.text('지금 설치'));
|
||||
await until(() => File(p.join(work.path, 'jj_mkvmaker_update.ps1')).existsSync(), ms: 20000);
|
||||
expect(File(p.join(work.path, 'jj_mkvmaker_update.ps1')).existsSync(), isTrue, reason: '교체 스크립트 예약');
|
||||
expect(VersionSnapshot.instance!.has(from), isTrue, reason: '지금 판 ($from) 설정 보관');
|
||||
// ignore: avoid_print
|
||||
print('UPD_SCHEDULED pid=$pid');
|
||||
}, skip: skip != null, timeout: const Timeout(Duration(minutes: 5)));
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
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/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/download_manager.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/models.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/home_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/monitor_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 253 · 255 · 256 (관리자 지시 10/21): test/large_text_test 의 화면들을 실제 글꼴로 - 글자 1.6배 (412×900 · 900×412 · 1280×800) 와
|
||||
/// 폰 크기 글자 1.0 (412×900 세로 · 900×412 가로) 에서 넘치지 않고, 누르는 것 (단추 · 고르기 · 입력 칸) 이 화면 폭 안에 있다. ko · ja
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
final pages = <String, Widget Function(AppController, DownloadManager)>{
|
||||
'mkv': (c, d) => HomePage(c: c),
|
||||
'explorer': (c, d) => ExplorerPage(c: c),
|
||||
'rsync': (c, d) => ExplorerPage(c: c, rsync: true),
|
||||
'monitor': (c, d) => MonitorPage(c: c),
|
||||
'downloads': (c, d) => DownloadsPage(d: d),
|
||||
'settings': (c, d) => SettingsPage(c: c),
|
||||
};
|
||||
final cases = [
|
||||
for (final s in [const Size(412, 900), const Size(900, 412), const Size(1280, 800)]) (s, 1.6),
|
||||
for (final s in [const Size(412, 900), const Size(900, 412)]) (s, 1.0),
|
||||
];
|
||||
for (final lang in ['ko', 'ja']) {
|
||||
for (final MapEntry(key: name, value: page) in pages.entries) {
|
||||
for (final (size, scale) in cases) {
|
||||
final tag = '${name}_${lang}_${size.width.round()}x${size.height.round()}_s$scale';
|
||||
testWidgets('253 · 255 · 256 $tag', (t) async {
|
||||
t.view.physicalSize = size;
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_bigtext_');
|
||||
appTempInto(tmp);
|
||||
final video = p.join(tmp.path, '아주 긴 이름의 동영상 파일 - 시험용 2026.mp4');
|
||||
File(video).writeAsStringSync('v');
|
||||
Directory(p.join(tmp.path, 'sub')).createSync();
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..explorerPaths = [tmp.path, tmp.path]
|
||||
..rsyncPaths = [tmp.path, tmp.path]
|
||||
..copyTasks = [CopyTask(id: '1', sources: [tmp.path], dest: p.join(tmp.path, 'sub'), method: 'rsync', options: '-av')]
|
||||
..liveSyncPairs = [LiveSyncPair(tmp.path, p.join(tmp.path, 'x'))];
|
||||
c.videos.add(VideoItem(video));
|
||||
// 256: 폰 가로에서 작업 기록을 켜도 목록이 여러 줄 보이는지 - 동영상 5개 · 기록 몇 줄
|
||||
for (var i = 2; i <= 5; i++) {
|
||||
final f = p.join(tmp.path, '에피소드 0$i.mp4');
|
||||
File(f).writeAsStringSync('v');
|
||||
c.videos.add(VideoItem(f));
|
||||
}
|
||||
for (var i = 1; i <= 3; i++) {
|
||||
c.note('시험 기록 $i');
|
||||
}
|
||||
c.selected = c.videos.first;
|
||||
final d = DownloadManager(backends: const [], settings: () => c.settings, readClipboard: () async => null);
|
||||
addTearDown(d.dispose);
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!),
|
||||
home: page(c, d),
|
||||
),
|
||||
));
|
||||
for (var i = 0; i < 15; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 40)));
|
||||
await t.pump();
|
||||
}
|
||||
if (out.isNotEmpty) {
|
||||
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => r.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'bigtext_$tag.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '$tag 넘침');
|
||||
// 넘침 오류 없이 옆으로 밀려나는 것도 본다: 누르는 것은 화면 폭 안 (가로로 넘기는 막대 안의 것은 빼고)
|
||||
final hit = find.byWidgetPredicate((w) =>
|
||||
w is ButtonStyleButton || w is IconButton || w is Checkbox || w is DropdownButton || w is TextField || w is ChoiceChip || w is Radio);
|
||||
final off = <String>[];
|
||||
for (final e in hit.evaluate()) {
|
||||
final inRow = e.findAncestorWidgetOfExactType<SingleChildScrollView>()?.scrollDirection == Axis.horizontal ||
|
||||
e.findAncestorWidgetOfExactType<ListView>()?.scrollDirection == Axis.horizontal;
|
||||
if (inRow) continue;
|
||||
final r = t.getRect(find.byElementPredicate((x) => x == e));
|
||||
if (r.left < -0.5 || r.right > size.width + 0.5) off.add('${e.widget.runtimeType} $r');
|
||||
}
|
||||
expect(off, isEmpty, reason: '$tag: 화면 밖 ${off.join(' · ')}');
|
||||
await t.pumpWidget(const SizedBox());
|
||||
await t.pump(const Duration(seconds: 1));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
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/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/settings_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 131 (관리자 지시 10/21): 환경 설정의 YouTube 쿠키 칸 (앱 안 브라우저 로그인 · 평문 쿠키 파일 안내 · 넘길 사이트 · [쿠키 파일 지우기]) 을
|
||||
/// ko · en · ja × 412 · 960 으로 찍고, [사용 안 함] 으로 바꾸면 내보낸 쿠키 파일 (평문) 이 지워지는지 본다.
|
||||
/// 사용자 데이터는 쓰지 않는다 (시험 폴더의 가짜 쿠키 파일, 설정은 메모리만). 실행: … -d windows (JJ_SHOT_DIR 에 PNG)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('쿠키 칸 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_cookieshot_');
|
||||
final web = Directory(p.join(tmp.path, 'webview'))..createSync();
|
||||
final cookie = File(p.join(web.path, 'cookies_youtube.txt'))..writeAsStringSync('# Netscape HTTP Cookie File\n.youtube.com\tTRUE\t/\tTRUE\t0\tSID\tfake\n');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings.webViewDataDir = web.path;
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: SettingsPage(c: c)),
|
||||
));
|
||||
Future<void> settle([int n = 10]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
if (out.isEmpty) return;
|
||||
final b = shotKey.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, 'cookie_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle(20);
|
||||
// 세로로 넘기는 설정 목록에서 [쿠키 파일 지우기] (칸의 맨 아래) 가 보일 때까지
|
||||
final list = find.byWidgetPredicate((x) => x is Scrollable && x.axisDirection == AxisDirection.down).first;
|
||||
await t.scrollUntilVisible(find.text(tr('쿠키 파일 지우기')), 200, scrollable: list, maxScrolls: 200);
|
||||
// 칸 제목이 위 묶음 바로가기 막대 아래 (화면 1/4 쯤) 에 오게 - 그래야 고르기 · 안내 · 칩 · 버튼이 함께 보인다
|
||||
await t.runAsync(() => Scrollable.ensureVisible(t.element(find.text(tr('YouTube 쿠키 (로봇 확인이 나올 때)'))), alignment: 0.25));
|
||||
await settle();
|
||||
expect(find.text(tr('YouTube 쿠키 (로봇 확인이 나올 때)')), findsOneWidget, reason: '칸 제목도 화면 안');
|
||||
expect(find.text(tr('쿠키 파일 지우기')), findsOneWidget, reason: '앱 안 브라우저 로그인 (처음 값) 이면 안내 · 지우기 버튼');
|
||||
await shot('1_internal');
|
||||
// [사용 안 함] 으로 바꾸면 내보낸 평문 쿠키 파일을 지운다 (131)
|
||||
await t.tap(find.text(tr('앱 안 브라우저 (권장)')).first);
|
||||
await settle();
|
||||
await t.tap(find.text(tr('사용 안 함')).last);
|
||||
await settle(15);
|
||||
expect(c.settings.ytCookiesBrowser, '');
|
||||
expect(cookie.existsSync(), isFalse, reason: '쓰지 않게 바꾸면 내보낸 쿠키 파일을 지움');
|
||||
expect(find.text(tr('쿠키 파일 지우기')), findsNothing, reason: '파일이 없고 쓰지 않으면 안내도 숨김');
|
||||
await shot('2_off');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
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/app/download_manager.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/download_detect.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/services/downloader.dart';
|
||||
import 'package:jj_mkvmaker/ui/downloads_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 받는 척만 하는 백엔드 (네트워크 없음)
|
||||
class _Backend implements DownloadBackend {
|
||||
@override
|
||||
final DownloadKind kind;
|
||||
_Backend(this.kind);
|
||||
@override
|
||||
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
|
||||
@override
|
||||
Future<void> start(DownloadTask t, void Function() changed) async {
|
||||
t
|
||||
..state = DownloadState.downloading
|
||||
..progress = 0.4
|
||||
..speed = '1.2MB/s'
|
||||
..title = kind == DownloadKind.video ? 'Test video' : 'ubuntu-24.04.1-desktop-amd64.iso';
|
||||
changed();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(DownloadTask t) async => t.state = DownloadState.paused;
|
||||
@override
|
||||
Future<void> cancel(DownloadTask t) async => t.state = DownloadState.cancelled;
|
||||
@override
|
||||
Future<void> shutdown() async {}
|
||||
}
|
||||
|
||||
/// 185 (관리자 지시 10/21): [다운로드 취소] 를 누르면 묻는 창 - [계속 받기] 면 그대로 받는다. ko · en · ja × 412 · 960
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('185 계속 받기 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_dlcancel_');
|
||||
final d = DownloadManager(
|
||||
backends: [_Backend(DownloadKind.video), _Backend(DownloadKind.torrent)],
|
||||
settings: () => AppSettings()..downloadRoot = tmp.path,
|
||||
readClipboard: () async => null,
|
||||
);
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: DownloadsPage(d: d)),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
if (out.isEmpty) return;
|
||||
final b = shotKey.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, 'dlcancel_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
d.add('https://youtu.be/AAA111');
|
||||
d.add('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567');
|
||||
await settle();
|
||||
await t.tap(find.text(tr('전체 선택')));
|
||||
await settle();
|
||||
await t.tap(find.text(tr('다운로드 취소')));
|
||||
await settle();
|
||||
expect(find.text(trf('선택한 다운로드 {0}개를 취소할까요? 잠깐 멈추려면 [일시정지] 를 쓰세요.', [2])), findsOneWidget);
|
||||
await shot('1_ask');
|
||||
await t.tap(find.text(tr('계속 받기')));
|
||||
await settle();
|
||||
expect(d.tasks.every((x) => x.state == DownloadState.downloading), isTrue, reason: '[계속 받기] 면 그대로');
|
||||
await shot('2_kept');
|
||||
expect(p.basename(tmp.path), startsWith('jj_dlcancel_'));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import 'dart:async';
|
||||
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/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/ai_settings.dart';
|
||||
import 'package:jj_mkvmaker/ui/archive_ui.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// F1 (관리자 지시 10/21, 15 찾음): 비밀번호 · 키 칸 이름은 짧게, 설명은 칸 아래 - ZIP 으로 묶기 창 · AI 인터넷 서비스 추가 창을
|
||||
/// ko · en · ja × 412 · 960 으로 찍는다 (SFTP 추가 창은 windows_trunc_shot_test). 아무것도 만들지 않는다 (창만 열고 닫음).
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('F1 칸 이름 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_f1shot_');
|
||||
final file = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('a');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 60)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(3);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
if (out.isEmpty) return;
|
||||
final b = shotKey.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, 'f1_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
Future<void> closeAll() async {
|
||||
navKey.currentState!.popUntil((r) => r.isFirst);
|
||||
await settle();
|
||||
}
|
||||
|
||||
unawaited(createZipFlow(navKey.currentContext!, c, [file.path], dir: tmp.path));
|
||||
await settle(12);
|
||||
await shot('zip');
|
||||
await closeAll();
|
||||
unawaited(editAiService(navKey.currentContext!, c));
|
||||
await settle(12);
|
||||
await shot('ai');
|
||||
// F2 (10/21): ComfyUI 로 바꾸면 주소 예시 · 모델 도움 글 - 3줄까지 (잘리지 않게)
|
||||
await t.tap(find.text('Automatic1111 · Forge').first);
|
||||
await settle();
|
||||
await t.tap(find.text('ComfyUI').last);
|
||||
await settle();
|
||||
await shot('ai_comfy');
|
||||
await closeAll();
|
||||
expect(Directory(tmp.path).listSync().map((e) => p.basename(e.path)), ['a.txt'], reason: '창만 열고 닫음 - 아무것도 만들지 않음');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
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/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/models.dart';
|
||||
import 'package:jj_mkvmaker/core/output_paths.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/services/media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/home_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 동영상 하나 (음성 2 · 자막 1) 로 보이는 가짜 FFmpeg (실제로 만들지 않음)
|
||||
class _Tool implements MediaTool {
|
||||
@override
|
||||
Future<String?> version() async => 'ffmpeg test';
|
||||
@override
|
||||
Future<Set<String>> encoders() async => {'libx264'};
|
||||
@override
|
||||
Future<MediaInfo> probe(String path) async => MediaInfo(duration: const Duration(minutes: 24), streams: [
|
||||
const StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080),
|
||||
const StreamInfo(index: 1, type: 'audio', codec: 'aac', language: 'jpn'),
|
||||
const StreamInfo(index: 2, type: 'audio', codec: 'aac', language: 'kor'),
|
||||
const StreamInfo(index: 3, type: 'subtitle', codec: 'subrip', language: 'kor'),
|
||||
]);
|
||||
@override
|
||||
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {}
|
||||
@override
|
||||
void cancel() {}
|
||||
@override
|
||||
void cancelTagged(Object tag) {}
|
||||
}
|
||||
|
||||
/// F5 · 206 (관리자 지시 10/21): MKV 자세히 칸 - 오른쪽 아래 떠 있는 [이동] 단추가 마지막 줄 ("출력: …") 이나 칩을 가리지 않는다.
|
||||
/// 넓은 화면은 끝까지 내리면 마지막 줄이 단추 위로 (목록 끝 여백), 좁은 화면 (412) 은 단추가 칩 줄 아래에 놓인다.
|
||||
/// 412 · 960×600 · 1280×720 × 글자 1.0 · 1.6 · 낮은 화면 900×412 · 1280×480 (F7) × ko · ja. [자막 파일 추가] 도 단추에 가리지 않는다. 단추와 출력 줄 · 칩이 겹치는지 자리로 잰다
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
|
||||
for (final lang in ['ko', 'ja']) {
|
||||
for (final (w, h, scale) in [
|
||||
(412, 900, 1.0), (412, 900, 1.6), (960, 600, 1.0), (960, 600, 1.6), (1280, 720, 1.0), (1280, 720, 1.6),
|
||||
// F7 (10/21): 높이가 낮은 화면 (폰 가로 · 낮은 창) - [이동] 이 [자막 파일 추가] 를 가렸다
|
||||
(900, 412, 1.0), (900, 412, 1.6), (1280, 480, 1.0),
|
||||
]) {
|
||||
testWidgets('MKV 자세히 칸 이동 단추 $lang ${w}x$h 글자 $scale', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_mkvdetail_');
|
||||
appTempInto(tmp);
|
||||
final file = (File(p.join(tmp.path, '[시험] 아주 긴 이름의 동영상 - 1화 고화질.mp4'))..writeAsBytesSync([0])).path;
|
||||
// 같은 폴더의 외부 자막 (문자셋 고르기까지 든 줄 - F6)
|
||||
File(p.join(tmp.path, '[시험] 아주 긴 이름의 동영상 - 1화 고화질.ko.srt')).writeAsStringSync('1\n00:00:01,000 --> 00:00:02,000\n안녕\n');
|
||||
final c = AppController(PlatformServices(mediaTool: _Tool(), storage: DesktopStorageService()));
|
||||
await t.runAsync(() async {
|
||||
await c.init();
|
||||
await c.addVideos([file]);
|
||||
});
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (ctx, child) => MediaQuery(
|
||||
data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!),
|
||||
home: HomePage(c: c),
|
||||
),
|
||||
));
|
||||
Future<void> settle([int n = 8]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(2);
|
||||
if (out.isEmpty) return;
|
||||
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => r.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'mkvdetail_${name}_${lang}_${w}x${h}_s$scale.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle();
|
||||
// 좁은 화면은 목록 · 자세히가 위아래 - 동영상 줄을 눌러 자세히를 연다 (넓은 화면은 처음부터 보임)
|
||||
final outputLine = find.text(trf('출력: {0}', [outputMkvPath(file)]));
|
||||
if (outputLine.evaluate().isEmpty && find.text(tr('자막')).evaluate().isEmpty) {
|
||||
await t.tap(find.textContaining('아주 긴 이름의 동영상').first);
|
||||
await settle();
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '넘침 (처음 자리)');
|
||||
final fab = find.byType(FloatingActionButton);
|
||||
// 206: 처음 자리에서 칩 (음성 n개 등) 이 [이동] 단추에 가리지 않음 (좁은 화면은 단추가 칩 줄 아래에 놓임)
|
||||
if (fab.evaluate().isNotEmpty) {
|
||||
final b = t.getRect(fab.first);
|
||||
for (final text in [trf('음성 {0}개', [2]), trf('영상 {0}', ['h264']), '1920×1080', tr('자막 파일 추가')]) {
|
||||
if (find.text(text).evaluate().isEmpty) continue;
|
||||
final r = t.getRect(find.text(text));
|
||||
expect(r.overlaps(b), isFalse, reason: '칩 "$text" 이 단추에 가리지 않음 ($r · 단추 $b)');
|
||||
}
|
||||
}
|
||||
// 글자가 크면 출력 줄이 아래에 있어 아직 만들어지지 않았을 수 있다 - 자세히 칸을 (칩 자리에서) 끌어 내려 찾는다
|
||||
if (outputLine.evaluate().isEmpty) {
|
||||
final down = find.byWidgetPredicate((x) => x is Scrollable && x.axisDirection == AxisDirection.down);
|
||||
final pane = t.state<ScrollableState>(find.ancestor(of: find.text('1920×1080'), matching: down).first);
|
||||
for (var i = 0; i < 10 && outputLine.evaluate().isEmpty; i++) {
|
||||
pane.position.jumpTo(pane.position.maxScrollExtent);
|
||||
await settle(3);
|
||||
}
|
||||
}
|
||||
expect(outputLine, findsOneWidget, reason: '자세히 칸의 출력 줄');
|
||||
await shot('1_top');
|
||||
expect(t.takeException(), isNull, reason: '넘침 (출력 줄까지)');
|
||||
// F5: 자세히 칸을 끝까지 내리면 출력 줄이 단추 위로 (목록 끝 여백)
|
||||
final scroll = t.state<ScrollableState>(find.ancestor(of: outputLine, matching: find.byType(Scrollable)).first);
|
||||
scroll.position.jumpTo(scroll.position.maxScrollExtent);
|
||||
await settle();
|
||||
await shot('2_end');
|
||||
expect(t.takeException(), isNull, reason: '넘침 (끝)');
|
||||
if (fab.evaluate().isNotEmpty && outputLine.evaluate().isNotEmpty) {
|
||||
final b = t.getRect(fab.first), line = t.getRect(outputLine);
|
||||
expect(line.overlaps(b), isFalse, reason: '끝까지 내리면 출력 줄이 단추에 가리지 않음 (줄 $line · 단추 $b)');
|
||||
}
|
||||
expect(c.videos.single.status, isNot(JobStatus.running));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import 'dart:async';
|
||||
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/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/models.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/services/media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/home_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 시험이 끝내라고 할 때까지 FFmpeg 이 도는 척 (끝나면 임시 출력 파일을 씀) - test/batch_test.dart 의 _EndTool 과 같음
|
||||
class _EndTool implements MediaTool {
|
||||
final runs = <Object?, Completer<void>>{};
|
||||
@override
|
||||
Future<String?> version() async => 'ffmpeg test';
|
||||
@override
|
||||
Future<Set<String>> encoders() async => {'libx264'};
|
||||
@override
|
||||
Future<MediaInfo> probe(String path) async => const MediaInfo(duration: Duration(seconds: 1));
|
||||
@override
|
||||
Future<void> runFfmpeg(List<String> args, {Duration? duration, ProgressCallback? onProgress, Object? tag}) async {
|
||||
final c = runs[tag] = Completer<void>();
|
||||
try {
|
||||
await c.future;
|
||||
} finally {
|
||||
runs.remove(tag);
|
||||
}
|
||||
File(args.last).writeAsStringSync('NEW');
|
||||
}
|
||||
|
||||
void _stop(Completer<void>? c) {
|
||||
if (c != null && !c.isCompleted) c.completeError(MediaToolException('사용자가 취소했습니다.'));
|
||||
}
|
||||
|
||||
void finish(Object tag) => runs[tag]!.complete();
|
||||
@override
|
||||
void cancel() => runs.values.toList().forEach(_stop);
|
||||
@override
|
||||
void cancelTagged(Object tag) => _stop(runs[tag]);
|
||||
}
|
||||
|
||||
/// 262 (관리자 지시 10/21): MKV 묶음에서 한 동영상이 끝나는 바로 그때 - 그 줄의 ■ 자리에 오는 ⟳ 는 묶음이 끝날 때까지 눌리지 않고
|
||||
/// (✕ 도 밀려오지 않음), 끝나면 둘 다 성공. ko · en · ja × 412 · 960 (가짜 FFmpeg, 시험 폴더 안)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(412, 900), (960, 600)]) {
|
||||
testWidgets('262 MKV 끝나는 순간 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_mkvend_');
|
||||
appTempInto(tmp);
|
||||
final files = [for (final n in ['episode_01', 'episode_02']) (File(p.join(tmp.path, '$n.mp4'))..writeAsBytesSync([0])).path];
|
||||
final tool = _EndTool();
|
||||
final c = AppController(PlatformServices(mediaTool: tool, storage: DesktopStorageService()));
|
||||
late Future<void> building;
|
||||
await t.runAsync(() async {
|
||||
await c.init();
|
||||
c.settings.maxParallelJobs = 1;
|
||||
await c.addVideos(files);
|
||||
building = c.buildVideos([...c.videos]);
|
||||
for (var i = 0; i < 200 && tool.runs.isEmpty; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
});
|
||||
final [a, b] = c.videos;
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: HomePage(c: c)),
|
||||
));
|
||||
Future<void> settle([int n = 6]) async {
|
||||
for (var i = 0; i < n; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
|
||||
await t.pump();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
await settle(2);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
if (out.isEmpty) return;
|
||||
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => r.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'mkvend_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle();
|
||||
final stop = find.byTooltip(tr('이 동영상만 취소'));
|
||||
expect(stop, findsWidgets, reason: '도는 줄의 ■');
|
||||
final stopAt = t.getCenter(stop.first);
|
||||
await shot('1_running');
|
||||
// a 가 끝나고 b 가 시작 - a 의 ■ 자리를 누른다 (손가락이 ■ 를 누르려던 것)
|
||||
await t.runAsync(() async {
|
||||
tool.finish(a);
|
||||
for (var i = 0; i < 200 && !tool.runs.containsKey(b); i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
});
|
||||
await settle();
|
||||
expect(a.status, JobStatus.done);
|
||||
await t.tapAt(stopAt);
|
||||
await settle();
|
||||
expect(a.status, JobStatus.done, reason: '⟳ 로 대기가 되지 않음');
|
||||
expect(c.videos, contains(a), reason: '✕ 로 빠지지 않음');
|
||||
await shot('2_a_done_b_running');
|
||||
await t.runAsync(() async {
|
||||
tool.finish(b);
|
||||
await building;
|
||||
});
|
||||
await settle();
|
||||
expect((a.status, b.status), (JobStatus.done, JobStatus.done));
|
||||
await shot('3_all_done');
|
||||
await t.pumpWidget(const SizedBox());
|
||||
await t.pump(const Duration(seconds: 2));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
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/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/net_guard.dart';
|
||||
import 'package:jj_mkvmaker/app/settings.dart';
|
||||
import 'package:jj_mkvmaker/core/models.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/home_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 261 · 266 (관리자 지시 10/21): test/mkv_portrait_detail_test 를 실제 글꼴로 - 폰 세로 (노트 10+ 412×869) · 작업 기록 켬 ·
|
||||
/// 감지한 언어 줄 · DNS 닿지 않음 줄이 있어도 [AI 자막 만들기] 가 스크롤 없이 보이고, DNS 줄은 한 줄 · [시스템 DNS 로 바꾸기] 도 그 줄에.
|
||||
/// ko · ja × 글자 1.0 · 1.6
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
|
||||
for (final lang in ['ko', 'ja']) {
|
||||
for (final scale in [1.0, 1.6]) {
|
||||
for (final (detected, dns) in [(false, false), (true, false), (true, true)]) {
|
||||
final tag = '${lang}_s${scale}_${detected ? 'detected' : 'plain'}${dns ? '_dns' : ''}';
|
||||
testWidgets('261 · 266 $tag', (t) async {
|
||||
t.view.physicalSize = const Size(412, 869);
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_mkvportrait_');
|
||||
appTempInto(tmp);
|
||||
final c = AppController(PlatformServices(
|
||||
mediaTool: ProcessMediaTool('x', 'y'),
|
||||
storage: DesktopStorageService(),
|
||||
createRecognizer: () => throw UnimplementedError(),
|
||||
createTranslator: () => throw UnimplementedError(),
|
||||
));
|
||||
c.settings.showLog = true;
|
||||
c.settings.moveTargets = [MoveTarget('보관', p.join(tmp.path, '보관'))];
|
||||
for (var i = 1; i <= 4; i++) {
|
||||
c.videos.add(VideoItem(p.join(tmp.path, '아주 긴 이름의 동영상 파일 $i.mp4')));
|
||||
}
|
||||
c.selected = c.videos.first..aiDetected = detected ? 'en' : null;
|
||||
if (dns) {
|
||||
NetGuard.instance.failure.value = DnsFailure('192.0.2.1', '답이 없습니다', DateTime(2026, 10, 12, 8, 42));
|
||||
addTearDown(() => NetGuard.instance.failure.value = null);
|
||||
}
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!),
|
||||
home: HomePage(c: c),
|
||||
),
|
||||
));
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 40)));
|
||||
await t.pump();
|
||||
}
|
||||
if (out.isNotEmpty) {
|
||||
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
|
||||
final img = await t.runAsync(() => r.toImage());
|
||||
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
|
||||
Directory(out).createSync(recursive: true);
|
||||
File(p.join(out, 'mkvportrait_$tag.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
expect(t.takeException(), isNull, reason: '$tag 넘침');
|
||||
final ai = find.text(tr('AI 자막 만들기'));
|
||||
expect(ai, findsOneWidget, reason: '$tag: 세부 정보 칸 아래로 밀림 (화면 밖이면 목록이 만들지 않는다)');
|
||||
final view = t.getRect(find.ancestor(of: ai, matching: find.byType(Scrollable)).first);
|
||||
final button = t.getRect(ai);
|
||||
expect(button.bottom, lessThanOrEqualTo(view.bottom), reason: '$tag: 버튼 $button · 세부 정보 칸 $view');
|
||||
if (dns) {
|
||||
final line = t.getRect(find.textContaining('192.0.2.1'));
|
||||
final sys = t.getRect(find.text(tr('시스템 DNS 로 바꾸기')));
|
||||
expect((sys.center.dy - line.center.dy).abs(), lessThan(4), reason: '$tag: 버튼이 같은 줄 (줄 $line · 버튼 $sys)');
|
||||
expect(line.height, lessThan(40 * scale), reason: '$tag: 한 줄');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
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/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/explorer_page.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// 264 · 265 (관리자 지시 10/21): 폴더를 합쳐 옮긴 뒤 - 알림에 "n개는 원본에 남음 (건너뜀)" · 펼쳐 둔 원본 폴더를 다시 읽어
|
||||
/// 옮겨진 파일은 보이지 않고 남은 것만 보인다. ko · en · ja × 960 · 1400 (두 창). 시험 폴더 안에서만 (실제 이동, 휴지통 쓰지 않음)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
for (final (w, h) in [(960, 600), (1400, 800)]) {
|
||||
testWidgets('264 · 265 이동 뒤 $lang ${w}x$h', (t) async {
|
||||
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_moveleft_');
|
||||
final left = p.join(tmp.path, 'left'), right = p.join(tmp.path, 'right');
|
||||
final d = p.join(left, 'D');
|
||||
File(p.join(d, 'same.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('old')
|
||||
..setLastModifiedSync(DateTime(2026, 1, 1));
|
||||
File(p.join(d, 'add.txt')).writeAsStringSync('a');
|
||||
File(p.join(right, 'D', 'same.txt'))
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync('NEWER');
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
||||
c.settings
|
||||
..explorerPaths = [left, right]
|
||||
..copyConflict = 'overwrite';
|
||||
final root = p.rootPrefix(tmp.path);
|
||||
ExplorerPage.debugVolumes = () => [(root, root.replaceAll(RegExp(r'[\\/]+$'), ''))];
|
||||
addTearDown(() => ExplorerPage.debugVolumes = null);
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(theme: buildTheme(), debugShowCheckedModeBanner: false, home: ExplorerPage(c: c)),
|
||||
));
|
||||
Future<void> settle(bool Function() done, {int rounds = 150}) async {
|
||||
for (var i = 0; i < rounds && !done(); i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 40)));
|
||||
await t.pump();
|
||||
}
|
||||
await t.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
Future<void> shot(String name) async {
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
if (out.isEmpty) return;
|
||||
final b = shotKey.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, 'moveleft_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
await settle(() => find.text('D').evaluate().length >= 2);
|
||||
// 왼쪽 D 를 눌러 펼치고 (고른 것) 안의 add.txt 가 보이게
|
||||
await t.tap(find.text('D').first);
|
||||
await settle(() => find.text('add.txt').evaluate().isNotEmpty);
|
||||
await shot('1_before');
|
||||
await t.tap(find.text(tr('이동')).first);
|
||||
await settle(() => find.widgetWithText(FilledButton, tr('이동')).evaluate().isNotEmpty);
|
||||
await t.tap(find.widgetWithText(FilledButton, tr('이동')));
|
||||
await settle(() => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.byType(SnackBar).evaluate().isNotEmpty);
|
||||
await settle(() => false, rounds: 10); // 알림이 다 올라오게
|
||||
expect(File(p.join(right, 'D', 'add.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(d, 'same.txt')).readAsStringSync(), 'old', reason: '받는 쪽이 더 새것이라 원본에 남음');
|
||||
expect(find.textContaining(trf('{0}개는 원본에 남았습니다 (건너뜀).', [1])), findsOneWidget, reason: '264: 알림에');
|
||||
expect(find.text('add.txt'), findsNothing, reason: '265: 원본 D 를 다시 읽음');
|
||||
expect(find.text('same.txt'), findsOneWidget);
|
||||
await shot('2_after');
|
||||
ScaffoldMessenger.of(t.element(find.byType(ExplorerPage))).clearSnackBars();
|
||||
await settle(() => false, rounds: 20);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,9 @@ void main() {
|
|||
await t.runAsync(() async {
|
||||
await lock.load();
|
||||
await lock.setMaster('시험-마스터');
|
||||
await lock.setSuper('시험-최상');
|
||||
});
|
||||
lock.unlocked = false;
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
|
|
@ -66,10 +68,21 @@ void main() {
|
|||
await settle();
|
||||
}
|
||||
|
||||
// ① 최상 비밀번호 - 초기화 (무엇을 초기화할지) - 셋 다 체크해 긴 설명까지 보이게
|
||||
unawaited(showSuperReset(navKey.currentContext!, c, lock));
|
||||
// ⓪ 관리자 지시 (10/21): 사용자가 가는 길 그대로 - 마스터 입력 창에 최상 비밀번호를 넣으면 초기화 창 (가짜 저장소 · 가짜 비밀번호)
|
||||
unawaited(askMaster(navKey.currentContext!, c, lock));
|
||||
await settle();
|
||||
await shot('0_master');
|
||||
await t.enterText(find.byType(TextField), '시험-최상');
|
||||
await t.tap(find.byType(FilledButton).last);
|
||||
for (var i = 0; i < 100 && find.byType(CheckboxListTile).evaluate().isEmpty; i++) {
|
||||
await settle(1);
|
||||
}
|
||||
// ① 최상 비밀번호 - 초기화 (무엇을 초기화할지): 체크 3개, 처음 체크는 마스터만
|
||||
expect(find.byType(CheckboxListTile), findsNWidgets(3), reason: '마스터 창에 최상 비밀번호 → 초기화 창');
|
||||
expect([for (final e in find.byType(CheckboxListTile).evaluate()) (e.widget as CheckboxListTile).value], [true, false, false],
|
||||
reason: '처음 체크는 마스터만');
|
||||
await shot('1_super');
|
||||
// 셋 다 체크해 긴 설명까지 보이게
|
||||
for (final cb in find.byType(CheckboxListTile).evaluate().toList()) {
|
||||
final tile = cb.widget as CheckboxListTile;
|
||||
if (tile.value != true && tile.onChanged != null) tile.onChanged!(true);
|
||||
|
|
@ -88,6 +101,7 @@ void main() {
|
|||
await shot('4_forgot');
|
||||
await closeAll();
|
||||
expect(lock.hasMaster, isTrue, reason: '찍기만 하고 지우지 않음');
|
||||
expect(lock.hasSuper, isTrue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import 'dart:async';
|
||||
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/app/app_controller.dart';
|
||||
import 'package:jj_mkvmaker/app/i18n_controller.dart';
|
||||
import 'package:jj_mkvmaker/core/sftp.dart';
|
||||
import 'package:jj_mkvmaker/l10n/tr.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
||||
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
||||
import 'package:jj_mkvmaker/services/platform_services.dart';
|
||||
import 'package:jj_mkvmaker/ui/sftp_settings.dart';
|
||||
import 'package:jj_mkvmaker/ui/theme.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../test/support/sftp_server.dart';
|
||||
import 'support/temp_dir.dart';
|
||||
|
||||
/// [개인 키 파일 고르기] 가 고르는 파일을 시험이 정한다 (실제 파일 창은 열지 않음)
|
||||
class _PickStorage extends DesktopStorageService {
|
||||
String? next;
|
||||
@override
|
||||
Future<List<String>> pickFiles({required String title, required List<String> extensions, String? initialDirectory}) async =>
|
||||
next == null ? const [] : [next!];
|
||||
}
|
||||
|
||||
/// 관리자 지시 (10/21): SFTP 키 로그인의 사용자 글 - 키 고른 뒤 안내 · 암호 필요 · 암호 틀림 · 서버 거부 · .ppk 안내 를
|
||||
/// ko · en · ja × 412 로 찍는다. 실제 시험 서버 (paramiko) · ssh-keygen 으로 만든 시험 키만 쓴다 (시험 폴더 안, 설정은 메모리만)
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
|
||||
final keygen = r'C:\Windows\System32\OpenSSH\ssh-keygen.exe';
|
||||
final skip = !sftpTestServerAvailable || !File(keygen).existsSync();
|
||||
|
||||
for (final lang in ['ko', 'en', 'ja']) {
|
||||
testWidgets('SSH 키 글 $lang 412x900', (t) async {
|
||||
t.view.physicalSize = const Size(412, 900);
|
||||
t.view.devicePixelRatio = 1;
|
||||
addTearDown(t.view.reset);
|
||||
await t.runAsync(() => i18n.apply(lang, save: false));
|
||||
addTearDown(() => i18n.apply('ko', save: false));
|
||||
final tmp = testTempDir('jj_sshkeyshot_');
|
||||
final root = Directory(p.join(tmp.path, 'root'))..createSync();
|
||||
Future<String> gen(String name, String pass) async {
|
||||
final f = p.join(tmp.path, name);
|
||||
final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', pass, '-f', f, '-C', 'jj-test']);
|
||||
expect(r.exitCode, 0, reason: '${r.stderr}');
|
||||
return f;
|
||||
}
|
||||
|
||||
late SftpTestServer server;
|
||||
late String keyA, keyB;
|
||||
await t.runAsync(() async {
|
||||
keyA = await gen('a', 'kp');
|
||||
keyB = await gen('b', '');
|
||||
server = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'hostkey'), password: null, pubkey: '$keyA.pub');
|
||||
});
|
||||
addTearDown(() => t.runAsync(server.stop));
|
||||
final ppk = File(p.join(tmp.path, 'key.ppk'))
|
||||
..writeAsStringSync('PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: x\nPublic-Lines: 1\nAAAA\n');
|
||||
final storage = _PickStorage();
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: storage));
|
||||
final old = SftpServer(id: 's1', name: 'NAS', host: '127.0.0.1', port: server.port, user: 'jj', hostKeyFingerprint: server.fingerprint);
|
||||
final navKey = GlobalKey<NavigatorState>();
|
||||
final shotKey = GlobalKey();
|
||||
await t.pumpWidget(RepaintBoundary(
|
||||
key: shotKey,
|
||||
child: MaterialApp(
|
||||
theme: buildTheme(), debugShowCheckedModeBanner: false, navigatorKey: navKey, home: const Scaffold(body: SizedBox.expand())),
|
||||
));
|
||||
Future<void> settle(bool Function() done, {int rounds = 150}) async {
|
||||
for (var i = 0; i < rounds && !done(); i++) {
|
||||
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 40)));
|
||||
await t.pump();
|
||||
}
|
||||
await t.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Future<void> shot(String name, String expectText) async {
|
||||
final f = find.textContaining(expectText);
|
||||
await settle(() => f.evaluate().isNotEmpty);
|
||||
expect(f, findsOneWidget, reason: name);
|
||||
await t.ensureVisible(f);
|
||||
await settle(() => false, rounds: 5);
|
||||
expect(t.takeException(), isNull, reason: '$name 넘침');
|
||||
if (out.isEmpty) return;
|
||||
final b = shotKey.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, 'sshkey_${name}_${lang}_412.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||
}
|
||||
|
||||
Future<void> pick(String file) async {
|
||||
storage.next = file;
|
||||
final b = find.byIcon(Icons.key_outlined);
|
||||
await t.ensureVisible(b);
|
||||
await t.tap(b);
|
||||
}
|
||||
|
||||
Future<void> testConnection() async {
|
||||
final b = find.text(tr('연결 확인'));
|
||||
await t.ensureVisible(b);
|
||||
await t.tap(b);
|
||||
await settle(() => find.byType(LinearProgressIndicator).evaluate().isNotEmpty, rounds: 20);
|
||||
await settle(() => find.byType(LinearProgressIndicator).evaluate().isEmpty, rounds: 400);
|
||||
}
|
||||
|
||||
unawaited(editSftpServer(navKey.currentContext!, c, old: old));
|
||||
await settle(() => find.text(tr('연결 확인')).evaluate().isNotEmpty);
|
||||
// ① PuTTY 키를 고르면 바꾸는 법
|
||||
await pick(ppk.path);
|
||||
await shot('1_ppk', 'Export OpenSSH key');
|
||||
// ② 암호가 걸린 키를 고르면 "아래에 키 암호를"
|
||||
await pick(keyA);
|
||||
await shot('2_picked', trf('개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)', [tr('개인 키 암호')]));
|
||||
// ③ 키 암호 없이 [연결 확인]
|
||||
await testConnection();
|
||||
await shot('3_needpass', tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'));
|
||||
// ④ 틀린 키 암호
|
||||
await t.enterText(find.widgetWithText(TextField, tr('개인 키 암호')), 'nope');
|
||||
await testConnection();
|
||||
await shot('4_badpass', tr('개인 키의 암호가 맞지 않습니다'));
|
||||
// ⑤ 서버가 모르는 키 (짝 공개 키가 서버에 없음)
|
||||
await pick(keyB);
|
||||
await settle(() => false, rounds: 5);
|
||||
await testConnection();
|
||||
await shot('5_rejected', 'authorized_keys');
|
||||
navKey.currentState!.popUntil((r) => r.isFirst);
|
||||
await settle(() => false, rounds: 10);
|
||||
expect(c.settings.sftpServers, isEmpty, reason: '[저장] 은 누르지 않음');
|
||||
}, skip: skip, timeout: const Timeout(Duration(minutes: 3)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1774,12 +1774,34 @@ class SettingsStore {
|
|||
});
|
||||
}
|
||||
|
||||
/// 이 판의 설정이 맡는 안전 저장소 열쇠 - 이 판이 만드는 정확한 이름 꼴만 ([_secretsOf] 와 같은 꼴).
|
||||
/// 관리자 지시 (10/21, 15 알림): 예전에는 머리글 (dav. · sftp. 등) 만 보아, 새 판이 같은 머리글로 새 종류의 열쇠 (예: dav.token.x) 를
|
||||
/// 쓰면 이 판에서 보통 저장할 때 지웠다 - 예전 판으로 되돌리면 사용자 비밀번호를 잃을 수 있었다 (40 의 비밀 값 쪽).
|
||||
/// 그 밖은 마스터 · 최상 (sec.) 이거나 이 판이 모르는 꼴 (다른 판이 쓴 열쇠) - 보통 저장에서는 건드리지 않는다
|
||||
static bool ownsSecret(String k) => _osKeys.containsValue(k) || k == _davServers || _ownForm.hasMatch(k);
|
||||
static final _ownForm = RegExp(r'^(dav\.pw|sftp\.pw|sftp\.key|sftp\.kp|smb\.pw|ftp\.pw|ai\.key)\..+$');
|
||||
|
||||
/// 관리자 판단 (10/21): 이 판이 모르는 꼴의 비밀 값 (다른 판이 쓴 열쇠 - 머리글이 같아도) 을 지운다 - 사용자가 초기화 창에서
|
||||
/// [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 부른다 (고르지 않았으면 남김). 마스터 · 최상 (sec.) 은 [마스터 비밀번호] 체크가 정해 여기서 남긴다.
|
||||
/// 지운 열쇠 수
|
||||
Future<int> clearForeignSecrets() async {
|
||||
final all = await secrets.readAll();
|
||||
var n = 0;
|
||||
for (final k in all.keys) {
|
||||
if (k.startsWith('sec.') || ownsSecret(k)) continue;
|
||||
await secrets.delete(k);
|
||||
_known.remove(k);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// 바뀐 비밀 값만 안전 저장소에 쓴다 (빈 값 · 지운 서버는 저장소에서도 지움). 저장소에 넣지 못한 열쇠를 돌려준다.
|
||||
Future<Set<String>> _writeSecrets(Map<String, String> values) async {
|
||||
if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])};
|
||||
final unsaved = <String>{};
|
||||
for (final k in {..._known.keys, ...values.keys}) {
|
||||
if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.') && !k.startsWith('smb.') && !k.startsWith('ftp.')) continue;
|
||||
if (!ownsSecret(k)) continue;
|
||||
final v = values[k];
|
||||
if (v == _known[k]) continue;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -113,7 +113,32 @@ class SftpServer {
|
|||
}
|
||||
|
||||
/// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데)
|
||||
enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, other }
|
||||
/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림)
|
||||
enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other }
|
||||
|
||||
/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true.
|
||||
/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다
|
||||
bool checkPrivateKey(String text) {
|
||||
if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat);
|
||||
try {
|
||||
return SSHKeyPair.isEncryptedPem(text);
|
||||
} catch (_) {
|
||||
throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat);
|
||||
}
|
||||
}
|
||||
|
||||
/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다
|
||||
List<SSHKeyPair> parsePrivateKey(String text, String passphrase) {
|
||||
final encrypted = checkPrivateKey(text);
|
||||
if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase);
|
||||
try {
|
||||
return SSHKeyPair.fromPem(text, encrypted ? passphrase : null);
|
||||
} catch (_) {
|
||||
// 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다)
|
||||
if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase);
|
||||
throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat);
|
||||
}
|
||||
}
|
||||
|
||||
/// SFTP 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다
|
||||
class SftpException extends DavException {
|
||||
|
|
@ -187,13 +212,10 @@ class SftpRemote implements RemoteClient {
|
|||
List<SSHKeyPair>? keys;
|
||||
if (s.privateKey.isNotEmpty) {
|
||||
try {
|
||||
keys = SSHKeyPair.fromPem(s.privateKey, s.keyPassphrase.isEmpty ? null : s.keyPassphrase);
|
||||
} on SSHKeyDecryptError {
|
||||
sock.destroy();
|
||||
throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.auth);
|
||||
keys = parsePrivateKey(s.privateKey, s.keyPassphrase);
|
||||
} catch (_) {
|
||||
sock.destroy();
|
||||
throw SftpException(tr('개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)'), SftpFailure.auth);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
final ssh = SSHClient(
|
||||
|
|
@ -250,7 +272,12 @@ class SftpRemote implements RemoteClient {
|
|||
: tr('서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 "오래된 ssh-rsa 허용" 을 켜 보세요'),
|
||||
SftpFailure.other);
|
||||
}
|
||||
if (e is SSHAuthError) return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth);
|
||||
if (e is SSHAuthError) {
|
||||
// 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다
|
||||
if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected);
|
||||
if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth);
|
||||
return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth);
|
||||
}
|
||||
return SftpException('$e', SftpFailure.other);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ Map<String, String> _fallback = const {};
|
|||
/// 지금 화면 언어 코드 (ko · en · ja · zh-Hans …)
|
||||
String get uiLanguage => _lang;
|
||||
|
||||
/// 문장 여럿을 한 줄로 잇는다 - 한국어 · 영어는 빈칸, 일본어 · 중국어는 붙여서 (。 뒤에 빈칸을 두지 않음, 관리자 지시 10/21)
|
||||
String joinSentences(Iterable<String> parts) =>
|
||||
parts.where((x) => x.isNotEmpty).join(_lang.startsWith('ja') || _lang.startsWith('zh') ? '' : ' ');
|
||||
|
||||
/// 화면 언어와 사전을 바꾼다. [fallback] 은 사전에 없는 글자를 찾을 사전 (보통 영어).
|
||||
void setTranslations(String lang, Map<String, String> dict, [Map<String, String> fallback = const {}]) {
|
||||
_lang = lang;
|
||||
|
|
|
|||
|
|
@ -262,7 +262,10 @@ Future<void> main(List<String> args) async {
|
|||
// 99: 시작 메뉴 바로 가기 (앱 ID) · jjmkvmaker:// 연결 - 알림이 JJ_MKVMaker 로 보이고 눌러서 열린다 (설정에서 끔)
|
||||
StartMenu.setProcessAppId();
|
||||
final shell0 = services.shell;
|
||||
if (controller.settings.startMenuShortcut) {
|
||||
if (StartMenu.userAreaLocked()) {
|
||||
// 관리자 지시 (10/20): 시험판 · 시험 (따로 데이터) - 사용자 바로 가기 · 주소 연결은 그대로 (StartMenu 가 막음), 작업 기록에 한 줄
|
||||
controller.note(tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다'));
|
||||
} else if (controller.settings.startMenuShortcut) {
|
||||
unawaited(StartMenu.ensure().then((ok) {
|
||||
if (shell0 is DesktopShell) shell0.toastAppReady = ok;
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -6,11 +6,53 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'app_paths.dart';
|
||||
import 'data_dir_override.dart';
|
||||
|
||||
import '../../core/app_update.dart';
|
||||
import '../../services/updater.dart';
|
||||
import '../../l10n/tr.dart';
|
||||
|
||||
/// 관리자 지시 (10/20): 업데이트 시험 길 - GitHub 대신 이 PC 의 zip (`JJ_UPDATE_LOCAL_ZIP=zip 경로;sha256`).
|
||||
/// 시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켰을 때만 받는다 (사용자 판에서는 무시). sha256 이 없거나 zip 과 다르면 거절
|
||||
class LocalUpdateZip {
|
||||
final String path;
|
||||
final String sha256;
|
||||
const LocalUpdateZip(this.path, this.sha256);
|
||||
|
||||
/// 환경 변수에서. 시험 길이 아니면 null, 값이 틀리면 [UpdateException]
|
||||
static LocalUpdateZip? fromEnvironment([Map<String, String>? env, String? dataDir]) {
|
||||
final e = env ?? Platform.environment;
|
||||
final raw = e['JJ_UPDATE_LOCAL_ZIP']?.trim() ?? '';
|
||||
if (raw.isEmpty || (env == null ? dataDirOverride : dataDir) == null) return null;
|
||||
final i = raw.lastIndexOf(';');
|
||||
final hash = i < 0 ? '' : raw.substring(i + 1).trim().toLowerCase();
|
||||
if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(hash)) {
|
||||
throw UpdateException(tr('[시험 길] JJ_UPDATE_LOCAL_ZIP 에 sha256 이 없습니다 (zip 경로;sha256)'));
|
||||
}
|
||||
return LocalUpdateZip(raw.substring(0, i).trim(), hash);
|
||||
}
|
||||
|
||||
/// zip 이름 (`JJ_MKVMaker_v판_win64.zip`) 의 판
|
||||
String? get version => RegExp(r'_v(\d{4}\.\d{2}\.\d{2}_\d{3})_win64\.zip$', caseSensitive: false).firstMatch(p.basename(path))?.group(1);
|
||||
|
||||
ReleaseInfo release() {
|
||||
final v = version;
|
||||
final f = File(path);
|
||||
if (v == null || !f.existsSync()) throw UpdateException(trf('[시험 길] zip 을 읽을 수 없습니다: {0}', [path]));
|
||||
return ReleaseInfo(
|
||||
version: v,
|
||||
tag: 'v$v',
|
||||
name: '[${tr('시험 길')}] v$v',
|
||||
notes: '${tr('[시험 길] GitHub 대신 이 PC 의 zip 으로 업데이트합니다 (JJ_UPDATE_LOCAL_ZIP). 시험용 데이터 폴더로 켰을 때만 쓰입니다.')}\n$path',
|
||||
pageUrl: '',
|
||||
zipUrl: Uri.file(path).toString(),
|
||||
zipName: p.basename(path),
|
||||
zipSize: f.lengthSync(),
|
||||
sha256: sha256,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GitHub Release 로 업데이트 (Windows)
|
||||
///
|
||||
/// 1. releases/latest 조회 → 2. zip 받기 · SHA256 확인 · 압축 풀기
|
||||
|
|
@ -27,12 +69,17 @@ class GitHubUpdater implements Updater {
|
|||
/// (예전에는 시험이 %TEMP% 에 jj_mkvmaker_update_1.9.0 · .ps1 · .log 를 남겼다, 관리자 지시 10/20)
|
||||
final String? tempRoot;
|
||||
|
||||
/// 시험 길 (null = GitHub). 기본은 환경 변수 ([LocalUpdateZip.fromEnvironment]) - 값이 틀리면 확인 · 받기 때 그 까닭으로 실패
|
||||
final LocalUpdateZip? Function() _local;
|
||||
|
||||
GitHubUpdater({
|
||||
this.apiBase = 'https://api.github.com',
|
||||
String? appDir,
|
||||
this.versionOverride,
|
||||
this.tempRoot,
|
||||
}) : appDir = appDir ?? AppPaths.root; // Lib 구조면 배포 폴더 맨 위
|
||||
LocalUpdateZip? Function()? local,
|
||||
}) : appDir = appDir ?? AppPaths.root, // Lib 구조면 배포 폴더 맨 위
|
||||
_local = local ?? LocalUpdateZip.fromEnvironment;
|
||||
|
||||
String get _temp => tempRoot ?? Directory.systemTemp.path;
|
||||
|
||||
|
|
@ -49,6 +96,8 @@ class GitHubUpdater implements Updater {
|
|||
|
||||
@override
|
||||
Future<ReleaseInfo?> latest() async {
|
||||
final local = _local();
|
||||
if (local != null) return local.release();
|
||||
final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases/latest'));
|
||||
req.headers.set('Accept', 'application/vnd.github+json');
|
||||
final res = await req.close();
|
||||
|
|
@ -62,6 +111,8 @@ class GitHubUpdater implements Updater {
|
|||
|
||||
@override
|
||||
Future<List<ReleaseInfo>> releases() async {
|
||||
final local = _local();
|
||||
if (local != null) return [local.release()];
|
||||
final req = await _http.getUrl(Uri.parse('$apiBase/repos/$updateRepo/releases?per_page=100'));
|
||||
req.headers.set('Accept', 'application/vnd.github+json');
|
||||
final res = await req.close();
|
||||
|
|
@ -93,8 +144,15 @@ class GitHubUpdater implements Updater {
|
|||
await work.create(recursive: true);
|
||||
final zip = File(p.join(work.path, r.zipName ?? 'update.zip'));
|
||||
|
||||
final uri = Uri.parse(url);
|
||||
if (uri.isScheme('file')) {
|
||||
// 시험 길: 이 PC 의 zip (sha256 은 꼭 있어야 하고 아래에서 견준다)
|
||||
if (_local() == null || r.sha256 == null) throw UpdateException(tr('[시험 길] 시험용 데이터 폴더 · sha256 없이는 이 PC 의 zip 을 쓰지 않습니다'));
|
||||
await File(uri.toFilePath()).copy(zip.path);
|
||||
onProgress(0.99);
|
||||
} else {
|
||||
// 받기
|
||||
final req = await _http.getUrl(Uri.parse(url));
|
||||
final req = await _http.getUrl(uri);
|
||||
final res = await req.close();
|
||||
if (res.statusCode != 200) throw UpdateException(trf('내려받기 실패 ({0})', [res.statusCode]));
|
||||
final total = res.contentLength > 0 ? res.contentLength : r.zipSize;
|
||||
|
|
@ -106,6 +164,7 @@ class GitHubUpdater implements Updater {
|
|||
if (total > 0) onProgress((got / total).clamp(0.0, 0.99));
|
||||
}
|
||||
await sink.close();
|
||||
}
|
||||
|
||||
// 검증: GitHub 가 알려 준 SHA256 과 비교
|
||||
if (r.sha256 != null) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import 'dart:io';
|
|||
import 'package:ffi/ffi.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'data_dir_override.dart';
|
||||
|
||||
/// 99 (사용자 결정): 지금 사용자의 시작 메뉴에 JJ_MKVMaker 바로 가기를 앱 ID (AUMID) 와 함께 만들고,
|
||||
/// jjmkvmaker:// 주소를 이 앱으로 연결한다 (관리자 권한 없이 - 사용자 영역만).
|
||||
/// 그래야 Windows 알림의 출처가 "JJ_MKVMaker" 로 보이고, 알림을 누르면 앱이 열린다 (jjmkvmaker://lsync).
|
||||
|
|
@ -12,8 +14,19 @@ class StartMenu {
|
|||
static const aumid = 'JJ.MKVMaker';
|
||||
static const protocol = 'jjmkvmaker';
|
||||
|
||||
/// 관리자 지시 (10/20, 15 찾음): 시험판 · 시험 (따로 데이터 JJ_MKVMAKER_DATA) 이면 사용자의 시작 메뉴 바로 가기 ·
|
||||
/// jjmkvmaker:// 를 자기 쪽으로 바꾸거나 지우지 않는다. 예전에는 시험 폴더의 판을 켜면 사용자 바로 가기가 그 판을 가리키고,
|
||||
/// 시험 폴더를 지우면 시작 메뉴 · 알림 누르기가 깨졌다. 시험이 바꿀 수 있게 함수로 둔다
|
||||
static bool Function() userAreaLocked = () => dataDirOverride != null;
|
||||
|
||||
/// 바로 가기 · 레지스트리를 바꾸는 명령 실행 (시험이 가짜로 바꿔 무엇을 부르는지 본다 - 사용자 영역을 건드리지 않고)
|
||||
static Future<ProcessResult> Function(String exe, List<String> args) run = Process.run;
|
||||
|
||||
/// 시험용: 바로 가기 파일 자리 (null = 사용자의 시작 메뉴). 시험이 잘못돼도 사용자 바로 가기를 지우지 않게
|
||||
static String? shortcutPathOverride;
|
||||
|
||||
/// 바로 가기 파일 (사용자의 시작 메뉴)
|
||||
static String get shortcutPath => p.join(Platform.environment['APPDATA'] ?? '', 'Microsoft', 'Windows', 'Start Menu',
|
||||
static String get shortcutPath => shortcutPathOverride ?? p.join(Platform.environment['APPDATA'] ?? '', 'Microsoft', 'Windows', 'Start Menu',
|
||||
'Programs', 'JJ_MKVMaker.lnk');
|
||||
|
||||
/// 이 프로세스의 앱 ID (작업 표시줄 · 알림이 바로 가기와 같은 앱으로 묶이게)
|
||||
|
|
@ -35,10 +48,12 @@ class StartMenu {
|
|||
/// [lnk] · [registerProtocol]: 시험용 (사용자의 시작 메뉴 · 레지스트리를 건드리지 않고)
|
||||
static Future<bool> ensure({String? exe, String? lnk, bool registerProtocol = true}) async {
|
||||
if (!Platform.isWindows) return false;
|
||||
// 사용자 영역 (시작 메뉴 바로 가기 · HKCU 주소 연결) 을 건드리는 부름만 막는다 - 시험이 따로 준 바로 가기 파일만 쓰면 그대로
|
||||
if ((lnk == null || registerProtocol) && userAreaLocked()) return false;
|
||||
final target = exe ?? appLauncher();
|
||||
final script = _script(lnk ?? shortcutPath, target, registerProtocol: registerProtocol);
|
||||
try {
|
||||
final r = await Process.run(
|
||||
final r = await run(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', _encode(script)],
|
||||
).timeout(const Duration(seconds: 60));
|
||||
|
|
@ -61,11 +76,11 @@ class StartMenu {
|
|||
|
||||
/// 설정에서 끄면: 바로 가기 · 주소 연결을 지운다 (이 앱이 만든 것만)
|
||||
static Future<void> remove() async {
|
||||
if (!Platform.isWindows) return;
|
||||
if (!Platform.isWindows || userAreaLocked()) return;
|
||||
try {
|
||||
final f = File(shortcutPath);
|
||||
if (await f.exists()) await f.delete();
|
||||
await Process.run('reg.exe', ['delete', r'HKCU\Software\Classes\' + protocol, '/f']);
|
||||
await run('reg.exe', ['delete', r'HKCU\Software\Classes\' + protocol, '/f']);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -505,6 +505,8 @@ Future<void> editAiService(BuildContext context, AppController c, {AiService? ol
|
|||
'openai' => tr('예: https://api.openai.com (경로 /v1/images/generations 를 붙여 부름)'),
|
||||
_ => tr('예: http://100.x.x.x:7860 (--api 로 켠 Automatic1111 · Forge)'),
|
||||
},
|
||||
// 관리자 지시 (10/21, 15 찾음 F2): 412 ja 에서 한 줄로 잘려 어떤 옵션으로 켜야 하는지가 안 보였다 - 다른 창의 도움 글처럼 3줄까지
|
||||
helperMaxLines: 3,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
|
|
@ -512,13 +514,17 @@ Future<void> editAiService(BuildContext context, AppController c, {AiService? ol
|
|||
decoration: InputDecoration(
|
||||
labelText: tr('모델 (선택)'),
|
||||
helperText: kind == 'comfy' ? tr('체크포인트 파일 이름 (예: sd_xl_turbo_1.0_fp16.safetensors)') : null,
|
||||
helperMaxLines: 3,
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: key,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
labelText: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호 (선택)') : tr('API 키 (선택)'),
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다
|
||||
labelText: kind == 'a1111' ? tr('API 키 또는 아이디:비밀번호') : tr('API 키'),
|
||||
helperText: tr('넣지 않아도 됩니다'),
|
||||
helperMaxLines: 3,
|
||||
suffixIcon: IconButton(icon: Icon(hide ? Icons.visibility : Icons.visibility_off), onPressed: () => set(() => hide = !hide)),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -103,11 +103,17 @@ class _AppTopBarState extends State<AppTopBar> {
|
|||
actions,
|
||||
]),
|
||||
),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
// F8 (10/21): 아래 줄은 48 이상 - middle 이 좁아 두 줄로 바꾸면 (다운로드 화면의 주소 칸) 그만큼 늘어난다. 한 줄이면 예전처럼 48 가운데
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 48),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
heightFactor: 1,
|
||||
child: SizedBox(
|
||||
width: double.infinity, // 아래 줄은 가운데가 폭을 다 쓴다 (201)
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: widget.trailing == null ? middle : Row(children: [Expanded(child: middle), widget.trailing!]))),
|
||||
child: widget.trailing == null ? middle : Row(children: [Expanded(child: middle), widget.trailing!]))))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,8 +360,10 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
|
|||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호 (선택)'),
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 - "선택" 은 아래 도움 글 (비우면 보통 ZIP) 이 알린다
|
||||
labelText: tr('비밀번호'),
|
||||
helperText: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'),
|
||||
helperMaxLines: 3,
|
||||
isDense: true,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: showPass ? tr('비밀번호 숨기기') : tr('비밀번호 보기'),
|
||||
|
|
|
|||
|
|
@ -93,12 +93,10 @@ class _DownloadsPageState extends State<DownloadsPage> {
|
|||
minMiddleWidth: 420,
|
||||
nav: const AppNavButtons(onDownloadsPage: true),
|
||||
actions: const AppActions(),
|
||||
middle: Row(children: [
|
||||
Text(trf('다운로드 ({0})', [d.tasks.length]),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
SizedBox(width: isCompact(context) ? 8 : 20),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
middle: LayoutBuilder(builder: (context, box) {
|
||||
final title = Text(trf('다운로드 ({0})', [d.tasks.length]),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600));
|
||||
final field = TextField(
|
||||
controller: _url,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
|
|
@ -110,11 +108,30 @@ class _DownloadsPageState extends State<DownloadsPage> {
|
|||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _add(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(onPressed: _add, child: Text(tr('추가'))),
|
||||
]),
|
||||
);
|
||||
final add = FilledButton(onPressed: _add, child: Text(tr('추가')));
|
||||
// F8 (관리자 승인 10/21, 15 찾음): 좁고 글자가 크면 제목이 넓어 주소 칸이 「動画...」 만큼으로 눌렸다 -
|
||||
// 주소 칸이 200 보다 좁아질 때만 주소 칸 · [추가] 를 제목 아래 한 줄로 (넓으면 예전 한 줄 그대로)
|
||||
final scaler = MediaQuery.textScalerOf(context);
|
||||
double textW(String t, double size, [FontWeight? w]) => (TextPainter(
|
||||
text: TextSpan(text: t, style: DefaultTextStyle.of(context).style.copyWith(fontSize: size, fontWeight: w)),
|
||||
textDirection: TextDirection.ltr,
|
||||
textScaler: scaler,
|
||||
maxLines: 1)
|
||||
..layout())
|
||||
.width;
|
||||
final gap = isCompact(context) ? 8.0 : 20.0;
|
||||
final need = textW(trf('다운로드 ({0})', [d.tasks.length]), 16, FontWeight.w600) + gap + 200 + 8 + textW(tr('추가'), 14) + 48;
|
||||
// 한 줄 막대 (높이가 정해짐) 에서는 늘 한 줄 - 모자라면 막대가 스스로 두 줄 배치로 바뀐다 (AppTopBar)
|
||||
if (box.maxWidth >= need || box.maxHeight.isFinite) {
|
||||
return Row(children: [title, SizedBox(width: gap), Expanded(child: field), const SizedBox(width: 8), add]);
|
||||
}
|
||||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
title,
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [Expanded(child: field), const SizedBox(width: 8), add]),
|
||||
]);
|
||||
}),
|
||||
),
|
||||
Container(
|
||||
color: JjColors.bg,
|
||||
|
|
|
|||
|
|
@ -3160,18 +3160,18 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
notDeleted.add('${vBasename(x)}: ${e is FileSystemException ? (e.osError?.message ?? e.message) : e}');
|
||||
}
|
||||
}
|
||||
_snack([
|
||||
_snack(joinSentences([
|
||||
if (tooLong.length > notDeleted.length) trf('{0}개를 영구 삭제했습니다.', [tooLong.length - notDeleted.length]),
|
||||
if (notDeleted.isNotEmpty) trf('지우지 못함: {0}', [notDeleted.take(3).join(' · ')]),
|
||||
].join(' '));
|
||||
]));
|
||||
}
|
||||
} else if (!permanent && mounted) {
|
||||
final text = [
|
||||
final text = joinSentences([
|
||||
if (recycled > 0) trf('{0}개 항목을 휴지통으로 보냈습니다.', [recycled]),
|
||||
// 휴지통보다 커서 Windows 가 물었고 사용자가 영구 삭제를 골랐다
|
||||
if (nuked > 0) trf('{0}개는 휴지통에 들어가지 않아 영구 삭제했습니다 (Windows 가 묻고 고른 대로).', [nuked]),
|
||||
if (kept > 0) trf('{0}개는 지우지 않았습니다 (취소).', [kept]),
|
||||
].join(' ');
|
||||
]);
|
||||
// 148: 휴지통으로 보낸 것은 바로 [되돌리기]
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
duration: const Duration(seconds: 10),
|
||||
|
|
@ -3252,10 +3252,10 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
for (final x in _panes) {
|
||||
x.changed();
|
||||
}
|
||||
_snack([
|
||||
_snack(joinSentences([
|
||||
if (back > 0) trf('{0}개 항목을 되돌렸습니다.', [back]),
|
||||
if (failed.isNotEmpty) trf('되돌리지 못함: {0}', [failed.take(3).join(' · ')]),
|
||||
].join(' '));
|
||||
]));
|
||||
}
|
||||
|
||||
/// 다른 창의 지금 폴더로 복사 · 이동 (진행 창 · 취소)
|
||||
|
|
@ -3594,16 +3594,17 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
? tr('취소했습니다.')
|
||||
: error != null
|
||||
? trf('끝나지 못했습니다: {0}', [error])
|
||||
: [
|
||||
: joinSentences([
|
||||
trf('{0}개 항목을 {1}', [job.made.length, move ? tr('옮겼습니다.') : tr('복사했습니다.')]),
|
||||
// 관리자 지시 (10/21, 15 찾음 F3): 뒤 조각도 문장으로 쓰고 빈칸으로 잇는다 (예전에는 "…옮겼습니다. · 1개는 …" 처럼 마침표 뒤에 " · ")
|
||||
// 264: 작업 기록에만 있던 것도 알림에 (폴더를 합칠 때 받는 쪽이 더 새 파일 등)
|
||||
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)]),
|
||||
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(' · ');
|
||||
if (job.dirConflicts.isNotEmpty) trf('같은 이름의 폴더가 있어 {0}개를 건너뛰었습니다: {1}.', [job.dirConflicts.length, _namesOf(job.dirConflicts)]),
|
||||
if (skippedHere.isNotEmpty) trf('이미 이 폴더에 있어 건너뛰었습니다: {0}.', [_namesOf(skippedHere)]),
|
||||
]);
|
||||
// 85: 끝나면 [폴더 열기] - 앱 탐색기에서 넣은 폴더로 (그 폴더를 보고 있는 창, 없으면 지금 창)
|
||||
if (error == null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
|
|
@ -5005,11 +5006,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(
|
||||
duration: const Duration(seconds: 10),
|
||||
content: Text([
|
||||
content: Text(joinSentences([
|
||||
trf('{0}개를 옮겼습니다.', [moved]),
|
||||
if (notUndoable > 0) trf('합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.', [notUndoable]),
|
||||
if (overwritten > 0) trf('같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.', [overwritten]),
|
||||
].join(' ')),
|
||||
])),
|
||||
action: SnackBarAction(label: tr('되돌리기'), onPressed: () => unawaited(_undoMove(_panes[load.from], back))),
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1103,7 +1103,9 @@ class _VideoDetail extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
// 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다
|
||||
if (isCompact(context)) {
|
||||
// F7 (관리자 승인 10/21, 15 찾음): 높이가 낮은 화면 (폰 가로 등) 도 - 떠 있는 버튼이 [자막 파일 추가] 를 반쯤 가렸다.
|
||||
// 높이는 글자 배율로 나눠 본다 (글자가 크면 같은 높이에 들어가는 줄이 적다 - 960×600 · 글자 1.6 에서도 가렸다)
|
||||
if (isCompact(context) || MediaQuery.sizeOf(context).height < 500 * MediaQuery.textScalerOf(context).scale(1)) {
|
||||
final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [
|
||||
if (buttons.isEmpty) button(null),
|
||||
for (final t in buttons) button(t),
|
||||
|
|
@ -1288,21 +1290,28 @@ class _SubtitleRow extends StatelessWidget {
|
|||
const _SubtitleRow(
|
||||
{required this.c, required this.v, required this.s, required this.locked});
|
||||
|
||||
/// 관리자 지시 (10/21, 15 찾음 F6): 한 줄에 들어가는 폭 - 내장 표 · 이름 (최소) · 언어 고르기 · 문자셋 · 단추.
|
||||
/// 고르기는 가장 긴 항목 글의 폭을 쓰므로 (DropdownButton) 그 글을 지금 글꼴 · 글자 배율로 잰다
|
||||
static double _oneLineWidth(BuildContext context, {required bool embedded, required int buttons}) {
|
||||
final scaler = MediaQuery.textScalerOf(context);
|
||||
final base = DefaultTextStyle.of(context).style;
|
||||
double textW(String t, double size) =>
|
||||
(TextPainter(text: TextSpan(text: t, style: base.copyWith(fontSize: size)), textDirection: TextDirection.ltr, textScaler: scaler, maxLines: 1)
|
||||
..layout())
|
||||
.width;
|
||||
double widest(Iterable<String> xs, double size) => xs.fold(0.0, (m, t) => math.max(m, textW(t, size)));
|
||||
final lang = widest([tr('언어: 미지정'), for (final l in languages) '${l.name} (${l.code})'], 12) + 24;
|
||||
final charset = embedded ? 0.0 : widest(supportedCharsets.values, 12) + 24 + 8;
|
||||
final badge = textW(embedded ? tr('내장') : tr('외부'), 11) + 12;
|
||||
const nameMin = 60.0;
|
||||
return badge + 10 + nameMin + 8 + lang + charset + 4 + buttons * 48;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final embedded = s.kind == SubtitleKind.embedded;
|
||||
final off = !s.enabled;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: JjColors.panel,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: JjColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
final badge = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (embedded ? JjColors.textDim : JjColors.accent)
|
||||
|
|
@ -1313,10 +1322,8 @@ class _SubtitleRow extends StatelessWidget {
|
|||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: embedded ? JjColors.textDim : JjColors.accent)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
);
|
||||
final name = Text(
|
||||
s.displayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
|
|
@ -1324,21 +1331,19 @@ class _SubtitleRow extends StatelessWidget {
|
|||
color: off ? JjColors.textDim : JjColors.text,
|
||||
decoration: off ? TextDecoration.lineThrough : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
);
|
||||
final pickers = <Widget>[
|
||||
_LanguageDropdown(
|
||||
value: s.language,
|
||||
onChanged: locked || off ? null : (l) => c.setLanguage(s, l),
|
||||
),
|
||||
if (!embedded) ...[
|
||||
const SizedBox(width: 8),
|
||||
if (!embedded)
|
||||
_CharsetDropdown(
|
||||
value: s.charset ?? 'UTF-8',
|
||||
onChanged: locked ? null : (cs) => c.setCharset(s, cs),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
];
|
||||
final buttons = <Widget>[
|
||||
IconButton(
|
||||
tooltip: c.canEdit(s) ? tr('내용 편집 · 문자셋 변환') : tr('이미지 자막은 편집할 수 없습니다'),
|
||||
iconSize: 18,
|
||||
|
|
@ -1363,8 +1368,40 @@ class _SubtitleRow extends StatelessWidget {
|
|||
color: JjColors.textDim,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: JjColors.panel,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: JjColors.border),
|
||||
),
|
||||
child: LayoutBuilder(builder: (context, box) {
|
||||
final fits = box.maxWidth >= _oneLineWidth(context, embedded: embedded, buttons: buttons.length);
|
||||
if (fits) {
|
||||
// 넓을 때: 지금처럼 한 줄
|
||||
return Row(children: [
|
||||
badge,
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: name),
|
||||
const SizedBox(width: 8),
|
||||
for (final (i, x) in pickers.indexed) ...[if (i > 0) const SizedBox(width: 8), x],
|
||||
const SizedBox(width: 4),
|
||||
...buttons,
|
||||
]);
|
||||
}
|
||||
// 좁아 넘칠 때만 두 줄: 위 = 내장 표 · 트랙 이름, 아래 = 언어 고르기 · 단추 (언어 이름은 줄이지 않는다 - 무엇을 골랐는지 보이게)
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [badge, const SizedBox(width: 10), Expanded(child: name)]),
|
||||
const SizedBox(height: 2),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 8,
|
||||
children: [...pickers, Row(mainAxisSize: MainAxisSize.min, children: buttons)],
|
||||
),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,11 +147,17 @@ Future<void> clearStoredSecrets(AppController c) => c.updateSettings((s) => s
|
|||
// AI 서비스 API 키도 (문구 "비밀번호 · API 키" 와 같게 - 예전에는 남았다, 관리자 결정 10/17)
|
||||
..aiServices = [for (final x in s.aiServices) x.copyWith(apiKey: '')]
|
||||
..openSubtitlesKey = ''
|
||||
..openSubtitlesPassword = '');
|
||||
..openSubtitlesPassword = '').then((_) async {
|
||||
// 관리자 판단 (10/21): 다른 판 (새 판) 이 안전 저장소에 넣은 이 판이 모르는 비밀번호 · 키도 (마스터 · 최상은 남김)
|
||||
await c.settingsStore?.clearForeignSecrets();
|
||||
});
|
||||
|
||||
/// 앱 설정 전체를 처음 상태로 (다음에 켤 때부터 모두 적용되도록 앱을 다시 켜 달라고 한다)
|
||||
/// 관리자 판단 (10/21): 이 판이 모르는 설정 키 (다른 판이 쓴 것) 도 지운다 - 사용자가 "전체" 를 골랐고, 새 판으로 돌아갔을 때
|
||||
/// 예전 값이 되살아나면 더 헷갈린다. 모르는 키를 남기는 규칙 (40) 은 보통 저장 때만 (새 AppSettings 는 [AppSettings.extra] 가 비어 있다).
|
||||
/// 안전 저장소의 이 판이 모르는 열쇠는 [저장된 실제 비밀번호 · API 키] 를 골랐을 때만 지운다 ([clearStoredSecrets])
|
||||
Future<void> resetAllSettings(AppController c) async {
|
||||
c.settings = AppSettings();
|
||||
c.settings = AppSettings()..extra = {};
|
||||
await c.updateSettings((_) {});
|
||||
}
|
||||
|
||||
|
|
@ -198,14 +204,17 @@ Future<void> showSuperReset(BuildContext context, AppController c, MasterLock lo
|
|||
onChanged: (v) => set(() => secrets = v!),
|
||||
title: Text(tr('저장된 실제 비밀번호 · API 키')),
|
||||
// 145: 10/18 에 생긴 SFTP · SMB · FTP 비밀번호 · 키도 지운다 (clearStoredSecrets)
|
||||
subtitle: Text(tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다')),
|
||||
subtitle: Text('${tr('WebDAV · SFTP · SMB · FTP · OpenSubtitles 비밀번호, SFTP 키, API 키. 서버 목록과 아이디는 남깁니다.')}\n'
|
||||
'${tr('다른 판에서 넣은 비밀번호 · 키도 지웁니다.')}'),
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: settings,
|
||||
onChanged: (v) => set(() => settings = v!),
|
||||
title: Text(tr('앱 설정 전체 (처음 상태로)')),
|
||||
// 145: 파일 탐색기 즐겨찾기 (73) 는 설정 안에 있어 함께 지워진다 - 예전 글은 "즐겨찾기는 그대로"
|
||||
subtitle: Text(tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다')),
|
||||
subtitle: Text('${tr('서버 목록 (WebDAV · SFTP · SMB · FTP) 과 그 비밀번호 · API 키, 파일 탐색기 즐겨찾기도 지워집니다. 동영상 목록 · 웹 즐겨찾기 · 파일은 그대로입니다.')}\n'
|
||||
// 관리자 판단 (10/21): 이 판이 모르는 설정 키도 지운다 (resetAllSettings)
|
||||
'${tr('다른 판에서 만든 설정도 지웁니다 (다른 판이 넣은 비밀번호는 [저장된 실제 비밀번호 · API 키] 를 함께 골라야 지워집니다).')}'),
|
||||
),
|
||||
// X-plore 비교 5-2 (관리자 결정 10/21): 보관함은 초기화 대상이 아님 (풀 수 있는 열쇠가 없음)
|
||||
if (c.settings.vaultEnabled)
|
||||
|
|
|
|||
|
|
@ -1264,7 +1264,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
Text(
|
||||
tr('로그인 쿠키는 다운로드 (yt-dlp) 가 쓰도록 이 기기의 앱 폴더에 cookies_youtube.txt 로 저장됩니다 (평문). '
|
||||
'이 파일을 가진 사람은 그 계정에 로그인할 수 있으니 기기를 남에게 줄 때는 지우세요. 고른 사이트의 쿠키만 넘깁니다:'),
|
||||
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||
// 관리자 지시 (10/21, 131 확인): 12px 흐린 회색은 읽기 힘들었다 - 위아래 설명 글 (ListTile 부제목) 과 같은 크기 · 색으로
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(spacing: 6, runSpacing: 6, children: [
|
||||
for (final d in loginCookieDomains)
|
||||
|
|
@ -1328,7 +1329,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||
}
|
||||
},
|
||||
title: Text(tr('시작 메뉴에 등록')),
|
||||
subtitle: Text(tr('알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)')),
|
||||
subtitle: Text(StartMenu.userAreaLocked()
|
||||
? tr('시험용 데이터 폴더 (JJ_MKVMAKER_DATA) 로 켜져 시작 메뉴 바로 가기 · jjmkvmaker:// 연결은 건드리지 않습니다')
|
||||
: tr('알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)')),
|
||||
),
|
||||
SettingTile(
|
||||
title: Text(tr('종료 (창 닫기 ✕ · 종료 버튼) 를 누르면')),
|
||||
|
|
|
|||
|
|
@ -227,15 +227,19 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
|
|||
// 개인 키는 작다 - 큰 파일은 키가 아니다 (실수로 다른 파일을 고름)
|
||||
if (await f.length() > 64 * 1024) throw FormatException(tr('개인 키 파일이 아닙니다 (너무 큼)'));
|
||||
final text = await f.readAsString();
|
||||
if (!text.contains('PRIVATE KEY')) throw FormatException(tr('개인 키 파일이 아닙니다 (OpenSSH · PEM 형식의 비밀 키를 고르세요, .pub 아님)'));
|
||||
// 관리자 지시 (10/21): 고를 때 바로 꼴을 본다 (OpenSSH · PEM RSA · EC - PuTTY · PKCS#8 · 공개 키는 바꾸는 법을 알림)
|
||||
final encrypted = checkPrivateKey(text);
|
||||
set(() {
|
||||
key = text;
|
||||
result = tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)');
|
||||
// 관리자 지시 (10/21, 15 찾음 F4): 안내는 창 맨 아래 - 키 암호 칸은 그 위 (칸 이름은 실제 칸 이름 그대로)
|
||||
result = encrypted
|
||||
? trf('개인 키를 넣었습니다. 암호가 걸린 키라 위의 [{0}] 칸에 키 암호를 넣으세요 (원래 파일 경로는 저장하지 않습니다)', [tr('개인 키 암호')])
|
||||
: tr('개인 키를 넣었습니다 (원래 파일 경로는 저장하지 않습니다)');
|
||||
passed = true;
|
||||
});
|
||||
} catch (e) {
|
||||
set(() {
|
||||
result = e is FormatException ? e.message : '$e';
|
||||
result = e is FormatException ? e.message : e is SftpException ? e.message : '$e';
|
||||
passed = false;
|
||||
});
|
||||
}
|
||||
|
|
@ -274,7 +278,10 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
|
|||
controller: pass,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr('비밀번호 (개인 키만 쓰면 비움)'),
|
||||
// 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다
|
||||
labelText: tr('비밀번호'),
|
||||
helperText: tr('개인 키만 쓰면 비웁니다'),
|
||||
helperMaxLines: 3,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: hide ? tr('보이기') : tr('가리기'),
|
||||
icon: Icon(hide ? Icons.visibility_outlined : Icons.visibility_off_outlined, size: 18),
|
||||
|
|
@ -298,7 +305,7 @@ Future<SftpServer?> editSftpServer(BuildContext context, AppController c, {SftpS
|
|||
TextField(
|
||||
controller: passphrase,
|
||||
obscureText: hide,
|
||||
decoration: InputDecoration(labelText: tr('개인 키 암호 (없으면 비움)')),
|
||||
decoration: InputDecoration(labelText: tr('개인 키 암호'), helperText: tr('없으면 비웁니다'), helperMaxLines: 3),
|
||||
),
|
||||
TextField(
|
||||
controller: root,
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ void main() {
|
|||
await settle(tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty && find.byType(SnackBar).evaluate().isNotEmpty);
|
||||
expect(File(p.join(right, 'D', 'add.txt')).existsSync(), isTrue);
|
||||
expect(File(p.join(d, 'same.txt')).existsSync(), isTrue, reason: '받는 쪽이 더 새것이라 원본에 남음');
|
||||
expect(find.textContaining('1개는 원본에 남음 (건너뜀)'), findsOneWidget, reason: '264: 알림에도');
|
||||
expect(find.textContaining('1개는 원본에 남았습니다 (건너뜀).'), findsOneWidget, reason: '264: 알림에도');
|
||||
expect(find.text('add.txt'), findsNothing, reason: '265: 원본 D 를 다시 읽음 (오른쪽 D 는 펼치지 않음)');
|
||||
expect(find.text('same.txt'), findsOneWidget);
|
||||
await calm(tester);
|
||||
|
|
@ -359,13 +359,13 @@ void main() {
|
|||
expect(find.textContaining('2개 표시함'), findsOneWidget);
|
||||
await tester.runAsync(() => tester.tap(find.text('이동').first));
|
||||
await settle(tester, () => find.byType(AlertDialog).evaluate().isNotEmpty, rounds: 30);
|
||||
expect(find.textContaining('이미 이 폴더에 있어 건너뜀: b.txt'), findsOneWidget);
|
||||
expect(find.textContaining('이미 이 폴더에 있어 건너뜀: b.txt'), findsOneWidget, reason: '확인 창 (설명 목록 꼴 그대로)');
|
||||
await tester.runAsync(() => tester.tap(find.widgetWithText(FilledButton, '이동')));
|
||||
await settle(tester, () => File(p.join(right, 'a.txt')).existsSync() && find.byType(SnackBar).evaluate().isNotEmpty);
|
||||
expect(File(p.join(right, 'a.txt')).readAsStringSync(), 'a');
|
||||
expect(File(p.join(left, 'a.txt')).existsSync(), isFalse);
|
||||
expect(File(p.join(right, 'b.txt')).readAsStringSync(), 'b');
|
||||
expect(find.textContaining('이미 이 폴더에 있어 건너뜀: b.txt'), findsOneWidget, reason: '끝난 알림에도');
|
||||
expect(find.textContaining('이미 이 폴더에 있어 건너뛰었습니다: b.txt.'), findsOneWidget, reason: '끝난 알림에도');
|
||||
});
|
||||
|
||||
testWidgets('147: 버튼 줄이 다 들어가지 않으면 (낮은 화면) 나머지는 [더 보기] 메뉴로 - "숨은 항목 표시" 도 누를 수 있다', (tester) async {
|
||||
|
|
@ -522,6 +522,6 @@ void main() {
|
|||
await confirm(tester);
|
||||
expect(File(p.join(right, 'doc.txt')).readAsStringSync(), 'old');
|
||||
expect(File(p.join(right, 'doc (2).txt')).existsSync(), isFalse);
|
||||
expect(find.textContaining('같은 이름이라 건너뜀: doc.txt'), findsOneWidget);
|
||||
expect(find.textContaining('같은 이름이라 건너뛰었습니다: doc.txt.'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -270,8 +270,33 @@ void main() {
|
|||
}
|
||||
|
||||
final sw = Stopwatch()..start();
|
||||
// 관리자 지시 (10/21): 전체 시험 부하에서만 가끔 실패 - 콘솔이 잘려도 남게, 실패하면 (시간 초과 포함) 시험 폴더 밖
|
||||
// %LOCALAPPDATA%\Temp\jj_ftp28_fail_<시각>.txt 에 시험 이름 · 옵션마다 걸린 시간 · 다른 점 · 목록을 쓴다. 성공하면 쓰지 않는다
|
||||
final progress = <String>[];
|
||||
var finished = false;
|
||||
void writeFail(String why) {
|
||||
final base = Platform.environment['LOCALAPPDATA'];
|
||||
if (base == null || base.isEmpty) return;
|
||||
final stamp = DateTime.now().toIso8601String().replaceAll(':', '').split('.').first;
|
||||
try {
|
||||
File(p.join(base, 'Temp', 'jj_ftp28_fail_$stamp.txt')).writeAsStringSync([
|
||||
'ftp_test: FTP 맞추기 결과 = 실제 rsync 결과 (28 의 장면 · 옵션 28개)',
|
||||
'까닭: $why · 걸린 시간 ${sw.elapsedMilliseconds}ms · 포트 $port · 끝 ${DateTime.now().toIso8601String()}',
|
||||
'옵션마다:',
|
||||
...progress.map((x) => ' $x'),
|
||||
'',
|
||||
...bad,
|
||||
].join('\n'));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 시간 초과로 몸통이 끊겨도 (tearDown 은 돈다) 어디까지 갔는지 남긴다
|
||||
addTearDown(() {
|
||||
if (!finished) writeFail('끝나지 않음 (시간 초과 · 예외)');
|
||||
});
|
||||
for (final opts in sceneOptions) {
|
||||
final t0 = sw.elapsedMilliseconds;
|
||||
progress.add('$opts - 시작 ${t0}ms');
|
||||
writeScene(src, want);
|
||||
final w = await realRsyncTree(rsync, src, want, opts);
|
||||
final wantList = listing(want);
|
||||
|
|
@ -300,7 +325,10 @@ void main() {
|
|||
listing(got),
|
||||
].join('\n'));
|
||||
}
|
||||
progress[progress.length - 1] += ' · ${sw.elapsedMilliseconds - t0}ms · ${d.isEmpty && error == null ? '같음' : '다름'}';
|
||||
}
|
||||
finished = true;
|
||||
if (bad.isNotEmpty) writeFail('결과가 rsync 와 다름 ${bad.length}개');
|
||||
expect(bad, isEmpty, reason: bad.join('\n'));
|
||||
}, skip: hasPy ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 5)));
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,18 @@ void main() {
|
|||
dir.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('관리자 지시 (10/21): 문장 잇기 - 한국어 · 영어는 빈칸, 일본어 · 중국어는 。 뒤에 빈칸 없이', () {
|
||||
for (final (lang, want) in [
|
||||
('ko', '옮겼습니다. 남았습니다.'),
|
||||
('en', '옮겼습니다. 남았습니다.'),
|
||||
('ja', '옮겼습니다.남았습니다.'),
|
||||
('zh-Hans', '옮겼습니다.남았습니다.'),
|
||||
]) {
|
||||
setTranslations(lang, const {});
|
||||
expect(joinSentences(['옮겼습니다.', '', '남았습니다.']), want, reason: lang);
|
||||
}
|
||||
});
|
||||
|
||||
test('기본 사전: 영어 · 일본어 · 중국어가 같은 열쇠를 모두 가진다', () {
|
||||
final en = _asset('en'), ja = _asset('ja'), zh = _asset('zh-Hans');
|
||||
expect(en.length, greaterThan(700));
|
||||
|
|
|
|||
|
|
@ -298,6 +298,34 @@ void main() {
|
|||
expect((c.settings.webdavServers.single.user, c.settings.sftpServers.single.user, c.settings.aiServices.single.url), ('u', 'u', 'https://ai'));
|
||||
});
|
||||
|
||||
test('관리자 판단 (10/21): "앱 설정 전체" 는 이 판이 모르는 설정 키도 지우고, 다른 판의 비밀 값은 "저장된 비밀번호" 를 골랐을 때만', () async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_master_full_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final file = File(p.join(tmp.path, 'settings.json'))
|
||||
..writeAsStringSync(jsonEncode({'explorerLayout': 'single', 'zz_new_version': {'keep': 1}}));
|
||||
final secrets = MemorySecretStore()
|
||||
..values['nfs.pw.n1'] = 'new-version-pw' // 다른 (새) 판이 쓴 열쇠
|
||||
..values['sec.master'] = 'hash';
|
||||
final store = SettingsStore(file.path, secrets);
|
||||
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()),
|
||||
settingsStore: store);
|
||||
await c.init();
|
||||
// 보통 저장: 모르는 키 · 다른 판의 열쇠 남음 (40)
|
||||
await c.updateSettings((s) => s.explorerLayout = 'dual');
|
||||
Map<String, dynamic> saved() => jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
|
||||
expect(saved()['zz_new_version'], {'keep': 1});
|
||||
expect(secrets.values['nfs.pw.n1'], 'new-version-pw');
|
||||
// 앱 설정 전체: 모르는 키 없음 · 다른 판의 열쇠는 아직 남음 (비밀번호 체크를 안 함)
|
||||
await resetAllSettings(c);
|
||||
expect(saved().containsKey('zz_new_version'), isFalse);
|
||||
expect(saved()['explorerLayout'], AppSettings().explorerLayout);
|
||||
expect(secrets.values['nfs.pw.n1'], 'new-version-pw', reason: '[저장된 실제 비밀번호] 를 고르지 않으면 남김');
|
||||
// 저장된 실제 비밀번호 · API 키: 다른 판의 열쇠도 지움 · 마스터 (sec.) 는 남김
|
||||
await clearStoredSecrets(c);
|
||||
expect(secrets.values.containsKey('nfs.pw.n1'), isFalse);
|
||||
expect(secrets.values['sec.master'], 'hash');
|
||||
});
|
||||
|
||||
test('최상으로 "앱 설정 전체" 를 지우면 처음 상태', () async {
|
||||
final c = controller()..settings.explorerLayout = 'single';
|
||||
await resetAllSettings(c);
|
||||
|
|
|
|||
|
|
@ -55,12 +55,19 @@ void main() {
|
|||
File(p.join(src, 'big.bin')).writeAsBytesSync(List.filled(300 * 1024, 7));
|
||||
final job = TransferJob(sources: [src], dest: dst, move: true, contents: true, bandwidthKBps: 400);
|
||||
final running = job.run();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
// 정해진 시간 (예전 250ms) 이 아니라 보내기가 실제로 시작된 뒤 (받는 쪽에 무엇이 생긴 뒤) 새 파일을 만든다 -
|
||||
// 전체 시험 부하에서는 250ms 안에 원본 목록을 아직 읽지 않아 새 파일까지 옮겨져 거짓 실패 (10/21, 15)
|
||||
for (var i = 0; i < 200; i++) {
|
||||
if (Directory(dst).existsSync() && Directory(dst).listSync(recursive: true).isNotEmpty) break;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 25));
|
||||
}
|
||||
File(p.join(src, 'arrived.txt')).writeAsStringSync('new');
|
||||
await running;
|
||||
expect(job.error, isNull);
|
||||
expect(File(p.join(dst, 'big.bin')).lengthSync(), 300 * 1024);
|
||||
expect(File(p.join(src, 'big.bin')).existsSync(), isFalse);
|
||||
// 데이터: 새 파일은 어디에도 없어지면 안 된다 (원본에 남았거나 옮겨졌거나)
|
||||
expect(File(p.join(src, 'arrived.txt')).existsSync() || File(p.join(dst, 'arrived.txt')).existsSync(), isTrue, reason: '새 파일을 잃지 않음');
|
||||
expect(File(p.join(src, 'arrived.txt')).existsSync(), isTrue, reason: '복사하지 않은 것은 남김');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ 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/webdav.dart';
|
||||
import 'package:jj_mkvmaker/services/secret_store.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
void main() {
|
||||
|
|
@ -25,6 +28,40 @@ void main() {
|
|||
expect((await SettingsStore(file).load()).homeUrl, 'https://4.example');
|
||||
});
|
||||
|
||||
test('관리자 지시 (10/21): 보통 저장은 이 판이 만드는 꼴의 열쇠만 지운다 - 모르는 꼴 (새 판의 dav.token.x 등) 은 남김 · 지운 서버의 아는 열쇠만', () async {
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_secret_form_');
|
||||
addTearDown(() => tmp.deleteSync(recursive: true));
|
||||
final secrets = MemorySecretStore()
|
||||
..values['dav.token.d1'] = 'new-kind' // 새 판이 같은 머리글로 쓴 새 종류
|
||||
..values['sftp.cert.s1'] = 'new-kind-2'
|
||||
..values['nfs.pw.n1'] = 'new-server-kind'
|
||||
..values['sec.master'] = 'hash';
|
||||
final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
||||
final s = await store.load()
|
||||
..webdavServers = [const DavServer(id: 'd1', name: 'n', url: 'https://x', user: 'u', password: 'dp')]
|
||||
..sftpServers = [
|
||||
const SftpServer(id: 's1', name: 'a', host: 'h', user: 'u', password: 'sp1', privateKey: 'KEY1', keyPassphrase: 'kp1'),
|
||||
const SftpServer(id: 's2', name: 'b', host: 'h', user: 'u', password: 'sp2'),
|
||||
];
|
||||
await store.save(s);
|
||||
expect(
|
||||
{for (final k in ['dav.pw.d1', 'sftp.pw.s1', 'sftp.key.s1', 'sftp.kp.s1', 'sftp.pw.s2']) k: secrets.values[k]},
|
||||
{'dav.pw.d1': 'dp', 'sftp.pw.s1': 'sp1', 'sftp.key.s1': 'KEY1', 'sftp.kp.s1': 'kp1', 'sftp.pw.s2': 'sp2'});
|
||||
// 서버 s1 을 지움: s1 의 아는 열쇠 (pw · key · kp) 만 없어지고, s2 · 모르는 꼴 · 마스터는 그대로
|
||||
s.sftpServers = [s.sftpServers.last];
|
||||
await store.save(s);
|
||||
expect(secrets.values.keys.where((k) => k.endsWith('.s1')), ['sftp.cert.s1']);
|
||||
expect(secrets.values['sftp.pw.s2'], 'sp2');
|
||||
expect((secrets.values['dav.token.d1'], secrets.values['nfs.pw.n1'], secrets.values['sec.master']), ('new-kind', 'new-server-kind', 'hash'));
|
||||
// 다시 읽어 저장해도 (새 SettingsStore = 앱을 다시 켬) 모르는 꼴은 남음
|
||||
final again = SettingsStore(p.join(tmp.path, 'settings.json'), secrets);
|
||||
await again.save(await again.load());
|
||||
expect((secrets.values['dav.token.d1'], secrets.values['sftp.cert.s1']), ('new-kind', 'new-kind-2'));
|
||||
// 초기화의 [저장된 실제 비밀번호 · API 키] 쪽 (clearForeignSecrets) 은 모르는 꼴도 지우고 sec. 만 남김
|
||||
expect(await again.clearForeignSecrets(), 3);
|
||||
expect(secrets.values.keys.where((k) => !SettingsStore.ownsSecret(k)), ['sec.master']);
|
||||
});
|
||||
|
||||
test('깨진 설정 파일: 백업으로 되살리고, 깨진 파일은 보관 · 알림 정보', () async {
|
||||
final store = SettingsStore(file);
|
||||
await store.save(AppSettings()..homeUrl = 'https://good.example');
|
||||
|
|
|
|||
|
|
@ -299,10 +299,78 @@ void main() {
|
|||
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! as SftpException).kind, SftpFailure.keyPassphrase);
|
||||
expect('$bad', contains('개인 키의 암호'));
|
||||
}, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음');
|
||||
|
||||
test('관리자 지시 (10/21): SSH 키 파일 로그인 - ed25519 · rsa · ecdsa × OpenSSH · PEM (암호 있음 · 없음) 이 실제 서버에 붙고, 서버 거부 · 암호 없음은 따로', () async {
|
||||
final dir = Directory(p.join(tmp.path, 'keys'))..createSync();
|
||||
Future<String?> gen(String name, List<String> args) async {
|
||||
final f = p.join(dir.path, name);
|
||||
final r = await Process.run(keygen, ['-q', ...args, '-f', f, '-C', 'jj-test']);
|
||||
return r.exitCode == 0 ? f : null;
|
||||
}
|
||||
|
||||
final cases = [
|
||||
('ed25519 OpenSSH 암호', ['-t', 'ed25519', '-N', 'kp'], 'kp'),
|
||||
('ed25519 OpenSSH 암호 없음', ['-t', 'ed25519', '-N', ''], ''),
|
||||
('rsa OpenSSH 암호', ['-t', 'rsa', '-b', '2048', '-N', 'kp'], 'kp'),
|
||||
('rsa PEM 암호 없음', ['-t', 'rsa', '-b', '2048', '-m', 'PEM', '-N', ''], ''),
|
||||
('ecdsa OpenSSH 암호', ['-t', 'ecdsa', '-b', '256', '-N', 'kp'], 'kp'),
|
||||
('ecdsa PEM 암호 없음', ['-t', 'ecdsa', '-b', '256', '-m', 'PEM', '-N', ''], ''),
|
||||
];
|
||||
final failed = <String>[];
|
||||
for (final (i, (name, args, kp)) in cases.indexed) {
|
||||
final key = await gen('k$i', args);
|
||||
if (key == null) return markTestSkipped('ssh-keygen 이 $name 을 만들지 못함');
|
||||
await server?.stop();
|
||||
await start(password: null, pubkey: '$key.pub');
|
||||
final pem = File(key).readAsStringSync();
|
||||
expect(checkPrivateKey(pem), kp.isNotEmpty, reason: '$name: 암호가 걸렸는지');
|
||||
final c = SftpRemote(srv(password: '', key: pem, kp: kp, fp: fingerprint));
|
||||
try {
|
||||
await c.list('/');
|
||||
} catch (e) {
|
||||
failed.add('$name: $e');
|
||||
} finally {
|
||||
c.close();
|
||||
}
|
||||
}
|
||||
expect(failed, isEmpty, reason: failed.join('\n'));
|
||||
// 서버가 모르는 키 (짝 공개 키가 서버에 없음) → 키 거부 (비밀번호 틀림과 따로)
|
||||
final other = await gen('other', ['-t', 'ed25519', '-N', '']);
|
||||
final rejected = await err(() => SftpRemote(srv(password: '', key: File(other!).readAsStringSync(), fp: fingerprint)).list('/'));
|
||||
expect((rejected! as SftpException).kind, SftpFailure.keyRejected, reason: '$rejected');
|
||||
expect('$rejected', contains('authorized_keys'));
|
||||
// 암호가 걸린 키인데 키 암호를 넣지 않음 → 그렇다고 (서버에 묻기 전에)
|
||||
final locked = File(p.join(dir.path, 'k0')).readAsStringSync();
|
||||
final noPass = await err(() => SftpRemote(srv(password: '', key: locked, fp: fingerprint)).list('/'));
|
||||
expect((noPass! as SftpException).kind, SftpFailure.keyPassphrase);
|
||||
expect('$noPass', contains('키 암호를 넣으세요'));
|
||||
}, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음', timeout: const Timeout(Duration(minutes: 3)));
|
||||
|
||||
test('관리자 지시 (10/21): 쓸 수 없는 키 꼴은 고를 때 바로 - PuTTY (.ppk) · PKCS#8 · 공개 키 · 다른 파일', () async {
|
||||
final dir = Directory(p.join(tmp.path, 'keys'))..createSync();
|
||||
final f = p.join(dir.path, 'p8');
|
||||
final r = await Process.run(keygen, ['-q', '-t', 'rsa', '-b', '2048', '-m', 'PKCS8', '-N', '', '-f', f]);
|
||||
final samples = {
|
||||
'PuTTY': 'PuTTY-User-Key-File-3: ssh-ed25519\nEncryption: none\nComment: x\nPublic-Lines: 1\nAAAA\n',
|
||||
if (r.exitCode == 0) 'PKCS#8': File(f).readAsStringSync(),
|
||||
if (r.exitCode == 0) '공개 키': File('$f.pub').readAsStringSync(),
|
||||
'다른 파일': 'hello',
|
||||
};
|
||||
for (final MapEntry(key: name, value: text) in samples.entries) {
|
||||
Object? e;
|
||||
try {
|
||||
checkPrivateKey(text);
|
||||
} catch (x) {
|
||||
e = x;
|
||||
}
|
||||
expect(e, isA<SftpException>().having((x) => x.kind, 'kind', SftpFailure.keyFormat), reason: name);
|
||||
if (name == 'PuTTY') expect('$e', contains('Export OpenSSH key'));
|
||||
}
|
||||
}, skip: File(keygen).existsSync() ? false : 'ssh-keygen 없음');
|
||||
|
||||
test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async {
|
||||
await start(keytype: 'rsa', legacyOnly: true);
|
||||
final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/'));
|
||||
|
|
|
|||
|
|
@ -32,6 +32,43 @@ void main() {
|
|||
expect(await read('id'), StartMenu.aumid);
|
||||
}, skip: !Platform.isWindows, timeout: const Timeout(Duration(minutes: 2)));
|
||||
|
||||
test('관리자 지시 (10/20): 시험판 · 시험 (따로 데이터) 이면 사용자 시작 메뉴 · jjmkvmaker:// 를 바꾸거나 지우지 않는다', () async {
|
||||
// 명령은 가짜로만 (사용자 영역을 건드리지 않음) - 무엇을 부르는지만 본다
|
||||
final calls = <String>[];
|
||||
final runBefore = StartMenu.run, lockedBefore = StartMenu.userAreaLocked;
|
||||
final tmp = Directory.systemTemp.createTempSync('jj_lnk_');
|
||||
addTearDown(() {
|
||||
StartMenu.run = runBefore;
|
||||
StartMenu.userAreaLocked = lockedBefore;
|
||||
StartMenu.shortcutPathOverride = null;
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
// 바로 가기 파일도 시험 폴더로 (시험이 잘못돼도 사용자 시작 메뉴의 것을 지우지 않게)
|
||||
final lnk = p.join(tmp.path, 'JJ_MKVMaker.lnk');
|
||||
StartMenu.shortcutPathOverride = lnk;
|
||||
File(lnk).writeAsStringSync('x');
|
||||
StartMenu.run = (exe, args) async {
|
||||
calls.add(exe);
|
||||
return ProcessResult(0, 0, 'JJ_OK', '');
|
||||
};
|
||||
StartMenu.userAreaLocked = () => true;
|
||||
expect(await StartMenu.ensure(), isFalse, reason: '사용자 바로 가기 · 주소 연결');
|
||||
expect(await StartMenu.ensure(lnk: lnk), isFalse, reason: '주소 연결 (HKCU) 은 사용자 영역');
|
||||
await StartMenu.remove();
|
||||
expect(calls, isEmpty, reason: '막힌 동안은 아무 명령도 부르지 않음');
|
||||
expect(File(lnk).existsSync(), isTrue, reason: '바로 가기를 지우지 않음');
|
||||
// 시험이 따로 준 바로 가기 파일만 쓰는 것은 그대로
|
||||
expect(await StartMenu.ensure(exe: Platform.resolvedExecutable, lnk: lnk, registerProtocol: false), isTrue);
|
||||
expect(calls, ['powershell.exe']);
|
||||
// 막지 않으면 (사용자 판) 예전처럼 부른다
|
||||
StartMenu.userAreaLocked = () => false;
|
||||
calls.clear();
|
||||
expect(await StartMenu.ensure(), isTrue);
|
||||
await StartMenu.remove();
|
||||
expect(calls, ['powershell.exe', 'reg.exe']);
|
||||
expect(File(lnk).existsSync(), isFalse, reason: '사용자 판에서 끄면 지움 (여기서는 시험 폴더의 것)');
|
||||
}, skip: !Platform.isWindows);
|
||||
|
||||
test('알림을 누르면 오는 주소 (jjmkvmaker://lsync) 는 실시간 동기화 화면 요청', () {
|
||||
expect(LaunchRequest.parse(['jjmkvmaker://lsync/']).action, LaunchAction.lsync);
|
||||
expect(LaunchRequest.parse(['jjmkvmaker://lsync/']).files, isEmpty);
|
||||
|
|
|
|||
|
|
@ -168,6 +168,38 @@ void main() {
|
|||
await expectLater(up.download(bad, (_) {}), throwsA(predicate((e) => '$e'.contains('SHA256'))));
|
||||
});
|
||||
|
||||
test('관리자 지시 (10/20): 시험 길 JJ_UPDATE_LOCAL_ZIP - 시험용 데이터 폴더일 때만 · sha256 이 꼭 있고 다르면 거절', () async {
|
||||
final zip = p.join(tmp.path, 'JJ_MKVMaker_v2026.10.20_001_win64.zip');
|
||||
File(zip).writeAsBytesSync(zipBytes);
|
||||
final env = {'JJ_UPDATE_LOCAL_ZIP': '$zip;${digest.toUpperCase()}'};
|
||||
// 사용자 판 (따로 데이터 없음) 은 무시 - GitHub 그대로
|
||||
expect(LocalUpdateZip.fromEnvironment(env, null), isNull);
|
||||
expect(LocalUpdateZip.fromEnvironment(const {}, 'D:/data'), isNull);
|
||||
// sha256 이 없거나 꼴이 틀리면 거절
|
||||
for (final v in [zip, '$zip;', '$zip;abc']) {
|
||||
expect(() => LocalUpdateZip.fromEnvironment({'JJ_UPDATE_LOCAL_ZIP': v}, 'D:/data'),
|
||||
throwsA(predicate((e) => '$e'.contains('sha256'))), reason: v);
|
||||
}
|
||||
final local = LocalUpdateZip.fromEnvironment(env, 'D:/data')!;
|
||||
expect((local.path, local.sha256, local.version), (zip, digest, '2026.10.20_001'));
|
||||
// GitHub 를 부르지 않음 (닿지 않는 주소) - 확인 · 목록 · 받기 모두 이 PC 의 zip
|
||||
final up = GitHubUpdater(apiBase: 'http://127.0.0.1:1', appDir: tmp.path, versionOverride: '2026.10.18_001', tempRoot: tmp.path, local: () => local);
|
||||
final r = (await up.latest())!;
|
||||
expect(r.name, contains('시험 길'), reason: '화면에 시험 길이라고');
|
||||
expect(r.notes, contains('시험 길'));
|
||||
expect(r.isNewerThan(await up.currentVersion()), isTrue);
|
||||
expect((await up.releases()).single.version, '2026.10.20_001');
|
||||
final dir = await up.download(r, (_) {});
|
||||
expect(File(p.join(dir, 'jj_mkvmaker.exe')).readAsStringSync(), 'NEW EXE');
|
||||
expect(File(zip).existsSync(), isTrue, reason: '원래 zip 은 그대로 (복사해서 씀)');
|
||||
// sha256 이 다르면 거절
|
||||
final bad = GitHubUpdater(apiBase: 'http://127.0.0.1:1', appDir: tmp.path, tempRoot: tmp.path, local: () => LocalUpdateZip(zip, '0' * 64));
|
||||
await expectLater(bad.download((await bad.latest())!, (_) {}), throwsA(predicate((e) => '$e'.contains('SHA256'))));
|
||||
// 시험 길이 아닌데 file: 주소가 오면 쓰지 않음
|
||||
final none = GitHubUpdater(apiBase: 'http://127.0.0.1:1', appDir: tmp.path, tempRoot: tmp.path, local: () => null);
|
||||
await expectLater(none.download(r, (_) {}), throwsA(predicate((e) => '$e'.contains('시험 길'))));
|
||||
});
|
||||
|
||||
test('업데이트 스크립트: 프로그램 파일만 교체, 받은 파일 · 모델 보존', () async {
|
||||
if (!Platform.isWindows) return markTestSkipped('Windows 전용');
|
||||
final app = Directory(p.join(tmp.path, 'installed'))..createSync();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
# 정식 판 업데이트 길 시험 (관리자 지시 10/20): 사용자 데이터 · 시작 메뉴 · 실제 GitHub 를 건드리지 않고 끝까지.
|
||||
# 1. %TEMP%\jj_updtest_<표> 에 예전 판 배포 폴더 (-OldStage) 와 그 판이 남겼을 데이터 (설정 · 동영상 목록 · 비밀번호 (DPAPI secrets.dat,
|
||||
# 가짜 서버 jj_updtest_dav) · 이 판이 모르는 설정 키) 를 만든다.
|
||||
# 2. 통합 시험 integration_test\update_path_real_test.dart 가 앱 안 업데이트 길로 [지금 설치] (시험 길 JJ_UPDATE_LOCAL_ZIP = -NewZip).
|
||||
# 3. 시험이 끝나면 앱의 교체 스크립트가 배포 폴더를 바꾸고 새 판을 띄운다 (따로 데이터라 시작 메뉴를 건드리지 않음 - 121dd43).
|
||||
# 4. 새 판이 설정을 쓴 뒤 끄고 확인: 판 · 설정 · 모르는 키 · 동영상 목록 · 비밀번호 · 보관본 (비밀번호 없음) · 시작 메뉴 바로 가기 그대로.
|
||||
# 5. 시험 폴더를 지우고 jj_updtest_* 가 0개인지 본다.
|
||||
# 사용자 시작 메뉴를 지키는 두 겹 (관리자 지시 10/21, 28 지적):
|
||||
# ① 시작 전에 -NewZip 안의 en.json 에 시작 메뉴 막기 (121dd43) 의 글이 있는지 본다 - 없으면 (10.20_001 과 그 전 판) 아무것도 하지 않고 멈춘다.
|
||||
# 예전 판 (-OldStage) 은 켜지 않으므로 보지 않는다.
|
||||
# ② 시작 전에 사용자 바로 가기 (.lnk 사본) 와 HKCU\Software\Classes\jjmkvmaker (reg export) 를 시험 폴더 밖 %TEMP%\jj_updtest_backup_<표> 에
|
||||
# 보관하고, 끝에 (시험 폴더를 지우기 전) 다르면 되돌린 뒤 같은지 다시 보고 크게 알린다. 같으면 보관본을 지운다 (되돌리지 못하면 남김).
|
||||
# 사용 (app 폴더에서): powershell -ExecutionPolicy Bypass -File tool\update_path_check.ps1 -NewZip <zip> [-OldStage <폴더>] [-From 2026.10.18_001]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$NewZip,
|
||||
[string]$OldStage = '',
|
||||
[string]$From = '2026.10.18_001',
|
||||
[int]$WaitSeconds = 60
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$appSrc = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$root = Split-Path $appSrc # 앱 폴더 (app · wt_*) 의 위 = 루트
|
||||
if (-not $OldStage) { $OldStage = Join-Path $root "release\stage_v$From\JJ_MKVMaker" }
|
||||
$NewZip = (Resolve-Path $NewZip).Path
|
||||
if ((Split-Path $NewZip -Leaf) -notmatch '_v(\d{4}\.\d{2}\.\d{2}_\d{3})_win64\.zip$') { throw "zip 이름에서 판을 읽지 못함: $NewZip" }
|
||||
$newVer = $Matches[1]
|
||||
$sha = (Get-FileHash $NewZip -Algorithm SHA256).Hash.ToLower()
|
||||
$fails = New-Object System.Collections.Generic.List[string]
|
||||
function Check([string]$what, [bool]$ok, [string]$detail = '') {
|
||||
if ($ok) { Write-Host "통과: $what $detail" } else { Write-Host "실패: $what $detail"; $fails.Add($what) }
|
||||
}
|
||||
|
||||
# 사용자 쪽 (읽기만): 시작 메뉴 바로 가기 · 주소 연결 · %TEMP% 의 jj 이름
|
||||
$lnk = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\JJ_MKVMaker.lnk'
|
||||
function UserArea {
|
||||
$t = if (Test-Path $lnk) { (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath + ' @' + (Get-Item $lnk).LastWriteTime.Ticks } else { '(없음)' }
|
||||
$k = 'HKCU:\Software\Classes\jjmkvmaker\shell\open\command'
|
||||
$r = if (Test-Path $k) { (Get-ItemProperty $k).'(default)' } else { '(없음)' }
|
||||
"$t | $r"
|
||||
}
|
||||
$userBefore = UserArea
|
||||
$sysTemp = [IO.Path]::GetTempPath()
|
||||
$tempBefore = @(Get-ChildItem $sysTemp -Force -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name })
|
||||
|
||||
# ① 새 판에 시작 메뉴 막기가 있는지 (번역 글로 본다 - 121dd43 에서 생긴 글)
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$marker = 'Started with a test data folder (JJ_MKVMAKER_DATA)'
|
||||
$zf = [IO.Compression.ZipFile]::OpenRead($NewZip)
|
||||
try {
|
||||
$en = $zf.Entries | Where-Object { $_.FullName -like '*/data/flutter_assets/assets/l10n/en.json' } | Select-Object -First 1
|
||||
$hasGuard = $false
|
||||
if ($en) { $sr = New-Object IO.StreamReader($en.Open()); try { $hasGuard = $sr.ReadToEnd().Contains($marker) } finally { $sr.Dispose() } }
|
||||
} finally { $zf.Dispose() }
|
||||
if (-not $hasGuard) {
|
||||
Write-Host "멈춤: $NewZip 에는 시작 메뉴 막기 (121dd43) 가 없습니다 - 이 판을 따로 데이터로 켜면 사용자 시작 메뉴 바로 가기 · jjmkvmaker:// 가 바뀝니다. 아무것도 하지 않았습니다."
|
||||
exit 3
|
||||
}
|
||||
Write-Host '통과: 새 판에 시작 메뉴 막기가 있음 (en.json)'
|
||||
|
||||
$tag = -join ((1..6) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) })
|
||||
# ② 사용자 바로 가기 · 주소 연결 보관 (시험 폴더 밖)
|
||||
$B = Join-Path $sysTemp "jj_updtest_backup_$tag"
|
||||
$protoKey = 'HKCU\Software\Classes\jjmkvmaker'
|
||||
New-Item -ItemType Directory $B | Out-Null
|
||||
$hadLnk = Test-Path $lnk
|
||||
if ($hadLnk) { Copy-Item -LiteralPath $lnk "$B\JJ_MKVMaker.lnk" }
|
||||
$hadProto = Test-Path 'HKCU:\Software\Classes\jjmkvmaker'
|
||||
if ($hadProto) {
|
||||
cmd /c "reg export `"$protoKey`" `"$B\jjmkvmaker.reg`" /y >nul 2>&1"
|
||||
if ($LASTEXITCODE -ne 0) { throw "주소 연결을 보관하지 못함 (reg export $LASTEXITCODE) - 시작하지 않음" }
|
||||
}
|
||||
Write-Host "사용자 바로 가기 · 주소 연결 보관: $B (바로 가기 $hadLnk · 주소 연결 $hadProto)"
|
||||
$T = Join-Path $sysTemp "jj_updtest_$tag"
|
||||
$app = "$T\app"; $data = "$T\data"; $media = "$T\media"; $tmp = "$T\temp"
|
||||
New-Item -ItemType Directory $T, $data, $media, $tmp | Out-Null
|
||||
Write-Host "시험 폴더: $T ($From → $newVer)"
|
||||
$appProcs = { @(Get-Process jj_mkvmaker -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($T, [StringComparison]::OrdinalIgnoreCase) }) }
|
||||
try {
|
||||
# 1. 예전 판 배포 폴더 · 데이터
|
||||
robocopy $OldStage $app /E /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
if ($LASTEXITCODE -ge 8) { throw "예전 판 복사 실패 $LASTEXITCODE" }
|
||||
$oldVer = (Get-Item "$app\Lib\jj_mkvmaker.exe").VersionInfo.ProductVersion
|
||||
$videos = @("$media\a.mp4", "$media\b.mkv")
|
||||
foreach ($v in $videos) { [IO.File]::WriteAllBytes($v, [byte[]](1..64)) }
|
||||
$pw = "jj_updtest-Pw-$tag"
|
||||
$utf8 = New-Object Text.UTF8Encoding $false
|
||||
[IO.File]::WriteAllText("$data\videos.json", (ConvertTo-Json -InputObject $videos -Compress), $utf8)
|
||||
$settings = [ordered]@{
|
||||
lastRunVersion = $From
|
||||
autoCheckUpdates = $false
|
||||
startMenuShortcut = $true
|
||||
webdavServers = @(@{ id = 'jj_updtest_dav'; name = 'jj_updtest_dav'; url = 'https://127.0.0.1:9/dav'; user = 'jj_updtest_user' })
|
||||
zz_jj_updtest_future = @{ keep = $true; n = 7 }
|
||||
}
|
||||
[IO.File]::WriteAllText("$data\settings.json", (ConvertTo-Json -InputObject $settings -Depth 5), $utf8)
|
||||
Add-Type -AssemblyName System.Security
|
||||
$plain = $utf8.GetBytes((ConvertTo-Json -InputObject @{ 'dav.pw.jj_updtest_dav' = $pw } -Compress))
|
||||
[IO.File]::WriteAllBytes("$data\secrets.dat", [Security.Cryptography.ProtectedData]::Protect($plain, $null, 'CurrentUser'))
|
||||
|
||||
# 2. 앱 안 업데이트 길 (통합 시험) - 환경 변수는 이 시험 · 새 판에만
|
||||
$saved = @{}
|
||||
$vars = @{ JJ_MKVMAKER_DATA = $data; JJ_UPDATE_LOCAL_ZIP = "$NewZip;$sha"; JJ_UPD_APP = $app; JJ_UPD_FROM = $From; TEMP = $tmp; TMP = $tmp }
|
||||
foreach ($k in $vars.Keys) { $saved[$k] = [Environment]::GetEnvironmentVariable($k); [Environment]::SetEnvironmentVariable($k, $vars[$k]) }
|
||||
Push-Location $appSrc
|
||||
try {
|
||||
# flutter 의 경고 (stderr) 를 오류로 보지 않게 (PowerShell 5.1 의 2>&1 + Stop)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$out = & flutter test integration_test\update_path_real_test.dart -d windows 2>&1 | ForEach-Object { "$_" }
|
||||
$code = $LASTEXITCODE
|
||||
$ErrorActionPreference = 'Stop'
|
||||
} finally {
|
||||
Pop-Location
|
||||
foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) }
|
||||
}
|
||||
$out | Select-String 'UPD_SCHEDULED|All tests|Some tests|\[E\]|Expected|Actual' | ForEach-Object { Write-Host " $_" }
|
||||
Check '앱 안 업데이트 길 ([지금 설치] · 설정 보관 · 교체 예약)' ($code -eq 0 -and ($out -match 'UPD_SCHEDULED')) "(exit $code)"
|
||||
|
||||
# 3. 교체 → 새 판이 켜짐 → 설정을 씀
|
||||
# flutter test 는 시험이 끝나면 시험 앱과 그 앱이 띄운 프로세스 (교체 스크립트 포함) 를 함께 끝낸다 (15 확인 10/21: 기록 없음 · 도는 것 0).
|
||||
# 사용자 판은 앱이 스스로 끝나 그런 일이 없다. 그래서 앱이 써 둔 교체 스크립트를 앱이 넘긴 것과 같은 인수 · 환경으로 여기서 띄운다
|
||||
$exe = "$app\Lib\jj_mkvmaker.exe"
|
||||
$work = "$T\upd_tmp"
|
||||
$ps1 = "$work\jj_mkvmaker_update.ps1"
|
||||
$src = Get-ChildItem "$work\jj_mkvmaker_update_$newVer\extract" -Recurse -Filter jj_mkvmaker.exe -ErrorAction SilentlyContinue |
|
||||
Sort-Object { $_.FullName.Split('\').Count } | Select-Object -First 1
|
||||
$testPid = if (($out -join "`n") -match 'UPD_SCHEDULED pid=(\d+)') { [int]$Matches[1] } else { 0 }
|
||||
Check '앱이 교체 스크립트 · 풀어 둔 새 판을 남김' ((Test-Path $ps1) -and $src) "($ps1)"
|
||||
if ((Test-Path $ps1) -and $src) {
|
||||
foreach ($k in $vars.Keys) { [Environment]::SetEnvironmentVariable($k, $vars[$k]) }
|
||||
try {
|
||||
Start-Process powershell -WindowStyle Hidden -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', $ps1,
|
||||
'-ParentPid', $testPid, '-Source', $src.DirectoryName, '-Target', $app, '-Exe', "$app\jj_mkvmaker.exe" | Out-Null
|
||||
} finally {
|
||||
foreach ($k in $saved.Keys) { [Environment]::SetEnvironmentVariable($k, $saved[$k]) }
|
||||
}
|
||||
}
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($sw.Elapsed.TotalSeconds -lt 120 -and -not ((Get-Item $exe).VersionInfo.ProductVersion -ne $oldVer -and (& $appProcs).Count -gt 0)) { Start-Sleep -Milliseconds 500 }
|
||||
$newPv = (Get-Item $exe).VersionInfo.ProductVersion
|
||||
# 교체 스크립트의 흔적 (안 됐을 때 까닭을 보려고): 기록 · 아직 도는 스크립트
|
||||
$ulog = "$app\..\upd_tmp\jj_mkvmaker_update.log"
|
||||
Write-Host (" 교체 기록: " + $(if (Test-Path $ulog) { ((Get-Content $ulog | Select-String 'Copied|Extras|Failed|ERROR' | Select-Object -First 4) -join ' | ') } else { '(없음 - 스크립트가 robocopy 까지 가지 않음)' }))
|
||||
$ps = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | Where-Object { $_.CommandLine -like '*jj_mkvmaker_update.ps1*' -and $_.CommandLine -like "*$T*" })
|
||||
Write-Host " 아직 도는 교체 스크립트: $($ps.Count)개"
|
||||
Check '프로그램 교체' ($newPv -ne $oldVer) "($oldVer → $newPv)"
|
||||
Check '새 판이 저절로 켜짐 (교체 스크립트)' ((& $appProcs).Count -gt 0)
|
||||
$sw.Restart()
|
||||
$s = $null
|
||||
while ($sw.Elapsed.TotalSeconds -lt $WaitSeconds) {
|
||||
try { $s = Get-Content -Raw -Encoding UTF8 "$data\settings.json" | ConvertFrom-Json } catch { $s = $null }
|
||||
if ($s -and $s.lastRunVersion -eq $newVer) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
Start-Sleep -Seconds 3
|
||||
& $appProcs | ForEach-Object { $_.CloseMainWindow() | Out-Null }
|
||||
Start-Sleep -Seconds 5
|
||||
& $appProcs | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
# 4. 확인
|
||||
$raw = Get-Content -Raw -Encoding UTF8 "$data\settings.json"
|
||||
$s = $raw | ConvertFrom-Json
|
||||
Check '판 기록 (lastRunVersion)' ($s.lastRunVersion -eq $newVer) "($($s.lastRunVersion))"
|
||||
Check '이 판이 모르는 설정 키 그대로' ($s.zz_jj_updtest_future.keep -eq $true -and $s.zz_jj_updtest_future.n -eq 7)
|
||||
Check 'WebDAV 서버 그대로' (@($s.webdavServers | Where-Object { $_.id -eq 'jj_updtest_dav' -and $_.user -eq 'jj_updtest_user' }).Count -eq 1)
|
||||
$v2 = Get-Content -Raw -Encoding UTF8 "$data\videos.json" | ConvertFrom-Json
|
||||
Check '동영상 목록 그대로' ((@($v2) -join '|') -eq ($videos -join '|')) "($(@($v2).Count)개)"
|
||||
$dec = $utf8.GetString([Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes("$data\secrets.dat"), $null, 'CurrentUser')) | ConvertFrom-Json
|
||||
Check '비밀번호 (안전 저장소) 그대로' ($dec.'dav.pw.jj_updtest_dav' -eq $pw)
|
||||
$plainHits = @(Get-ChildItem $data -Recurse -File | Where-Object { $_.Name -ne 'secrets.dat' -and (Select-String -LiteralPath $_.FullName -SimpleMatch $pw -Quiet) } | ForEach-Object { $_.FullName.Substring($data.Length + 1) })
|
||||
Check '평문 비밀번호 없음 (설정 · .bak · 보관본 · 기록)' ($plainHits.Count -eq 0) ($plainHits -join ', ')
|
||||
Check "예전 판 ($From) 설정 보관본" (Test-Path "$data\version_snapshots\$From\settings.json")
|
||||
$userAfter = UserArea
|
||||
Check '사용자 시작 메뉴 바로 가기 · jjmkvmaker:// 그대로' ($userAfter -eq $userBefore) "($userAfter)"
|
||||
} finally {
|
||||
& $appProcs | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
# ② 바뀌었으면 되돌림 (시험 폴더를 지우기 전) - 같은지 다시 보고, 같을 때만 보관본을 지운다
|
||||
$now = UserArea
|
||||
if ($now -ne $userBefore) {
|
||||
Write-Host '!!!!!!!! 사용자 시작 메뉴 바로 가기 · jjmkvmaker:// 가 바뀌었습니다 - 보관본으로 되돌립니다 !!!!!!!!'
|
||||
Write-Host " 시험 전: $userBefore"
|
||||
Write-Host " 시험 뒤: $now"
|
||||
if ($hadLnk) { Copy-Item -LiteralPath "$B\JJ_MKVMaker.lnk" $lnk -Force } elseif (Test-Path $lnk) { Remove-Item -LiteralPath $lnk -Force }
|
||||
# reg 의 오류 글은 cmd 안에서 버린다 (PowerShell 의 2>$null 은 Stop 에서 예외가 됨)
|
||||
cmd /c "reg delete `"$protoKey`" /f >nul 2>&1"
|
||||
if ($hadProto) { cmd /c "reg import `"$B\jjmkvmaker.reg`" >nul 2>&1" }
|
||||
$restored = UserArea
|
||||
# 되돌린 바로 가기는 고친 시각이 보관본 것과 같아야 한다 (Copy-Item 은 고친 시각을 그대로 둔다)
|
||||
Check '되돌린 뒤 사용자 바로 가기 · 주소 연결이 시험 전과 같음' ($restored -eq $userBefore) "($restored)"
|
||||
if ($restored -ne $userBefore) { Write-Host " 보관본을 남깁니다: $B" }
|
||||
$fails.Add('사용자 시작 메뉴 · 주소 연결이 시험 중 바뀜 (되돌림)')
|
||||
}
|
||||
if ((UserArea) -eq $userBefore) { cmd /c "rd /s /q `"$B`" 2>nul" }
|
||||
for ($i = 0; $i -lt 30 -and (Test-Path -LiteralPath $T); $i++) {
|
||||
if ($i -gt 0) { Start-Sleep -Seconds 2 }
|
||||
cmd /c "rd /s /q `"\\?\$T`" 2>nul"
|
||||
}
|
||||
$left = @(Get-ChildItem $sysTemp -Force -Filter 'jj_updtest_*' -ErrorAction SilentlyContinue)
|
||||
Check '시험 폴더 · 가짜 자격 증명 (jj_updtest_*) 0개' ($left.Count -eq 0) "($($left.Count)개)"
|
||||
$tempNew = @(Get-ChildItem $sysTemp -Force -Filter 'jj*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name } | Where-Object { $tempBefore -notcontains $_ -and $_ -notlike 'jj_updtest_*' })
|
||||
# 참고만: 시험 · 새 판의 TEMP 는 시험 폴더로 돌렸으므로, 여기 새로 생긴 jj 이름은 다른 세션의 시험일 수 있다
|
||||
if ($tempNew.Count -gt 0) { Write-Host "참고 - %TEMP% 에 새로 생긴 jj 이름 (다른 세션일 수 있음): $($tempNew -join ', ')" }
|
||||
}
|
||||
if ($fails.Count -gt 0) { Write-Host "실패 $($fails.Count)개"; exit 1 }
|
||||
Write-Host '업데이트 길: 모두 통과'
|
||||
exit 0
|
||||
Loading…
Reference in New Issue