diff --git a/.env.example b/.env.example
index 253da60..1ef97dc 100644
--- a/.env.example
+++ b/.env.example
@@ -10,3 +10,23 @@ POWERLENS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)"
# Keychain profile created with:
# xcrun notarytool store-credentials powerlens-notary --apple-id ... --team-id ... --password ...
POWERLENS_NOTARY_PROFILE="powerlens-notary"
+
+# Sparkle in-app updates.
+# Generate the key with:
+# .build/artifacts/sparkle/Sparkle/bin/generate_keys --account powerlens
+# Store only the printed public key here. Keep the private key in Keychain or
+# pass it to generate_appcast locally; never commit private Sparkle keys.
+POWERLENS_SPARKLE_FEED_URL="https://progresshans.github.io/powerlens/appcast.xml"
+POWERLENS_SPARKLE_ALPHA_FEED_URL="https://progresshans.github.io/powerlens/appcast-alpha.xml"
+POWERLENS_SPARKLE_PUBLIC_ED_KEY=""
+POWERLENS_SPARKLE_KEY_ACCOUNT="powerlens"
+# CI can pass the exported private EdDSA key through this environment variable
+# when generating appcasts. Do not commit a real private key.
+# POWERLENS_SPARKLE_PRIVATE_ED_KEY=""
+
+# Optional appcast generation for maintainers.
+# POWERLENS_SPARKLE_GENERATE_APPCAST=1
+# POWERLENS_SPARKLE_APPCAST_DIR="$PWD/release/appcast-work"
+# POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast.xml"
+# POWERLENS_SPARKLE_DOWNLOAD_URL_PREFIX="https://github.com/progresshans/powerlens/releases/download/v0.9.1/"
+# POWERLENS_SPARKLE_RELEASE_NOTES_URL_PREFIX="https://github.com/progresshans/powerlens/releases/download/v0.9.1/"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..38d41bf
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,70 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - develop
+ - "feature/**"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Build, Test, and Package Smoke Check
+ runs-on: macos-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Show toolchain
+ run: |
+ swift --version
+ xcodebuild -version
+
+ - name: Run unit tests
+ run: swift test
+
+ - name: Validate scripts
+ run: |
+ bash -n \
+ script/lib/powerlens_packaging.sh \
+ script/build_and_run.sh \
+ script/package_release.sh \
+ script/test_sparkle_update.sh \
+ script/verify_distribution.sh
+
+ - name: Validate metadata
+ run: |
+ plutil -lint \
+ Packaging/Info.plist \
+ Sources/PowerLens/Resources/en.lproj/Localizable.strings \
+ Sources/PowerLens/Resources/ko.lproj/Localizable.strings
+ python3 - <<'PY'
+ import xml.etree.ElementTree as ET
+ for path in ("docs/appcast.xml", "docs/appcast-alpha.xml"):
+ ET.parse(path)
+ print(f"{path}: OK")
+ PY
+
+ - name: Package ad-hoc release smoke build
+ env:
+ POWERLENS_VERSION: 0.0.0-ci
+ POWERLENS_BUILD: ${{ github.run_number }}
+ POWERLENS_SKIP_NOTARIZATION: "1"
+ POWERLENS_CLEAN_BUILD: "0"
+ run: ./script/package_release.sh
+
+ - name: Verify packaged bundle metadata
+ run: |
+ ./script/verify_distribution.sh release/stage/PowerLens.app
+ test -f release/PowerLens-0.0.0-ci.app.zip
+ test -f release/PowerLens-0.0.0-ci.dmg
+ test -f release/PowerLens-0.0.0-ci-checksums.txt
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..7fbf332
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,343 @@
+name: Publish Release
+
+on:
+ push:
+ branches:
+ - develop
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Release version without the leading v, for example 0.9.1 or 0.9.2-alpha.1"
+ required: true
+ type: string
+ channel:
+ description: "Sparkle update channel to publish"
+ required: true
+ default: stable
+ type: choice
+ options:
+ - stable
+ - alpha
+
+permissions:
+ contents: read
+
+concurrency:
+ group: publish-release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ name: Build, Notarize, Release, and Publish Appcast
+ runs-on: macos-latest
+ environment: release
+ permissions:
+ contents: write
+
+ env:
+ POWERLENS_SIGN_IDENTITY: ${{ secrets.POWERLENS_SIGN_IDENTITY }}
+ POWERLENS_SPARKLE_PUBLIC_ED_KEY: ${{ secrets.POWERLENS_SPARKLE_PUBLIC_ED_KEY }}
+ POWERLENS_SPARKLE_PRIVATE_ED_KEY: ${{ secrets.POWERLENS_SPARKLE_PRIVATE_ED_KEY }}
+ POWERLENS_SPARKLE_FEED_URL: https://progresshans.github.io/powerlens/appcast.xml
+ POWERLENS_SPARKLE_ALPHA_FEED_URL: https://progresshans.github.io/powerlens/appcast-alpha.xml
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Resolve release metadata
+ id: meta
+ env:
+ DISPATCH_VERSION: ${{ inputs.version }}
+ DISPATCH_CHANNEL: ${{ inputs.channel }}
+ ALPHA_BASE_VERSION: ${{ vars.POWERLENS_ALPHA_BASE_VERSION }}
+ run: |
+ set -euo pipefail
+
+ next_patch_alpha_base_version() {
+ local latest_stable_tag
+ latest_stable_tag="$(git tag --list 'v*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
+
+ if [[ -z "$latest_stable_tag" ]]; then
+ echo "0.1.0"
+ return
+ fi
+
+ local stable_version="${latest_stable_tag#v}"
+ local major minor patch
+ IFS=. read -r major minor patch <<< "$stable_version"
+ echo "$major.$minor.$((patch + 1))"
+ }
+
+ validate_alpha_base_version() {
+ local candidate="$1"
+ if [[ ! "$candidate" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "invalid alpha base version: $candidate" >&2
+ echo "expected a stable semantic version such as 0.9.2, 0.10.0, or 1.0.0" >&2
+ exit 2
+ fi
+ }
+
+ if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
+ version="${GITHUB_REF_NAME#v}"
+ tag="$GITHUB_REF_NAME"
+ if [[ "$version" == *"-alpha"* ]]; then
+ channel="alpha"
+ else
+ channel="stable"
+ fi
+ elif [[ "${GITHUB_REF_NAME}" == "develop" && -z "${DISPATCH_VERSION:-}" ]]; then
+ base_version="${ALPHA_BASE_VERSION:-$(next_patch_alpha_base_version)}"
+ validate_alpha_base_version "$base_version"
+ version="${base_version}-alpha.${GITHUB_RUN_NUMBER}"
+ tag="v${version}"
+ channel="alpha"
+ else
+ version="${DISPATCH_VERSION:?workflow_dispatch requires a version}"
+ tag="v${version}"
+ channel="${DISPATCH_CHANNEL:-stable}"
+ fi
+
+ case "$channel" in
+ stable)
+ appcast_path="docs/appcast.xml"
+ prerelease="false"
+ latest="true"
+ ;;
+ alpha)
+ appcast_path="docs/appcast-alpha.xml"
+ prerelease="true"
+ latest="false"
+ ;;
+ *)
+ echo "unsupported channel: $channel" >&2
+ exit 2
+ ;;
+ esac
+
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
+ echo "channel=$channel" >> "$GITHUB_OUTPUT"
+ echo "appcast_path=$appcast_path" >> "$GITHUB_OUTPUT"
+ echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
+ echo "latest=$latest" >> "$GITHUB_OUTPUT"
+ echo "download_url_prefix=https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}/" >> "$GITHUB_OUTPUT"
+
+ echo "PowerLens $version ($channel) -> $tag"
+
+ - name: Validate release secrets
+ env:
+ CERTIFICATE_P12_BASE64: ${{ secrets.POWERLENS_DEVELOPER_ID_APPLICATION_P12_BASE64 }}
+ CERTIFICATE_PASSWORD: ${{ secrets.POWERLENS_DEVELOPER_ID_APPLICATION_P12_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.POWERLENS_KEYCHAIN_PASSWORD }}
+ NOTARY_APPLE_ID: ${{ secrets.POWERLENS_NOTARY_APPLE_ID }}
+ NOTARY_TEAM_ID: ${{ secrets.POWERLENS_NOTARY_TEAM_ID }}
+ NOTARY_PASSWORD: ${{ secrets.POWERLENS_NOTARY_PASSWORD }}
+ run: |
+ set -euo pipefail
+ required=(
+ CERTIFICATE_P12_BASE64
+ CERTIFICATE_PASSWORD
+ KEYCHAIN_PASSWORD
+ NOTARY_APPLE_ID
+ NOTARY_TEAM_ID
+ NOTARY_PASSWORD
+ POWERLENS_SIGN_IDENTITY
+ POWERLENS_SPARKLE_PUBLIC_ED_KEY
+ POWERLENS_SPARKLE_PRIVATE_ED_KEY
+ )
+ for name in "${required[@]}"; do
+ if [[ -z "${!name:-}" ]]; then
+ echo "missing required GitHub secret: $name" >&2
+ exit 2
+ fi
+ done
+
+ - name: Import Developer ID certificate
+ env:
+ CERTIFICATE_P12_BASE64: ${{ secrets.POWERLENS_DEVELOPER_ID_APPLICATION_P12_BASE64 }}
+ CERTIFICATE_PASSWORD: ${{ secrets.POWERLENS_DEVELOPER_ID_APPLICATION_P12_PASSWORD }}
+ KEYCHAIN_PASSWORD: ${{ secrets.POWERLENS_KEYCHAIN_PASSWORD }}
+ run: |
+ set -euo pipefail
+ keychain_path="$RUNNER_TEMP/powerlens-signing.keychain-db"
+ certificate_path="$RUNNER_TEMP/powerlens-developer-id.p12"
+
+ echo "$CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path"
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
+ security set-keychain-settings -lut 21600 "$keychain_path"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
+ security import "$certificate_path" \
+ -k "$keychain_path" \
+ -P "$CERTIFICATE_PASSWORD" \
+ -T /usr/bin/codesign \
+ -T /usr/bin/productsign
+ security list-keychains -d user -s "$keychain_path" login.keychain-db
+ security set-key-partition-list \
+ -S apple-tool:,apple:,codesign: \
+ -s \
+ -k "$KEYCHAIN_PASSWORD" \
+ "$keychain_path"
+
+ - name: Store notarization credentials
+ env:
+ NOTARY_APPLE_ID: ${{ secrets.POWERLENS_NOTARY_APPLE_ID }}
+ NOTARY_TEAM_ID: ${{ secrets.POWERLENS_NOTARY_TEAM_ID }}
+ NOTARY_PASSWORD: ${{ secrets.POWERLENS_NOTARY_PASSWORD }}
+ run: |
+ xcrun notarytool store-credentials powerlens-notary \
+ --apple-id "$NOTARY_APPLE_ID" \
+ --team-id "$NOTARY_TEAM_ID" \
+ --password "$NOTARY_PASSWORD"
+
+ - name: Build signed notarized release
+ env:
+ POWERLENS_VERSION: ${{ steps.meta.outputs.version }}
+ POWERLENS_BUILD: ${{ github.run_number }}
+ POWERLENS_NOTARY_PROFILE: powerlens-notary
+ POWERLENS_SPARKLE_GENERATE_APPCAST: "1"
+ POWERLENS_SPARKLE_APPCAST_DIR: ${{ github.workspace }}/release/appcast-work
+ POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH: ${{ github.workspace }}/${{ steps.meta.outputs.appcast_path }}
+ POWERLENS_SPARKLE_DOWNLOAD_URL_PREFIX: ${{ steps.meta.outputs.download_url_prefix }}
+ POWERLENS_SPARKLE_RELEASE_NOTES_URL_PREFIX: ${{ steps.meta.outputs.download_url_prefix }}
+ run: ./script/package_release.sh
+
+ - name: Verify distribution bundle
+ env:
+ STRICT: "1"
+ run: ./script/verify_distribution.sh release/stage/PowerLens.app
+
+ - name: Preserve generated appcast
+ run: |
+ mkdir -p "$RUNNER_TEMP/powerlens-appcast"
+ cp "${{ steps.meta.outputs.appcast_path }}" "$RUNNER_TEMP/powerlens-appcast/appcast.xml"
+
+ - name: Create release notes
+ run: |
+ cat > "$RUNNER_TEMP/powerlens-release-notes.md" </dev/null 2>&1; then
+ edit_args=(
+ "$TAG"
+ --title "PowerLens $VERSION"
+ --notes-file "$RUNNER_TEMP/powerlens-release-notes.md"
+ --target "$GITHUB_SHA"
+ )
+
+ if [[ "$PRERELEASE" == "true" ]]; then
+ edit_args+=(--prerelease --latest=false)
+ else
+ edit_args+=(--prerelease=false --latest)
+ fi
+
+ gh release edit "${edit_args[@]}"
+ gh release upload "$TAG" \
+ "release/PowerLens-$VERSION.dmg" \
+ "release/PowerLens-$VERSION.app.zip" \
+ "release/PowerLens-$VERSION-checksums.txt" \
+ --clobber
+ else
+ gh release create "${args[@]}"
+ fi
+
+ - name: Prepare GitHub Pages artifact
+ env:
+ APPCAST_PATH: ${{ steps.meta.outputs.appcast_path }}
+ STABLE_FEED_URL: ${{ env.POWERLENS_SPARKLE_FEED_URL }}
+ ALPHA_FEED_URL: ${{ env.POWERLENS_SPARKLE_ALPHA_FEED_URL }}
+ run: |
+ set -euo pipefail
+
+ pages_dir="$RUNNER_TEMP/powerlens-pages"
+ rm -rf "$pages_dir"
+ mkdir -p "$pages_dir"
+ cp -R docs/. "$pages_dir/"
+
+ fetch_existing_feed() {
+ local url="$1"
+ local destination="$2"
+ local tmp="$destination.tmp"
+
+ if curl -fsSL "$url" -o "$tmp"; then
+ python3 - "$tmp" <<'PY'
+ import sys
+ import xml.etree.ElementTree as ET
+ ET.parse(sys.argv[1])
+ PY
+ mv "$tmp" "$destination"
+ echo "preserved existing feed from $url"
+ else
+ rm -f "$tmp"
+ echo "no existing feed at $url; using repository placeholder for $destination"
+ fi
+ }
+
+ fetch_existing_feed "$STABLE_FEED_URL" "$pages_dir/appcast.xml"
+ fetch_existing_feed "$ALPHA_FEED_URL" "$pages_dir/appcast-alpha.xml"
+
+ relative_appcast="${APPCAST_PATH#docs/}"
+ mkdir -p "$pages_dir/$(dirname "$relative_appcast")"
+ cp "$RUNNER_TEMP/powerlens-appcast/appcast.xml" "$pages_dir/$relative_appcast"
+ python3 - "$pages_dir/$relative_appcast" <<'PY'
+ import sys
+ import xml.etree.ElementTree as ET
+ ET.parse(sys.argv[1])
+ PY
+
+ - name: Upload GitHub Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: ${{ runner.temp }}/powerlens-pages
+
+ deploy-pages:
+ name: Deploy Sparkle Appcast
+ needs: publish
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Deploy GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e547d3a..38c354f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes to PowerLens will be documented in this file.
PowerLens uses `0.x` versioning until the first stable `1.0` release.
+## [0.9.1] - Unreleased
+
+### Added
+
+- Sparkle-based software update checks from the app menu and Settings.
+
+### Changed
+
+- Shared local and release bundle-staging logic between packaging scripts.
+
## [0.9.0] - 2026-05-02
### Added
diff --git a/PRIVACY.md b/PRIVACY.md
index 8e6555e..ac46537 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -9,6 +9,10 @@ dashboard.
PowerLens does not send analytics, telemetry, crash reports, or usage events to
a server.
+If you manually choose **Check for Updates** or enable automatic update checks,
+PowerLens may contact the configured Sparkle appcast URL and GitHub release
+asset URLs to look for a newer version. These requests are only for app updates.
+
Release notarization is handled by Apple during packaging, outside the running
app. The app itself does not need an account or a PowerLens cloud service.
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..737e2dc
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "10ce1437ba8cdf6f7b4ee32bb210fb3ee105e1d18e9ce4b18c059487bfbbbc90",
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "066e75a8b3e99962685d6a90cdd5293ebffd9261",
+ "version" : "2.9.1"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index cfbdcf0..2ab66b1 100644
--- a/Package.swift
+++ b/Package.swift
@@ -7,9 +7,15 @@ let package = Package(
platforms: [
.macOS(.v13),
],
+ dependencies: [
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"),
+ ],
targets: [
.executableTarget(
name: "PowerLens",
+ dependencies: [
+ .product(name: "Sparkle", package: "Sparkle"),
+ ],
resources: [
.process("Resources"),
]
diff --git a/Packaging/Info.plist b/Packaging/Info.plist
index 23776e5..367794d 100644
--- a/Packaging/Info.plist
+++ b/Packaging/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.9.0
+ 0.9.1
CFBundleVersion
1
LSMinimumSystemVersion
@@ -28,5 +28,17 @@
Copyright © 2026 HYEONJIN HAN
NSPrincipalClass
NSApplication
+ SUAutomaticallyUpdate
+
+ SUEnableAutomaticChecks
+
+ SUEnableSystemProfiling
+
+ SUFeedURL
+ https://progresshans.github.io/powerlens/appcast.xml
+ SUAlphaFeedURL
+ https://progresshans.github.io/powerlens/appcast-alpha.xml
+ SUScheduledCheckInterval
+ 86400
diff --git a/Packaging/README.md b/Packaging/README.md
index c99327b..f8f4f8a 100644
--- a/Packaging/README.md
+++ b/Packaging/README.md
@@ -16,7 +16,7 @@ This folder holds distribution metadata for `PowerLens`.
## Release Packaging
Run `script/package_release.sh` to create release artifacts under `release/`.
-By default it builds an unsigned local release and creates:
+By default it builds an ad-hoc signed local release and creates:
- `PowerLens-.app.zip`
- `PowerLens-.dmg`
@@ -25,6 +25,10 @@ By default it builds an unsigned local release and creates:
Set `POWERLENS_SIGN_IDENTITY` to a `Developer ID Application` certificate name
to sign the app and DMG. Set `POWERLENS_NOTARY_PROFILE` to a stored
`notarytool` keychain profile to notarize and staple the app and DMG.
+Set `POWERLENS_SPARKLE_PUBLIC_ED_KEY` to the Sparkle EdDSA public key to enable
+in-app update checks in the resulting build. Set
+`POWERLENS_SPARKLE_ALPHA_FEED_URL` when the alpha update feed should differ
+from the default GitHub Pages URL.
For local use, copy `.env.example` to `.env`, update the identity, then run:
```bash
@@ -41,14 +45,134 @@ set +a
- release packaging creates ZIP and DMG artifacts
- release packaging supports Developer ID signing, notarization, stapling, and
checksum generation when local environment variables are provided
+- release packaging embeds Sparkle and can optionally generate an appcast when
+ local Sparkle signing material is available
- signing certificates and notarization credentials are intentionally local and
are not stored in the repository
-## Next Steps
+## Sparkle Updates
+
+PowerLens uses Sparkle for in-app update checks. The app reads the stable
+update feed from `SUFeedURL`, which defaults to:
+
+```text
+https://progresshans.github.io/powerlens/appcast.xml
+```
+
+The optional alpha update channel reads from `SUAlphaFeedURL`, which defaults
+to:
+
+```text
+https://progresshans.github.io/powerlens/appcast-alpha.xml
+```
+
+Generate a Sparkle EdDSA key once on a maintainer machine:
+
+```bash
+.build/artifacts/sparkle/Sparkle/bin/generate_keys --account powerlens
+```
+
+Put only the printed public key in `POWERLENS_SPARKLE_PUBLIC_ED_KEY`. The
+private key stays in Keychain or another local secret store.
+
+To generate an appcast while packaging, set the optional appcast variables from
+`.env.example`, then run the release script. The script copies the signed app
+ZIP into a temporary appcast directory and invokes Sparkle's `generate_appcast`
+tool. Set `POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast.xml"` for
+stable releases or `POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast-alpha.xml"`
+for alpha releases to copy the generated feed into the local `docs/` directory
+without committing the ZIP archive.
+
+The repository is prepared for GitHub Pages deployed by GitHub Actions. The
+production appcast URL is:
+
+```text
+https://progresshans.github.io/powerlens/appcast.xml
+```
+
+The alpha channel URL is:
+
+```text
+https://progresshans.github.io/powerlens/appcast-alpha.xml
+```
+
+For a local end-to-end update smoke test, run:
+
+```bash
+./script/test_sparkle_update.sh
+```
+
+The test script builds a synthetic older app, builds a newer update archive,
+generates a local appcast, serves it from `127.0.0.1`, and opens the older app.
+It is meant to verify the Sparkle UI and update path before publishing a real
+GitHub Release.
+
+## GitHub Actions
+
+PowerLens has two workflow layers:
+
+- `.github/workflows/ci.yml`
+ - runs on pull requests and pushes to `main`, `develop`, and `feature/**`
+ - runs `swift test`
+ - validates scripts, metadata, and appcast XML
+ - performs an ad-hoc package smoke build without notarization
+- `.github/workflows/release.yml`
+ - runs on `v*` tags, `develop` pushes, or manual dispatch
+ - builds, signs, notarizes, and packages the app
+ - creates or updates a GitHub Release
+ - regenerates the stable or alpha Sparkle appcast
+ - deploys the appcast site through GitHub Pages Actions
+
+Stable releases should normally be published by pushing a version tag such as
+`v0.9.1`, or by manually dispatching the release workflow. Develop branch pushes
+publish alpha prereleases with the GitHub Actions run number, for example
+`0.9.2-alpha.123`. By default, the alpha base version is inferred from the
+latest stable tag by bumping the patch version. Set the optional repository
+variable `POWERLENS_ALPHA_BASE_VERSION` only when the next alpha line should
+target a minor or major version such as `0.10.0` or `1.0.0`.
+
+Set the repository's GitHub Pages source to `GitHub Actions`. The release
+workflow publishes the `docs/` site as a Pages artifact after preserving the
+currently published feed for the other update channel.
+
+The release workflow requires these GitHub Secrets:
+
+- `POWERLENS_SIGN_IDENTITY`
+ - exact codesigning identity, for example
+ `Developer ID Application: HYEONJIN HAN (262HQB69RN)`
+- `POWERLENS_DEVELOPER_ID_APPLICATION_P12_BASE64`
+ - base64-encoded exported Developer ID Application `.p12`
+- `POWERLENS_DEVELOPER_ID_APPLICATION_P12_PASSWORD`
+ - password for the exported `.p12`
+- `POWERLENS_KEYCHAIN_PASSWORD`
+ - temporary CI keychain password
+- `POWERLENS_NOTARY_APPLE_ID`
+ - Apple ID used for notarization
+- `POWERLENS_NOTARY_TEAM_ID`
+ - Apple Developer Team ID
+- `POWERLENS_NOTARY_PASSWORD`
+ - Apple app-specific password for notarization
+- `POWERLENS_SPARKLE_PUBLIC_ED_KEY`
+ - Sparkle public EdDSA key embedded in the app
+- `POWERLENS_SPARKLE_PRIVATE_ED_KEY`
+ - exported Sparkle private EdDSA key used only to sign appcasts
+
+The private Sparkle key can be exported on a maintainer Mac with:
+
+```bash
+.build/artifacts/sparkle/Sparkle/bin/generate_keys --account powerlens -x ./sparkle-private-ed-key.txt
+```
+
+Store the file contents in the `POWERLENS_SPARKLE_PRIVATE_ED_KEY` secret, then
+delete the exported local file.
+
+## Release Checklist
1. build a release with `POWERLENS_SIGN_IDENTITY` and
`POWERLENS_NOTARY_PROFILE`
2. validate the resulting app bundle with `script/verify_distribution.sh`
3. install the DMG on a clean macOS user account and confirm first-launch
Gatekeeper behavior
-4. publish the DMG, ZIP, checksum file, and release notes together
+4. generate and publish the Sparkle appcast when the release should be visible
+ to in-app update checks
+5. publish the DMG, ZIP, checksum file, and release notes together
diff --git a/README.md b/README.md
index a870ade..b83a712 100644
--- a/README.md
+++ b/README.md
@@ -89,9 +89,9 @@ PowerLens watches for common power situations:
PowerLens includes app-language settings, telemetry-engine selection, menu bar
-display styles, and Dock visibility control. The telemetry engine can run in
-automatic, compatible, or live precision mode depending on the Mac and the data
-available.
+display styles, Dock visibility control, update checking, and Stable/Alpha
+update-channel selection. The telemetry engine can run in automatic,
+compatible, or live precision mode depending on the Mac and the data available.
### Local History
@@ -117,6 +117,9 @@ comparisons without sending your data anywhere.
4. Launch PowerLens from `Applications`.
Release builds are distributed as Developer ID signed and notarized macOS apps.
+PowerLens can check for updates from the app menu or Settings when a release
+build includes the configured Sparkle update feed. The update feed is published
+through GitHub Pages by the release workflow.
### Verify The Download
diff --git a/Sources/PowerLens/AppDelegate.swift b/Sources/PowerLens/AppDelegate.swift
index 89fba88..7c445b6 100644
--- a/Sources/PowerLens/AppDelegate.swift
+++ b/Sources/PowerLens/AppDelegate.swift
@@ -4,6 +4,7 @@ import Combine
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
let store = PowerLensStore()
+ let softwareUpdateController = SoftwareUpdateController()
private let dashboardSceneController = DashboardSceneController()
private let presentationController = ApplicationPresentationController()
@@ -41,6 +42,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
_ = statusItemController
_ = popoverPresenter
observeChanges()
+ softwareUpdateController.startIfConfigured()
updateStatusItem(using: store.latest)
updateRefreshCadence()
@@ -76,6 +78,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
focusManagedWindow(identifier: PowerLensWindowIdentifier.settings)
}
+ func checkForUpdates() {
+ softwareUpdateController.checkForUpdates()
+ }
+
private func observeChanges() {
store.$latest
.receive(on: RunLoop.main)
diff --git a/Sources/PowerLens/AppPreferences.swift b/Sources/PowerLens/AppPreferences.swift
index e5d2db3..aae674b 100644
--- a/Sources/PowerLens/AppPreferences.swift
+++ b/Sources/PowerLens/AppPreferences.swift
@@ -59,3 +59,95 @@ enum MenuBarDisplayStylePreference: String, CaseIterable, Identifiable {
}
}
}
+
+enum UpdateChannelPreference: String, CaseIterable, Identifiable {
+ case stable
+ case alpha
+
+ static let storageKey = "updateChannel"
+ static let defaultValue = Self.stable.rawValue
+ static let stableFeedInfoKey = "SUFeedURL"
+ static let alphaFeedInfoKey = "SUAlphaFeedURL"
+ static let fallbackStableFeedURL = "https://progresshans.github.io/powerlens/appcast.xml"
+ static let fallbackAlphaFeedURL = "https://progresshans.github.io/powerlens/appcast-alpha.xml"
+
+ var id: String { rawValue }
+
+ static var current: Self {
+ guard let rawValue = UserDefaults.standard.string(forKey: storageKey),
+ let channel = Self(rawValue: rawValue) else {
+ return .stable
+ }
+
+ return channel
+ }
+
+ var title: String {
+ switch self {
+ case .stable:
+ L10n.text("updates.channel.stable")
+ case .alpha:
+ L10n.text("updates.channel.alpha")
+ }
+ }
+
+ var detail: String {
+ switch self {
+ case .stable:
+ L10n.text("updates.channel.stable.detail")
+ case .alpha:
+ L10n.text("updates.channel.alpha.detail")
+ }
+ }
+
+ static var currentFeedURLString: String? {
+ feedURLString(for: current)
+ }
+
+ static func feedURLString(for channel: Self, bundle: Bundle = .main) -> String? {
+ resolvedFeedURLString(
+ for: channel,
+ stableFeedURLString: bundle.object(forInfoDictionaryKey: stableFeedInfoKey) as? String,
+ alphaFeedURLString: bundle.object(forInfoDictionaryKey: alphaFeedInfoKey) as? String
+ )
+ }
+
+ static func resolvedFeedURLString(
+ for channel: Self,
+ stableFeedURLString: String?,
+ alphaFeedURLString: String?
+ ) -> String? {
+ let stableFeed = normalizedFeedURLString(stableFeedURLString) ?? fallbackStableFeedURL
+
+ switch channel {
+ case .stable:
+ return stableFeed
+ case .alpha:
+ return normalizedFeedURLString(alphaFeedURLString)
+ ?? Self.alphaFeedURLString(derivedFromStableFeedURLString: stableFeed)
+ ?? fallbackAlphaFeedURL
+ }
+ }
+
+ private static func normalizedFeedURLString(_ value: String?) -> String? {
+ guard let value else {
+ return nil
+ }
+
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private static func alphaFeedURLString(derivedFromStableFeedURLString stableFeedURLString: String) -> String? {
+ guard var components = URLComponents(string: stableFeedURLString),
+ !components.path.isEmpty else {
+ return nil
+ }
+
+ let directory = (components.path as NSString).deletingLastPathComponent
+ components.path = directory == "/" || directory.isEmpty
+ ? "/appcast-alpha.xml"
+ : "\(directory)/appcast-alpha.xml"
+ return components.string
+ }
+}
diff --git a/Sources/PowerLens/DashboardScene.swift b/Sources/PowerLens/DashboardScene.swift
index 0471a54..d10ea0d 100644
--- a/Sources/PowerLens/DashboardScene.swift
+++ b/Sources/PowerLens/DashboardScene.swift
@@ -72,10 +72,14 @@ struct DashboardSceneRootView: View {
struct SettingsSceneRootView: View {
@ObservedObject var store: PowerLensStore
+ @ObservedObject var softwareUpdateController: SoftwareUpdateController
@AppStorage(AppLanguage.storageKey) private var appLanguage = AppLanguage.system.rawValue
var body: some View {
- SettingsView(store: store)
+ SettingsView(
+ store: store,
+ softwareUpdateController: softwareUpdateController
+ )
.environment(\.locale, L10n.locale)
.id(appLanguage)
.background(
diff --git a/Sources/PowerLens/PowerLensApp.swift b/Sources/PowerLens/PowerLensApp.swift
index 05a1903..780cf1d 100644
--- a/Sources/PowerLens/PowerLensApp.swift
+++ b/Sources/PowerLens/PowerLensApp.swift
@@ -29,6 +29,13 @@ struct PowerLensApp: App {
)
}
.commands {
+ CommandGroup(after: .appInfo) {
+ Button(L10n.text("updates.check")) {
+ appDelegate.checkForUpdates()
+ }
+ .keyboardShortcut("u", modifiers: [.command, .shift])
+ }
+
CommandGroup(replacing: .appSettings) {
Button(L10n.text("ui.section.settings")) {
appDelegate.openSettingsWindow()
@@ -38,7 +45,10 @@ struct PowerLensApp: App {
}
Window(L10n.text("ui.window.settings"), id: PowerLensSceneID.settings) {
- SettingsSceneRootView(store: appDelegate.store)
+ SettingsSceneRootView(
+ store: appDelegate.store,
+ softwareUpdateController: appDelegate.softwareUpdateController
+ )
}
}
}
diff --git a/Sources/PowerLens/Resources/en.lproj/Localizable.strings b/Sources/PowerLens/Resources/en.lproj/Localizable.strings
index ef3ba3d..92f995e 100644
--- a/Sources/PowerLens/Resources/en.lproj/Localizable.strings
+++ b/Sources/PowerLens/Resources/en.lproj/Localizable.strings
@@ -44,10 +44,27 @@
"settings.telemetry.status" = "Telemetry Status";
"settings.currentMode" = "Current Mode";
+"updates.check" = "Check for Updates…";
+"updates.check.button" = "Check Now";
+"updates.check.description" = "Check GitHub Releases using PowerLens' update feed.";
+"updates.channel" = "Update Channel";
+"updates.channel.stable" = "Stable";
+"updates.channel.alpha" = "Alpha";
+"updates.channel.stable.detail" = "Use signed public releases intended for everyday use.";
+"updates.channel.alpha.detail" = "Use prerelease builds from the alpha feed. These may change more often.";
+"updates.automatic" = "Automatically check for updates";
+"updates.automatic.description" = "PowerLens can periodically check the update feed in the background.";
+"updates.notConfigured" = "Available in signed release builds with an update feed.";
+"updates.unconfigured.title" = "Updates Are Not Configured";
+"updates.unconfigured.message" = "This build does not include a Sparkle feed URL and public update key yet.";
+"updates.startFailed.title" = "Updates Could Not Start";
+"updates.startFailed.message" = "PowerLens could not start the update checker. Check the Sparkle feed URL and public update key in this build.";
+
"common.unknown" = "Unknown";
"common.none" = "--";
"common.on" = "On";
"common.off" = "Off";
+"common.ok" = "OK";
"common.quit" = "Quit";
"powerSource.ac" = "AC";
diff --git a/Sources/PowerLens/Resources/ko.lproj/Localizable.strings b/Sources/PowerLens/Resources/ko.lproj/Localizable.strings
index 7a77f85..77b8cd1 100644
--- a/Sources/PowerLens/Resources/ko.lproj/Localizable.strings
+++ b/Sources/PowerLens/Resources/ko.lproj/Localizable.strings
@@ -44,10 +44,27 @@
"settings.telemetry.status" = "텔레메트리 상태";
"settings.currentMode" = "현재 모드";
+"updates.check" = "업데이트 확인…";
+"updates.check.button" = "지금 확인";
+"updates.check.description" = "PowerLens 업데이트 피드를 통해 GitHub Releases를 확인합니다.";
+"updates.channel" = "업데이트 채널";
+"updates.channel.stable" = "Stable";
+"updates.channel.alpha" = "Alpha";
+"updates.channel.stable.detail" = "일상적으로 쓰기 위한 서명된 공개 릴리스를 받습니다.";
+"updates.channel.alpha.detail" = "알파 피드의 시험 빌드를 받습니다. 더 자주 바뀔 수 있습니다.";
+"updates.automatic" = "업데이트 자동 확인";
+"updates.automatic.description" = "PowerLens가 백그라운드에서 주기적으로 업데이트 피드를 확인합니다.";
+"updates.notConfigured" = "업데이트 피드가 포함된 서명된 릴리스 빌드에서 사용할 수 있습니다.";
+"updates.unconfigured.title" = "업데이트가 설정되지 않음";
+"updates.unconfigured.message" = "이 빌드에는 아직 Sparkle 피드 URL과 공개 업데이트 키가 포함되어 있지 않습니다.";
+"updates.startFailed.title" = "업데이트 확인을 시작할 수 없음";
+"updates.startFailed.message" = "PowerLens가 업데이트 확인기를 시작하지 못했습니다. 이 빌드의 Sparkle 피드 URL과 공개 업데이트 키를 확인해 주세요.";
+
"common.unknown" = "알 수 없음";
"common.none" = "--";
"common.on" = "켬";
"common.off" = "끔";
+"common.ok" = "확인";
"common.quit" = "종료";
"powerSource.ac" = "전원";
diff --git a/Sources/PowerLens/SettingsView.swift b/Sources/PowerLens/SettingsView.swift
index 18d07a9..6582965 100644
--- a/Sources/PowerLens/SettingsView.swift
+++ b/Sources/PowerLens/SettingsView.swift
@@ -2,10 +2,12 @@ import SwiftUI
struct SettingsView: View {
@ObservedObject var store: PowerLensStore
+ @ObservedObject var softwareUpdateController: SoftwareUpdateController
@AppStorage(AppLanguage.storageKey) private var appLanguage = AppLanguage.system.rawValue
@AppStorage(TelemetryEnginePreference.storageKey) private var telemetryEnginePreference = TelemetryEnginePreference.auto.rawValue
@AppStorage(DockIconPreference.storageKey) private var showDockIcon = DockIconPreference.defaultValue
@AppStorage(MenuBarDisplayStylePreference.storageKey) private var menuBarDisplayStyle = MenuBarDisplayStylePreference.defaultValue
+ @AppStorage(UpdateChannelPreference.storageKey) private var updateChannel = UpdateChannelPreference.defaultValue
@SceneStorage("settings.selectedPane") private var selectedPaneRaw = SettingsPane.general.rawValue
private var selectedPane: SettingsPane {
@@ -30,6 +32,9 @@ struct SettingsView: View {
.onChange(of: telemetryEnginePreference) { _ in
store.refreshNow()
}
+ .onChange(of: updateChannel) { _ in
+ softwareUpdateController.updateChannelPreferenceChanged()
+ }
}
private var sidebar: some View {
@@ -160,6 +165,58 @@ struct SettingsView: View {
SettingsDivider()
+ PreferenceRow(
+ title: L10n.text("updates.channel"),
+ detail: (UpdateChannelPreference(rawValue: updateChannel) ?? .stable).detail
+ ) {
+ Picker(L10n.text("updates.channel"), selection: $updateChannel) {
+ ForEach(UpdateChannelPreference.allCases) { channel in
+ Text(channel.title).tag(channel.rawValue)
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.segmented)
+ .frame(width: 220)
+ .disabled(!softwareUpdateController.isConfigured)
+ }
+
+ SettingsDivider()
+
+ PreferenceRow(
+ title: L10n.text("updates.check"),
+ detail: softwareUpdateController.isConfigured
+ ? L10n.text("updates.check.description")
+ : L10n.text("updates.notConfigured")
+ ) {
+ Button(L10n.text("updates.check.button")) {
+ softwareUpdateController.checkForUpdates()
+ }
+ .disabled(!softwareUpdateController.canCheckForUpdates)
+ .controlSize(.regular)
+ }
+
+ SettingsDivider()
+
+ PreferenceRow(
+ title: L10n.text("updates.automatic"),
+ detail: softwareUpdateController.isConfigured
+ ? L10n.text("updates.automatic.description")
+ : L10n.text("updates.notConfigured")
+ ) {
+ Toggle(
+ L10n.text("updates.automatic"),
+ isOn: Binding(
+ get: { softwareUpdateController.automaticallyChecksForUpdates },
+ set: { softwareUpdateController.automaticallyChecksForUpdates = $0 }
+ )
+ )
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .disabled(!softwareUpdateController.isConfigured)
+ }
+
+ SettingsDivider()
+
ValueRow(
title: L10n.text("settings.currentMode"),
detail: showDockIcon
diff --git a/Sources/PowerLens/SoftwareUpdateController.swift b/Sources/PowerLens/SoftwareUpdateController.swift
new file mode 100644
index 0000000..f3d3dd5
--- /dev/null
+++ b/Sources/PowerLens/SoftwareUpdateController.swift
@@ -0,0 +1,147 @@
+import AppKit
+import Sparkle
+
+@MainActor
+final class SoftwareUpdateController: NSObject, ObservableObject, SPUUpdaterDelegate {
+ private lazy var updaterController = SPUStandardUpdaterController(
+ startingUpdater: false,
+ updaterDelegate: self,
+ userDriverDelegate: nil
+ )
+ private var didStartUpdater = false
+ private var updaterStartErrorDescription: String?
+
+ override init() {
+ super.init()
+ }
+
+ var isConfigured: Bool {
+ Self.hasConfiguredSparkleMetadata
+ }
+
+ var canCheckForUpdates: Bool {
+ guard isConfigured else {
+ return true
+ }
+
+ guard didStartUpdater else {
+ return true
+ }
+
+ return updaterController.updater.canCheckForUpdates
+ }
+
+ var automaticallyChecksForUpdates: Bool {
+ get {
+ guard isConfigured, didStartUpdater else {
+ return false
+ }
+
+ return updaterController.updater.automaticallyChecksForUpdates
+ }
+ set {
+ guard isConfigured else {
+ showUnconfiguredAlert()
+ return
+ }
+
+ guard startIfConfigured() else {
+ showStartFailureAlert()
+ return
+ }
+
+ updaterController.updater.automaticallyChecksForUpdates = newValue
+ objectWillChange.send()
+ }
+ }
+
+ @discardableResult
+ func startIfConfigured() -> Bool {
+ guard isConfigured else {
+ return false
+ }
+
+ guard !didStartUpdater else {
+ return true
+ }
+
+ do {
+ try updaterController.updater.start()
+ didStartUpdater = true
+ updaterStartErrorDescription = nil
+ objectWillChange.send()
+ return true
+ } catch {
+ updaterStartErrorDescription = error.localizedDescription
+ NSLog("PowerLens Sparkle updater failed to start: \(error.localizedDescription)")
+ objectWillChange.send()
+ return false
+ }
+ }
+
+ func checkForUpdates() {
+ guard isConfigured else {
+ showUnconfiguredAlert()
+ return
+ }
+
+ guard startIfConfigured() else {
+ showStartFailureAlert()
+ return
+ }
+
+ updaterController.checkForUpdates(nil)
+ }
+
+ func updateChannelPreferenceChanged() {
+ guard isConfigured, didStartUpdater else {
+ objectWillChange.send()
+ return
+ }
+
+ updaterController.updater.resetUpdateCycle()
+ objectWillChange.send()
+ }
+
+ func feedURLString(for updater: SPUUpdater) -> String? {
+ UpdateChannelPreference.currentFeedURLString
+ }
+
+ private func showUnconfiguredAlert() {
+ let alert = NSAlert()
+ alert.messageText = L10n.text("updates.unconfigured.title")
+ alert.informativeText = L10n.text("updates.unconfigured.message")
+ alert.alertStyle = .informational
+ alert.addButton(withTitle: L10n.text("common.ok"))
+ alert.runModal()
+ }
+
+ private func showStartFailureAlert() {
+ let alert = NSAlert()
+ alert.messageText = L10n.text("updates.startFailed.title")
+ alert.informativeText = startFailureMessage
+ alert.alertStyle = .warning
+ alert.addButton(withTitle: L10n.text("common.ok"))
+ alert.runModal()
+ }
+
+ private var startFailureMessage: String {
+ let message = L10n.text("updates.startFailed.message")
+ guard let updaterStartErrorDescription,
+ !updaterStartErrorDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return message
+ }
+
+ return "\(message)\n\n\(updaterStartErrorDescription)"
+ }
+
+ private static var hasConfiguredSparkleMetadata: Bool {
+ guard UpdateChannelPreference.feedURLString(for: .stable) != nil,
+ let publicKey = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String,
+ !publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return false
+ }
+
+ return true
+ }
+}
diff --git a/Tests/PowerLensTests/AppPreferencesTests.swift b/Tests/PowerLensTests/AppPreferencesTests.swift
index 86e6a74..fbbb420 100644
--- a/Tests/PowerLensTests/AppPreferencesTests.swift
+++ b/Tests/PowerLensTests/AppPreferencesTests.swift
@@ -48,6 +48,72 @@ struct AppPreferencesTests {
#expect(MenuBarDisplayStylePreference.current == .nativeBattery)
}
+ @Test
+ func updateChannelDefaultsToStable() {
+ let defaults = UserDefaults.standard
+ let previousValue = defaults.object(forKey: UpdateChannelPreference.storageKey)
+ defer { restore(previousValue, forKey: UpdateChannelPreference.storageKey, in: defaults) }
+
+ defaults.removeObject(forKey: UpdateChannelPreference.storageKey)
+
+ #expect(UpdateChannelPreference.current == .stable)
+ }
+
+ @Test
+ func updateChannelReadsStoredValue() {
+ let defaults = UserDefaults.standard
+ let previousValue = defaults.object(forKey: UpdateChannelPreference.storageKey)
+ defer { restore(previousValue, forKey: UpdateChannelPreference.storageKey, in: defaults) }
+
+ defaults.set(UpdateChannelPreference.alpha.rawValue, forKey: UpdateChannelPreference.storageKey)
+
+ #expect(UpdateChannelPreference.current == .alpha)
+ }
+
+ @Test
+ func updateChannelStableFeedUsesConfiguredStableURL() {
+ let feed = UpdateChannelPreference.resolvedFeedURLString(
+ for: .stable,
+ stableFeedURLString: " https://example.com/powerlens/appcast.xml ",
+ alphaFeedURLString: nil
+ )
+
+ #expect(feed == "https://example.com/powerlens/appcast.xml")
+ }
+
+ @Test
+ func updateChannelAlphaFeedUsesConfiguredAlphaURL() {
+ let feed = UpdateChannelPreference.resolvedFeedURLString(
+ for: .alpha,
+ stableFeedURLString: "https://example.com/powerlens/appcast.xml",
+ alphaFeedURLString: " https://example.com/powerlens/appcast-alpha.xml "
+ )
+
+ #expect(feed == "https://example.com/powerlens/appcast-alpha.xml")
+ }
+
+ @Test
+ func updateChannelAlphaFeedDerivesFromConfiguredStableURL() {
+ let feed = UpdateChannelPreference.resolvedFeedURLString(
+ for: .alpha,
+ stableFeedURLString: "http://127.0.0.1:18080/updates/appcast.xml",
+ alphaFeedURLString: nil
+ )
+
+ #expect(feed == "http://127.0.0.1:18080/updates/appcast-alpha.xml")
+ }
+
+ @Test
+ func updateChannelFallsBackToStableProductionFeed() {
+ let feed = UpdateChannelPreference.resolvedFeedURLString(
+ for: .stable,
+ stableFeedURLString: nil,
+ alphaFeedURLString: nil
+ )
+
+ #expect(feed == UpdateChannelPreference.fallbackStableFeedURL)
+ }
+
private func restore(_ value: Any?, forKey key: String, in defaults: UserDefaults) {
if let value {
defaults.set(value, forKey: key)
diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/docs/.nojekyll
@@ -0,0 +1 @@
+
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..5f73305
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,69 @@
+# PowerLens GitHub Pages
+
+This directory is the static GitHub Pages content for PowerLens update feeds.
+The release workflow uploads this directory as a GitHub Pages artifact after
+regenerating the target Sparkle appcast.
+
+When GitHub Pages is configured to deploy from GitHub Actions,
+`appcast.xml` is served at:
+
+```text
+https://progresshans.github.io/powerlens/appcast.xml
+```
+
+That URL is the default stable Sparkle feed URL embedded in release builds.
+Alpha updates use the adjacent feed:
+
+```text
+https://progresshans.github.io/powerlens/appcast-alpha.xml
+```
+
+## Appcast Workflow
+
+`docs/appcast.xml` and `docs/appcast-alpha.xml` start as valid empty feeds so
+update checks do not hit a 404 before the first Sparkle-visible release is
+published. For actual releases, the `Publish Release` GitHub Actions workflow
+generates the target feed from the exact signed ZIP uploaded to GitHub Releases.
+
+For a manual local package, use a temporary work directory for Sparkle's
+generated files, then copy only the stable feed into `docs/appcast.xml`:
+
+```bash
+set -a
+source .env
+set +a
+
+POWERLENS_VERSION=0.9.1 \
+POWERLENS_BUILD=2 \
+POWERLENS_SPARKLE_GENERATE_APPCAST=1 \
+POWERLENS_SPARKLE_APPCAST_DIR="$PWD/release/appcast-work" \
+POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast.xml" \
+POWERLENS_SPARKLE_DOWNLOAD_URL_PREFIX="https://github.com/progresshans/powerlens/releases/download/v0.9.1/" \
+./script/package_release.sh
+```
+
+Upload the matching `release/PowerLens-0.9.1.app.zip` to the same GitHub
+Release referenced by the download URL prefix. If the ZIP is rebuilt after the
+appcast is generated, regenerate `docs/appcast.xml` before publishing.
+
+For an alpha release, use the same workflow with an alpha version/tag and copy
+the generated feed into `docs/appcast-alpha.xml`:
+
+```bash
+POWERLENS_VERSION=0.9.2-alpha.1 \
+POWERLENS_BUILD=3 \
+POWERLENS_SPARKLE_GENERATE_APPCAST=1 \
+POWERLENS_SPARKLE_APPCAST_DIR="$PWD/release/appcast-alpha-work" \
+POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH="$PWD/docs/appcast-alpha.xml" \
+POWERLENS_SPARKLE_DOWNLOAD_URL_PREFIX="https://github.com/progresshans/powerlens/releases/download/v0.9.2-alpha.1/" \
+./script/package_release.sh
+```
+
+## GitHub Pages Settings
+
+In GitHub:
+
+1. Open the repository settings.
+2. Go to `Pages`.
+3. Set the source to `GitHub Actions`.
+4. Save and let the release workflow deploy the Pages artifact.
diff --git a/docs/appcast-alpha.xml b/docs/appcast-alpha.xml
new file mode 100644
index 0000000..d1090ef
--- /dev/null
+++ b/docs/appcast-alpha.xml
@@ -0,0 +1,11 @@
+
+
+
+ PowerLens Alpha Updates
+ https://github.com/progresshans/powerlens/releases
+ PowerLens Sparkle alpha update feed.
+ en
+
+
diff --git a/docs/appcast.xml b/docs/appcast.xml
new file mode 100644
index 0000000..fc002fc
--- /dev/null
+++ b/docs/appcast.xml
@@ -0,0 +1,15 @@
+
+
+
+ PowerLens
+ -
+ 0.9.1
+ Tue, 05 May 2026 04:16:47 +0900
+ 2
+ 0.9.1
+ 13.0
+ arm64
+
+
+
+
\ No newline at end of file
diff --git a/script/build_and_run.sh b/script/build_and_run.sh
index acc1618..3d655ca 100755
--- a/script/build_and_run.sh
+++ b/script/build_and_run.sh
@@ -2,35 +2,38 @@
set -euo pipefail
MODE="${1:-run}"
-APP_NAME="PowerLens"
-BUNDLE_ID="com.progresshans.powerlens"
-MIN_SYSTEM_VERSION="13.0"
-
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-PACKAGING_DIR="$ROOT_DIR/Packaging"
+source "$ROOT_DIR/script/lib/powerlens_packaging.sh"
+
+APP_NAME="$POWERLENS_APP_NAME"
+BUNDLE_ID="$POWERLENS_BUNDLE_ID"
DIST_DIR="$ROOT_DIR/dist"
APP_BUNDLE="$DIST_DIR/$APP_NAME.app"
APP_CONTENTS="$APP_BUNDLE/Contents"
APP_MACOS="$APP_CONTENTS/MacOS"
APP_RESOURCES="$APP_CONTENTS/Resources"
+APP_FRAMEWORKS="$APP_CONTENTS/Frameworks"
APP_BINARY="$APP_MACOS/$APP_NAME"
INFO_PLIST="$APP_CONTENTS/Info.plist"
-SOURCE_INFO_PLIST="$PACKAGING_DIR/Info.plist"
-SOURCE_ICON="$PACKAGING_DIR/AppIcon.icns"
-DEFAULT_VERSION="0.9.0"
-DEFAULT_BUILD="$(git -C "$ROOT_DIR" rev-list --count HEAD 2>/dev/null || echo 1)"
+SOURCE_INFO_PLIST="$POWERLENS_SOURCE_INFO_PLIST"
+DEFAULT_VERSION="0.9.1"
+DEFAULT_BUILD="$(powerlens_default_build_number)"
VERSION="${POWERLENS_VERSION:-$DEFAULT_VERSION}"
BUILD_NUMBER="${POWERLENS_BUILD:-$DEFAULT_BUILD}"
+SPARKLE_FEED_URL="${POWERLENS_SPARKLE_FEED_URL:-https://progresshans.github.io/powerlens/appcast.xml}"
+SPARKLE_ALPHA_FEED_URL="${POWERLENS_SPARKLE_ALPHA_FEED_URL:-https://progresshans.github.io/powerlens/appcast-alpha.xml}"
+SPARKLE_PUBLIC_ED_KEY="${POWERLENS_SPARKLE_PUBLIC_ED_KEY:-}"
kill_running_app() {
pkill -x "$APP_NAME" >/dev/null 2>&1 || true
}
require_packaging_inputs() {
- if [[ ! -f "$SOURCE_INFO_PLIST" ]]; then
- echo "missing packaging plist: $SOURCE_INFO_PLIST" >&2
- exit 2
- fi
+ powerlens_require_file "$SOURCE_INFO_PLIST"
+}
+
+sign_bundle_for_local_run() {
+ codesign --force --deep --sign - "$APP_BUNDLE" >/dev/null
}
build_bundle() {
@@ -41,32 +44,17 @@ build_bundle() {
local build_binary
build_dir="$(swift build --show-bin-path)"
build_binary="$build_dir/$APP_NAME"
- local resource_bundle="$build_dir/${APP_NAME}_${APP_NAME}.bundle"
rm -rf "$APP_BUNDLE"
- mkdir -p "$APP_MACOS" "$APP_RESOURCES"
+ mkdir -p "$APP_MACOS" "$APP_RESOURCES" "$APP_FRAMEWORKS"
cp "$build_binary" "$APP_BINARY"
chmod +x "$APP_BINARY"
- if [[ -d "$resource_bundle" ]]; then
- cp -R "$resource_bundle" "$APP_BUNDLE/"
- fi
+ powerlens_copy_sparkle_framework "$APP_FRAMEWORKS" "$APP_BINARY"
+ powerlens_copy_resource_bundle "$build_dir" "$APP_RESOURCES"
cp "$SOURCE_INFO_PLIST" "$INFO_PLIST"
- /usr/libexec/PlistBuddy -c "Set :CFBundleExecutable $APP_NAME" "$INFO_PLIST"
- /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$INFO_PLIST"
- /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$INFO_PLIST"
- /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$INFO_PLIST"
- /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$INFO_PLIST"
- /usr/libexec/PlistBuddy -c "Set :LSMinimumSystemVersion $MIN_SYSTEM_VERSION" "$INFO_PLIST"
-
- if [[ -f "$SOURCE_ICON" ]]; then
- cp "$SOURCE_ICON" "$APP_RESOURCES/AppIcon.icns"
- if /usr/libexec/PlistBuddy -c "Print :CFBundleIconFile" "$INFO_PLIST" >/dev/null 2>&1; then
- /usr/libexec/PlistBuddy -c "Set :CFBundleIconFile AppIcon" "$INFO_PLIST"
- else
- /usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string AppIcon" "$INFO_PLIST"
- fi
- fi
+ powerlens_apply_common_info_plist "$INFO_PLIST" "$VERSION" "$BUILD_NUMBER" "$SPARKLE_FEED_URL" "$SPARKLE_ALPHA_FEED_URL" "$SPARKLE_PUBLIC_ED_KEY"
+ powerlens_copy_app_icon "$INFO_PLIST" "$APP_RESOURCES"
}
open_app() {
@@ -76,6 +64,7 @@ open_app() {
require_packaging_inputs
kill_running_app
build_bundle
+sign_bundle_for_local_run
case "$MODE" in
run)
diff --git a/script/lib/powerlens_packaging.sh b/script/lib/powerlens_packaging.sh
new file mode 100644
index 0000000..99c5f8f
--- /dev/null
+++ b/script/lib/powerlens_packaging.sh
@@ -0,0 +1,141 @@
+POWERLENS_ROOT_DIR="${POWERLENS_ROOT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
+POWERLENS_APP_NAME="${POWERLENS_APP_NAME:-PowerLens}"
+POWERLENS_BUNDLE_ID="${POWERLENS_BUNDLE_ID:-com.progresshans.powerlens}"
+POWERLENS_MIN_SYSTEM_VERSION="${POWERLENS_MIN_SYSTEM_VERSION:-13.0}"
+POWERLENS_PACKAGING_DIR="${POWERLENS_PACKAGING_DIR:-$POWERLENS_ROOT_DIR/Packaging}"
+POWERLENS_SOURCE_INFO_PLIST="${POWERLENS_SOURCE_INFO_PLIST:-$POWERLENS_PACKAGING_DIR/Info.plist}"
+POWERLENS_SOURCE_ICON="${POWERLENS_SOURCE_ICON:-$POWERLENS_PACKAGING_DIR/AppIcon.icns}"
+POWERLENS_ENTITLEMENTS="${POWERLENS_ENTITLEMENTS:-$POWERLENS_PACKAGING_DIR/PowerLens.entitlements}"
+POWERLENS_SPARKLE_FRAMEWORK_SOURCE="${POWERLENS_SPARKLE_FRAMEWORK_SOURCE:-$POWERLENS_ROOT_DIR/.build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework}"
+POWERLENS_SPARKLE_GENERATE_APPCAST_TOOL="${POWERLENS_SPARKLE_GENERATE_APPCAST_TOOL:-$POWERLENS_ROOT_DIR/.build/artifacts/sparkle/Sparkle/bin/generate_appcast}"
+
+powerlens_default_build_number() {
+ git -C "$POWERLENS_ROOT_DIR" rev-list --count HEAD 2>/dev/null || echo 1
+}
+
+powerlens_require_file() {
+ local path="$1"
+ if [[ ! -f "$path" ]]; then
+ echo "missing required file: $path" >&2
+ exit 2
+ fi
+}
+
+powerlens_require_directory() {
+ local path="$1"
+ if [[ ! -d "$path" ]]; then
+ echo "missing required directory: $path" >&2
+ exit 2
+ fi
+}
+
+powerlens_set_plist_string() {
+ local plist="$1"
+ local key="$2"
+ local value="$3"
+
+ if /usr/libexec/PlistBuddy -c "Print :$key" "$plist" >/dev/null 2>&1; then
+ /usr/libexec/PlistBuddy -c "Set :$key $value" "$plist"
+ else
+ /usr/libexec/PlistBuddy -c "Add :$key string $value" "$plist"
+ fi
+}
+
+powerlens_set_plist_bool() {
+ local plist="$1"
+ local key="$2"
+ local value="$3"
+
+ if /usr/libexec/PlistBuddy -c "Print :$key" "$plist" >/dev/null 2>&1; then
+ /usr/libexec/PlistBuddy -c "Set :$key $value" "$plist"
+ else
+ /usr/libexec/PlistBuddy -c "Add :$key bool $value" "$plist"
+ fi
+}
+
+powerlens_set_plist_integer() {
+ local plist="$1"
+ local key="$2"
+ local value="$3"
+
+ if /usr/libexec/PlistBuddy -c "Print :$key" "$plist" >/dev/null 2>&1; then
+ /usr/libexec/PlistBuddy -c "Set :$key $value" "$plist"
+ else
+ /usr/libexec/PlistBuddy -c "Add :$key integer $value" "$plist"
+ fi
+}
+
+powerlens_delete_plist_key_if_present() {
+ local plist="$1"
+ local key="$2"
+ /usr/libexec/PlistBuddy -c "Delete :$key" "$plist" >/dev/null 2>&1 || true
+}
+
+powerlens_strip_extended_attributes() {
+ local path="$1"
+
+ if command -v xattr >/dev/null 2>&1 && [[ -e "$path" ]]; then
+ xattr -cr "$path" 2>/dev/null || true
+ fi
+}
+
+powerlens_copy_sparkle_framework() {
+ local app_frameworks="$1"
+ local app_binary="$2"
+
+ powerlens_require_directory "$POWERLENS_SPARKLE_FRAMEWORK_SOURCE"
+ ditto --noqtn --noextattr "$POWERLENS_SPARKLE_FRAMEWORK_SOURCE" "$app_frameworks/Sparkle.framework"
+ install_name_tool -add_rpath "@executable_path/../Frameworks" "$app_binary" 2>/dev/null || true
+}
+
+powerlens_copy_resource_bundle() {
+ local build_dir="$1"
+ local app_resources="$2"
+ local resource_bundle="$build_dir/${POWERLENS_APP_NAME}_${POWERLENS_APP_NAME}.bundle"
+
+ if [[ -d "$resource_bundle" ]]; then
+ ditto --noqtn --noextattr "$resource_bundle" "$app_resources/$(basename "$resource_bundle")"
+ fi
+}
+
+powerlens_apply_common_info_plist() {
+ local plist="$1"
+ local version="$2"
+ local build_number="$3"
+ local sparkle_feed_url="$4"
+ local sparkle_alpha_feed_url="$5"
+ local sparkle_public_ed_key="$6"
+ local include_display_name="${7:-0}"
+
+ powerlens_set_plist_string "$plist" "CFBundleExecutable" "$POWERLENS_APP_NAME"
+ powerlens_set_plist_string "$plist" "CFBundleIdentifier" "$POWERLENS_BUNDLE_ID"
+ powerlens_set_plist_string "$plist" "CFBundleName" "$POWERLENS_APP_NAME"
+ if [[ "$include_display_name" == "1" ]]; then
+ powerlens_set_plist_string "$plist" "CFBundleDisplayName" "$POWERLENS_APP_NAME"
+ fi
+ powerlens_set_plist_string "$plist" "CFBundleShortVersionString" "$version"
+ powerlens_set_plist_string "$plist" "CFBundleVersion" "$build_number"
+ powerlens_set_plist_string "$plist" "LSMinimumSystemVersion" "$POWERLENS_MIN_SYSTEM_VERSION"
+ powerlens_set_plist_string "$plist" "SUFeedURL" "$sparkle_feed_url"
+ powerlens_set_plist_string "$plist" "SUAlphaFeedURL" "$sparkle_alpha_feed_url"
+ powerlens_set_plist_bool "$plist" "SUAutomaticallyUpdate" "false"
+ powerlens_set_plist_bool "$plist" "SUEnableAutomaticChecks" "false"
+ powerlens_set_plist_bool "$plist" "SUEnableSystemProfiling" "false"
+ powerlens_set_plist_integer "$plist" "SUScheduledCheckInterval" "86400"
+
+ if [[ -n "$sparkle_public_ed_key" ]]; then
+ powerlens_set_plist_string "$plist" "SUPublicEDKey" "$sparkle_public_ed_key"
+ else
+ powerlens_delete_plist_key_if_present "$plist" "SUPublicEDKey"
+ fi
+}
+
+powerlens_copy_app_icon() {
+ local plist="$1"
+ local app_resources="$2"
+
+ if [[ -f "$POWERLENS_SOURCE_ICON" ]]; then
+ cp "$POWERLENS_SOURCE_ICON" "$app_resources/AppIcon.icns"
+ powerlens_set_plist_string "$plist" "CFBundleIconFile" "AppIcon"
+ fi
+}
diff --git a/script/package_release.sh b/script/package_release.sh
index 2dd322e..70e692a 100755
--- a/script/package_release.sh
+++ b/script/package_release.sh
@@ -1,12 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
-APP_NAME="PowerLens"
-BUNDLE_ID="com.progresshans.powerlens"
-MIN_SYSTEM_VERSION="13.0"
-
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-PACKAGING_DIR="$ROOT_DIR/Packaging"
+source "$ROOT_DIR/script/lib/powerlens_packaging.sh"
+
+APP_NAME="$POWERLENS_APP_NAME"
RELEASE_DIR="$ROOT_DIR/release"
STAGE_DIR="$RELEASE_DIR/stage"
DMG_STAGE_DIR="$RELEASE_DIR/dmg-stage"
@@ -14,14 +12,15 @@ APP_BUNDLE="$STAGE_DIR/$APP_NAME.app"
APP_CONTENTS="$APP_BUNDLE/Contents"
APP_MACOS="$APP_CONTENTS/MacOS"
APP_RESOURCES="$APP_CONTENTS/Resources"
+APP_FRAMEWORKS="$APP_CONTENTS/Frameworks"
APP_BINARY="$APP_MACOS/$APP_NAME"
INFO_PLIST="$APP_CONTENTS/Info.plist"
-SOURCE_INFO_PLIST="$PACKAGING_DIR/Info.plist"
-SOURCE_ICON="$PACKAGING_DIR/AppIcon.icns"
-ENTITLEMENTS="$PACKAGING_DIR/PowerLens.entitlements"
+SOURCE_INFO_PLIST="$POWERLENS_SOURCE_INFO_PLIST"
+ENTITLEMENTS="$POWERLENS_ENTITLEMENTS"
+SPARKLE_GENERATE_APPCAST_TOOL="$POWERLENS_SPARKLE_GENERATE_APPCAST_TOOL"
-DEFAULT_VERSION="0.9.0"
-DEFAULT_BUILD="$(git -C "$ROOT_DIR" rev-list --count HEAD 2>/dev/null || echo 1)"
+DEFAULT_VERSION="0.9.1"
+DEFAULT_BUILD="$(powerlens_default_build_number)"
VERSION="${POWERLENS_VERSION:-$DEFAULT_VERSION}"
BUILD_NUMBER="${POWERLENS_BUILD:-$DEFAULT_BUILD}"
RELEASE_BASENAME="$APP_NAME-$VERSION"
@@ -32,37 +31,21 @@ SIGN_IDENTITY="${POWERLENS_SIGN_IDENTITY:-}"
NOTARY_PROFILE="${POWERLENS_NOTARY_PROFILE:-}"
SKIP_NOTARIZATION="${POWERLENS_SKIP_NOTARIZATION:-0}"
CLEAN_BUILD="${POWERLENS_CLEAN_BUILD:-1}"
-
-require_file() {
- local path="$1"
- if [[ ! -f "$path" ]]; then
- echo "missing required file: $path" >&2
- exit 2
- fi
-}
-
-set_plist_value() {
- local key="$1"
- local value="$2"
-
- if /usr/libexec/PlistBuddy -c "Print :$key" "$INFO_PLIST" >/dev/null 2>&1; then
- /usr/libexec/PlistBuddy -c "Set :$key $value" "$INFO_PLIST"
- else
- /usr/libexec/PlistBuddy -c "Add :$key string $value" "$INFO_PLIST"
- fi
-}
-
-strip_extended_attributes() {
- local path="$1"
-
- if command -v xattr >/dev/null 2>&1 && [[ -e "$path" ]]; then
- xattr -cr "$path" 2>/dev/null || true
- fi
-}
+SPARKLE_FEED_URL="${POWERLENS_SPARKLE_FEED_URL:-https://progresshans.github.io/powerlens/appcast.xml}"
+SPARKLE_ALPHA_FEED_URL="${POWERLENS_SPARKLE_ALPHA_FEED_URL:-https://progresshans.github.io/powerlens/appcast-alpha.xml}"
+SPARKLE_PUBLIC_ED_KEY="${POWERLENS_SPARKLE_PUBLIC_ED_KEY:-}"
+SPARKLE_GENERATE_APPCAST="${POWERLENS_SPARKLE_GENERATE_APPCAST:-0}"
+SPARKLE_APPCAST_DIR="${POWERLENS_SPARKLE_APPCAST_DIR:-}"
+SPARKLE_APPCAST_OUTPUT_PATH="${POWERLENS_SPARKLE_APPCAST_OUTPUT_PATH:-}"
+SPARKLE_DOWNLOAD_URL_PREFIX="${POWERLENS_SPARKLE_DOWNLOAD_URL_PREFIX:-}"
+SPARKLE_RELEASE_NOTES_URL_PREFIX="${POWERLENS_SPARKLE_RELEASE_NOTES_URL_PREFIX:-}"
+SPARKLE_KEY_ACCOUNT="${POWERLENS_SPARKLE_KEY_ACCOUNT:-powerlens}"
+SPARKLE_PRIVATE_ED_KEY="${POWERLENS_SPARKLE_PRIVATE_ED_KEY:-}"
+SPARKLE_ED_KEY_FILE="${POWERLENS_SPARKLE_ED_KEY_FILE:-}"
prepare_release_dir() {
rm -rf "$STAGE_DIR" "$DMG_STAGE_DIR" "$APP_ZIP" "$DMG_PATH" "$CHECKSUMS_PATH"
- mkdir -p "$APP_MACOS" "$APP_RESOURCES" "$DMG_STAGE_DIR"
+ mkdir -p "$APP_MACOS" "$APP_RESOURCES" "$APP_FRAMEWORKS" "$DMG_STAGE_DIR"
}
build_app_bundle() {
@@ -76,42 +59,44 @@ build_app_bundle() {
local build_dir
local build_binary
- local resource_bundle
build_dir="$(swift build -c release --show-bin-path)"
build_binary="$build_dir/$APP_NAME"
- resource_bundle="$build_dir/${APP_NAME}_${APP_NAME}.bundle"
cp "$build_binary" "$APP_BINARY"
chmod +x "$APP_BINARY"
-
- if [[ -d "$resource_bundle" ]]; then
- ditto --noqtn --noextattr "$resource_bundle" "$APP_RESOURCES/$(basename "$resource_bundle")"
- fi
+ powerlens_copy_sparkle_framework "$APP_FRAMEWORKS" "$APP_BINARY"
+ powerlens_copy_resource_bundle "$build_dir" "$APP_RESOURCES"
cp "$SOURCE_INFO_PLIST" "$INFO_PLIST"
- set_plist_value "CFBundleExecutable" "$APP_NAME"
- set_plist_value "CFBundleIdentifier" "$BUNDLE_ID"
- set_plist_value "CFBundleName" "$APP_NAME"
- set_plist_value "CFBundleDisplayName" "$APP_NAME"
- set_plist_value "CFBundleShortVersionString" "$VERSION"
- set_plist_value "CFBundleVersion" "$BUILD_NUMBER"
- set_plist_value "LSMinimumSystemVersion" "$MIN_SYSTEM_VERSION"
-
- if [[ -f "$SOURCE_ICON" ]]; then
- cp "$SOURCE_ICON" "$APP_RESOURCES/AppIcon.icns"
- set_plist_value "CFBundleIconFile" "AppIcon"
+ powerlens_apply_common_info_plist "$INFO_PLIST" "$VERSION" "$BUILD_NUMBER" "$SPARKLE_FEED_URL" "$SPARKLE_ALPHA_FEED_URL" "$SPARKLE_PUBLIC_ED_KEY" "1"
+ if [[ -z "$SPARKLE_PUBLIC_ED_KEY" ]]; then
+ echo "sparkle: SUPublicEDKey omitted; in-app updates are disabled for this build"
fi
- strip_extended_attributes "$APP_BUNDLE"
+ powerlens_copy_app_icon "$INFO_PLIST" "$APP_RESOURCES"
+
+ powerlens_strip_extended_attributes "$APP_BUNDLE"
}
sign_app_if_configured() {
if [[ -z "$SIGN_IDENTITY" ]]; then
- echo "codesign: skipped (set POWERLENS_SIGN_IDENTITY to sign release artifacts)"
+ echo "codesign: signing app with ad-hoc identity (set POWERLENS_SIGN_IDENTITY for distribution)"
+ codesign --force --deep --sign - "$APP_BUNDLE"
+ codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
return
fi
echo "codesign: signing $APP_BUNDLE"
+
+ if [[ -d "$APP_FRAMEWORKS/Sparkle.framework" ]]; then
+ codesign \
+ --force \
+ --options runtime \
+ --timestamp \
+ --sign "$SIGN_IDENTITY" \
+ "$APP_FRAMEWORKS/Sparkle.framework"
+ fi
+
codesign \
--force \
--deep \
@@ -124,6 +109,51 @@ sign_app_if_configured() {
codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
}
+generate_appcast_if_configured() {
+ if [[ "$SPARKLE_GENERATE_APPCAST" != "1" ]]; then
+ return
+ fi
+
+ if [[ -z "$SPARKLE_APPCAST_DIR" ]]; then
+ echo "sparkle: POWERLENS_SPARKLE_APPCAST_DIR is required when POWERLENS_SPARKLE_GENERATE_APPCAST=1" >&2
+ exit 2
+ fi
+
+ powerlens_require_file "$SPARKLE_GENERATE_APPCAST_TOOL"
+ mkdir -p "$SPARKLE_APPCAST_DIR"
+ cp "$APP_ZIP" "$SPARKLE_APPCAST_DIR/"
+
+ local args=("$SPARKLE_GENERATE_APPCAST_TOOL" --account "$SPARKLE_KEY_ACCOUNT")
+ if [[ -n "$SPARKLE_PRIVATE_ED_KEY" ]]; then
+ args+=(--ed-key-file -)
+ elif [[ -n "$SPARKLE_ED_KEY_FILE" ]]; then
+ powerlens_require_file "$SPARKLE_ED_KEY_FILE"
+ args+=(--ed-key-file "$SPARKLE_ED_KEY_FILE")
+ fi
+ if [[ -n "$SPARKLE_DOWNLOAD_URL_PREFIX" ]]; then
+ args+=(--download-url-prefix "$SPARKLE_DOWNLOAD_URL_PREFIX")
+ fi
+ if [[ -n "$SPARKLE_RELEASE_NOTES_URL_PREFIX" ]]; then
+ args+=(--release-notes-url-prefix "$SPARKLE_RELEASE_NOTES_URL_PREFIX")
+ fi
+
+ args+=("$SPARKLE_APPCAST_DIR")
+ echo "sparkle: generating appcast in $SPARKLE_APPCAST_DIR"
+ if [[ -n "$SPARKLE_PRIVATE_ED_KEY" ]]; then
+ printf '%s' "$SPARKLE_PRIVATE_ED_KEY" | "${args[@]}"
+ else
+ "${args[@]}"
+ fi
+
+ if [[ -n "$SPARKLE_APPCAST_OUTPUT_PATH" ]]; then
+ local generated_appcast="$SPARKLE_APPCAST_DIR/appcast.xml"
+ powerlens_require_file "$generated_appcast"
+ mkdir -p "$(dirname "$SPARKLE_APPCAST_OUTPUT_PATH")"
+ cp "$generated_appcast" "$SPARKLE_APPCAST_OUTPUT_PATH"
+ echo "sparkle: copied appcast to $SPARKLE_APPCAST_OUTPUT_PATH"
+ fi
+}
+
zip_app_for_notarization() {
ditto --noqtn --noextattr -c -k --keepParent "$APP_BUNDLE" "$APP_ZIP"
}
@@ -143,7 +173,7 @@ notarize_app_if_configured() {
create_dmg() {
ditto --noqtn --noextattr "$APP_BUNDLE" "$DMG_STAGE_DIR/$APP_NAME.app"
ln -s /Applications "$DMG_STAGE_DIR/Applications"
- strip_extended_attributes "$DMG_STAGE_DIR"
+ powerlens_strip_extended_attributes "$DMG_STAGE_DIR"
hdiutil create \
-volname "$APP_NAME" \
@@ -151,7 +181,7 @@ create_dmg() {
-ov \
-format UDZO \
"$DMG_PATH" >/dev/null
- strip_extended_attributes "$DMG_PATH"
+ powerlens_strip_extended_attributes "$DMG_PATH"
}
sign_dmg_if_configured() {
@@ -193,8 +223,8 @@ print_summary() {
echo "- $CHECKSUMS_PATH"
}
-require_file "$SOURCE_INFO_PLIST"
-require_file "$ENTITLEMENTS"
+powerlens_require_file "$SOURCE_INFO_PLIST"
+powerlens_require_file "$ENTITLEMENTS"
prepare_release_dir
build_app_bundle
sign_app_if_configured
@@ -204,5 +234,6 @@ zip_app_for_notarization
create_dmg
sign_dmg_if_configured
notarize_dmg_if_configured
+generate_appcast_if_configured
write_checksums
print_summary
diff --git a/script/test_sparkle_update.sh b/script/test_sparkle_update.sh
new file mode 100755
index 0000000..a6f0c7d
--- /dev/null
+++ b/script/test_sparkle_update.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+source "$ROOT_DIR/script/lib/powerlens_packaging.sh"
+
+APP_NAME="$POWERLENS_APP_NAME"
+ENV_FILE="${POWERLENS_ENV_FILE:-$ROOT_DIR/.env}"
+MODE="${1:-run}"
+SERVER_PID=""
+
+source_env_if_present() {
+ if [[ -f "$ENV_FILE" ]]; then
+ set -a
+ # shellcheck disable=SC1090
+ source "$ENV_FILE"
+ set +a
+ fi
+}
+
+source_env_if_present
+
+HOST="${POWERLENS_SPARKLE_TEST_HOST:-127.0.0.1}"
+PORT="${POWERLENS_SPARKLE_TEST_PORT:-18080}"
+FEED_URL="http://$HOST:$PORT/appcast.xml"
+TEST_ROOT="${POWERLENS_SPARKLE_TEST_DIR:-$ROOT_DIR/release/sparkle-local-test}"
+FEED_DIR="$TEST_ROOT/feed"
+BASE_DIR="$TEST_ROOT/base"
+BASE_APP="$BASE_DIR/$APP_NAME.app"
+BASE_VERSION="${POWERLENS_SPARKLE_TEST_BASE_VERSION:-0.9.1}"
+BASE_BUILD="${POWERLENS_SPARKLE_TEST_BASE_BUILD:-2}"
+UPDATE_VERSION="${POWERLENS_SPARKLE_TEST_UPDATE_VERSION:-0.9.2}"
+UPDATE_BUILD="${POWERLENS_SPARKLE_TEST_UPDATE_BUILD:-3}"
+
+usage() {
+ cat </dev/null 2>&1 || true
+ fi
+}
+
+require_local_update_inputs() {
+ powerlens_require_file "$POWERLENS_SOURCE_INFO_PLIST"
+ powerlens_require_file "$POWERLENS_ENTITLEMENTS"
+ powerlens_require_file "$POWERLENS_SPARKLE_GENERATE_APPCAST_TOOL"
+
+ if [[ -z "${POWERLENS_SPARKLE_PUBLIC_ED_KEY:-}" ]]; then
+ echo "missing POWERLENS_SPARKLE_PUBLIC_ED_KEY; add the Sparkle public key to .env first" >&2
+ exit 2
+ fi
+
+ if ! command -v python3 >/dev/null 2>&1; then
+ echo "missing python3; it is required for the local update feed server" >&2
+ exit 2
+ fi
+
+ if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null; then
+ echo "port $PORT is already in use; set POWERLENS_SPARKLE_TEST_PORT to another port" >&2
+ exit 2
+ fi
+}
+
+package_base_app() {
+ echo "sparkle-test: packaging base app $BASE_VERSION ($BASE_BUILD)"
+ POWERLENS_VERSION="$BASE_VERSION" \
+ POWERLENS_BUILD="$BASE_BUILD" \
+ POWERLENS_SPARKLE_FEED_URL="$FEED_URL" \
+ POWERLENS_SPARKLE_ALPHA_FEED_URL="$FEED_URL" \
+ POWERLENS_SPARKLE_GENERATE_APPCAST=0 \
+ POWERLENS_SKIP_NOTARIZATION=1 \
+ POWERLENS_CLEAN_BUILD=1 \
+ "$ROOT_DIR/script/package_release.sh"
+
+ rm -rf "$BASE_DIR"
+ mkdir -p "$BASE_DIR"
+ ditto --noqtn --noextattr "$ROOT_DIR/release/stage/$APP_NAME.app" "$BASE_APP"
+}
+
+package_update_appcast() {
+ echo "sparkle-test: packaging update app $UPDATE_VERSION ($UPDATE_BUILD)"
+ rm -rf "$FEED_DIR"
+ mkdir -p "$FEED_DIR"
+
+ POWERLENS_VERSION="$UPDATE_VERSION" \
+ POWERLENS_BUILD="$UPDATE_BUILD" \
+ POWERLENS_SPARKLE_FEED_URL="$FEED_URL" \
+ POWERLENS_SPARKLE_ALPHA_FEED_URL="$FEED_URL" \
+ POWERLENS_SPARKLE_GENERATE_APPCAST=1 \
+ POWERLENS_SPARKLE_APPCAST_DIR="$FEED_DIR" \
+ POWERLENS_SPARKLE_DOWNLOAD_URL_PREFIX="http://$HOST:$PORT/" \
+ POWERLENS_SKIP_NOTARIZATION=1 \
+ POWERLENS_CLEAN_BUILD=0 \
+ "$ROOT_DIR/script/package_release.sh"
+
+ powerlens_require_file "$FEED_DIR/appcast.xml"
+ powerlens_require_file "$FEED_DIR/$APP_NAME-$UPDATE_VERSION.app.zip"
+}
+
+start_feed_server() {
+ echo "sparkle-test: serving $FEED_DIR at $FEED_URL"
+ (
+ cd "$FEED_DIR"
+ exec python3 -m http.server "$PORT" --bind "$HOST"
+ ) &
+ SERVER_PID="$!"
+ sleep 1
+}
+
+open_base_app() {
+ pkill -x "$APP_NAME" >/dev/null 2>&1 || true
+ echo "sparkle-test: opening $BASE_APP"
+ /usr/bin/open -n "$BASE_APP"
+}
+
+case "$MODE" in
+ --help|-h|help)
+ usage
+ exit 0
+ ;;
+ run|prepare-only)
+ ;;
+ *)
+ usage >&2
+ exit 2
+ ;;
+esac
+
+trap cleanup EXIT INT TERM
+require_local_update_inputs
+rm -rf "$TEST_ROOT"
+mkdir -p "$TEST_ROOT"
+package_base_app
+package_update_appcast
+
+echo
+echo "Sparkle local test artifacts:"
+echo "- base app: $BASE_APP"
+echo "- appcast: $FEED_DIR/appcast.xml"
+echo "- update: $FEED_DIR/$APP_NAME-$UPDATE_VERSION.app.zip"
+
+if [[ "$MODE" == "prepare-only" ]]; then
+ echo
+ echo "Run this to serve the feed manually:"
+ echo " cd \"$FEED_DIR\" && python3 -m http.server \"$PORT\" --bind \"$HOST\""
+ exit 0
+fi
+
+start_feed_server
+open_base_app
+
+cat <