diff --git a/.github/workflows/cleanzip-liquid-glass-icon.yml b/.github/workflows/cleanzip-liquid-glass-icon.yml index 0afcc7d..9b6c677 100644 --- a/.github/workflows/cleanzip-liquid-glass-icon.yml +++ b/.github/workflows/cleanzip-liquid-glass-icon.yml @@ -6,7 +6,7 @@ on: release_tag: description: "Release tag to update" required: true - default: "v2.6.34" + default: "v2.6.35" upload_release: description: "Upload pkg, zip, and checksums to the release" required: true @@ -122,17 +122,136 @@ jobs: print("Assets.car contains a dynamic app icon image stack.") PY + - name: Configure Developer ID signing + env: + APPLICATION_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_P12_BASE64 }} + APPLICATION_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_P12_PASSWORD }} + APPLICATION_IDENTITY: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_IDENTITY }} + INSTALLER_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_INSTALLER_P12_BASE64 }} + INSTALLER_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_INSTALLER_P12_PASSWORD }} + INSTALLER_IDENTITY: ${{ secrets.APPLE_DEVELOPER_ID_INSTALLER_IDENTITY }} + shell: bash + run: | + set -euo pipefail + if [[ -z "$APPLICATION_P12_BASE64" || -z "$APPLICATION_P12_PASSWORD" || -z "$APPLICATION_IDENTITY" || \ + -z "$INSTALLER_P12_BASE64" || -z "$INSTALLER_P12_PASSWORD" || -z "$INSTALLER_IDENTITY" ]]; then + echo "CLEANZIP_SIGNING_MODE=ad-hoc" >> "$GITHUB_ENV" + echo "::notice::Developer ID secrets are incomplete; this build will be ad-hoc signed and cannot be notarized." + exit 0 + fi + + keychain="$RUNNER_TEMP/cleanzip-signing.keychain-db" + keychain_password="$(openssl rand -hex 24)" + application_p12="$RUNNER_TEMP/developer-id-application.p12" + installer_p12="$RUNNER_TEMP/developer-id-installer.p12" + printf '%s' "$APPLICATION_P12_BASE64" | /usr/bin/base64 -D > "$application_p12" + printf '%s' "$INSTALLER_P12_BASE64" | /usr/bin/base64 -D > "$installer_p12" + + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$application_p12" -k "$keychain" -P "$APPLICATION_P12_PASSWORD" -T /usr/bin/codesign + security import "$installer_p12" -k "$keychain" -P "$INSTALLER_P12_PASSWORD" -T /usr/bin/productbuild -T /usr/bin/pkgbuild + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" + security list-keychains -d user -s "$keychain" + + echo "CLEANZIP_SIGNING_MODE=developer-id" >> "$GITHUB_ENV" + echo "CLEANZIP_SIGNING_KEYCHAIN=$keychain" >> "$GITHUB_ENV" + echo "CLEANZIP_APPLICATION_IDENTITY=$APPLICATION_IDENTITY" >> "$GITHUB_ENV" + echo "CLEANZIP_INSTALLER_IDENTITY=$INSTALLER_IDENTITY" >> "$GITHUB_ENV" + - name: Sign app bundles shell: bash run: | set -euo pipefail - codesign --force --deep --sign - work/CleanZipBuild/CleanZip.app - codesign --force --deep --sign - work/CleanZipBuild/CleanZipService.service + sign_bundle() { + local bundle="$1" + local helper="$bundle/Contents/Resources/7zz" + if [[ "$CLEANZIP_SIGNING_MODE" == "developer-id" ]]; then + codesign --force --options runtime --timestamp --keychain "$CLEANZIP_SIGNING_KEYCHAIN" --sign "$CLEANZIP_APPLICATION_IDENTITY" "$helper" + codesign --force --options runtime --timestamp --keychain "$CLEANZIP_SIGNING_KEYCHAIN" --sign "$CLEANZIP_APPLICATION_IDENTITY" "$bundle" + else + codesign --force --options runtime --timestamp=none --sign - "$helper" + codesign --force --options runtime --timestamp=none --sign - "$bundle" + fi + codesign --verify --deep --strict --verbose=2 "$bundle" + } + sign_bundle work/CleanZipBuild/CleanZip.app + sign_bundle work/CleanZipBuild/CleanZipService.service + + - name: Notarize and staple app bundles + env: + NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + shell: bash + run: | + set -euo pipefail + if [[ "$CLEANZIP_SIGNING_MODE" != "developer-id" || -z "$NOTARY_KEY_P8_BASE64" || -z "$NOTARY_KEY_ID" || -z "$NOTARY_ISSUER_ID" ]]; then + echo "CLEANZIP_NOTARIZED=0" >> "$GITHUB_ENV" + echo "::notice::Notary credentials are unavailable; app bundles were not notarized." + exit 0 + fi + + notary_key="$RUNNER_TEMP/AuthKey.p8" + notary_stage="$RUNNER_TEMP/cleanzip-notary" + notary_zip="$RUNNER_TEMP/CleanZip-notary-upload.zip" + printf '%s' "$NOTARY_KEY_P8_BASE64" | /usr/bin/base64 -D > "$notary_key" + mkdir -p "$notary_stage" + ditto work/CleanZipBuild/CleanZip.app "$notary_stage/CleanZip.app" + ditto work/CleanZipBuild/CleanZipService.service "$notary_stage/CleanZipService.service" + ditto -c -k --keepParent "$notary_stage" "$notary_zip" + xcrun notarytool submit "$notary_zip" --key "$notary_key" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait + xcrun stapler staple work/CleanZipBuild/CleanZip.app + xcrun stapler validate work/CleanZipBuild/CleanZip.app + xcrun stapler staple work/CleanZipBuild/CleanZipService.service || echo "::notice::The service ticket remains available online." + spctl --assess --type execute --verbose=2 work/CleanZipBuild/CleanZip.app + echo "CLEANZIP_NOTARIZED=1" >> "$GITHUB_ENV" - name: Build installer package shell: bash run: work/CleanZipBuild/src/package.sh + - name: Notarize and staple installer package + if: ${{ env.CLEANZIP_NOTARIZED == '1' }} + env: + NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + shell: bash + run: | + set -euo pipefail + notary_key="$RUNNER_TEMP/AuthKey.p8" + pkg="$(find work/CleanZipBuild/dist -maxdepth 1 -name 'CleanZip-*.pkg' -print | sort | tail -n 1)" + test -f "$pkg" + printf '%s' "$NOTARY_KEY_P8_BASE64" | /usr/bin/base64 -D > "$notary_key" + xcrun notarytool submit "$pkg" --key "$notary_key" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER_ID" --wait + xcrun stapler staple "$pkg" + xcrun stapler validate "$pkg" + pkgutil --check-signature "$pkg" + spctl --assess --type install --verbose=2 "$pkg" + + - name: Summarize distribution trust + shell: bash + run: | + { + echo "### CleanZip distribution" + echo "" + echo "- Signing mode: \`$CLEANZIP_SIGNING_MODE\`" + echo "- Notarized: \`${CLEANZIP_NOTARIZED:-0}\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Refresh release checksums + shell: bash + run: | + set -euo pipefail + cd work/CleanZipBuild/dist + pkg="$(find . -maxdepth 1 -name 'CleanZip-*.pkg' -print | sort | tail -n 1)" + zip="$(find . -maxdepth 1 -name 'CleanZip-*.zip' -print | sort | tail -n 1)" + test -f "$pkg" + test -f "$zip" + shasum -a 256 "${pkg#./}" "${zip#./}" > SHA256SUMS.txt + - name: Upload package assets to release if: ${{ github.event_name == 'workflow_dispatch' && inputs.upload_release }} env: diff --git a/README.md b/README.md index 5cc83c6..76a704e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ · Product page · - CleanZip 2.6.34 + CleanZip 2.6.35 · Build from source

