From 9fc75beb1ea51a131d6472b450733926810c2a57 Mon Sep 17 00:00:00 2001 From: andyhtran <76441965+andyhtran@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:56:57 -0400 Subject: [PATCH] Replace Sparkle standard driver with custom user driver for inline update UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sparkle's built-in alert windows don't work well for this LSUIElement app — they appear unfocused, behind other windows, or on the wrong Space. This replaces the standard driver with a custom SPUUserDriver (UpdateDriver) that folds every Sparkle callback into an observable UpdateState enum, rendered inline as a menu-bar banner with full pipeline visibility (checking → available → downloading → preparing → installing). Adds a debug-only UpdateSimulator for exercising the UI without signing/appcast, and a local E2E test script (just test-update) for the real Sparkle flow. Co-Authored-By: Claude Opus 4.6 --- .envrc.example | 7 +- Scripts/build-app.sh | 10 + Scripts/sign-dev-app.sh | 100 +++++++ Scripts/test-update-flow.sh | 115 ++++++++ Sources/MiniWhisper/AppDelegate.swift | 6 +- .../Updater/DisabledUpdaterController.swift | 2 +- .../Updater/SparkleUpdaterController.swift | 182 +++--------- .../MiniWhisper/Updater/UpdateDriver.swift | 258 ++++++++++++++++++ .../MiniWhisper/Updater/UpdateSimulator.swift | 144 ++++++++++ Sources/MiniWhisper/Updater/UpdateState.swift | 121 ++++++++ .../MiniWhisper/Updater/UpdaterFactory.swift | 25 +- .../Updater/UpdaterProviding.swift | 23 +- Sources/MiniWhisper/Views/MenuBarView.swift | 60 +--- .../Views/Popovers/SettingsPopoverView.swift | 33 ++- .../Views/SettingsWindowView.swift | 72 ++++- Sources/MiniWhisper/Views/UpdateBanner.swift | 235 ++++++++++++++++ Tests/MiniWhisperTests/UpdateStateTests.swift | 110 ++++++++ .../UpdaterFactoryTests.swift | 26 +- justfile | 18 +- 19 files changed, 1281 insertions(+), 266 deletions(-) create mode 100755 Scripts/sign-dev-app.sh create mode 100755 Scripts/test-update-flow.sh create mode 100644 Sources/MiniWhisper/Updater/UpdateDriver.swift create mode 100644 Sources/MiniWhisper/Updater/UpdateSimulator.swift create mode 100644 Sources/MiniWhisper/Updater/UpdateState.swift create mode 100644 Sources/MiniWhisper/Views/UpdateBanner.swift create mode 100644 Tests/MiniWhisperTests/UpdateStateTests.swift diff --git a/.envrc.example b/.envrc.example index 3b04b0f..69696f2 100644 --- a/.envrc.example +++ b/.envrc.example @@ -1,9 +1,10 @@ # Copy to .envrc and fill in your values # If using direnv, run: direnv allow -# Code signing (for local dev builds via `just run`) -export CODESIGN_IDENTITY="-" # ad-hoc signing, or "Developer ID Application: Your Name (TEAMID)" -export CODESIGN_TEAM_ID="" # your Apple team ID, leave empty for ad-hoc +# Code signing. Local dev auto-detects a Developer ID Application identity when available +# so `just dev` and `just test-update` share the same Accessibility permission scope. +export CODESIGN_IDENTITY="" # optional explicit "Developer ID Application: Example (TEAMID)" +export CODESIGN_TEAM_ID="" # your Apple team ID, leave empty for local-only builds # Release only (for `just publish`) export TAP_DIR="" # path to your local homebrew-tap checkout diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index c5e832b..6d0142d 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -24,6 +24,16 @@ else AUTO_CHECKS=true fi +if [[ -n "${SPARKLE_FEED_URL_OVERRIDE:-}" ]]; then + if [[ "$BUILD_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 + # (see Scripts/test-update-flow.sh). + FEED_URL="$SPARKLE_FEED_URL_OVERRIDE" +fi + echo "Building $APP_NAME ($BUILD_CONFIG)..." swift build -c "$BUILD_CONFIG" --product "$APP_NAME" diff --git a/Scripts/sign-dev-app.sh b/Scripts/sign-dev-app.sh new file mode 100755 index 0000000..d2695c8 --- /dev/null +++ b/Scripts/sign-dev-app.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) + +REQUIRE_DEVELOPER_ID=false +if [[ "${1:-}" == "--require-developer-id" ]]; then + REQUIRE_DEVELOPER_ID=true + shift +fi + +APP=${1:?"Usage: $0 [--require-developer-id] "} +ENTITLEMENTS="$ROOT/build/MiniWhisper.entitlements" + +if [[ ! -d "$APP" ]]; then + echo "App bundle not found: $APP" >&2 + exit 1 +fi + +if [[ ! -f "$ENTITLEMENTS" ]]; then + echo "Entitlements not found: $ENTITLEMENTS" >&2 + exit 1 +fi + +find_developer_id_identity() { + security find-identity -v -p codesigning \ + | awk -F'"' '/Developer ID Application/ {print $2; exit}' +} + +choose_identity() { + if [[ -n "${MINIWHISPER_DEV_CODESIGN_IDENTITY:-}" && "${MINIWHISPER_DEV_CODESIGN_IDENTITY}" != "-" ]]; then + printf '%s\n' "$MINIWHISPER_DEV_CODESIGN_IDENTITY" + return + fi + + if [[ -n "${CODESIGN_IDENTITY:-}" && "${CODESIGN_IDENTITY}" != "-" ]]; then + printf '%s\n' "$CODESIGN_IDENTITY" + return + fi + + local developer_id + developer_id=$(find_developer_id_identity) + if [[ -n "$developer_id" ]]; then + printf '%s\n' "$developer_id" + return + fi + + if [[ -n "${DEV_CODESIGN_IDENTITY:-}" && "${DEV_CODESIGN_IDENTITY}" != "-" ]]; then + printf '%s\n' "$DEV_CODESIGN_IDENTITY" + return + fi + + printf '%s\n' "-" +} + +IDENTITY=$(choose_identity) +if [[ "$REQUIRE_DEVELOPER_ID" == true && "$IDENTITY" == "-" ]]; then + echo "No Developer ID Application identity found; Sparkle stays disabled without it." >&2 + exit 1 +fi + +echo "==> Signing ${APP} with: ${IDENTITY}" + +sign_if_present() { + local item="$1" + [[ -e "$item" ]] || return 0 + codesign --force --sign "$IDENTITY" "$item" +} + +SPARKLE="$APP/Contents/Frameworks/Sparkle.framework" +if [[ -d "$SPARKLE" ]]; then + for item in \ + "$SPARKLE/Versions/B/Sparkle" \ + "$SPARKLE/Versions/B/Autoupdate" \ + "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" \ + "$SPARKLE/Versions/B/Updater.app" \ + "$SPARKLE/Versions/B/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" \ + "$SPARKLE/Versions/B/XPCServices/Downloader.xpc" \ + "$SPARKLE/Versions/B/XPCServices/Installer.xpc/Contents/MacOS/Installer" \ + "$SPARKLE/Versions/B/XPCServices/Installer.xpc" \ + "$SPARKLE/Versions/B" \ + "$SPARKLE"; do + sign_if_present "$item" + done +fi + +sign_if_present "$APP/Contents/Frameworks/whisper.framework" +sign_if_present "$APP/Contents/Resources/miniwhispercli" + +codesign --force --sign "$IDENTITY" \ + --entitlements "$ENTITLEMENTS" \ + "$APP" + +if [[ "$REQUIRE_DEVELOPER_ID" == true ]]; then + signature_info=$(codesign -dvv "$APP" 2>&1) + if ! grep -q '^Authority=Developer ID Application:' <<<"$signature_info"; then + echo "Expected a Developer ID Application signature, but ${APP} was signed differently." >&2 + exit 1 + fi +fi diff --git a/Scripts/test-update-flow.sh b/Scripts/test-update-flow.sh new file mode 100755 index 0000000..14d1fb4 --- /dev/null +++ b/Scripts/test-update-flow.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# +# Local end-to-end test of the Sparkle update flow with the real updater: +# +# 1. Builds the current version as "MiniWhisper 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: open the menu popover, Check Now (footer → Settings, or the +# Settings window), and watch available → 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/MiniWhisper Dev.app" +DEV_EXEC="${INSTALL_PATH}/Contents/MacOS/MiniWhisper" + +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/mw-update-test.XXXXXX) +VERSION_BACKUP=$(mktemp /tmp/mw-version-env.XXXXXX) +cp version.env "$VERSION_BACKUP" + +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 "com.miniwhisper.dev" 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) +} + +echo "==> Building current version (${MARKETING_VERSION}, build ${BUILD_NUMBER})..." +SPARKLE_FEED_URL_OVERRIDE="$FEED" bash Scripts/build-app.sh debug +bash Scripts/sign-dev-app.sh --require-developer-id "build/MiniWhisper.app" + +echo "==> Installing to ${INSTALL_PATH}..." +quit_dev_app +rm -rf "$INSTALL_PATH" +cp -R "build/MiniWhisper.app" "$INSTALL_PATH" + +NEW_MARKETING="${MARKETING_VERSION%.*}.$((${MARKETING_VERSION##*.} + 1))" +NEW_BUILD=$((BUILD_NUMBER + 1)) +echo "==> Building update (${NEW_MARKETING}, build ${NEW_BUILD})..." +sed -i '' \ + -e "s/^MARKETING_VERSION=.*/MARKETING_VERSION=${NEW_MARKETING}/" \ + -e "s/^BUILD_NUMBER=.*/BUILD_NUMBER=${NEW_BUILD}/" \ + version.env +SPARKLE_FEED_URL_OVERRIDE="$FEED" bash Scripts/build-app.sh debug +cp "$VERSION_BACKUP" version.env +bash Scripts/sign-dev-app.sh --require-developer-id "build/MiniWhisper.app" + +echo "==> Generating signed appcast..." +/usr/bin/ditto -c -k --keepParent "build/MiniWhisper.app" \ + "$SERVE_DIR/MiniWhisper-${NEW_MARKETING}.zip" +rm -rf "build/MiniWhisper.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 com.miniwhisper.dev "UpdateSimulatorScenario" 2>/dev/null || true + +open "$INSTALL_PATH" + +cat < Bool - { - // immediateFocus is true only right after updater start, when Sparkle - // can show the alert in front — let it. Otherwise we take over. - immediateFocus - } - - nonisolated func standardUserDriverWillHandleShowingUpdate( - _ handleShowingUpdate: Bool, - forUpdate update: SUAppcastItem, - state: SPUUserUpdateState) - { - guard !handleShowingUpdate else { return } - let version = update.displayVersionString - Task { @MainActor in - self.updateStatus.updateAvailable = true - self.updateStatus.availableVersion = version - self.postUpdateAvailableNotification(version: version) + guard !state.isIdle else { + updater.checkForUpdates() + return } - } - - nonisolated func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { - Task { @MainActor in - self.clearGentleReminders() - } - } - - nonisolated func standardUserDriverWillFinishUpdateSession() { - Task { @MainActor in - self.clearGentleReminders() - } - } - - private func postUpdateAvailableNotification(version: String) { - let content = UNMutableNotificationContent() - content.title = "Update Available" - content.body = "MiniWhisper \(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 banner still covers discovery. - UNUserNotificationCenter.current().add(request) - } - - private func clearGentleReminders() { - updateStatus.updateAvailable = false - updateStatus.availableVersion = nil - let center = UNUserNotificationCenter.current() - center.removeDeliveredNotifications(withIdentifiers: [UpdateNotification.identifier]) - center.removePendingNotificationRequests(withIdentifiers: [UpdateNotification.identifier]) - } - - // MARK: - SPUUpdaterDelegate - - nonisolated func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - let version = item.displayVersionString - Task { @MainActor in - self.updateStatus.isUpdateReady = true - self.updateStatus.availableVersion = version - } - } - - nonisolated func updater(_ updater: SPUUpdater, failedToDownloadUpdate item: SUAppcastItem, error: Error) { - Task { @MainActor in - self.updateStatus.isUpdateReady = false - } - } - nonisolated func userDidCancelDownload(_ updater: SPUUpdater) { - Task { @MainActor in - self.updateStatus.isUpdateReady = false + // 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() } } - nonisolated func updater( - _ updater: SPUUpdater, - userDidMake choice: SPUUserUpdateChoice, - forUpdate updateItem: SUAppcastItem, - state: SPUUserUpdateState) - { - let downloaded = state.stage == .downloaded - Task { @MainActor in - switch choice { - case .install, .skip: - self.updateStatus.isUpdateReady = false - case .dismiss: - self.updateStatus.isUpdateReady = downloaded - @unknown default: - self.updateStatus.isUpdateReady = false - } + 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 + })) } } } diff --git a/Sources/MiniWhisper/Updater/UpdateDriver.swift b/Sources/MiniWhisper/Updater/UpdateDriver.swift new file mode 100644 index 0000000..6fd080c --- /dev/null +++ b/Sources/MiniWhisper/Updater/UpdateDriver.swift @@ -0,0 +1,258 @@ +#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-bar UI renders inline, replacing Sparkle's own alert and +/// progress windows entirely. No windows means no focus juggling for this +/// LSUIElement app — the class of bugs the old standard-driver setup needed +/// activate()/orderFrontRegardless() workarounds for. +@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 the popover where + // the banner lives (handled in AppDelegate). For user-initiated + // checks the banner is already on screen — 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 banner UI, and the appcast + // doesn't link any, so this never fires. + } + + 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 banner may never be seen if the popover is + // closed — 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 = "MiniWhisper \(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 banner still covers 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/MiniWhisper/Updater/UpdateSimulator.swift b/Sources/MiniWhisper/Updater/UpdateSimulator.swift new file mode 100644 index 0000000..3471945 --- /dev/null +++ b/Sources/MiniWhisper/Updater/UpdateSimulator.swift @@ -0,0 +1,144 @@ +#if DEBUG +import Foundation +import UserNotifications + +/// Debug-only fake updater that walks the banner UI through scripted update +/// scenarios with realistic pacing — no Sparkle, no signing, no appcast. +/// +/// Enable, then launch with `just dev`: +/// +/// defaults write com.miniwhisper.dev UpdateSimulatorScenario happy +/// +/// Disable: +/// +/// defaults delete com.miniwhisper.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 = "MiniWhisper 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/MiniWhisper/Updater/UpdateState.swift b/Sources/MiniWhisper/Updater/UpdateState.swift new file mode 100644 index 0000000..7f4410b --- /dev/null +++ b/Sources/MiniWhisper/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 (install, cancel, dismiss) 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/MiniWhisper/Updater/UpdaterFactory.swift b/Sources/MiniWhisper/Updater/UpdaterFactory.swift index f2735ef..e7a8edd 100644 --- a/Sources/MiniWhisper/Updater/UpdaterFactory.swift +++ b/Sources/MiniWhisper/Updater/UpdaterFactory.swift @@ -4,6 +4,12 @@ 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 { @@ -14,10 +20,21 @@ func makeUpdaterController() -> UpdaterProviding { 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, @@ -37,6 +54,12 @@ private func isDeveloperIDSigned(bundleURL: URL) -> Bool { #else @MainActor func makeUpdaterController() -> UpdaterProviding { - DisabledUpdaterController() + #if DEBUG + if let simulator = UpdateSimulator.configured() { + return simulator + } + #endif + + return DisabledUpdaterController() } #endif diff --git a/Sources/MiniWhisper/Updater/UpdaterProviding.swift b/Sources/MiniWhisper/Updater/UpdaterProviding.swift index ccc7836..f047d87 100644 --- a/Sources/MiniWhisper/Updater/UpdaterProviding.swift +++ b/Sources/MiniWhisper/Updater/UpdaterProviding.swift @@ -5,30 +5,11 @@ protocol UpdaterProviding: AnyObject, Sendable { var automaticallyChecksForUpdates: Bool { get set } var isAvailable: Bool { get } var unavailableReason: String? { get } - var updateStatus: UpdateStatus { get } + var updateViewModel: UpdateViewModel { get } func checkForUpdates(_ sender: Any?) } -@MainActor -@Observable -final class UpdateStatus { - static let disabled = UpdateStatus() - /// An update has been downloaded and is ready to install. - var isUpdateReady: Bool - /// A scheduled check found an update but the alert was intentionally not - /// shown (gentle reminders); the menu banner points the user at it. - var updateAvailable: Bool = false - var availableVersion: String? - - /// Drives the menu banner. - var needsUserAttention: Bool { updateAvailable || isUpdateReady } - - init(isUpdateReady: Bool = false) { - self.isUpdateReady = isUpdateReady - } -} - -/// Shared between the Sparkle controller (posts) and AppDelegate (handles the +/// Shared between the Sparkle driver (posts) and AppDelegate (handles the /// tap), which compile under different flags. enum UpdateNotification { static let identifier = "sparkle-update-available" diff --git a/Sources/MiniWhisper/Views/MenuBarView.swift b/Sources/MiniWhisper/Views/MenuBarView.swift index d77ade4..c585b88 100644 --- a/Sources/MiniWhisper/Views/MenuBarView.swift +++ b/Sources/MiniWhisper/Views/MenuBarView.swift @@ -19,15 +19,10 @@ struct MenuBarView: View { .padding(.top, 14) } - if let updaterController, updaterController.updateStatus.needsUserAttention { - UpdateAvailableBanner( - version: updaterController.updateStatus.availableVersion, - isReady: updaterController.updateStatus.isUpdateReady - ) { - updaterController.checkForUpdates(nil) - } - .padding(.horizontal, 16) - .padding(.top, 14) + if let updaterController, !updaterController.updateViewModel.state.isIdle { + UpdateBanner(model: updaterController.updateViewModel) + .padding(.horizontal, 16) + .padding(.top, 14) } if appState.showMenuBarVisibilityHint { @@ -636,53 +631,6 @@ private struct PermissionRow: View { } } -// MARK: - Update Banner - -private struct UpdateAvailableBanner: View { - let version: String? - let isReady: Bool - let action: () -> Void - @State private var isHovering = false - - var body: some View { - Button(action: action) { - HStack(spacing: 8) { - Image(systemName: "arrow.down.circle.fill") - .font(.system(size: 12)) - .foregroundColor(.blue) - .frame(width: 20) - - VStack(alignment: .leading, spacing: 1) { - Text(isReady ? "Update Ready" : "Update Available") - .font(.system(size: 13, weight: .medium)) - Text(version.map { "MiniWhisper \($0)" } ?? "A new version is available") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - - Spacer() - - Text(isReady ? "Install" : "View") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.blue) - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(isHovering ? Color.blue.opacity(0.08) : Color.blue.opacity(0.04)) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .onHover { hovering in - withAnimation(.easeInOut(duration: 0.12)) { - isHovering = hovering - } - } - } -} - // MARK: - Footer Bar private struct FooterBarView: View { diff --git a/Sources/MiniWhisper/Views/Popovers/SettingsPopoverView.swift b/Sources/MiniWhisper/Views/Popovers/SettingsPopoverView.swift index bb3b7a8..161d94e 100644 --- a/Sources/MiniWhisper/Views/Popovers/SettingsPopoverView.swift +++ b/Sources/MiniWhisper/Views/Popovers/SettingsPopoverView.swift @@ -63,10 +63,13 @@ struct SettingsPopoverView: View { .buttonStyle(.plain) SettingsPopoverActionRow( - title: "Check for Updates", + title: updateCheckTitle, icon: "arrow.clockwise", - disabled: updaterController?.isAvailable != true + disabled: updateCheckDisabled ) { + guard updaterController?.updateViewModel.state.allowsManualCheck == true else { + return + } updaterController?.checkForUpdates(nil) } @@ -81,6 +84,32 @@ struct SettingsPopoverView: View { } } + private var updateCheckTitle: String { + switch updaterController?.updateViewModel.state ?? .idle { + case .idle, .notFound: + "Check for Updates" + case .checking: + "Checking for Updates..." + case .updateAvailable: + "Update Available" + case .downloading: + "Downloading Update..." + case .extracting: + "Preparing Update..." + case .installing: + "Installing Update..." + case .failed: + "Retry Update Check" + } + } + + private var updateCheckDisabled: Bool { + guard let updaterController, updaterController.isAvailable else { + return true + } + return !updaterController.updateViewModel.state.allowsManualCheck + } + private var errorNotificationsRow: some View { HStack { InfoLabel( diff --git a/Sources/MiniWhisper/Views/SettingsWindowView.swift b/Sources/MiniWhisper/Views/SettingsWindowView.swift index be7c7db..a99e0e2 100644 --- a/Sources/MiniWhisper/Views/SettingsWindowView.swift +++ b/Sources/MiniWhisper/Views/SettingsWindowView.swift @@ -211,10 +211,7 @@ private struct GeneralSettingsPage: View { ) LabeledContent("Check for updates") { - Button("Check Now") { - updaterController?.checkForUpdates(nil) - } - .disabled(updaterController?.isAvailable != true) + updateCheckContent } } @@ -296,6 +293,73 @@ private struct GeneralSettingsPage: View { editModeBehavior = EditModeSettings.behavior } } + + /// Mirrors the live update state next to the Check Now button, since the + /// menu popover (where the full banner lives) is 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… MiniWhisper 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 + } } private struct ShortcutSettingsPage: View { diff --git a/Sources/MiniWhisper/Views/UpdateBanner.swift b/Sources/MiniWhisper/Views/UpdateBanner.swift new file mode 100644 index 0000000..d785ae8 --- /dev/null +++ b/Sources/MiniWhisper/Views/UpdateBanner.swift @@ -0,0 +1,235 @@ +import SwiftUI + +/// Inline banner in the menu popover that renders the whole update pipeline +/// — available → downloading → preparing → installing — plus check results +/// and errors. This is the app's only update UI; Sparkle's own windows are +/// suppressed by the custom user driver. +struct UpdateBanner: View { + @Environment(\.updaterController) private var updaterController + let model: UpdateViewModel + + var body: some View { + switch model.state { + case .idle: + EmptyView() + + case .checking(let checking): + BannerRow(tint: .blue, onClose: checking.cancel) { + ProgressView() + .controlSize(.small) + .frame(width: 20) + } content: { + BannerTitle("Checking for Updates…") + } + + case .updateAvailable(let update): + AvailableBannerRow(update: update) + + case .downloading(let download): + BannerRow(tint: .blue, onClose: download.cancel) { + BannerIcon("arrow.down.circle.fill", color: .blue) + } content: { + BannerProgress( + title: "Downloading Update…", + fraction: download.fraction) + } + + case .extracting(let extracting): + BannerRow(tint: .blue, onClose: nil) { + BannerIcon("shippingbox.fill", color: .blue) + } content: { + BannerProgress( + title: "Preparing Update…", + fraction: extracting.progress) + } + + case .installing: + BannerRow(tint: .blue, onClose: nil) { + ProgressView() + .controlSize(.small) + .frame(width: 20) + } content: { + BannerTitle( + "Installing Update…", + subtitle: "MiniWhisper will relaunch") + } + + case .notFound(let notFound): + BannerRow(tint: .green, onClose: notFound.acknowledge) { + BannerIcon("checkmark.circle.fill", color: .green) + } content: { + BannerTitle( + "You're up to date", + subtitle: "MiniWhisper \(AppVersionInfo.current.displayString)") + } + + case .failed(let failure): + BannerRow(tint: .orange, onClose: failure.dismiss) { + BannerIcon("exclamationmark.triangle.fill", color: .orange) + } content: { + HStack(spacing: 8) { + BannerTitle("Update Failed", subtitle: failure.message) + Spacer(minLength: 0) + Button("Retry") { + updaterController?.checkForUpdates(nil) + } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.orange) + } + } + } + } +} + +/// The "Update Available" state keeps the old banner's affordance: the whole +/// row is the install action, with a separate close button for "later". +private struct AvailableBannerRow: View { + let update: UpdateState.Available + @State private var isHovering = false + + var body: some View { + HStack(spacing: 8) { + Button(action: update.install) { + HStack(spacing: 8) { + BannerIcon("arrow.down.circle.fill", color: .blue) + BannerTitle("Update Available", subtitle: subtitle) + Spacer(minLength: 0) + Text("Install") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.blue) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + BannerCloseButton(action: update.dismiss) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isHovering ? Color.blue.opacity(0.08) : Color.blue.opacity(0.04)) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isHovering = hovering + } + } + } + + private var subtitle: String { + var text = "MiniWhisper \(update.version)" + if let byteCount = update.byteCount { + let size = ByteCountFormatter.string( + fromByteCount: byteCount, countStyle: .file) + text += " · \(size)" + } + return text + } +} + +// MARK: - Building blocks + +private struct BannerRow: View { + let tint: Color + let onClose: (() -> Void)? + @ViewBuilder let leading: Leading + @ViewBuilder let content: Content + + var body: some View { + HStack(spacing: 8) { + leading + content + .frame(maxWidth: .infinity, alignment: .leading) + if let onClose { + BannerCloseButton(action: onClose) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(tint.opacity(0.05)) + ) + } +} + +private struct BannerIcon: View { + let name: String + let color: Color + + init(_ name: String, color: Color) { + self.name = name + self.color = color + } + + var body: some View { + Image(systemName: name) + .font(.system(size: 12)) + .foregroundColor(color) + .frame(width: 20) + } +} + +private struct BannerTitle: View { + let title: String + let subtitle: String? + + init(_ title: String, subtitle: String? = nil) { + self.title = title + self.subtitle = subtitle + } + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 13, weight: .medium)) + if let subtitle { + Text(subtitle) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .lineLimit(2) + } + } + } +} + +private struct BannerProgress: View { + let title: String + /// Nil renders an indeterminate bar. + let fraction: Double? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(title) + .font(.system(size: 13, weight: .medium)) + Spacer() + if let fraction { + Text("\(Int(fraction * 100))%") + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(.secondary) + } + } + ProgressView(value: fraction) + .progressViewStyle(.linear) + .controlSize(.small) + } + } +} + +private struct BannerCloseButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/Tests/MiniWhisperTests/UpdateStateTests.swift b/Tests/MiniWhisperTests/UpdateStateTests.swift new file mode 100644 index 0000000..660ab7e --- /dev/null +++ b/Tests/MiniWhisperTests/UpdateStateTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing +@testable import MiniWhisper + +@Suite("Update state") +@MainActor +struct UpdateStateTests { + @Test func viewModelDefaultsToIdle() { + let model = UpdateViewModel() + #expect(model.state.isIdle) + #expect(model.state.phase == .idle) + } + + @Test func phaseMatchesCase() { + #expect(UpdateState.idle.phase == .idle) + #expect(UpdateState.checking(.init(cancel: {})).phase == .checking) + #expect( + UpdateState.updateAvailable( + .init(version: "1.0", byteCount: nil, install: {}, dismiss: {}) + ).phase == .updateAvailable) + #expect( + UpdateState.downloading( + .init(cancel: {}, expectedLength: nil, receivedLength: 0) + ).phase == .downloading) + #expect(UpdateState.extracting(.init(progress: 0)).phase == .extracting) + #expect(UpdateState.installing.phase == .installing) + #expect(UpdateState.notFound(.init(acknowledge: {})).phase == .notFound) + #expect( + UpdateState.failed(.init(message: "boom", dismiss: {})).phase + == .failed) + } + + @Test func manualChecksOnlyStartFromIdleOrTerminalStates() { + #expect(UpdateState.idle.allowsManualCheck) + #expect(!UpdateState.checking(.init(cancel: {})).allowsManualCheck) + #expect( + !UpdateState.updateAvailable( + .init(version: "1.0", byteCount: nil, install: {}, dismiss: {}) + ).allowsManualCheck) + #expect( + !UpdateState.downloading( + .init(cancel: {}, expectedLength: nil, receivedLength: 0) + ).allowsManualCheck) + #expect(!UpdateState.extracting(.init(progress: 0)).allowsManualCheck) + #expect(!UpdateState.installing.allowsManualCheck) + #expect(UpdateState.notFound(.init(acknowledge: {})).allowsManualCheck) + #expect(UpdateState.failed(.init(message: "boom", dismiss: {})).allowsManualCheck) + } + + @Test func cancelInvokesCheckingCancellation() { + var canceled = false + UpdateState.checking(.init(cancel: { canceled = true })).cancel() + #expect(canceled) + } + + @Test func cancelDismissesAvailableUpdate() { + var installed = false + var dismissed = false + UpdateState.updateAvailable(.init( + version: "1.0", byteCount: nil, + install: { installed = true }, + dismiss: { dismissed = true } + )).cancel() + #expect(dismissed) + #expect(!installed) + } + + @Test func cancelStopsDownload() { + var canceled = false + UpdateState.downloading(.init( + cancel: { canceled = true }, expectedLength: 100, receivedLength: 10 + )).cancel() + #expect(canceled) + } + + @Test func cancelAcknowledgesNotFound() { + var acknowledged = false + UpdateState.notFound(.init(acknowledge: { acknowledged = true })).cancel() + #expect(acknowledged) + } + + @Test func cancelDismissesFailure() { + var dismissed = false + UpdateState.failed(.init(message: "boom", dismiss: { dismissed = true })) + .cancel() + #expect(dismissed) + } + + @Test func downloadFractionRequiresExpectedLength() { + let unknown = UpdateState.Downloading( + cancel: {}, expectedLength: nil, receivedLength: 500) + #expect(unknown.fraction == nil) + + let zero = UpdateState.Downloading( + cancel: {}, expectedLength: 0, receivedLength: 500) + #expect(zero.fraction == nil) + } + + @Test func downloadFractionIsRatioCappedAtOne() throws { + let half = UpdateState.Downloading( + cancel: {}, expectedLength: 200, receivedLength: 100) + #expect(try #require(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) + #expect(try #require(over.fraction) == 1.0) + } +} diff --git a/Tests/MiniWhisperTests/UpdaterFactoryTests.swift b/Tests/MiniWhisperTests/UpdaterFactoryTests.swift index 596afcd..0bde2b4 100644 --- a/Tests/MiniWhisperTests/UpdaterFactoryTests.swift +++ b/Tests/MiniWhisperTests/UpdaterFactoryTests.swift @@ -10,35 +10,13 @@ struct UpdaterFactoryTests { let updater = DisabledUpdaterController(unavailableReason: "test reason") #expect(!updater.isAvailable) #expect(updater.unavailableReason == "test reason") - #expect(!updater.updateStatus.isUpdateReady) + #expect(updater.updateViewModel.state.isIdle) } @Test func disabledUpdaterCheckIsNoop() { let updater = DisabledUpdaterController() updater.checkForUpdates(nil) - } - - @Test func updateStatusDefaultsToNotReady() { - let status = UpdateStatus() - #expect(!status.isUpdateReady) - #expect(!status.updateAvailable) - #expect(status.availableVersion == nil) - #expect(!status.needsUserAttention) - } - - @Test func updateStatusNeedsAttentionWhenUpdateAvailable() { - let status = UpdateStatus() - status.updateAvailable = true - #expect(status.needsUserAttention) - } - - @Test func updateStatusNeedsAttentionWhenUpdateReady() { - let status = UpdateStatus(isUpdateReady: true) - #expect(status.needsUserAttention) - } - - @Test func disabledStaticInstanceIsNotReady() { - #expect(!UpdateStatus.disabled.isUpdateReady) + #expect(updater.updateViewModel.state.isIdle) } @Test func updaterEnvironmentStoresInjectedController() throws { diff --git a/justfile b/justfile index a196ddc..f348e19 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,5 @@ app_name := "MiniWhisper" bundle_id := "com.miniwhisper.dev" -signing_id := env("CODESIGN_IDENTITY", "-") -dev_signing_id := env("DEV_CODESIGN_IDENTITY", "-") team_id := env("CODESIGN_TEAM_ID", "") install_path := "/Applications/MiniWhisper Dev.app" @@ -15,16 +13,7 @@ dev: kill build package set -euo pipefail rm -rf "{{install_path}}" cp -R build/{{app_name}}.app "{{install_path}}" - # Sign embedded frameworks - for fw in "{{install_path}}"/Contents/Frameworks/*.framework; do - [ -d "$fw" ] && codesign --force --sign "{{dev_signing_id}}" "$fw" - done - if [ -f "{{install_path}}"/Contents/Resources/miniwhispercli ]; then - codesign --force --sign "{{dev_signing_id}}" "{{install_path}}"/Contents/Resources/miniwhispercli - fi - codesign --force --sign "{{dev_signing_id}}" \ - --entitlements build/MiniWhisper.entitlements \ - "{{install_path}}" + bash Scripts/sign-dev-app.sh "{{install_path}}" rm -rf build/{{app_name}}.app open "{{install_path}}" @@ -102,6 +91,11 @@ generate-appcast-beta zip: 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 + # Kill running instance [group('dev')] kill: