crispctl: connect, disconnect and toggle a physical display - #97
crispctl: connect, disconnect and toggle a physical display#97celsinho17 wants to merge 1 commit into
Conversation
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
left a comment
There was a problem hiding this comment.
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.
|
Ran the branch on my three displays tonight (merged onto main locally with uuid as The race I flagged is real. With one display already off, two 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, |
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
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
PhysicalDisplayToggleServicealready does perfectly, just not from anywhere a script can reach.What
No new capability: the service does the work, this exposes it.
Shape, and why
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 listnow reports both.displays listincludes disconnected displays, markedconnected:false. Without that half,connectcould never name its target — its display is absent from every macOS list by construction.togglecollapses into a concrete direction inside the purehandle, so the server never re-reads state and the decision stays unit-testable.connectordisconnectis safe to press twice.CrispControlDisplaygained a hand-writteninit(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_profilerused as an independent witness rather than trusting Crisp's own report:Exit codes confirmed:
0success,2bad arguments,3refusal.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.