From 57b2f0e8c2af14ff68b544d3271235230eac6bd5 Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Thu, 17 Sep 2026 10:42:05 +0200 Subject: [PATCH 1/3] feat: check for and install updates via AppUpdater (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AppUpdater 4.1.2 (pinned exact, Package.resolved committed) and an UpdateManager wrapping it, following the same pattern as OpenLens and OpenDefendrWatchr: checks GitHub Releases once a day (waking hourly), downloads and validates a found update in the background, and only ever installs on an explicit user action. GitHubAttestationPolicy is deliberately not required: the notarization broker builds the release in its own repository, so there's no provenance from trsdn/OpenPromptr for AppUpdater to check against. A teleprompter must not restart mid-talk, so UpdateFlow.installUpdate refuses while AppModel.isRunning is true (with an explanatory alert) instead of just disabling a button — both the status-item menu and the app's new Update command menu route through it, so there is one place that decides whether an install may proceed. Automatic checks are opt-out via a toggle; installing itself is never automatic regardless. build-app.sh now also copies AppUpdater_AppUpdater.bundle (the TUF trust roots AppUpdater needs at runtime) into Contents/Resources, so local dev builds have a working updater too, not just broker-built releases. This is OpenPromptr's first third-party dependency. The broker-side half (updating the stale profile, adding the dependency lock, a new assemble_openpromptr build step) is trsdn/macos-notarization-broker#49. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U --- Package.resolved | 24 +++ Package.swift | 11 +- Sources/OpenPromptr/OpenPromptrApp.swift | 83 ++++++++++ Sources/OpenPromptr/Update/UpdateFlow.swift | 73 +++++++++ .../OpenPromptr/Update/UpdateManager.swift | 155 ++++++++++++++++++ build-app.sh | 8 + 6 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 Package.resolved create mode 100644 Sources/OpenPromptr/Update/UpdateFlow.swift create mode 100644 Sources/OpenPromptr/Update/UpdateManager.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..903bad2 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "e4fa558729b70b84dcb3a3a1af9cc32d22341458c9d4edfec5b0dbc2552e08ee", + "pins" : [ + { + "identity" : "appupdater", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mxcl/AppUpdater.git", + "state" : { + "revision" : "4826e7205ed0159347de84b19960f4ba0e535504", + "version" : "4.1.2" + } + }, + { + "identity" : "version", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mxcl/Version", + "state" : { + "revision" : "3043fcd2a50375db76d89ff206a612471833d1c2", + "version" : "2.2.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 345d2a7..e8b7733 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,12 @@ let package = Package( targets: ["OpenPromptr"] ) ], + dependencies: [ + // Pinned exactly: the notarization broker builds with + // `--only-use-versions-from-resolved-file` against its own copy of + // Package.resolved. + .package(url: "https://github.com/mxcl/AppUpdater.git", exact: "4.1.2") + ], targets: [ .target( name: "OpenPromptrCore" @@ -32,7 +38,10 @@ let package = Package( ), .executableTarget( name: "OpenPromptr", - dependencies: ["OpenPromptrCore", "VirtualDisplayBridge"], + dependencies: [ + "OpenPromptrCore", "VirtualDisplayBridge", + .product(name: "AppUpdater", package: "AppUpdater"), + ], linkerSettings: [ .linkedFramework("AppKit"), .linkedFramework("AVFoundation"), diff --git a/Sources/OpenPromptr/OpenPromptrApp.swift b/Sources/OpenPromptr/OpenPromptrApp.swift index 163d478..7a39a52 100644 --- a/Sources/OpenPromptr/OpenPromptrApp.swift +++ b/Sources/OpenPromptr/OpenPromptrApp.swift @@ -4,10 +4,14 @@ import SwiftUI @MainActor final class AppStatusItemController: NSObject, NSMenuDelegate { private weak var model: AppModel? + private weak var updates: UpdateManager? private var showControlsHandler: (() -> Void)? private let statusItem: NSStatusItem private let startItem: NSMenuItem private let stopItem: NSMenuItem + private let checkForUpdatesItem: NSMenuItem + private let automaticUpdatesItem: NSMenuItem + private let installUpdateItem: NSMenuItem override init() { statusItem = NSStatusBar.system.statusItem( @@ -23,6 +27,21 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { action: #selector(stopOutput), keyEquivalent: "." ) + checkForUpdatesItem = NSMenuItem( + title: "Check for Updates…", + action: #selector(checkForUpdates), + keyEquivalent: "" + ) + automaticUpdatesItem = NSMenuItem( + title: "Check for Updates Automatically", + action: #selector(toggleAutomaticUpdates), + keyEquivalent: "" + ) + installUpdateItem = NSMenuItem( + title: "Install Update and Restart…", + action: #selector(installUpdate), + keyEquivalent: "" + ) super.init() statusItem.button?.image = NSImage( @@ -59,6 +78,15 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { menu.addItem(aboutItem) menu.addItem(.separator()) + installUpdateItem.target = self + installUpdateItem.isHidden = true + menu.addItem(installUpdateItem) + checkForUpdatesItem.target = self + menu.addItem(checkForUpdatesItem) + automaticUpdatesItem.target = self + menu.addItem(automaticUpdatesItem) + menu.addItem(.separator()) + let quitItem = NSMenuItem( title: "Quit OpenPromptr", action: #selector(quit), @@ -72,15 +100,27 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { func configure( model: AppModel, + updates: UpdateManager, showControls: @escaping () -> Void ) { self.model = model + self.updates = updates showControlsHandler = showControls } func menuWillOpen(_ menu: NSMenu) { startItem.isEnabled = model?.canStart == true stopItem.isEnabled = model?.canStop == true + + automaticUpdatesItem.state = updates?.automaticChecksEnabled == true ? .on : .off + checkForUpdatesItem.isEnabled = updates?.isBusy != true + + if case .readyToInstall(let version)? = updates?.state { + installUpdateItem.title = "Install Update \(version) and Restart…" + installUpdateItem.isHidden = false + } else { + installUpdateItem.isHidden = true + } } @objc @@ -105,6 +145,24 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { AboutPanel.show() } + @objc + private func checkForUpdates() { + guard let model, let updates else { return } + UpdateFlow.checkForUpdates(updates: updates, model: model) + } + + @objc + private func toggleAutomaticUpdates() { + guard let updates else { return } + updates.automaticChecksEnabled.toggle() + } + + @objc + private func installUpdate() { + guard let model, let updates else { return } + UpdateFlow.installUpdate(updates: updates, model: model) + } + @objc private func quit() { NSApplication.shared.terminate(nil) @@ -120,12 +178,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func configure( model: AppModel, + updates: UpdateManager, showControls: @escaping () -> Void ) { self.model = model showControlsHandler = showControls statusItemController.configure( model: model, + updates: updates, showControls: showControls ) } @@ -185,6 +245,9 @@ struct OpenPromptrApp: App { @StateObject private var model = AppModel() + @StateObject + private var updates = UpdateManager() + var body: some Scene { Window("OpenPromptr", id: "controls") { ControlRootView( @@ -192,9 +255,11 @@ struct OpenPromptrApp: App { configure: { showControls in appDelegate.configure( model: model, + updates: updates, showControls: showControls ) model.appDidLaunch() + updates.startAutomaticChecks() } ) } @@ -206,6 +271,24 @@ struct OpenPromptrApp: App { } } + CommandMenu("Update") { + if case .readyToInstall(let version) = updates.state { + Button("Install Update \(version) and Restart…") { + UpdateFlow.installUpdate(updates: updates, model: model) + } + } + + Button("Check for Updates…") { + UpdateFlow.checkForUpdates(updates: updates, model: model) + } + .disabled(updates.isBusy) + + Toggle( + "Check for Updates Automatically", + isOn: $updates.automaticChecksEnabled + ) + } + CommandMenu("Output") { Button("Start Output") { Task { @MainActor in diff --git a/Sources/OpenPromptr/Update/UpdateFlow.swift b/Sources/OpenPromptr/Update/UpdateFlow.swift new file mode 100644 index 0000000..dabdc3f --- /dev/null +++ b/Sources/OpenPromptr/Update/UpdateFlow.swift @@ -0,0 +1,73 @@ +import AppKit + +/// Shared "Check for Updates…" / "Install and Restart" flow, used identically by the +/// status-item menu and the app's Update command menu so there is one place that decides +/// whether an install may proceed. +@MainActor +enum UpdateFlow { + static func checkForUpdates(updates: UpdateManager, model: AppModel) { + Task { + await updates.check(userInitiated: true) + switch updates.state { + case .upToDate: + presentResult( + title: "OpenPromptr is up to date", + message: "You are running the newest release." + ) + case .failed(let message): + presentResult(title: "Update check failed", message: message) + case .readyToInstall(let version): + offerInstall(version: version, updates: updates, model: model) + default: + break + } + } + } + + /// Only ever called from an explicit menu action — never automatically, and never + /// while `model.isRunning`, so a teleprompter session is never interrupted mid-talk. + static func installUpdate(updates: UpdateManager, model: AppModel) { + guard !model.isRunning else { + presentResult( + title: "Stop output before installing", + message: + "OpenPromptr restarts to install the update. Stop the current output first." + ) + return + } + Task { + if await !updates.installAndRelaunch() { + if case .failed(let message) = updates.state { + presentResult(title: "Update could not be installed", message: message) + } + } + } + } + + private static func offerInstall(version: String, updates: UpdateManager, model: AppModel) { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.messageText = "OpenPromptr \(version) is ready to install" + alert.informativeText = + model.isRunning + ? "OpenPromptr quits and reopens to install this update, so it can't be installed while output is running. Stop output first, then install from the menu." + : "OpenPromptr quits, updates itself, and opens again." + alert.addButton(withTitle: model.isRunning ? "OK" : "Install and Restart") + if !model.isRunning { + alert.addButton(withTitle: "Later") + } + if alert.runModal() == .alertFirstButtonReturn && !model.isRunning { + installUpdate(updates: updates, model: model) + } + } + + private static func presentResult(title: String, message: String) { + NSApp.activate(ignoringOtherApps: true) + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/Sources/OpenPromptr/Update/UpdateManager.swift b/Sources/OpenPromptr/Update/UpdateManager.swift new file mode 100644 index 0000000..120e210 --- /dev/null +++ b/Sources/OpenPromptr/Update/UpdateManager.swift @@ -0,0 +1,155 @@ +import AppUpdater +import Foundation + +/// Checks GitHub Releases for a newer OpenPromptr and installs it in place. +/// +/// Backed by [AppUpdater](https://github.com/mxcl/AppUpdater). It only accepts a release +/// asset named exactly `OpenPromptr-.dmg`, and only if the app inside carries the +/// same Developer ID Team ID, signing identifier and bundle identifier as this one. +/// +/// GitHub artifact attestation (`GitHubAttestationPolicy`) is deliberately not required: +/// the notarization broker builds the release in its own repository, so there is no +/// provenance from `trsdn/OpenPromptr` for AppUpdater to check against. Requiring it would +/// reject every genuine release. +@MainActor +public final class UpdateManager: ObservableObject { + public enum State: Equatable { + case idle + case checking + case upToDate + case downloading(version: String) + case readyToInstall(version: String) + case installing + case failed(String) + } + + public enum Key { + public static let automaticChecks = "checkForUpdatesAutomatically" + } + + @Published public private(set) var state: State = .idle + + @Published public var automaticChecksEnabled: Bool { + didSet { + guard automaticChecksEnabled != oldValue else { return } + defaults.set(automaticChecksEnabled, forKey: Key.automaticChecks) + if automaticChecksEnabled { startAutomaticChecks() } else { stopAutomaticChecks() } + } + } + + private static let automaticCheckInterval: TimeInterval = 24 * 60 * 60 + + private let updater = AppUpdater(owner: "trsdn", repo: "OpenPromptr") + private let defaults: UserDefaults + private var preparedUpdate: PreparedUpdate? + private var lastAutomaticCheck: Date? + private var automaticCheckTask: Task? + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + automaticChecksEnabled = defaults.object(forKey: Key.automaticChecks) as? Bool ?? true + } + + public var isBusy: Bool { + switch state { + case .checking, .downloading, .installing: return true + default: return false + } + } + + public var hasPreparedUpdate: Bool { preparedUpdate != nil } + + // MARK: - Automatic checks + + public func startAutomaticChecks() { + automaticCheckTask?.cancel() + guard automaticChecksEnabled else { return } + // Wakes hourly but checks at most once a day: a Mac that sleeps through the night + // would otherwise miss a plain 24-hour timer indefinitely. + automaticCheckTask = Task { [weak self] in + while !Task.isCancelled { + if let self, self.isAutomaticCheckDue { + await self.check(userInitiated: false) + } + try? await Task.sleep(for: .seconds(60 * 60)) + } + } + } + + public func stopAutomaticChecks() { + automaticCheckTask?.cancel() + automaticCheckTask = nil + } + + private var isAutomaticCheckDue: Bool { + guard let lastAutomaticCheck else { return true } + return Date().timeIntervalSince(lastAutomaticCheck) >= Self.automaticCheckInterval + } + + // MARK: - Check, install, dismiss + + /// Looks for a newer release and, if there is one, downloads and validates it so that + /// installing is a single click. Downloading never interrupts a running teleprompter + /// session; only the actual install (which relaunches the app) has to wait for one. + /// + /// A failed background check is only logged: being offline is not worth an alert. A + /// check the user asked for always answers. + public func check(userInitiated: Bool) async { + guard !isBusy, preparedUpdate == nil else { return } + if userInitiated { + state = .checking + } else { + lastAutomaticCheck = Date() + } + + do { + guard let update = try await updater.check() else { + state = userInitiated ? .upToDate : .idle + return + } + NSLog("OpenPromptr: update available: \(update.version)") + state = .downloading(version: update.version) + preparedUpdate = try await update.prepareInstallation() + state = .readyToInstall(version: update.version) + } catch is CancellationError { + state = .idle + } catch { + NSLog("OpenPromptr: update check failed: \(error.localizedDescription)") + state = userInitiated ? .failed(error.localizedDescription) : .idle + } + } + + /// Replaces the app and relaunches it. On success this never returns. Returns `false` + /// if installation failed, so the caller can resume whatever it stopped beforehand. + /// + /// Callers must not invoke this while a display is being mirrored — a teleprompter must + /// not restart mid-session. `UpdateManager` itself has no notion of mirroring state, so + /// the UI layer is responsible for only offering this while `AppModel.isRunning` is + /// false, and never triggering it automatically. + @discardableResult + public func installAndRelaunch() async -> Bool { + guard let prepared = preparedUpdate else { return false } + preparedUpdate = nil + state = .installing + stopAutomaticChecks() + + do { + try await prepared.installAndRelaunch() + return true + } catch { + NSLog("OpenPromptr: update install failed: \(error.localizedDescription)") + state = .failed(error.localizedDescription) + startAutomaticChecks() + return false + } + } + + /// Throws the downloaded update away. The next automatic check finds it again. + public func dismiss() async { + if let prepared = preparedUpdate { + preparedUpdate = nil + await prepared.discard() + } + state = .idle + } +} diff --git a/build-app.sh b/build-app.sh index 6e51cd9..1f86ba4 100755 --- a/build-app.sh +++ b/build-app.sh @@ -59,6 +59,14 @@ mkdir -p -- "${APP_DIR}/Contents/MacOS" "${APP_DIR}/Contents/Resources" install -m 0755 "${EXECUTABLE}" "${APP_DIR}/Contents/MacOS/${PRODUCT_NAME}" install -m 0644 "${SCRIPT_DIR}/Config/Info.plist" "${APP_DIR}/Contents/Info.plist" +# AppUpdater ships its TUF trust roots as a SwiftPM resource bundle next to the +# built executable; without it in Contents/Resources the updater can't verify +# a release at runtime, even for local dev builds. +UPDATER_BUNDLE="${BIN_DIR}/AppUpdater_AppUpdater.bundle" +if [[ -d "${UPDATER_BUNDLE}" ]]; then + cp -R "${UPDATER_BUNDLE}" "${APP_DIR}/Contents/Resources/" +fi + # The marketing version and build number come from the git tag/history rather # than being hand-typed in Config/Info.plist. Both fall back to whatever is # already in the plist when there's no tag to read (a tarball checkout, a From e0009867f677b9cab60cee9071e81ff39085cc03 Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Thu, 17 Sep 2026 10:42:12 +0200 Subject: [PATCH 2/3] docs: disclose the update check's network access (Y02) AppUpdater's GitHub Releases check is the app's first network access, so the "no network communication" claims in SECURITY.md and the Pages site's Privacy section were no longer accurate. Adds a README section on how the check works, the opt-out toggle, and why installing is blocked while output is running; a third-party licenses note for AppUpdater (Unlicense) and its own Version dependency (Apache-2.0); and updates AGENTS.md's forbidden-operations list to reflect that a dependency and a network connection now exist deliberately, with tighter limits (don't add another; don't install mid-session). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U --- AGENTS.md | 33 +++++++++++++++++++++------------ README.md | 36 +++++++++++++++++++++++++++++++++--- SECURITY.md | 8 ++++++-- docs/index.html | 12 +++++++----- 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 08352bd..8af1821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,12 +69,16 @@ open "dist/OpenPromptr.app" --args --self-test checks, topics, or security features. These are recorded in `.github/conformance.yml`, so changing one silently makes that record wrong. - **Publishing a release, or triggering the notarization broker.** -- **Adding a third-party dependency.** `Package.swift` has none by design - (see `README.md`); the app links only system frameworks. -- **Adding anything that opens a network connection.** The app makes none — - `SECURITY.md` states this as a guarantee to the user, and the only - inter-process communication is the local, unnamed pipe between the main - process and its own headless virtual-display-host instance. +- **Adding another third-party dependency.** `AppUpdater` (pinned exact, + `Package.resolved` committed) is the only one, added deliberately for #7; + the app otherwise links only system frameworks. +- **Adding a network connection beyond AppUpdater's GitHub Releases check.** + `SECURITY.md` and the README's "Checking for updates" section state that + check as the app's only network access; the only other inter-process + communication is the local, unnamed pipe between the main process and its + own headless virtual-display-host instance. +- **Making an update install automatic, or offering it while `AppModel.isRunning` + is true.** A teleprompter must not restart mid-talk — see `UpdateFlow.swift`. - **Loosening `NSScreenCaptureUsageDescription`** or any other usage- description string in `Config/Info.plist`. @@ -110,11 +114,15 @@ fallback for a non-git checkout. Distributable, signed and notarized builds are meant to come from `trsdn/macos-notarization-broker`, the same as sibling apps in this account. -As of this writing that broker's profile for this app is still stale (tracked -in issue #7) — there is no automated release path yet. `build-app.sh` is a -local convenience for development builds, signed with whatever identity is -available locally (falling back to ad-hoc with a warning); it is not -necessarily the definition of what a broker-built release bundle looks like. +As of this writing the broker's `openpromptr` profile is proposed but not yet +merged (trsdn/macos-notarization-broker#49, tracked in issue #7) — there is +no automated release path yet. `build-app.sh` is a local convenience for +development builds, signed with whatever identity is available locally +(falling back to ad-hoc with a warning); the broker assembles the app bundle +itself via its own `assemble_openpromptr` build step, so `build-app.sh` is +not necessarily the definition of what a broker-built release bundle looks +like — keep the two in sync deliberately, not by assumption, if one changes +(bundle layout, Info.plist location, resource bundles). ## Architecture @@ -127,7 +135,8 @@ Sources/ │ target can't mix Swift and Objective-C. ARC. └── OpenPromptr/ App wiring: SwiftUI views, AppModel, capture pipeline, display catalog, the virtual-display-host - process, main.swift's dispatch between the two. + process, main.swift's dispatch between the two, and + Update/ (AppUpdater integration, see #7). ``` Three source types feed one output pipeline: a private virtual display, a diff --git a/README.md b/README.md index 404691a..fbe80a4 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ created. OpenPromptr combines the earlier *Display Transformer* (display source) and *Teleprompter Mirror* (virtual source) in one program and adds Window mode. -There are no third-party packages or permanently installed daemons. Only in -**Virtual display** mode, a second instance of the same signed binary runs -headless as a local display host while the app is running. +There are no permanently installed daemons. Only in **Virtual display** mode, +a second instance of the same signed binary runs headless as a local display +host while the app is running. The only third-party code is +[AppUpdater](https://github.com/mxcl/AppUpdater) (see "Checking for +updates" below). ## The virtual source display @@ -274,6 +276,29 @@ Disabling recovery during healthy output leaves that output running. Lifecycle logs record stop causes, error domains/codes, retry scheduling, cancellation, and results; potentially sensitive error text is private. +## Checking for updates + +OpenPromptr checks GitHub Releases for this repository once a day (waking +hourly, so a Mac that slept through a plain 24-hour timer doesn't miss a day) +using [AppUpdater](https://github.com/mxcl/AppUpdater). A found update is +downloaded and validated in the background — this is the only network access +the app makes, and the only data involved is the request itself (no telemetry, +no account, no identifying information sent beyond what a normal HTTPS request +to GitHub implies). **Check for Updates Automatically** in the status menu or +the app's Update menu turns this off; **Check for Updates…** always checks +once regardless of that setting. + +Installing an update replaces the running app and relaunches it, so it is +**never done automatically and never offered while output is running** — a +teleprompter must not restart mid-talk. Stop output first, then install from +the status menu or the Update menu. + +Only Developer ID-signed releases from this project's own signing identity are +accepted; nothing else can be installed this way. Until a signed release +exists (tracked in +[issue #7](https://github.com/trsdn/OpenPromptr/issues/7)), a check simply +finds nothing to install. + ## Limitations - The app uses a **private, undocumented** CoreGraphics API for the virtual @@ -329,3 +354,8 @@ The [Code of Conduct](CODE_OF_CONDUCT.md) applies to how we work together. ## License [MIT](LICENSE) — Copyright © 2026 Torsten Mahr. + +### Third-party components + +- [AppUpdater](https://github.com/mxcl/AppUpdater) 4.1.2 — Unlicense. +- [Version](https://github.com/mxcl/Version) (AppUpdater's own dependency) — Apache-2.0. diff --git a/SECURITY.md b/SECURITY.md index 1450e16..91beb18 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,8 +25,12 @@ You will usually receive a response within seven days. The following architecture is relevant for evaluating reports: - The app requires **Screen Recording** permission. Captured images are processed - exclusively locally and displayed on a display. There is no network - communication, no telemetry, and no storage of image content on disk. + exclusively locally and displayed on a display; there is no telemetry and no + storage of image content on disk. +- The only network access is an update check against this repository's GitHub + Releases, via [AppUpdater](https://github.com/mxcl/AppUpdater). See + "Checking for updates" in `README.md`. It can be turned off; the app makes + no other network connection. - In **Virtual display** mode, the app starts a second instance of the same signed binary as a headless display host. Only its own bundle path is started; no external programs are executed. diff --git a/docs/index.html b/docs/index.html index fc44d6e..b4ac4bc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -113,8 +113,8 @@

How to get it

Distributable, notarized builds are in progress (tracked in issue #7). - For now, build it yourself — the app is a small SwiftPM project - with no third-party dependencies. + For now, build it yourself — the app is a small SwiftPM project. + Once a release exists, the app checks for updates itself.

@@ -155,9 +155,11 @@

Privacy

OpenPromptr collects nothing and sends nothing

- Captured images are processed locally and shown on a display only. - There is no network communication, no telemetry, and no storage of - image content on disk. Settings are kept in the app's own + Captured images are processed locally and shown on a display only, + with no telemetry and no storage of image content on disk. The only + network access is a daily check against this repository's GitHub + Releases (can be turned off), used solely to offer app updates. + Settings are kept in the app's own UserDefaults domain. Full detail is in the security policy. From a29bc5b8480f1500b3a10a1ccd7be5f616e3e9f9 Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Thu, 17 Sep 2026 10:49:07 +0200 Subject: [PATCH 3/3] fix: gate update installs on canStop, not isRunning, and wire up dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent code review of this branch found that isRunning goes false the instant a capture failure starts an automatic-recovery retry (AppModel sets isRunning = false while desiredOutput stays true), even though that retry is still trying to restore the same session. installUpdate() and offerInstall() gated on isRunning alone, so an install could fire mid-recovery — exactly the "restart mid-talk" scenario this feature is supposed to prevent. Both now gate on model.canStop (desiredOutput || isRunning || isBusy), which covers that window. Also wires up UpdateManager.dismiss()/hasPreparedUpdate, which had no caller: adds a persistent "Later" item next to "Install Update and Restart…" in both the status-item menu and the Update command menu, and disables "Check for Updates…" while an update is already prepared, matching OpenDefendrWatchr's reference behavior. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U --- AGENTS.md | 5 +++- Sources/OpenPromptr/OpenPromptrApp.swift | 25 ++++++++++++++++-- Sources/OpenPromptr/Update/UpdateFlow.swift | 29 ++++++++++++++++----- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8af1821..348b050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,8 +77,11 @@ open "dist/OpenPromptr.app" --args --self-test check as the app's only network access; the only other inter-process communication is the local, unnamed pipe between the main process and its own headless virtual-display-host instance. -- **Making an update install automatic, or offering it while `AppModel.isRunning` +- **Making an update install automatic, or allowing it while `AppModel.canStop` is true.** A teleprompter must not restart mid-talk — see `UpdateFlow.swift`. + Gate on `canStop`, not `isRunning`: `isRunning` goes false the instant a + capture failure starts an automatic-recovery retry, even though that retry + is still trying to restore the same session. - **Loosening `NSScreenCaptureUsageDescription`** or any other usage- description string in `Config/Info.plist`. diff --git a/Sources/OpenPromptr/OpenPromptrApp.swift b/Sources/OpenPromptr/OpenPromptrApp.swift index 7a39a52..5e2684c 100644 --- a/Sources/OpenPromptr/OpenPromptrApp.swift +++ b/Sources/OpenPromptr/OpenPromptrApp.swift @@ -12,6 +12,7 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { private let checkForUpdatesItem: NSMenuItem private let automaticUpdatesItem: NSMenuItem private let installUpdateItem: NSMenuItem + private let laterUpdateItem: NSMenuItem override init() { statusItem = NSStatusBar.system.statusItem( @@ -42,6 +43,11 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { action: #selector(installUpdate), keyEquivalent: "" ) + laterUpdateItem = NSMenuItem( + title: "Later", + action: #selector(dismissUpdate), + keyEquivalent: "" + ) super.init() statusItem.button?.image = NSImage( @@ -81,6 +87,9 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { installUpdateItem.target = self installUpdateItem.isHidden = true menu.addItem(installUpdateItem) + laterUpdateItem.target = self + laterUpdateItem.isHidden = true + menu.addItem(laterUpdateItem) checkForUpdatesItem.target = self menu.addItem(checkForUpdatesItem) automaticUpdatesItem.target = self @@ -113,13 +122,16 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { stopItem.isEnabled = model?.canStop == true automaticUpdatesItem.state = updates?.automaticChecksEnabled == true ? .on : .off - checkForUpdatesItem.isEnabled = updates?.isBusy != true + checkForUpdatesItem.isEnabled = + updates?.isBusy != true && updates?.hasPreparedUpdate != true if case .readyToInstall(let version)? = updates?.state { installUpdateItem.title = "Install Update \(version) and Restart…" installUpdateItem.isHidden = false + laterUpdateItem.isHidden = false } else { installUpdateItem.isHidden = true + laterUpdateItem.isHidden = true } } @@ -163,6 +175,12 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { UpdateFlow.installUpdate(updates: updates, model: model) } + @objc + private func dismissUpdate() { + guard let updates else { return } + UpdateFlow.dismissUpdate(updates: updates) + } + @objc private func quit() { NSApplication.shared.terminate(nil) @@ -276,12 +294,15 @@ struct OpenPromptrApp: App { Button("Install Update \(version) and Restart…") { UpdateFlow.installUpdate(updates: updates, model: model) } + Button("Later") { + UpdateFlow.dismissUpdate(updates: updates) + } } Button("Check for Updates…") { UpdateFlow.checkForUpdates(updates: updates, model: model) } - .disabled(updates.isBusy) + .disabled(updates.isBusy || updates.hasPreparedUpdate) Toggle( "Check for Updates Automatically", diff --git a/Sources/OpenPromptr/Update/UpdateFlow.swift b/Sources/OpenPromptr/Update/UpdateFlow.swift index dabdc3f..222bda9 100644 --- a/Sources/OpenPromptr/Update/UpdateFlow.swift +++ b/Sources/OpenPromptr/Update/UpdateFlow.swift @@ -25,9 +25,13 @@ enum UpdateFlow { } /// Only ever called from an explicit menu action — never automatically, and never - /// while `model.isRunning`, so a teleprompter session is never interrupted mid-talk. + /// while `model.canStop` (a session is running, desired, or in the middle of starting, + /// stopping, or automatic recovery), so a teleprompter session is never interrupted + /// mid-talk. `isRunning` alone is not enough: it goes false the instant a capture + /// failure starts an automatic-recovery retry, even though that retry is still trying + /// to restore the same session. static func installUpdate(updates: UpdateManager, model: AppModel) { - guard !model.isRunning else { + guard !model.canStop else { presentResult( title: "Stop output before installing", message: @@ -44,20 +48,33 @@ enum UpdateFlow { } } + /// Discards a downloaded update without installing it. The next check finds it again. + static func dismissUpdate(updates: UpdateManager) { + Task { + await updates.dismiss() + } + } + private static func offerInstall(version: String, updates: UpdateManager, model: AppModel) { NSApp.activate(ignoringOtherApps: true) + let sessionActive = model.canStop let alert = NSAlert() alert.messageText = "OpenPromptr \(version) is ready to install" alert.informativeText = - model.isRunning + sessionActive ? "OpenPromptr quits and reopens to install this update, so it can't be installed while output is running. Stop output first, then install from the menu." : "OpenPromptr quits, updates itself, and opens again." - alert.addButton(withTitle: model.isRunning ? "OK" : "Install and Restart") - if !model.isRunning { + alert.addButton(withTitle: sessionActive ? "OK" : "Install and Restart") + if !sessionActive { alert.addButton(withTitle: "Later") } - if alert.runModal() == .alertFirstButtonReturn && !model.isRunning { + switch alert.runModal() { + case .alertFirstButtonReturn where !sessionActive: installUpdate(updates: updates, model: model) + case .alertSecondButtonReturn where !sessionActive: + dismissUpdate(updates: updates) + default: + break } }