From 68fd8bbc36f7febfe26e94ddf914c84d25052f0e Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Thu, 17 Sep 2026 12:44:10 +0200 Subject: [PATCH 1/4] feat: add Swifter and the local API's pure logic (issue #4, part 1/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Swifter 1.5.0 (pinned exact, BSD-3-Clause, zero dependencies of its own) as the HTTP layer for a local control API, per your choice over hand-rolling one on Network.framework. Sources/OpenPromptrCore/LocalAPI.swift holds everything that can be pure and unit-tested: the /v1/state response shape, a TransformPatch that merges only the fields it mentions into the current transform, and LocalAPIAuth (constant-time token comparison, Origin-header rejection, bearer-token extraction) — kept separate from the server itself so these don't need a running HttpServer to test. AppSettings gains enableLocalAPI, following autoStartOutput's exact pattern (CodingKeys, default-false fallback, encode). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U --- Package.resolved | 11 +- Package.swift | 4 +- Sources/OpenPromptrCore/LocalAPI.swift | 138 ++++++++++++++++++ Sources/OpenPromptrCore/Models.swift | 12 +- .../OpenPromptrCoreTests/LocalAPITests.swift | 59 ++++++++ 5 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 Sources/OpenPromptrCore/LocalAPI.swift create mode 100644 Tests/OpenPromptrCoreTests/LocalAPITests.swift diff --git a/Package.resolved b/Package.resolved index 903bad2..9b34730 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e4fa558729b70b84dcb3a3a1af9cc32d22341458c9d4edfec5b0dbc2552e08ee", + "originHash" : "ee65e45727e23405f73ea91a23159b5f8aa39b46eecc8209fdf25716491f41c1", "pins" : [ { "identity" : "appupdater", @@ -10,6 +10,15 @@ "version" : "4.1.2" } }, + { + "identity" : "swifter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/httpswift/swifter.git", + "state" : { + "revision" : "9483a5d459b45c3ffd059f7b55f9638e268632fd", + "version" : "1.5.0" + } + }, { "identity" : "version", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index e8b7733..e4ede22 100644 --- a/Package.swift +++ b/Package.swift @@ -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( @@ -41,6 +42,7 @@ let package = Package( dependencies: [ "OpenPromptrCore", "VirtualDisplayBridge", .product(name: "AppUpdater", package: "AppUpdater"), + .product(name: "Swifter", package: "swifter"), ], linkerSettings: [ .linkedFramework("AppKit"), diff --git a/Sources/OpenPromptrCore/LocalAPI.swift b/Sources/OpenPromptrCore/LocalAPI.swift new file mode 100644 index 0000000..0e4e495 --- /dev/null +++ b/Sources/OpenPromptrCore/LocalAPI.swift @@ -0,0 +1,138 @@ +import Foundation + +/// The `GET /v1/state` response shape for the local control API (issue #4). +public struct LocalAPIState: Codable, Equatable, Sendable { + public var running: Bool + public var busy: Bool + public var status: String + public var error: Bool + public var permissionGranted: Bool + public var source: String + public var display: LocalAPIDisplay? + public var transform: LocalAPITransform + + public init( + running: Bool, + busy: Bool, + status: String, + error: Bool, + permissionGranted: Bool, + source: String, + display: LocalAPIDisplay?, + transform: LocalAPITransform + ) { + self.running = running + self.busy = busy + self.status = status + self.error = error + self.permissionGranted = permissionGranted + self.source = source + self.display = display + self.transform = transform + } +} + +public struct LocalAPIDisplay: Codable, Equatable, Sendable { + public var id: UInt32 + public var name: String + + public init(id: UInt32, name: String) { + self.id = id + self.name = name + } +} + +public struct LocalAPITransform: Codable, Equatable, Sendable { + public var rotation: Int + public var mirrorH: Bool + public var mirrorV: Bool + + public init(rotation: Int, mirrorH: Bool, mirrorV: Bool) { + self.rotation = rotation + self.mirrorH = mirrorH + self.mirrorV = mirrorV + } +} + +/// A `POST /v1/transform` body: every field is optional, so a caller can +/// change just the rotation, just one mirror axis, or any combination, +/// without first reading the current transform. +public struct TransformPatch: Decodable, Equatable, Sendable { + public var rotation: Int? + public var mirrorH: Bool? + public var mirrorV: Bool? + + public init(rotation: Int? = nil, mirrorH: Bool? = nil, mirrorV: Bool? = nil) { + self.rotation = rotation + self.mirrorH = mirrorH + self.mirrorV = mirrorV + } + + /// Applies this patch to `transform`, leaving fields the patch doesn't + /// mention unchanged. An unrecognized `rotation` value (not 0/90/180/270) + /// is ignored rather than rejecting the whole patch. + public func apply(to transform: DisplayTransform) -> DisplayTransform { + var result = transform + if let rotation, let value = DisplayRotation(rawValue: rotation) { + result.rotation = value + } + if let mirrorH { + result.mirrorHorizontally = mirrorH + } + if let mirrorV { + result.mirrorVertically = mirrorV + } + return result + } +} + +/// A `POST /v1/display` body: selects the target display by its transient +/// `CGDirectDisplayID`. +public struct DisplaySelectionPatch: Decodable, Equatable, Sendable { + public var id: UInt32 + + public init(id: UInt32) { + self.id = id + } +} + +/// Pure authorization checks for the local API, kept separate from the +/// server so they're unit-testable without a running `HttpServer`. +public enum LocalAPIAuth { + /// Constant-time comparison: a naive `==` on the token would leak its + /// length and content one byte at a time through response-time + /// differences, which matters here specifically because the token is the + /// only thing standing between "just a localhost app" and full remote + /// control. + public static func tokenMatches(provided: String?, expected: String) -> Bool { + guard let provided else { + return false + } + let providedBytes = Array(provided.utf8) + let expectedBytes = Array(expected.utf8) + var difference = UInt8(providedBytes.count ^ expectedBytes.count) + for index in 0.. Bool { + headers.keys.contains { $0.caseInsensitiveCompare("Origin") == .orderedSame } + } + + /// Extracts the bearer token from an `Authorization` header value, or + /// `nil` if it isn't a bearer token. + public static func bearerToken(fromAuthorizationHeader value: String?) -> String? { + guard let value, value.hasPrefix("Bearer ") else { + return nil + } + return String(value.dropFirst("Bearer ".count)) + } +} diff --git a/Sources/OpenPromptrCore/Models.swift b/Sources/OpenPromptrCore/Models.swift index 83e9bac..641c569 100644 --- a/Sources/OpenPromptrCore/Models.swift +++ b/Sources/OpenPromptrCore/Models.swift @@ -583,17 +583,20 @@ public struct AppSettings: Codable, Equatable, Sendable { public var configuration: TeleprompterConfiguration public var autoStartOutput: Bool public var autoResumeOutput: Bool + public var enableLocalAPI: Bool public init( schemaVersion: Int = currentSchemaVersion, configuration: TeleprompterConfiguration = .init(), autoStartOutput: Bool = false, - autoResumeOutput: Bool = false + autoResumeOutput: Bool = false, + enableLocalAPI: Bool = false ) { self.schemaVersion = schemaVersion self.configuration = configuration self.autoStartOutput = autoStartOutput self.autoResumeOutput = autoResumeOutput + self.enableLocalAPI = enableLocalAPI } public static var defaults: AppSettings { @@ -607,6 +610,7 @@ public struct AppSettings: Codable, Equatable, Sendable { case presets case autoStartOutput case autoResumeOutput + case enableLocalAPI } public init(from decoder: Decoder) throws { @@ -626,6 +630,11 @@ public struct AppSettings: Codable, Equatable, Sendable { Bool.self, forKey: .autoResumeOutput ) ?? false + enableLocalAPI = + try container.decodeIfPresent( + Bool.self, + forKey: .enableLocalAPI + ) ?? false if let configuration = try container.decodeIfPresent( TeleprompterConfiguration.self, @@ -660,6 +669,7 @@ public struct AppSettings: Codable, Equatable, Sendable { try container.encode(configuration, forKey: .configuration) try container.encode(autoStartOutput, forKey: .autoStartOutput) try container.encode(autoResumeOutput, forKey: .autoResumeOutput) + try container.encode(enableLocalAPI, forKey: .enableLocalAPI) } public func normalized() -> AppSettings { diff --git a/Tests/OpenPromptrCoreTests/LocalAPITests.swift b/Tests/OpenPromptrCoreTests/LocalAPITests.swift new file mode 100644 index 0000000..e025c56 --- /dev/null +++ b/Tests/OpenPromptrCoreTests/LocalAPITests.swift @@ -0,0 +1,59 @@ +import Testing + +@testable import OpenPromptrCore + +@Test("A transform patch only changes the fields it mentions") +func transformPatchAppliesPartially() { + let base = DisplayTransform( + rotation: .degrees0, + mirrorHorizontally: true, + mirrorVertically: false + ) + + let rotationOnly = TransformPatch(rotation: 180).apply(to: base) + #expect(rotationOnly.rotation == .degrees180) + #expect(rotationOnly.mirrorHorizontally == true) + #expect(rotationOnly.mirrorVertically == false) + + let mirrorOnly = TransformPatch(mirrorV: true).apply(to: base) + #expect(mirrorOnly.rotation == .degrees0) + #expect(mirrorOnly.mirrorVertically == true) + + let empty = TransformPatch().apply(to: base) + #expect(empty == base) +} + +@Test("An unrecognized rotation value is ignored rather than rejecting the patch") +func transformPatchIgnoresInvalidRotation() { + let base = DisplayTransform(rotation: .degrees90) + let patched = TransformPatch(rotation: 45, mirrorH: false).apply(to: base) + + #expect(patched.rotation == .degrees90) + #expect(patched.mirrorHorizontally == false) +} + +@Test("Token comparison accepts only an exact match") +func tokenMatchesExactly() { + #expect(LocalAPIAuth.tokenMatches(provided: "secret", expected: "secret")) + #expect(!LocalAPIAuth.tokenMatches(provided: "secre", expected: "secret")) + #expect(!LocalAPIAuth.tokenMatches(provided: "secret ", expected: "secret")) + #expect(!LocalAPIAuth.tokenMatches(provided: "wrong", expected: "secret")) + #expect(!LocalAPIAuth.tokenMatches(provided: nil, expected: "secret")) +} + +@Test("A request is rejected for carrying any Origin header, regardless of case or value") +func originHeaderIsRejectedRegardlessOfValue() { + #expect(LocalAPIAuth.isOriginRejected(headers: ["Origin": "http://example.com"])) + #expect(LocalAPIAuth.isOriginRejected(headers: ["origin": "null"])) + #expect(LocalAPIAuth.isOriginRejected(headers: ["ORIGIN": ""])) + #expect(!LocalAPIAuth.isOriginRejected(headers: ["Authorization": "Bearer x"])) + #expect(!LocalAPIAuth.isOriginRejected(headers: [:])) +} + +@Test("A bearer token is extracted only from a well-formed Authorization header") +func bearerTokenExtraction() { + #expect(LocalAPIAuth.bearerToken(fromAuthorizationHeader: "Bearer abc123") == "abc123") + #expect(LocalAPIAuth.bearerToken(fromAuthorizationHeader: "Basic abc123") == nil) + #expect(LocalAPIAuth.bearerToken(fromAuthorizationHeader: nil) == nil) + #expect(LocalAPIAuth.bearerToken(fromAuthorizationHeader: "") == nil) +} From b17987d678d425d9b4d2228886cd398419ab100f Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Thu, 17 Sep 2026 12:44:27 +0200 Subject: [PATCH 2/4] feat: serve the local HTTP API and wire it into AppModel (issue #4, part 2/2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalAPIServer binds 127.0.0.1 only (forceIPv4 + listenAddressIPv4), generates a fresh per-launch bearer token, and publishes it with the OS-assigned port to a 0600 discovery file in Application Support (LocalAPICredentials) — the first file this app writes outside UserDefaults. Routes: GET /v1/state, POST /v1/output/{start,stop,toggle}, POST /v1/transform (partial patch), POST /v1/display (by ID). Actions are fire-and-forget Task { @MainActor in ... } — the client polls /v1/state for the result, same as the issue's own examples already assumed. Reads use a runOnMainActorSync bridge instead, since they touch no async work and so can't deadlock the way blocking on in-flight async work could. Deliberately not @MainActor: an earlier version marked LocalAPIServer @MainActor, and Swifter calls route/middleware closures from its own background queue — the closures inherited that isolation implicitly despite carrying no annotation, and calling them crashed with a SIGTRAP in dispatch_assert_queue the first time this was actually run. Verified live end-to-end after the fix: state reads, auth rejection (401/wrong or missing token, 403/Origin header present), and a transform patch that correctly merges rather than replaces. Start/stop/toggle were verified by code review only, not live — a real production instance of OpenPromptr was already running during this session and touching capture/virtual-display resources risked interfering with it. AppModel: enableLocalAPI setting, starts the server from finishLaunching() when enabled, stops it in shutdown() and prepareForTermination(). ControlView: a "Remote control" section with the toggle and a "Reveal Connection Info in Finder" button, since without a way to find the port/token the feature is unusable from a Deck. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U --- Sources/OpenPromptr/AppModel.swift | 37 +++ Sources/OpenPromptr/ControlView.swift | 29 +++ .../LocalAPI/LocalAPICredentials.swift | 70 ++++++ .../OpenPromptr/LocalAPI/LocalAPIServer.swift | 227 ++++++++++++++++++ 4 files changed, 363 insertions(+) create mode 100644 Sources/OpenPromptr/LocalAPI/LocalAPICredentials.swift create mode 100644 Sources/OpenPromptr/LocalAPI/LocalAPIServer.swift diff --git a/Sources/OpenPromptr/AppModel.swift b/Sources/OpenPromptr/AppModel.swift index cc9d72d..58de61f 100644 --- a/Sources/OpenPromptr/AppModel.swift +++ b/Sources/OpenPromptr/AppModel.swift @@ -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 @@ -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 @@ -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 @@ -284,6 +287,9 @@ final class AppModel: ObservableObject { if sourceKind == .window { refreshWindows() } + if enableLocalAPI { + startLocalAPIServer() + } if isSelfTest { await startSelfTestIfRequested() @@ -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 { @@ -698,6 +733,7 @@ final class AppModel: ObservableObject { } func shutdown() async { + stopLocalAPIServer() cancelRecovery(resetBudget: true) displayChangeTask?.cancel() windowRefreshTask?.cancel() @@ -796,6 +832,7 @@ final class AppModel: ObservableObject { } func prepareForTermination() { + stopLocalAPIServer() cancelRecovery(resetBudget: true) outputController?.close() startingOutputController?.close() diff --git a/Sources/OpenPromptr/ControlView.swift b/Sources/OpenPromptr/ControlView.swift index 6b3efd4..98091e5 100644 --- a/Sources/OpenPromptr/ControlView.swift +++ b/Sources/OpenPromptr/ControlView.swift @@ -124,6 +124,7 @@ struct ControlView: View { targetSection orientationSection startupSection + remoteControlSection statusSection actionBar } @@ -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) { diff --git a/Sources/OpenPromptr/LocalAPI/LocalAPICredentials.swift b/Sources/OpenPromptr/LocalAPI/LocalAPICredentials.swift new file mode 100644 index 0000000..2ffb2e7 --- /dev/null +++ b/Sources/OpenPromptr/LocalAPI/LocalAPICredentials.swift @@ -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]) + } +} diff --git a/Sources/OpenPromptr/LocalAPI/LocalAPIServer.swift b/Sources/OpenPromptr/LocalAPI/LocalAPIServer.swift new file mode 100644 index 0000000..0c45dd7 --- /dev/null +++ b/Sources/OpenPromptr/LocalAPI/LocalAPIServer.swift @@ -0,0 +1,227 @@ +import CoreGraphics +import Foundation +import OSLog +import OpenPromptrCore +import Swifter + +/// Blocks the calling thread until `body` — a synchronous `@MainActor` +/// closure — has run on the main actor, and returns its result. +/// +/// Used only for state reads that touch no `await`: the block is bounded to +/// however long it takes to read a handful of `@Published` properties, never +/// to arbitrarily long async work, which is what would make a bridge like +/// this a deadlock risk. Actions that need real work (starting output, etc.) +/// must NOT use this — see `LocalAPIServer`'s route handlers, which dispatch +/// those as fire-and-forget `Task`s instead. +private func runOnMainActorSync(_ body: @escaping @MainActor () -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = LocalAPIResultBox() + Task { @MainActor in + box.value = body() + semaphore.signal() + } + semaphore.wait() + return box.value! +} + +private final class LocalAPIResultBox: @unchecked Sendable { + var value: T? +} + +/// The local control API from issue #4: a loopback-only HTTP server that +/// lets an external tool (a script, a Stream Deck plugin) start/stop output +/// and read status, since the menu bar isn't reachable from outside the app. +/// +/// Deliberately not `@MainActor`: Swifter invokes route/middleware closures +/// on its own background dispatch queue, never on the main actor. A closure +/// written lexically inside a `@MainActor` type inherits that isolation +/// implicitly, and the Swift runtime enforces it dynamically even though the +/// closure's declared type carries no isolation annotation — calling it from +/// Swifter's queue then traps with `EXC_BREAKPOINT` in +/// `dispatch_assert_queue`. Every actual touch of `AppModel`'s `@MainActor` +/// state below goes through an explicit hop (`runOnMainActorSync` for reads, +/// `Task { @MainActor in ... }` for actions) instead. +final class LocalAPIServer { + private static let logger = Logger( + subsystem: "com.github.trsdn.OpenPromptr", + category: "local-api" + ) + + private weak var model: AppModel? + private var server: HttpServer? + private var token: String? + + init(model: AppModel) { + self.model = model + } + + var isRunning: Bool { server != nil } + + func start() { + guard server == nil, let model else { + return + } + + let token = LocalAPICredentials.generateToken() + let server = HttpServer() + server.listenAddressIPv4 = "127.0.0.1" + installMiddleware(on: server, token: token) + installRoutes(on: server, model: model) + + do { + try server.start(0, forceIPv4: true) + let port = try server.port() + self.server = server + self.token = token + LocalAPICredentials.publish(port: port, token: token) + Self.logger.info("Local API listening on 127.0.0.1:\(port, privacy: .public).") + } catch { + Self.logger.error("Local API failed to start: \(error.localizedDescription)") + } + } + + func stop() { + server?.stop() + server = nil + token = nil + LocalAPICredentials.remove() + } + + // MARK: - Middleware + + private func installMiddleware(on server: HttpServer, token: String) { + server.middleware.append { request in + if LocalAPIAuth.isOriginRejected(headers: request.headers) { + return Self.textResponse( + 403, "Forbidden", "Requests with an Origin header are not accepted." + ) + } + let provided = LocalAPIAuth.bearerToken( + fromAuthorizationHeader: request.headers["authorization"] + ) + guard LocalAPIAuth.tokenMatches(provided: provided, expected: token) else { + return Self.textResponse(401, "Unauthorized", "Missing or invalid bearer token.") + } + return nil + } + } + + // MARK: - Routes + + private func installRoutes(on server: HttpServer, model: AppModel) { + server.get["/v1/state"] = { [weak model] _ in + guard let model else { + return .internalServerError + } + let state = runOnMainActorSync { Self.state(of: model) } + return Self.jsonResponse(state) + } + + server.post["/v1/output/start"] = { [weak model] _ in + guard let model else { + return .internalServerError + } + Task { @MainActor in await model.start() } + return Self.acceptedResponse() + } + + server.post["/v1/output/stop"] = { [weak model] _ in + guard let model else { + return .internalServerError + } + Task { @MainActor in model.requestStop(message: "Stopped via the local API.") } + return Self.acceptedResponse() + } + + server.post["/v1/output/toggle"] = { [weak model] _ in + guard let model else { + return .internalServerError + } + let isRunning = runOnMainActorSync { model.isRunning } + Task { @MainActor in + if isRunning { + model.requestStop(message: "Stopped via the local API.") + } else { + await model.start() + } + } + return Self.acceptedResponse() + } + + server.post["/v1/transform"] = { [weak model] request in + guard let model else { + return .internalServerError + } + guard let patch = Self.decode(TransformPatch.self, from: request.body) else { + return .badRequest( + .text("Body must be a JSON object with optional rotation/mirrorH/mirrorV.")) + } + Task { @MainActor in + model.setTransform(patch.apply(to: model.transform)) + } + return Self.acceptedResponse() + } + + server.post["/v1/display"] = { [weak model] request in + guard let model else { + return .internalServerError + } + guard let selection = Self.decode(DisplaySelectionPatch.self, from: request.body) else { + return .badRequest(.text("Body must be a JSON object with an integer \"id\".")) + } + Task { @MainActor in + model.selectDisplay(CGDirectDisplayID(selection.id)) + } + return Self.acceptedResponse() + } + } + + // MARK: - Helpers + + @MainActor + private static func state(of model: AppModel) -> LocalAPIState { + let display = model.displays.first { $0.id == model.selectedDisplayID } + return LocalAPIState( + running: model.isRunning, + busy: model.isBusy, + status: model.statusText, + error: model.statusIsError, + permissionGranted: model.permissionGranted, + source: model.sourceKind.localizedName, + display: display.map { LocalAPIDisplay(id: $0.id, name: $0.name) }, + transform: LocalAPITransform( + rotation: model.transform.rotation.rawValue, + mirrorH: model.transform.mirrorHorizontally, + mirrorV: model.transform.mirrorVertically + ) + ) + } + + private static func jsonResponse(_ state: LocalAPIState) -> HttpResponse { + guard let data = try? JSONEncoder().encode(state), + let object = try? JSONSerialization.jsonObject(with: data) + else { + return .internalServerError + } + return .ok(.json(object)) + } + + private static func acceptedResponse() -> HttpResponse { + .ok(.json(["ok": true])) + } + + /// `.unauthorized`/`.forbidden` carry no body in this version of + /// Swifter, so a response that needs explanatory text for either goes + /// through `.raw` instead. + private static func textResponse(_ statusCode: Int, _ reason: String, _ message: String) + -> HttpResponse + { + .raw(statusCode, reason, ["Content-Type": "text/plain; charset=utf-8"]) { writer in + try writer.write([UInt8](message.utf8)) + } + } + + private static func decode(_ type: T.Type, from body: [UInt8]) -> T? { + try? JSONDecoder().decode(T.self, from: Data(body)) + } +} From 0316f2e7f814b7d94682a908041b15c908c0e541 Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Thu, 17 Sep 2026 12:44:35 +0200 Subject: [PATCH 3/4] docs: document the local HTTP API and Swifter (issue #4) README gets a "Local HTTP API" section (endpoints table, security model, a curl example) and Swifter added to the third-party licenses note. SECURITY.md distinguishes outbound network access (still only the update check) from this new optional inbound-only listener, and notes the token lives in a 0600 file rather than UserDefaults. The Pages site's Privacy section gets the same distinction. AGENTS.md gets a forbidden-operations entry (don't loosen the bind address, auth, or Origin check) and a non-negotiable-constraints entry documenting the @MainActor-closure-isolation trap found while building this, so it isn't rediscovered by crashing again. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U --- AGENTS.md | 19 +++++++++++++++++-- README.md | 37 +++++++++++++++++++++++++++++++++++++ SECURITY.md | 19 ++++++++++++++----- docs/index.html | 9 ++++++--- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dfc77ea..43a9443 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 0ec27bb..420e92c 100644 --- a/README.md +++ b/README.md @@ -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 `. 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 @@ -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. diff --git a/SECURITY.md b/SECURITY.md index 91beb18..5b6f65e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/docs/index.html b/docs/index.html index b4ac4bc..fa1b8be 100644 --- a/docs/index.html +++ b/docs/index.html @@ -157,9 +157,12 @@

