Merge branch 'zip_aes' into next

# Conflicts:
#	assets/l10n/en.json
#	assets/l10n/ja.json
#	assets/l10n/zh-Hans.json
hint_fix
RyuJongJin 2026-10-18 01:02:39 +09:00
commit 7e38ad8aec
7 changed files with 336 additions and 7 deletions

View File

@ -875,6 +875,7 @@
"내장 플레이어로 재생": "Play in built-in player",
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "Page direction: right → left (manga)",
"넘기는 방향: 왼쪽 → 오른쪽": "Page direction: left → right",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "If set, encrypts with AES-256. Leave empty for a normal ZIP",
"넣지 못했습니다: {0}": "Could not add: {0}",
"네덜란드어": "Dutch",
"네트워크 · DNS": "Network · DNS",
@ -1065,6 +1066,7 @@
"되살리지 못함: {0}": "Couldn't restore: {0}",
"되살림": "restored",
"두 번 넣은 비밀번호가 다릅니다": "The two passwords don't match",
"두 비밀번호가 다릅니다": "The passwords don't match",
"두 쪽 나눔 끄기 (한 장으로 보기)": "Turn off two-page split (show as one)",
"두 쪽 나눠 보기": "Split two-page spreads",
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "Split two-page spreads: off (tap for auto)",
@ -1200,6 +1202,7 @@
"무엇이 바뀌는지 비교하는 중…": "Comparing what will change…",
"무한": "Unlimited",
"묶기": "Compress",
"묶을 파일이 없습니다": "There are no files to add",
"문서 미리보기": "Document preview",
"문서 미리보기 컴포넌트를 먼저 설치하세요": "Install the document preview component first",
"문자셋 경고": "Charset warning",
@ -1390,9 +1393,13 @@
"비밀번호": "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)",
"비밀번호 · 자막 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)",
"비밀번호 다시 넣기": "Enter password again",
"비밀번호 보기": "Show password",
"비밀번호 숨기기": "Hide password",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "Can't preview a password-protected archive - use [Extract]",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "Some files are password-protected (you'll be asked when extracting)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "This file is password-protected - use [Extract]",
@ -1403,6 +1410,8 @@
"비밀번호를 넣으세요": "Enter a password",
"비밀번호를 안전 저장소에 저장했습니다": "Password saved to secure storage",
"비밀번호를 잊었습니다": "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.",
"비어 있는 폴더": "Empty folder",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "This folder is empty.\nPress [Add bookmark] · [New folder] above, or drag bookmarks onto a folder on the left.",
@ -1645,6 +1654,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 - 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)",
"암호 ZIP 으로 묶는 중 · {0}": "Making password ZIP · {0}",
"암호로 묶기": "Make password ZIP",
"암호화 안 됨 (http)": "Not encrypted (http)",
"압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "Inside an archive is read-only (you can only extract)",
"압축 옆 새 폴더 \"{0}\"": "New folder \"{0}\" next to the archive",
@ -2634,6 +2645,7 @@
"한 창일 때 복사 · 이동": "Copy / move with one pane",
"한국어": "Korean",
"한글": "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",
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} merged folders can't be undone (what was moved isn't known).",
"항목 {0}/{1}": "Items {0}/{1}",

View File

