diff --git a/AGENTS.md b/AGENTS.md index 08352bd..348b050 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,12 +69,19 @@ 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 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`. @@ -110,11 +117,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 +138,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/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/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/Sources/OpenPromptr/OpenPromptrApp.swift b/Sources/OpenPromptr/OpenPromptrApp.swift index 163d478..5e2684c 100644 --- a/Sources/OpenPromptr/OpenPromptrApp.swift +++ b/Sources/OpenPromptr/OpenPromptrApp.swift @@ -4,10 +4,15 @@ 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 + private let laterUpdateItem: NSMenuItem override init() { statusItem = NSStatusBar.system.statusItem( @@ -23,6 +28,26 @@ 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: "" + ) + laterUpdateItem = NSMenuItem( + title: "Later", + action: #selector(dismissUpdate), + keyEquivalent: "" + ) super.init() statusItem.button?.image = NSImage( @@ -59,6 +84,18 @@ final class AppStatusItemController: NSObject, NSMenuDelegate { menu.addItem(aboutItem) menu.addItem(.separator()) + 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 + menu.addItem(automaticUpdatesItem) + menu.addItem(.separator()) + let quitItem = NSMenuItem( title: "Quit OpenPromptr", action: #selector(quit), @@ -72,15 +109,30 @@ 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 && 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 + } } @objc @@ -105,6 +157,30 @@ 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 dismissUpdate() { + guard let updates else { return } + UpdateFlow.dismissUpdate(updates: updates) + } + @objc private func quit() { NSApplication.shared.terminate(nil) @@ -120,12 +196,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 +263,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 +273,11 @@ struct OpenPromptrApp: App { configure: { showControls in appDelegate.configure( model: model, + updates: updates, showControls: showControls ) model.appDidLaunch() + updates.startAutomaticChecks() } ) } @@ -206,6 +289,27 @@ 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("Later") { + UpdateFlow.dismissUpdate(updates: updates) + } + } + + Button("Check for Updates…") { + UpdateFlow.checkForUpdates(updates: updates, model: model) + } + .disabled(updates.isBusy || updates.hasPreparedUpdate) + + 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..222bda9 --- /dev/null +++ b/Sources/OpenPromptr/Update/UpdateFlow.swift @@ -0,0 +1,90 @@ +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.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.canStop 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) + } + } + } + } + + /// 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 = + 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: sessionActive ? "OK" : "Install and Restart") + if !sessionActive { + alert.addButton(withTitle: "Later") + } + switch alert.runModal() { + case .alertFirstButtonReturn where !sessionActive: + installUpdate(updates: updates, model: model) + case .alertSecondButtonReturn where !sessionActive: + dismissUpdate(updates: updates) + default: + break + } + } + + 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 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.