Skip to content

crispctl: connect, disconnect and toggle a physical display - #97

Open
celsinho17 wants to merge 1 commit into
didriksg:mainfrom
celsinho17:feat/crispctl-display-connection
Open

crispctl: connect, disconnect and toggle a physical display#97
celsinho17 wants to merge 1 commit into
didriksg:mainfrom
celsinho17:feat/crispctl-display-connection

Conversation

@celsinho17

Copy link
Copy Markdown

Why

Disconnect Display is only reachable from the menu, so on a KVM desk the one thing worth automating is the one thing that cannot be scripted.

Concretely: two hosts share a monitor through its KVM. The panel's input follows the switch, but macOS keeps the display in its layout the whole time, so windows go on living on a screen that is currently showing the other computer. The fix is to drop it out of the layout when the KVM moves away and put it back when it returns — which PhysicalDisplayToggleService already does perfectly, just not from anywhere a script can reach.

What

crispctl display disconnect <display>
crispctl display connect <display>
crispctl display toggle <display>

No new capability: the service does the work, this exposes it.

Shape, and why

  • The selector is a runtime id or a uuid. A disconnected display is gone from CGGetOnlineDisplayList, so its id is only a last-known value and cannot be trusted to find it again. The uuid is the selector that survives a replug or a wake, and it is the one to use for anything scripted. displays list now reports both.
  • displays list includes disconnected displays, marked connected:false. Without that half, connect could never name its target — its display is absent from every macOS list by construction.
  • toggle collapses into a concrete direction inside the pure handle, so the server never re-reads state and the decision stays unit-testable.
  • Asking for the state a display is already in succeeds and changes nothing, so a button bound to connect or disconnect is safe to press twice.
  • These replies are not optimistic, unlike brightness. Disconnecting can be legitimately refused — it would leave no active display — and a caller wiring this to a physical button needs the reason, so the reply carries Crisp's own error text.
  • CrispControlDisplay gained a hand-written init(from:) so the two new fields decode as absent rather than throwing. That keeps the forward compatibility the help already promises ("Later versions may add fields; ignore what you do not know") true in both directions.

I kept the existing (response, brightnessChange) tuple shape and added a third slot rather than refactoring to an effect enum, so every existing test compiles untouched. Happy to switch to an enum if you would rather — it is the direction the code seems to be heading, and it is a small change.

Verification

make check — lint clean, 84 tests green, localization keys complete.

Five new unit tests cover selector resolution by id and by lowercased uuid, toggle direction in both directions, the already-in-that-state no-op, the settled-state reply, rejection of missing / unknown / uuid-less displays, and legacy decoding.

Live on an M1 Pro, macOS 26.6.2, against a display the app was already holding disconnected — system_profiler used as an independent witness rather than trusting Crisp's own report:

BEFORE   macOS displays=1   crisp=[('Gigabyte M32U', True), ('M32UC', False)]
toggle → {"ok":true,...,"connected":true}
AFTER#1  macOS displays=2   crisp=[('Gigabyte M32U', True), ('M32UC', True)]
toggle → {"ok":true,...,"connected":false}
AFTER#2  macOS displays=1   crisp=[('Gigabyte M32U', True), ('M32UC', False)]

Exit codes confirmed: 0 success, 2 bad arguments, 3 refusal.

One question

Is there a plan to ship crispctl — or at least the control server — in release builds? Everything above only reaches people who build from source. I have opened that as a separate issue so it does not ride on this PR.

Thanks for Crisp, and for making the control surface a plain socket. It made this a pleasure to work on.

Disconnect Display is only reachable from the menu, so the one thing a
KVM desk wants to automate is the one thing that cannot be scripted. On a
two-host desk the monitor's input follows the KVM but macOS keeps the
display in its layout, and windows go on living on a screen that is
showing the other computer.

PhysicalDisplayToggleService already does the work. This exposes it:

  crispctl display disconnect <display>
  crispctl display connect <display>
  crispctl display toggle <display>

Notes on the shape:

- The selector is a runtime id or a uuid. A disconnected display is gone
  from CGGetOnlineDisplayList, so its id is only a last-known value and
  cannot be trusted to find it again; uuid is the selector that survives
  a replug or a wake, and `displays list` now reports both.
- `displays list` includes disconnected displays, marked connected:false.
  Without that half, `connect` could never name its target.
- `toggle` is collapsed into a concrete direction inside the pure
  `handle`, so the server never re-reads state and the decision is
  testable.