@ -875,6 +875,7 @@
"내장 플레이어로 재생": "内蔵プレーヤーで再生",
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "めくる方向: 右 → 左 (マンガ)",
"넘기는 방향: 왼쪽 → 오른쪽": "めくる方向: 左 → 右",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "入力すると AES-256 で暗号化します。空欄なら通常の ZIP",
"넣지 못했습니다: {0}": "追加できませんでした: {0}",
"네덜란드어": "オランダ語",
"네트워크 · DNS": "ネットワーク・DNS",
@ -1065,6 +1066,7 @@
"되살리지 못함: {0}": "元に戻せませんでした: {0}",
"되살림": "復元",
"두 번 넣은 비밀번호가 다릅니다": "2 回入力したパスワードが違います",
"두 비밀번호가 다릅니다": "2 つのパスワードが一致しません",
"두 쪽 나눔 끄기 (한 장으로 보기)": "見開き分割をオフ (1 枚で表示)",
"두 쪽 나눠 보기": "見開きを分けて表示",
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "見開きを分けて表示: オフ (押すと自動)",
@ -1200,6 +1202,7 @@
"무엇이 바뀌는지 비교하는 중…": "何が変わるか比較中…",
"무한": "無制限",
"묶기": "まとめる",
"묶을 파일이 없습니다": "まとめるファイルがありません",
"문서 미리보기": "文書プレビュー",
"문서 미리보기 컴포넌트를 먼저 설치하세요": "先にドキュメントプレビューのコンポーネントをインストールしてください",
"문자셋 경고": "文字コードの警告",
@ -1390,9 +1393,13 @@
"비밀번호": "パスワード",
"비밀번호 (개인 키만 쓰면 비움)": "パスワード (秘密鍵だけを使う場合は空欄)",
"비밀번호 (선택)": "パスワード(任意)",
"비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "このバージョンにはパスワード付き ZIP を作る 7-Zip がありません。パスワードなしでまとめるか、アプリを再インストールしてください。",
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "パスワード・API キー (WebDAV・OpenSubtitles・AI サービス)",
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "パスワード・字幕 API キーはバックアップされないので、もう一度入力してください。自動バックアップは 1 日に 1 回程度作成されるため、その後に変更したものは含まれていない場合があります。",
"비밀번호 기억 (안전 저장소)": "パスワードを記憶 (安全な保存領域)",
"비밀번호 다시 넣기": "パスワードをもう一度入力",
"비밀번호 보기": "パスワードを表示",
"비밀번호 숨기기": "パスワードを隠す",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "パスワード付きアーカイブの中はプレビューできません - [展開] で展開してください",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "パスワード付きのファイルがあります (展開時に確認します)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "パスワード付きのファイルです - [展開] で展開してください",
@ -1403,6 +1410,8 @@
"비밀번호를 넣으세요": "パスワードを入力してください",
"비밀번호를 안전 저장소에 저장했습니다": "パスワードを安全な保存領域に保存しました",
"비밀번호를 잊었습니다": "パスワードを忘れました",
"비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.": "パスワードを忘れるとこの ZIP は展開できません (アプリは記憶しません)。ファイル名の一覧は暗号化されません。",
"비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.": "パスワードに英字以外の文字 (ハングルなど) があると、512MB を超えるファイル ({0}) はまとめられません。英数字・記号のパスワードを使うか、ファイルを分けてください。",
"비어 있는 압축 파일입니다.": "空の圧縮ファイルです。",
"비어 있는 폴더": "空のフォルダー",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "空のフォルダーです。\n上の[ブックマーク追加]・[新しいフォルダー]を押すか、ブックマークを左のフォルダーにドラッグしてください。",
@ -1645,6 +1654,8 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせると古いもので上書きします",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知が JJ_MKVMaker の名前で表示され、クリックするとアプリが開きます (管理者権限なしで現在のユーザーのみ)",
"암호 ZIP 으로 묶는 중 · {0}": "パスワード付き ZIP にまとめています · {0}",
"암호로 묶기": "パスワード付きでまとめる",
"암호화 안 됨 (http)": "暗号化なし (http)",
"압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "アーカイブ内は読み取り専用です (取り出しのみできます)",
"압축 옆 새 폴더 \"{0}\"": "アーカイブの隣の新しいフォルダー「{0}」",
@ -2634,6 +2645,7 @@
"한 창일 때 복사 · 이동": "1 ペイン時のコピー・移動",
"한국어": "韓国語",
"한글": "ハングル",
"한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.": "英字以外の文字 (ハングルなど) を含むパスワードは、このアプリでは展開できますが、7-Zip など他のプログラムでは開けないことがあります。他の端末やプログラムと共有する ZIP には英数字・記号のパスワードを使ってください。",
"할 일": "内容",
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "結合したフォルダー {0} 件は、何を移動したか分からないため元に戻しません。",
"항목 {0}/{1}": "項目 {0}/{1}",

View File

