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"