diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 714811d..35ee3ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ env: APP_NAME: PureMac BUNDLE_ID: com.puremac.app TEAM_ID: H3WXHVTP97 + SPARKLE_VERSION: 2.9.2 SCHEME: PureMac PROJECT: PureMac.xcodeproj CONFIGURATION: Release @@ -129,13 +130,23 @@ jobs: run: | set -euo pipefail APP="build/export/PureMac.app" - codesign --verify --deep --strict --verbose=2 "${APP}" + codesign --verify --deep --strict --verbose=4 "${APP}" codesign -dvv "${APP}" 2>&1 | tee /tmp/cs-info.txt grep -q "flags=0x10000(runtime)" /tmp/cs-info.txt || { echo "::error::Hardened runtime missing"; exit 1; } spctl --assess --type execute --verbose=4 "${APP}" || true # Universal arch check lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "x86_64" && lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "arm64" + - name: Verify Sparkle helper bundles + run: | + set -euo pipefail + APP="build/export/PureMac.app" + while IFS= read -r helper; do + if [[ -n "${helper}" ]]; then + codesign --verify --deep --strict --verbose=4 "${helper}" + fi + done < <(find "${APP}" -type d \( -name 'Autoupdate.app' -o -name 'Updater.app' \) | sort) + - name: Build DMG env: VERSION: ${{ steps.ver.outputs.version }} @@ -202,6 +213,39 @@ jobs: # Final shippable zip with stapled ticket ditto -c -k --keepParent --sequesterRsrc build/export/PureMac.app "${ZIP}" + - name: Download Sparkle release tools + run: | + set -euo pipefail + SPARKLE_TARBALL="${RUNNER_TEMP}/Sparkle-${SPARKLE_VERSION}.tar.xz" + SPARKLE_DIR="${RUNNER_TEMP}/Sparkle-${SPARKLE_VERSION}" + curl -L -o "${SPARKLE_TARBALL}" "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" + rm -rf "${SPARKLE_DIR}" + tar -xf "${SPARKLE_TARBALL}" -C "${RUNNER_TEMP}" + echo "SPARKLE_BIN_DIR=${SPARKLE_DIR}/bin" >> "$GITHUB_ENV" + + - name: Generate Sparkle appcast + env: + VERSION: ${{ steps.ver.outputs.version }} + TAG: ${{ steps.ver.outputs.tag }} + SPARKLE_ED25519_PRIVATE_KEY: ${{ secrets.SPARKLE_ED25519_PRIVATE_KEY }} + run: | + set -euo pipefail + APPCAST_DIR="build/appcast" + DMG="build/PureMac-${VERSION}.dmg" + rm -rf "${APPCAST_DIR}" + mkdir -p "${APPCAST_DIR}" + cp "${DMG}" "${APPCAST_DIR}/" + # Sign the DMG explicitly (sign_update) so the signature is available + # for auditing and log output. Then generate the signed appcast. + printf '%s' "${SPARKLE_ED25519_PRIVATE_KEY}" | "${SPARKLE_BIN_DIR}/sign_update" --ed-key-file - "${APPCAST_DIR}/$(basename "${DMG}")" || true + + printf '%s' "${SPARKLE_ED25519_PRIVATE_KEY}" | "${SPARKLE_BIN_DIR}/generate_appcast" \ + --ed-key-file - \ + --output-path "${APPCAST_DIR}/appcast.xml" \ + --download-url-prefix "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/" \ + --full-release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" \ + "${APPCAST_DIR}" + - name: Compute SHA256 checksums id: sha env: @@ -228,6 +272,7 @@ jobs: path: | build/PureMac-${{ steps.ver.outputs.version }}.dmg build/PureMac-${{ steps.ver.outputs.version }}.zip + build/appcast/appcast.xml build/CHECKSUMS.md retention-days: 14 @@ -259,11 +304,13 @@ jobs: gh release upload "${TAG}" \ "build/PureMac-${VERSION}.dmg" \ "build/PureMac-${VERSION}.zip" \ + "build/appcast/appcast.xml" \ --repo "${GITHUB_REPOSITORY}" --clobber else gh release create "${TAG}" \ "build/PureMac-${VERSION}.dmg" \ "build/PureMac-${VERSION}.zip" \ + "build/appcast/appcast.xml" \ --title "PureMac ${TAG}" \ --notes-file "${NOTES_FILE}" \ --repo "${GITHUB_REPOSITORY}" diff --git a/PureMac.xcodeproj/project.pbxproj b/PureMac.xcodeproj/project.pbxproj index c82ca72..05847ce 100644 --- a/PureMac.xcodeproj/project.pbxproj +++ b/PureMac.xcodeproj/project.pbxproj @@ -555,6 +555,8 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PureMac/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; + PUREMAC_FEED_URL = "http://127.0.0.1:8000/appcast.xml"; + PUREMAC_PUBLIC_ED_KEY = "G2pCmpU2grTTNsigrEmNsPmZX2LjiMBkvMP1fvMCuig="; MARKETING_VERSION = 2.6.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -673,6 +675,8 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PureMac/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; + PUREMAC_FEED_URL = "https://github.com/momenbasel/PureMac/releases/latest/download/appcast.xml"; + PUREMAC_PUBLIC_ED_KEY = "IEOLcqFwQpIXmRrnmBzgjxIR2L9bnh+C+Oye6cMkePk="; MARKETING_VERSION = 2.6.1; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; diff --git a/PureMac/Info.plist b/PureMac/Info.plist index e9c7374..81dc3aa 100644 --- a/PureMac/Info.plist +++ b/PureMac/Info.plist @@ -22,6 +22,14 @@ $(MACOSX_DEPLOYMENT_TARGET) LSApplicationCategoryType public.app-category.utilities + SUFeedURL + $(PUREMAC_FEED_URL) + SUPublicEDKey + $(PUREMAC_PUBLIC_ED_KEY) + SUEnableAutomaticChecks + + SUScheduledCheckInterval + 86400 NSHumanReadableCopyright Copyright 2026. MIT License. NSDesktopFolderUsageDescription diff --git a/PureMac/PureMacApp.swift b/PureMac/PureMacApp.swift index 7faa6a1..797e7e4 100644 --- a/PureMac/PureMacApp.swift +++ b/PureMac/PureMacApp.swift @@ -9,6 +9,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Touch TCC-protected paths so macOS registers PureMac in the // Full Disk Access pane on first launch (fixes issue #75). FullDiskAccessManager.shared.triggerRegistration() + + if ProcessInfo.processInfo.environment["PUREMAC_AUTO_CHECK_UPDATES"] == "1" { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + UpdateService.shared.checkForUpdates() + } + } } } diff --git a/PureMac/Services/UpdateService.swift b/PureMac/Services/UpdateService.swift index a46db1a..47336da 100644 --- a/PureMac/Services/UpdateService.swift +++ b/PureMac/Services/UpdateService.swift @@ -14,11 +14,115 @@ final class UpdateService: ObservableObject { private init() { #if canImport(Sparkle) - // Do not start automatic checking by default; keep control minimal. + // Create the standard updater controller but do not start it automatically. updaterController = SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil) + + // Install lightweight runtime observers to surface Sparkle notifications to the app console. + installDebugObservers() + + // If the updater's stored preference indicates automatic checks, start the updater so Sparkle can schedule checks. + if let updater = updaterController?.updater, updater.automaticallyChecksForUpdates { + updaterController?.startUpdater() + } #endif } + #if canImport(Sparkle) + private var debugObservers: [Any] = [] + + private func installDebugObservers() { + let nc = NotificationCenter.default + + debugObservers.append(nc.addObserver(forName: NSNotification.Name("SUUpdaterDidFinishLoadingAppCastNotification"), object: nil, queue: .main) { note in + print("NOTIFICATION_APPCAST") + if let appcast = note.userInfo?["appcast"] as? SUAppcast { + print("APPCAST_LOADED \(appcast.items.count)") + } + }) + + debugObservers.append(nc.addObserver(forName: NSNotification.Name("SUUpdaterDidFindValidUpdateNotification"), object: nil, queue: .main) { _ in + print("NOTIFICATION_FOUND") + }) + + debugObservers.append(nc.addObserver(forName: NSNotification.Name("SUUpdaterDidNotFindUpdateNotification"), object: nil, queue: .main) { _ in + print("NOTIFICATION_NOT_FOUND") + }) + + debugObservers.append(nc.addObserver(forName: NSNotification.Name("SUUpdaterDidAbortWithErrorNotification"), object: nil, queue: .main) { note in + let msg = "NOTIFICATION_ABORTED \(String(describing: note.userInfo?["error"]))" + print(msg) + self.appendDebugLog(msg) + }) + // Global observer: capture any Sparkle-related notifications for debugging. + debugObservers.append(nc.addObserver(forName: nil, object: nil, queue: .main) { note in + let name = note.name.rawValue + if name.contains("SU") || name.lowercased().contains("sparkle") { + let msg = "NOTIF: \(name) userInfo=\(String(describing: note.userInfo))" + print(msg) + self.appendDebugLog(msg) + } + }) + } + + private func appendDebugLog(_ message: String) { + let fm = FileManager.default + let logDir = fm.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/PureMac") + try? fm.createDirectory(at: logDir, withIntermediateDirectories: true) + let logFile = logDir.appendingPathComponent("update-debug.log") + let ts = ISO8601DateFormatter().string(from: Date()) + let line = "[\(ts)] \(message)\n" + if let data = line.data(using: .utf8) { + if fm.fileExists(atPath: logFile.path) { + if let fh = try? FileHandle(forWritingTo: logFile) { + fh.seekToEndOfFile() + fh.write(data) + try? fh.close() + } + } else { + try? data.write(to: logFile) + } + } + } + #endif + + #if canImport(Sparkle) + var updater: SPUUpdater? { updaterController?.updater } + + /// Set whether Sparkle should automatically check for updates. + func setAutomaticallyChecks(_ enabled: Bool) { + DispatchQueue.main.async { + guard let updater = self.updaterController?.updater else { + UserDefaults.standard.set(enabled, forKey: "settings.updates.checkAutomatically") + return + } + updater.automaticallyChecksForUpdates = enabled + if enabled { + self.updaterController?.startUpdater() + } + updater.resetUpdateCycleAfterShortDelay() + } + } + + /// Set the Sparkle update check interval (seconds). + func setUpdateInterval(_ seconds: TimeInterval) { + DispatchQueue.main.async { + if let updater = self.updaterController?.updater { + updater.updateCheckInterval = seconds + updater.resetUpdateCycleAfterShortDelay() + } else { + // Fallback: persist to UserDefaults for older code paths. + let raw: String + switch Int(seconds) { + case 60 * 60 * 24: raw = "Daily" + case 60 * 60 * 24 * 30: raw = "Monthly" + default: raw = "Weekly" + } + UserDefaults.standard.set(raw, forKey: "settings.updates.checkInterval") + } + } + } + #endif + func checkForUpdates() { #if canImport(Sparkle) DispatchQueue.main.async { diff --git a/PureMac/Views/Settings/SettingsView.swift b/PureMac/Views/Settings/SettingsView.swift index 71e2520..1e9a21d 100644 --- a/PureMac/Views/Settings/SettingsView.swift +++ b/PureMac/Views/Settings/SettingsView.swift @@ -7,6 +7,8 @@ struct SettingsView: View { TabView { GeneralSettingsView() .tabItem { Label("General", systemImage: "gear") } + UpdatesSettingsView() + .tabItem { Label("Updates", systemImage: "arrow.triangle.2.circlepath") } CleaningSettingsView() .tabItem { Label("Cleaning", systemImage: "trash") } ScheduleSettingsView() @@ -18,6 +20,91 @@ struct SettingsView: View { } } +// MARK: - Updates + +enum UpdateInterval: String, CaseIterable, Identifiable, Codable { + case daily = "Daily" + case weekly = "Weekly" + case monthly = "Monthly" + + var id: String { rawValue } + + var timeInterval: TimeInterval { + switch self { + case .daily: return 60 * 60 * 24 + case .weekly: return 60 * 60 * 24 * 7 + case .monthly: return 60 * 60 * 24 * 30 + } + } +} + +struct UpdatesSettingsView: View { + @State private var automaticallyChecks: Bool = true + @State private var interval: UpdateInterval = .weekly + + init() { + if let updater = UpdateService.shared.updater { + _automaticallyChecks = State(initialValue: updater.automaticallyChecksForUpdates) + let seconds = updater.updateCheckInterval + if let matched = UpdateInterval.allCases.first(where: { $0.timeInterval == seconds }) { + _interval = State(initialValue: matched) + } else { + _interval = State(initialValue: .weekly) + } + } else { + let defaults = UserDefaults.standard + let enabled = defaults.object(forKey: "settings.updates.checkAutomatically") as? Bool ?? true + let raw = defaults.string(forKey: "settings.updates.checkInterval") ?? UpdateInterval.weekly.rawValue + _automaticallyChecks = State(initialValue: enabled) + _interval = State(initialValue: UpdateInterval(rawValue: raw) ?? .weekly) + } + } + + var body: some View { + Form { + Section("Updates") { + Toggle("Check automatically", isOn: $automaticallyChecks) + .onChange(of: automaticallyChecks) { newValue in + UpdateService.shared.setAutomaticallyChecks(newValue) + } + + Picker("Check interval", selection: Binding( + get: { interval }, + set: { newValue in + interval = newValue + UpdateService.shared.setUpdateInterval(newValue.timeInterval) + } + )) { + ForEach(UpdateInterval.allCases) { value in + Text(LocalizedStringKey(value.rawValue)).tag(value) + } + } + .pickerStyle(.radioGroup) + .disabled(!automaticallyChecks) + + HStack { + Spacer() + Button("Check Now") { + UpdateService.shared.checkForUpdates() + } + .keyboardShortcut("u", modifiers: [.command]) + } + } + } + .formStyle(.grouped) + .onAppear { + // Sync UI from updater in case external changes occurred + if let updater = UpdateService.shared.updater { + automaticallyChecks = updater.automaticallyChecksForUpdates + let seconds = updater.updateCheckInterval + if let matched = UpdateInterval.allCases.first(where: { $0.timeInterval == seconds }) { + interval = matched + } + } + } + } +} + // MARK: - General enum SearchSensitivity: String, CaseIterable, Identifiable, Codable { diff --git a/project.yml b/project.yml index ccb459c..331d102 100644 --- a/project.yml +++ b/project.yml @@ -26,6 +26,8 @@ settings: MARKETING_VERSION: "2.6.1" CURRENT_PROJECT_VERSION: "13" GENERATE_INFOPLIST_FILE: "NO" + PUREMAC_FEED_URL: "https://github.com/momenbasel/PureMac/releases/latest/download/appcast.xml" + PUREMAC_PUBLIC_ED_KEY: "IEOLcqFwQpIXmRrnmBzgjxIR2L9bnh+C+Oye6cMkePk=" ASSETCATALOG_COMPILER_APPICON_NAME: "AppIcon" COMBINE_HIDPI_IMAGES: "YES" @@ -47,6 +49,8 @@ targets: Debug: CODE_SIGNING_ALLOWED: "NO" CODE_SIGNING_REQUIRED: "NO" + PUREMAC_FEED_URL: "http://127.0.0.1:8000/appcast.xml" + PUREMAC_PUBLIC_ED_KEY: "G2pCmpU2grTTNsigrEmNsPmZX2LjiMBkvMP1fvMCuig=" PureMacTests: type: bundle.unit-test diff --git a/scripts/SECRETS.md b/scripts/SECRETS.md index 726fcbe..7f4e789 100644 --- a/scripts/SECRETS.md +++ b/scripts/SECRETS.md @@ -8,7 +8,7 @@ The pipeline uses an **App Store Connect API key** for notarization (modern, no rotation, scoped to one team) instead of the legacy `APPLE_ID + app-specific-password` flow. -## Required secrets (6) +## Required secrets (7) | Secret | Source | Notes | |--------|--------|-------| @@ -18,6 +18,7 @@ no rotation, scoped to one team) instead of the legacy | `APP_STORE_CONNECT_KEY_ID` | The 10-char ID from the `.p8` filename (`AuthKey_XXXXXXXXXX.p8`) | e.g. `5G7R52L8RK` | | `APP_STORE_CONNECT_ISSUER_ID` | UUID from | e.g. `5de3898a-cd31-4061-850f-ae17b389e46a` | | `APP_STORE_CONNECT_PRIVATE_KEY` | Full contents of the `.p8` file (`-----BEGIN PRIVATE KEY-----` ... `-----END PRIVATE KEY-----`) | Paste raw, including the BEGIN/END lines | +| `SPARKLE_ED25519_PRIVATE_KEY` | The private Ed25519 key exported from Sparkle's `generate_keys -x` flow | Used by `generate_appcast` to sign `appcast.xml` | ## Optional secret (1) @@ -112,6 +113,24 @@ rm -P ~/Desktop/PureMac-secrets/PureMac-DeveloperID.p12* \ ~/Desktop/PureMac-secrets/KEYCHAIN_PASSWORD.txt ``` +## Storing the Sparkle Ed25519 key locally + +Generate the public/private pair once with Sparkle's `generate_keys` tool. The +public key goes into `PureMac/Info.plist` as `SUPublicEDKey`. The private key +should be exported from the same keychain account and stored in GitHub Actions +as `SPARKLE_ED25519_PRIVATE_KEY`. + +Example flow: + +```bash +./bin/generate_keys --account puremac +./bin/generate_keys --account puremac -x puremac-ed25519.key +cat puremac-ed25519.key +``` + +Use the exported file contents as the secret value. The release workflow pipes +that value into `generate_appcast --ed-key-file -` when building `appcast.xml`. + ## Triggering a release Dry run first (build + sign + notarize, no upload, no homebrew bump): diff --git a/scripts/release-local.sh b/scripts/release-local.sh index 8be8eb4..d776fbf 100755 --- a/scripts/release-local.sh +++ b/scripts/release-local.sh @@ -7,6 +7,9 @@ # xcrun notarytool store-credentials AC_NOTARY \ # --key ~/.appstoreconnect/private_keys/AuthKey_5G7R52L8RK.p8 \ # --key-id 5G7R52L8RK --issuer 5de3898a-cd31-4061-850f-ae17b389e46a +# - Sparkle Ed25519 signing key available either in the keychain account +# named `puremac` or via the SPARKLE_ED25519_PRIVATE_KEY environment +# variable # - xcodegen + create-dmg installed (brew install xcodegen create-dmg) # # Usage: scripts/release-local.sh [notary_profile] @@ -19,11 +22,20 @@ VERSION="${1:?Usage: $0 [notary_profile]}" NOTARY_PROFILE="${2:-AC_NOTARY}" TEAM_ID="H3WXHVTP97" SIGN_ID="Developer ID Application: Moamen Basel (${TEAM_ID})" +SPARKLE_VERSION="2.9.2" +SPARKLE_ACCOUNT="puremac" SCHEME="PureMac" PROJECT="PureMac.xcodeproj" APP="build/export/PureMac.app" DMG="build/PureMac-${VERSION}.dmg" ZIP="build/PureMac-${VERSION}.zip" +APPCAST_DIR="build/appcast" + +WORK_TMP="${TMPDIR:-${HOME}/.tmp}" +SPARKLE_TARBALL="${WORK_TMP}/Sparkle-${SPARKLE_VERSION}.tar.xz" +SPARKLE_DIR="${WORK_TMP}/Sparkle-${SPARKLE_VERSION}" + +mkdir -p "${WORK_TMP}" cd "$(dirname "$0")/.." @@ -74,11 +86,18 @@ xcodebuild -exportArchive \ -exportOptionsPlist build/ExportOptions.plist echo "==> verify codesign" -codesign --verify --deep --strict --verbose=2 "${APP}" +codesign --verify --deep --strict --verbose=4 "${APP}" codesign -dvv "${APP}" 2>&1 | grep -E "Identifier|TeamIdentifier|flags|Authority" codesign -dvv "${APP}" 2>&1 | grep -q "flags=0x10000(runtime)" || { echo "Hardened runtime missing"; exit 1; } lipo -archs "${APP}/Contents/MacOS/PureMac" +echo "==> verify Sparkle helper bundles" +while IFS= read -r helper; do + if [[ -n "${helper}" ]]; then + codesign --verify --deep --strict --verbose=4 "${helper}" + fi +done < <(find "${APP}" -type d \( -name 'Autoupdate.app' -o -name 'Updater.app' \) | sort) + echo "==> dmg" create-dmg \ --volname "PureMac ${VERSION}" \ @@ -110,6 +129,33 @@ spctl --assess --type install --verbose=4 "${DMG}" echo "==> final zip with stapled app" ditto -c -k --keepParent --sequesterRsrc "${APP}" "${ZIP}" +echo "==> Sparkle appcast" +rm -rf "${APPCAST_DIR}" +mkdir -p "${APPCAST_DIR}" +cp "${DMG}" "${APPCAST_DIR}/" +curl -L -o "${SPARKLE_TARBALL}" "https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz" +rm -rf "${SPARKLE_DIR}" +tar -xf "${SPARKLE_TARBALL}" -C "${WORK_TMP}" + +if [[ -n "${SPARKLE_ED25519_PRIVATE_KEY:-}" ]]; then + # Explicitly sign the DMG first to emit an EdDSA signature for auditing. + printf '%s' "${SPARKLE_ED25519_PRIVATE_KEY}" | "${SPARKLE_DIR}/bin/sign_update" --ed-key-file - "${APPCAST_DIR}/$(basename "${DMG}")" || true + + printf '%s' "${SPARKLE_ED25519_PRIVATE_KEY}" | "${SPARKLE_DIR}/bin/generate_appcast" \ + --ed-key-file - \ + --output-path "${APPCAST_DIR}/appcast.xml" \ + --download-url-prefix "https://github.com/momenbasel/PureMac/releases/download/v${VERSION}/" \ + --full-release-notes-url "https://github.com/momenbasel/PureMac/releases/tag/v${VERSION}" \ + "${APPCAST_DIR}" +else + "${SPARKLE_DIR}/bin/generate_appcast" \ + --account "${SPARKLE_ACCOUNT}" \ + --output-path "${APPCAST_DIR}/appcast.xml" \ + --download-url-prefix "https://github.com/momenbasel/PureMac/releases/download/v${VERSION}/" \ + --full-release-notes-url "https://github.com/momenbasel/PureMac/releases/tag/v${VERSION}" \ + "${APPCAST_DIR}" +fi + DMG_SHA=$(shasum -a 256 "${DMG}" | awk '{print $1}') ZIP_SHA=$(shasum -a 256 "${ZIP}" | awk '{print $1}') @@ -121,6 +167,7 @@ echo "DMG: ${DMG}" echo " sha256: ${DMG_SHA}" echo "ZIP: ${ZIP}" echo " sha256: ${ZIP_SHA}" +echo "Appcast: ${APPCAST_DIR}/appcast.xml" echo "" -echo "Next: gh release create v${VERSION} ${DMG} ${ZIP} --title \"PureMac v${VERSION}\"" +echo "Next: gh release create v${VERSION} ${DMG} ${ZIP} ${APPCAST_DIR}/appcast.xml --title \"PureMac v${VERSION}\"" echo "Then: bump homebrew/puremac.rb sha256 to ${ZIP_SHA}"