diff --git a/appcast.xml b/appcast.xml new file mode 100644 index 000000000..0b60e35c0 --- /dev/null +++ b/appcast.xml @@ -0,0 +1,10 @@ + + + + Clawdis Updates + https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml + Signed update feed for the Clawdis macOS companion app. + + diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 84392aa94..885057fef 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d88e9364f346bbb20f6e4f0bba6328ce6780b32d4645e22c3a9acc8802298c52", + "originHash" : "9d6819a603c065346890e6bfc47d0239e92e1b6510e22766b85e6bdf4f891831", "pins" : [ { "identity" : "asyncxpcconnection", @@ -19,6 +19,15 @@ "version" : "1.2.2" } }, + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", + "version" : "2.8.1" + } + }, { "identity" : "swift-subprocess", "kind" : "remoteSourceControl", diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index ebea2f45f..16e797344 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -17,6 +17,7 @@ let package = Package( .package(url: "https://github.com/ChimeHQ/AsyncXPCConnection", from: "1.3.0"), .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), + .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), ], targets: [ .target( @@ -32,6 +33,7 @@ let package = Package( .product(name: "AsyncXPCConnection", package: "AsyncXPCConnection"), .product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"), .product(name: "Subprocess", package: "swift-subprocess"), + .product(name: "Sparkle", package: "Sparkle"), ], resources: [ .copy("Resources/Clawdis.icns"), diff --git a/apps/macos/Sources/Clawdis/AboutSettings.swift b/apps/macos/Sources/Clawdis/AboutSettings.swift index 4efb54548..23ba40bc0 100644 --- a/apps/macos/Sources/Clawdis/AboutSettings.swift +++ b/apps/macos/Sources/Clawdis/AboutSettings.swift @@ -1,7 +1,10 @@ import SwiftUI struct AboutSettings: View { + weak var updater: UpdaterProviding? @State private var iconHover = false + @AppStorage("autoUpdateEnabled") private var autoCheckEnabled = true + @State private var didLoadUpdaterState = false var body: some View { VStack(spacing: 8) { @@ -54,6 +57,25 @@ struct AboutSettings: View { .multilineTextAlignment(.center) .padding(.vertical, 10) + if let updater { + Divider() + .padding(.vertical, 8) + + if updater.isAvailable { + VStack(spacing: 10) { + Toggle("Check for updates automatically", isOn: self.$autoCheckEnabled) + .toggleStyle(.checkbox) + .frame(maxWidth: .infinity, alignment: .center) + + Button("Check for Updates…") { updater.checkForUpdates(nil) } + } + } else { + Text("Updates unavailable in this build.") + .foregroundStyle(.secondary) + .padding(.top, 4) + } + } + Text("© 2025 Peter Steinberger — MIT License.") .font(.footnote) .foregroundStyle(.secondary) @@ -65,6 +87,15 @@ struct AboutSettings: View { .padding(.top, 4) .padding(.horizontal, 24) .padding(.bottom, 24) + .onAppear { + guard let updater, !self.didLoadUpdaterState else { return } + // Keep Sparkle’s auto-check setting in sync with the persisted toggle. + updater.automaticallyChecksForUpdates = self.autoCheckEnabled + self.didLoadUpdaterState = true + } + .onChange(of: self.autoCheckEnabled) { _, newValue in + self.updater?.automaticallyChecksForUpdates = newValue + } } private var versionString: String { diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index d3743dbcb..97c280381 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -19,7 +19,7 @@ struct ClawdisApp: App { } var body: some Scene { - MenuBarExtra { MenuContent(state: self.state) } label: { + MenuBarExtra { MenuContent(state: self.state, updater: self.delegate.updaterController) } label: { CritterStatusLabel( isPaused: self.state.isPaused, isWorking: self.state.isWorking, @@ -38,7 +38,7 @@ struct ClawdisApp: App { } Settings { - SettingsRootView(state: self.state) + SettingsRootView(state: self.state, updater: self.delegate.updaterController) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight, alignment: .topLeading) } .defaultSize(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) @@ -52,6 +52,7 @@ struct ClawdisApp: App { private struct MenuContent: View { @ObservedObject var state: AppState + let updater: UpdaterProviding? @ObservedObject private var relayManager = RelayProcessManager.shared @ObservedObject private var healthStore = HealthStore.shared @Environment(\.openSettings) private var openSettings @@ -69,6 +70,9 @@ private struct MenuContent: View { Button("Settings…") { self.open(tab: .general) } .keyboardShortcut(",", modifiers: [.command]) Button("About Clawdis") { self.open(tab: .about) } + if let updater, updater.isAvailable { + Button("Check for Updates…") { updater.checkForUpdates(nil) } + } Divider() Button("Quit") { NSApplication.shared.terminate(nil) } } @@ -82,7 +86,6 @@ private struct MenuContent: View { } private var statusRow: some View { - let relay = self.relayManager.status let health = self.healthStore.state let isRefreshing = self.healthStore.isRefreshing let lastAge = self.healthStore.lastSuccess.map { age(from: $0) } @@ -491,6 +494,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate private let xpcLogger = Logger(subsystem: "com.steipete.clawdis", category: "xpc") private let webChatAutoLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChat") private let allowedTeamIDs: Set = ["Y5PE65HELJ"] + let updaterController: UpdaterProviding = makeUpdaterController() @MainActor func applicationDidFinishLaunching(_ notification: Notification) { @@ -609,3 +613,76 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate private func writeEndpoint(_ endpoint: NSXPCListenerEndpoint) {} @MainActor private func writeEndpointIfAvailable() {} } + +// MARK: - Sparkle updater (disabled for unsigned/dev builds) + +@MainActor +protocol UpdaterProviding: AnyObject { + var automaticallyChecksForUpdates: Bool { get set } + var isAvailable: Bool { get } + func checkForUpdates(_ sender: Any?) +} + +// No-op updater used for debug/dev runs to suppress Sparkle dialogs. +final class DisabledUpdaterController: UpdaterProviding { + var automaticallyChecksForUpdates: Bool = false + let isAvailable: Bool = false + func checkForUpdates(_: Any?) {} +} + +#if canImport(Sparkle) +import Sparkle + +extension SPUStandardUpdaterController: UpdaterProviding { + var automaticallyChecksForUpdates: Bool { + get { self.updater.automaticallyChecksForUpdates } + set { self.updater.automaticallyChecksForUpdates = newValue } + } + + var isAvailable: Bool { true } +} + +private func isDeveloperIDSigned(bundleURL: URL) -> Bool { + var staticCode: SecStaticCode? + guard SecStaticCodeCreateWithPath(bundleURL as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, + let code = staticCode + else { return false } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any], + let certs = info[kSecCodeInfoCertificates as String] as? [SecCertificate], + let leaf = certs.first + else { + return false + } + + if let summary = SecCertificateCopySubjectSummary(leaf) as String? { + return summary.hasPrefix("Developer ID Application:") + } + return false +} + +private func makeUpdaterController() -> UpdaterProviding { + let bundleURL = Bundle.main.bundleURL + let isBundledApp = bundleURL.pathExtension == "app" + guard isBundledApp, isDeveloperIDSigned(bundleURL: bundleURL) else { return DisabledUpdaterController() } + + let defaults = UserDefaults.standard + let autoUpdateKey = "autoUpdateEnabled" + // Default to true; honor the user's last choice otherwise. + let savedAutoUpdate = (defaults.object(forKey: autoUpdateKey) as? Bool) ?? true + + let controller = SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: nil, + userDriverDelegate: nil) + controller.updater.automaticallyChecksForUpdates = savedAutoUpdate + controller.startUpdater() + return controller +} +#else +private func makeUpdaterController() -> UpdaterProviding { + DisabledUpdaterController() +} +#endif diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 86af1400c..ad527bbfe 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -5,6 +5,7 @@ struct SettingsRootView: View { @ObservedObject private var permissionMonitor = PermissionMonitor.shared @State private var monitoringPermissions = false @State private var selectedTab: SettingsTab = .general + let updater: UpdaterProviding? var body: some View { TabView(selection: self.$selectedTab) { @@ -41,7 +42,7 @@ struct SettingsRootView: View { .tag(SettingsTab.debug) } - AboutSettings() + AboutSettings(updater: self.updater) .tabItem { Label("About", systemImage: "info.circle") } .tag(SettingsTab.about) } diff --git a/docs/clawdis-mac.md b/docs/clawdis-mac.md index 1beb83d85..1ee216d39 100644 --- a/docs/clawdis-mac.md +++ b/docs/clawdis-mac.md @@ -91,4 +91,4 @@ struct Response { ok: Bool; message?: String; payload?: Data } - Where to place the dev symlink `bin/clawdis-mac` (repo root vs. `apps/macos/bin`)? - Should `runShell` support streaming stdout/stderr (XPC with AsyncSequence) or just buffered? (Start buffered; streaming later.) - Icon: reuse Clawdis lobster or new mac-specific glyph? -- Sparkle updates: out of scope initially; add later if we ship signed builds. +- Sparkle updates: bundled via Sparkle; release builds point at `https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml` and enable auto-checks, while debug builds leave the feed blank and disable checks. diff --git a/docs/mac/release.md b/docs/mac/release.md new file mode 100644 index 000000000..bfa86ae3e --- /dev/null +++ b/docs/mac/release.md @@ -0,0 +1,67 @@ +--- +summary: "Clawdis macOS release checklist (Sparkle feed, packaging, signing)" +read_when: + - Cutting or validating a Clawdis macOS release + - Updating the Sparkle appcast or feed assets +--- + +# Clawdis macOS release (Sparkle) + +This app now ships Sparkle auto-updates. Release builds must be Developer ID–signed, zipped, and published with a signed appcast entry. + +## Prereqs +- Developer ID Application cert installed (`Developer ID Application: Peter Steinberger (Y5PE65HELJ)` is expected). +- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE`; key lives in `/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle` (same key as Trimmy; public key baked into Info.plist). +- `pnpm` deps installed (`pnpm install --config.node-linker=hoisted`). +- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.). + +## Build & package +```bash +# From repo root; set release IDs so Sparkle feed is enabled +BUNDLE_ID=com.steipete.clawdis \ +APP_VERSION=0.1.0 \ +APP_BUILD=0.1.0 \ +BUILD_CONFIG=release \ +SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \ +scripts/package-mac-app.sh + +# Zip for distribution (includes resource forks for Sparkle delta support) +ditto -c -k --sequesterRsrc --keepParent dist/Clawdis.app dist/Clawdis-0.1.0.zip + +# Optional: ship dSYM alongside the release +ditto -c -k --keepParent apps/macos/.build/release/Clawdis.app.dSYM dist/Clawdis-0.1.0.dSYM.zip +``` + +## Appcast entry +1. Generate the ed25519 signature (requires `SPARKLE_PRIVATE_KEY_FILE`): + ```bash + SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key \ + apps/macos/.build/artifacts/sparkle/Sparkle/bin/sign_update dist/Clawdis-0.1.0.zip + ``` + Copy the reported signature and file size. +2. Edit `appcast.xml` (root of repo), add a new `` at the top pointing to the GitHub release asset. Example snippet to adapt: + ```xml + + Clawdis 0.1.0 + https://github.com/steipete/clawdis/releases/tag/v0.1.0 + Sun, 07 Dec 2025 12:00:00 +0000 + + + ``` + Keep the newest item first; leave the channel metadata intact. +3. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. + +## Publish & verify +- Upload `Clawdis-0.1.0.zip` (and `Clawdis-0.1.0.dSYM.zip`) to the GitHub release for tag `v0.1.0`. +- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml`. +- Sanity checks: + - `curl -I https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml` returns 200. + - `curl -I ` returns 200 after assets upload. + - On a previous public build, run “Check for Updates…” from the About tab and verify Sparkle installs the new build cleanly. + +Definition of done: signed app + appcast are published, update flow works from an older installed version, and release assets are attached to the GitHub release. diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index e98d3f556..baf5c0e1e 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -69,6 +69,11 @@ sign_item() { codesign --force --options runtime --timestamp=none --entitlements "$ENT_TMP" --sign "$IDENTITY" "$target" } +sign_plain_item() { + local target="$1" + codesign --force --options runtime --timestamp=none --sign "$IDENTITY" "$target" +} + # Sign main binary and CLI helper if present if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdis" ]; then echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdis" @@ -84,10 +89,26 @@ if [ -d "$APP_BUNDLE/Contents/Resources/Relay" ]; then done fi -# Sign any embedded frameworks/dylibs if they ever appear +# Sign Sparkle deeply if present +SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework" +if [ -d "$SPARKLE" ]; then + echo "Signing Sparkle framework and helpers" + sign_plain_item "$SPARKLE/Versions/B/Sparkle" + sign_plain_item "$SPARKLE/Versions/B/Autoupdate" + sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" + sign_plain_item "$SPARKLE/Versions/B/Updater.app" + sign_plain_item "$SPARKLE/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" + sign_plain_item "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" + sign_plain_item "$SPARKLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" + sign_plain_item "$SPARKLE/Versions/B/XPCServices/Installer.xpc" + sign_plain_item "$SPARKLE/Versions/B" + sign_plain_item "$SPARKLE" +fi + +# Sign any other embedded frameworks/dylibs if [ -d "$APP_BUNDLE/Contents/Frameworks" ]; then - find "$APP_BUNDLE/Contents/Frameworks" \( -name "*.framework" -o -name "*.dylib" \) -print0 | while IFS= read -r -d '' f; do - echo "Signing framework: $f"; sign_item "$f" + find "$APP_BUNDLE/Contents/Frameworks" \( -name "*.framework" -o -name "*.dylib" \) ! -path "*Sparkle.framework*" -print0 | while IFS= read -r -d '' f; do + echo "Signing framework: $f"; sign_plain_item "$f" done fi diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 40c6cb0b1..e0c278fa3 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -14,6 +14,14 @@ BUILD_TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") GIT_COMMIT=$(cd "$ROOT_DIR" && git rev-parse --short HEAD 2>/dev/null || echo "unknown") APP_VERSION="${APP_VERSION:-$PKG_VERSION}" APP_BUILD="${APP_BUILD:-$PKG_VERSION}" +BUILD_CONFIG="${BUILD_CONFIG:-debug}" +SPARKLE_PUBLIC_ED_KEY="${SPARKLE_PUBLIC_ED_KEY:-AGCY8w5vHirVfGGDGc8Szc5iuOqupZSh9pMj/Qs67XI=}" +SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml}" +AUTO_CHECKS=true +if [[ "$BUNDLE_ID" == *.debug ]]; then + SPARKLE_FEED_URL="" + AUTO_CHECKS=false +fi echo "📦 Ensuring deps (pnpm install)" (cd "$ROOT_DIR" && pnpm install --no-frozen-lockfile --config.node-linker=hoisted) @@ -22,16 +30,17 @@ echo "📦 Building JS (pnpm exec tsc)" cd "$ROOT_DIR/apps/macos" -echo "🔨 Building $PRODUCT (debug)" -swift build -c debug --product "$PRODUCT" --product "${PRODUCT}CLI" --build-path "$BUILD_PATH" +echo "🔨 Building $PRODUCT ($BUILD_CONFIG)" +swift build -c "$BUILD_CONFIG" --product "$PRODUCT" --product "${PRODUCT}CLI" --build-path "$BUILD_PATH" -BIN="$BUILD_PATH/debug/$PRODUCT" -CLI_BIN="$BUILD_PATH/debug/ClawdisCLI" +BIN="$BUILD_PATH/$BUILD_CONFIG/$PRODUCT" +CLI_BIN="$BUILD_PATH/$BUILD_CONFIG/ClawdisCLI" echo "🧹 Cleaning old app bundle" rm -rf "$APP_ROOT" mkdir -p "$APP_ROOT/Contents/MacOS" mkdir -p "$APP_ROOT/Contents/Resources" mkdir -p "$APP_ROOT/Contents/Resources/Relay" +mkdir -p "$APP_ROOT/Contents/Frameworks" echo "📄 Writing Info.plist" cat > "$APP_ROOT/Contents/Info.plist" < "$APP_ROOT/Contents/Info.plist" <${BUILD_TS} ClawdisGitCommit ${GIT_COMMIT} + SUFeedURL + ${SPARKLE_FEED_URL} + SUPublicEDKey + ${SPARKLE_PUBLIC_ED_KEY} + SUEnableAutomaticChecks + <${AUTO_CHECKS}/> NSUserNotificationUsageDescription Clawdis needs notification permission to show alerts for agent actions. NSScreenCaptureDescription @@ -79,6 +94,14 @@ echo "🚚 Copying binary" cp "$BIN" "$APP_ROOT/Contents/MacOS/Clawdis" chmod +x "$APP_ROOT/Contents/MacOS/Clawdis" +SPARKLE_FRAMEWORK="$BUILD_PATH/$BUILD_CONFIG/Sparkle.framework" +if [ -d "$SPARKLE_FRAMEWORK" ]; then + echo "✨ Embedding Sparkle.framework" + cp -R "$SPARKLE_FRAMEWORK" "$APP_ROOT/Contents/Frameworks/" + chmod -R a+rX "$APP_ROOT/Contents/Frameworks/Sparkle.framework" + install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP_ROOT/Contents/MacOS/Clawdis" +fi + echo "🖼 Copying app icon" cp "$ROOT_DIR/apps/macos/Sources/Clawdis/Resources/Clawdis.icns" "$APP_ROOT/Contents/Resources/Clawdis.icns"