From 395f297d841f851fc9fdeca366c564e94299e375 Mon Sep 17 00:00:00 2001 From: andyhtran <76441965+andyhtran@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:26:59 -0400 Subject: [PATCH 1/2] Detect starved event tap via WindowServer queue latency tapIsEnabled reports true even when the mach port is registered but events are no longer being serviced. Query CGGetEventTapList for per-tap latency and rebuild when it exceeds 5s during a silent window. Co-Authored-By: Claude Opus 4.6 --- Sources/CopyCat/PasteHandler.swift | 52 ++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 7 deletions(-) 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 } From 8a0c11a820ae81ebea36f7d3d58d5971161120e3 Mon Sep 17 00:00:00 2001 From: andyhtran <76441965+andyhtran@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:11:40 -0400 Subject: [PATCH 2/2] Add Sparkle auto-update integration Wire up Sparkle for in-app updates: updater subsystem with custom SPUUserDriver (no Sparkle windows), inline update state in the menu bar and Settings, notification on background discovery, Homebrew Cask detection, build-time framework embedding and re-signing, appcast generation/verification scripts, and local E2E update-flow testing. Co-Authored-By: Claude Opus 4.6 --- Package.swift | 7 + Scripts/build-app.sh | 61 ++++- Scripts/make-appcast.sh | 48 ++++ Scripts/test-update-flow.sh | 127 +++++++++ Scripts/verify-appcast.sh | 63 +++++ Sources/CopyCat/AppVersionInfo.swift | 43 +++ Sources/CopyCat/CopyCatApp.swift | 100 ++++++- Sources/CopyCat/SettingsView.swift | 106 +++++++- Sources/CopyCat/SettingsWindow.swift | 12 +- .../Updater/DisabledUpdaterController.swift | 18 ++ Sources/CopyCat/Updater/InstallOrigin.swift | 9 + .../Updater/SparkleUpdaterController.swift | 82 ++++++ Sources/CopyCat/Updater/UpdateDriver.swift | 254 ++++++++++++++++++ Sources/CopyCat/Updater/UpdateSimulator.swift | 144 ++++++++++ Sources/CopyCat/Updater/UpdateState.swift | 121 +++++++++ Sources/CopyCat/Updater/UpdaterDefaults.swift | 26 ++ .../CopyCat/Updater/UpdaterEnvironment.swift | 12 + Sources/CopyCat/Updater/UpdaterFactory.swift | 70 +++++ .../CopyCat/Updater/UpdaterProviding.swift | 16 ++ Tests/CopyCatTests/InstallOriginTests.swift | 25 ++ Tests/CopyCatTests/UpdateStateTests.swift | 110 ++++++++ Tests/CopyCatTests/UpdaterFactoryTests.swift | 71 +++++ appcast.xml | 8 + justfile | 28 +- version.env | 1 + 25 files changed, 1549 insertions(+), 13 deletions(-) create mode 100755 Scripts/make-appcast.sh create mode 100755 Scripts/test-update-flow.sh create mode 100755 Scripts/verify-appcast.sh create mode 100644 Sources/CopyCat/AppVersionInfo.swift create mode 100644 Sources/CopyCat/Updater/DisabledUpdaterController.swift create mode 100644 Sources/CopyCat/Updater/InstallOrigin.swift create mode 100644 Sources/CopyCat/Updater/SparkleUpdaterController.swift create mode 100644 Sources/CopyCat/Updater/UpdateDriver.swift create mode 100644 Sources/CopyCat/Updater/UpdateSimulator.swift create mode 100644 Sources/CopyCat/Updater/UpdateState.swift create mode 100644 Sources/CopyCat/Updater/UpdaterDefaults.swift create mode 100644 Sources/CopyCat/Updater/UpdaterEnvironment.swift create mode 100644 Sources/CopyCat/Updater/UpdaterFactory.swift create mode 100644 Sources/CopyCat/Updater/UpdaterProviding.swift create mode 100644 Tests/CopyCatTests/InstallOriginTests.swift create mode 100644 Tests/CopyCatTests/UpdateStateTests.swift create mode 100644 Tests/CopyCatTests/UpdaterFactoryTests.swift create mode 100644 appcast.xml 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/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=