diff --git a/Package.swift b/Package.swift
index 42096e7..4c79e10 100644
--- a/Package.swift
+++ b/Package.swift
@@ -4,12 +4,19 @@ import PackageDescription
let package = Package(
name: "CopyCat",
platforms: [.macOS(.v14)],
+ dependencies: [
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"),
+ ],
targets: [
.executableTarget(
name: "CopyCat",
+ dependencies: [
+ .product(name: "Sparkle", package: "Sparkle"),
+ ],
path: "Sources/CopyCat",
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
+ .define("ENABLE_SPARKLE"),
]),
.testTarget(
name: "CopyCatTests",
diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh
index f118025..0baf035 100755
--- a/Scripts/build-app.sh
+++ b/Scripts/build-app.sh
@@ -7,7 +7,9 @@ source "$ROOT/version.env"
# YYMMDDHHMM timestamp — unique, monotonic, debuggable. Avoids manual bumping
# and satisfies the App Store / notarization monotonic-build-number rule.
-BUILD_NUMBER="$(date +%y%m%d%H%M)"
+# Local Sparkle tests build old and new apps back-to-back, so they inject
+# distinct build numbers instead of waiting for the next minute.
+BUILD_NUMBER="${COPYCAT_BUILD_NUMBER:-$(date +%y%m%d%H%M)}"
APP_NAME="CopyCat"
CONFIG="${1:-debug}"
@@ -25,7 +27,8 @@ fi
cd "$ROOT"
swift build -c "$CONFIG" --product "$APP_NAME"
-BIN_PATH="$ROOT/.build/$CONFIG/$APP_NAME"
+BUILD_DIR="$ROOT/.build/$CONFIG"
+BIN_PATH="$BUILD_DIR/$APP_NAME"
if [[ ! -x "$BIN_PATH" ]]; then
BIN_PATH="$(find "$ROOT/.build" -type f -path "*/$CONFIG/$APP_NAME" -print -quit || true)"
fi
@@ -37,9 +40,22 @@ fi
if [[ "$CONFIG" == "release" ]]; then
BUNDLE_ID="$RELEASE_BUNDLE_ID"
DISPLAY_NAME="CopyCat"
+ FEED_URL="https://raw.githubusercontent.com/andyhtran/CopyCat/main/appcast.xml"
+ AUTO_CHECKS=true
else
BUNDLE_ID="${RELEASE_BUNDLE_ID}.dev"
DISPLAY_NAME="CopyCat Dev"
+ FEED_URL=""
+ AUTO_CHECKS=false
+fi
+
+if [[ -n "${SPARKLE_FEED_URL_OVERRIDE:-}" ]]; then
+ if [[ "$CONFIG" != "debug" ]]; then
+ echo "SPARKLE_FEED_URL_OVERRIDE is only allowed for debug builds." >&2
+ exit 1
+ fi
+ # Local update-flow testing points the feed at a localhost appcast.
+ FEED_URL="$SPARKLE_FEED_URL_OVERRIDE"
fi
APP_BUNDLE="$ROOT/build/$APP_NAME.app"
@@ -58,6 +74,37 @@ for resource in AppIcon.icns MenuBarIcon.pdf; do
fi
done
+# Embed Sparkle.framework.
+FRAMEWORKS_DIR="$APP_BUNDLE/Contents/Frameworks"
+if [[ -d "$BUILD_DIR/Sparkle.framework" ]]; then
+ mkdir -p "$FRAMEWORKS_DIR"
+ cp -R "$BUILD_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
+ chmod -R a+rX "$FRAMEWORKS_DIR/Sparkle.framework"
+ install_name_tool -add_rpath "@executable_path/../Frameworks" \
+ "$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true
+
+ SPARKLE_FW="$FRAMEWORKS_DIR/Sparkle.framework"
+
+ if [[ "$CONFIG" == "debug" ]]; then
+ CODESIGN_ARGS=(--force --sign "-")
+ else
+ CODESIGN_ARGS=(--force --timestamp --options runtime --sign "${CODESIGN_IDENTITY:--}")
+ fi
+
+ resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; }
+
+ resign "$SPARKLE_FW/Versions/B/Sparkle"
+ resign "$SPARKLE_FW/Versions/B/Autoupdate"
+ resign "$SPARKLE_FW/Versions/B/Updater.app/Contents/MacOS/Updater"
+ resign "$SPARKLE_FW/Versions/B/Updater.app"
+ resign "$SPARKLE_FW/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader"
+ resign "$SPARKLE_FW/Versions/B/XPCServices/Downloader.xpc"
+ resign "$SPARKLE_FW/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer"
+ resign "$SPARKLE_FW/Versions/B/XPCServices/Installer.xpc"
+ resign "$SPARKLE_FW/Versions/B"
+ resign "$SPARKLE_FW"
+fi
+
cat > "$APP_BUNDLE/Contents/Info.plist" <
@@ -93,6 +140,16 @@ cat > "$APP_BUNDLE/Contents/Info.plist" <
NSPrincipalClass
NSApplication
+ SUFeedURL
+ ${FEED_URL}
+ SUPublicEDKey
+ ${SU_PUBLIC_ED_KEY}
+ SUEnableAutomaticChecks
+ <${AUTO_CHECKS}/>
+ SUAutomaticallyUpdate
+
+ SUAllowsAutomaticUpdates
+
PLIST
diff --git a/Scripts/make-appcast.sh b/Scripts/make-appcast.sh
new file mode 100755
index 0000000..a9d0080
--- /dev/null
+++ b/Scripts/make-appcast.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+source "$ROOT/version.env"
+
+ZIP=${1:?"Usage: $0 .zip>"}
+FEED_URL="${SPARKLE_FEED_URL:-https://raw.githubusercontent.com/andyhtran/CopyCat/main/appcast.xml}"
+SPARKLE_CHANNEL="${SPARKLE_CHANNEL:-}"
+
+if [[ ! -f "$ZIP" ]]; then
+ echo "Zip not found: $ZIP" >&2
+ exit 1
+fi
+
+if ! command -v generate_appcast &>/dev/null; then
+ echo "generate_appcast not found. Install: brew install andyhtran/tap/sparkle-tools" >&2
+ exit 1
+fi
+
+ZIP_NAME=$(basename "$ZIP")
+DOWNLOAD_URL_PREFIX="${SPARKLE_DOWNLOAD_URL_PREFIX:-https://github.com/andyhtran/CopyCat/releases/download/v${MARKETING_VERSION}/}"
+
+WORK_DIR=$(mktemp -d /tmp/appcast-gen.XXXXXX)
+cleanup() { rm -rf "$WORK_DIR"; }
+trap cleanup EXIT
+
+if [[ -f "$ROOT/appcast.xml" ]]; then
+ cp "$ROOT/appcast.xml" "$WORK_DIR/appcast.xml"
+fi
+cp "$ZIP" "$WORK_DIR/$ZIP_NAME"
+
+CHANNEL_ARGS=()
+if [[ -n "$SPARKLE_CHANNEL" ]]; then
+ CHANNEL_ARGS=(--channel "$SPARKLE_CHANNEL")
+fi
+
+generate_appcast \
+ --download-url-prefix "$DOWNLOAD_URL_PREFIX" \
+ --embed-release-notes \
+ --link "$FEED_URL" \
+ "${CHANNEL_ARGS[@]}" \
+ "$WORK_DIR"
+
+cp "$WORK_DIR/appcast.xml" "$ROOT/appcast.xml"
+
+echo "Appcast updated: appcast.xml"
+echo "Upload $ZIP_NAME to GitHub release, then commit appcast.xml."
diff --git a/Scripts/test-update-flow.sh b/Scripts/test-update-flow.sh
new file mode 100755
index 0000000..f885fcb
--- /dev/null
+++ b/Scripts/test-update-flow.sh
@@ -0,0 +1,127 @@
+#!/usr/bin/env bash
+#
+# Local end-to-end test of the Sparkle update flow with the real updater:
+#
+# 1. Builds the current version as "CopyCat Dev.app", Developer ID signed
+# (required for UpdaterFactory to enable Sparkle), with its feed pointed
+# at a localhost appcast.
+# 2. Builds a version-bumped copy, signs it, zips it, and generates a
+# signed appcast for it (requires the Sparkle EdDSA private key in the
+# login Keychain, same as a real release).
+# 3. Serves zip + appcast on localhost and launches the old version.
+#
+# From there: use the menu or Settings window to Check for Updates, click
+# Install Update, and watch downloading → preparing → installing → relaunch as
+# the bumped version. Ctrl-C stops the server.
+#
+# Nothing is committed or uploaded; version.env is restored on exit.
+set -euo pipefail
+
+ROOT=$(cd "$(dirname "$0")/.." && pwd)
+cd "$ROOT"
+
+PORT="${PORT:-8123}"
+FEED="http://localhost:${PORT}/appcast.xml"
+INSTALL_PATH="/Applications/CopyCat Dev.app"
+DEV_EXEC="${INSTALL_PATH}/Contents/MacOS/CopyCat"
+DEV_BUNDLE_ID="${COPYCAT_BUNDLE_ID:-com.copycat.macos.app}.dev"
+SIGNING_ID="${CODESIGN_IDENTITY:?Set CODESIGN_IDENTITY to your Developer ID Application identity}"
+
+if ! command -v generate_appcast &>/dev/null; then
+ echo "generate_appcast not found. Install: brew install andyhtran/tap/sparkle-tools" >&2
+ exit 1
+fi
+
+source version.env
+SERVE_DIR=$(mktemp -d /tmp/copycat-update-test.XXXXXX)
+VERSION_BACKUP=$(mktemp /tmp/copycat-version-env.XXXXXX)
+cp version.env "$VERSION_BACKUP"
+OLD_BUILD=$(date +%y%m%d%H%M%S)
+NEW_BUILD=$((OLD_BUILD + 1))
+
+SERVER_PID=""
+cleanup() {
+ cp "$VERSION_BACKUP" version.env
+ rm -f "$VERSION_BACKUP"
+ rm -rf "$SERVE_DIR"
+ [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+quit_dev_app() {
+ osascript -e "tell application id \"${DEV_BUNDLE_ID}\" to quit" \
+ >/dev/null 2>&1 || true
+ sleep 1
+ while read -r pid; do
+ [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
+ done < <(pgrep -f "$DEV_EXEC" 2>/dev/null || true)
+}
+
+sign_dev_with_developer_id() {
+ local app=${1:?usage: sign_dev_with_developer_id }
+ codesign --force --deep --timestamp --options runtime \
+ --sign "$SIGNING_ID" \
+ --entitlements "build/CopyCat.entitlements" \
+ "$app"
+}
+
+echo "==> Building current version (${MARKETING_VERSION}, build ${OLD_BUILD})..."
+COPYCAT_BUILD_NUMBER="$OLD_BUILD" \
+ SPARKLE_FEED_URL_OVERRIDE="$FEED" bash Scripts/build-app.sh debug
+sign_dev_with_developer_id "build/CopyCat.app"
+
+echo "==> Installing to ${INSTALL_PATH}..."
+quit_dev_app
+rm -rf "$INSTALL_PATH"
+cp -R "build/CopyCat.app" "$INSTALL_PATH"
+
+NEW_MARKETING="${MARKETING_VERSION%.*}.$((${MARKETING_VERSION##*.} + 1))"
+echo "==> Building update (${NEW_MARKETING}, build ${NEW_BUILD})..."
+sed -i '' \
+ -e "s/^MARKETING_VERSION=.*/MARKETING_VERSION=${NEW_MARKETING}/" \
+ version.env
+COPYCAT_BUILD_NUMBER="$NEW_BUILD" \
+ SPARKLE_FEED_URL_OVERRIDE="$FEED" bash Scripts/build-app.sh debug
+cp "$VERSION_BACKUP" version.env
+sign_dev_with_developer_id "build/CopyCat.app"
+
+echo "==> Generating signed appcast..."
+/usr/bin/ditto -c -k --keepParent "build/CopyCat.app" \
+ "$SERVE_DIR/CopyCat-${NEW_MARKETING}.zip"
+rm -rf "build/CopyCat.app"
+generate_appcast \
+ --download-url-prefix "http://localhost:${PORT}/" \
+ --link "$FEED" \
+ "$SERVE_DIR"
+
+echo "==> Serving appcast on port ${PORT}..."
+python3 -m http.server "$PORT" --directory "$SERVE_DIR" --bind 127.0.0.1 \
+ >/dev/null 2>&1 &
+SERVER_PID=$!
+
+# The debug-only UpdateSimulator shadows the real updater when its defaults
+# key is set; a leftover key from a simulator session would silently turn
+# this whole test into a simulation.
+defaults delete "$DEV_BUNDLE_ID" "UpdateSimulatorScenario" 2>/dev/null || true
+
+open "$INSTALL_PATH"
+
+cat <&2
+ exit 1
+fi
+
+if ! command -v sign_update &>/dev/null; then
+ echo "sign_update not found. Install: brew install andyhtran/tap/sparkle-tools" >&2
+ exit 1
+fi
+
+TMP_ZIP=$(mktemp /tmp/appcast-verify.XXXX.zip)
+trap 'rm -f "$TMP_ZIP" "$TMP_ZIP.meta"' EXIT
+
+python3 - "$APPCAST" "$VERSION" >"$TMP_ZIP.meta" <<'PY'
+import sys, xml.etree.ElementTree as ET
+
+appcast, version = sys.argv[1], sys.argv[2]
+tree = ET.parse(appcast)
+ns = {"sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"}
+
+for item in tree.getroot().findall("./channel/item"):
+ sv = item.findtext("sparkle:shortVersionString", default="", namespaces=ns)
+ if sv == version:
+ enc = item.find("enclosure")
+ url = enc.get("url")
+ sig = enc.get("{http://www.andymatuschak.org/xml-namespaces/sparkle}edSignature")
+ length = enc.get("length")
+ if not all([url, sig, length]):
+ sys.exit(f"Missing url/signature/length for version {version}")
+ print(url)
+ print(sig)
+ print(length)
+ sys.exit(0)
+
+sys.exit(f"No appcast entry for version {version}")
+PY
+
+readarray -t META <"$TMP_ZIP.meta"
+URL="${META[0]}"
+SIG="${META[1]}"
+LEN_EXPECTED="${META[2]}"
+
+echo "Downloading: $URL"
+curl -fSL -o "$TMP_ZIP" "$URL"
+
+LEN_ACTUAL=$(stat -f%z "$TMP_ZIP")
+if [[ "$LEN_ACTUAL" != "$LEN_EXPECTED" ]]; then
+ echo "Length mismatch: expected $LEN_EXPECTED, got $LEN_ACTUAL" >&2
+ exit 1
+fi
+
+echo "Verifying signature..."
+sign_update --verify "$TMP_ZIP" "$SIG"
+echo "Appcast entry for $VERSION verified."
diff --git a/Sources/CopyCat/AppVersionInfo.swift b/Sources/CopyCat/AppVersionInfo.swift
new file mode 100644
index 0000000..f34f206
--- /dev/null
+++ b/Sources/CopyCat/AppVersionInfo.swift
@@ -0,0 +1,43 @@
+import Foundation
+import SwiftUI
+
+struct AppVersionInfo {
+ let shortVersion: String
+
+ static var current: AppVersionInfo {
+ let info = Bundle.main.infoDictionary ?? [:]
+ let shortVersion = info["CFBundleShortVersionString"] as? String
+
+ return AppVersionInfo(
+ shortVersion: shortVersion?.nilIfBlank ?? "Development"
+ )
+ }
+
+ var displayString: String {
+ shortVersion
+ }
+}
+
+struct AppVersionFooter: View {
+ private let version = AppVersionInfo.current
+
+ var body: some View {
+ HStack {
+ Text("Version")
+ Spacer()
+ Text(version.displayString)
+ .monospacedDigit()
+ }
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("Version \(version.displayString)")
+ }
+}
+
+private extension String {
+ var nilIfBlank: String? {
+ let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+}
diff --git a/Sources/CopyCat/CopyCatApp.swift b/Sources/CopyCat/CopyCatApp.swift
index 4baf5e7..d8b43fa 100644
--- a/Sources/CopyCat/CopyCatApp.swift
+++ b/Sources/CopyCat/CopyCatApp.swift
@@ -1,5 +1,6 @@
import AppKit
import SwiftUI
+import UserNotifications
@main
struct CopyCatApp: App {
@@ -36,6 +37,7 @@ struct CopyCatApp: App {
// can't surface it.
MenuBarExtra(isInserted: $showMenuBarIcon) {
CopyCatMenu()
+ .environment(\.updaterController, appDelegate.updaterController)
} label: {
Image(nsImage: menuBarIcon)
.accessibilityLabel("CopyCat")
@@ -50,6 +52,9 @@ private struct CopyCatMenu: View {
StatusHeader()
Divider()
+ UpdateMenuItems()
+ Divider()
+
Toggle("Local paste (\(HotkeyBinding.localPaste.displayString))", isOn: $store.enableLocalPaste)
Toggle("SSH paste (\(store.broadcastHotkey.label))", isOn: $store.enableBroadcast)
@@ -95,6 +100,72 @@ private struct CopyCatMenu: View {
}
}
+private struct UpdateMenuItems: View {
+ @Environment(\.updaterController) private var updaterController
+
+ var body: some View {
+ switch updaterController?.updateViewModel.state ?? .idle {
+ case .idle:
+ Button("Check for Updates", action: checkForUpdates)
+ .disabled(!isUpdaterAvailable)
+ if let reason = updaterController?.unavailableReason {
+ Text(reason)
+ }
+
+ case .checking:
+ Text("Checking for Updates…")
+
+ case .updateAvailable(let update):
+ Button("Install Update \(update.version)") {
+ update.install()
+ }
+ Button("Later") {
+ update.dismiss()
+ }
+
+ case .downloading(let download):
+ Text(downloadTitle(for: download))
+ Button("Cancel Download") {
+ download.cancel()
+ }
+
+ case .extracting:
+ Text("Preparing Update…")
+
+ case .installing:
+ Text("Installing Update…")
+
+ case .notFound:
+ Text("You're up to date")
+ Button("Check Again", action: checkForUpdates)
+ .disabled(!isUpdaterAvailable)
+
+ case .failed:
+ Text("Update Failed")
+ Button("Retry Update Check", action: checkForUpdates)
+ .disabled(!isUpdaterAvailable)
+ }
+ }
+
+ private var isUpdaterAvailable: Bool {
+ updaterController?.isAvailable == true
+ }
+
+ private func checkForUpdates() {
+ guard updaterController?.updateViewModel.state.allowsManualCheck == true else {
+ return
+ }
+ updaterController?.checkForUpdates(nil)
+ }
+
+ private func downloadTitle(for download: UpdateState.Downloading) -> String {
+ if let fraction = download.fraction {
+ return "Downloading Update… \(Int(fraction * 100))%"
+ }
+ return "Downloading Update…"
+ }
+}
+
private struct StatusHeader: View {
@ObservedObject private var store = SettingsStore.shared
@ObservedObject private var status = StatusModel.shared
@@ -208,10 +279,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
var pasteHandler: PasteHandler?
private var settingsWindowController: SettingsWindowController?
+ let updaterController: UpdaterProviding = makeUpdaterController()
func openSettings() {
if settingsWindowController == nil {
- settingsWindowController = SettingsWindowController()
+ settingsWindowController = SettingsWindowController(updaterController: updaterController)
}
NSApp.activate(ignoringOtherApps: true)
settingsWindowController?.showWindow(nil)
@@ -220,6 +292,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
AppDelegate.shared = self
Settings.registerDefaults()
+ UNUserNotificationCenter.current().delegate = self
NSApp.setActivationPolicy(.accessory)
Log.app.info("CopyCat launching (pid=\(ProcessInfo.processInfo.processIdentifier))")
@@ -250,3 +323,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return true
}
}
+
+extension AppDelegate: UNUserNotificationCenterDelegate {
+ nonisolated func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse
+ ) async {
+ let identifier = response.notification.request.identifier
+ guard identifier == UpdateNotification.identifier else { return }
+ // The update session is still pending in the updater's view model;
+ // opening Settings surfaces the Install action even if the menu bar
+ // icon is hidden.
+ await MainActor.run {
+ self.openSettings()
+ }
+ }
+
+ nonisolated func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification
+ ) async -> UNNotificationPresentationOptions {
+ // Without this, notifications are suppressed whenever the app is
+ // active (for example, while Settings is open).
+ [.banner]
+ }
+}
diff --git a/Sources/CopyCat/PasteHandler.swift b/Sources/CopyCat/PasteHandler.swift
index 3e8bf27..1d098ca 100644
--- a/Sources/CopyCat/PasteHandler.swift
+++ b/Sources/CopyCat/PasteHandler.swift
@@ -10,6 +10,7 @@ final class PasteHandler: @unchecked Sendable {
private var watchdog: Timer?
private var uiTimer: Timer?
private var lastEventTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()
+ private var starvedRebuilds = 0
// Dedup flag so the blocked→restored transition is logged/notified once
// each, not every 30s tick. Menu state is pushed separately via publishStatus.
private var secureInputWarned = false
@@ -213,15 +214,36 @@ final class PasteHandler: @unchecked Sendable {
}
}
- // How long the tap must be silent before we look for Secure Input. Silence
- // is NOT proof of a dead tap — it's identical to the user simply not typing —
- // so it only gates the Secure Input check (and keeps that from false-alarming
- // on brief password prompts). The "silent death" that originally motivated a
- // silence-based reinstall traced back to Secure Input, which we now detect
- // directly; real tap death surfaces via tapIsEnabled and the OS tapDisabled
- // events instead.
+ // How long the tap must be silent before we look for blocked delivery paths.
+ // Silence is NOT proof of a dead tap — it's identical to the user simply not
+ // typing — so it only gates checks that have an independent signal. Secure
+ // Input is detected directly, and real tap death must surface via tapIsEnabled,
+ // the OS tapDisabled events, or the starved-queue check below.
private static let staleTapInterval: CFTimeInterval = 90
+ // WindowServer-reported queue latency above which an "enabled" tap is
+ // treated as dead. Healthy FILTER taps report µs–ms; WindowServer's own
+ // per-event tap timeout is single-digit seconds, so anything past 5s means
+ // events are rotting in the queue, not being processed slowly.
+ private static let starvedTapLatencyUs: Float = 5_000_000
+
+ // How WindowServer sees our tap. A starved tap — mach port still registered
+ // but its events no longer being serviced — keeps reporting enabled=true,
+ // so tapIsEnabled can't detect it. The queue latency WindowServer tracks
+ // per tap can: it grows in lockstep with wall clock while an event sits
+ // undelivered.
+ private func reportedTapLatencyUs() -> Float? {
+ var count: UInt32 = 0
+ guard CGGetEventTapList(0, nil, &count) == .success, count > 0 else { return nil }
+ var taps = [CGEventTapInformation](repeating: CGEventTapInformation(), count: Int(count))
+ guard CGGetEventTapList(count, &taps, &count) == .success else { return nil }
+ let pid = getpid()
+ return taps.prefix(Int(count))
+ .filter { $0.tappingProcess == pid }
+ .map(\.avgUsecLatency)
+ .max()
+ }
+
private func checkAndRevive() {
// Refresh the menu model once per tick regardless of which branch we
// take — this is what keeps Secure Input status current in the menu.
@@ -234,6 +256,21 @@ final class PasteHandler: @unchecked Sendable {
let enabled = CGEvent.tapIsEnabled(tap: tap)
let silent = CFAbsoluteTimeGetCurrent() - lastEventTime
+ // Starved tap: enabled by every local measure, but WindowServer shows
+ // events queued and unserviced. Re-enabling is a no-op for this state;
+ // only a full rebuild recovers. Gate on silence too so one slow event
+ // around a sleep/wake transition doesn't churn a healthy tap. Repeated
+ // rebuilds point to an upstream event-delivery/session problem; the count
+ // in the log line is the diagnostic signal.
+ if enabled && silent > Self.staleTapInterval,
+ let latencyUs = reportedTapLatencyUs(), latencyUs > Self.starvedTapLatencyUs {
+ starvedRebuilds += 1
+ Log.watchdog.error("tap starved — enabled but WindowServer queue latency \(Int(latencyUs / 1_000_000))s; rebuilding (rebuild #\(starvedRebuilds) since last healthy tick)")
+ teardownTap()
+ installTap()
+ return
+ }
+
// Long silence with the tap still "enabled" has one confirmed cause:
// Secure Input swallowing key events. Idle looks identical, so we surface
// Secure Input but deliberately do NOT reinstall on silence — that just
@@ -252,6 +289,7 @@ final class PasteHandler: @unchecked Sendable {
noteSecureInputCleared()
if enabled {
+ starvedRebuilds = 0
Log.watchdog.info("tap.enabled=true")
return
}
diff --git a/Sources/CopyCat/SettingsView.swift b/Sources/CopyCat/SettingsView.swift
index dce6e4a..eb23b46 100644
--- a/Sources/CopyCat/SettingsView.swift
+++ b/Sources/CopyCat/SettingsView.swift
@@ -40,6 +40,7 @@ struct SettingsView: View {
// MARK: - General
private struct GeneralSettingsView: View {
+ @Environment(\.updaterController) private var updaterController
@ObservedObject private var store = SettingsStore.shared
// Bound directly to the same UserDefaults key as the App scene's
@@ -47,14 +48,39 @@ private struct GeneralSettingsView: View {
// immediately. Routing through SettingsStore (raw UserDefaults.set) does
// not reliably notify @AppStorage observers.
@AppStorage("showMenuBarIcon") private var showMenuBarIcon: Bool = true
+ @State private var autoUpdateEnabled = true
var body: some View {
Form {
- Section("Behavior") {
+ Section("App") {
Toggle("Launch at login", isOn: $store.launchAtLogin)
.onChange(of: store.launchAtLogin) { _, new in
LaunchAtLogin.setEnabled(new)
}
+ Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
+ Toggle(
+ "Check for updates automatically",
+ isOn: Binding(
+ get: { autoUpdateEnabled },
+ set: {
+ autoUpdateEnabled = $0
+ updaterController?.automaticallyChecksForUpdates = $0
+ }
+ )
+ )
+
+ LabeledContent("Check for updates") {
+ updateCheckContent
+ }
+
+ if let reason = updaterController?.unavailableReason {
+ Text(reason)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Pasting") {
Toggle(
"Enable local paste (\(HotkeyBinding.localPaste.displayString))",
isOn: $store.enableLocalPaste)
@@ -67,10 +93,6 @@ private struct GeneralSettingsView: View {
.disabled(!store.enableBroadcast)
}
- Section("Appearance") {
- Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
- }
-
Section("Cache") {
LabeledContent("Local cache") {
HStack {
@@ -85,9 +107,83 @@ private struct GeneralSettingsView: View {
Text("Keep most recent: \(store.cacheKeepCount) screenshots")
}
}
+
+ Section("About") {
+ LabeledContent("Version", value: AppVersionInfo.current.displayString)
+ }
}
.formStyle(.grouped)
.padding(.horizontal, 4)
+ .onAppear {
+ autoUpdateEnabled = updaterController?.automaticallyChecksForUpdates ?? true
+ }
+ }
+
+ /// Mirrors the live update state next to the Check Now button, since the
+ /// menu (where the full banner lives) may be closed while the user is in
+ /// this window.
+ @ViewBuilder private var updateCheckContent: some View {
+ switch updaterController?.updateViewModel.state ?? .idle {
+ case .idle:
+ checkNowButton
+
+ case .checking:
+ HStack(spacing: 8) {
+ ProgressView()
+ .controlSize(.small)
+ Text("Checking…")
+ .foregroundStyle(.secondary)
+ }
+
+ case .updateAvailable(let update):
+ Button("Install \(update.version)") {
+ update.install()
+ }
+
+ case .downloading(let download):
+ Text(
+ download.fraction.map { "Downloading… \(Int($0 * 100))%" }
+ ?? "Downloading…"
+ )
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+
+ case .extracting:
+ Text("Preparing…")
+ .foregroundStyle(.secondary)
+
+ case .installing:
+ Text("Installing… CopyCat will relaunch")
+ .foregroundStyle(.secondary)
+
+ case .notFound:
+ Text("You're up to date")
+ .foregroundStyle(.secondary)
+
+ case .failed:
+ HStack(spacing: 8) {
+ Text("Update failed")
+ .foregroundStyle(.secondary)
+ checkNowButton
+ }
+ }
+ }
+
+ private var checkNowButton: some View {
+ Button("Check Now") {
+ guard updaterController?.updateViewModel.state.allowsManualCheck == true else {
+ return
+ }
+ updaterController?.checkForUpdates(nil)
+ }
+ .disabled(updateCheckDisabled)
+ }
+
+ private var updateCheckDisabled: Bool {
+ guard let updaterController, updaterController.isAvailable else {
+ return true
+ }
+ return !updaterController.updateViewModel.state.allowsManualCheck
}
}
diff --git a/Sources/CopyCat/SettingsWindow.swift b/Sources/CopyCat/SettingsWindow.swift
index de8eb02..6827094 100644
--- a/Sources/CopyCat/SettingsWindow.swift
+++ b/Sources/CopyCat/SettingsWindow.swift
@@ -37,8 +37,11 @@ final class SettingsWindowController: NSWindowController, NSToolbarDelegate {
specs.first { $0.id == id }
}
- convenience init() {
- let host = NSHostingController(rootView: SettingsView())
+ init(updaterController: UpdaterProviding?) {
+ let host = NSHostingController(
+ rootView: SettingsView()
+ .environment(\.updaterController, updaterController)
+ )
let window = NSWindow(contentViewController: host)
window.title = "CopyCat Settings"
window.styleMask = [.titled, .closable]
@@ -46,7 +49,7 @@ final class SettingsWindowController: NSWindowController, NSToolbarDelegate {
window.setFrameAutosaveName("CopyCatSettingsWindow")
window.center()
- self.init(window: window)
+ super.init(window: window)
let toolbar = NSToolbar(identifier: "CopyCatSettingsToolbar")
toolbar.delegate = self
@@ -66,6 +69,9 @@ final class SettingsWindowController: NSWindowController, NSToolbarDelegate {
}
}
+ @available(*, unavailable)
+ required init?(coder: NSCoder) { fatalError() }
+
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
Self.specs.map(\.id)
}
diff --git a/Sources/CopyCat/Updater/DisabledUpdaterController.swift b/Sources/CopyCat/Updater/DisabledUpdaterController.swift
new file mode 100644
index 0000000..6989262
--- /dev/null
+++ b/Sources/CopyCat/Updater/DisabledUpdaterController.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+@MainActor
+final class DisabledUpdaterController: UpdaterProviding {
+ var automaticallyChecksForUpdates: Bool {
+ get { UpdaterDefaults.savedAutoUpdateEnabled() }
+ set { UpdaterDefaults.setAutoUpdateEnabled(newValue) }
+ }
+ let isAvailable: Bool = false
+ let unavailableReason: String?
+ let updateViewModel = UpdateViewModel()
+
+ init(unavailableReason: String? = nil) {
+ self.unavailableReason = unavailableReason
+ }
+
+ func checkForUpdates(_ sender: Any?) {}
+}
diff --git a/Sources/CopyCat/Updater/InstallOrigin.swift b/Sources/CopyCat/Updater/InstallOrigin.swift
new file mode 100644
index 0000000..6de78ac
--- /dev/null
+++ b/Sources/CopyCat/Updater/InstallOrigin.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+enum InstallOrigin {
+ static func isHomebrewCask(appBundleURL: URL) -> Bool {
+ let resolved = appBundleURL.resolvingSymlinksInPath()
+ let path = resolved.path
+ return path.contains("/Caskroom/") || path.contains("/Homebrew/Caskroom/")
+ }
+}
diff --git a/Sources/CopyCat/Updater/SparkleUpdaterController.swift b/Sources/CopyCat/Updater/SparkleUpdaterController.swift
new file mode 100644
index 0000000..c6ea5cb
--- /dev/null
+++ b/Sources/CopyCat/Updater/SparkleUpdaterController.swift
@@ -0,0 +1,82 @@
+#if canImport(Sparkle) && ENABLE_SPARKLE
+import AppKit
+import Foundation
+import Sparkle
+
+@MainActor
+final class SparkleUpdaterController: NSObject, UpdaterProviding {
+ private let driver: UpdateDriver
+ private let updater: SPUUpdater
+ private var started = false
+ let unavailableReason: String? = nil
+
+ var updateViewModel: UpdateViewModel { driver.viewModel }
+
+ init(savedAutoUpdate: Bool) {
+ let driver = UpdateDriver(viewModel: UpdateViewModel())
+ self.driver = driver
+ self.updater = SPUUpdater(
+ hostBundle: .main,
+ applicationBundle: .main,
+ userDriver: driver,
+ delegate: nil)
+ super.init()
+
+ UpdaterDefaults.disableAutomaticDownloads()
+ updater.automaticallyChecksForUpdates = savedAutoUpdate
+ updater.automaticallyDownloadsUpdates = false
+ startUpdater()
+ }
+
+ var automaticallyChecksForUpdates: Bool {
+ get { updater.automaticallyChecksForUpdates }
+ set {
+ UpdaterDefaults.setAutoUpdateEnabled(newValue)
+ UpdaterDefaults.disableAutomaticDownloads()
+ updater.automaticallyChecksForUpdates = newValue
+ updater.automaticallyDownloadsUpdates = false
+ }
+ }
+
+ var isAvailable: Bool { true }
+
+ func checkForUpdates(_ sender: Any?) {
+ guard started else {
+ startUpdater()
+ if started { updater.checkForUpdates() }
+ return
+ }
+
+ let state = updateViewModel.state
+ guard state.allowsManualCheck else { return }
+
+ guard !state.isIdle else {
+ updater.checkForUpdates()
+ return
+ }
+
+ // Only terminal result banners reach this path. Acknowledge them
+ // first; Sparkle needs a beat to settle before it accepts a new check.
+ state.cancel()
+ Task { [weak self] in
+ try? await Task.sleep(for: .milliseconds(150))
+ self?.updater.checkForUpdates()
+ }
+ }
+
+ private func startUpdater() {
+ do {
+ try updater.start()
+ started = true
+ } catch {
+ // Start only fails on configuration problems (e.g. a broken feed
+ // URL); surface it in the banner rather than dying silently.
+ driver.viewModel.state = .failed(.init(
+ message: error.localizedDescription,
+ dismiss: { [weak self] in
+ self?.updateViewModel.state = .idle
+ }))
+ }
+ }
+}
+#endif
diff --git a/Sources/CopyCat/Updater/UpdateDriver.swift b/Sources/CopyCat/Updater/UpdateDriver.swift
new file mode 100644
index 0000000..a16dbd2
--- /dev/null
+++ b/Sources/CopyCat/Updater/UpdateDriver.swift
@@ -0,0 +1,254 @@
+#if canImport(Sparkle) && ENABLE_SPARKLE
+import AppKit
+import Foundation
+import Sparkle
+import UserNotifications
+
+/// Custom Sparkle user driver: every callback is folded into an UpdateState
+/// that the menu and Settings UI render inline, replacing Sparkle's own alert
+/// and progress windows entirely.
+@MainActor
+final class UpdateDriver: NSObject, SPUUserDriver {
+ let viewModel: UpdateViewModel
+
+ /// Pending acknowledgement for a not-found or error result. Routed
+ /// through acknowledgePending() so the UI's dismiss action and the
+ /// auto-dismiss timer can't both invoke Sparkle's one-shot block.
+ private var pendingAcknowledgement: (() -> Void)?
+ private var autoDismissTask: Task?
+
+ init(viewModel: UpdateViewModel) {
+ self.viewModel = viewModel
+ }
+
+ // MARK: - SPUUserDriver
+
+ func show(
+ _ request: SPUUpdatePermissionRequest,
+ reply: @escaping (SUUpdatePermissionResponse) -> Void
+ ) {
+ // Not reached in practice: the controller sets
+ // automaticallyChecksForUpdates explicitly at startup, which tells
+ // Sparkle the app manages that preference itself. Answer from the
+ // saved preference just in case.
+ reply(SUUpdatePermissionResponse(
+ automaticUpdateChecks: UpdaterDefaults.savedAutoUpdateEnabled(),
+ sendSystemProfile: false))
+ }
+
+ func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
+ let cancel = OneShotAction(cancellation)
+ viewModel.state = .checking(.init(cancel: { cancel() }))
+ }
+
+ func showUpdateFound(
+ with appcastItem: SUAppcastItem,
+ state: SPUUserUpdateState,
+ reply: @escaping (SPUUserUpdateChoice) -> Void
+ ) {
+ let infoOnly = appcastItem.isInformationOnlyUpdate
+ let infoURL = appcastItem.infoURL
+ let updateChoice = OneShotReply(reply)
+ viewModel.state = .updateAvailable(.init(
+ version: appcastItem.displayVersionString,
+ byteCount: appcastItem.contentLength > 0
+ ? Int64(appcastItem.contentLength) : nil,
+ install: {
+ // Info-only updates must not be installed; the best we can
+ // do is send the user to the release page.
+ if infoOnly {
+ guard updateChoice.send(.dismiss) else { return }
+ if let infoURL { NSWorkspace.shared.open(infoURL) }
+ } else {
+ updateChoice.send(.install)
+ }
+ },
+ dismiss: { updateChoice.send(.dismiss) }))
+
+ // A scheduled background check has no visible UI moment, so surface
+ // discovery with a notification; tapping it opens Settings where the
+ // update controls live. For user-initiated checks the banner is
+ // already visible — just drop any stale one.
+ if state.userInitiated {
+ clearUpdateNotification()
+ } else {
+ postUpdateAvailableNotification(
+ version: appcastItem.displayVersionString)
+ }
+ }
+
+ func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
+ // Release notes aren't rendered in the inline update UI.
+ }
+
+ func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {
+ // See showUpdateReleaseNotes.
+ }
+
+ func showUpdateNotFoundWithError(
+ _ error: any Error,
+ acknowledgement: @escaping () -> Void
+ ) {
+ pendingAcknowledgement = acknowledgement
+ viewModel.state = .notFound(.init(
+ acknowledge: { [weak self] in self?.acknowledgePending() }))
+ // Sparkle only ends the session (and allows the next check) once
+ // acknowledged, and the menu may never be opened — so acknowledge on
+ // a timer, which also auto-dismisses the "up to date" banner.
+ scheduleAutoDismiss(after: .seconds(5))
+ }
+
+ func showUpdaterError(
+ _ error: any Error,
+ acknowledgement: @escaping () -> Void
+ ) {
+ pendingAcknowledgement = acknowledgement
+ viewModel.state = .failed(.init(
+ message: error.localizedDescription,
+ dismiss: { [weak self] in self?.acknowledgePending() }))
+ }
+
+ func showDownloadInitiated(cancellation: @escaping () -> Void) {
+ let cancel = OneShotAction(cancellation)
+ clearUpdateNotification()
+ viewModel.state = .downloading(.init(
+ cancel: { cancel() }, expectedLength: nil, receivedLength: 0))
+ }
+
+ func showDownloadDidReceiveExpectedContentLength(
+ _ expectedContentLength: UInt64
+ ) {
+ guard case .downloading(let downloading) = viewModel.state else { return }
+ viewModel.state = .downloading(.init(
+ cancel: downloading.cancel,
+ expectedLength: expectedContentLength,
+ receivedLength: 0))
+ }
+
+ func showDownloadDidReceiveData(ofLength length: UInt64) {
+ guard case .downloading(let downloading) = viewModel.state else { return }
+ viewModel.state = .downloading(.init(
+ cancel: downloading.cancel,
+ expectedLength: downloading.expectedLength,
+ receivedLength: downloading.receivedLength + length))
+ }
+
+ func showDownloadDidStartExtractingUpdate() {
+ viewModel.state = .extracting(.init(progress: 0))
+ }
+
+ func showExtractionReceivedProgress(_ progress: Double) {
+ viewModel.state = .extracting(.init(progress: progress))
+ }
+
+ func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
+ // The download only ever starts from an explicit Install click
+ // (automatic downloads are disabled), so readiness is consent:
+ // confirm immediately and let install → relaunch chain through
+ // with no further prompts.
+ reply(.install)
+ }
+
+ func showInstallingUpdate(
+ withApplicationTerminated applicationTerminated: Bool,
+ retryTerminatingApplication: @escaping () -> Void
+ ) {
+ viewModel.state = .installing
+ }
+
+ func showUpdateInstalledAndRelaunched(
+ _ relaunched: Bool,
+ acknowledgement: @escaping () -> Void
+ ) {
+ // Not reached when the updater dies with the app (our case), but
+ // Sparkle requires the acknowledgement if it ever is.
+ acknowledgement()
+ viewModel.state = .idle
+ }
+
+ func dismissUpdateInstallation() {
+ autoDismissTask?.cancel()
+ autoDismissTask = nil
+ // Sparkle is tearing the session down; the acknowledgement (if any)
+ // was already consumed on the path that got us here.
+ pendingAcknowledgement = nil
+ clearUpdateNotification()
+ viewModel.state = .idle
+ }
+
+ // MARK: - Acknowledgement plumbing
+
+ private func acknowledgePending() {
+ autoDismissTask?.cancel()
+ autoDismissTask = nil
+ guard let acknowledgement = pendingAcknowledgement else { return }
+ pendingAcknowledgement = nil
+ // Sparkle follows up with dismissUpdateInstallation, which resets
+ // the state to idle.
+ acknowledgement()
+ }
+
+ private func scheduleAutoDismiss(after duration: Duration) {
+ autoDismissTask?.cancel()
+ autoDismissTask = Task { [weak self] in
+ try? await Task.sleep(for: duration)
+ guard !Task.isCancelled else { return }
+ self?.acknowledgePending()
+ }
+ }
+
+ // MARK: - Update-available notification
+
+ private func postUpdateAvailableNotification(version: String) {
+ let content = UNMutableNotificationContent()
+ content.title = "Update Available"
+ content.body = "CopyCat \(version) is available. Click to update."
+ let request = UNNotificationRequest(
+ identifier: UpdateNotification.identifier, content: content,
+ trigger: nil)
+ // If notification permission was denied, this is silently dropped —
+ // the menu and Settings surfaces still cover discovery.
+ UNUserNotificationCenter.current().add(request)
+ }
+
+ private func clearUpdateNotification() {
+ let center = UNUserNotificationCenter.current()
+ center.removeDeliveredNotifications(
+ withIdentifiers: [UpdateNotification.identifier])
+ center.removePendingNotificationRequests(
+ withIdentifiers: [UpdateNotification.identifier])
+ }
+}
+
+@MainActor
+private final class OneShotAction {
+ private var action: (() -> Void)?
+
+ init(_ action: @escaping () -> Void) {
+ self.action = action
+ }
+
+ func callAsFunction() {
+ guard let action else { return }
+ self.action = nil
+ action()
+ }
+}
+
+@MainActor
+private final class OneShotReply {
+ private var reply: ((Value) -> Void)?
+
+ init(_ reply: @escaping (Value) -> Void) {
+ self.reply = reply
+ }
+
+ @discardableResult
+ func send(_ value: Value) -> Bool {
+ guard let reply else { return false }
+ self.reply = nil
+ reply(value)
+ return true
+ }
+}
+#endif
diff --git a/Sources/CopyCat/Updater/UpdateSimulator.swift b/Sources/CopyCat/Updater/UpdateSimulator.swift
new file mode 100644
index 0000000..8d544b1
--- /dev/null
+++ b/Sources/CopyCat/Updater/UpdateSimulator.swift
@@ -0,0 +1,144 @@
+#if DEBUG
+import Foundation
+import UserNotifications
+
+/// Debug-only fake updater that walks the update UI through scripted update
+/// scenarios with realistic pacing — no Sparkle, no signing, no appcast.
+///
+/// Enable, then launch with `just dev`:
+///
+/// defaults write com.copycat.macos.app.dev UpdateSimulatorScenario happy
+///
+/// Disable:
+///
+/// defaults delete com.copycat.macos.app.dev UpdateSimulatorScenario
+@MainActor
+final class UpdateSimulator: UpdaterProviding {
+ enum Scenario: String {
+ /// Check Now → update available → Install → download → prepare →
+ /// install. A real update terminates and relaunches the app at the
+ /// end; the simulator returns to idle instead.
+ case happy
+ /// An update is "found by a scheduled check" a few seconds after
+ /// launch: banner and notification appear without any user action.
+ case background
+ /// Check Now → "You're up to date" (auto-dismisses).
+ case notfound
+ /// Check Now → failure banner with Retry.
+ case error
+ }
+
+ static let defaultsKey = "UpdateSimulatorScenario"
+
+ static func configured() -> UpdateSimulator? {
+ guard let raw = UserDefaults.standard.string(forKey: defaultsKey),
+ let scenario = Scenario(rawValue: raw)
+ else { return nil }
+ return UpdateSimulator(scenario: scenario)
+ }
+
+ let updateViewModel = UpdateViewModel()
+ let isAvailable = true
+ let unavailableReason: String? = nil
+
+ private let scenario: Scenario
+ private var task: Task?
+
+ var automaticallyChecksForUpdates: Bool {
+ get { UpdaterDefaults.savedAutoUpdateEnabled() }
+ set { UpdaterDefaults.setAutoUpdateEnabled(newValue) }
+ }
+
+ init(scenario: Scenario) {
+ self.scenario = scenario
+ guard scenario == .background else { return }
+ run {
+ try await Task.sleep(for: .seconds(3))
+ self.offerUpdate(notify: true)
+ }
+ }
+
+ func checkForUpdates(_ sender: Any?) {
+ run {
+ self.updateViewModel.state = .checking(.init(
+ cancel: { [weak self] in self?.reset() }))
+ try await Task.sleep(for: .seconds(1.2))
+
+ switch self.scenario {
+ case .happy, .background:
+ self.offerUpdate(notify: false)
+
+ case .notfound:
+ self.updateViewModel.state = .notFound(.init(
+ acknowledge: { [weak self] in self?.reset() }))
+ // Mirror the real driver's auto-dismiss.
+ try await Task.sleep(for: .seconds(5))
+ self.updateViewModel.state = .idle
+
+ case .error:
+ self.updateViewModel.state = .failed(.init(
+ message: "The update feed could not be reached (simulated).",
+ dismiss: { [weak self] in self?.reset() }))
+ }
+ }
+ }
+
+ private func offerUpdate(notify: Bool) {
+ updateViewModel.state = .updateAvailable(.init(
+ version: "99.0",
+ byteCount: 12_800_000,
+ install: { [weak self] in self?.install() },
+ dismiss: { [weak self] in self?.reset() }))
+ if notify {
+ let content = UNMutableNotificationContent()
+ content.title = "Update Available"
+ content.body = "CopyCat 99.0 is available. Click to update."
+ UNUserNotificationCenter.current().add(
+ UNNotificationRequest(
+ identifier: UpdateNotification.identifier,
+ content: content, trigger: nil))
+ }
+ }
+
+ private func install() {
+ run {
+ let total: UInt64 = 12_800_000
+ let cancel: () -> Void = { [weak self] in self?.reset() }
+ self.updateViewModel.state = .downloading(.init(
+ cancel: cancel, expectedLength: nil, receivedLength: 0))
+ // Brief indeterminate stretch before the content length arrives,
+ // like a real download.
+ try await Task.sleep(for: .milliseconds(500))
+ var received: UInt64 = 0
+ while received < total {
+ received = min(total, received + 320_000)
+ self.updateViewModel.state = .downloading(.init(
+ cancel: cancel, expectedLength: total,
+ receivedLength: received))
+ try await Task.sleep(for: .milliseconds(100))
+ }
+ for step in 1...15 {
+ self.updateViewModel.state = .extracting(.init(
+ progress: Double(step) / 15))
+ try await Task.sleep(for: .milliseconds(100))
+ }
+ self.updateViewModel.state = .installing
+ try await Task.sleep(for: .seconds(2.5))
+ self.reset()
+ }
+ }
+
+ private func run(_ body: @escaping @MainActor () async throws -> Void) {
+ task?.cancel()
+ task = Task {
+ try? await body()
+ }
+ }
+
+ private func reset() {
+ task?.cancel()
+ task = nil
+ updateViewModel.state = .idle
+ }
+}
+#endif
diff --git a/Sources/CopyCat/Updater/UpdateState.swift b/Sources/CopyCat/Updater/UpdateState.swift
new file mode 100644
index 0000000..0b9647a
--- /dev/null
+++ b/Sources/CopyCat/Updater/UpdateState.swift
@@ -0,0 +1,121 @@
+import Foundation
+import Observation
+
+/// One state of the update pipeline, mirrored from Sparkle's user-driver
+/// callbacks. Cases carry the reply/cancel closures Sparkle hands us, so
+/// the UI can drive the updater without touching Sparkle types — this file
+/// must stay importable in non-Sparkle builds.
+enum UpdateState {
+ case idle
+ case checking(Checking)
+ case updateAvailable(Available)
+ case downloading(Downloading)
+ case extracting(Extracting)
+ case installing
+ case notFound(NotFound)
+ case failed(Failure)
+
+ struct Checking {
+ let cancel: () -> Void
+ }
+
+ struct Available {
+ let version: String
+ let byteCount: Int64?
+ /// Begins the download; the driver then chains through extract,
+ /// install, and relaunch without further prompts.
+ let install: () -> Void
+ /// "Later" — ends the session; the update is offered again on the
+ /// next check.
+ let dismiss: () -> Void
+ }
+
+ struct Downloading {
+ let cancel: () -> Void
+ let expectedLength: UInt64?
+ let receivedLength: UInt64
+
+ /// Nil when Sparkle hasn't reported a content length (or reported
+ /// zero), in which case the UI shows an indeterminate bar.
+ var fraction: Double? {
+ guard let expectedLength, expectedLength > 0 else { return nil }
+ return min(1, Double(receivedLength) / Double(expectedLength))
+ }
+ }
+
+ struct Extracting {
+ let progress: Double
+ }
+
+ struct NotFound {
+ let acknowledge: () -> Void
+ }
+
+ struct Failure {
+ let message: String
+ /// Acknowledges the error to Sparkle and clears the banner.
+ let dismiss: () -> Void
+ }
+}
+
+extension UpdateState {
+ /// Case discriminator for equality checks — the payloads hold closures,
+ /// so the enum itself can't usefully be Equatable.
+ enum Phase: Equatable {
+ case idle, checking, updateAvailable, downloading, extracting,
+ installing, notFound, failed
+ }
+
+ var phase: Phase {
+ switch self {
+ case .idle: .idle
+ case .checking: .checking
+ case .updateAvailable: .updateAvailable
+ case .downloading: .downloading
+ case .extracting: .extracting
+ case .installing: .installing
+ case .notFound: .notFound
+ case .failed: .failed
+ }
+ }
+
+ var isIdle: Bool { phase == .idle }
+
+ /// Manual checks can only start when Sparkle has no active update UI, or
+ /// after a terminal result that can be acknowledged before retrying.
+ var allowsManualCheck: Bool {
+ switch self {
+ case .idle, .notFound, .failed:
+ true
+ case .checking, .updateAvailable, .downloading, .extracting, .installing:
+ false
+ }
+ }
+
+ /// Unwinds whatever is pending so a fresh check can start cleanly.
+ /// Extraction and installation can't be canceled once begun; idle has
+ /// nothing to unwind.
+ func cancel() {
+ switch self {
+ case .idle, .extracting, .installing:
+ break
+ case .checking(let checking):
+ checking.cancel()
+ case .updateAvailable(let available):
+ available.dismiss()
+ case .downloading(let downloading):
+ downloading.cancel()
+ case .notFound(let notFound):
+ notFound.acknowledge()
+ case .failed(let failure):
+ failure.dismiss()
+ }
+ }
+}
+
+/// Observable holder so SwiftUI can react to update-state changes.
+@MainActor
+@Observable
+final class UpdateViewModel {
+ var state: UpdateState = .idle
+}
diff --git a/Sources/CopyCat/Updater/UpdaterDefaults.swift b/Sources/CopyCat/Updater/UpdaterDefaults.swift
new file mode 100644
index 0000000..dfe8632
--- /dev/null
+++ b/Sources/CopyCat/Updater/UpdaterDefaults.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+enum UpdaterDefaults {
+ static let appAutomaticUpdateChecksEnabledKey = "autoUpdateEnabled"
+ static let sparkleEnableAutomaticChecksKey = "SUEnableAutomaticChecks"
+ static let sparkleAutomaticallyUpdateKey = "SUAutomaticallyUpdate"
+
+ static func savedAutoUpdateEnabled(in defaults: UserDefaults = .standard) -> Bool {
+ if defaults.object(forKey: appAutomaticUpdateChecksEnabledKey) != nil {
+ return defaults.bool(forKey: appAutomaticUpdateChecksEnabledKey)
+ }
+ if defaults.object(forKey: sparkleEnableAutomaticChecksKey) != nil {
+ return defaults.bool(forKey: sparkleEnableAutomaticChecksKey)
+ }
+ return true
+ }
+
+ static func setAutoUpdateEnabled(_ enabled: Bool, in defaults: UserDefaults = .standard) {
+ defaults.set(enabled, forKey: appAutomaticUpdateChecksEnabledKey)
+ defaults.set(enabled, forKey: sparkleEnableAutomaticChecksKey)
+ }
+
+ static func disableAutomaticDownloads(in defaults: UserDefaults = .standard) {
+ defaults.set(false, forKey: sparkleAutomaticallyUpdateKey)
+ }
+}
diff --git a/Sources/CopyCat/Updater/UpdaterEnvironment.swift b/Sources/CopyCat/Updater/UpdaterEnvironment.swift
new file mode 100644
index 0000000..02bff11
--- /dev/null
+++ b/Sources/CopyCat/Updater/UpdaterEnvironment.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+private struct UpdaterControllerEnvironmentKey: EnvironmentKey {
+ static let defaultValue: UpdaterProviding? = nil
+}
+
+extension EnvironmentValues {
+ var updaterController: UpdaterProviding? {
+ get { self[UpdaterControllerEnvironmentKey.self] }
+ set { self[UpdaterControllerEnvironmentKey.self] = newValue }
+ }
+}
diff --git a/Sources/CopyCat/Updater/UpdaterFactory.swift b/Sources/CopyCat/Updater/UpdaterFactory.swift
new file mode 100644
index 0000000..0e3f942
--- /dev/null
+++ b/Sources/CopyCat/Updater/UpdaterFactory.swift
@@ -0,0 +1,70 @@
+import Foundation
+import Security
+
+#if canImport(Sparkle) && ENABLE_SPARKLE
+@MainActor
+func makeUpdaterController() -> UpdaterProviding {
+ #if DEBUG
+ if let simulator = UpdateSimulator.configured() {
+ return simulator
+ }
+ #endif
+
+ let bundleURL = Bundle.main.bundleURL
+
+ guard bundleURL.pathExtension == "app" else {
+ return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.")
+ }
+
+ if InstallOrigin.isHomebrewCask(appBundleURL: bundleURL) {
+ return DisabledUpdaterController(
+ unavailableReason: "Updates managed by Homebrew. Run: brew upgrade --cask andyhtran/tap/copycat")
+ }
+
+ guard isDeveloperIDSigned(bundleURL: bundleURL) else {
+ return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.")
+ }
+
+ guard hasUpdateFeed(bundle: .main) else {
+ return DisabledUpdaterController(unavailableReason: "Updates unavailable in this build.")
+ }
+
+ let savedAutoUpdate = UpdaterDefaults.savedAutoUpdateEnabled()
+ return SparkleUpdaterController(savedAutoUpdate: savedAutoUpdate)
+}
+
+private func hasUpdateFeed(bundle: Bundle) -> Bool {
+ guard let feedURL = bundle.object(forInfoDictionaryKey: "SUFeedURL") as? String else {
+ return false
+ }
+ return !feedURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+}
+
+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
+}
+#else
+@MainActor
+func makeUpdaterController() -> UpdaterProviding {
+ #if DEBUG
+ if let simulator = UpdateSimulator.configured() {
+ return simulator
+ }
+ #endif
+
+ return DisabledUpdaterController()
+}
+#endif
diff --git a/Sources/CopyCat/Updater/UpdaterProviding.swift b/Sources/CopyCat/Updater/UpdaterProviding.swift
new file mode 100644
index 0000000..d18715c
--- /dev/null
+++ b/Sources/CopyCat/Updater/UpdaterProviding.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+@MainActor
+protocol UpdaterProviding: AnyObject, Sendable {
+ var automaticallyChecksForUpdates: Bool { get set }
+ var isAvailable: Bool { get }
+ var unavailableReason: String? { get }
+ var updateViewModel: UpdateViewModel { get }
+ func checkForUpdates(_ sender: Any?)
+}
+
+/// Shared between the Sparkle driver (posts) and AppDelegate (handles the
+/// tap), which compile under different flags.
+enum UpdateNotification {
+ static let identifier = "copycat-update-available"
+}
diff --git a/Tests/CopyCatTests/InstallOriginTests.swift b/Tests/CopyCatTests/InstallOriginTests.swift
new file mode 100644
index 0000000..0aea4f5
--- /dev/null
+++ b/Tests/CopyCatTests/InstallOriginTests.swift
@@ -0,0 +1,25 @@
+import Foundation
+import XCTest
+@testable import CopyCat
+
+final class InstallOriginTests: XCTestCase {
+ func testHomebrewCaskroomDetected() {
+ let url = URL(fileURLWithPath: "/opt/homebrew/Caskroom/copycat/1.0/CopyCat.app")
+ XCTAssertTrue(InstallOrigin.isHomebrewCask(appBundleURL: url))
+ }
+
+ func testHomebrewCaskroomAlternatePathDetected() {
+ let url = URL(fileURLWithPath: "/usr/local/Homebrew/Caskroom/copycat/1.0/CopyCat.app")
+ XCTAssertTrue(InstallOrigin.isHomebrewCask(appBundleURL: url))
+ }
+
+ func testApplicationsPathNotDetected() {
+ let url = URL(fileURLWithPath: "/Applications/CopyCat.app")
+ XCTAssertFalse(InstallOrigin.isHomebrewCask(appBundleURL: url))
+ }
+
+ func testUserApplicationsPathNotDetected() {
+ let url = URL(fileURLWithPath: "/Users/someone/Applications/CopyCat.app")
+ XCTAssertFalse(InstallOrigin.isHomebrewCask(appBundleURL: url))
+ }
+}
diff --git a/Tests/CopyCatTests/UpdateStateTests.swift b/Tests/CopyCatTests/UpdateStateTests.swift
new file mode 100644
index 0000000..898085b
--- /dev/null
+++ b/Tests/CopyCatTests/UpdateStateTests.swift
@@ -0,0 +1,110 @@
+import XCTest
+@testable import CopyCat
+
+@MainActor
+final class UpdateStateTests: XCTestCase {
+ func testViewModelDefaultsToIdle() {
+ let model = UpdateViewModel()
+ XCTAssertTrue(model.state.isIdle)
+ XCTAssertEqual(model.state.phase, .idle)
+ }
+
+ func testPhaseMatchesCase() {
+ XCTAssertEqual(UpdateState.idle.phase, .idle)
+ XCTAssertEqual(UpdateState.checking(.init(cancel: {})).phase, .checking)
+ XCTAssertEqual(
+ UpdateState.updateAvailable(
+ .init(version: "1.0", byteCount: nil, install: {}, dismiss: {})
+ ).phase,
+ .updateAvailable)
+ XCTAssertEqual(
+ UpdateState.downloading(
+ .init(cancel: {}, expectedLength: nil, receivedLength: 0)
+ ).phase,
+ .downloading)
+ XCTAssertEqual(UpdateState.extracting(.init(progress: 0)).phase, .extracting)
+ XCTAssertEqual(UpdateState.installing.phase, .installing)
+ XCTAssertEqual(UpdateState.notFound(.init(acknowledge: {})).phase, .notFound)
+ XCTAssertEqual(
+ UpdateState.failed(.init(message: "boom", dismiss: {})).phase,
+ .failed)
+ }
+
+ func testManualChecksOnlyStartFromIdleOrTerminalStates() {
+ XCTAssertTrue(UpdateState.idle.allowsManualCheck)
+ XCTAssertFalse(UpdateState.checking(.init(cancel: {})).allowsManualCheck)
+ XCTAssertFalse(
+ UpdateState.updateAvailable(
+ .init(version: "1.0", byteCount: nil, install: {}, dismiss: {})
+ ).allowsManualCheck)
+ XCTAssertFalse(
+ UpdateState.downloading(
+ .init(cancel: {}, expectedLength: nil, receivedLength: 0)
+ ).allowsManualCheck)
+ XCTAssertFalse(UpdateState.extracting(.init(progress: 0)).allowsManualCheck)
+ XCTAssertFalse(UpdateState.installing.allowsManualCheck)
+ XCTAssertTrue(UpdateState.notFound(.init(acknowledge: {})).allowsManualCheck)
+ XCTAssertTrue(UpdateState.failed(.init(message: "boom", dismiss: {})).allowsManualCheck)
+ }
+
+ func testCancelInvokesCheckingCancellation() {
+ var canceled = false
+ UpdateState.checking(.init(cancel: { canceled = true })).cancel()
+ XCTAssertTrue(canceled)
+ }
+
+ func testCancelDismissesAvailableUpdate() {
+ var installed = false
+ var dismissed = false
+ UpdateState.updateAvailable(.init(
+ version: "1.0", byteCount: nil,
+ install: { installed = true },
+ dismiss: { dismissed = true }
+ )).cancel()
+ XCTAssertTrue(dismissed)
+ XCTAssertFalse(installed)
+ }
+
+ func testCancelStopsDownload() {
+ var canceled = false
+ UpdateState.downloading(.init(
+ cancel: { canceled = true }, expectedLength: 100, receivedLength: 10
+ )).cancel()
+ XCTAssertTrue(canceled)
+ }
+
+ func testCancelAcknowledgesNotFound() {
+ var acknowledged = false
+ UpdateState.notFound(.init(acknowledge: { acknowledged = true })).cancel()
+ XCTAssertTrue(acknowledged)
+ }
+
+ func testCancelDismissesFailure() {
+ var dismissed = false
+ UpdateState.failed(.init(message: "boom", dismiss: { dismissed = true }))
+ .cancel()
+ XCTAssertTrue(dismissed)
+ }
+
+ func testDownloadFractionRequiresExpectedLength() {
+ let unknown = UpdateState.Downloading(
+ cancel: {}, expectedLength: nil, receivedLength: 500)
+ XCTAssertNil(unknown.fraction)
+
+ let zero = UpdateState.Downloading(
+ cancel: {}, expectedLength: 0, receivedLength: 500)
+ XCTAssertNil(zero.fraction)
+ }
+
+ func testDownloadFractionIsRatioCappedAtOne() throws {
+ let half = UpdateState.Downloading(
+ cancel: {}, expectedLength: 200, receivedLength: 100)
+ XCTAssertEqual(try XCTUnwrap(half.fraction), 0.5)
+
+ // Sparkle documents that the expected length can undershoot the
+ // actual download size.
+ let over = UpdateState.Downloading(
+ cancel: {}, expectedLength: 200, receivedLength: 300)
+ XCTAssertEqual(try XCTUnwrap(over.fraction), 1.0)
+ }
+}
diff --git a/Tests/CopyCatTests/UpdaterFactoryTests.swift b/Tests/CopyCatTests/UpdaterFactoryTests.swift
new file mode 100644
index 0000000..c1212d5
--- /dev/null
+++ b/Tests/CopyCatTests/UpdaterFactoryTests.swift
@@ -0,0 +1,71 @@
+import SwiftUI
+import XCTest
+@testable import CopyCat
+
+@MainActor
+final class UpdaterFactoryTests: XCTestCase {
+ func testDisabledUpdaterReportsUnavailable() {
+ let updater = DisabledUpdaterController(unavailableReason: "test reason")
+ XCTAssertFalse(updater.isAvailable)
+ XCTAssertEqual(updater.unavailableReason, "test reason")
+ XCTAssertTrue(updater.updateViewModel.state.isIdle)
+ }
+
+ func testDisabledUpdaterCheckIsNoop() {
+ let updater = DisabledUpdaterController()
+ updater.checkForUpdates(nil)
+ XCTAssertTrue(updater.updateViewModel.state.isIdle)
+ }
+
+ func testUpdaterEnvironmentStoresInjectedController() throws {
+ let updater = DisabledUpdaterController()
+ var values = EnvironmentValues()
+ values.updaterController = updater
+
+ let stored = try XCTUnwrap(values.updaterController)
+ XCTAssertTrue(stored === updater)
+ }
+
+ func testAutoUpdateDefaultsToEnabledWhenNoPreferenceExists() throws {
+ try withIsolatedDefaults { defaults in
+ XCTAssertTrue(UpdaterDefaults.savedAutoUpdateEnabled(in: defaults))
+ }
+ }
+
+ func testAutoUpdatePreferenceUsesSparkleKeyWhenLegacyKeyIsMissing() throws {
+ try withIsolatedDefaults { defaults in
+ defaults.set(false, forKey: UpdaterDefaults.sparkleEnableAutomaticChecksKey)
+
+ XCTAssertFalse(UpdaterDefaults.savedAutoUpdateEnabled(in: defaults))
+ }
+ }
+
+ func testAutoUpdatePreferenceWritesLegacyAndSparkleKeys() throws {
+ try withIsolatedDefaults { defaults in
+ UpdaterDefaults.setAutoUpdateEnabled(false, in: defaults)
+
+ XCTAssertFalse(defaults.bool(forKey: UpdaterDefaults.appAutomaticUpdateChecksEnabledKey))
+ XCTAssertFalse(defaults.bool(forKey: UpdaterDefaults.sparkleEnableAutomaticChecksKey))
+ }
+ }
+
+ func testAutomaticDownloadsMigrationPreservesChecksAndDisablesDownloads() throws {
+ try withIsolatedDefaults { defaults in
+ UpdaterDefaults.setAutoUpdateEnabled(true, in: defaults)
+ defaults.set(true, forKey: UpdaterDefaults.sparkleAutomaticallyUpdateKey)
+
+ UpdaterDefaults.disableAutomaticDownloads(in: defaults)
+
+ XCTAssertTrue(UpdaterDefaults.savedAutoUpdateEnabled(in: defaults))
+ XCTAssertFalse(defaults.bool(forKey: UpdaterDefaults.sparkleAutomaticallyUpdateKey))
+ }
+ }
+
+ private func withIsolatedDefaults(_ body: (UserDefaults) throws -> Void) throws {
+ let suiteName = "CopyCatTests.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ try body(defaults)
+ }
+}
diff --git a/appcast.xml b/appcast.xml
new file mode 100644
index 0000000..3cb7f03
--- /dev/null
+++ b/appcast.xml
@@ -0,0 +1,8 @@
+
+
+
+ CopyCat
+ https://raw.githubusercontent.com/andyhtran/CopyCat/main/appcast.xml
+ CopyCat updates
+
+
diff --git a/justfile b/justfile
index 99cc8bf..6518c82 100644
--- a/justfile
+++ b/justfile
@@ -75,7 +75,31 @@ github-release: sign-and-notarize create-dmg
update-tap:
bash Scripts/update-tap.sh
-# Full release: sign + notarize, GitHub release, update tap.
+# Full release: sign + notarize, GitHub release, update tap, update appcast.
[group('release')]
publish: github-release update-tap
- @echo "Release complete!"
+ #!/usr/bin/env bash
+ set -euo pipefail
+ source version.env
+ just generate-appcast "CopyCat-${MARKETING_VERSION}.zip"
+ git add appcast.xml
+ git commit -m "Update appcast for v${MARKETING_VERSION}"
+ git push origin main
+ echo "Release complete!"
+
+[group('sparkle')]
+generate-appcast zip:
+ ./Scripts/make-appcast.sh {{zip}}
+
+[group('sparkle')]
+generate-appcast-beta zip:
+ SPARKLE_CHANNEL=beta ./Scripts/make-appcast.sh {{zip}}
+
+[group('sparkle')]
+verify-appcast version="":
+ ./Scripts/verify-appcast.sh {{version}}
+
+# Local E2E test of the update flow: real Sparkle against a localhost appcast.
+[group('sparkle')]
+test-update:
+ bash Scripts/test-update-flow.sh
diff --git a/version.env b/version.env
index e88fddd..b75040d 100644
--- a/version.env
+++ b/version.env
@@ -1 +1,2 @@
MARKETING_VERSION=0.3.3
+SU_PUBLIC_ED_KEY=0q8CRemlwTVWxExC4B5sGdL+azOeFC/yKxHzaAMus0E=