@ -875,6 +875,7 @@
"내장 플레이어로 재생": "用内置播放器播放",
"넘기는 방향: 오른쪽 → 왼쪽 (만화)": "翻页方向: 右 → 左 (漫画)",
"넘기는 방향: 왼쪽 → 오른쪽": "翻页方向: 左 → 右",
"넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP": "填写后使用 AES-256 加密。留空则为普通 ZIP",
"넣지 못했습니다: {0}": "无法加入: {0}",
"네덜란드어": "荷兰语",
"네트워크 · DNS": "网络 · DNS",
@ -1065,6 +1066,7 @@
"되살리지 못함: {0}": "无法还原: {0}",
"되살림": "已还原",
"두 번 넣은 비밀번호가 다릅니다": "两次输入的密码不一致",
"두 비밀번호가 다릅니다": "两次输入的密码不一致",
"두 쪽 나눔 끄기 (한 장으로 보기)": "关闭双页拆分(整张显示)",
"두 쪽 나눠 보기": "拆分双页",
"두 쪽 나눠 보기: 끄기 (누르면 자동)": "拆分双页: 关闭 (点按切换为自动)",
@ -1200,6 +1202,7 @@
"무엇이 바뀌는지 비교하는 중…": "正在比较将要更改的内容…",
"무한": "无限制",
"묶기": "压缩",
"묶을 파일이 없습니다": "没有可压缩的文件",
"문서 미리보기": "文档预览",
"문서 미리보기 컴포넌트를 먼저 설치하세요": "请先安装文档预览组件",
"문자셋 경고": "字符集警告",
@ -1390,9 +1393,13 @@
"비밀번호": "密码",
"비밀번호 (개인 키만 쓰면 비움)": "密码 (仅使用私钥时留空)",
"비밀번호 (선택)": "密码(可选)",
"비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.": "此版本没有用于创建加密 ZIP 的 7-Zip。请不设密码压缩,或重新安装应用。",
"비밀번호 · API 키 (WebDAV · OpenSubtitles · AI 서비스)": "密码 · API 密钥 (WebDAV · OpenSubtitles · AI 服务)",
"비밀번호 · 자막 API 키는 백업하지 않으므로 다시 넣어 주세요. 자동 백업은 하루에 한 번쯤 만들어져 그 뒤에 바꾼 것은 빠졌을 수 있습니다.": "密码 · 字幕 API 密钥不会备份,请重新输入。自动备份大约每天创建一次,之后更改的内容可能缺失。",
"비밀번호 기억 (안전 저장소)": "记住密码 (安全存储)",
"비밀번호 다시 넣기": "再次输入密码",
"비밀번호 보기": "显示密码",
"비밀번호 숨기기": "隐藏密码",
"비밀번호가 걸린 압축은 안을 미리 볼 수 없습니다 - [압축 풀기] 로 푸세요": "无法预览有密码的压缩包 - 请用 [解压]",
"비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)": "部分文件设有密码 (解压时会询问)",
"비밀번호가 걸린 파일입니다 - [압축 풀기] 로 푸세요": "此文件有密码 - 请用 [解压]",
@ -1403,6 +1410,8 @@
"비밀번호를 넣으세요": "请输入密码",
"비밀번호를 안전 저장소에 저장했습니다": "已将密码保存到安全存储",
"비밀번호를 잊었습니다": "忘记密码",
"비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.": "忘记密码将无法解压此 ZIP (应用不会记住密码)。文件名列表不会加密。",
"비밀번호에 한글 등 영문 밖 글자가 있으면 512MB 넘는 파일 ({0}) 은 묶을 수 없습니다. 영문 · 숫자 · 기호 비밀번호를 쓰거나 파일을 나눠 주세요.": "密码含有英文以外的字符 (如韩文) 时,无法压缩超过 512MB 的文件 ({0})。请使用英文字母、数字和符号组成的密码,或拆分文件。",
"비어 있는 압축 파일입니다.": "压缩文件为空。",
"비어 있는 폴더": "空文件夹",
"비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.": "这是空文件夹。\n请点击上方的[添加书签] · [新建文件夹],或将书签拖到左侧文件夹。",
@ -1645,6 +1654,8 @@
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합치면 예전 것으로 덮어씁니다": "里面有 {0} 个文件在接收端较新 - 合并会用旧文件覆盖",
"알림이 JJ_MKVMaker 이름으로 보이고, 누르면 앱이 열립니다 (관리자 권한 없이 지금 사용자에게만)": "通知以 JJ_MKVMaker 名义显示,点击即可打开应用 (仅当前用户,无需管理员权限)",
"암호 ZIP 으로 묶는 중 · {0}": "正在创建加密 ZIP · {0}",
"암호로 묶기": "加密压缩",
"암호화 안 됨 (http)": "未加密 (http)",
"압축 안은 읽기 전용입니다 (꺼내기만 할 수 있습니다)": "压缩包内为只读 (只能解压取出)",
"압축 옆 새 폴더 \"{0}\"": "压缩包旁的新文件夹“{0}”",
@ -2634,6 +2645,7 @@
"한 창일 때 복사 · 이동": "单窗格时的复制 · 移动",
"한국어": "韩语",
"한글": "韩文",
"한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.": "含有英文以外字符 (如韩文) 的密码可在本应用中解压,但 7-Zip 等其他程序可能无法打开。要与其他设备或程序共享的 ZIP,请使用英文字母、数字和符号组成的密码。",
"할 일": "任务",
"합친 폴더 {0}개는 무엇을 옮겼는지 몰라 되돌리지 않습니다.": "{0} 个合并的文件夹无法撤销 (不清楚移动了哪些内容)。",
"항목 {0}/{1}": "项目 {0}/{1}",

View File

