-
CleanZip is ad-hoc signed and open source, but it is not notarized with an Apple Developer ID. macOS may block CleanZip-2.6.30.pkg before the installer opens and show a warning such as “Apple could not verify CleanZip is free of malware.”
+
CleanZip is ad-hoc signed and open source, but it is not notarized with an Apple Developer ID. macOS may block CleanZip-2.6.35.pkg before the installer opens and show a warning such as “Apple could not verify CleanZip is free of malware.”
- - In Finder, Control-click CleanZip-2.6.30.pkg, then choose Open.
+ - In Finder, Control-click CleanZip-2.6.35.pkg, then choose Open.
- If the same warning still appears with only Done and Move to Trash, open System Settings → Privacy & Security.
- - At the bottom of Privacy & Security, choose Open Anyway for CleanZip-2.6.30.pkg, then confirm.
+ - At the bottom of Privacy & Security, choose Open Anyway for CleanZip-2.6.35.pkg, then confirm.
- After installation, if macOS blocks CleanZip.app, repeat the same Open Anyway step for the app.
After you approve the installer or app once, macOS opens it normally.
diff --git a/work/CleanZipBuild/CleanZip.xcodeproj/project.pbxproj b/work/CleanZipBuild/CleanZip.xcodeproj/project.pbxproj
index 2202706..28503fb 100644
--- a/work/CleanZipBuild/CleanZip.xcodeproj/project.pbxproj
+++ b/work/CleanZipBuild/CleanZip.xcodeproj/project.pbxproj
@@ -288,6 +288,7 @@
COMBINE_HIDPI_IMAGES = YES;
COPY_PHASE_STRIP = YES;
DEVELOPMENT_TEAM = "";
+ ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "src/CleanZipService-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
@@ -302,7 +303,8 @@
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
- SWIFT_VERSION = 5.0;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = service;
};
name = Release;
@@ -322,6 +324,7 @@
COMBINE_HIDPI_IMAGES = YES;
COPY_PHASE_STRIP = NO;
DEVELOPMENT_TEAM = "";
+ ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "src/CleanZipService-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
@@ -335,7 +338,8 @@
PRODUCT_NAME = CleanZipService;
SDKROOT = macosx;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- SWIFT_VERSION = 5.0;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = service;
};
name = Debug;
@@ -355,6 +359,7 @@
COMBINE_HIDPI_IMAGES = YES;
COPY_PHASE_STRIP = NO;
DEVELOPMENT_TEAM = "";
+ ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "src/CleanZip-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
@@ -368,7 +373,8 @@
PRODUCT_NAME = CleanZip;
SDKROOT = macosx;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- SWIFT_VERSION = 5.0;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = app;
};
name = Debug;
@@ -432,7 +438,8 @@
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
- SWIFT_VERSION = 5.0;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
};
name = Debug;
};
@@ -488,7 +495,8 @@
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
- SWIFT_VERSION = 5.0;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
};
name = Release;
};
@@ -507,6 +515,7 @@
COMBINE_HIDPI_IMAGES = YES;
COPY_PHASE_STRIP = YES;
DEVELOPMENT_TEAM = "";
+ ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "src/CleanZip-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
@@ -521,7 +530,8 @@
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
- SWIFT_VERSION = 5.0;
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 6.0;
WRAPPER_EXTENSION = app;
};
name = Release;
diff --git a/work/CleanZipBuild/src/CleanZip-Info.plist b/work/CleanZipBuild/src/CleanZip-Info.plist
index 8e03786..10783b8 100644
--- a/work/CleanZipBuild/src/CleanZip-Info.plist
+++ b/work/CleanZipBuild/src/CleanZip-Info.plist
@@ -132,7 +132,7 @@
CFBundleShortVersionString
2.6
CFBundleVersion
-
34
+
35
LSApplicationCategoryType
public.app-category.utilities
LSMinimumSystemVersion
diff --git a/work/CleanZipBuild/src/CleanZipService-Info.plist b/work/CleanZipBuild/src/CleanZipService-Info.plist
index be41eea..36e6f21 100644
--- a/work/CleanZipBuild/src/CleanZipService-Info.plist
+++ b/work/CleanZipBuild/src/CleanZipService-Info.plist
@@ -37,7 +37,7 @@
CFBundleShortVersionString
2.6
CFBundleVersion
-
34
+
35
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/work/CleanZipBuild/src/Resources/de.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/de.lproj/InfoPlist.strings
index c646deb..8ea821d 100644
--- a/work/CleanZipBuild/src/Resources/de.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/de.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "Archiv";
+"Archive" = "Archiv";
+"7-Zip Archive" = "7-Zip-Archiv";
+"RAR Archive" = "RAR-Archiv";
diff --git a/work/CleanZipBuild/src/Resources/de.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/de.lproj/Localizable.strings
index 27549d7..44258fb 100644
--- a/work/CleanZipBuild/src/Resources/de.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/de.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "Testen";
"toolbar.test.tooltip" = "Archivintegrität testen";
"window.progressTitle" = "CleanZip-Fortschritt";
+"menu.about" = "Über %@";
+"menu.bringAllToFront" = "Alle nach vorne";
+"menu.close" = "Schließen";
+"menu.copy" = "Kopieren";
+"menu.cut" = "Ausschneiden";
+"menu.edit" = "Bearbeiten";
+"menu.enterFullScreen" = "Vollbildmodus aktivieren";
+"menu.file" = "Ablage";
+"menu.help" = "Hilfe";
+"menu.helpItem" = "%@-Hilfe";
+"menu.hide" = "%@ ausblenden";
+"menu.hideOthers" = "Andere ausblenden";
+"menu.minimize" = "Im Dock ablegen";
+"menu.paste" = "Einsetzen";
+"menu.quit" = "%@ beenden";
+"menu.redo" = "Wiederholen";
+"menu.selectAll" = "Alles auswählen";
+"menu.services" = "Dienste";
+"menu.showAll" = "Alle einblenden";
+"menu.undo" = "Widerrufen";
+"menu.view" = "Darstellung";
+"menu.window" = "Fenster";
+"menu.zoom" = "Zoomen";
+"settings.invalidSplitSize" = "Gib eine ganze Zahl zwischen 1 und 1.048.576 ein.";
+"error.passwordRequired" = "Dieses Archiv ist passwortgeschützt.";
+"error.wrongPassword" = "Das Passwort ist falsch.";
+"error.cancelled" = "Der Vorgang wurde abgebrochen.";
+"error.unknownArchiveFailure" = "Der Archivvorgang ist ohne weitere Angaben fehlgeschlagen.";
+"error.mainAppUnavailable" = "CleanZip konnte die Haupt-App für die Passworteingabe nicht öffnen.";
+"status.cancelling" = "Wird abgebrochen…";
+"status.cancelled" = "Vorgang abgebrochen.";
+"status.passwordCancelled" = "Passworteingabe abgebrochen.";
+"button.cancelOperation" = "Vorgang abbrechen";
+"password.title" = "Archivpasswort";
+"password.message" = "Gib das Passwort für „%@“ ein.";
+"password.incorrect" = "Das Passwort für „%@“ ist falsch. Versuche es erneut.";
+"password.placeholder" = "Passwort";
+"password.unlock" = "Entsperren";
diff --git a/work/CleanZipBuild/src/Resources/en.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/en.lproj/InfoPlist.strings
index 227c7f9..cdd23c4 100644
--- a/work/CleanZipBuild/src/Resources/en.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/en.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "Archive";
+"Archive" = "Archive";
+"7-Zip Archive" = "7-Zip Archive";
+"RAR Archive" = "RAR Archive";
diff --git a/work/CleanZipBuild/src/Resources/en.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/en.lproj/Localizable.strings
index 0caa04a..15a9984 100644
--- a/work/CleanZipBuild/src/Resources/en.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/en.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "Test";
"toolbar.test.tooltip" = "Test archive integrity";
"window.progressTitle" = "CleanZip Progress";
+"menu.about" = "About %@";
+"menu.bringAllToFront" = "Bring All to Front";
+"menu.close" = "Close";
+"menu.copy" = "Copy";
+"menu.cut" = "Cut";
+"menu.edit" = "Edit";
+"menu.enterFullScreen" = "Enter Full Screen";
+"menu.file" = "File";
+"menu.help" = "Help";
+"menu.helpItem" = "%@ Help";
+"menu.hide" = "Hide %@";
+"menu.hideOthers" = "Hide Others";
+"menu.minimize" = "Minimize";
+"menu.paste" = "Paste";
+"menu.quit" = "Quit %@";
+"menu.redo" = "Redo";
+"menu.selectAll" = "Select All";
+"menu.services" = "Services";
+"menu.showAll" = "Show All";
+"menu.undo" = "Undo";
+"menu.view" = "View";
+"menu.window" = "Window";
+"menu.zoom" = "Zoom";
+"settings.invalidSplitSize" = "Enter a whole number from 1 to 1,048,576.";
+"error.passwordRequired" = "This archive is password protected.";
+"error.wrongPassword" = "The password is incorrect.";
+"error.cancelled" = "The operation was cancelled.";
+"error.unknownArchiveFailure" = "The archive operation failed without additional details.";
+"error.mainAppUnavailable" = "CleanZip could not open the main app to request the archive password.";
+"status.cancelling" = "Cancelling…";
+"status.cancelled" = "Operation cancelled.";
+"status.passwordCancelled" = "Password entry cancelled.";
+"button.cancelOperation" = "Cancel Operation";
+"password.title" = "Archive Password";
+"password.message" = "Enter the password for “%@”.";
+"password.incorrect" = "The password for “%@” is incorrect. Try again.";
+"password.placeholder" = "Password";
+"password.unlock" = "Unlock";
diff --git a/work/CleanZipBuild/src/Resources/es.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/es.lproj/InfoPlist.strings
index 3ea6629..8951d8e 100644
--- a/work/CleanZipBuild/src/Resources/es.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/es.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "Archivo comprimido";
+"Archive" = "Archivo comprimido";
+"7-Zip Archive" = "Archivo 7-Zip";
+"RAR Archive" = "Archivo RAR";
diff --git a/work/CleanZipBuild/src/Resources/es.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/es.lproj/Localizable.strings
index 94e1c88..54d3740 100644
--- a/work/CleanZipBuild/src/Resources/es.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/es.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "Probar";
"toolbar.test.tooltip" = "Probar la integridad del archivo";
"window.progressTitle" = "Progreso de CleanZip";
+"menu.about" = "Acerca de %@";
+"menu.bringAllToFront" = "Traer todo al frente";
+"menu.close" = "Cerrar";
+"menu.copy" = "Copiar";
+"menu.cut" = "Cortar";
+"menu.edit" = "Edición";
+"menu.enterFullScreen" = "Usar pantalla completa";
+"menu.file" = "Archivo";
+"menu.help" = "Ayuda";
+"menu.helpItem" = "Ayuda de %@";
+"menu.hide" = "Ocultar %@";
+"menu.hideOthers" = "Ocultar otros";
+"menu.minimize" = "Minimizar";
+"menu.paste" = "Pegar";
+"menu.quit" = "Salir de %@";
+"menu.redo" = "Rehacer";
+"menu.selectAll" = "Seleccionar todo";
+"menu.services" = "Servicios";
+"menu.showAll" = "Mostrar todo";
+"menu.undo" = "Deshacer";
+"menu.view" = "Visualización";
+"menu.window" = "Ventana";
+"menu.zoom" = "Zoom";
+"settings.invalidSplitSize" = "Introduce un número entero entre 1 y 1.048.576.";
+"error.passwordRequired" = "Este archivo está protegido con contraseña.";
+"error.wrongPassword" = "La contraseña es incorrecta.";
+"error.cancelled" = "La operación se ha cancelado.";
+"error.unknownArchiveFailure" = "La operación de archivo ha fallado sin más detalles.";
+"error.mainAppUnavailable" = "CleanZip no pudo abrir la app principal para solicitar la contraseña.";
+"status.cancelling" = "Cancelando…";
+"status.cancelled" = "Operación cancelada.";
+"status.passwordCancelled" = "Entrada de contraseña cancelada.";
+"button.cancelOperation" = "Cancelar operación";
+"password.title" = "Contraseña del archivo";
+"password.message" = "Introduce la contraseña de «%@».";
+"password.incorrect" = "La contraseña de «%@» es incorrecta. Inténtalo de nuevo.";
+"password.placeholder" = "Contraseña";
+"password.unlock" = "Desbloquear";
diff --git a/work/CleanZipBuild/src/Resources/fr.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/fr.lproj/InfoPlist.strings
index 227c7f9..7813987 100644
--- a/work/CleanZipBuild/src/Resources/fr.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/fr.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "Archive";
+"Archive" = "Archive";
+"7-Zip Archive" = "Archive 7-Zip";
+"RAR Archive" = "Archive RAR";
diff --git a/work/CleanZipBuild/src/Resources/fr.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/fr.lproj/Localizable.strings
index b0bc93d..99e4ebd 100644
--- a/work/CleanZipBuild/src/Resources/fr.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/fr.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "Tester";
"toolbar.test.tooltip" = "Tester l’intégrité de l’archive";
"window.progressTitle" = "Progression CleanZip";
+"menu.about" = "À propos de %@";
+"menu.bringAllToFront" = "Tout ramener au premier plan";
+"menu.close" = "Fermer";
+"menu.copy" = "Copier";
+"menu.cut" = "Couper";
+"menu.edit" = "Édition";
+"menu.enterFullScreen" = "Activer le mode plein écran";
+"menu.file" = "Fichier";
+"menu.help" = "Aide";
+"menu.helpItem" = "Aide %@";
+"menu.hide" = "Masquer %@";
+"menu.hideOthers" = "Masquer les autres";
+"menu.minimize" = "Réduire";
+"menu.paste" = "Coller";
+"menu.quit" = "Quitter %@";
+"menu.redo" = "Rétablir";
+"menu.selectAll" = "Tout sélectionner";
+"menu.services" = "Services";
+"menu.showAll" = "Tout afficher";
+"menu.undo" = "Annuler";
+"menu.view" = "Présentation";
+"menu.window" = "Fenêtre";
+"menu.zoom" = "Zoom";
+"settings.invalidSplitSize" = "Saisissez un nombre entier compris entre 1 et 1 048 576.";
+"error.passwordRequired" = "Cette archive est protégée par un mot de passe.";
+"error.wrongPassword" = "Le mot de passe est incorrect.";
+"error.cancelled" = "L’opération a été annulée.";
+"error.unknownArchiveFailure" = "L’opération d’archive a échoué sans autre précision.";
+"error.mainAppUnavailable" = "CleanZip n’a pas pu ouvrir l’app principale pour demander le mot de passe.";
+"status.cancelling" = "Annulation…";
+"status.cancelled" = "Opération annulée.";
+"status.passwordCancelled" = "Saisie du mot de passe annulée.";
+"button.cancelOperation" = "Annuler l’opération";
+"password.title" = "Mot de passe de l’archive";
+"password.message" = "Saisissez le mot de passe de « %@ ».";
+"password.incorrect" = "Le mot de passe de « %@ » est incorrect. Réessayez.";
+"password.placeholder" = "Mot de passe";
+"password.unlock" = "Déverrouiller";
diff --git a/work/CleanZipBuild/src/Resources/it.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/it.lproj/InfoPlist.strings
index 984a48b..48e2ac7 100644
--- a/work/CleanZipBuild/src/Resources/it.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/it.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "Archivio";
+"Archive" = "Archivio";
+"7-Zip Archive" = "Archivio 7-Zip";
+"RAR Archive" = "Archivio RAR";
diff --git a/work/CleanZipBuild/src/Resources/it.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/it.lproj/Localizable.strings
index 0bd4e26..acb3bce 100644
--- a/work/CleanZipBuild/src/Resources/it.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/it.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "Test";
"toolbar.test.tooltip" = "Testa l’integrità dell’archivio";
"window.progressTitle" = "Avanzamento CleanZip";
+"menu.about" = "Informazioni su %@";
+"menu.bringAllToFront" = "Porta tutto in primo piano";
+"menu.close" = "Chiudi";
+"menu.copy" = "Copia";
+"menu.cut" = "Taglia";
+"menu.edit" = "Modifica";
+"menu.enterFullScreen" = "Attiva modalità a schermo intero";
+"menu.file" = "File";
+"menu.help" = "Aiuto";
+"menu.helpItem" = "Aiuto %@";
+"menu.hide" = "Nascondi %@";
+"menu.hideOthers" = "Nascondi altre";
+"menu.minimize" = "Riduci a icona";
+"menu.paste" = "Incolla";
+"menu.quit" = "Esci da %@";
+"menu.redo" = "Ripristina";
+"menu.selectAll" = "Seleziona tutto";
+"menu.services" = "Servizi";
+"menu.showAll" = "Mostra tutte";
+"menu.undo" = "Annulla";
+"menu.view" = "Vista";
+"menu.window" = "Finestra";
+"menu.zoom" = "Zoom";
+"settings.invalidSplitSize" = "Inserisci un numero intero compreso tra 1 e 1.048.576.";
+"error.passwordRequired" = "Questo archivio è protetto da password.";
+"error.wrongPassword" = "La password non è corretta.";
+"error.cancelled" = "L’operazione è stata annullata.";
+"error.unknownArchiveFailure" = "L’operazione sull’archivio non è riuscita senza ulteriori dettagli.";
+"error.mainAppUnavailable" = "CleanZip non ha potuto aprire l’app principale per richiedere la password.";
+"status.cancelling" = "Annullamento…";
+"status.cancelled" = "Operazione annullata.";
+"status.passwordCancelled" = "Inserimento della password annullato.";
+"button.cancelOperation" = "Annulla operazione";
+"password.title" = "Password dell’archivio";
+"password.message" = "Inserisci la password per “%@”.";
+"password.incorrect" = "La password per “%@” non è corretta. Riprova.";
+"password.placeholder" = "Password";
+"password.unlock" = "Sblocca";
diff --git a/work/CleanZipBuild/src/Resources/ja.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/ja.lproj/InfoPlist.strings
index ab062f2..b6a763a 100644
--- a/work/CleanZipBuild/src/Resources/ja.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/ja.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "アーカイブ";
+"Archive" = "アーカイブ";
+"7-Zip Archive" = "7-Zipアーカイブ";
+"RAR Archive" = "RARアーカイブ";
diff --git a/work/CleanZipBuild/src/Resources/ja.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/ja.lproj/Localizable.strings
index 7f31d40..ba35041 100644
--- a/work/CleanZipBuild/src/Resources/ja.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/ja.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "テスト";
"toolbar.test.tooltip" = "アーカイブの整合性をテスト";
"window.progressTitle" = "CleanZip の進行状況";
+"menu.about" = "%@について";
+"menu.bringAllToFront" = "すべてを手前に移動";
+"menu.close" = "閉じる";
+"menu.copy" = "コピー";
+"menu.cut" = "カット";
+"menu.edit" = "編集";
+"menu.enterFullScreen" = "フルスクリーンにする";
+"menu.file" = "ファイル";
+"menu.help" = "ヘルプ";
+"menu.helpItem" = "%@ヘルプ";
+"menu.hide" = "%@を隠す";
+"menu.hideOthers" = "ほかを隠す";
+"menu.minimize" = "しまう";
+"menu.paste" = "ペースト";
+"menu.quit" = "%@を終了";
+"menu.redo" = "やり直す";
+"menu.selectAll" = "すべてを選択";
+"menu.services" = "サービス";
+"menu.showAll" = "すべてを表示";
+"menu.undo" = "取り消す";
+"menu.view" = "表示";
+"menu.window" = "ウインドウ";
+"menu.zoom" = "拡大/縮小";
+"settings.invalidSplitSize" = "1から1,048,576までの整数を入力してください。";
+"error.passwordRequired" = "このアーカイブはパスワードで保護されています。";
+"error.wrongPassword" = "パスワードが正しくありません。";
+"error.cancelled" = "操作はキャンセルされました。";
+"error.unknownArchiveFailure" = "詳細情報なしでアーカイブ操作に失敗しました。";
+"error.mainAppUnavailable" = "パスワードを入力するための CleanZip アプリを開けませんでした。";
+"status.cancelling" = "キャンセル中…";
+"status.cancelled" = "操作をキャンセルしました。";
+"status.passwordCancelled" = "パスワード入力をキャンセルしました。";
+"button.cancelOperation" = "操作をキャンセル";
+"password.title" = "アーカイブのパスワード";
+"password.message" = "「%@」のパスワードを入力してください。";
+"password.incorrect" = "「%@」のパスワードが正しくありません。もう一度お試しください。";
+"password.placeholder" = "パスワード";
+"password.unlock" = "ロック解除";
diff --git a/work/CleanZipBuild/src/Resources/ko.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/ko.lproj/InfoPlist.strings
index 1d1f066..7c84927 100644
--- a/work/CleanZipBuild/src/Resources/ko.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/ko.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "압축 파일";
+"Archive" = "압축 파일";
+"7-Zip Archive" = "7-Zip 압축 파일";
+"RAR Archive" = "RAR 압축 파일";
diff --git a/work/CleanZipBuild/src/Resources/ko.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/ko.lproj/Localizable.strings
index 42cbac2..efcd630 100644
--- a/work/CleanZipBuild/src/Resources/ko.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/ko.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "테스트";
"toolbar.test.tooltip" = "압축 파일 무결성 테스트";
"window.progressTitle" = "CleanZip 진행률";
+"menu.about" = "%@에 관하여";
+"menu.bringAllToFront" = "모든 윈도우 앞으로";
+"menu.close" = "닫기";
+"menu.copy" = "복사";
+"menu.cut" = "오려두기";
+"menu.edit" = "편집";
+"menu.enterFullScreen" = "전체 화면 시작";
+"menu.file" = "파일";
+"menu.help" = "도움말";
+"menu.helpItem" = "%@ 도움말";
+"menu.hide" = "%@ 가리기";
+"menu.hideOthers" = "기타 가리기";
+"menu.minimize" = "최소화";
+"menu.paste" = "붙여넣기";
+"menu.quit" = "%@ 종료";
+"menu.redo" = "다시 실행";
+"menu.selectAll" = "모두 선택";
+"menu.services" = "서비스";
+"menu.showAll" = "모두 보기";
+"menu.undo" = "실행 취소";
+"menu.view" = "보기";
+"menu.window" = "윈도우";
+"menu.zoom" = "확대/축소";
+"settings.invalidSplitSize" = "1에서 1,048,576 사이의 정수를 입력하십시오.";
+"error.passwordRequired" = "이 압축 파일은 암호로 보호되어 있습니다.";
+"error.wrongPassword" = "암호가 올바르지 않습니다.";
+"error.cancelled" = "작업이 취소되었습니다.";
+"error.unknownArchiveFailure" = "추가 정보 없이 압축 파일 작업에 실패했습니다.";
+"error.mainAppUnavailable" = "암호를 요청할 CleanZip 앱을 열 수 없습니다.";
+"status.cancelling" = "취소 중…";
+"status.cancelled" = "작업이 취소되었습니다.";
+"status.passwordCancelled" = "암호 입력이 취소되었습니다.";
+"button.cancelOperation" = "작업 취소";
+"password.title" = "압축 파일 암호";
+"password.message" = "“%@”의 암호를 입력하십시오.";
+"password.incorrect" = "“%@”의 암호가 올바르지 않습니다. 다시 시도하십시오.";
+"password.placeholder" = "암호";
+"password.unlock" = "잠금 해제";
diff --git a/work/CleanZipBuild/src/Resources/pt-BR.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/pt-BR.lproj/InfoPlist.strings
index 335dc3a..8b6f8f8 100644
--- a/work/CleanZipBuild/src/Resources/pt-BR.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/pt-BR.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "Arquivo compactado";
+"Archive" = "Arquivo compactado";
+"7-Zip Archive" = "Arquivo 7-Zip";
+"RAR Archive" = "Arquivo RAR";
diff --git a/work/CleanZipBuild/src/Resources/pt-BR.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/pt-BR.lproj/Localizable.strings
index 4e1ad1d..1c674ee 100644
--- a/work/CleanZipBuild/src/Resources/pt-BR.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/pt-BR.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "Testar";
"toolbar.test.tooltip" = "Testar a integridade do arquivo";
"window.progressTitle" = "Progresso do CleanZip";
+"menu.about" = "Sobre %@";
+"menu.bringAllToFront" = "Trazer Tudo para Frente";
+"menu.close" = "Fechar";
+"menu.copy" = "Copiar";
+"menu.cut" = "Recortar";
+"menu.edit" = "Editar";
+"menu.enterFullScreen" = "Entrar em Tela Cheia";
+"menu.file" = "Arquivo";
+"menu.help" = "Ajuda";
+"menu.helpItem" = "Ajuda do %@";
+"menu.hide" = "Ocultar %@";
+"menu.hideOthers" = "Ocultar Outros";
+"menu.minimize" = "Minimizar";
+"menu.paste" = "Colar";
+"menu.quit" = "Encerrar %@";
+"menu.redo" = "Refazer";
+"menu.selectAll" = "Selecionar Tudo";
+"menu.services" = "Serviços";
+"menu.showAll" = "Mostrar Tudo";
+"menu.undo" = "Desfazer";
+"menu.view" = "Visualizar";
+"menu.window" = "Janela";
+"menu.zoom" = "Zoom";
+"settings.invalidSplitSize" = "Digite um número inteiro entre 1 e 1.048.576.";
+"error.passwordRequired" = "Este arquivo está protegido por senha.";
+"error.wrongPassword" = "A senha está incorreta.";
+"error.cancelled" = "A operação foi cancelada.";
+"error.unknownArchiveFailure" = "A operação de arquivo falhou sem detalhes adicionais.";
+"error.mainAppUnavailable" = "O CleanZip não pôde abrir o app principal para solicitar a senha.";
+"status.cancelling" = "Cancelando…";
+"status.cancelled" = "Operação cancelada.";
+"status.passwordCancelled" = "Entrada de senha cancelada.";
+"button.cancelOperation" = "Cancelar operação";
+"password.title" = "Senha do arquivo";
+"password.message" = "Digite a senha de “%@”.";
+"password.incorrect" = "A senha de “%@” está incorreta. Tente novamente.";
+"password.placeholder" = "Senha";
+"password.unlock" = "Desbloquear";
diff --git a/work/CleanZipBuild/src/Resources/ru.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/ru.lproj/InfoPlist.strings
index d7692a7..34c61ba 100644
--- a/work/CleanZipBuild/src/Resources/ru.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/ru.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "Архив";
+"Archive" = "Архив";
+"7-Zip Archive" = "Архив 7-Zip";
+"RAR Archive" = "Архив RAR";
diff --git a/work/CleanZipBuild/src/Resources/ru.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/ru.lproj/Localizable.strings
index d65c7d9..cdad91e 100644
--- a/work/CleanZipBuild/src/Resources/ru.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/ru.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "Проверить";
"toolbar.test.tooltip" = "Проверить целостность архива";
"window.progressTitle" = "Ход выполнения CleanZip";
+"menu.about" = "О программе «%@»";
+"menu.bringAllToFront" = "Все окна вперед";
+"menu.close" = "Закрыть";
+"menu.copy" = "Копировать";
+"menu.cut" = "Вырезать";
+"menu.edit" = "Правка";
+"menu.enterFullScreen" = "Перейти в полноэкранный режим";
+"menu.file" = "Файл";
+"menu.help" = "Справка";
+"menu.helpItem" = "Справка %@";
+"menu.hide" = "Скрыть %@";
+"menu.hideOthers" = "Скрыть остальные";
+"menu.minimize" = "Свернуть";
+"menu.paste" = "Вставить";
+"menu.quit" = "Завершить %@";
+"menu.redo" = "Повторить";
+"menu.selectAll" = "Выбрать все";
+"menu.services" = "Службы";
+"menu.showAll" = "Показать все";
+"menu.undo" = "Отменить";
+"menu.view" = "Вид";
+"menu.window" = "Окно";
+"menu.zoom" = "Масштаб";
+"settings.invalidSplitSize" = "Введите целое число от 1 до 1 048 576.";
+"error.passwordRequired" = "Этот архив защищён паролем.";
+"error.wrongPassword" = "Неверный пароль.";
+"error.cancelled" = "Операция отменена.";
+"error.unknownArchiveFailure" = "Операция с архивом завершилась ошибкой без дополнительных сведений.";
+"error.mainAppUnavailable" = "CleanZip не удалось открыть основное приложение для ввода пароля.";
+"status.cancelling" = "Отмена…";
+"status.cancelled" = "Операция отменена.";
+"status.passwordCancelled" = "Ввод пароля отменён.";
+"button.cancelOperation" = "Отменить операцию";
+"password.title" = "Пароль архива";
+"password.message" = "Введите пароль для «%@».";
+"password.incorrect" = "Пароль для «%@» неверен. Повторите попытку.";
+"password.placeholder" = "Пароль";
+"password.unlock" = "Разблокировать";
diff --git a/work/CleanZipBuild/src/Resources/zh-Hans.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/zh-Hans.lproj/InfoPlist.strings
index cd7cab9..3f2f3ad 100644
--- a/work/CleanZipBuild/src/Resources/zh-Hans.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/zh-Hans.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "压缩包";
+"Archive" = "压缩包";
+"7-Zip Archive" = "7-Zip 压缩包";
+"RAR Archive" = "RAR 压缩包";
diff --git a/work/CleanZipBuild/src/Resources/zh-Hans.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/zh-Hans.lproj/Localizable.strings
index 48504f0..257927b 100644
--- a/work/CleanZipBuild/src/Resources/zh-Hans.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/zh-Hans.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "测试";
"toolbar.test.tooltip" = "测试压缩包完整性";
"window.progressTitle" = "CleanZip 进度";
+"menu.about" = "关于 %@";
+"menu.bringAllToFront" = "前置全部窗口";
+"menu.close" = "关闭";
+"menu.copy" = "拷贝";
+"menu.cut" = "剪切";
+"menu.edit" = "编辑";
+"menu.enterFullScreen" = "进入全屏幕";
+"menu.file" = "文件";
+"menu.help" = "帮助";
+"menu.helpItem" = "%@ 帮助";
+"menu.hide" = "隐藏 %@";
+"menu.hideOthers" = "隐藏其他";
+"menu.minimize" = "最小化";
+"menu.paste" = "粘贴";
+"menu.quit" = "退出 %@";
+"menu.redo" = "重做";
+"menu.selectAll" = "全选";
+"menu.services" = "服务";
+"menu.showAll" = "全部显示";
+"menu.undo" = "撤销";
+"menu.view" = "显示";
+"menu.window" = "窗口";
+"menu.zoom" = "缩放";
+"settings.invalidSplitSize" = "请输入 1 到 1,048,576 之间的整数。";
+"error.passwordRequired" = "此压缩包受密码保护。";
+"error.wrongPassword" = "密码不正确。";
+"error.cancelled" = "操作已取消。";
+"error.unknownArchiveFailure" = "压缩包操作失败,工具未提供更多信息。";
+"error.mainAppUnavailable" = "无法打开 CleanZip 主应用以询问压缩包密码。";
+"status.cancelling" = "正在取消…";
+"status.cancelled" = "操作已取消。";
+"status.passwordCancelled" = "已取消输入密码。";
+"button.cancelOperation" = "取消操作";
+"password.title" = "压缩包密码";
+"password.message" = "请输入“%@”的密码。";
+"password.incorrect" = "“%@”的密码不正确,请重试。";
+"password.placeholder" = "密码";
+"password.unlock" = "解锁";
diff --git a/work/CleanZipBuild/src/Resources/zh-Hant.lproj/InfoPlist.strings b/work/CleanZipBuild/src/Resources/zh-Hant.lproj/InfoPlist.strings
index 14bd828..211dc10 100644
--- a/work/CleanZipBuild/src/Resources/zh-Hant.lproj/InfoPlist.strings
+++ b/work/CleanZipBuild/src/Resources/zh-Hant.lproj/InfoPlist.strings
@@ -1,3 +1,6 @@
"CFBundleDisplayName" = "CleanZip";
"CFBundleName" = "CleanZip";
"CFBundleTypeName" = "壓縮檔";
+"Archive" = "壓縮檔";
+"7-Zip Archive" = "7-Zip 壓縮檔";
+"RAR Archive" = "RAR 壓縮檔";
diff --git a/work/CleanZipBuild/src/Resources/zh-Hant.lproj/Localizable.strings b/work/CleanZipBuild/src/Resources/zh-Hant.lproj/Localizable.strings
index b53acda..f883b87 100644
--- a/work/CleanZipBuild/src/Resources/zh-Hant.lproj/Localizable.strings
+++ b/work/CleanZipBuild/src/Resources/zh-Hant.lproj/Localizable.strings
@@ -77,3 +77,41 @@
"toolbar.test" = "測試";
"toolbar.test.tooltip" = "測試壓縮檔完整性";
"window.progressTitle" = "CleanZip 進度";
+"menu.about" = "關於 %@";
+"menu.bringAllToFront" = "將全部移至最前方";
+"menu.close" = "關閉";
+"menu.copy" = "拷貝";
+"menu.cut" = "剪下";
+"menu.edit" = "編輯";
+"menu.enterFullScreen" = "進入全螢幕";
+"menu.file" = "檔案";
+"menu.help" = "輔助說明";
+"menu.helpItem" = "%@ 輔助說明";
+"menu.hide" = "隱藏 %@";
+"menu.hideOthers" = "隱藏其他項目";
+"menu.minimize" = "縮到最小";
+"menu.paste" = "貼上";
+"menu.quit" = "結束 %@";
+"menu.redo" = "重做";
+"menu.selectAll" = "全選";
+"menu.services" = "服務";
+"menu.showAll" = "全部顯示";
+"menu.undo" = "還原";
+"menu.view" = "顯示方式";
+"menu.window" = "視窗";
+"menu.zoom" = "縮放";
+"settings.invalidSplitSize" = "請輸入 1 到 1,048,576 之間的整數。";
+"error.passwordRequired" = "此壓縮檔受密碼保護。";
+"error.wrongPassword" = "密碼不正確。";
+"error.cancelled" = "操作已取消。";
+"error.unknownArchiveFailure" = "壓縮檔操作失敗,工具未提供更多資訊。";
+"error.mainAppUnavailable" = "無法開啟 CleanZip 主程式以詢問壓縮檔密碼。";
+"status.cancelling" = "正在取消…";
+"status.cancelled" = "操作已取消。";
+"status.passwordCancelled" = "已取消輸入密碼。";
+"button.cancelOperation" = "取消操作";
+"password.title" = "壓縮檔密碼";
+"password.message" = "請輸入「%@」的密碼。";
+"password.incorrect" = "「%@」的密碼不正確,請再試一次。";
+"password.placeholder" = "密碼";
+"password.unlock" = "解鎖";
diff --git a/work/CleanZipBuild/src/build.sh b/work/CleanZipBuild/src/build.sh
index 2421e7d..68b9fde 100755
--- a/work/CleanZipBuild/src/build.sh
+++ b/work/CleanZipBuild/src/build.sh
@@ -30,7 +30,7 @@ for arch in $ARCHS; do
app_slice="$BUILD_DIR/CleanZip.${arch}"
service_slice="$BUILD_DIR/CleanZipService.${arch}"
- xcrun swiftc -O -parse-as-library \
+ xcrun swiftc -O -parse-as-library -swift-version 6 -strict-concurrency=complete -warn-concurrency \
-target "$target" \
-framework AppKit \
-framework SwiftUI \
@@ -40,7 +40,7 @@ for arch in $ARCHS; do
"$ROOT/src/main.swift" \
-o "$app_slice"
- xcrun swiftc -O -parse-as-library \
+ xcrun swiftc -O -parse-as-library -swift-version 6 -strict-concurrency=complete -warn-concurrency \
-target "$target" \
-framework AppKit \
-framework UserNotifications \
@@ -62,8 +62,8 @@ fi
chmod +x "$APP/Contents/MacOS/CleanZip" "$SERVICE/Contents/MacOS/CleanZipService"
/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $DEPLOYMENT_TARGET" "$APP/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $DEPLOYMENT_TARGET" "$SERVICE/Contents/Info.plist"
-codesign --force --deep --sign - "$APP"
-codesign --force --deep --sign - "$SERVICE"
+codesign --force --deep --options runtime --timestamp=none --sign - "$APP"
+codesign --force --deep --options runtime --timestamp=none --sign - "$SERVICE"
echo "Built $APP"
echo "Built $SERVICE"
diff --git a/work/CleanZipBuild/src/build_xcode.sh b/work/CleanZipBuild/src/build_xcode.sh
index 0e7451b..c84b115 100755
--- a/work/CleanZipBuild/src/build_xcode.sh
+++ b/work/CleanZipBuild/src/build_xcode.sh
@@ -58,8 +58,8 @@ if [[ -x "$SERVICE/Contents/Resources/7zz" ]]; then
chmod +x "$SERVICE/Contents/Resources/7zz"
fi
-codesign --force --deep --sign - "$APP"
-codesign --force --deep --sign - "$SERVICE"
+codesign --force --deep --options runtime --timestamp=none --sign - "$APP"
+codesign --force --deep --options runtime --timestamp=none --sign - "$SERVICE"
echo "Built $APP"
echo "Built $SERVICE"
diff --git a/work/CleanZipBuild/src/generate_xcode_project.rb b/work/CleanZipBuild/src/generate_xcode_project.rb
index 0e46448..d373b5a 100755
--- a/work/CleanZipBuild/src/generate_xcode_project.rb
+++ b/work/CleanZipBuild/src/generate_xcode_project.rb
@@ -43,6 +43,7 @@ def configure_target(target, info_plist, bundle_id, executable_name, product_nam
config.build_settings["CODE_SIGN_STYLE"] = "Manual"
config.build_settings["COMBINE_HIDPI_IMAGES"] = "YES"
config.build_settings["DEVELOPMENT_TEAM"] = ""
+ config.build_settings["ENABLE_HARDENED_RUNTIME"] = "YES"
config.build_settings["GENERATE_INFOPLIST_FILE"] = "NO"
config.build_settings["INFOPLIST_FILE"] = info_plist
config.build_settings["LD_RUNPATH_SEARCH_PATHS"] = "$(inherited) @executable_path/../Frameworks"
@@ -52,7 +53,8 @@ def configure_target(target, info_plist, bundle_id, executable_name, product_nam
config.build_settings["PRODUCT_BUNDLE_IDENTIFIER"] = bundle_id
config.build_settings["PRODUCT_NAME"] = product_name
config.build_settings["SDKROOT"] = "macosx"
- config.build_settings["SWIFT_VERSION"] = "5.0"
+ config.build_settings["SWIFT_STRICT_CONCURRENCY"] = "complete"
+ config.build_settings["SWIFT_VERSION"] = "6.0"
config.build_settings["WRAPPER_EXTENSION"] = wrapper_extension
if config.name == "Release"
@@ -124,6 +126,8 @@ def configure_target(target, info_plist, bundle_id, executable_name, product_nam
project.build_configurations.each do |config|
config.build_settings["MACOSX_DEPLOYMENT_TARGET"] = "14.0"
config.build_settings["SDKROOT"] = "macosx"
+ config.build_settings["SWIFT_STRICT_CONCURRENCY"] = "complete"
+ config.build_settings["SWIFT_VERSION"] = "6.0"
end
[app_target, service_target].each do |target|
diff --git a/work/CleanZipBuild/src/main.swift b/work/CleanZipBuild/src/main.swift
index 9d00ff1..ccf5f82 100644
--- a/work/CleanZipBuild/src/main.swift
+++ b/work/CleanZipBuild/src/main.swift
@@ -1,8 +1,8 @@
-import AppKit
-import Combine
-import QuartzCore
-import SwiftUI
-import UniformTypeIdentifiers
+@preconcurrency import AppKit
+@preconcurrency import Combine
+@preconcurrency import QuartzCore
+@preconcurrency import SwiftUI
+@preconcurrency import UniformTypeIdentifiers
@preconcurrency import UserNotifications
enum L10n {
@@ -25,7 +25,7 @@ enum L10n {
}
}
-struct ArchiveEntry: Identifiable, Equatable {
+struct ArchiveEntry: Identifiable, Equatable, Sendable {
let id = UUID()
let path: String
let size: Int64
@@ -33,7 +33,7 @@ struct ArchiveEntry: Identifiable, Equatable {
let isDirectory: Bool
}
-struct OperationProgress {
+struct OperationProgress: Sendable {
var title: String
var detail: String
var fraction: Double?
@@ -48,7 +48,7 @@ extension Notification.Name {
static let cleanZipStateDidChange = Notification.Name("local.codex.cleanzip.stateDidChange")
}
-struct SelectedItem: Identifiable, Equatable {
+struct SelectedItem: Identifiable, Equatable, Sendable {
let url: URL
let isDirectory: Bool
let byteSize: Int64?
@@ -74,14 +74,14 @@ struct SelectedItem: Identifiable, Equatable {
}
}
-enum ArchiveFormat: String, CaseIterable, Identifiable {
+enum ArchiveFormat: String, CaseIterable, Identifiable, Sendable {
case zip = "ZIP"
case sevenZ = "7Z"
var id: String { rawValue }
var fileExtension: String { self == .zip ? "zip" : "7z" }
}
-struct SplitPreset: Identifiable, Hashable {
+struct SplitPreset: Identifiable, Hashable, Sendable {
let id: String
let titleKey: String
let spec: String?
@@ -97,13 +97,13 @@ struct SplitPreset: Identifiable, Hashable {
]
}
-struct ProcessResult {
+struct ProcessResult: Sendable {
let status: Int32
let stdout: String
let stderr: String
}
-private final class DataBuffer {
+private final class DataBuffer: @unchecked Sendable {
private var data = Data()
private let lock = NSLock()
@@ -120,21 +120,69 @@ private final class DataBuffer {
}
}
-enum ArchiveError: Error, LocalizedError {
+final class OperationCancellation: @unchecked Sendable {
+ private let lock = NSLock()
+ private var process: Process?
+ private var cancelled = false
+
+ var isCancelled: Bool {
+ lock.withLock { cancelled }
+ }
+
+ func register(_ process: Process) {
+ let shouldTerminate = lock.withLock {
+ self.process = process
+ return cancelled
+ }
+ if shouldTerminate, process.isRunning { process.terminate() }
+ }
+
+ func unregister(_ process: Process) {
+ lock.withLock {
+ if self.process === process { self.process = nil }
+ }
+ }
+
+ func cancel() {
+ let runningProcess = lock.withLock {
+ cancelled = true
+ return process
+ }
+ if let runningProcess, runningProcess.isRunning { runningProcess.terminate() }
+ }
+
+ func checkCancellation() throws {
+ if isCancelled { throw ArchiveError.cancelled }
+ }
+}
+
+enum ArchiveError: Error, LocalizedError, Sendable {
case missingTool(String)
case failed(String)
+ case passwordRequired
+ case wrongPassword
+ case cancelled
+
var errorDescription: String? {
switch self {
case .missingTool(let tool): return L10n.tr("error.missingTool", tool)
case .failed(let message): return message
+ case .passwordRequired: return L10n.tr("error.passwordRequired")
+ case .wrongPassword: return L10n.tr("error.wrongPassword")
+ case .cancelled: return L10n.tr("error.cancelled")
}
}
}
-final class ArchiveEngine {
+final class ArchiveEngine: @unchecked Sendable {
static let shared = ArchiveEngine()
+ static let supportedFilenameExtensions = [
+ "zip", "7z", "rar", "tar", "tar.gz", "tgz", "tar.bz2", "tbz", "tbz2",
+ "tar.xz", "txz", "tar.zst", "tzst", "gz", "bz2", "xz", "zst",
+ "iso", "cab", "dmg", "xar", "jar", "war", "apk", "zip.001", "7z.001"
+ ]
private static let progressRegex = try! NSRegularExpression(pattern: #"(? Void
+ typealias ProgressHandler = @Sendable (Double) -> Void
private let fileManager = FileManager.default
var sevenZipURL: URL? {
@@ -153,31 +201,52 @@ final class ArchiveEngine {
func isArchive(_ url: URL) -> Bool {
let name = url.lastPathComponent.lowercased()
- let extensions = [
- ".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz", ".tbz2",
- ".tar.xz", ".txz", ".tar.zst", ".tzst", ".gz", ".bz2", ".xz", ".zst",
- ".iso", ".cab", ".dmg", ".xar", ".jar", ".war", ".apk", ".zip.001", ".7z.001"
- ]
- if extensions.contains(where: { name.hasSuffix($0) }) { return true }
+ if Self.supportedFilenameExtensions.contains(where: { name.hasSuffix(".\($0)") }) { return true }
if name.range(of: #"\.z\d{2}$"#, options: .regularExpression) != nil { return true }
if name.range(of: #"\.r\d{2}$"#, options: .regularExpression) != nil { return true }
return false
}
- func listArchive(_ archive: URL) throws -> [ArchiveEntry] {
+ func listArchive(_ archive: URL, password: String? = nil, cancellation: OperationCancellation? = nil) throws -> [ArchiveEntry] {
guard let sevenZipURL else { throw ArchiveError.missingTool("7zz") }
- let result = try runProcess(executable: sevenZipURL, arguments: ["l", "-slt", archive.path])
- guard result.status == 0 else { throw ArchiveError.failed(result.stderr.isEmpty ? result.stdout : result.stderr) }
+ let result = try runProcess(
+ executable: sevenZipURL,
+ arguments: ["l", "-slt", archive.path],
+ password: password,
+ cancellation: cancellation
+ )
+ guard result.status == 0 else { throw archiveError(for: result) }
return parseSevenZipList(result.stdout)
}
- func testArchive(_ archive: URL) throws {
+ @concurrent
+ func listArchiveInBackground(_ archive: URL, password: String? = nil, cancellation: OperationCancellation? = nil) async throws -> [ArchiveEntry] {
+ try listArchive(archive, password: password, cancellation: cancellation)
+ }
+
+ func testArchive(_ archive: URL, password: String? = nil, cancellation: OperationCancellation? = nil) throws {
guard let sevenZipURL else { throw ArchiveError.missingTool("7zz") }
- let result = try runProcess(executable: sevenZipURL, arguments: ["t", "-y", archive.path])
- guard result.status == 0 else { throw ArchiveError.failed(result.stderr.isEmpty ? result.stdout : result.stderr) }
+ let result = try runProcess(
+ executable: sevenZipURL,
+ arguments: ["t", "-y", archive.path],
+ password: password,
+ cancellation: cancellation
+ )
+ guard result.status == 0 else { throw archiveError(for: result) }
+ }
+
+ @concurrent
+ func testArchiveInBackground(_ archive: URL, password: String? = nil, cancellation: OperationCancellation? = nil) async throws {
+ try testArchive(archive, password: password, cancellation: cancellation)
}
- func compress(urls: [URL], format: ArchiveFormat, splitSpec: String?, progressHandler: ProgressHandler? = nil) throws -> URL {
+ func compress(
+ urls: [URL],
+ format: ArchiveFormat,
+ splitSpec: String?,
+ cancellation: OperationCancellation? = nil,
+ progressHandler: ProgressHandler? = nil
+ ) throws -> URL {
guard !urls.isEmpty else { throw ArchiveError.failed(L10n.tr("error.noItemsToCompress")) }
let parent = urls[0].deletingLastPathComponent()
guard urls.allSatisfy({ $0.deletingLastPathComponent().standardizedFileURL == parent.standardizedFileURL }) else {
@@ -186,30 +255,120 @@ final class ArchiveEngine {
let baseName = urls.count == 1 ? urls[0].deletingPathExtension().lastPathComponent : "Archive"
let output = uniqueFileURL(in: parent, baseName: baseName, extensionName: format.fileExtension, splitSpec: splitSpec)
let itemNames = urls.map { itemNameForProcess($0) }
- try compressWith7z(parent: parent, output: output, itemNames: itemNames, archiveType: format == .zip ? "zip" : "7z", splitSpec: splitSpec, progressHandler: progressHandler)
- return output
+ do {
+ try compressWith7z(
+ parent: parent,
+ output: output,
+ itemNames: itemNames,
+ archiveType: format == .zip ? "zip" : "7z",
+ splitSpec: splitSpec,
+ cancellation: cancellation,
+ progressHandler: progressHandler
+ )
+ return output
+ } catch {
+ removePartialArchive(at: output)
+ throw error
+ }
+ }
+
+ @concurrent
+ func compressInBackground(
+ urls: [URL],
+ format: ArchiveFormat,
+ splitSpec: String?,
+ cancellation: OperationCancellation? = nil,
+ progressHandler: ProgressHandler? = nil
+ ) async throws -> URL {
+ try compress(
+ urls: urls,
+ format: format,
+ splitSpec: splitSpec,
+ cancellation: cancellation,
+ progressHandler: progressHandler
+ )
}
- func extract(archive: URL, progressHandler: ProgressHandler? = nil) throws -> URL {
+ func extract(
+ archive: URL,
+ password: String? = nil,
+ cancellation: OperationCancellation? = nil,
+ progressHandler: ProgressHandler? = nil
+ ) throws -> URL {
let parent = archive.deletingLastPathComponent()
let baseName = archiveBaseName(archive)
let outputDir = uniqueDirectoryURL(in: parent, baseName: baseName)
try fileManager.createDirectory(at: outputDir, withIntermediateDirectories: true)
- if progressHandler != nil {
- return try extractWith7z(archive: archive, outputDir: outputDir, progressHandler: progressHandler)
- }
- if archive.lastPathComponent.lowercased().hasSuffix(".zip") {
- let result = try runProcess(executable: URL(fileURLWithPath: "/usr/bin/ditto"), arguments: ["-x", "-k", archive.path, outputDir.path])
- if result.status == 0 { return outputDir }
+ do {
+ if progressHandler != nil || password != nil {
+ return try extractWith7z(
+ archive: archive,
+ outputDir: outputDir,
+ password: password,
+ cancellation: cancellation,
+ progressHandler: progressHandler
+ )
+ }
+ if archive.lastPathComponent.lowercased().hasSuffix(".zip") {
+ let result = try runProcess(
+ executable: URL(fileURLWithPath: "/usr/bin/ditto"),
+ arguments: ["-x", "-k", archive.path, outputDir.path],
+ cancellation: cancellation
+ )
+ if result.status == 0 { return outputDir }
+ try? fileManager.removeItem(at: outputDir)
+ let fallbackDir = uniqueDirectoryURL(in: parent, baseName: baseName)
+ try fileManager.createDirectory(at: fallbackDir, withIntermediateDirectories: true)
+ do {
+ return try extractWith7z(
+ archive: archive,
+ outputDir: fallbackDir,
+ password: password,
+ cancellation: cancellation,
+ progressHandler: progressHandler
+ )
+ } catch {
+ try? fileManager.removeItem(at: fallbackDir)
+ throw error
+ }
+ }
+ return try extractWith7z(
+ archive: archive,
+ outputDir: outputDir,
+ password: password,
+ cancellation: cancellation,
+ progressHandler: progressHandler
+ )
+ } catch {
try? fileManager.removeItem(at: outputDir)
- let fallbackDir = uniqueDirectoryURL(in: parent, baseName: baseName)
- try fileManager.createDirectory(at: fallbackDir, withIntermediateDirectories: true)
- return try extractWith7z(archive: archive, outputDir: fallbackDir, progressHandler: progressHandler)
- }
- return try extractWith7z(archive: archive, outputDir: outputDir, progressHandler: progressHandler)
+ throw error
+ }
+ }
+
+ @concurrent
+ func extractInBackground(
+ archive: URL,
+ password: String? = nil,
+ cancellation: OperationCancellation? = nil,
+ progressHandler: ProgressHandler? = nil
+ ) async throws -> URL {
+ try extract(
+ archive: archive,
+ password: password,
+ cancellation: cancellation,
+ progressHandler: progressHandler
+ )
}
- private func compressWith7z(parent: URL, output: URL, itemNames: [String], archiveType: String, splitSpec: String?, progressHandler: ProgressHandler?) throws {
+ private func compressWith7z(
+ parent: URL,
+ output: URL,
+ itemNames: [String],
+ archiveType: String,
+ splitSpec: String?,
+ cancellation: OperationCancellation?,
+ progressHandler: ProgressHandler?
+ ) throws {
guard let sevenZipURL else { throw ArchiveError.missingTool("7zz") }
var args = ["a", "-t\(archiveType)", "-mx=5", "-y"]
if progressHandler != nil { args.append("-bsp1") }
@@ -219,22 +378,68 @@ final class ArchiveEngine {
args.append(contentsOf: ["-xr!.DS_Store", "-xr!__MACOSX", "-xr!._*"])
var env = ProcessInfo.processInfo.environment
env["COPYFILE_DISABLE"] = "1"
- let result = try runProcess(executable: sevenZipURL, arguments: args, currentDirectory: parent, environment: env, progressHandler: progressHandler)
- guard result.status == 0 else { throw ArchiveError.failed(result.stderr.isEmpty ? result.stdout : result.stderr) }
+ let result = try runProcess(
+ executable: sevenZipURL,
+ arguments: args,
+ currentDirectory: parent,
+ environment: env,
+ cancellation: cancellation,
+ progressHandler: progressHandler
+ )
+ guard result.status == 0 else { throw archiveError(for: result) }
}
- private func extractWith7z(archive: URL, outputDir: URL, progressHandler: ProgressHandler?) throws -> URL {
+ private func extractWith7z(
+ archive: URL,
+ outputDir: URL,
+ password: String?,
+ cancellation: OperationCancellation?,
+ progressHandler: ProgressHandler?
+ ) throws -> URL {
guard let sevenZipURL else { throw ArchiveError.missingTool("7zz") }
var args = ["x", "-y"]
if progressHandler != nil { args.append("-bsp1") }
args.append("-o\(outputDir.path)")
args.append(archive.path)
- let result = try runProcess(executable: sevenZipURL, arguments: args, progressHandler: progressHandler)
- guard result.status == 0 else { throw ArchiveError.failed(result.stderr.isEmpty ? result.stdout : result.stderr) }
+ let result = try runProcess(
+ executable: sevenZipURL,
+ arguments: args,
+ password: password,
+ cancellation: cancellation,
+ progressHandler: progressHandler
+ )
+ guard result.status == 0 else {
+ try? fileManager.removeItem(at: outputDir)
+ throw archiveError(for: result)
+ }
return outputDir
}
- private func runProcess(executable: URL, arguments: [String], currentDirectory: URL? = nil, environment: [String: String]? = nil, progressHandler: ProgressHandler? = nil) throws -> ProcessResult {
+ private func removePartialArchive(at output: URL) {
+ try? fileManager.removeItem(at: output)
+ let directory = output.deletingLastPathComponent()
+ let volumePrefix = output.lastPathComponent + "."
+ guard let candidates = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return }
+ for candidate in candidates {
+ let name = candidate.lastPathComponent
+ guard name.hasPrefix(volumePrefix) else { continue }
+ let suffix = name.dropFirst(volumePrefix.count)
+ if suffix.count == 3, suffix.allSatisfy(\.isNumber) {
+ try? fileManager.removeItem(at: candidate)
+ }
+ }
+ }
+
+ private func runProcess(
+ executable: URL,
+ arguments: [String],
+ currentDirectory: URL? = nil,
+ environment: [String: String]? = nil,
+ password: String? = nil,
+ cancellation: OperationCancellation? = nil,
+ progressHandler: ProgressHandler? = nil
+ ) throws -> ProcessResult {
+ try cancellation?.checkCancellation()
let process = Process()
process.executableURL = executable
process.arguments = arguments
@@ -244,6 +449,15 @@ final class ArchiveEngine {
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
+ let stdinPipe: Pipe?
+ if password != nil {
+ let pipe = Pipe()
+ process.standardInput = pipe
+ stdinPipe = pipe
+ } else {
+ process.standardInput = FileHandle.nullDevice
+ stdinPipe = nil
+ }
let stdoutBuffer = DataBuffer()
let stderrBuffer = DataBuffer()
let readers = DispatchGroup()
@@ -258,11 +472,28 @@ final class ArchiveEngine {
readers.leave()
}
try process.run()
+ cancellation?.register(process)
+ defer { cancellation?.unregister(process) }
+ if let password, let stdinPipe {
+ stdinPipe.fileHandleForWriting.write(Data("\(password)\n".utf8))
+ try? stdinPipe.fileHandleForWriting.close()
+ }
process.waitUntilExit()
readers.wait()
+ try cancellation?.checkCancellation()
return ProcessResult(status: process.terminationStatus, stdout: stdoutBuffer.string, stderr: stderrBuffer.string)
}
+ private func archiveError(for result: ProcessResult) -> ArchiveError {
+ let message = [result.stdout, result.stderr].filter { !$0.isEmpty }.joined(separator: "\n")
+ let lowercased = message.lowercased()
+ if lowercased.contains("wrong password") { return .wrongPassword }
+ if lowercased.contains("enter password") || lowercased.contains("password is required") {
+ return .passwordRequired
+ }
+ return .failed(message.isEmpty ? L10n.tr("error.unknownArchiveFailure") : message)
+ }
+
private func readPipe(_ pipe: Pipe, into buffer: DataBuffer, progressHandler: ProgressHandler?) {
while true {
let data = pipe.fileHandleForReading.availableData
@@ -364,6 +595,22 @@ final class ArchiveEngine {
}
}
+enum PendingPasswordAction: Sendable {
+ case preview(URL, generation: Int)
+ case extract(URL)
+ case test(URL)
+}
+
+enum ServiceHandoffError: Error, Sendable {
+ case passwordRequired(URL)
+}
+
+struct PasswordPrompt: Identifiable, Sendable {
+ let id = UUID()
+ let archiveName: String
+ let isRetry: Bool
+}
+
@MainActor
final class AppState: ObservableObject {
static let shared = AppState()
@@ -393,22 +640,34 @@ final class AppState: ObservableObject {
@Published var format: ArchiveFormat = .zip
@Published var splitPreset: SplitPreset = SplitPreset.all[0]
@Published var customSplitMB = "100"
+ @Published var passwordPrompt: PasswordPrompt?
private(set) var selectedItems: [SelectedItem] = []
private(set) var selectedItemsRevision = 0
private(set) var entriesRevision = 0
private(set) var filteredEntries: [ArchiveEntry] = []
private(set) var totalFiles = 0
private(set) var totalBytes: Int64 = 0
+ private var previewGeneration = 0
+ private var previewCancellation: OperationCancellation?
+ private var operationCancellation: OperationCancellation?
+ private var pendingPasswordAction: PendingPasswordAction?
+ private var archivePassword: String?
func handle(urls: [URL]) {
guard !urls.isEmpty else { return }
+ previewCancellation?.cancel()
+ previewCancellation = nil
+ archivePassword = nil
+ pendingPasswordAction = nil
+ passwordPrompt = nil
+ previewGeneration &+= 1
searchText = ""
if urls.count == 1, ArchiveEngine.shared.isArchive(urls[0]) {
archiveURL = urls[0]
selectedURLs = []
selectedItemIDs = []
operationProgress = nil
- previewArchive(urls[0])
+ previewArchive(urls[0], generation: previewGeneration, password: nil)
} else {
archiveURL = nil
entries = []
@@ -422,6 +681,8 @@ final class AppState: ObservableObject {
func appendItems(_ urls: [URL]) {
guard !urls.isEmpty else { return }
+ previewCancellation?.cancel()
+ previewCancellation = nil
archiveURL = nil
entries = []
selectedURLs = uniqued(selectedURLs + urls)
@@ -447,25 +708,34 @@ final class AppState: ObservableObject {
notifyStateDidChange()
}
- func previewArchive(_ url: URL) {
+ private func previewArchive(_ url: URL, generation: Int, password: String?) {
+ previewCancellation?.cancel()
+ let cancellation = OperationCancellation()
+ previewCancellation = cancellation
isBusy = true
status = L10n.tr("status.reading", url.lastPathComponent)
- DispatchQueue.global(qos: .userInitiated).async {
+ Task { @MainActor [weak self] in
+ guard let self else { return }
do {
- let list = try ArchiveEngine.shared.listArchive(url)
- DispatchQueue.main.async {
- self.entries = list
- self.status = L10n.tr("status.previewComplete", L10n.fileCount(self.totalFiles), Self.formatBytes(self.totalBytes))
- self.isBusy = false
- self.notifyStateDidChange()
- }
+ let list = try await ArchiveEngine.shared.listArchiveInBackground(url, password: password, cancellation: cancellation)
+ guard self.previewGeneration == generation, self.archiveURL?.standardizedFileURL == url.standardizedFileURL else { return }
+ self.previewCancellation = nil
+ self.entries = list
+ self.status = L10n.tr("status.previewComplete", L10n.fileCount(self.totalFiles), Self.formatBytes(self.totalBytes))
+ self.isBusy = false
+ self.notifyStateDidChange()
} catch {
- DispatchQueue.main.async {
- self.entries = []
- self.status = L10n.tr("status.previewFailed", error.localizedDescription)
- self.isBusy = false
- self.notifyStateDidChange()
+ guard self.previewGeneration == generation, self.archiveURL?.standardizedFileURL == url.standardizedFileURL else { return }
+ self.previewCancellation = nil
+ if self.isPasswordError(error) {
+ self.requestPassword(for: .preview(url, generation: generation), archiveName: url.lastPathComponent, retry: self.isWrongPassword(error))
+ return
}
+ guard !self.isCancellation(error) else { return }
+ self.entries = []
+ self.status = L10n.tr("status.previewFailed", error.localizedDescription)
+ self.isBusy = false
+ self.notifyStateDidChange()
}
}
notifyStateDidChange()
@@ -473,61 +743,62 @@ final class AppState: ObservableObject {
func compressSelected() {
let urls = selectedURLs
- guard !urls.isEmpty else { return }
+ guard !urls.isEmpty, isSplitConfigurationValid else { return }
+ Self.prepareNotificationsForOperation()
let split = resolvedSplitSpec()
let selectedFormat = format
- beginOperation(title: L10n.tr("operation.compressing"), detail: L10n.itemCount(urls.count))
+ let cancellation = beginOperation(title: L10n.tr("operation.compressing"), detail: L10n.itemCount(urls.count))
showingCompressSheet = false
- DispatchQueue.global(qos: .userInitiated).async {
+ Task { @MainActor [weak self] in
+ guard let self else { return }
do {
- let output = try ArchiveEngine.shared.compress(urls: urls, format: selectedFormat, splitSpec: split) { fraction in
- DispatchQueue.main.async {
- self.updateOperationProgress(fraction)
- }
- }
- DispatchQueue.main.async {
- self.status = L10n.tr("status.created", output.lastPathComponent)
- self.isBusy = false
- self.operationProgress = nil
- self.notifyStateDidChange()
- Self.notify(title: "CleanZip", message: L10n.tr("notification.created", output.lastPathComponent))
- NSWorkspace.shared.activateFileViewerSelecting([output])
+ let output = try await ArchiveEngine.shared.compressInBackground(
+ urls: urls,
+ format: selectedFormat,
+ splitSpec: split,
+ cancellation: cancellation
+ ) { fraction in
+ Task { @MainActor in AppState.shared.updateOperationProgress(fraction, for: cancellation) }
}
+ guard self.finishOperation(status: L10n.tr("status.created", output.lastPathComponent), for: cancellation) else { return }
+ Self.notify(title: "CleanZip", message: L10n.tr("notification.created", output.lastPathComponent))
+ NSWorkspace.shared.activateFileViewerSelecting([output])
} catch {
- DispatchQueue.main.async {
- self.status = L10n.tr("status.compressFailed", error.localizedDescription)
- self.isBusy = false
- self.operationProgress = nil
- self.notifyStateDidChange()
- }
+ self.finishOperation(
+ status: self.isCancellation(error) ? L10n.tr("status.cancelled") : L10n.tr("status.compressFailed", error.localizedDescription),
+ for: cancellation
+ )
}
}
}
func extractCurrentArchive() {
guard let archiveURL else { return }
- beginOperation(title: L10n.tr("operation.extracting"), detail: archiveURL.lastPathComponent)
- DispatchQueue.global(qos: .userInitiated).async {
+ Self.prepareNotificationsForOperation()
+ let password = archivePassword
+ let cancellation = beginOperation(title: L10n.tr("operation.extracting"), detail: archiveURL.lastPathComponent)
+ Task { @MainActor [weak self] in
+ guard let self else { return }
do {
- let output = try ArchiveEngine.shared.extract(archive: archiveURL) { fraction in
- DispatchQueue.main.async {
- self.updateOperationProgress(fraction)
- }
- }
- DispatchQueue.main.async {
- self.status = L10n.tr("status.extractedTo", output.lastPathComponent)
- self.isBusy = false
- self.operationProgress = nil
- self.notifyStateDidChange()
- Self.notify(title: "CleanZip", message: L10n.tr("notification.extractedTo", output.lastPathComponent))
- NSWorkspace.shared.activateFileViewerSelecting([output])
+ let output = try await ArchiveEngine.shared.extractInBackground(
+ archive: archiveURL,
+ password: password,
+ cancellation: cancellation
+ ) { fraction in
+ Task { @MainActor in AppState.shared.updateOperationProgress(fraction, for: cancellation) }
}
+ guard self.finishOperation(status: L10n.tr("status.extractedTo", output.lastPathComponent), for: cancellation) else { return }
+ Self.notify(title: "CleanZip", message: L10n.tr("notification.extractedTo", output.lastPathComponent))
+ NSWorkspace.shared.activateFileViewerSelecting([output])
} catch {
- DispatchQueue.main.async {
- self.status = L10n.tr("status.extractFailed", error.localizedDescription)
- self.isBusy = false
- self.operationProgress = nil
- self.notifyStateDidChange()
+ if self.isPasswordError(error) {
+ guard self.finishOperation(status: error.localizedDescription, for: cancellation) else { return }
+ self.requestPassword(for: .extract(archiveURL), archiveName: archiveURL.lastPathComponent, retry: self.isWrongPassword(error))
+ } else {
+ self.finishOperation(
+ status: self.isCancellation(error) ? L10n.tr("status.cancelled") : L10n.tr("status.extractFailed", error.localizedDescription),
+ for: cancellation
+ )
}
}
}
@@ -535,22 +806,22 @@ final class AppState: ObservableObject {
func testCurrentArchive() {
guard let archiveURL else { return }
- isBusy = true
- status = L10n.tr("status.testing")
- notifyStateDidChange()
- DispatchQueue.global(qos: .userInitiated).async {
+ let password = archivePassword
+ let cancellation = beginOperation(title: L10n.tr("status.testing"), detail: archiveURL.lastPathComponent, fraction: nil)
+ Task { @MainActor [weak self] in
+ guard let self else { return }
do {
- try ArchiveEngine.shared.testArchive(archiveURL)
- DispatchQueue.main.async {
- self.status = L10n.tr("status.testPassed")
- self.isBusy = false
- self.notifyStateDidChange()
- }
+ try await ArchiveEngine.shared.testArchiveInBackground(archiveURL, password: password, cancellation: cancellation)
+ self.finishOperation(status: L10n.tr("status.testPassed"), for: cancellation)
} catch {
- DispatchQueue.main.async {
- self.status = L10n.tr("status.testFailed", error.localizedDescription)
- self.isBusy = false
- self.notifyStateDidChange()
+ if self.isPasswordError(error) {
+ guard self.finishOperation(status: error.localizedDescription, for: cancellation) else { return }
+ self.requestPassword(for: .test(archiveURL), archiveName: archiveURL.lastPathComponent, retry: self.isWrongPassword(error))
+ } else {
+ self.finishOperation(
+ status: self.isCancellation(error) ? L10n.tr("status.cancelled") : L10n.tr("status.testFailed", error.localizedDescription),
+ for: cancellation
+ )
}
}
}
@@ -561,6 +832,12 @@ final class AppState: ObservableObject {
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
+ var seenTypeIdentifiers = Set
()
+ panel.allowedContentTypes = ArchiveEngine.supportedFilenameExtensions.compactMap { extensionName in
+ let filenameExtension = extensionName.split(separator: ".").last.map(String.init) ?? extensionName
+ guard let type = UTType(filenameExtension: filenameExtension), seenTypeIdentifiers.insert(type.identifier).inserted else { return nil }
+ return type
+ }
if panel.runModal() == .OK, let url = panel.url { handle(urls: [url]) }
}
@@ -572,10 +849,17 @@ final class AppState: ObservableObject {
if panel.runModal() == .OK { append ? appendItems(panel.urls) : handle(urls: panel.urls) }
}
+ var isSplitConfigurationValid: Bool {
+ guard splitPreset.id == "custom" else { return true }
+ let trimmed = customSplitMB.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let value = Int(trimmed) else { return false }
+ return (1...1_048_576).contains(value)
+ }
+
func resolvedSplitSpec() -> String? {
if splitPreset.id == "custom" {
let trimmed = customSplitMB.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
+ guard isSplitConfigurationValid else { return nil }
return "\(trimmed)m"
}
return splitPreset.spec
@@ -585,15 +869,37 @@ final class AppState: ObservableObject {
ByteCountFormatter.string(fromByteCount: value, countStyle: .file)
}
- static func notify(title: String, message: String, completion: (() -> Void)? = nil) {
+ static func prepareNotificationsForOperation() {
let center = UNUserNotificationCenter.current()
- center.requestAuthorization(options: [.alert, .sound]) { _, _ in
- let content = UNMutableNotificationContent()
- content.title = title
- content.body = message
- content.sound = .default
- let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
- center.add(request) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { completion?() } }
+ center.getNotificationSettings { settings in
+ guard settings.authorizationStatus == .notDetermined else { return }
+ center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ }
+ }
+
+ static func notify(title: String, message: String, completion: (@MainActor @Sendable () -> Void)? = nil) {
+ let center = UNUserNotificationCenter.current()
+ center.getNotificationSettings { settings in
+ let deliver: @Sendable () -> Void = {
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = message
+ content.sound = .default
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
+ center.add(request) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { completion?() } }
+ }
+ switch settings.authorizationStatus {
+ case .authorized, .provisional, .ephemeral:
+ deliver()
+ case .notDetermined:
+ center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
+ granted ? deliver() : DispatchQueue.main.async { completion?() }
+ }
+ case .denied:
+ DispatchQueue.main.async { completion?() }
+ @unknown default:
+ DispatchQueue.main.async { completion?() }
+ }
}
}
@@ -638,14 +944,20 @@ final class AppState: ObservableObject {
beginOperation(title: title, detail: detail)
}
- func beginOperation(title: String, detail: String) {
+ @discardableResult
+ func beginOperation(title: String, detail: String, fraction: Double? = 0) -> OperationCancellation {
+ operationCancellation?.cancel()
+ let cancellation = OperationCancellation()
+ operationCancellation = cancellation
isBusy = true
- operationProgress = OperationProgress(title: title, detail: detail, fraction: 0)
- status = L10n.tr("status.progress", title, "0")
+ operationProgress = OperationProgress(title: title, detail: detail, fraction: fraction)
+ status = fraction == nil ? title : L10n.tr("status.progress", title, "0")
notifyStateDidChange()
+ return cancellation
}
- func updateOperationProgress(_ fraction: Double) {
+ func updateOperationProgress(_ fraction: Double, for operation: OperationCancellation? = nil) {
+ if let operation, operationCancellation !== operation { return }
let clamped = min(max(fraction, 0), 1)
let previous = operationProgress?.fraction ?? -1
let percent = Int((clamped * 100).rounded())
@@ -660,18 +972,84 @@ final class AppState: ObservableObject {
status = L10n.tr("status.progress", title, String(percent))
}
- func finishOperation(status: String) {
+ @discardableResult
+ func finishOperation(status: String, for operation: OperationCancellation? = nil) -> Bool {
+ if let operation, operationCancellation !== operation { return false }
self.status = status
isBusy = false
operationProgress = nil
+ operationCancellation = nil
+ notifyStateDidChange()
+ return true
+ }
+
+ func cancelCurrentOperation() {
+ guard let operationCancellation, isBusy else { return }
+ status = L10n.tr("status.cancelling")
+ operationCancellation.cancel()
+ notifyStateDidChange()
+ }
+
+ func submitPassword(_ password: String) {
+ let password = password.trimmingCharacters(in: .newlines)
+ guard !password.isEmpty, let action = pendingPasswordAction else { return }
+ archivePassword = password
+ pendingPasswordAction = nil
+ passwordPrompt = nil
+ switch action {
+ case .preview(let url, let generation):
+ previewArchive(url, generation: generation, password: password)
+ case .extract:
+ extractCurrentArchive()
+ case .test:
+ testCurrentArchive()
+ }
+ }
+
+ func cancelPasswordPrompt() {
+ pendingPasswordAction = nil
+ passwordPrompt = nil
+ isBusy = false
+ operationProgress = nil
+ operationCancellation = nil
+ status = L10n.tr("status.passwordCancelled")
notifyStateDidChange()
}
+ private func requestPassword(for action: PendingPasswordAction, archiveName: String, retry: Bool) {
+ pendingPasswordAction = action
+ passwordPrompt = PasswordPrompt(archiveName: archiveName, isRetry: retry)
+ isBusy = false
+ operationProgress = nil
+ operationCancellation = nil
+ status = L10n.tr(retry ? "error.wrongPassword" : "error.passwordRequired")
+ notifyStateDidChange()
+ }
+
+ private func isPasswordError(_ error: Error) -> Bool {
+ guard let archiveError = error as? ArchiveError else { return false }
+ switch archiveError {
+ case .passwordRequired, .wrongPassword: return true
+ default: return false
+ }
+ }
+
+ private func isWrongPassword(_ error: Error) -> Bool {
+ guard case ArchiveError.wrongPassword = error else { return false }
+ return true
+ }
+
+ private func isCancellation(_ error: Error) -> Bool {
+ guard case ArchiveError.cancelled = error else { return false }
+ return true
+ }
+
private func notifyStateDidChange() {
NotificationCenter.default.post(name: .cleanZipStateDidChange, object: self)
}
}
+@MainActor
enum FinderTableBehavior {
static func configure(_ table: NSTableView, allowsMultipleSelection: Bool, autosaveName: String) {
table.usesAlternatingRowBackgroundColors = true
@@ -707,6 +1085,7 @@ enum FinderTableBehavior {
}
}
+@MainActor
struct SystemContentBackground: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
@@ -721,24 +1100,29 @@ struct SystemContentBackground: NSViewRepresentable {
}
}
-final class ServiceProgressHUD {
+@MainActor
+final class ServiceProgressHUD: NSObject {
private var panel: NSPanel?
private var titleField: NSTextField?
private var detailField: NSTextField?
private var percentField: NSTextField?
private var progressIndicator: NSProgressIndicator?
+ private var cancelButton: NSButton?
private var scheduledShow: DispatchWorkItem?
+ private var cancelHandler: (() -> Void)?
private var title = L10n.tr("operation.processing")
private var detail = ""
private var fraction = 0.0
private var finished = false
- func begin(title: String, detail: String) {
+ func begin(title: String, detail: String, onCancel: (() -> Void)? = nil) {
scheduledShow?.cancel()
self.title = title
self.detail = detail
+ cancelHandler = onCancel
fraction = 0
finished = false
+ cancelButton?.isEnabled = true
updateVisibleControls()
let workItem = DispatchWorkItem { [weak self] in
@@ -762,6 +1146,7 @@ final class ServiceProgressHUD {
func finish() {
finished = true
+ cancelHandler = nil
scheduledShow?.cancel()
scheduledShow = nil
fraction = 1
@@ -862,13 +1247,23 @@ final class ServiceProgressHUD {
percentField.textColor = .secondaryLabelColor
percentField.alignment = .right
+ let cancelButton = NSButton(
+ image: NSImage(systemSymbolName: "xmark", accessibilityDescription: L10n.tr("button.cancelOperation")) ?? NSImage(),
+ target: self,
+ action: #selector(cancelOperation(_:))
+ )
+ cancelButton.bezelStyle = .circular
+ cancelButton.controlSize = .small
+ cancelButton.toolTip = L10n.tr("button.cancelOperation")
+ cancelButton.isHidden = cancelHandler == nil
+
let textStack = NSStackView(views: [titleField, detailField])
textStack.orientation = .vertical
textStack.spacing = 2
textStack.alignment = .leading
textStack.translatesAutoresizingMaskIntoConstraints = false
- let bottomStack = NSStackView(views: [progressIndicator, percentField])
+ let bottomStack = NSStackView(views: [progressIndicator, percentField, cancelButton])
bottomStack.orientation = .horizontal
bottomStack.spacing = 10
bottomStack.alignment = .centerY
@@ -886,7 +1281,7 @@ final class ServiceProgressHUD {
bottomStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
bottomStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
bottomStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18),
- progressIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
+ progressIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 190),
percentField.widthAnchor.constraint(equalToConstant: 42)
])
@@ -894,6 +1289,7 @@ final class ServiceProgressHUD {
self.detailField = detailField
self.percentField = percentField
self.progressIndicator = progressIndicator
+ self.cancelButton = cancelButton
position(panel)
return panel
}
@@ -926,13 +1322,13 @@ final class ServiceProgressHUD {
}
}
- private func animate(_ panel: NSPanel, alpha: CGFloat, duration: TimeInterval, completion: (() -> Void)? = nil) {
+ private func animate(_ panel: NSPanel, alpha: CGFloat, duration: TimeInterval, completion: (@MainActor @Sendable () -> Void)? = nil) {
NSAnimationContext.runAnimationGroup { context in
context.duration = duration
context.timingFunction = CAMediaTimingFunction(name: alpha > panel.alphaValue ? .easeOut : .easeIn)
panel.animator().alphaValue = alpha
} completionHandler: {
- completion?()
+ Task { @MainActor in completion?() }
}
}
@@ -945,14 +1341,24 @@ final class ServiceProgressHUD {
detailField?.stringValue = detail
progressIndicator?.doubleValue = fraction
percentField?.stringValue = percentText
+ cancelButton?.isHidden = cancelHandler == nil
+ }
+
+ @objc private func cancelOperation(_ sender: NSButton) {
+ sender.isEnabled = false
+ title = L10n.tr("status.cancelling")
+ updateVisibleControls()
+ cancelHandler?()
}
}
+@MainActor
struct ArchiveEntriesTable: NSViewRepresentable {
let entries: [ArchiveEntry]
let contentRevision: Int
let filter: String
+ @MainActor
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
var entries: [ArchiveEntry]
var contentRevision: Int
@@ -1022,7 +1428,10 @@ struct ArchiveEntriesTable: NSViewRepresentable {
])
}
- imageView.image = NSImage(systemSymbolName: entry.isDirectory ? "folder" : "doc", accessibilityDescription: nil)
+ imageView.image = NSImage(
+ systemSymbolName: entry.isDirectory ? "folder" : "doc",
+ accessibilityDescription: L10n.tr(entry.isDirectory ? "item.type.folder" : "item.type.file")
+ )
imageView.contentTintColor = .secondaryLabelColor
textField.stringValue = entry.path
textField.alignment = .left
@@ -1114,11 +1523,13 @@ struct ArchiveEntriesTable: NSViewRepresentable {
}
}
+@MainActor
struct SelectedItemsTable: NSViewRepresentable {
let items: [SelectedItem]
let contentRevision: Int
@Binding var selectedIDs: Set
+ @MainActor
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate {
var items: [SelectedItem]
var contentRevision: Int
@@ -1225,7 +1636,10 @@ struct SelectedItemsTable: NSViewRepresentable {
])
}
- imageView.image = NSImage(systemSymbolName: item.isDirectory ? "folder" : "doc", accessibilityDescription: nil)
+ imageView.image = NSImage(
+ systemSymbolName: item.isDirectory ? "folder" : "doc",
+ accessibilityDescription: L10n.tr(item.isDirectory ? "item.type.folder" : "item.type.file")
+ )
imageView.contentTintColor = .secondaryLabelColor
textField.stringValue = item.name
textField.alignment = .left
@@ -1344,8 +1758,17 @@ struct ContentView: View {
.background(SystemContentBackground().ignoresSafeArea())
.animation(contentAnimation, value: contentIdentity)
.animation(dropAnimation, value: dropIsTargeted)
- .onDrop(of: [.fileURL], isTargeted: $dropIsTargeted, perform: handleDrop)
+ .dropDestination(for: URL.self) { urls, _ in
+ guard !urls.isEmpty else { return false }
+ state.handle(urls: urls)
+ return true
+ } isTargeted: { isTargeted in
+ dropIsTargeted = isTargeted
+ }
.sheet(isPresented: $state.showingCompressSheet) { CompressSheet().environmentObject(state) }
+ .sheet(item: $state.passwordPrompt) { prompt in
+ PasswordSheet(prompt: prompt).environmentObject(state)
+ }
}
private var contentIdentity: String {
@@ -1441,16 +1864,28 @@ struct ContentView: View {
.foregroundStyle(.secondary)
Spacer()
if let progress = state.operationProgress {
- ProgressView(value: progress.fraction ?? 0, total: 1)
- .progressViewStyle(.linear)
- .frame(width: 180)
- .accessibilityLabel(progress.title)
- .accessibilityValue(progress.percentText)
- Text(progress.percentText)
- .font(.caption.monospacedDigit())
- .foregroundStyle(.secondary)
- .frame(width: 42, alignment: .trailing)
- .contentTransition(.numericText())
+ if let fraction = progress.fraction {
+ ProgressView(value: fraction, total: 1)
+ .progressViewStyle(.linear)
+ .frame(width: 180)
+ .accessibilityLabel(progress.title)
+ .accessibilityValue(progress.percentText)
+ Text(progress.percentText)
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.secondary)
+ .frame(width: 42, alignment: .trailing)
+ .contentTransition(.numericText())
+ } else {
+ ProgressView()
+ .controlSize(.small)
+ .accessibilityLabel(progress.title)
+ }
+ Button { state.cancelCurrentOperation() } label: {
+ Image(systemName: "xmark.circle.fill")
+ }
+ .buttonStyle(.borderless)
+ .help(L10n.tr("button.cancelOperation"))
+ .accessibilityLabel(L10n.tr("button.cancelOperation"))
}
}
.padding(.horizontal, 12)
@@ -1458,22 +1893,44 @@ struct ContentView: View {
.background(.bar)
}
- private func handleDrop(_ providers: [NSItemProvider]) -> Bool {
- let lock = NSLock()
- var urls = [URL?](repeating: nil, count: providers.count)
- let group = DispatchGroup()
- for (index, provider) in providers.enumerated() {
- group.enter()
- provider.loadDataRepresentation(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
- defer { group.leave() }
- guard let data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
- lock.lock()
- urls[index] = url
- lock.unlock()
+}
+
+struct PasswordSheet: View {
+ @EnvironmentObject private var state: AppState
+ let prompt: PasswordPrompt
+ @State private var password = ""
+ @FocusState private var passwordFocused: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ Text(L10n.tr("password.title"))
+ .font(.title2.weight(.semibold))
+ Text(L10n.tr(prompt.isRetry ? "password.incorrect" : "password.message", prompt.archiveName))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ SecureField(L10n.tr("password.placeholder"), text: $password)
+ .textFieldStyle(.roundedBorder)
+ .focused($passwordFocused)
+ .onSubmit(submit)
+ HStack {
+ Spacer()
+ Button(L10n.tr("button.cancel")) { state.cancelPasswordPrompt() }
+ .keyboardShortcut(.cancelAction)
+ Button(L10n.tr("password.unlock"), action: submit)
+ .buttonStyle(.borderedProminent)
+ .keyboardShortcut(.defaultAction)
+ .disabled(password.isEmpty)
}
}
- group.notify(queue: .main) { state.handle(urls: urls.compactMap { $0 }) }
- return true
+ .padding(22)
+ .frame(width: 420)
+ .interactiveDismissDisabled()
+ .onAppear { passwordFocused = true }
+ }
+
+ private func submit() {
+ guard !password.isEmpty else { return }
+ state.submitPassword(password)
}
}
@@ -1494,9 +1951,18 @@ struct CompressSheet: View {
ForEach(SplitPreset.all) { preset in Text(preset.title).tag(preset) }
}
if state.splitPreset.id == "custom" {
- HStack {
- TextField(L10n.tr("settings.sizePlaceholder"), text: $state.customSplitMB).textFieldStyle(.roundedBorder).frame(width: 90)
- Text("MB").foregroundStyle(.secondary)
+ VStack(alignment: .leading, spacing: 5) {
+ HStack {
+ TextField(L10n.tr("settings.sizePlaceholder"), text: $state.customSplitMB)
+ .textFieldStyle(.roundedBorder)
+ .frame(width: 90)
+ Text("MB").foregroundStyle(.secondary)
+ }
+ if !state.isSplitConfigurationValid {
+ Text(L10n.tr("settings.invalidSplitSize"))
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
}
.transition(reduceMotion ? .opacity : .opacity.combined(with: .move(edge: .top)))
}
@@ -1507,7 +1973,9 @@ struct CompressSheet: View {
HStack {
Spacer()
Button(L10n.tr("button.cancel")) { dismiss() }
- Button { state.compressSelected() } label: { Label(L10n.tr("button.startCompress"), systemImage: "archivebox") }.buttonStyle(.borderedProminent)
+ Button { state.compressSelected() } label: { Label(L10n.tr("button.startCompress"), systemImage: "archivebox") }
+ .buttonStyle(.borderedProminent)
+ .disabled(!state.isSplitConfigurationValid || state.isBusy)
}
}
.padding(22)
@@ -1516,7 +1984,7 @@ struct CompressSheet: View {
}
@MainActor
-final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSToolbarDelegate, UNUserNotificationCenterDelegate, NSSearchFieldDelegate {
+final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSToolbarDelegate, UNUserNotificationCenterDelegate, NSSearchFieldDelegate, NSMenuItemValidation {
private var window: NSWindow?
private var serviceInvoked = false
private var openedFromFile = false
@@ -1524,6 +1992,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
private var stateObserver: AnyCancellable?
private var searchExpanded = false
private let serviceProgressHUD = ServiceProgressHUD()
+ private var serviceCancellation: OperationCancellation?
private let chooseArchiveItemID = NSToolbarItem.Identifier("local.codex.cleanzip.chooseArchive")
private let chooseItemsItemID = NSToolbarItem.Identifier("local.codex.cleanzip.chooseItems")
private let addItemsItemID = NSToolbarItem.Identifier("local.codex.cleanzip.addItems")
@@ -1535,6 +2004,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
private let compactSearchItemID = NSToolbarItem.Identifier("local.codex.cleanzip.compactSearch")
private let searchItemID = NSToolbarItem.Identifier("local.codex.cleanzip.search")
+ func applicationWillFinishLaunching(_ notification: Notification) {
+ configureMainMenu()
+ }
+
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.servicesProvider = self
NSUpdateDynamicServices()
@@ -1549,7 +2022,158 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
}
}
- func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { [.banner, .sound] }
+ func configureMainMenu() {
+ let appName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) ?? "CleanZip"
+ let mainMenu = NSMenu(title: appName)
+
+ let appMenu = NSMenu(title: appName)
+ appMenu.addItem(menuItem(
+ title: L10n.tr("menu.about", appName),
+ action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
+ target: NSApp
+ ))
+ appMenu.addItem(.separator())
+ let servicesMenu = NSMenu(title: L10n.tr("menu.services"))
+ let servicesItem = menuItem(title: L10n.tr("menu.services"), action: nil)
+ servicesItem.submenu = servicesMenu
+ appMenu.addItem(servicesItem)
+ NSApp.servicesMenu = servicesMenu
+ appMenu.addItem(.separator())
+ appMenu.addItem(menuItem(
+ title: L10n.tr("menu.hide", appName),
+ action: #selector(NSApplication.hide(_:)),
+ keyEquivalent: "h",
+ target: NSApp
+ ))
+ appMenu.addItem(menuItem(
+ title: L10n.tr("menu.hideOthers"),
+ action: #selector(NSApplication.hideOtherApplications(_:)),
+ keyEquivalent: "h",
+ modifiers: [.command, .option],
+ target: NSApp
+ ))
+ appMenu.addItem(menuItem(
+ title: L10n.tr("menu.showAll"),
+ action: #selector(NSApplication.unhideAllApplications(_:)),
+ target: NSApp
+ ))
+ appMenu.addItem(.separator())
+ appMenu.addItem(menuItem(
+ title: L10n.tr("menu.quit", appName),
+ action: #selector(NSApplication.terminate(_:)),
+ keyEquivalent: "q",
+ target: NSApp
+ ))
+ mainMenu.addItem(topLevelItem(title: appName, submenu: appMenu))
+
+ let fileMenu = NSMenu(title: L10n.tr("menu.file"))
+ fileMenu.addItem(menuItem(
+ title: L10n.tr("toolbar.chooseArchive") + "\u{2026}",
+ action: #selector(openArchiveFromToolbar(_:)),
+ keyEquivalent: "o",
+ target: self
+ ))
+ fileMenu.addItem(menuItem(
+ title: L10n.tr("toolbar.chooseItems") + "\u{2026}",
+ action: #selector(openItemsFromToolbar(_:)),
+ keyEquivalent: "o",
+ modifiers: [.command, .shift],
+ target: self
+ ))
+ fileMenu.addItem(menuItem(
+ title: L10n.tr("toolbar.add") + "\u{2026}",
+ action: #selector(addItemsFromToolbar(_:)),
+ keyEquivalent: "o",
+ modifiers: [.command, .option],
+ target: self
+ ))
+ fileMenu.addItem(.separator())
+ fileMenu.addItem(menuItem(title: L10n.tr("toolbar.test"), action: #selector(testArchiveFromToolbar(_:)), target: self))
+ fileMenu.addItem(menuItem(title: L10n.tr("toolbar.extract"), action: #selector(extractArchiveFromToolbar(_:)), target: self))
+ fileMenu.addItem(menuItem(title: L10n.tr("toolbar.compressSettings") + "\u{2026}", action: #selector(compressSettingsFromToolbar(_:)), target: self))
+ fileMenu.addItem(.separator())
+ fileMenu.addItem(menuItem(
+ title: L10n.tr("menu.close"),
+ action: #selector(NSWindow.performClose(_:)),
+ keyEquivalent: "w"
+ ))
+ mainMenu.addItem(topLevelItem(title: L10n.tr("menu.file"), submenu: fileMenu))
+
+ let editMenu = NSMenu(title: L10n.tr("menu.edit"))
+ editMenu.addItem(menuItem(title: L10n.tr("menu.undo"), action: Selector(("undo:")), keyEquivalent: "z"))
+ editMenu.addItem(menuItem(title: L10n.tr("menu.redo"), action: Selector(("redo:")), keyEquivalent: "z", modifiers: [.command, .shift]))
+ editMenu.addItem(.separator())
+ editMenu.addItem(menuItem(title: L10n.tr("menu.cut"), action: #selector(NSText.cut(_:)), keyEquivalent: "x"))
+ editMenu.addItem(menuItem(title: L10n.tr("menu.copy"), action: #selector(NSText.copy(_:)), keyEquivalent: "c"))
+ editMenu.addItem(menuItem(title: L10n.tr("menu.paste"), action: #selector(NSText.paste(_:)), keyEquivalent: "v"))
+ editMenu.addItem(.separator())
+ editMenu.addItem(menuItem(
+ title: L10n.tr("toolbar.remove"),
+ action: #selector(removeItemsFromToolbar(_:)),
+ keyEquivalent: "\u{8}",
+ modifiers: [],
+ target: self
+ ))
+ editMenu.addItem(menuItem(title: L10n.tr("toolbar.clear"), action: #selector(clearItemsFromToolbar(_:)), target: self))
+ editMenu.addItem(.separator())
+ editMenu.addItem(menuItem(title: L10n.tr("menu.selectAll"), action: #selector(NSText.selectAll(_:)), keyEquivalent: "a"))
+ mainMenu.addItem(topLevelItem(title: L10n.tr("menu.edit"), submenu: editMenu))
+
+ let viewMenu = NSMenu(title: L10n.tr("menu.view"))
+ viewMenu.addItem(menuItem(
+ title: L10n.tr("toolbar.search"),
+ action: #selector(beginSearchFromToolbar(_:)),
+ keyEquivalent: "f",
+ target: self
+ ))
+ viewMenu.addItem(.separator())
+ viewMenu.addItem(menuItem(
+ title: L10n.tr("menu.enterFullScreen"),
+ action: #selector(NSWindow.toggleFullScreen(_:)),
+ keyEquivalent: "f",
+ modifiers: [.command, .control]
+ ))
+ mainMenu.addItem(topLevelItem(title: L10n.tr("menu.view"), submenu: viewMenu))
+
+ let windowMenu = NSMenu(title: L10n.tr("menu.window"))
+ windowMenu.addItem(menuItem(
+ title: L10n.tr("menu.minimize"),
+ action: #selector(NSWindow.performMiniaturize(_:)),
+ keyEquivalent: "m"
+ ))
+ windowMenu.addItem(menuItem(title: L10n.tr("menu.zoom"), action: #selector(NSWindow.performZoom(_:))))
+ windowMenu.addItem(.separator())
+ windowMenu.addItem(menuItem(title: L10n.tr("menu.bringAllToFront"), action: #selector(NSApplication.arrangeInFront(_:))))
+ mainMenu.addItem(topLevelItem(title: L10n.tr("menu.window"), submenu: windowMenu))
+ NSApp.windowsMenu = windowMenu
+
+ let helpMenu = NSMenu(title: L10n.tr("menu.help"))
+ helpMenu.addItem(menuItem(title: L10n.tr("menu.helpItem", appName), action: #selector(showHelp(_:)), target: self))
+ mainMenu.addItem(topLevelItem(title: L10n.tr("menu.help"), submenu: helpMenu))
+
+ NSApp.mainMenu = mainMenu
+ }
+
+ private func topLevelItem(title: String, submenu: NSMenu) -> NSMenuItem {
+ let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
+ item.submenu = submenu
+ return item
+ }
+
+ private func menuItem(
+ title: String,
+ action: Selector?,
+ keyEquivalent: String = "",
+ modifiers: NSEvent.ModifierFlags = [.command],
+ target: AnyObject? = nil
+ ) -> NSMenuItem {
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
+ item.target = target
+ if !keyEquivalent.isEmpty { item.keyEquivalentModifierMask = modifiers }
+ return item
+ }
+
+ nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { [.banner, .sound] }
func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool {
openedFromUntitledLaunch = true
NSApp.setActivationPolicy(.regular)
@@ -1579,7 +2203,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
let operationDetail = shouldExtract
? (urls.count == 1 ? urls[0].lastPathComponent : L10n.archiveCount(urls.count))
: (urls.count == 1 ? urls[0].lastPathComponent : L10n.itemCount(urls.count))
- serviceProgressHUD.begin(title: operationTitle, detail: operationDetail)
+ AppState.prepareNotificationsForOperation()
+ let cancellation = OperationCancellation()
+ serviceCancellation = cancellation
+ serviceProgressHUD.begin(title: operationTitle, detail: operationDetail) { cancellation.cancel() }
DispatchQueue.global(qos: .userInitiated).async {
do {
if shouldExtract {
@@ -1587,31 +2214,56 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
for (index, url) in urls.enumerated() {
let base = Double(index) / Double(urls.count)
let scale = 1 / Double(urls.count)
- outputs.append(try ArchiveEngine.shared.extract(archive: url) { fraction in
- DispatchQueue.main.async {
- self.serviceProgressHUD.update(fraction: base + fraction * scale, detail: url.lastPathComponent)
+ do {
+ outputs.append(try ArchiveEngine.shared.extract(archive: url, cancellation: cancellation) { fraction in
+ DispatchQueue.main.async {
+ self.serviceProgressHUD.update(fraction: base + fraction * scale, detail: url.lastPathComponent)
+ }
+ })
+ } catch let archiveError as ArchiveError {
+ switch archiveError {
+ case .passwordRequired, .wrongPassword:
+ throw ServiceHandoffError.passwordRequired(url)
+ default:
+ throw archiveError
}
- })
+ }
}
DispatchQueue.main.async {
+ self.serviceCancellation = nil
self.serviceProgressHUD.finish()
let message = outputs.count == 1 ? L10n.tr("notification.extractedTo", outputs[0].lastPathComponent) : L10n.tr("notification.extractedArchives", L10n.archiveCount(outputs.count))
AppState.notify(title: "CleanZip", message: message) { self.terminateIfServiceOnly() }
}
} else {
- let output = try ArchiveEngine.shared.compress(urls: urls, format: .zip, splitSpec: nil) { fraction in
+ let output = try ArchiveEngine.shared.compress(urls: urls, format: .zip, splitSpec: nil, cancellation: cancellation) { fraction in
DispatchQueue.main.async {
self.serviceProgressHUD.update(fraction: fraction)
}
}
DispatchQueue.main.async {
+ self.serviceCancellation = nil
self.serviceProgressHUD.finish()
let message = L10n.tr("notification.created", output.lastPathComponent)
AppState.notify(title: "CleanZip", message: message) { self.terminateIfServiceOnly() }
}
}
+ } catch ServiceHandoffError.passwordRequired(let url) {
+ DispatchQueue.main.async {
+ self.serviceCancellation = nil
+ self.serviceProgressHUD.finish()
+ self.showWindow()
+ AppState.shared.handle(urls: [url])
+ }
+ } catch ArchiveError.cancelled {
+ DispatchQueue.main.async {
+ self.serviceCancellation = nil
+ self.serviceProgressHUD.finish()
+ AppState.notify(title: "CleanZip", message: L10n.tr("status.cancelled")) { self.terminateIfServiceOnly() }
+ }
} catch {
DispatchQueue.main.async {
+ self.serviceCancellation = nil
self.serviceProgressHUD.finish()
AppState.notify(title: L10n.tr("notification.operationFailedTitle"), message: error.localizedDescription) {
self.terminateIfServiceOnly()
@@ -1836,6 +2488,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
@objc private func testArchiveFromToolbar(_ sender: Any?) { AppState.shared.testCurrentArchive(); refreshToolbar() }
@objc private func extractArchiveFromToolbar(_ sender: Any?) { AppState.shared.extractCurrentArchive(); refreshToolbar() }
@objc private func searchFromToolbar(_ sender: NSSearchField) { AppState.shared.searchText = sender.stringValue; refreshToolbar() }
+ @objc private func showHelp(_ sender: Any?) {
+ guard let url = URL(string: "https://lyc280705.github.io/CleanZip/") else { return }
+ NSWorkspace.shared.open(url)
+ }
@objc private func beginSearchFromToolbar(_ sender: Any?) {
searchExpanded = true
refreshToolbar()
@@ -1869,6 +2525,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
}
}
+ func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
+ guard let action = menuItem.action else { return true }
+ let state = AppState.shared
+ switch action {
+ case #selector(openArchiveFromToolbar(_:)), #selector(openItemsFromToolbar(_:)):
+ return !state.isBusy
+ case #selector(addItemsFromToolbar(_:)), #selector(clearItemsFromToolbar(_:)), #selector(compressSettingsFromToolbar(_:)):
+ return !state.selectedURLs.isEmpty && !state.isBusy
+ case #selector(removeItemsFromToolbar(_:)):
+ return !state.selectedItemIDs.isEmpty && !state.isBusy
+ case #selector(testArchiveFromToolbar(_:)), #selector(extractArchiveFromToolbar(_:)), #selector(beginSearchFromToolbar(_:)):
+ return state.archiveURL != nil && !state.isBusy
+ default:
+ return true
+ }
+ }
+
private func toolbarItem(identifier: NSToolbarItem.Identifier, label: String, symbol: String, tooltip: String, action: Selector) -> NSToolbarItem {
let item = NSToolbarItem(itemIdentifier: identifier)
item.label = label
@@ -1933,7 +2606,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate, NSTo
private func configureNotifications() {
let center = UNUserNotificationCenter.current()
center.delegate = self
- center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
}
private func pasteboardURLs(_ pasteboard: NSPasteboard) -> [URL] {
if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty { return urls }
diff --git a/work/CleanZipBuild/src/package.sh b/work/CleanZipBuild/src/package.sh
index 6347ed0..d5026bc 100755
--- a/work/CleanZipBuild/src/package.sh
+++ b/work/CleanZipBuild/src/package.sh
@@ -53,14 +53,21 @@ exit 0
SCRIPT
chmod +x "$SCRIPTS_DIR/postinstall"
-pkgbuild \
- --root "$ROOT_DIR" \
- --component-plist "$COMPONENT_PLIST" \
- --scripts "$SCRIPTS_DIR" \
- --identifier "local.codex.cleanzip.pkg" \
- --version "$PACKAGE_VERSION" \
- --install-location "/" \
- "$DIST/$PKG_NAME"
+pkgbuild_args=(
+ --root "$ROOT_DIR"
+ --component-plist "$COMPONENT_PLIST"
+ --scripts "$SCRIPTS_DIR"
+ --identifier "local.codex.cleanzip.pkg"
+ --version "$PACKAGE_VERSION"
+ --install-location "/"
+)
+if [[ -n "${CLEANZIP_INSTALLER_IDENTITY:-}" ]]; then
+ pkgbuild_args+=(--sign "$CLEANZIP_INSTALLER_IDENTITY")
+ if [[ -n "${CLEANZIP_SIGNING_KEYCHAIN:-}" ]]; then
+ pkgbuild_args+=(--keychain "$CLEANZIP_SIGNING_KEYCHAIN")
+ fi
+fi
+pkgbuild "${pkgbuild_args[@]}" "$DIST/$PKG_NAME"
PACKAGE_VERIFY_DIR="$DIST/pkgverify"
pkgutil --expand "$DIST/$PKG_NAME" "$PACKAGE_VERIFY_DIR"
diff --git a/work/CleanZipBuild/src/service.swift b/work/CleanZipBuild/src/service.swift
index be722ae..e480cc1 100644
--- a/work/CleanZipBuild/src/service.swift
+++ b/work/CleanZipBuild/src/service.swift
@@ -1,5 +1,5 @@
-import AppKit
-import QuartzCore
+@preconcurrency import AppKit
+@preconcurrency import QuartzCore
@preconcurrency import UserNotifications
enum L10n {
@@ -18,13 +18,13 @@ enum L10n {
}
}
-struct ProcessResult {
+struct ProcessResult: Sendable {
let status: Int32
let stdout: String
let stderr: String
}
-private final class DataBuffer {
+private final class DataBuffer: @unchecked Sendable {
private var data = Data()
private let lock = NSLock()
@@ -41,22 +41,68 @@ private final class DataBuffer {
}
}
-enum ArchiveError: Error, LocalizedError {
+final class OperationCancellation: @unchecked Sendable {
+ private let lock = NSLock()
+ private var process: Process?
+ private var cancelled = false
+
+ var isCancelled: Bool {
+ lock.withLock { cancelled }
+ }
+
+ func register(_ process: Process) {
+ let shouldTerminate = lock.withLock {
+ self.process = process
+ return cancelled
+ }
+ if shouldTerminate, process.isRunning { process.terminate() }
+ }
+
+ func unregister(_ process: Process) {
+ lock.withLock {
+ if self.process === process { self.process = nil }
+ }
+ }
+
+ func cancel() {
+ let runningProcess = lock.withLock {
+ cancelled = true
+ return process
+ }
+ if let runningProcess, runningProcess.isRunning { runningProcess.terminate() }
+ }
+
+ func checkCancellation() throws {
+ if isCancelled { throw ArchiveError.cancelled }
+ }
+}
+
+enum ArchiveError: Error, LocalizedError, Sendable {
case missingTool(String)
case failed(String)
+ case passwordRequired
+ case wrongPassword
+ case cancelled
var errorDescription: String? {
switch self {
case .missingTool(let tool): return L10n.tr("error.missingTool", tool)
case .failed(let message): return message
+ case .passwordRequired: return L10n.tr("error.passwordRequired")
+ case .wrongPassword: return L10n.tr("error.wrongPassword")
+ case .cancelled: return L10n.tr("error.cancelled")
}
}
}
-final class ArchiveEngine {
+enum ServiceHandoffError: Error, Sendable {
+ case passwordRequired(URL)
+}
+
+final class ArchiveEngine: @unchecked Sendable {
static let shared = ArchiveEngine()
private static let progressRegex = try! NSRegularExpression(pattern: #"(? Void
+ typealias ProgressHandler = @Sendable (Double) -> Void
private let fileManager = FileManager.default
var sevenZipURL: URL? {
@@ -80,7 +126,7 @@ final class ArchiveEngine {
return false
}
- func compress(urls: [URL], progressHandler: ProgressHandler? = nil) throws -> URL {
+ func compress(urls: [URL], cancellation: OperationCancellation? = nil, progressHandler: ProgressHandler? = nil) throws -> URL {
guard !urls.isEmpty else { throw ArchiveError.failed(L10n.tr("error.noItemsToCompress")) }
let parent = urls[0].deletingLastPathComponent()
guard urls.allSatisfy({ $0.deletingLastPathComponent().standardizedFileURL == parent.standardizedFileURL }) else {
@@ -89,37 +135,48 @@ final class ArchiveEngine {
let baseName = urls.count == 1 ? urls[0].deletingPathExtension().lastPathComponent : "Archive"
let output = uniqueFileURL(in: parent, baseName: baseName, extensionName: "zip")
let itemNames = urls.map { itemNameForProcess($0) }
- try compressWith7z(parent: parent, output: output, itemNames: itemNames, progressHandler: progressHandler)
- return output
+ do {
+ try compressWith7z(parent: parent, output: output, itemNames: itemNames, cancellation: cancellation, progressHandler: progressHandler)
+ return output
+ } catch {
+ removePartialArchive(at: output)
+ throw error
+ }
}
- func extract(archive: URL, progressHandler: ProgressHandler? = nil) throws -> URL {
+ func extract(archive: URL, cancellation: OperationCancellation? = nil, progressHandler: ProgressHandler? = nil) throws -> URL {
let parent = archive.deletingLastPathComponent()
let baseName = archiveBaseName(archive)
let outputDir = uniqueDirectoryURL(in: parent, baseName: baseName)
try fileManager.createDirectory(at: outputDir, withIntermediateDirectories: true)
- return try extractWith7z(archive: archive, outputDir: outputDir, progressHandler: progressHandler)
+ do {
+ return try extractWith7z(archive: archive, outputDir: outputDir, cancellation: cancellation, progressHandler: progressHandler)
+ } catch {
+ try? fileManager.removeItem(at: outputDir)
+ throw error
+ }
}
- private func compressWith7z(parent: URL, output: URL, itemNames: [String], progressHandler: ProgressHandler?) throws {
+ private func compressWith7z(parent: URL, output: URL, itemNames: [String], cancellation: OperationCancellation?, progressHandler: ProgressHandler?) throws {
guard let sevenZipURL else { throw ArchiveError.missingTool("7zz") }
var args = ["a", "-tzip", "-mx=5", "-y", "-bsp1", output.path]
args.append(contentsOf: itemNames)
args.append(contentsOf: ["-xr!.DS_Store", "-xr!__MACOSX", "-xr!._*"])
var env = ProcessInfo.processInfo.environment
env["COPYFILE_DISABLE"] = "1"
- let result = try runProcess(executable: sevenZipURL, arguments: args, currentDirectory: parent, environment: env, progressHandler: progressHandler)
- guard result.status == 0 else { throw ArchiveError.failed(result.stderr.isEmpty ? result.stdout : result.stderr) }
+ let result = try runProcess(executable: sevenZipURL, arguments: args, currentDirectory: parent, environment: env, cancellation: cancellation, progressHandler: progressHandler)
+ guard result.status == 0 else { throw archiveError(for: result) }
}
- private func extractWith7z(archive: URL, outputDir: URL, progressHandler: ProgressHandler?) throws -> URL {
+ private func extractWith7z(archive: URL, outputDir: URL, cancellation: OperationCancellation?, progressHandler: ProgressHandler?) throws -> URL {
guard let sevenZipURL else { throw ArchiveError.missingTool("7zz") }
- let result = try runProcess(executable: sevenZipURL, arguments: ["x", "-y", "-bsp1", "-o\(outputDir.path)", archive.path], progressHandler: progressHandler)
- guard result.status == 0 else { throw ArchiveError.failed(result.stderr.isEmpty ? result.stdout : result.stderr) }
+ let result = try runProcess(executable: sevenZipURL, arguments: ["x", "-y", "-bsp1", "-o\(outputDir.path)", archive.path], cancellation: cancellation, progressHandler: progressHandler)
+ guard result.status == 0 else { throw archiveError(for: result) }
return outputDir
}
- private func runProcess(executable: URL, arguments: [String], currentDirectory: URL? = nil, environment: [String: String]? = nil, progressHandler: ProgressHandler? = nil) throws -> ProcessResult {
+ private func runProcess(executable: URL, arguments: [String], currentDirectory: URL? = nil, environment: [String: String]? = nil, cancellation: OperationCancellation? = nil, progressHandler: ProgressHandler? = nil) throws -> ProcessResult {
+ try cancellation?.checkCancellation()
let process = Process()
process.executableURL = executable
process.arguments = arguments
@@ -129,6 +186,7 @@ final class ArchiveEngine {
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
+ process.standardInput = FileHandle.nullDevice
let stdoutBuffer = DataBuffer()
let stderrBuffer = DataBuffer()
let readers = DispatchGroup()
@@ -143,11 +201,39 @@ final class ArchiveEngine {
readers.leave()
}
try process.run()
+ cancellation?.register(process)
+ defer { cancellation?.unregister(process) }
process.waitUntilExit()
readers.wait()
+ try cancellation?.checkCancellation()
return ProcessResult(status: process.terminationStatus, stdout: stdoutBuffer.string, stderr: stderrBuffer.string)
}
+ private func archiveError(for result: ProcessResult) -> ArchiveError {
+ let message = [result.stdout, result.stderr].filter { !$0.isEmpty }.joined(separator: "\n")
+ let lowercased = message.lowercased()
+ if lowercased.contains("wrong password") { return .wrongPassword }
+ if lowercased.contains("enter password") || lowercased.contains("password is required") {
+ return .passwordRequired
+ }
+ return .failed(message.isEmpty ? L10n.tr("error.unknownArchiveFailure") : message)
+ }
+
+ private func removePartialArchive(at output: URL) {
+ try? fileManager.removeItem(at: output)
+ let directory = output.deletingLastPathComponent()
+ let volumePrefix = output.lastPathComponent + "."
+ guard let candidates = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else { return }
+ for candidate in candidates {
+ let name = candidate.lastPathComponent
+ guard name.hasPrefix(volumePrefix) else { continue }
+ let suffix = name.dropFirst(volumePrefix.count)
+ if suffix.count == 3, suffix.allSatisfy(\.isNumber) {
+ try? fileManager.removeItem(at: candidate)
+ }
+ }
+ }
+
private func readPipe(_ pipe: Pipe, into buffer: DataBuffer, progressHandler: ProgressHandler?) {
while true {
let data = pipe.fileHandleForReading.availableData
@@ -206,24 +292,29 @@ final class ArchiveEngine {
}
}
-final class ServiceProgressHUD {
+@MainActor
+final class ServiceProgressHUD: NSObject {
private var panel: NSPanel?
private var titleField: NSTextField?
private var detailField: NSTextField?
private var percentField: NSTextField?
private var progressIndicator: NSProgressIndicator?
+ private var cancelButton: NSButton?
private var scheduledShow: DispatchWorkItem?
+ private var cancelHandler: (() -> Void)?
private var title = L10n.tr("operation.processing")
private var detail = ""
private var fraction = 0.0
private var finished = false
- func begin(title: String, detail: String) {
+ func begin(title: String, detail: String, onCancel: (() -> Void)? = nil) {
scheduledShow?.cancel()
self.title = title
self.detail = detail
+ cancelHandler = onCancel
fraction = 0
finished = false
+ cancelButton?.isEnabled = true
updateVisibleControls()
let workItem = DispatchWorkItem { [weak self] in self?.showIfNeeded() }
scheduledShow = workItem
@@ -239,6 +330,7 @@ final class ServiceProgressHUD {
func finish() {
finished = true
+ cancelHandler = nil
scheduledShow?.cancel()
scheduledShow = nil
fraction = 1
@@ -332,13 +424,23 @@ final class ServiceProgressHUD {
percentField.textColor = .secondaryLabelColor
percentField.alignment = .right
+ let cancelButton = NSButton(
+ image: NSImage(systemSymbolName: "xmark", accessibilityDescription: L10n.tr("button.cancelOperation")) ?? NSImage(),
+ target: self,
+ action: #selector(cancelOperation(_:))
+ )
+ cancelButton.bezelStyle = .circular
+ cancelButton.controlSize = .small
+ cancelButton.toolTip = L10n.tr("button.cancelOperation")
+ cancelButton.isHidden = cancelHandler == nil
+
let textStack = NSStackView(views: [titleField, detailField])
textStack.orientation = .vertical
textStack.spacing = 2
textStack.alignment = .leading
textStack.translatesAutoresizingMaskIntoConstraints = false
- let bottomStack = NSStackView(views: [progressIndicator, percentField])
+ let bottomStack = NSStackView(views: [progressIndicator, percentField, cancelButton])
bottomStack.orientation = .horizontal
bottomStack.spacing = 10
bottomStack.alignment = .centerY
@@ -355,7 +457,7 @@ final class ServiceProgressHUD {
bottomStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
bottomStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
bottomStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -18),
- progressIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
+ progressIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 190),
percentField.widthAnchor.constraint(equalToConstant: 42)
])
@@ -363,6 +465,7 @@ final class ServiceProgressHUD {
self.detailField = detailField
self.percentField = percentField
self.progressIndicator = progressIndicator
+ self.cancelButton = cancelButton
position(panel)
return panel
}
@@ -393,13 +496,13 @@ final class ServiceProgressHUD {
}
}
- private func animate(_ panel: NSPanel, alpha: CGFloat, duration: TimeInterval, completion: (() -> Void)? = nil) {
+ private func animate(_ panel: NSPanel, alpha: CGFloat, duration: TimeInterval, completion: (@MainActor @Sendable () -> Void)? = nil) {
NSAnimationContext.runAnimationGroup { context in
context.duration = duration
context.timingFunction = CAMediaTimingFunction(name: alpha > panel.alphaValue ? .easeOut : .easeIn)
panel.animator().alphaValue = alpha
} completionHandler: {
- completion?()
+ Task { @MainActor in completion?() }
}
}
@@ -408,21 +511,29 @@ final class ServiceProgressHUD {
detailField?.stringValue = detail
progressIndicator?.doubleValue = fraction
percentField?.stringValue = percentText
+ cancelButton?.isHidden = cancelHandler == nil
+ }
+
+ @objc private func cancelOperation(_ sender: NSButton) {
+ sender.isEnabled = false
+ title = L10n.tr("status.cancelling")
+ updateVisibleControls()
+ cancelHandler?()
}
}
@MainActor
final class ServiceDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
private let hud = ServiceProgressHUD()
+ private var cancellation: OperationCancellation?
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.servicesProvider = self
NSUpdateDynamicServices()
UNUserNotificationCenter.current().delegate = self
- UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
}
- func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
+ nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
[.banner, .sound]
}
@@ -438,7 +549,10 @@ final class ServiceDelegate: NSObject, NSApplicationDelegate, UNUserNotification
let shouldExtract = urls.allSatisfy { ArchiveEngine.shared.isArchive($0) }
let title = shouldExtract ? L10n.tr("operation.extracting") : L10n.tr("operation.compressing")
let detail = urls.count == 1 ? urls[0].lastPathComponent : (shouldExtract ? L10n.archiveCount(urls.count) : L10n.itemCount(urls.count))
- hud.begin(title: title, detail: detail)
+ prepareNotificationsForOperation()
+ let cancellation = OperationCancellation()
+ self.cancellation = cancellation
+ hud.begin(title: title, detail: detail) { cancellation.cancel() }
DispatchQueue.global(qos: .userInitiated).async {
do {
@@ -447,20 +561,33 @@ final class ServiceDelegate: NSObject, NSApplicationDelegate, UNUserNotification
for (index, url) in urls.enumerated() {
let base = Double(index) / Double(urls.count)
let scale = 1 / Double(urls.count)
- outputs.append(try ArchiveEngine.shared.extract(archive: url) { fraction in
- DispatchQueue.main.async {
- self.hud.update(fraction: base + fraction * scale, detail: url.lastPathComponent)
+ do {
+ outputs.append(try ArchiveEngine.shared.extract(archive: url, cancellation: cancellation) { fraction in
+ DispatchQueue.main.async {
+ self.hud.update(fraction: base + fraction * scale, detail: url.lastPathComponent)
+ }
+ })
+ } catch let archiveError as ArchiveError {
+ switch archiveError {
+ case .passwordRequired, .wrongPassword:
+ throw ServiceHandoffError.passwordRequired(url)
+ default:
+ throw archiveError
}
- })
+ }
}
let message = outputs.count == 1 ? L10n.tr("notification.extractedTo", outputs[0].lastPathComponent) : L10n.tr("notification.extractedArchives", L10n.archiveCount(outputs.count))
DispatchQueue.main.async { self.finish(message: message) }
} else {
- let output = try ArchiveEngine.shared.compress(urls: urls) { fraction in
+ let output = try ArchiveEngine.shared.compress(urls: urls, cancellation: cancellation) { fraction in
DispatchQueue.main.async { self.hud.update(fraction: fraction) }
}
DispatchQueue.main.async { self.finish(message: L10n.tr("notification.created", output.lastPathComponent)) }
}
+ } catch ServiceHandoffError.passwordRequired(let url) {
+ DispatchQueue.main.async { self.handoffPasswordArchive(url) }
+ } catch ArchiveError.cancelled {
+ DispatchQueue.main.async { self.finish(message: L10n.tr("status.cancelled")) }
} catch {
DispatchQueue.main.async { self.finish(title: L10n.tr("notification.operationFailedTitle"), message: error.localizedDescription) }
}
@@ -468,20 +595,66 @@ final class ServiceDelegate: NSObject, NSApplicationDelegate, UNUserNotification
}
private func finish(title: String = "CleanZip", message: String) {
+ cancellation = nil
hud.finish()
notify(title: title, message: message) {
NSApp.terminate(nil)
}
}
- private func notify(title: String, message: String, completion: @escaping () -> Void) {
- let content = UNMutableNotificationContent()
- content.title = title
- content.body = message
- content.sound = .default
- let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
- UNUserNotificationCenter.current().add(request) { _ in
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: completion)
+ private func handoffPasswordArchive(_ url: URL) {
+ cancellation = nil
+ hud.finish()
+ guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "local.codex.cleanzip") else {
+ finish(title: L10n.tr("notification.operationFailedTitle"), message: L10n.tr("error.mainAppUnavailable"))
+ return
+ }
+ let configuration = NSWorkspace.OpenConfiguration()
+ configuration.activates = true
+ NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: configuration) { _, launchError in
+ DispatchQueue.main.async {
+ if let launchError {
+ self.finish(title: L10n.tr("notification.operationFailedTitle"), message: launchError.localizedDescription)
+ } else {
+ NSApp.terminate(nil)
+ }
+ }
+ }
+ }
+
+ private func notify(title: String, message: String, completion: @escaping @MainActor @Sendable () -> Void) {
+ let center = UNUserNotificationCenter.current()
+ center.getNotificationSettings { settings in
+ let deliver: @Sendable () -> Void = {
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = message
+ content.sound = .default
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
+ center.add(request) { _ in
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { completion() }
+ }
+ }
+ switch settings.authorizationStatus {
+ case .authorized, .provisional, .ephemeral:
+ deliver()
+ case .notDetermined:
+ center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
+ granted ? deliver() : DispatchQueue.main.async { completion() }
+ }
+ case .denied:
+ DispatchQueue.main.async { completion() }
+ @unknown default:
+ DispatchQueue.main.async { completion() }
+ }
+ }
+ }
+
+ private func prepareNotificationsForOperation() {
+ let center = UNUserNotificationCenter.current()
+ center.getNotificationSettings { settings in
+ guard settings.authorizationStatus == .notDetermined else { return }
+ center.requestAuthorization(options: [.alert, .sound]) { _, _ in }
}
}
diff --git a/work/CleanZipBuild/tests/TableBehaviorTests.swift b/work/CleanZipBuild/tests/TableBehaviorTests.swift
index ee60c0b..fc315cf 100644
--- a/work/CleanZipBuild/tests/TableBehaviorTests.swift
+++ b/work/CleanZipBuild/tests/TableBehaviorTests.swift
@@ -8,14 +8,19 @@ struct TableBehaviorTests {
static func main() {
_ = NSApplication.shared
+ testMainMenuConfiguration()
testNativeTableConfiguration()
testArchiveColumnReorderingPolicy()
testSelectedItemsColumnReorderingPolicy()
testNativeColumnSizing()
testSelectedItemMetadataSnapshot()
testProgressPublishingIsDeduplicated()
+ testStaleOperationCallbacksAreIgnored()
testArchiveDerivedStateIsCached()
+ testSplitSizeValidation()
testArchiveEngineRoundTrips()
+ testEncryptedArchivePasswordFlow()
+ testPreCancelledOperationDoesNotCreateOutput()
if failures.isEmpty {
print("PASS: CleanZip table behavior tests")
@@ -26,6 +31,24 @@ struct TableBehaviorTests {
exit(EXIT_FAILURE)
}
+ private static func testMainMenuConfiguration() {
+ let delegate = AppDelegate()
+ delegate.configureMainMenu()
+
+ guard let mainMenu = NSApp.mainMenu else {
+ failures.append("the app should install a standard main menu")
+ return
+ }
+ expect(mainMenu.items.count == 6, "the main menu should include app, File, Edit, View, Window, and Help menus")
+ expect(NSApp.servicesMenu != nil, "the app menu should register a Services submenu")
+ expect(NSApp.windowsMenu != nil, "the Window menu should be registered with NSApplication")
+
+ let fileMenu = mainMenu.items.dropFirst().first?.submenu
+ let openItem = fileMenu?.items.first
+ expect(openItem?.keyEquivalent == "o", "Open Archive should use the standard Command-O shortcut")
+ expect(openItem?.keyEquivalentModifierMask == [.command], "Open Archive should use Command-O without extra modifiers")
+ }
+
private static func testNativeTableConfiguration() {
let table = NSTableView()
FinderTableBehavior.configure(
@@ -144,6 +167,19 @@ struct TableBehaviorTests {
expect(state.operationProgress?.fraction == 0.006, "progress within the same displayed percent should be deduplicated")
}
+ private static func testStaleOperationCallbacksAreIgnored() {
+ let state = AppState()
+ let staleOperation = state.beginOperation(title: "Old", detail: "")
+ let activeOperation = state.beginOperation(title: "New", detail: "")
+
+ state.updateOperationProgress(0.75, for: staleOperation)
+ expect(state.operationProgress?.title == "New", "a stale progress callback must not replace the active operation")
+ expect(state.operationProgress?.fraction == 0, "a stale progress callback must not advance the active operation")
+ expect(!state.finishOperation(status: "Old finished", for: staleOperation), "a stale completion must be rejected")
+ expect(state.isBusy, "a stale completion must not clear the active busy state")
+ expect(state.finishOperation(status: "New finished", for: activeOperation), "the active completion should be accepted")
+ }
+
private static func testArchiveDerivedStateIsCached() {
let state = AppState()
state.entries = [
@@ -162,6 +198,25 @@ struct TableBehaviorTests {
expect(state.filteredEntries.map(\.path) == ["Folder/keep.txt"], "progress updates must not rebuild archive search results")
}
+ private static func testSplitSizeValidation() {
+ let state = AppState()
+ guard let customPreset = SplitPreset.all.first(where: { $0.id == "custom" }) else {
+ failures.append("the custom split preset should exist")
+ return
+ }
+ state.splitPreset = customPreset
+
+ state.customSplitMB = "100"
+ expect(state.isSplitConfigurationValid, "a positive whole-number split size should be valid")
+ expect(state.resolvedSplitSpec() == "100m", "a valid custom split size should resolve to a 7-Zip volume spec")
+
+ for invalidValue in ["", "0", "-1", "1.5", "letters", "1048577"] {
+ state.customSplitMB = invalidValue
+ expect(!state.isSplitConfigurationValid, "custom split size '\(invalidValue)' should be rejected")
+ expect(state.resolvedSplitSpec() == nil, "an invalid custom split size must not silently create an unsplit archive")
+ }
+ }
+
private static func testArchiveEngineRoundTrips() {
let root = FileManager.default.temporaryDirectory.appendingPathComponent("cleanzip-engine-tests-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: root) }
@@ -202,6 +257,88 @@ struct TableBehaviorTests {
expect(entries.allSatisfy { isCleanArchivePath($0.path) }, "split \(format.rawValue) should exclude macOS metadata")
}
+ private static func testEncryptedArchivePasswordFlow() {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("cleanzip-password-tests-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ do {
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let source = root.appendingPathComponent("secret.txt")
+ let archive = root.appendingPathComponent("encrypted.7z")
+ try Data("confidential".utf8).write(to: source)
+ guard let sevenZip = ArchiveEngine.shared.sevenZipURL else {
+ failures.append("7zz should be available for encrypted archive tests")
+ return
+ }
+ try runTool(sevenZip, arguments: ["a", "-t7z", "-pcorrect-password", "-mhe=on", archive.path, source.lastPathComponent], currentDirectory: root)
+
+ do {
+ _ = try ArchiveEngine.shared.listArchive(archive)
+ failures.append("a header-encrypted archive should request a password")
+ } catch ArchiveError.passwordRequired {
+ // Expected.
+ } catch {
+ failures.append("a missing password should be classified, got: \(error.localizedDescription)")
+ }
+
+ do {
+ _ = try ArchiveEngine.shared.listArchive(archive, password: "wrong-password")
+ failures.append("an incorrect archive password should fail")
+ } catch ArchiveError.wrongPassword {
+ // Expected.
+ } catch {
+ failures.append("an incorrect password should be classified, got: \(error.localizedDescription)")
+ }
+
+ let entries = try ArchiveEngine.shared.listArchive(archive, password: "correct-password")
+ expect(entries.contains { $0.path == "secret.txt" }, "the correct password should reveal encrypted archive entries")
+ try ArchiveEngine.shared.testArchive(archive, password: "correct-password")
+ let extracted = try ArchiveEngine.shared.extract(archive: archive, password: "correct-password")
+ expect(FileManager.default.fileExists(atPath: extracted.appendingPathComponent("secret.txt").path), "the correct password should extract the encrypted archive")
+ } catch {
+ failures.append("encrypted archive flow failed: \(error.localizedDescription)")
+ }
+ }
+
+ private static func testPreCancelledOperationDoesNotCreateOutput() {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("cleanzip-cancel-tests-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ do {
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ let source = root.appendingPathComponent("cancel-me.txt")
+ try Data("cancel".utf8).write(to: source)
+ let cancellation = OperationCancellation()
+ cancellation.cancel()
+ do {
+ _ = try ArchiveEngine.shared.compress(urls: [source], format: .zip, splitSpec: nil, cancellation: cancellation)
+ failures.append("a pre-cancelled compression should not run")
+ } catch ArchiveError.cancelled {
+ // Expected.
+ }
+ expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("cancel-me.zip").path), "cancelled compression must not leave a partial archive")
+ } catch {
+ failures.append("cancellation cleanup test failed: \(error.localizedDescription)")
+ }
+ }
+
+ private static func runTool(_ executable: URL, arguments: [String], currentDirectory: URL) throws {
+ let process = Process()
+ process.executableURL = executable
+ process.arguments = arguments
+ process.currentDirectoryURL = currentDirectory
+ process.standardInput = FileHandle.nullDevice
+ process.standardOutput = FileHandle.nullDevice
+ let errorPipe = Pipe()
+ process.standardError = errorPipe
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else {
+ let message = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ throw ArchiveError.failed(message)
+ }
+ }
+
private static func makePayload(in directory: URL) throws -> URL {
let payload = directory.appendingPathComponent("Payload")
try FileManager.default.createDirectory(at: payload.appendingPathComponent("__MACOSX"), withIntermediateDirectories: true)
diff --git a/work/CleanZipBuild/tests/run.sh b/work/CleanZipBuild/tests/run.sh
index c66404f..06c4a2e 100755
--- a/work/CleanZipBuild/tests/run.sh
+++ b/work/CleanZipBuild/tests/run.sh
@@ -7,7 +7,7 @@ trap 'rm -rf "$BUILD_DIR"' EXIT
target="$(uname -m)-apple-macos14.0"
-xcrun swiftc -Onone -parse-as-library -DCLEANZIP_TESTING \
+xcrun swiftc -Onone -parse-as-library -swift-version 6 -strict-concurrency=complete -warn-concurrency -DCLEANZIP_TESTING \
-target "$target" \
-framework AppKit \
-framework SwiftUI \