Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"),
Expand Down
36 changes: 33 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
8 changes: 6 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
104 changes: 104 additions & 0 deletions Sources/OpenPromptr/OpenPromptrApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
)
}
Expand Down Expand Up @@ -185,16 +263,21 @@ struct OpenPromptrApp: App {
@StateObject
private var model = AppModel()

@StateObject
private var updates = UpdateManager()

var body: some Scene {
Window("OpenPromptr", id: "controls") {
ControlRootView(
model: model,
configure: { showControls in
appDelegate.configure(
model: model,
updates: updates,
showControls: showControls
)
model.appDidLaunch()
updates.startAutomaticChecks()
}
)
}
Expand All @@ -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
Expand Down
Loading