Skip to content

Local HTTP API for start/stop/status (issue #4) - #12

Merged
trsdn merged 4 commits into
mainfrom
local-http-api
Sep 17, 2026
Merged

trsdn merged 4 commits into
mainfrom
local-http-api

Conversation

@trsdn

@trsdn trsdn commented Sep 17, 2026

Copy link
Copy Markdown
Owner

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).
  • Bound to 127.0.0.1 only; off by default; a fresh random bearer token every launch, published with the port to a 0600 discovery file in Application Support; any request carrying an Origin header is rejected outright.
  • A "Remote control" section in the app with the toggle and a "Reveal Connection Info in Finder" button.

Two real bugs found and fixed via live testing and an independent /code-review:

  1. LocalAPIServer was originally @MainActor. Swifter calls route/middleware closures from its own background queue, and a closure written inside a @MainActor type inherits that isolation implicitly even with no annotation — the app crashed with a SIGTRAP in dispatch_assert_queue the first time this actually ran. Fixed by making the class plain (not @MainActor) and routing every real touch of AppModel through an explicit hop (runOnMainActorSync for reads, Task { @MainActor in ... } for actions).
  2. The constant-time token comparison folded both byte counts into a single UInt8 via 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.md so 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, Origin header), a POST /v1/transform partial-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-tested AppModel methods.

Test plan

  • swift test — 45 tests pass, including new coverage for TransformPatch, LocalAPIAuth (token matching, the crash regression, Origin rejection, bearer extraction)
  • swift format lint --strict --recursive Sources Tests Package.swift clean
  • ./build-app.sh — builds, signs, verifies
  • Live: server start, discovery file, state shape, all four auth paths, transform patch, garbage-token crash fix — all against a throwaway settings config that never touched the real production instance's settings or capture resources
  • Two independent /code-review passes on this branch; both findings fixed here
  • CI (Build and tests, App bundle)
  • Live-test /v1/output/start//stop//toggle when you can quit the production instance safely

Follow-up

A small companion PR against trsdn/macos-notarization-broker to update profiles/locks/openpromptr-Package.resolved for 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

trsdn and others added 4 commits September 17, 2026 12:44
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
@trsdn
trsdn merged commit 6ca5956 into main Sep 17, 2026
4 checks passed
@trsdn
trsdn deleted the local-http-api branch September 17, 2026 11:17
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant