diff --git a/.env.example b/.env.example index 253da60..f30031b 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,25 @@ 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" +# Optional when the notarytool profile is stored in a non-default keychain. +# POWERLENS_NOTARY_KEYCHAIN="/path/to/powerlens-signing.keychain-db" + +# 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..b9e91cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - develop + +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-26 + + 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..b08401d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,346 @@ +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-26 + 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" + echo "POWERLENS_NOTARY_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV" + + - 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: | + set -euo pipefail + xcrun notarytool store-credentials powerlens-notary \ + --keychain "$POWERLENS_NOTARY_KEYCHAIN" \ + --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..7f6a46e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.3 +// swift-tools-version: 6.2 import PackageDescription let package = Package( @@ -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..e1c42dc 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` and `develop` + - 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..684d0b4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ License: AGPL-3.0-only macOS target: 13+ Tested on macOS 26 - Swift 6.3 + Swift 6.2

@@ -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/PowerLensStore.swift b/Sources/PowerLens/PowerLensStore.swift index 731ec44..cf4bb11 100644 --- a/Sources/PowerLens/PowerLensStore.swift +++ b/Sources/PowerLens/PowerLensStore.swift @@ -1,3 +1,4 @@ +import Combine import Foundation @MainActor 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..e352e7c --- /dev/null +++ b/Sources/PowerLens/SoftwareUpdateController.swift @@ -0,0 +1,148 @@ +import AppKit +import Combine +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..7579edd --- /dev/null +++ b/docs/appcast.xml @@ -0,0 +1,11 @@ + + + + PowerLens Updates + https://github.com/progresshans/powerlens/releases + PowerLens Sparkle update feed. + en + + 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..8b8af8a 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" @@ -30,39 +29,24 @@ DMG_PATH="$RELEASE_DIR/$RELEASE_BASENAME.dmg" CHECKSUMS_PATH="$RELEASE_DIR/$RELEASE_BASENAME-checksums.txt" SIGN_IDENTITY="${POWERLENS_SIGN_IDENTITY:-}" NOTARY_PROFILE="${POWERLENS_NOTARY_PROFILE:-}" +NOTARY_KEYCHAIN="${POWERLENS_NOTARY_KEYCHAIN:-}" 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 +60,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,10 +110,66 @@ 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" } +submit_for_notarization() { + local artifact="$1" + local args=(submit "$artifact" --keychain-profile "$NOTARY_PROFILE" --wait) + + if [[ -n "$NOTARY_KEYCHAIN" ]]; then + args+=(--keychain "$NOTARY_KEYCHAIN") + fi + + xcrun notarytool "${args[@]}" +} + notarize_app_if_configured() { if [[ -z "$SIGN_IDENTITY" || -z "$NOTARY_PROFILE" || "$SKIP_NOTARIZATION" == "1" ]]; then echo "notarization: skipped (set POWERLENS_SIGN_IDENTITY and POWERLENS_NOTARY_PROFILE to enable)" @@ -135,7 +177,7 @@ notarize_app_if_configured() { fi echo "notarization: submitting app zip" - xcrun notarytool submit "$APP_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait + submit_for_notarization "$APP_ZIP" xcrun stapler staple "$APP_BUNDLE" xcrun stapler validate "$APP_BUNDLE" } @@ -143,7 +185,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 +193,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() { @@ -172,7 +214,7 @@ notarize_dmg_if_configured() { fi echo "notarization: submitting dmg" - xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait + submit_for_notarization "$DMG_PATH" xcrun stapler staple "$DMG_PATH" xcrun stapler validate "$DMG_PATH" } @@ -193,8 +235,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 +246,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 <