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
RyuJongJin 2026-10-18 00:09:10 +09:00
parent 13c8e5f5bf
commit 16f33b3b71
7 changed files with 319 additions and 7 deletions

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

@ -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 를 부르면 이미 끝난 흐름은 영영 끝나지 않았다)

View File

@ -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 {

View File

@ -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'

View File

@ -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: '[취소] 는 아무 일 없음');
});
} }