WIP zip_aes: 비밀번호 ZIP (AES-256) 만들기 · 한글 비밀번호 ZIP 풀기 고침 (검토 대기)
관리자 지시 (10/17, k): 86 [ZIP 으로 묶기] 창에 "비밀번호" 칸. 비밀번호는 기억 · 기록하지 않음. - 창: [비밀번호 (선택)] (보기 · 숨기기) → 넣으면 [비밀번호 다시 넣기], 같아야 [암호로 묶기]. 잊으면 못 푼다 · 이름 목록은 암호화 안 됨 안내 - 영문 · 숫자 · 기호 비밀번호: 동봉 7-Zip (Windows 7z.exe · Android lib7zz) 로 -tzip -mem=AES256 -mcu=on, 비밀번호는 표준 입력으로만. 흘려 가며 암호화해 큰 동영상도 메모리에 다 올리지 않음. 넣을 파일은 앱이 고름 (링크 · junction 빼고, 고른 것 기준 상대 이름) - 한글 등 영문 밖 글자 비밀번호: Windows 7-Zip 은 받지 않는다 (표준 입력 · 명령 줄 모두 "매개 변수가 틀립니다", 10/17 시험) → archive 의 AES-256 (WinZip AE) 으로. 파일을 통째로 메모리에 올리므로 512MB 넘는 파일이 있으면 미리 알리고 묶지 않음 (내 판단). 창에 "이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있음" 안내 (막지는 않음) - 버그 고침: archive 4.3.0 은 비밀번호를 password.codeUnits 를 바이트로 잘라 써서 한글이 깨졌다 (다른 프로그램과 맞지 않음). UTF-8 바이트로 넘기고 (zipPasswordBytes), 풀 때는 UTF-8 로 먼저 · 틀리면 예전 방식으로 한 번 더 - -mcu=on: 7-Zip 이 만든 ZIP 의 한글 이름을 UTF-8 로 (안 하면 앱 · Android 에서 이름이 깨졌다 - 시험에서 찾음) 따로 알릴 것 (이 커밋에서 고치지 않음): 86 의 7z · RAR 풀기도 비밀번호에 한글이 있으면 Windows 7-Zip 이 표준 입력을 받지 않아 실패할 것 (관리자 판단 필요). 시험: archive_ops (영문 비밀번호 → 7-Zip, 한글 비밀번호 → archive: 앱 풀기 · 틀린 비밀번호 · 7-Zip 풀기, 취소 · 실패 · 빈 비밀번호), archive_ui (두 번 같아야 누름 · 한글 안내 · [취소] 는 아무 일 없음), licenses - 25 통과, analyze 문제 없음, l10n 빠짐 0. 위젯 시험 안에서는 7-Zip 이 가짜 시간 때문에 끝나지 않아 창 동작만 본다 (만들기 · 풀기는 단위 시험). 7z 의 표준 입력 비밀번호는 실제 7z.exe 로 확인. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>zip_aes
parent
13c8e5f5bf
commit
16f33b3b71
|
|
@ -797,6 +797,7 @@
|
||||||
"내장 플레이어로 재생": "Play in built-in player",
|
"내장 플레이어로 재생": "Play in built-in player",
|
||||||
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "Page direction: right → left (manga)",
|
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "Page direction: right → left (manga)",
|
||||||
"넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right",
|
"넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right",
|
||||||
|
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "If set, encrypts with AES-256. Leave empty for a normal ZIP",
|
||||||
"넣지 못했습니다: {0}": "Could not add: {0}",
|
"넣지 못했습니다: {0}": "Could not add: {0}",
|
||||||
"네덜란드어": "Dutch",
|
"네덜란드어": "Dutch",
|
||||||
"네트워크 · DNS": "Network · DNS",
|
"네트워크 · DNS": "Network · DNS",
|
||||||
|
|
@ -978,6 +979,7 @@
|
||||||
"되살리고 다시 시작": "Restore and restart",
|
"되살리고 다시 시작": "Restore and restart",
|
||||||
"되살리기": "Restore",
|
"되살리기": "Restore",
|
||||||
"두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match",
|
"두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match",
|
||||||
|
"두 비밀번호가 다릅니다": "The passwords don't match",
|
||||||
"두 쪽 나눔 끄기 (한 장으로 보기)": "Turn off two-page split (show as one)",
|
"두 쪽 나눔 끄기 (한 장으로 보기)": "Turn off two-page split (show as one)",
|
||||||
"두 쪽 나눠 보기": "Split two-page spreads",
|
"두 쪽 나눠 보기": "Split two-page spreads",
|
||||||
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "Split two-page spreads: off (tap for auto)",
|
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "Split two-page spreads: off (tap for auto)",
|
||||||
|
|
@ -1111,6 +1113,7 @@
|
||||||
"무엇이 바뀌는지 비교하는 중…": "Comparing what will change…",
|
"무엇이 바뀌는지 비교하는 중…": "Comparing what will change…",
|
||||||
"무한": "Unlimited",
|
"무한": "Unlimited",
|
||||||
"묶기": "Compress",
|
"묶기": "Compress",
|
||||||
|
"묶을 파일이 없습니다": "There are no files to add",
|
||||||
"문서 미리보기": "Document preview",
|
"문서 미리보기": "Document preview",
|
||||||
"문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first",
|
"문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first",
|
||||||
"문자셋 경고": "Charset warning",
|
"문자셋 경고": "Charset warning",
|
||||||
|
|
@ -1291,9 +1294,13 @@
|
||||||
"비교하지 못했습니다: {0}": "Couldn't compare: {0}",
|
"비교하지 못했습니다: {0}": "Couldn't compare: {0}",
|
||||||
"비밀번호": "Password",
|
"비밀번호": "Password",
|
||||||
"비밀번호 (선택)": "Password (optional)",
|
"비밀번호 (선택)": "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)",
|
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "Passwords · API keys (WebDAV · OpenSubtitles · AI services)",
|
||||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.",
|
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "Passwords and the subtitle API key aren't backed up, so please enter them again. Auto Backup runs about once a day, so changes made after it may be missing.",
|
||||||
"비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
|
"비밀번호 기억 (안전 저장소)": "Remember passwords (secure storage)",
|
||||||
|
"비밀번호 다시 넣기": "Enter password again",
|
||||||
|
"비밀번호 보기": "Show password",
|
||||||
|
"비밀번호 숨기기": "Hide password",
|
||||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)",
|
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)",
|
||||||
"비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords",
|
"비밀번호가 저장된 기능을 쓸 때": "When using features with saved passwords",
|
||||||
"비밀번호가 틀렸습니다": "Wrong password",
|
"비밀번호가 틀렸습니다": "Wrong password",
|
||||||
|
|
@ -1302,6 +1309,8 @@
|
||||||
"비밀번호를 넣으세요": "Enter a password",
|
"비밀번호를 넣으세요": "Enter a password",
|
||||||
"비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage",
|
"비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage",
|
||||||
"비밀번호를 잊었습니다": "I forgot the password",
|
"비밀번호를 잊었습니다": "I forgot the password",
|
||||||
|
"비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.": "If you forget the password, this ZIP can't be extracted (the app doesn't remember it). The list of file names is not encrypted.",
|
||||||
|
"비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.": "With a password containing non-English letters (e.g. Korean), files over 512MB ({0}) can't be added. Use a password of English letters, digits and symbols, or split the file.",
|
||||||
"비어 있는 압축 파일입니다.": "The archive is empty.",
|
"비어 있는 압축 파일입니다.": "The archive is empty.",
|
||||||
"비어 있는 폴더": "Empty folder",
|
"비어 있는 폴더": "Empty folder",
|
||||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
|
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
|
||||||
|
|
@ -1521,6 +1530,8 @@
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "{0} files inside are newer on the receiving side - merging overwrites them with older ones",
|
||||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "Notifications show as JJ_MKVMaker and open the app when clicked (current user only, no admin rights)",
|
||||||
|
"암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
|
||||||
|
"암호로 묶기": "Make password ZIP",
|
||||||
"암호화 안 됨 (http)": "Not encrypted (http)",
|
"암호화 안 됨 (http)": "Not encrypted (http)",
|
||||||
"압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive",
|
"압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive",
|
||||||
"압축 파일 안에 없습니다": "Not in the archive",
|
"압축 파일 안에 없습니다": "Not in the archive",
|
||||||
|
|
@ -2449,6 +2460,7 @@
|
||||||
"한 창": "One pane",
|
"한 창": "One pane",
|
||||||
"한국어": "Korean",
|
"한국어": "Korean",
|
||||||
"한글": "Hangul",
|
"한글": "Hangul",
|
||||||
|
"한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.": "A password with non-English letters (e.g. Korean) works in this app, but other programs such as 7-Zip may not open it. For ZIPs you share with other devices or programs, use English letters, digits and symbols.",
|
||||||
"할 일": "Task",
|
"할 일": "Task",
|
||||||
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} merged folders can't be undone (what was moved isn't known).",
|
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} merged folders can't be undone (what was moved isn't known).",
|
||||||
"항목 {0}/{1}": "Items {0}/{1}",
|
"항목 {0}/{1}": "Items {0}/{1}",
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,7 @@
|
||||||
"내장 플레이어로 재생": "内蔵プレーヤーで再生",
|
"내장 플레이어로 재생": "内蔵プレーヤーで再生",
|
||||||
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "めくる方向: 右 → 左 (マンガ)",
|
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "めくる方向: 右 → 左 (マンガ)",
|
||||||
"넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右",
|
"넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右",
|
||||||
|
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "入力すると AES-256 で暗号化します。空欄なら通常の ZIP",
|
||||||
"넣지 못했습니다: {0}": "追加できませんでした: {0}",
|
"넣지 못했습니다: {0}": "追加できませんでした: {0}",
|
||||||
"네덜란드어": "オランダ語",
|
"네덜란드어": "オランダ語",
|
||||||
"네트워크 · DNS": "ネットワーク・DNS",
|
"네트워크 · DNS": "ネットワーク・DNS",
|
||||||
|
|
@ -978,6 +979,7 @@
|
||||||
"되살리고 다시 시작": "復元して再起動",
|
"되살리고 다시 시작": "復元して再起動",
|
||||||
"되살리기": "元に戻す",
|
"되살리기": "元に戻す",
|
||||||
"두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います",
|
"두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います",
|
||||||
|
"두 비밀번호가 다릅니다": "2 つのパスワードが一致しません",
|
||||||
"두 쪽 나눔 끄기 (한 장으로 보기)": "見開き分割をオフ (1 枚で表示)",
|
"두 쪽 나눔 끄기 (한 장으로 보기)": "見開き分割をオフ (1 枚で表示)",
|
||||||
"두 쪽 나눠 보기": "見開きを分けて表示",
|
"두 쪽 나눠 보기": "見開きを分けて表示",
|
||||||
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "見開きを分けて表示: オフ (押すと自動)",
|
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "見開きを分けて表示: オフ (押すと自動)",
|
||||||
|
|
@ -1111,6 +1113,7 @@
|
||||||
"무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…",
|
"무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…",
|
||||||
"무한": "無制限",
|
"무한": "無制限",
|
||||||
"묶기": "まとめる",
|
"묶기": "まとめる",
|
||||||
|
"묶을 파일이 없습니다": "まとめるファイルがありません",
|
||||||
"문서 미리보기": "文書プレビュー",
|
"문서 미리보기": "文書プレビュー",
|
||||||
"문서 미리보기 컴포넌트를 먼저 설치하세요": "先にドキュメントプレビューのコンポーネントをインストールしてください",
|
"문서 미리보기 컴포넌트를 먼저 설치하세요": "先にドキュメントプレビューのコンポーネントをインストールしてください",
|
||||||
"문자셋 경고": "文字コードの警告",
|
"문자셋 경고": "文字コードの警告",
|
||||||
|
|
@ -1291,9 +1294,13 @@
|
||||||
"비교하지 못했습니다: {0}": "比較できませんでした: {0}",
|
"비교하지 못했습니다: {0}": "比較できませんでした: {0}",
|
||||||
"비밀번호": "パスワード",
|
"비밀번호": "パスワード",
|
||||||
"비밀번호 (선택)": "パスワード(任意)",
|
"비밀번호 (선택)": "パスワード(任意)",
|
||||||
|
"비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "このバージョンにはパスワード付き ZIP を作る 7-Zip がありません。パスワードなしでまとめるか、アプリを再インストールしてください。",
|
||||||
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)",
|
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)",
|
||||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
|
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
|
||||||
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
|
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
|
||||||
|
"비밀번호 다시 넣기": "パスワードをもう一度入力",
|
||||||
|
"비밀번호 보기": "パスワードを表示",
|
||||||
|
"비밀번호 숨기기": "パスワードを隠す",
|
||||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)",
|
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)",
|
||||||
"비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき",
|
"비밀번호가 저장된 기능을 쓸 때": "パスワードを保存した機能を使うとき",
|
||||||
"비밀번호가 틀렸습니다": "パスワードが違います",
|
"비밀번호가 틀렸습니다": "パスワードが違います",
|
||||||
|
|
@ -1302,6 +1309,8 @@
|
||||||
"비밀번호를 넣으세요": "パスワードを入力してください",
|
"비밀번호를 넣으세요": "パスワードを入力してください",
|
||||||
"비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました",
|
"비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました",
|
||||||
"비밀번호를 잊었습니다": "パスワードを忘れました",
|
"비밀번호를 잊었습니다": "パスワードを忘れました",
|
||||||
|
"비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.": "パスワードを忘れるとこの ZIP は展開できません (アプリは記憶しません)。ファイル名の一覧は暗号化されません。",
|
||||||
|
"비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.": "パスワードに英字以外の文字 (ハングルなど) があると、512MB を超えるファイル ({0}) はまとめられません。英数字・記号のパスワードを使うか、ファイルを分けてください。",
|
||||||
"비어 있는 압축 파일입니다.": "空の圧縮ファイルです。",
|
"비어 있는 압축 파일입니다.": "空の圧縮ファイルです。",
|
||||||
"비어 있는 폴더": "空のフォルダー",
|
"비어 있는 폴더": "空のフォルダー",
|
||||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
|
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
|
||||||
|
|
@ -1521,6 +1530,8 @@
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
|
||||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
|
||||||
|
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
|
||||||
|
"암호로 묶기": "パスワード付きでまとめる",
|
||||||
"암호화 안 됨 (http)": "暗号化なし (http)",
|
"암호화 안 됨 (http)": "暗号化なし (http)",
|
||||||
"압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」",
|
"압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」",
|
||||||
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
|
"압축 파일 안에 없습니다": "圧縮ファイル内にありません",
|
||||||
|
|
@ -2449,6 +2460,7 @@
|
||||||
"한 창": "1 ペイン",
|
"한 창": "1 ペイン",
|
||||||
"한국어": "韓国語",
|
"한국어": "韓国語",
|
||||||
"한글": "ハングル",
|
"한글": "ハングル",
|
||||||
|
"한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.": "英字以外の文字 (ハングルなど) を含むパスワードは、このアプリでは展開できますが、7-Zip など他のプログラムでは開けないことがあります。他の端末やプログラムと共有する ZIP には英数字・記号のパスワードを使ってください。",
|
||||||
"할 일": "内容",
|
"할 일": "内容",
|
||||||
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。",
|
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。",
|
||||||
"항목 {0}/{1}": "項目 {0}/{1}",
|
"항목 {0}/{1}": "項目 {0}/{1}",
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,7 @@
|
||||||
"내장 플레이어로 재생": "用内置播放器播放",
|
"내장 플레이어로 재생": "用内置播放器播放",
|
||||||
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "翻页方向: 右 → 左 (漫画)",
|
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "翻页方向: 右 → 左 (漫画)",
|
||||||
"넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右",
|
"넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右",
|
||||||
|
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "填写后使用 AES-256 加密。留空则为普通 ZIP",
|
||||||
"넣지 못했습니다: {0}": "无法加入: {0}",
|
"넣지 못했습니다: {0}": "无法加入: {0}",
|
||||||
"네덜란드어": "荷兰语",
|
"네덜란드어": "荷兰语",
|
||||||
"네트워크 · DNS": "网络 · DNS",
|
"네트워크 · DNS": "网络 · DNS",
|
||||||
|
|
@ -978,6 +979,7 @@
|
||||||
"되살리고 다시 시작": "恢复并重新启动",
|
"되살리고 다시 시작": "恢复并重新启动",
|
||||||
"되살리기": "恢复",
|
"되살리기": "恢复",
|
||||||
"두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致",
|
"두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致",
|
||||||
|
"두 비밀번호가 다릅니다": "两次输入的密码不一致",
|
||||||
"두 쪽 나눔 끄기 (한 장으로 보기)": "关闭双页拆分(整张显示)",
|
"두 쪽 나눔 끄기 (한 장으로 보기)": "关闭双页拆分(整张显示)",
|
||||||
"두 쪽 나눠 보기": "拆分双页",
|
"두 쪽 나눠 보기": "拆分双页",
|
||||||
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "拆分双页: 关闭 (点按切换为自动)",
|
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "拆分双页: 关闭 (点按切换为自动)",
|
||||||
|
|
@ -1111,6 +1113,7 @@
|
||||||
"무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…",
|
"무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…",
|
||||||
"무한": "无限制",
|
"무한": "无限制",
|
||||||
"묶기": "压缩",
|
"묶기": "压缩",
|
||||||
|
"묶을 파일이 없습니다": "没有可压缩的文件",
|
||||||
"문서 미리보기": "文档预览",
|
"문서 미리보기": "文档预览",
|
||||||
"문서 미리보기 컴포넌트를 먼저 설치하세요": "请先安装文档预览组件",
|
"문서 미리보기 컴포넌트를 먼저 설치하세요": "请先安装文档预览组件",
|
||||||
"문자셋 경고": "字符集警告",
|
"문자셋 경고": "字符集警告",
|
||||||
|
|
@ -1291,9 +1294,13 @@
|
||||||
"비교하지 못했습니다: {0}": "无法比较: {0}",
|
"비교하지 못했습니다: {0}": "无法比较: {0}",
|
||||||
"비밀번호": "密码",
|
"비밀번호": "密码",
|
||||||
"비밀번호 (선택)": "密码(可选)",
|
"비밀번호 (선택)": "密码(可选)",
|
||||||
|
"비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "此版本没有用于创建加密 ZIP 的 7-Zip。请不设密码压缩,或重新安装应用。",
|
||||||
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)",
|
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)",
|
||||||
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
|
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
|
||||||
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
|
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
|
||||||
|
"비밀번호 다시 넣기": "再次输入密码",
|
||||||
|
"비밀번호 보기": "显示密码",
|
||||||
|
"비밀번호 숨기기": "隐藏密码",
|
||||||
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)",
|
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)",
|
||||||
"비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时",
|
"비밀번호가 저장된 기능을 쓸 때": "使用保存了密码的功能时",
|
||||||
"비밀번호가 틀렸습니다": "密码错误",
|
"비밀번호가 틀렸습니다": "密码错误",
|
||||||
|
|
@ -1302,6 +1309,8 @@
|
||||||
"비밀번호를 넣으세요": "请输入密码",
|
"비밀번호를 넣으세요": "请输入密码",
|
||||||
"비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储",
|
"비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储",
|
||||||
"비밀번호를 잊었습니다": "忘记密码",
|
"비밀번호를 잊었습니다": "忘记密码",
|
||||||
|
"비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.": "忘记密码将无法解压此 ZIP (应用不会记住密码)。文件名列表不会加密。",
|
||||||
|
"비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.": "密码含有英文以外的字符 (如韩文) 时,无法压缩超过 512MB 的文件 ({0})。请使用英文字母、数字和符号组成的密码,或拆分文件。",
|
||||||
"비어 있는 압축 파일입니다.": "压缩文件为空。",
|
"비어 있는 압축 파일입니다.": "压缩文件为空。",
|
||||||
"비어 있는 폴더": "空文件夹",
|
"비어 있는 폴더": "空文件夹",
|
||||||
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
|
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
|
||||||
|
|
@ -1521,6 +1530,8 @@
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
|
||||||
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
|
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
|
||||||
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
|
||||||
|
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
|
||||||
|
"암호로 묶기": "加密压缩",
|
||||||
"암호화 안 됨 (http)": "未加密 (http)",
|
"암호화 안 됨 (http)": "未加密 (http)",
|
||||||
"압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”",
|
"압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”",
|
||||||
"압축 파일 안에 없습니다": "压缩文件中没有",
|
"압축 파일 안에 없습니다": "压缩文件中没有",
|
||||||
|
|
@ -2449,6 +2460,7 @@
|
||||||
"한 창": "单窗格",
|
"한 창": "单窗格",
|
||||||
"한국어": "韩语",
|
"한국어": "韩语",
|
||||||
"한글": "韩文",
|
"한글": "韩文",
|
||||||
|
"한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.": "含有英文以外字符 (如韩文) 的密码可在本应用中解压,但 7-Zip 等其他程序可能无法打开。要与其他设备或程序共享的 ZIP,请使用英文字母、数字和符号组成的密码。",
|
||||||
"할 일": "任务",
|
"할 일": "任务",
|
||||||
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。",
|
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。",
|
||||||
"항목 {0}/{1}": "项目 {0}/{1}",
|
"항목 {0}/{1}": "项目 {0}/{1}",
|
||||||
|
|
|
||||||
|
|
@ -186,12 +186,39 @@ Future<List<ArchiveEntry>> listZip(String path) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ZIP 풀기 - [plan] 의 파일만, 같은 이름은 [conflict] 대로
|
/// ZIP 풀기 - [plan] 의 파일만, 같은 이름은 [conflict] 대로
|
||||||
|
/// zip_aes: ZIP 비밀번호를 UTF-8 바이트로 (한 바이트 = 한 글자). archive 4.3.0 은 비밀번호를 password.codeUnits 를 바이트로 잘라 써서
|
||||||
|
/// 한글 등이 깨지고 7-Zip (UTF-8) 과 맞지 않았다 - UTF-8 바이트를 글자로 넣으면 archive 가 그 바이트를 그대로 쓴다
|
||||||
|
String zipPasswordBytes(String password) => String.fromCharCodes(utf8.encode(password));
|
||||||
|
|
||||||
|
/// ZIP 풀기 ([_extractZipPlanWith]). 비밀번호에 영문 밖 글자가 있으면 UTF-8 (7-Zip · 이 앱이 만든 것) 로 먼저,
|
||||||
|
/// 틀리면 예전 방식 (이 앱의 예전 판 · archive 그대로) 으로 한 번 더
|
||||||
Future<ExtractResult> extractZipPlan(String path, String dest, ExtractPlan plan,
|
Future<ExtractResult> extractZipPlan(String path, String dest, ExtractPlan plan,
|
||||||
{NameConflict conflict = NameConflict.rename,
|
{NameConflict conflict = NameConflict.rename,
|
||||||
bool skipNewer = false,
|
bool skipNewer = false,
|
||||||
String? password,
|
String? password,
|
||||||
void Function(int done, int total)? onProgress,
|
void Function(int done, int total)? onProgress,
|
||||||
bool Function()? cancelled}) async {
|
bool Function()? cancelled}) async {
|
||||||
|
final tries = [
|
||||||
|
if (password != null && password.isNotEmpty) zipPasswordBytes(password),
|
||||||
|
if (password != null && password.isNotEmpty && zipPasswordBytes(password) != password) password,
|
||||||
|
if (password == null || password.isEmpty) password,
|
||||||
|
];
|
||||||
|
for (var i = 0;; i++) {
|
||||||
|
try {
|
||||||
|
return await _extractZipPlanWith(path, dest, plan,
|
||||||
|
conflict: conflict, skipNewer: skipNewer, password: tries[i], onProgress: onProgress, cancelled: cancelled);
|
||||||
|
} on ArchivePasswordException catch (e) {
|
||||||
|
if (!e.wrong || i + 1 >= tries.length) rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ExtractResult> _extractZipPlanWith(String path, String dest, ExtractPlan plan,
|
||||||
|
{NameConflict conflict = NameConflict.rename,
|
||||||
|
bool skipNewer = false,
|
||||||
|
String? password,
|
||||||
|
void Function(int done, int total)? onProgress,
|
||||||
|
bool Function()? cancelled}) async {
|
||||||
// 비밀번호가 걸린 항목인데 비밀번호가 없으면 (암호 글자를 그대로 쓰지 않게) 먼저 묻는다
|
// 비밀번호가 걸린 항목인데 비밀번호가 없으면 (암호 글자를 그대로 쓰지 않게) 먼저 묻는다
|
||||||
if (plan.encrypted && (password == null || password.isEmpty)) throw const ArchivePasswordException();
|
if (plan.encrypted && (password == null || password.isEmpty)) throw const ArchivePasswordException();
|
||||||
final input = InputFileStream(path);
|
final input = InputFileStream(path);
|
||||||
|
|
@ -249,7 +276,7 @@ Future<ExtractResult> extractZipPlan(String path, String dest, ExtractPlan plan,
|
||||||
/// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
|
/// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
|
||||||
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
|
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
|
||||||
Future<(int, int)> createZip(List<String> sources, String zipPath,
|
Future<(int, int)> createZip(List<String> sources, String zipPath,
|
||||||
{int level = 6, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async {
|
{int level = 6, String? password, void Function(int done, int total)? onProgress, bool Function()? cancelled}) async {
|
||||||
// 넣을 파일 (상대 이름) - 링크는 세기만
|
// 넣을 파일 (상대 이름) - 링크는 세기만
|
||||||
final files = <(String, String)>[];
|
final files = <(String, String)>[];
|
||||||
var links = 0;
|
var links = 0;
|
||||||
|
|
@ -271,7 +298,8 @@ Future<(int, int)> createZip(List<String> sources, String zipPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final part = '$zipPath.jjpart';
|
final part = '$zipPath.jjpart';
|
||||||
final enc = ZipFileEncoder()..create(part, level: level);
|
// zip_aes: [password] 가 있으면 archive 의 AES-256 (WinZip AE) - 파일마다 메모리에 올려 암호화하므로 큰 파일은 [createZipAes] 가 막는다
|
||||||
|
final enc = ZipFileEncoder(password: password == null || password.isEmpty ? null : zipPasswordBytes(password))..create(part, level: level);
|
||||||
var done = false;
|
var done = false;
|
||||||
try {
|
try {
|
||||||
for (final (i, (path, rel)) in files.indexed) {
|
for (final (i, (path, rel)) in files.indexed) {
|
||||||
|
|
@ -297,6 +325,101 @@ Future<(int, int)> createZip(List<String> sources, String zipPath,
|
||||||
|
|
||||||
// ───────── 7z · RAR (7-Zip 25.01 실행 파일, LGPL 2.1+ · unRAR 제한 - 풀기만) ─────────
|
// ───────── 7z · RAR (7-Zip 25.01 실행 파일, LGPL 2.1+ · unRAR 제한 - 풀기만) ─────────
|
||||||
|
|
||||||
|
/// zip_aes: 영문 밖 글자가 든 비밀번호로 (archive, 메모리에서) 암호화할 수 있는 파일 하나의 크기 한도
|
||||||
|
const aesInMemoryLimit = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// zip_aes: 영문 밖 글자가 든 비밀번호인데 [aesInMemoryLimit] 넘는 파일이 있음
|
||||||
|
class ZipPasswordTooBigException implements Exception {
|
||||||
|
final String name;
|
||||||
|
const ZipPasswordTooBigException(this.name);
|
||||||
|
@override
|
||||||
|
String toString() => trf('비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.', [name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// zip_aes (관리자 지시 10/17): 비밀번호 ZIP (AES-256) 을 동봉 7-Zip 으로 만든다 (Windows 7z.exe · Android lib7zz).
|
||||||
|
/// archive 패키지의 AES 는 파일마다 통째로 메모리에 올려 암호화해 큰 동영상에서 메모리가 모자랄 수 있어 7-Zip 으로 (흘려 가며).
|
||||||
|
/// 넣을 파일은 [createZip] 과 같게 앱이 고른다 (링크 · junction 빼고, 원본 폴더 기준 상대 이름). 비밀번호는 표준 입력으로만
|
||||||
|
/// (명령 줄 · 작업 기록 · 프로세스 목록에 남지 않게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
|
||||||
|
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
|
||||||
|
Future<(int, int)> createZipAes(String exe, List<String> sources, String zipPath,
|
||||||
|
{required String password, int level = 6, void Function(double v)? onProgress, bool Function()? cancelled}) async {
|
||||||
|
if (password.isEmpty) throw ArgumentError('password');
|
||||||
|
// Windows 7-Zip 은 표준 입력으로 받은 비밀번호에 영문 밖 글자 (한글 등) 가 있으면 "매개 변수가 틀립니다" 로 실패한다
|
||||||
|
// (코드 페이지를 바꿔도 같음, 10/17 시험). 그때는 archive 의 AES-256 으로 - 파일을 통째로 메모리에 올리므로 큰 파일은 막고 알린다 (내 판단)
|
||||||
|
if (password.codeUnits.any((c) => c < 0x20 || c > 0x7e)) {
|
||||||
|
for (final s in sources) {
|
||||||
|
for (final f in [
|
||||||
|
if (FileSystemEntity.isFileSync(s)) File(s),
|
||||||
|
if (FileSystemEntity.isDirectorySync(s)) ...Directory(s).listSync(recursive: true, followLinks: false).whereType<File>(),
|
||||||
|
]) {
|
||||||
|
if (f.lengthSync() > aesInMemoryLimit) throw ZipPasswordTooBigException(p.basename(f.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createZip(sources, zipPath,
|
||||||
|
level: level, password: password, cancelled: cancelled, onProgress: (d, t) => onProgress?.call(t == 0 ? 0 : d / t));
|
||||||
|
}
|
||||||
|
// 원본 폴더마다 (상대 이름이 그 폴더 기준이 되게) 넣을 파일 목록
|
||||||
|
final groups = <String, List<String>>{};
|
||||||
|
var links = 0, count = 0;
|
||||||
|
for (final s in sources) {
|
||||||
|
final base = p.dirname(s);
|
||||||
|
final t = FileSystemEntity.typeSync(s, followLinks: false);
|
||||||
|
if (t == FileSystemEntityType.link) {
|
||||||
|
links++;
|
||||||
|
} else if (t == FileSystemEntityType.file) {
|
||||||
|
groups.putIfAbsent(base, () => []).add(p.relative(s, from: base));
|
||||||
|
} else if (t == FileSystemEntityType.directory) {
|
||||||
|
await for (final e in Directory(s).list(recursive: true, followLinks: false)) {
|
||||||
|
if (e is Link || FileSystemEntity.isLinkSync(e.path)) {
|
||||||
|
links++;
|
||||||
|
} else if (e is File) {
|
||||||
|
groups.putIfAbsent(base, () => []).add(p.relative(e.path, from: base));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final l in groups.values) {
|
||||||
|
count += l.length;
|
||||||
|
}
|
||||||
|
final part = '$zipPath.jjpart';
|
||||||
|
final tmp = await Directory.systemTemp.createTemp('jj_7za_');
|
||||||
|
var done = false;
|
||||||
|
try {
|
||||||
|
var g = 0;
|
||||||
|
for (final MapEntry(key: base, value: rels) in groups.entries) {
|
||||||
|
if (cancelled?.call() ?? false) throw const ArchiveCancelled();
|
||||||
|
final list = File(p.join(tmp.path, 'list$g.txt'));
|
||||||
|
await list.writeAsString(rels.join('\n'), encoding: utf8);
|
||||||
|
final before = g;
|
||||||
|
final (code, out, err) = await _run7z(
|
||||||
|
exe,
|
||||||
|
['a', '-tzip', '-mem=AES256', '-mcu=on', '-mx=${level.clamp(0, 9)}', '-p', '-bsp1', '-bb0', '-y', part, '@${list.path}'],
|
||||||
|
password: password,
|
||||||
|
confirm: true,
|
||||||
|
workingDirectory: base,
|
||||||
|
cancelled: cancelled, onLine: (l) {
|
||||||
|
final m = RegExp(r'(\d+)%').firstMatch(l);
|
||||||
|
if (m != null) onProgress?.call((before + int.parse(m[1]!) / 100) / groups.length);
|
||||||
|
});
|
||||||
|
if (code != 0) throw ProcessException(exe, const ['a'], err.trim().isEmpty ? out.trim() : err.trim(), code);
|
||||||
|
g++;
|
||||||
|
}
|
||||||
|
if (count == 0) throw FileSystemException(tr('묶을 파일이 없습니다'), sources.first);
|
||||||
|
done = true;
|
||||||
|
await File(part).rename(zipPath);
|
||||||
|
} finally {
|
||||||
|
if (!done) {
|
||||||
|
try {
|
||||||
|
await File(part).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await tmp.delete(recursive: true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return (count, links);
|
||||||
|
}
|
||||||
|
|
||||||
/// 7-Zip 의 "l -slt" 출력 → 목록. 링크: "Symbolic Link" · "Hard Link" 칸이 있거나 속성에 유닉스 링크 (-l…) · 재분석 지점
|
/// 7-Zip 의 "l -slt" 출력 → 목록. 링크: "Symbolic Link" · "Hard Link" 칸이 있거나 속성에 유닉스 링크 (-l…) · 재분석 지점
|
||||||
List<ArchiveEntry> parse7zList(String out) {
|
List<ArchiveEntry> parse7zList(String out) {
|
||||||
final entries = <ArchiveEntry>[];
|
final entries = <ArchiveEntry>[];
|
||||||
|
|
@ -326,10 +449,12 @@ List<ArchiveEntry> parse7zList(String out) {
|
||||||
|
|
||||||
/// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단)
|
/// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단)
|
||||||
Future<(int, String, String)> _run7z(String exe, List<String> args,
|
Future<(int, String, String)> _run7z(String exe, List<String> args,
|
||||||
{String? password, void Function(String line)? onLine, bool Function()? cancelled}) async {
|
{String? password, void Function(String line)? onLine, bool Function()? cancelled, String? workingDirectory, bool confirm = false}) async {
|
||||||
final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args]);
|
final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args], workingDirectory: workingDirectory);
|
||||||
// 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다)
|
// 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다)
|
||||||
pr.stdin.writeln(password ?? '');
|
pr.stdin.writeln(password ?? '');
|
||||||
|
// 만들 때는 "다시 넣기" 도 묻는다 (zip_aes)
|
||||||
|
if (confirm) pr.stdin.writeln(password ?? '');
|
||||||
await pr.stdin.close();
|
await pr.stdin.close();
|
||||||
final out = StringBuffer(), err = StringBuffer();
|
final out = StringBuffer(), err = StringBuffer();
|
||||||
// 끝을 기다리는 Future 는 바로 만든다 (프로세스가 끝난 뒤 asFuture 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다)
|
// 끝을 기다리는 Future 는 바로 만든다 (프로세스가 끝난 뒤 asFuture 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다)
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,10 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
|
||||||
text: sources.length == 1 ? p.basenameWithoutExtension(sources.first) : p.basename(dir.endsWith(p.separator) ? p.dirname(dir) : dir));
|
text: sources.length == 1 ? p.basenameWithoutExtension(sources.first) : p.basename(dir.endsWith(p.separator) ? p.dirname(dir) : dir));
|
||||||
var level = c.settings.zipLevel;
|
var level = c.settings.zipLevel;
|
||||||
var where = dir;
|
var where = dir;
|
||||||
|
// zip_aes: 비밀번호 (비우면 보통 ZIP). 기억하지 않고, 기록에 남기지 않는다
|
||||||
|
final pass = TextEditingController();
|
||||||
|
final pass2 = TextEditingController();
|
||||||
|
var showPass = false;
|
||||||
final choices = <(String, String)>[(dir, tr('지금 폴더')), if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더'))];
|
final choices = <(String, String)>[(dir, tr('지금 폴더')), if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더'))];
|
||||||
String target() {
|
String target() {
|
||||||
var n = name.text.trim();
|
var n = name.text.trim();
|
||||||
|
|
@ -349,22 +353,79 @@ Future<String?> createZipFlow(BuildContext context, AppController c, List<String
|
||||||
_placeChoice(ctx, choices, where, (d) => set(() => where = d)),
|
_placeChoice(ctx, choices, where, (d) => set(() => where = d)),
|
||||||
Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
Text(trf('만들 파일: {0}', [p.basename(target())]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||||
Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
Text(tr('링크 · junction 은 넣지 않습니다'), style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextField(
|
||||||
|
controller: pass,
|
||||||
|
obscureText: !showPass,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: tr('비밀번호 (선택)'),
|
||||||
|
helperText: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'),
|
||||||
|
isDense: true,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
tooltip: showPass ? tr('비밀번호 숨기기') : tr('비밀번호 보기'),
|
||||||
|
icon: Icon(showPass ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18),
|
||||||
|
onPressed: () => set(() => showPass = !showPass),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (_) => set(() {}),
|
||||||
|
),
|
||||||
|
if (pass.text.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
TextField(
|
||||||
|
controller: pass2,
|
||||||
|
obscureText: !showPass,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: tr('비밀번호 다시 넣기'),
|
||||||
|
isDense: true,
|
||||||
|
errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null,
|
||||||
|
),
|
||||||
|
onChanged: (_) => set(() {}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(tr('비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
|
||||||
|
// zip_aes: 7-Zip 등 다른 프로그램은 영문 밖 글자 ZIP 비밀번호를 받지 않는다 (10/17 시험) - 막지는 않고 알린다
|
||||||
|
if (pass.text.codeUnits.any((c) => c > 0x7e))
|
||||||
|
Text(tr('한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.'),
|
||||||
|
style: const TextStyle(fontSize: 12, color: JjColors.danger)),
|
||||||
|
],
|
||||||
]),
|
]),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
|
||||||
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('묶기'))),
|
FilledButton(
|
||||||
|
onPressed: pass.text.isNotEmpty && pass.text != pass2.text ? null : () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(pass.text.isEmpty ? tr('묶기') : tr('암호로 묶기')),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final zip = target();
|
final zip = target();
|
||||||
|
final password = pass.text;
|
||||||
name.dispose();
|
name.dispose();
|
||||||
|
pass.dispose();
|
||||||
|
pass2.dispose();
|
||||||
if (go != true || !context.mounted) return null;
|
if (go != true || !context.mounted) return null;
|
||||||
if (level != c.settings.zipLevel) await c.updateSettings((x) => x.zipLevel = level);
|
if (level != c.settings.zipLevel) await c.updateSettings((x) => x.zipLevel = level);
|
||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
|
// zip_aes: 비밀번호 ZIP 은 동봉 7-Zip 으로 (흘려 가며 AES-256 - 큰 동영상도 메모리에 다 올리지 않게)
|
||||||
|
final exe = password.isEmpty ? null : await bundledSevenZip();
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
if (password.isNotEmpty && exe == null) {
|
||||||
|
say(tr('비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final (n, links) = await _withProgress(context, trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]),
|
final (n, links) = await _withProgress(
|
||||||
(set, cancelled) => createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)));
|
context,
|
||||||
|
password.isEmpty ? trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]) : trf('암호 ZIP 으로 묶는 중 · {0}', [p.basename(zip)]),
|
||||||
|
(set, cancelled) => exe == null
|
||||||
|
? createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t))
|
||||||
|
: createZipAes(exe, sources, zip, password: password, level: level, cancelled: cancelled, onProgress: set));
|
||||||
say([trf('{0}개를 묶었습니다: {1}', [n, p.basename(zip)]), if (links > 0) trf('링크 {0}개는 넣지 않음', [links])].join(' · '));
|
say([trf('{0}개를 묶었습니다: {1}', [n, p.basename(zip)]), if (links > 0) trf('링크 {0}개는 넣지 않음', [links])].join(' · '));
|
||||||
return zip;
|
return zip;
|
||||||
} on ArchiveCancelled {
|
} on ArchiveCancelled {
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,61 @@ void main() {
|
||||||
expect(File(p.join(out2, 'src', 'a.txt')).readAsStringSync(), 'A');
|
expect(File(p.join(out2, 'src', 'a.txt')).readAsStringSync(), 'A');
|
||||||
}, skip: has ? false : '7-Zip 이 없음 (tool/fetch_7zip.ps1)');
|
}, skip: has ? false : '7-Zip 이 없음 (tool/fetch_7zip.ps1)');
|
||||||
|
|
||||||
|
for (final (label, pw) in [('영문 비밀번호 → 7-Zip', 'pw 123!'), ('한글 비밀번호 → archive (7-Zip 은 표준 입력의 한글을 못 받음)', '비번 123')]) {
|
||||||
|
test('zip_aes: 비밀번호 ZIP (AES-256) $label - 앱의 ZIP 풀기 (archive) · 7-Zip 둘 다 같은 비밀번호로 풀림', () async {
|
||||||
|
final src = Directory(p.join(tmp.path, '원본'))..createSync();
|
||||||
|
File(p.join(src.path, '가.txt')).writeAsStringSync('하나');
|
||||||
|
File(p.join(src.path, '속', '나.bin'))
|
||||||
|
..createSync(recursive: true)
|
||||||
|
..writeAsBytesSync(List.generate(300000, (i) => i % 251));
|
||||||
|
final other = File(p.join(tmp.path, '따로.txt'))..writeAsStringSync('둘');
|
||||||
|
var linked = 0;
|
||||||
|
try {
|
||||||
|
Link(p.join(src.path, '밖')).createSync(tmp.path);
|
||||||
|
linked = 1;
|
||||||
|
} catch (_) {}
|
||||||
|
final zip = p.join(tmp.path, 'out.zip');
|
||||||
|
final (n, links) = await createZipAes(exe, [src.path, other.path], zip, password: pw);
|
||||||
|
expect(n, 3);
|
||||||
|
expect(links, linked, reason: '링크 · junction 은 넣지 않음');
|
||||||
|
expect(File('$zip.jjpart').existsSync(), isFalse);
|
||||||
|
// 7-Zip 이 보기에 AES-256 으로 암호화됨 (목록은 비밀번호 없이 보임 - ZIP 형식의 한계, 창에 안내)
|
||||||
|
final raw = await Process.run(exe, ['l', '-slt', zip]);
|
||||||
|
expect('${raw.stdout}', contains('AES-256'));
|
||||||
|
expect('${raw.stdout}', isNot(contains('Encrypted = -')));
|
||||||
|
// 앱의 ZIP 풀기 (archive 패키지) 로: 비밀번호 없으면 묻고, 틀리면 틀렸다고, 맞으면 풀림
|
||||||
|
final entries = await listZip(zip);
|
||||||
|
expect(entries.where((e) => !e.isDir).map((e) => e.name.replaceAll('\\', '/')).toSet(), {'원본/가.txt', '원본/속/나.bin', '따로.txt'},
|
||||||
|
reason: '고른 것 기준 상대 이름');
|
||||||
|
final plan = planExtract(entries);
|
||||||
|
expect(plan.encrypted, isTrue);
|
||||||
|
final out = Directory(p.join(tmp.path, '풀림'))..createSync();
|
||||||
|
await expectLater(extractZipPlan(zip, out.path, plan), throwsA(isA<ArchivePasswordException>()));
|
||||||
|
await expectLater(extractZipPlan(zip, out.path, plan, password: '틀림'), throwsA(isA<ArchivePasswordException>()));
|
||||||
|
await extractZipPlan(zip, out.path, plan, password: pw);
|
||||||
|
expect(File(p.join(out.path, '원본', '가.txt')).readAsStringSync(), '하나');
|
||||||
|
expect(File(p.join(out.path, '원본', '속', '나.bin')).readAsBytesSync(), List.generate(300000, (i) => i % 251));
|
||||||
|
expect(File(p.join(out.path, '따로.txt')).readAsStringSync(), '둘');
|
||||||
|
// 7-Zip 으로도 (시험에서만 명령 줄 -p - 앱은 비밀번호를 명령 줄에 넣지 않는다).
|
||||||
|
// 한글 비밀번호는 7-Zip 의 ZIP 이 받지 않는다 (만들기도 풀기도 "매개 변수가 틀립니다", 10/17 확인) - 이 앱에서만 풀림 (창에 안내)
|
||||||
|
if (pw.codeUnits.any((c) => c > 0x7e)) return;
|
||||||
|
final out2 = p.join(tmp.path, '풀림2');
|
||||||
|
final x = await Process.run(exe, ['x', '-scsUTF-8', '-p$pw', '-o$out2', '-y', zip]);
|
||||||
|
expect(x.exitCode, 0, reason: '${x.stdout}${x.stderr}');
|
||||||
|
expect(File(p.join(out2, '원본', '가.txt')).readAsStringSync(), '하나');
|
||||||
|
}, skip: has ? false : '7-Zip 이 없음 (tool/fetch_7zip.ps1)');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('zip_aes: 취소 · 실패면 만들다 만 ZIP 을 남기지 않고, 빈 비밀번호는 받지 않음', () async {
|
||||||
|
final f = File(p.join(tmp.path, 'a.txt'))..writeAsStringSync('A');
|
||||||
|
final zip = p.join(tmp.path, 'c.zip');
|
||||||
|
await expectLater(createZipAes(exe, [f.path], zip, password: ''), throwsArgumentError);
|
||||||
|
await expectLater(createZipAes(exe, [f.path], zip, password: 'x', cancelled: () => true), throwsA(isA<ArchiveCancelled>()));
|
||||||
|
expect(File(zip).existsSync() || File('$zip.jjpart').existsSync(), isFalse);
|
||||||
|
await expectLater(createZipAes(exe, [p.join(tmp.path, '없음')], zip, password: 'x'), throwsA(isA<FileSystemException>()));
|
||||||
|
expect(File(zip).existsSync() || File('$zip.jjpart').existsSync(), isFalse);
|
||||||
|
}, skip: has ? false : '7-Zip 이 없음 (tool/fetch_7zip.ps1)');
|
||||||
|
|
||||||
test('7-Zip 목록 글 읽기: 링크 (유닉스 · 재분석 지점) · 폴더 · 비밀번호', () {
|
test('7-Zip 목록 글 읽기: 링크 (유닉스 · 재분석 지점) · 폴더 · 비밀번호', () {
|
||||||
final l = parse7zList('Path = a\\b.txt\nSize = 3\nAttributes = A\nEncrypted = +\n\n'
|
final l = parse7zList('Path = a\\b.txt\nSize = 3\nAttributes = A\nEncrypted = +\n\n'
|
||||||
'Path = d\nSize = 0\nAttributes = D\n\n'
|
'Path = d\nSize = 0\nAttributes = D\n\n'
|
||||||
|
|
|
||||||
|
|
@ -124,4 +124,39 @@ void main() {
|
||||||
expect(c.settings.zipLevel, 9, reason: '고른 압축 정도를 기억');
|
expect(c.settings.zipLevel, 9, reason: '고른 압축 정도를 기억');
|
||||||
expect(File(made!).lengthSync() > 0, isTrue);
|
expect(File(made!).lengthSync() > 0, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('zip_aes: 비밀번호 칸 - 두 번 넣어 같아야 [암호로 묶기], 한글이면 다른 프로그램 안내 · [취소] 는 아무 일 없음', (tester) async {
|
||||||
|
final src = Directory(p.join(tmp.path, 'docs'))..createSync();
|
||||||
|
File(p.join(src.path, 'a.txt')).writeAsStringSync('A');
|
||||||
|
final run = await host<String>(tester);
|
||||||
|
String? made;
|
||||||
|
var finished = false;
|
||||||
|
run((ctx) => createZipFlow(ctx, c, [src.path], dir: tmp.path)).then((v) {
|
||||||
|
made = v;
|
||||||
|
finished = true;
|
||||||
|
});
|
||||||
|
await wait(tester, () => find.text('묶기').evaluate().isNotEmpty);
|
||||||
|
final fields = find.byType(TextField);
|
||||||
|
await tester.enterText(fields.at(1), '비번');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다'), findsOneWidget);
|
||||||
|
await tester.enterText(fields.at(1), 'pw1');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('다른 프로그램에서는'), findsNothing);
|
||||||
|
await tester.enterText(find.byType(TextField).at(2), 'pw2');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('두 비밀번호가 다릅니다'), findsOneWidget);
|
||||||
|
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '암호로 묶기')).onPressed, isNull, reason: '다르면 못 누름');
|
||||||
|
await tester.enterText(find.byType(TextField).at(2), 'pw1');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('두 비밀번호가 다릅니다'), findsNothing);
|
||||||
|
expect(tester.widget<FilledButton>(find.widgetWithText(FilledButton, '암호로 묶기')).onPressed, isNotNull, reason: '같으면 누를 수 있음');
|
||||||
|
// 실제로 만들고 푸는 것은 archive_ops_test (7-Zip 은 위젯 시험의 가짜 시간 안에서 끝나지 않아 여기서는 창만 본다)
|
||||||
|
await tester.ensureVisible(find.text('취소'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('취소'));
|
||||||
|
await wait(tester, () => finished);
|
||||||
|
expect(made, isNull);
|
||||||
|
expect(File(p.join(tmp.path, 'docs.zip')).existsSync(), isFalse, reason: '[취소] 는 아무 일 없음');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue