Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions .github/workflows/fork-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
name: Build Fork Prerelease

on:
push:
branches: [feat/one-click-extraction]
paths-ignore:
- '.github/workflows/**'
- 'scripts/sync-localizations.sh'
- '**/*.xcstrings'
workflow_dispatch:
inputs:
release_tag:
description: Optional tag (blank creates a unique build tag)
required: false
type: string

permissions:
contents: write

concurrency:
group: fork-release-${{ github.ref }}
cancel-in-progress: true

jobs:
build-release:
if: github.repository == 'n4dh1x/MacPacker'
runs-on: macos-26-intel
timeout-minutes: 60
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
REQUESTED_TAG: ${{ inputs.release_tag }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
ref: ${{ github.sha }}

- name: Choose a new prerelease tag
shell: bash
run: |
set -euo pipefail
release_tag="${REQUESTED_TAG:-one-click-build-${GITHUB_RUN_NUMBER}-${GITHUB_RUN_ATTEMPT}}"
git check-ref-format "refs/tags/$release_tag"
if git show-ref --verify --quiet "refs/tags/$release_tag"; then
echo "::error::The requested tag already exists. Choose a new tag."
exit 1
fi
echo "RELEASE_TAG=$release_tag" >> "$GITHUB_ENV"
xcodebuild -version
swift --version
toolchain_hash=$(xcodebuild -version | shasum -a 256 | cut -c 1-12)
echo "TOOLCHAIN_HASH=$toolchain_hash" >> "$GITHUB_ENV"

- name: Restore build caches
uses: actions/cache/restore@v4
with:
path: |
Modules/.build
${{ runner.temp }}/MacPackerBuild
key: fork-build-${{ runner.os }}-${{ runner.arch }}-${{ env.TOOLCHAIN_HASH }}-${{ hashFiles('Modules/Package.resolved', 'MacPacker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ github.sha }}
restore-keys: |
fork-build-${{ runner.os }}-${{ runner.arch }}-${{ env.TOOLCHAIN_HASH }}-${{ hashFiles('Modules/Package.resolved', 'MacPacker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-

- name: Install the test archive reader
run: bash .ci/install-sevenzip.sh

- name: Run package tests
shell: bash
run: |
set -euo pipefail
swift test --package-path Modules 2>&1 | tee "$RUNNER_TEMP/swift-test.log"

- name: Build the universal app with ad-hoc signing
shell: bash
run: |
set -euo pipefail
# This fork is downloaded from GitHub Releases. Its updater must not
# replace it with an upstream build that lacks the branch's features.
/usr/libexec/PlistBuddy -c 'Add :SUEnableAutomaticChecks bool false' MacPacker/Info.plist
/usr/libexec/PlistBuddy -c 'Add :SUAllowsAutomaticUpdates bool false' MacPacker/Info.plist
xcodebuild \
-project MacPacker.xcodeproj \
-scheme MacPacker \
-configuration Release \
-destination 'generic/platform=macOS' \
-derivedDataPath "$RUNNER_TEMP/MacPackerBuild" \
'ARCHS=arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY=- \
DEVELOPMENT_TEAM= \
PROVISIONING_PROFILE_SPECIFIER= \
BUNDLE_ID_SUFFIX=.fork \
APP_URL_SCHEME=app.macpacker.fork \
APP_GROUP_ID=UNSIGNED00.com.n4dh1x.MacPacker \
build 2>&1 | tee "$RUNNER_TEMP/app-build.log"

- name: Verify the app and package the download
shell: bash
run: |
set -euo pipefail
app="$RUNNER_TEMP/MacPackerBuild/Build/Products/Release/MacPacker.app"
bash scripts/check-architectures.sh "$app"
codesign --verify --deep --strict --verbose=2 "$app"
mkdir -p dist
ditto -c -k --sequesterRsrc --keepParent "$app" dist/MacPacker.zip
(cd dist && shasum -a 256 MacPacker.zip > SHA256SUMS.txt)

- name: Save Xcode-generated localization catalogs
shell: bash
run: |
set -euo pipefail
bash scripts/sync-localizations.sh "$RUNNER_TEMP/MacPackerBuild"
git add -- \
MacPacker/Localizable.xcstrings \
MacPacker/LeanBytes.xcstrings \
Modules/Sources/Core/Localizable.xcstrings \
Modules/Sources/ArchivePreviewUI/Localizable.xcstrings
if ! git diff --cached --quiet; then
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git commit -m 'lang: save Xcode-generated localization strings'
git push origin "HEAD:$GITHUB_REF_NAME"
fi
echo "RELEASE_COMMIT=$(git rev-parse HEAD)" >> "$GITHUB_ENV"

- name: Publish the GitHub prerelease
shell: bash
run: |
set -euo pipefail
cat > "$RUNNER_TEMP/release-notes.md" <<EOF
One-click archive extraction from Settings → General:
- Extract beside the archive.
- Extract into a folder named after the archive.
- Ask for an extraction location.
Existing output is preserved; conflicting names receive a numbered suffix.

Built from branch \`$GITHUB_REF_NAME\`, commit \`$RELEASE_COMMIT\`.
Universal app for Apple silicon and Intel; requires macOS 14.6 or later.

This is an unofficial fork build with ad-hoc signing. It is not signed
with an Apple Developer ID or notarized by Apple. macOS may require
approval in System Settings → Privacy & Security on first launch.
Automatic upstream updates are disabled for this build.

Download MacPacker.zip below, unzip it, and move MacPacker.app to Applications.
SHA256SUMS.txt contains the download's SHA-256 checksum.

Package tests, the Release app build, universal architecture checks,
and code-signature verification passed in
$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID.
EOF
gh release create "$RELEASE_TAG" \
dist/MacPacker.zip dist/SHA256SUMS.txt \
--target "$RELEASE_COMMIT" \
--title "One-click extraction — $RELEASE_TAG" \
--notes-file "$RUNNER_TEMP/release-notes.md" \
--prerelease \
--latest=false

- name: Keep build logs
if: always()
uses: actions/upload-artifact@v4
with:
name: fork-build-logs-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ runner.temp }}/swift-test.log
${{ runner.temp }}/app-build.log
if-no-files-found: ignore
retention-days: 14

- name: Save build caches for subsequent runs
if: ${{ !cancelled() && env.TOOLCHAIN_HASH != '' }}
uses: actions/cache/save@v4
with:
path: |
Modules/.build
${{ runner.temp }}/MacPackerBuild
key: fork-build-${{ runner.os }}-${{ runner.arch }}-${{ env.TOOLCHAIN_HASH }}-${{ hashFiles('Modules/Package.resolved', 'MacPacker.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ github.sha }}
66 changes: 66 additions & 0 deletions .github/workflows/verify-one-click-ui.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Verify One-click UI

on:
push:
branches: [feat/one-click-extraction]
paths:
- 'MacPackerUITests/**'
- '.github/workflows/verify-one-click-ui.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
ui:
if: github.repository == 'n4dh1x/MacPacker'
runs-on: macos-26-intel
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
revision: [before, after]
env:
REVISION: ${{ matrix.revision }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0
- name: Restore original app for the before screenshot
if: matrix.revision == 'before'
run: git restore --source=4e6097325ec3d3fc8da119ee54795d35751e7f3c --staged --worktree MacPacker Modules/Sources
- name: Build and exercise the macOS UI
shell: bash
run: |
set -euo pipefail
tests=(-only-testing:MacPackerUITests/MacPackerUITests/testCaptureGeneralSettings)
if [[ "$REVISION" == after ]]; then
for test in testOneClickExtractsBesideArchive testOneClickExtractsIntoArchiveNamedFolder testOneClickAsksForDestination testCancellingOneClickDestinationLeavesArchiveUntouched testOneClickDisabledOpensArchiveBrowser; do
tests+=("-only-testing:MacPackerUITests/MacPackerUITests/$test")
done
fi
xcodebuild -project MacPacker.xcodeproj -scheme MacPacker \
-configuration Debug -destination 'platform=macOS' \
-derivedDataPath "$RUNNER_TEMP/MacPackerUI" \
-resultBundlePath "$RUNNER_TEMP/ui-$REVISION.xcresult" \
CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= \
PROVISIONING_PROFILE_SPECIFIER= \
APP_GROUP_ID=UNSIGNED00.com.n4dh1x.MacPacker.debug \
"${tests[@]}" test 2>&1 | tee "$RUNNER_TEMP/ui-$REVISION.log"
- name: Export screenshots
if: always()
run: |
if [[ -d "$RUNNER_TEMP/ui-$REVISION.xcresult" ]]; then
xcrun xcresulttool export attachments \
--path "$RUNNER_TEMP/ui-$REVISION.xcresult" \
--output-path "$RUNNER_TEMP/screenshots-$REVISION"
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: one-click-ui-${{ matrix.revision }}
path: |
${{ runner.temp }}/ui-${{ matrix.revision }}.log
${{ runner.temp }}/screenshots-${{ matrix.revision }}
retention-days: 30
28 changes: 28 additions & 0 deletions Config/products/macpacker.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,34 @@
{
"version": "1.1.0",
"items": [
{
"type": "feat",
"highlight": true,
"title": {
"en": "One-click archive extraction",
"zh-Hans": "一键解压归档",
"fr": "Extraction d’archives en un clic",
"de": "Archive mit einem Klick entpacken",
"it": "Estrazione archivi con un clic",
"ja": "ワンクリックでアーカイブを展開",
"fa": "استخراج بایگانی با یک کلیک",
"pl": "Rozpakowywanie archiwów jednym kliknięciem",
"pt-BR": "Extração de arquivos com um clique",
"ru": "Распаковка архивов одним щелчком",
"uk": "Розпакування архівів одним клацанням",
"es-MX": "Extracción de archivos con un clic",
"ko": "클릭 한 번으로 압축 풀기",
"nl": "Archieven uitpakken met één klik",
"tr": "Tek tıkla arşiv çıkarma",
"vi": "Giải nén lưu trữ bằng một cú nhấp",
"ar": "استخراج الأرشيفات بنقرة واحدة",
"ca": "Extracció d’arxius amb un clic",
"el": "Εξαγωγή αρχείων με ένα κλικ",
"nb": "Pakk ut arkiver med ett klikk",
"zh-TW": "一鍵解壓縮封存檔"
},
"issues": ["280"]
},
{
"type": "feat",
"highlight": true,
Expand Down
23 changes: 21 additions & 2 deletions MacPacker/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@AppStorage("updateBetaChannelOn") var updateBetaChannelOn: Bool = false
@AppStorage("checkForUpdates") var checkForUpdates: SettingUpdateCheck = .automatically
@AppStorage(Keys.quitOnLastWindowClosed) var quitOnLastWindowClosed: Bool = false
@AppStorage(Keys.oneClickExtraction) private var oneClickExtraction: Bool = false
private var archiveWindowManager: ArchiveWindowManager? = nil
private var extractionProgressWindowController: ExtractionProgressWindowController? = nil
private var dropMenuBarItem: DropMenuBarItem? = nil
Expand Down Expand Up @@ -88,11 +89,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {

// first check if this is an app url, and handle it accordingly
let appUrl: AppUrl? = urls.first.flatMap { UrlParser().parse(appUrl: $0) }
let detector = ArchiveTypeDetector(catalog: appState.catalog)
let archivesToExtract = oneClickExtraction && appUrl == nil
? urls.filter { $0.isFileURL && detector.detect(for: $0) != nil }
: []

// On a cold start this is what the launch was started for, so it
// decides the launch windows. The welcome window goes on top of what
// opens below.
let showsLaunchWindows = launch.started(for: appUrl == nil ? .openWith : .finderExtension)
let reason: Launch.Reason = appUrl != nil ? .finderExtension
: archivesToExtract.isEmpty ? .openWith : .oneClickExtraction
let showsLaunchWindows = launch.started(for: reason)
if showsLaunchWindows {
openLaunchWindow()
}
Expand Down Expand Up @@ -154,9 +161,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
return
}

if let first = archivesToExtract.first, let archiveWindowManager {
let handler = AppUrlOneClickExtractionHandler(
destination: .saved(), catalog: appState.catalog, engineSelector: appState.engineSelector)
let request = AppUrl(action: .open, files: archivesToExtract, target: first.deletingLastPathComponent())
Task {
await ExtractionProgressCenter.shared.run {
await handler.handle(appUrl: request, archiveWindowManager: archiveWindowManager)
}
if finderLaunchIsOver { NSApp.terminate(nil) }
}
}

// it is not an app url, therefore the assumption is that the app
// was opened via Finder > right click > Open with...
for url in urls {
for url in urls where !archivesToExtract.contains(url) {
log.notice("Open-with: opening \(url.lastPathComponent)")
archiveWindowManager?.openArchiveWindow(for: url)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class AppUrlExtractToChosenFolderHandler: AppUrlHandler {
func handle(appUrl: AppUrl, archiveWindowManager: ArchiveWindowManager) async {
log.notice("Extract-to handler: \(appUrl.files.count) archive(s)")

guard let destination = await chooseDestination(startingIn: appUrl.target) else {
guard let destination = await chooseExtractionDestination(startingIn: appUrl.target) else {
log.notice("Extract-to cancelled")
return
}
Expand All @@ -54,24 +54,4 @@ class AppUrlExtractToChosenFolderHandler: AppUrlHandler {
NSWorkspace.shared.activateFileViewerSelecting(extracted)
}
}

/// Asks where to extract. `nil` when the panel is cancelled.
private func chooseDestination(startingIn folder: URL) async -> URL? {
await withCheckedContinuation { continuation in
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = folder
panel.prompt = String(localized: .commonExtract)
panel.message = String(localized: .archiveExtractChooseDestination)
panel.level = .floating
NSApp.activate(ignoringOtherApps: true)

panel.begin { response in
continuation.resume(returning: response == .OK ? panel.url : nil)
}
}
}
}
Loading