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
19 changes: 17 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ open "dist/OpenPromptr.app" --args --self-test
is still trying to restore the same session.
- **Loosening `NSScreenCaptureUsageDescription`** or any other usage-
description string in `Config/Info.plist`.
- **Loosening the local API's security model**: binding anything but
`127.0.0.1`, dropping bearer-token auth, or accepting a request that
carries an `Origin` header. See `LocalAPIServer.swift`/`LocalAPI.swift`
and issue #4.

## Generated and machine-owned paths

Expand Down Expand Up @@ -138,8 +142,9 @@ 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, and
Update/ (AppUpdater integration, see #7).
process, main.swift's dispatch between the two,
Update/ (AppUpdater integration, see #7), and
LocalAPI/ (the loopback HTTP control API, see #4).
```

Three source types feed one output pipeline: a private virtual display, a
Expand Down Expand Up @@ -175,6 +180,16 @@ display aren't reliably delivered to the process that created it).
- **A manual Stop always wins.** It suppresses automatic recovery and
automatic restart-on-reconnect for the rest of the app session; only an
explicit Start lifts that suppression.
- **A closure written inside a `@MainActor` type inherits that isolation
implicitly, even with no annotation on the closure itself.** `LocalAPIServer`
hands its route/middleware closures to Swifter, which calls them on its own
background queue — measured as a `SIGTRAP` in `dispatch_assert_queue` the
first time this was tried with the class marked `@MainActor`, since the
compiler let it through silently and only the runtime's dynamic isolation
check caught it. That's why `LocalAPIServer` is deliberately *not*
`@MainActor`: every actual touch of `AppModel`'s state goes through an
explicit hop instead (`runOnMainActorSync` for reads, `Task { @MainActor
in ... }` for actions).

## Repository quality standard

Expand Down
11 changes: 10 additions & 1 deletion Package.resolved

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

4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ let package = Package(
// 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")
.package(url: "https://github.com/mxcl/AppUpdater.git", exact: "4.1.2"),
.package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"),
],
targets: [
.target(
Expand All @@ -41,6 +42,7 @@ let package = Package(
dependencies: [
"OpenPromptrCore", "VirtualDisplayBridge",
.product(name: "AppUpdater", package: "AppUpdater"),
.product(name: "Swifter", package: "swifter"),
],
linkerSettings: [
.linkedFramework("AppKit"),
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,42 @@ exists (tracked in
finds nothing to install. See [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md)
for how a release is actually cut and published.

## Local HTTP API

For control from outside the app — a script, a Stream Deck plugin — since
the menu bar isn't reachable that way. Off by default; enable **Enable
local HTTP API** under **Remote control**.

- Binds `127.0.0.1` only; never reachable from the network.
- A random token is generated on every launch and published, with the
bound port, to
`~/Library/Application Support/com.github.trsdn.OpenPromptr/local-api.json`
(mode 0600). **Reveal Connection Info in Finder** in the same section
opens it directly.
- Every request needs `Authorization: Bearer <token>`. A request carrying
an `Origin` header — including from DNS-rebinding attempts — is rejected
outright, regardless of its value.
- Actions are fire-and-forget: a `POST` returns `{"ok": true}` immediately
without waiting for the change to finish; poll `GET /v1/state` to see the
result, the same way a Stream Deck button would.

| Endpoint | Effect |
| --- | --- |
| `GET /v1/state` | Current status, source, target display, and transform |
| `POST /v1/output/start` | Start output |
| `POST /v1/output/stop` | Stop output |
| `POST /v1/output/toggle` | Start or stop, whichever applies |
| `POST /v1/transform` `{"rotation":180,"mirrorH":true}` | Patch the transform — any subset of `rotation`/`mirrorH`/`mirrorV` |
| `POST /v1/display` `{"id":3}` | Select the target display by ID (see `GET /v1/state`'s `display.id`) |

```bash
API=$(cat ~/Library/Application\ Support/com.github.trsdn.OpenPromptr/local-api.json)
PORT=$(python3 -c "import json,sys;print(json.loads(sys.argv[1])['port'])" "$API")
TOKEN=$(python3 -c "import json,sys;print(json.loads(sys.argv[1])['token'])" "$API")
curl -s "http://127.0.0.1:$PORT/v1/state" -H "Authorization: Bearer $TOKEN"
curl -s -X POST "http://127.0.0.1:$PORT/v1/output/toggle" -H "Authorization: Bearer $TOKEN"
```

## Limitations

- The app uses a **private, undocumented** CoreGraphics API for the virtual
Expand Down Expand Up @@ -360,3 +396,4 @@ The [Code of Conduct](CODE_OF_CONDUCT.md) applies to how we work together.

- [AppUpdater](https://github.com/mxcl/AppUpdater) 4.1.2 — Unlicense.
- [Version](https://github.com/mxcl/Version) (AppUpdater's own dependency) — Apache-2.0.
- [Swifter](https://github.com/httpswift/swifter) 1.5.0 — BSD-3-Clause.
19 changes: 14 additions & 5 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,24 @@ 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 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.
- The only outbound 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 outbound connection.
- **Off by default**, the app can listen for local control commands (start,
stop, transform, target display) on `127.0.0.1` only — never reachable
from the network. Every request needs a per-launch random bearer token
from a 0600 discovery file in the app's Application Support directory, and
any request carrying an `Origin` header is rejected outright regardless of
its value, closing off browser-based access including DNS rebinding. See
"Local HTTP API" in `README.md`.
- 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.
- Access to the private CoreGraphics classes happens dynamically through
`NSClassFromString`, without linking private symbols.
- Settings remain unchanged in the app's `UserDefaults`. No credentials or
personal data are stored.
personal data are stored there. The one exception is the local API's own
bearer token (see above), which lives in a 0600 file, not `UserDefaults`,
and is regenerated every launch.
- The bundles are signed with "Developer ID" and enabled Hardened Runtime.
37 changes: 37 additions & 0 deletions Sources/OpenPromptr/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ final class AppModel: ObservableObject {
@Published private(set) var transform: DisplayTransform
@Published private(set) var autoStartOutput: Bool
@Published private(set) var autoResumeOutput: Bool
@Published private(set) var enableLocalAPI: Bool
@Published private(set) var isRunning = false
@Published private(set) var isBusy = false
@Published private(set) var isRefreshingWindows = false
Expand Down Expand Up @@ -127,6 +128,7 @@ final class AppModel: ObservableObject {
/// host only runs while the virtual display is actually the source.
private var virtualDisplayHost: VirtualDisplayHostProcess?
private var virtualDisplayID: CGDirectDisplayID?
private var localAPIServer: LocalAPIServer?
private var workingSource: CaptureSourceSelection
private var workingTargetIdentity: PersistentDisplayIdentity?
private var lifecycle: Lifecycle = .idle
Expand Down Expand Up @@ -159,6 +161,7 @@ final class AppModel: ObservableObject {
settings = loaded
autoStartOutput = loaded.autoStartOutput
autoResumeOutput = loaded.autoResumeOutput
enableLocalAPI = loaded.enableLocalAPI

let configuration = loaded.configuration
workingSource = configuration.source
Expand Down Expand Up @@ -284,6 +287,9 @@ final class AppModel: ObservableObject {
if sourceKind == .window {
refreshWindows()
}
if enableLocalAPI {
startLocalAPIServer()
}

if isSelfTest {
await startSelfTestIfRequested()
Expand Down Expand Up @@ -490,6 +496,35 @@ final class AppModel: ObservableObject {
}
}

/// See issue #4: a loopback-only HTTP API so an external tool (a script,
/// a Stream Deck plugin) can start/stop output and read status without
/// going through the menu bar.
func setEnableLocalAPI(_ enabled: Bool) {
enableLocalAPI = enabled
settings.enableLocalAPI = enabled
persistSettings()

if enabled {
startLocalAPIServer()
} else {
stopLocalAPIServer()
}
}

private func startLocalAPIServer() {
guard localAPIServer == nil else {
return
}
let server = LocalAPIServer(model: self)
server.start()
localAPIServer = server
}

private func stopLocalAPIServer() {
localAPIServer?.stop()
localAPIServer = nil
}

func refreshDisplays() {
refreshDisplaySnapshot()
if sourceKind == .window {
Expand Down Expand Up @@ -698,6 +733,7 @@ final class AppModel: ObservableObject {
}

func shutdown() async {
stopLocalAPIServer()
cancelRecovery(resetBudget: true)
displayChangeTask?.cancel()
windowRefreshTask?.cancel()
Expand Down Expand Up @@ -796,6 +832,7 @@ final class AppModel: ObservableObject {
}

func prepareForTermination() {
stopLocalAPIServer()
cancelRecovery(resetBudget: true)
outputController?.close()
startingOutputController?.close()
Expand Down
29 changes: 29 additions & 0 deletions Sources/OpenPromptr/ControlView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ struct ControlView: View {
targetSection
orientationSection
startupSection
remoteControlSection
statusSection
actionBar
}
Expand Down Expand Up @@ -391,6 +392,34 @@ struct ControlView: View {
}
}

private var remoteControlSection: some View {
ControlSection(title: "Remote control", systemImage: "network") {
VStack(alignment: .leading, spacing: 6) {
Toggle(
"Enable local HTTP API",
isOn: Binding(
get: { model.enableLocalAPI },
set: { model.setEnableLocalAPI($0) }
)
)
.toggleStyle(.checkbox)

Text(
"Lets a script or a Stream Deck plugin start/stop output and read status. Listens on 127.0.0.1 only and requires a token; never reachable from the network."
)
.font(.caption2)
.foregroundStyle(.secondary)

if model.enableLocalAPI {
Button("Reveal Connection Info in Finder") {
LocalAPICredentials.revealInFinder()
}
.controlSize(.small)
}
}
}
}

private var statusSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 8) {
Expand Down
70 changes: 70 additions & 0 deletions Sources/OpenPromptr/LocalAPI/LocalAPICredentials.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import AppKit
import Foundation
import OSLog
import Security

/// Generates and publishes the port/token a script or Stream Deck plugin
/// needs to reach the local API, and removes them again on shutdown so a
/// stale file never claims a port nothing is listening on.
enum LocalAPICredentials {
private static let logger = Logger(
subsystem: "com.github.trsdn.OpenPromptr",
category: "local-api"
)

private static var directory: URL {
let base =
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let bundleID = Bundle.main.bundleIdentifier ?? "com.github.trsdn.OpenPromptr"
return base.appendingPathComponent(bundleID, isDirectory: true)
}

private static var fileURL: URL {
directory.appendingPathComponent("local-api.json")
}

/// 32 random bytes, hex-encoded. Regenerated on every launch: a fresh
/// token each run limits how long a leaked one stays useful, and the
/// discovery file is rewritten on every launch anyway.
static func generateToken() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
precondition(status == errSecSuccess, "SecRandomCopyBytes failed: \(status)")
return bytes.map { String(format: "%02x", $0) }.joined()
}

/// Writes `{"port": ..., "token": ...}` to the discovery file, creating
/// the app's Application Support directory if this is the first thing
/// ever written there. Sets 0600 permissions so another local user
/// account on a shared Mac can't read the token even though the app
/// itself isn't sandboxed.
static func publish(port: Int, token: String) {
do {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
let payload = ["port": port, "token": token] as [String: Any]
let data = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
try data.write(to: fileURL, options: .atomic)
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: fileURL.path
)
} catch {
logger.error("Could not publish local API credentials: \(error.localizedDescription)")
}
}

static func remove() {
try? FileManager.default.removeItem(at: fileURL)
}

/// Reveals the discovery file so a Deck/script author can read its port
/// and token without hand-typing an Application Support path.
static func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting([fileURL])
}
}
Loading