- Asking for the state a display is already in succeeds and changes
  nothing, so a button bound to connect or disconnect is safe to press
  twice.
- Unlike brightness these replies are not optimistic. Disconnecting can
  be legitimately refused (it would leave no active display), and a
  caller wiring this to a button needs to hear the reason, so the
  server's reply carries Crisp's own error.
- CrispControlDisplay gained a hand-written init(from:) so the two new
  fields decode as absent rather than throwing, keeping the forward
  compatibility the help text promises.

Verified on an M1 Pro, macOS 26.6.2, against a display the app was
already holding disconnected: `displays list` reports it at
connected:false while macOS reports one display; toggle brings it back
and system_profiler then reports two; toggle again returns to one. Exit
codes 0 / 2 / 3 confirmed for success, bad arguments and refusal.

make check: lint clean, 84 tests green, localization keys complete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@didriksg didriksg left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read this in full and it is the right shape: uuid as the selector, disconnected displays in the list so connect can name its target, toggle collapsed to a direction inside handle, idempotent replies, and non-optimistic errors because a disconnect can be legitimately refused. The tests cover the cases that matter, resolve by id and by uuid in any case included.

Three things before it can go in.

It conflicts with #81, which has just merged, in all four files. Both add uuid to CrispControlDisplay, #81 as String? with a nil-defaulting memberwise init, this as String with a hand-written decoder. Rebase onto main and adopt the optional; your resolve and the "no stable uuid" refusal work unchanged on uuid ?? "", and the custom init(from:) can go, since decodeIfPresent on an optional gives the same forward compatibility for free.

The last-screen guard has a gap that only a socket can reach. The server serves each connection in its own detached task, and disconnect checks wouldLeaveNoActiveDisplay synchronously, then awaits the transaction. Two disconnect requests fired at once for the last two displays both run their check while the first transaction is still pending, both see two active displays, both pass, both commit, and every screen goes dark. A person in the menu cannot do that; a script with two sockets open can. restoreIfNoActiveDisplay brings a screen back, but it should never be reachable. The fix belongs in the server: apply connection changes one at a time, so the second request sees the first one's result before its own guard runs.

The README still opens with "It supports exactly three commands" above a list that now has four.

Once it is rebased I will run it on the three panels here; a KVM desk is not something I can reproduce, but connect, disconnect and toggle by id and by uuid against real displays I can, and the two-sockets case too.

@didriksg

didriksg commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Ran the branch on my three displays tonight (merged onto main locally with uuid as String?, as above), straight off the socket. Connect, disconnect and toggle by id and by uuid all work, the log shows every transaction landing, and unknown selectors come back as exit 3.

The race I flagged is real. With one display already off, two disconnectDisplay requests for the remaining two on two sockets at the same instant both passed the guard and both reported success (387 ms and 1155 ms), and every screen went dark. restoreIfNoActiveDisplay brought the built-in back 2.5 s later, but the guard is the thing that should have refused the second one. Serialising connection changes in the server fixes it.

Two more things from the same run. crispctl's 2 s receive timeout is too short for these commands: a disconnect that took 1995 ms in the app came back to the client as "read failed: Resource temporarily unavailable" with exit 1, while the display had gone off. The app's own wrapper allows 10 s, so the client should wait at least that. And while every screen was dark, displays list returned the macOS placeholder (id 65, empty name, 1920x1080). That one is not yours, it comes from the same display list the existing command reads, but the new commands make it reachable, so filtering unnamed displays out of the list would be worth a line.

ncchen99 pushed a commit to ncchen99/Crisp that referenced this pull request Sep 3, 2026
The help text read like a man page pasted into a terminal: full invocations under a heading, then four paragraphs. It now prints what every comparable tool prints, a usage line, an aligned command table with one-line descriptions, and a short footer with the output format and exit codes. The long explanations live in the README section, which already said most of it.

Two changes ride along, both cheap while nothing has shipped. `displays list` becomes `display list`, so every command uses the same singular noun as the display commands proposed in didriksg#97. And `brightness get` and `brightness set` take a uuid as well as a runtime id, through a `selector` field on the request that the app resolves id first, then uuid in any case; the numeric `display` field still works for clients that already send it. The resolver is written to match didriksg#97's, so that PR can adopt it on rebase.

Verified live on the Dell U2412M: get and set by id, by uuid in both cases, unknown selectors refused with exit 3, and the old command name rejected with the usage line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NDzD18GySiCwEGfWJiT4J
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.

2 participants