@@ -59,7 +59,7 @@ It is intentionally small: no always-on background app, no history database, no ## Download -Download `CleanZip-2.6.34.pkg` from the [latest release](https://github.com/lyc280705/CleanZip/releases/latest). For most users, the `.pkg` installer is the easiest option. +Download `CleanZip-2.6.35.pkg` from the [latest release](https://github.com/lyc280705/CleanZip/releases/latest). For most users, the `.pkg` installer is the easiest option. The installer places: @@ -70,9 +70,9 @@ CleanZip is ad-hoc signed for open source distribution, but it is not notarized For the `.pkg` installer: -1. In Finder, Control-click `CleanZip-2.6.34.pkg` and choose **Open**. +1. In Finder, Control-click `CleanZip-2.6.35.pkg` and choose **Open**. 2. If the same warning still appears with only **Done** and **Move to Trash**, open **System Settings** -> **Privacy & Security**. -3. At the bottom of Privacy & Security, choose **Open Anyway** for `CleanZip-2.6.34.pkg`, then confirm. +3. At the bottom of Privacy & Security, choose **Open Anyway** for `CleanZip-2.6.35.pkg`, then confirm. After installation, if macOS blocks `CleanZip.app` itself, Control-click `CleanZip.app` in `/Applications` and choose **Open**. If it is still blocked, use **System Settings** -> **Privacy & Security** -> **Open Anyway** for `CleanZip.app`. @@ -81,11 +81,11 @@ After you approve the installer or app once, macOS opens it normally. Advanced terminal alternative for the downloaded package: ```bash -xattr -dr com.apple.quarantine ~/Downloads/CleanZip-2.6.34.pkg -open ~/Downloads/CleanZip-2.6.34.pkg +xattr -dr com.apple.quarantine ~/Downloads/CleanZip-2.6.35.pkg +open ~/Downloads/CleanZip-2.6.35.pkg ``` -Manual installation is also available from `CleanZip-2.6.34.zip`: move `CleanZip.app` to `/Applications` and `CleanZipService.service` to `/Library/Services`. +Manual installation is also available from `CleanZip-2.6.35.zip`: move `CleanZip.app` to `/Applications` and `CleanZipService.service` to `/Library/Services`. ## Compatibility @@ -100,9 +100,10 @@ On macOS 26, CleanZip uses Liquid Glass interface effects where available. On ma | Clean compression | Creates clean ZIP output and excludes `.DS_Store`, `__MACOSX/`, and `._*` metadata. | | Finder integration | Adds one right-click service for compressing ordinary files/folders or extracting archives. | | Archive preview | Lists archive contents with name, size, modified time, and folder structure. Includes search. | +| Encrypted archives | Requests passwords in a native secure field; passwords are sent to `7zz` through standard input and never placed in process arguments. | | Extraction | Extracts common formats through bundled `7zz` and system tools. | | Split archive creation | Supports split ZIP and split 7Z creation with common presets and custom sizes. | -| Progress | Shows progress for larger compression and extraction jobs in the app and lightweight service HUD. | +| Progress | Shows cancellable progress for larger compression and extraction jobs in the app and lightweight service HUD, then removes partial output after cancellation. | | Compatibility | Universal app for supported Intel and Apple Silicon Macs running macOS 14 or later. | | Localization | Localized app UI, Finder service menu, notifications, errors, and document metadata. | @@ -151,6 +152,7 @@ CleanZip runs locally on your Mac. Archive operations are performed with local s - `work/CleanZipBuild/src/build_xcode.sh`: Xcode release build script for the app and Finder service. - `work/CleanZipBuild/src/build.sh`: lightweight local Swift build fallback for Macs without full Xcode. - `work/CleanZipBuild/src/package.sh`: package and ZIP release artifact script. +- `docs/RELEASING.md`: optional Developer ID signing and Apple notarization setup for maintainers. - `work/CleanZipBuild/src/generate_filled_icon.py`: vector icon generator and `Assets.car` compiler when Xcode `actool` is available. - `.github/workflows/cleanzip-liquid-glass-icon.yml`: macOS 26 GitHub Actions release build that uses full Xcode, compiles the dynamic icon stack, packages CleanZip, and can update release assets. @@ -172,7 +174,7 @@ gh workflow run cleanzip-liquid-glass-icon.yml --repo lyc280705/CleanZip --ref m To rebuild and update an existing GitHub release asset set: ```sh -gh workflow run cleanzip-liquid-glass-icon.yml --repo lyc280705/CleanZip --ref main -f release_tag=v2.6.34 -f upload_release=true +gh workflow run cleanzip-liquid-glass-icon.yml --repo lyc280705/CleanZip --ref main -f release_tag=v2.6.35 -f upload_release=true ``` Lightweight local fallback with Command Line Tools: @@ -181,7 +183,7 @@ Lightweight local fallback with Command Line Tools: work/CleanZipBuild/src/build.sh ``` -Dynamic Liquid Glass icon compilation requires Xcode 26 `actool`. The lightweight fallback still creates a usable app bundle, but the GitHub Actions/Xcode path is the canonical release path. +Dynamic Liquid Glass icon compilation requires Xcode 26 `actool`. The lightweight fallback still creates a usable app bundle, but the GitHub Actions/Xcode path is the canonical release path. Both paths compile in Swift 6 language mode with complete concurrency checking; long-running archive work uses Swift 6.2 structured concurrency to stay off the main actor. ## License diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..3c7055e --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,46 @@ +# Releasing CleanZip + +CleanZip's release workflow always builds universal `arm64` and `x86_64` app, service, package, and ZIP artifacts. It supports two trust modes: + +- **Developer ID:** signs with the Hardened Runtime, submits the app bundles and installer to Apple's notary service, staples tickets, and verifies them with `codesign`, `stapler`, and `spctl`. +- **Ad-hoc fallback:** keeps source builds installable when credentials are unavailable, but Gatekeeper requires the manual approval steps documented in the README. + +## GitHub Secrets + +Add these Actions secrets in **Settings -> Secrets and variables -> Actions**: + +| Secret | Value | +| --- | --- | +| `APPLE_DEVELOPER_ID_APPLICATION_P12_BASE64` | Base64-encoded Developer ID Application certificate and private key (`.p12`). | +| `APPLE_DEVELOPER_ID_APPLICATION_P12_PASSWORD` | Password used when exporting that `.p12`. | +| `APPLE_DEVELOPER_ID_APPLICATION_IDENTITY` | Full certificate name, such as `Developer ID Application: Example (TEAMID)`. | +| `APPLE_DEVELOPER_ID_INSTALLER_P12_BASE64` | Base64-encoded Developer ID Installer certificate and private key (`.p12`). | +| `APPLE_DEVELOPER_ID_INSTALLER_P12_PASSWORD` | Password used when exporting that `.p12`. | +| `APPLE_DEVELOPER_ID_INSTALLER_IDENTITY` | Full certificate name, such as `Developer ID Installer: Example (TEAMID)`. | +| `APPLE_NOTARY_KEY_P8_BASE64` | Base64-encoded App Store Connect API private key (`AuthKey_XXXXXXXXXX.p8`). | +| `APPLE_NOTARY_KEY_ID` | App Store Connect API key ID. | +| `APPLE_NOTARY_ISSUER_ID` | App Store Connect API issuer ID. | + +Encode certificate and API key files without line wrapping: + +```sh +base64 -i DeveloperIDApplication.p12 | pbcopy +base64 -i DeveloperIDInstaller.p12 | pbcopy +base64 -i AuthKey_XXXXXXXXXX.p8 | pbcopy +``` + +The workflow imports certificates into an ephemeral runner keychain. It never writes certificate passwords or private-key contents to logs or release artifacts. + +## Publish + +Create the tag and release first, then dispatch the workflow so its artifacts replace the release assets: + +```sh +gh workflow run cleanzip-liquid-glass-icon.yml \ + --repo lyc280705/CleanZip \ + --ref main \ + -f release_tag=v2.6.35 \ + -f upload_release=true +``` + +Check the workflow summary before announcing the release. A public build should only be described as notarized when the summary reports `developer-id` and `1` for the signing and notarization fields. diff --git a/docs/index.html b/docs/index.html index 16c0bc8..f282a89 100644 --- a/docs/index.html +++ b/docs/index.html @@ -34,7 +34,7 @@ "url": "https://lyc280705.github.io/CleanZip/", "image": "https://lyc280705.github.io/CleanZip/images/app-icon.png", "downloadUrl": "https://github.com/lyc280705/CleanZip/releases/latest", - "softwareVersion": "2.6.30", + "softwareVersion": "2.6.35", "license": "https://github.com/lyc280705/CleanZip/blob/main/LICENSE", "codeRepository": "https://github.com/lyc280705/CleanZip", "sameAs": [ @@ -82,7 +82,7 @@