@ -186,12 +186,39 @@ Future<List<ArchiveEntry>> listZip(String path) async {
}
/// 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,
{NameConflict conflict = NameConflict.rename,
bool skipNewer = false,
String? password,
void Function(int done, int total)? onProgress,
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();
final input = InputFileStream(path);
@ -249,7 +276,7 @@ Future<ExtractResult> extractZipPlan(String path, String dest, ExtractPlan plan,
/// [level] 0 (묶기만) ~ 9 (가장 작게). 다 만든 뒤에만 이름을 바꾼다 (취소 · 실패면 만들다 만 파일을 지움).
/// 돌려주는 값: (넣은 파일 수, 건너뛴 링크 수)
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)>[];
var links = 0;
@ -271,7 +298,8 @@ Future<(int, int)> createZip(List<String> sources, String zipPath,
}
}
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;
try {
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 제한 - 풀기만) ─────────
/// 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…) · 재분석 지점
List<ArchiveEntry> parse7zList(String out) {
final entries = <ArchiveEntry>[];
@ -326,10 +449,15 @@ List<ArchiveEntry> parse7zList(String out) {
/// 7-Zip 실행. 비밀번호는 명령 줄이 아니라 표준 입력으로만 (작업 기록 · 프로세스 목록에 남지 않게 - 관리자 판단)
Future<(int, String, String)> _run7z(String exe, List<String> args,
{String? password, void Function(String line)? onLine, bool Function()? cancelled}) async {
final pr = await Process.start(exe, ['-sccUTF-8', '-scsUTF-8', ...args]);
{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], workingDirectory: workingDirectory);
// -sccUTF-8 이면 7-Zip 은 표준 입력도 UTF-8 로 읽는다 - Dart 의 기본 (시스템 코드 페이지, 한국어 Windows 는 CP949) 으로 보내면
// 한글 비밀번호가 틀렸다고 나왔다 (관리자 지시로 10/18 시험: UTF-8 + -sccUTF-8 이면 7z · RAR 이 한글 비밀번호로 풀림)
pr.stdin.encoding = utf8;
// 묻지 않을 때도 빈 줄을 보내 기다리지 않게 (암호가 걸렸는데 비밀번호가 없으면 틀림으로 끝난다)
pr.stdin.writeln(password ?? '');
// 만들 때는 "다시 넣기" 도 묻는다 (zip_aes)
if (confirm) pr.stdin.writeln(password ?? '');
await pr.stdin.close();
final out = StringBuffer(), err = StringBuffer();
// 끝을 기다리는 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));
var level = c.settings.zipLevel;
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('다른 창 폴더'))];
String target() {
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)),
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)),
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: [
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 password = pass.text;
name.dispose();
pass.dispose();
pass2.dispose();
if (go != true || !context.mounted) return null;
if (level != c.settings.zipLevel) await c.updateSettings((x) => x.zipLevel = level);
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 {
final (n, links) = await _withProgress(context, trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]),
(set, cancelled) => createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)));
final (n, links) = await _withProgress(
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(' · '));
return zip;
} on ArchiveCancelled {

View File

@ -193,6 +193,75 @@ void main() {
expect(File(p.join(out2, 'src', 'a.txt')).readAsStringSync(), 'A');
}, 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: 한글 비밀번호 7z 풀기 - 표준 입력을 UTF-8 로 (머리까지 암호 · 목록 · 풀기 · 틀린 비밀번호)', () async {
File(p.join(tmp.path, 'a.txt')).writeAsStringSync('안');
final arc = p.join(tmp.path, 'k.7z');
// 시험 압축만 명령 줄 -p 로 만든다 (앱은 비밀번호를 명령 줄에 넣지 않는다)
final mk = await Process.run(exe, ['a', '-t7z', '-mhe=on', '-p비번 123', arc, p.join(tmp.path, 'a.txt')]);
expect(mk.exitCode, 0, reason: '${mk.stdout}');
await expectLater(list7z(exe, arc, password: '틀림'), throwsA(isA<ArchivePasswordException>()));
final list = await list7z(exe, arc, password: '비번 123');
expect(list.map((e) => e.name), ['a.txt']);
final out = Directory(p.join(tmp.path, '풀림'))..createSync();
await extract7zPlan(exe, arc, out.path, planExtract(list), password: '비번 123');
expect(File(p.join(out.path, 'a.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 목록 글 읽기: 링크 (유닉스 · 재분석 지점) · 폴더 · 비밀번호', () {
final l = parse7zList('Path = a\\b.txt\nSize = 3\nAttributes = A\nEncrypted = +\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(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: '[취소] 는 아무 일 없음');
});
}