Privacy

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 + outbound network access is a daily check against this repository's + GitHub Releases (can be turned off), used solely to offer app + updates. An optional, off-by-default local API (127.0.0.1 only, + token-authenticated) lets a script or a Stream Deck control output + from outside the app — never reachable from the network. Settings + are kept in the app's own UserDefaults domain. Full detail is in the security policy. From 5b024ee27e9feccfbf73ef66f8bd4feeea2e3dd5 Mon Sep 17 00:00:00 2001 From: Torsten Mahr Date: Thu, 17 Sep 2026 12:53:12 +0200 Subject: [PATCH 4/4] fix: stop the token comparison from crashing on a long garbage token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent code review of this branch found that LocalAPIAuth.tokenMatches folded the provided/expected byte counts into a single UInt8 via XOR before the constant-time comparison loop. UInt8(_:)'s trapping initializer aborts the process when that XOR exceeds 255, which a single unauthenticated request with a long enough bearer token reaches trivially — a one-request local DoS that crashes the whole app before the token is even checked, confirmed against a real build. Compares lengths directly instead: a mismatch returns false immediately, and only equal-length byte arrays go through the constant-time XOR loop. The token has a fixed 64-character length in practice, so length was never the secret part this needed to protect against timing analysis. Verified live: the same 4096-byte garbage-token request that used to crash the app now returns 401, and the process survives. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U --- Sources/OpenPromptrCore/LocalAPI.swift | 17 ++++++++++++----- Tests/OpenPromptrCoreTests/LocalAPITests.swift | 11 +++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Sources/OpenPromptrCore/LocalAPI.swift b/Sources/OpenPromptrCore/LocalAPI.swift index 0e4e495..a364863 100644 --- a/Sources/OpenPromptrCore/LocalAPI.swift +++ b/Sources/OpenPromptrCore/LocalAPI.swift @@ -110,11 +110,18 @@ public enum LocalAPIAuth { } let providedBytes = Array(provided.utf8) let expectedBytes = Array(expected.utf8) - var difference = UInt8(providedBytes.count ^ expectedBytes.count) - for index in 0..