Clean ZIPs, archive previews, and one Finder action.

Download for macOS View on GitHub -

Latest release: CleanZip 2.6.30. Universal app for Intel and Apple Silicon Macs.

+

Latest release: CleanZip 2.6.35. Universal app for Intel and Apple Silicon Macs.

@@ -185,7 +185,7 @@

Preview and extract the archive formats people actually s

Download the package and start from Finder.

    -
  1. Download CleanZip-2.6.30.pkg.
  2. +
  3. Download CleanZip-2.6.35.pkg.
  4. Open the installer. It installs CleanZip.app and the Finder service.
  5. Select files, folders, or archives in Finder and choose CleanZip Compress or Extract.
@@ -197,11 +197,11 @@

Download the package and start from Finder.

If macOS blocks the installer.

-

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.”

    -
  1. In Finder, Control-click CleanZip-2.6.30.pkg, then choose Open.
  2. +
  3. In Finder, Control-click CleanZip-2.6.35.pkg, then choose Open.
  4. If the same warning still appears with only Done and Move to Trash, open System SettingsPrivacy & Security.
  5. -
  6. At the bottom of Privacy & Security, choose Open Anyway for CleanZip-2.6.30.pkg, then confirm.
  7. +
  8. At the bottom of Privacy & Security, choose Open Anyway for CleanZip-2.6.35.pkg, then confirm.
  9. 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 \