Local HTTP API for start/stop/status (issue #4) - #12
Merged
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U
…art 2/2)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U
19 tasks
trsdn
added a commit
to trsdn/macos-notarization-broker
that referenced
this pull request
Sep 17, 2026
OpenPromptr trsdn/OpenPromptr#12 adds Swifter 1.5.0 (the local control API's HTTP layer, issue #4 there) as a second dependency alongside AppUpdater. This repo's copy of the lock was still the AppUpdater-only version from #49, so assemble_openpromptr would have rejected the real source the moment that PR merged. Swifter has no resource bundle of its own -- it compiles straight into the executable -- so nested_resource_bundles is unchanged; only the lock and its pin-verification test needed updating. Verified: full test suite (193 tests, one updated for the new pin), python3 scripts/validate-repository.py, py_compile, and a real end-to-end run of assemble_openpromptr (an actual swift build) against the current (unmerged) OpenPromptr source, followed by validate_app_tree -- both passed. Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
trsdn
added a commit
that referenced
this pull request
Sep 17, 2026
Covers everything merged since 1.1.0: the local HTTP API (#12), the AppUpdater integration (#10), the version-from-tag/About panel work, and the color-only status fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements issue #4: a loopback-only HTTP API so an external tool (a script, a Stream Deck plugin via OpenDeck) can control OpenPromptr without going through the menu bar. Follows the issue's own proposal closely, adapted for what's changed since it was written (presets don't exist anymore — dropped) and using Swifter as the HTTP layer, per your choice over hand-rolling one on
Network.framework.GET /v1/state,POST /v1/output/{start,stop,toggle},POST /v1/transform(partial patch),POST /v1/display(by ID).127.0.0.1only; off by default; a fresh random bearer token every launch, published with the port to a0600discovery file in Application Support; any request carrying anOriginheader is rejected outright.Two real bugs found and fixed via live testing and an independent
/code-review:LocalAPIServerwas originally@MainActor. Swifter calls route/middleware closures from its own background queue, and a closure written inside a@MainActortype inherits that isolation implicitly even with no annotation — the app crashed with aSIGTRAPindispatch_assert_queuethe first time this actually ran. Fixed by making the class plain (not@MainActor) and routing every real touch ofAppModelthrough an explicit hop (runOnMainActorSyncfor reads,Task { @MainActor in ... }for actions).UInt8via XOR before comparing —UInt8(_:)'s trapping initializer crashes the whole app on a single unauthenticated request with a long enough garbage bearer token. Fixed by comparing lengths directly (a mismatch returns immediately) and only running the constant-time loop on equal-length arrays.Both are documented in
AGENTS.mdso they aren't rediscovered by crashing again.What was and wasn't live-tested: a real production instance of OpenPromptr was already running on this machine throughout (uptime since Monday). To avoid interfering with it, I verified live — against a real build, with a throwaway settings config (
source: window, so no virtual display was touched) — server startup, the discovery file,GET /v1/state's shape, all four auth paths (valid/missing/wrong token,Originheader), aPOST /v1/transformpartial-merge patch, and the garbage-token crash fix. I deliberately did not live-test/v1/output/start//stop//toggle, since they'd touch real capture/virtual-display resources that could conflict with the running production instance — those are verified by code review only, as thin wrappers around already-testedAppModelmethods.Test plan
swift test— 45 tests pass, including new coverage forTransformPatch,LocalAPIAuth(token matching, the crash regression, Origin rejection, bearer extraction)swift format lint --strict --recursive Sources Tests Package.swiftclean./build-app.sh— builds, signs, verifies/code-reviewpasses on this branch; both findings fixed hereBuild and tests,App bundle)/v1/output/start//stop//togglewhen you can quit the production instance safelyFollow-up
A small companion PR against
trsdn/macos-notarization-brokerto updateprofiles/locks/openpromptr-Package.resolvedfor the new Swifter dependency, so a future release build doesn't fail preflight on a stale lock — coming next, not a blocker for this PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01XBbvDgF84mTXMJMcioTX4U