- )}
+ {info && (!info.nodeEnabled || info.reason) && }
{info && subshell && (
{nodeReasonLine(info.reason)}
- )}
+ {info && (!info.nodeEnabled || info.reason) && }
{info?.reason === "preview-disabled" && subshell && (
{
expect(changes).toEqual(["xvfb-x11vnc"]);
expect(virtualDisplayReason({ ...linux, virtualDisplayProviders: [] }, ready, "xvfb-x11vnc")).toContain("disabled");
});
+ it("offers only the providers this machine's OS can run", async () => {
+ // The stored policy is the owner's and can hold both, so the row filters by
+ // the node's reported OS: Xvfb is Linux's display server, and offering it on
+ // a Mac sends the person to a launch the machine refuses.
+ const paths: string[] = [];
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ paths.push(String(input));
+ return new Response(JSON.stringify(ready));
+ }) as typeof fetch;
+ render(
+ {}}
+ id="display"
+ checked
+ onChange={() => {}}
+ />,
+ { wrapper },
+ );
+ await waitFor(() => expect(paths.some((p) => p.endsWith("?provider=betterdisplay"))).toBe(true));
+ fireEvent.click(screen.getByRole("combobox", { name: "Display provider" }));
+ expect(await screen.findByRole("option", { name: "BetterDisplay" })).toBeTruthy();
+ expect(screen.queryByRole("option", { name: "Xvfb + VNC" })).toBeNull();
+ });
it("fails closed when a check fails and recovers on recheck", async () => {
respond({ message: "Bring this node online, then recheck." }, 409);
render( {}} />, { wrapper });
diff --git a/apps/server/web/src/components/subshell-picker/virtual-display-field.tsx b/apps/server/web/src/components/subshell-picker/virtual-display-field.tsx
index 4408d317c..d00cba35e 100644
--- a/apps/server/web/src/components/subshell-picker/virtual-display-field.tsx
+++ b/apps/server/web/src/components/subshell-picker/virtual-display-field.tsx
@@ -1,5 +1,5 @@
import { Button, Label, type Node } from "@internal/node-admin";
-import { DEFAULT_VIRTUAL_DISPLAY_PROVIDER } from "@internal/subshell-protocol";
+import { DEFAULT_VIRTUAL_DISPLAY_PROVIDER, displayProviderRunsOnOs } from "@internal/subshell-protocol";
import { Link } from "@tanstack/react-router";
import { DisplayMissingRequirements } from "@/components/display-missing-requirements";
import { Checkbox } from "@/components/ui/checkbox";
@@ -27,7 +27,17 @@ export function VirtualDisplayField({
resetReason?: string;
}) {
const { check, reason, needsSetup, shouldCheck } = useNodeDisplayReadiness(node, provider);
- const providers = node?.virtualDisplayProviders ?? [DEFAULT_VIRTUAL_DISPLAY_PROVIDER];
+ // A provider this machine's OS cannot drive is not a choice, so it is not
+ // offered: Xvfb is Linux's display server and BetterDisplay is macOS's, and a
+ // list that shows the other one sends the person to a launch the machine
+ // refuses. The stored list is the owner's, so this narrows what the row shows
+ // rather than rewriting it, and an id the catalog does not ship stays put for
+ // the node's own readiness answer to rule.
+ const providers = (node?.virtualDisplayProviders ?? [DEFAULT_VIRTUAL_DISPLAY_PROVIDER]).filter((id) =>
+ displayProviderRunsOnOs(id, node?.os),
+ );
+ const nodeDefault = node?.virtualDisplayProvider;
+ const defaultProvider = nodeDefault && displayProviderRunsOnOs(nodeDefault, node?.os) ? nodeDefault : undefined;
const control = (
)}
diff --git a/packages/subshell-protocol/src/__tests__/display-providers.test.ts b/packages/subshell-protocol/src/__tests__/display-providers.test.ts
index 924b6c4dd..285f883e5 100644
--- a/packages/subshell-protocol/src/__tests__/display-providers.test.ts
+++ b/packages/subshell-protocol/src/__tests__/display-providers.test.ts
@@ -1,7 +1,38 @@
import { expect, it } from "bun:test";
-import { parseDisplayProviderIds, parseVirtualDisplayRequest, storedDisplayProviderIds } from "../display-providers.js";
+import {
+ DEFAULT_VIRTUAL_DISPLAY_PROVIDER,
+ displayProviderRunsOnOs,
+ displayProvidersForOs,
+ parseDisplayProviderIds,
+ parseVirtualDisplayRequest,
+ storedDisplayProviderIds,
+ VIRTUAL_DISPLAY_PROVIDERS,
+} from "../display-providers.js";
import { parseNodeCommandBody, parseNodePreviewEnabled } from "../node-frames.js";
+it("names one OS per bundled provider, and the pickers ask that OS", () => {
+ // A provider with no platform here is offered everywhere, which is the
+ // defect: the launch dialog showed Xvfb + VNC on a Mac node, and the person
+ // found out at launch. Adding a provider means saying what it runs on.
+ for (const provider of VIRTUAL_DISPLAY_PROVIDERS) {
+ expect(provider.platform).toBeOneOf(["linux", "darwin"]);
+ }
+ expect(displayProvidersForOs("linux").map((p) => p.id)).toEqual(["xvfb-x11vnc"]);
+ expect(displayProvidersForOs("darwin").map((p) => p.id)).toEqual(["betterdisplay"]);
+ // A node that never came ready has no OS, and guessing the default would be
+ // guessing Linux.
+ expect(displayProvidersForOs(null)).toEqual([]);
+ expect(displayProvidersForOs("windows")).toEqual([]);
+ // A stored list is the owner's, so filtering it keeps what the OS runs and
+ // keeps an id this package does not ship: a provider the node installed out
+ // of band has no platform fact here, and its own readiness answer rules.
+ expect(displayProviderRunsOnOs("xvfb-x11vnc", "darwin")).toBe(false);
+ expect(displayProviderRunsOnOs("betterdisplay", "darwin")).toBe(true);
+ expect(displayProviderRunsOnOs("native-example", "darwin")).toBe(true);
+ expect(displayProviderRunsOnOs("native-example", null)).toBe(true);
+ expect(DEFAULT_VIRTUAL_DISPLAY_PROVIDER).toBe("xvfb-x11vnc");
+});
+
it("preserves explicit providers on readiness commands and signed policy", () => {
const check = { type: "preview_display_check", provider: "native-example" } as const;
expect(parseNodeCommandBody(check)).toEqual(check);
diff --git a/packages/subshell-protocol/src/display-providers.ts b/packages/subshell-protocol/src/display-providers.ts
index ab86e4ccb..b4de4847f 100644
--- a/packages/subshell-protocol/src/display-providers.ts
+++ b/packages/subshell-protocol/src/display-providers.ts
@@ -1,14 +1,52 @@
+/**
+ * The operating system one bundled provider can drive. It sits in the catalog
+ * rather than in each surface that asks, because the answer is a fact about the
+ * provider: Xvfb is Linux's display server and BetterDisplay is macOS's, and a
+ * picker that offers the other one is offering a choice the machine refuses.
+ */
+export type DisplayProviderPlatform = "linux" | "darwin";
+
/** Bundled display choices. Add implementations to pane-runtime's registry too. */
export const VIRTUAL_DISPLAY_PROVIDERS = [
- { id: "xvfb-x11vnc", name: "Xvfb + VNC", description: "Private Linux displays using Xvfb and x11vnc." },
+ {
+ id: "xvfb-x11vnc",
+ name: "Xvfb + VNC",
+ description: "Private Linux displays using Xvfb and x11vnc.",
+ platform: "linux",
+ },
{
id: "betterdisplay",
name: "BetterDisplay",
description: "macOS virtual screens using BetterDisplay and the opt-in Subshell Display Helper.",
+ platform: "darwin",
},
] as const;
export const DEFAULT_VIRTUAL_DISPLAY_PROVIDER = "xvfb-x11vnc";
+/**
+ * The bundled providers a node reporting this OS can run. An OS that is
+ * unknown, because the node never came ready, or is one no provider supports
+ * matches NOTHING: the honest answer there is no choice rather than the
+ * Linux default handed to a Mac.
+ */
+export function displayProvidersForOs(
+ os: string | null | undefined,
+): readonly (typeof VIRTUAL_DISPLAY_PROVIDERS)[number][] {
+ return VIRTUAL_DISPLAY_PROVIDERS.filter((provider) => provider.platform === os);
+}
+
+/**
+ * Whether one id from a node's stored provider list may be offered for that OS.
+ * A bundled provider answers from the catalog. An id this package does not ship
+ * belongs to the node itself, and no surface here has a fact about its platform,
+ * so it stays on the list and the node's own readiness answer rules it in or
+ * out: refusing what we cannot place would hide a provider the machine has.
+ */
+export function displayProviderRunsOnOs(id: string, os: string | null | undefined): boolean {
+ const bundled = VIRTUAL_DISPLAY_PROVIDERS.find((provider) => provider.id === id);
+ return bundled ? bundled.platform === os : true;
+}
+
/** Identifiers are bounded on the wire so older nodes can refuse unknown providers. */
export function isDisplayProviderId(value: unknown): value is string {
return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
From 81c813defa8ab76e0d44c8c7f1f215edcb79a28e Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 13:49:58 -0700
Subject: [PATCH 003/120] chore: add the changeset for the display rail entry
---
.changeset/virtual-display-rail-entry.md | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 .changeset/virtual-display-rail-entry.md
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
new file mode 100644
index 000000000..1931bcda5
--- /dev/null
+++ b/.changeset/virtual-display-rail-entry.md
@@ -0,0 +1,10 @@
+---
+"@internal/desktop-client": minor
+"@internal/desktop-server": minor
+"@internal/server": patch
+"@internal/docs": patch
+---
+
+Add a **Virtual Displays** section to both desktop assistants' sidebar, shown only where a display helper exists, so the helper is reachable from the window a person is already looking at instead of only from the macOS menu bar. The screen carries the steps the menu item never showed (enable the helper, grant Screen Recording and Accessibility to Subshell Display Helper, and what to do when macOS asks for a restart) above one button that opens the helper's own settings panel and a link to the setup guide. Document the sidebar as the way in.
+
+In the web dashboard, a node that cannot serve previews now says so in gold when the reason is its **Web previews & screens** switch, which an owner can turn on, with a link to the guide that says how; an offline or outdated node stays muted, because no setting fixes that. A launch form, a preset, and the provider list no longer offer a virtual display provider the selected machine's operating system cannot run.
From f046b30f1ba564bf842f6a38e0bddd003b8b2d7b Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 23:13:28 -0700
Subject: [PATCH 004/120] fix(preview): say which gate refused a display, and
keep the children's output
A slow or loaded Linux node answered launch-with-screen with a bare
'not answering within 5000ms' and then reaped the pane dir that held
Xvfb.log and x11vnc.log, leaving nothing to diagnose. The refusal now
names which of the three readiness gates was still shut (Xvfb socket,
rfb bind, listener ownership), the provider logs both children's tails
to the daemon journal before the teardown, and the budget is 10s since
the launch command has 60s on the wire anyway.
---
.../src/__tests__/preview-display.test.ts | 55 ++++++++++++++-
.../src/preview/xvfb-display-provider.ts | 67 +++++++++++++++++--
2 files changed, 115 insertions(+), 7 deletions(-)
diff --git a/apps/node/agent/src/__tests__/preview-display.test.ts b/apps/node/agent/src/__tests__/preview-display.test.ts
index 061b4c040..91b45b760 100644
--- a/apps/node/agent/src/__tests__/preview-display.test.ts
+++ b/apps/node/agent/src/__tests__/preview-display.test.ts
@@ -139,7 +139,9 @@ function makeDeps(over: DepOverrides = {}) {
};
let pidSeq = 4100;
let clock = Date.now();
+ const lines: string[] = [];
const deps: DisplayDeps = {
+ log: (line) => lines.push(line),
spawnSync: (argv) => {
calls.push({ kind: "spawnSync", argv });
if (String(argv[0]).endsWith("xauth")) writeFileSync(String(argv[2]), "", { mode: 0o600 });
@@ -150,6 +152,10 @@ function makeDeps(over: DepOverrides = {}) {
alivePids.add(child.pid);
const rfbIdx = argv.indexOf("-rfbport");
if (over.x11vncListens !== false && rfbIdx >= 0) busy.add(Number(argv[rfbIdx + 1]));
+ // The real spawn appends both child streams to `logPath`; a refusal
+ // reads those tails back into the daemon log, so the double has to put
+ // something there or the tail lines would be testing their own absence.
+ writeFileSync(opts.logPath, `# fake ${String(argv[0]).split("/").pop()} output\n`, { flag: "a" });
const jsonFile = join(opts.logPath, "..", "display.json");
calls.push({
kind: "spawn",
@@ -189,7 +195,7 @@ function makeDeps(over: DepOverrides = {}) {
calls.filter((c): c is Extract => c.kind === "spawn");
const killPids = (): number[] => calls.flatMap((c) => (c.kind === "kill" && c.signal !== 0 ? [c.pid] : []));
const probes = (): number[] => calls.flatMap((c) => (c.kind === "kill" && c.signal === 0 ? [c.pid] : []));
- return { deps, calls, busy, alivePids, spawns, killPids, probes, advanceClock: (ms: number) => (clock += ms) };
+ return { deps, calls, busy, alivePids, spawns, killPids, probes, lines, advanceClock: (ms: number) => (clock += ms) };
}
/** Let the resolved `exited` promises run their watch handlers. */
@@ -430,6 +436,53 @@ describe("PreviewDisplays.openFor", () => {
expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false);
});
+ it("names which readiness gate failed when a display is refused", async () => {
+ // The bare "not answering" sentence sent operators to check the wrong
+ // program: a missing X socket, a port that never binds, and a listener
+ // owned by someone else look nothing alike on a slow box. The refusal
+ // must say which of the three gates was still shut when the budget ran
+ // out, so the node's own Xvfb.log / x11vnc.log tail (the next case)
+ // points at the right suspect.
+ const socket = makeDeps({ xSocketReady: false, readyBudgetMs: 200 });
+ const socketReg = new PreviewDisplays(onDir("gate-socket"), socket.deps);
+ await expect(socketReg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow("Xvfb socket never appeared");
+
+ const bind = makeDeps({ x11vncListens: false, readyBudgetMs: 200 });
+ const bindReg = new PreviewDisplays(onDir("gate-bind"), bind.deps);
+ await expect(bindReg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow("rfb port answered nothing");
+
+ const owner = makeDeps({ readyBudgetMs: 200 });
+ const ownerReg = new PreviewDisplays(onDir("gate-owner"), { ...owner.deps, ownsRfbListener: () => false });
+ await expect(ownerReg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(
+ "listener not owned by the x11vnc pid",
+ );
+ });
+
+ it("logs both children's output when a display is refused, before the dir is reaped", async () => {
+ // The refusal reaps the pane dir, which held Xvfb.log and x11vnc.log -
+ // so without this, a slow or broken box left NO trace of why (the AI PC
+ // failure had nothing to read). The provider must hand both tails to its
+ // `log` dep before teardown so the daemon journal keeps the evidence.
+ const dataDir = onDir("refusal-tails");
+ const { deps, lines } = makeDeps({ x11vncListens: false, readyBudgetMs: 200 });
+ const reg = new PreviewDisplays(dataDir, deps);
+ await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(PREVIEW_DISPLAY_NOT_READY);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false);
+ expect(lines.some((line) => line.includes("Xvfb.log tail") && line.includes("# fake Xvfb output"))).toBe(true);
+ expect(lines.some((line) => line.includes("x11vnc.log tail") && line.includes("# fake x11vnc output"))).toBe(true);
+ });
+
+ it("stays silent about tails when the refusal predates both children", async () => {
+ // An xauth mint that fails throws before anything is spawned: the two
+ // logs are empty pre-created files, and dumping "(empty)" twice is noise
+ // in the very journal the evidence is meant to enrich. No children were
+ // started, so no lines about them.
+ const { deps, lines } = makeDeps({ xauthRc: 1 });
+ const reg = new PreviewDisplays(onDir("refusal-early"), deps);
+ await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(PREVIEW_DISPLAY_NOT_READY);
+ expect(lines.some((line) => line.includes("tail"))).toBe(false);
+ });
+
it.skipIf(process.platform === "linux")(
"openFor refuses preview-display-unsupported on non-Linux, before touching fs",
async () => {
diff --git a/packages/pane-runtime/src/preview/xvfb-display-provider.ts b/packages/pane-runtime/src/preview/xvfb-display-provider.ts
index a8bb89f97..d7be123ad 100644
--- a/packages/pane-runtime/src/preview/xvfb-display-provider.ts
+++ b/packages/pane-runtime/src/preview/xvfb-display-provider.ts
@@ -68,10 +68,20 @@ const DISPLAY_MAX = 199;
/** Where the RFB chooser starts. 5900-5909 stay the range of real X displays. */
const RFB_PORT_MIN = 5910;
const RFB_PORT_PROBES = 100;
-/** How long Xvfb and x11vnc get to come up before the allocation is refused. */
-const DEFAULT_READY_BUDGET_MS = 5000;
+/**
+ * How long Xvfb and x11vnc get to come up before the allocation is refused.
+ * Measured at well under a second on a warm box, but a cold or loaded node has
+ * been seen missing it (the `preview-display-not-ready` report), and the
+ * plane gives the whole launch command 60 s, so a slow first spawn costs a
+ * later refusal, never a stranded pane. The number the AI PC tripped over was
+ * 5000; doubled rather than tuned blind, because the refusal now also names
+ * its gate and logs both children's output.
+ */
+const DEFAULT_READY_BUDGET_MS = 10_000;
/** Readiness poll cadence. */
const READY_POLL_MS = 100;
+/** How many trailing lines each child log keeps in the daemon log on a refusal. */
+const REFUSAL_TAIL_LINES = 30;
/**
* The X auth file for one pane's display, byte-derived from `dataDir` the
@@ -352,8 +362,13 @@ export class XvfbDisplayProvider implements VirtualDisplayProvider {
this.say(`preview display ${display}: opened for pane ${paneId} (rfb 127.0.0.1:${rfbPort})`);
return { display, viewer: { protocol: "vnc", port: rfbPort } };
} catch (err: unknown) {
- // Whatever started, none of it outlives the refusal. teardown reads only
- // the child pids, so the fields the failure never reached stay zeroed.
+ // Whatever started, none of it outlives the refusal. The pane dir holds
+ // the only copy of what Xvfb and x11vnc said about WHY they did not
+ // come up, and teardown reaps it seconds later - so the tails ride the
+ // daemon log first. A refusal that predates the children (the xauth
+ // mint, the binaries, the lock) started nothing, so it says nothing
+ // about them.
+ if (xvfbChild) this.logRefusalTails(paneDir, display, paneId);
this.teardown(
{
paneId,
@@ -657,16 +672,40 @@ export class XvfbDisplayProvider implements VirtualDisplayProvider {
): Promise {
const start = d.now();
for (;;) {
- if (d.xSocketReady(displayNumber) && (await d.bindProbe(rfbPort)) && d.ownsRfbListener(vncPid, rfbPort)) return;
+ const xSocket = d.xSocketReady(displayNumber);
+ const rfb = xSocket && (await d.bindProbe(rfbPort));
+ if (rfb && d.ownsRfbListener(vncPid, rfbPort)) return;
if (d.now() - start >= d.readyBudgetMs) {
+ // Name the gate that was still shut: the three failures (Xvfb never
+ // made its socket, x11vnc never bound the port, and someone else
+ // owns the listener) want different eyes on the child logs below.
+ const gate = !xSocket
+ ? "Xvfb socket never appeared"
+ : !rfb
+ ? "rfb port answered nothing"
+ : "listener not owned by the x11vnc pid";
throw new Error(
- `${PREVIEW_DISPLAY_NOT_READY}: display :${displayNumber} / rfb ${rfbPort} not answering within ${d.readyBudgetMs}ms`,
+ `${PREVIEW_DISPLAY_NOT_READY}: display :${displayNumber} / rfb ${rfbPort} not answering within ${d.readyBudgetMs}ms (${gate})`,
);
}
await d.sleep(READY_POLL_MS);
}
}
+ /**
+ * Hand both children's output to the daemon log before the refusal's
+ * teardown reaps the dir that holds it. Each log keeps its last
+ * {@link REFUSAL_TAIL_LINES} lines; an absent or empty file says nothing,
+ * literally - a child that wrote no words is not worth a journal line, and
+ * a launch refused before the spawns reads no files at all.
+ */
+ private logRefusalTails(paneDir: string, display: string, paneId: string): void {
+ for (const name of ["Xvfb.log", "x11vnc.log"] as const) {
+ const tail = readTail(join(paneDir, name));
+ if (tail) this.say(`preview display ${display} for pane ${paneId}: ${name} tail:\n${tail}`);
+ }
+ }
+
/**
* One child's exit watch (spec §9). The ENTRY-IDENTITY guard is the whole
* story: `closeFor` and both sweep passes delete the entry BEFORE killing,
@@ -753,6 +792,22 @@ function writeRecord(dir: string, rec: Record): void {
chmodSync(file, 0o600); // the mode is create-only; every rewrite re-pins it
}
+/**
+ * The last {@link REFUSAL_TAIL_LINES} non-empty lines of a file, or `""` for
+ * one that is absent, unreadable, or holds nothing but whitespace. The
+ * refusal-diagnosis path reads a child's own log off disk seconds before it
+ * is reaped, so a missing or empty file must be a quiet no, never a throw.
+ */
+function readTail(path: string): string {
+ try {
+ const content = readFileSync(path, "utf8");
+ const lines = content.split("\n").filter((line) => line.trim().length > 0);
+ return lines.slice(-REFUSAL_TAIL_LINES).join("\n");
+ } catch {
+ return "";
+ }
+}
+
function readRecord(dir: string): DisplayRecord | null {
try {
const parsed = JSON.parse(readFileSync(join(dir, "display.json"), "utf8")) as unknown;
From 5e329567dce36b2c6e59a2483b4c5e3f5c5fa20b Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 23:13:30 -0700
Subject: [PATCH 005/120] fix(web): put a virtual-display launch refusal at its
checkbox
The create failure rendered in one form-level slot under 'Save as
preset' no matter which control caused it. screenLaunchRemedy now
always names the node's refusal word (bare form included) and the
switch preflight carries preview-node-off, so the dashboard can match
them (isDisplayLaunchRefusal) and route the refusal into the Virtual
display field's own error slot, wired into the checkbox. Everything
else keeps the form-level line.
---
.../__tests__/preview-routes-auth.test.ts | 13 +++----
.../api/src/modules/previews/refusals.ts | 10 +++--
.../modules/subshells/subshells.service.ts | 2 +-
.../sidebar/launch-subshell-dialog.tsx | 8 +++-
.../sidebar/new-workspace-dialog.tsx | 14 +++++--
.../__tests__/new-subshell-form.test.tsx | 29 ++++++++++++++-
.../__tests__/virtual-display-field.test.tsx | 22 +++++++++++
.../subshell-picker/add-subshell-dialog.tsx | 9 +++--
.../subshell-picker/new-subshell-form.tsx | 10 +++++
.../subshell-picker/virtual-display-field.tsx | 15 ++++++++
.../__tests__/create-subshell-error.test.ts | 37 ++++++++++++++++++-
.../web/src/lib/create-subshell-error.ts | 24 ++++++++++++
12 files changed, 171 insertions(+), 22 deletions(-)
diff --git a/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts
index ffac0325d..b32d8f356 100644
--- a/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts
+++ b/apps/server/api/src/api/subshells/__tests__/preview-routes-auth.test.ts
@@ -1069,15 +1069,14 @@ describe("preview REST surface (Task 9)", () => {
// The node-side half of the screen gate: refusals that arrive FROM the
// wire, which the service preflight cannot see (an older node, or a
- // switch flipped mid-race). Each row's `echo` says whether the remedy
- // matcher appends the node's own detail: it does only for a SUFFIXED form
- // (the missing-binaries pair). The bare word and the node-off word match
- // their keys exactly, and the node-off key already IS its remedy string,
- // so neither is appended to.
+ // switch flipped mid-race). Every row echoes the node's own detail, bare
+ // form included: the word is what an operator greps in the daemon log, and
+ // what `isDisplayLaunchRefusal` (apps/server/web) matches to render the
+ // refusal beside the Virtual display control that asked for the screen.
const SCREEN_REFUSALS: ReadonlyArray = [
- ["preview-display-unsupported", /provider is not supported/, false],
+ ["preview-display-unsupported", /provider is not supported/, true],
["preview-display-missing-requirements: x11vnc", /provider has unmet requirements/, true],
- ["preview-node-off: turn on Preview for this node", /Turn on Preview for this node/i, false],
+ ["preview-node-off: turn on Preview for this node", /Turn on Preview for this node/i, true],
];
it.each(SCREEN_REFUSALS)(
diff --git a/apps/server/api/src/modules/previews/refusals.ts b/apps/server/api/src/modules/previews/refusals.ts
index cbb1ab84c..b7203471d 100644
--- a/apps/server/api/src/modules/previews/refusals.ts
+++ b/apps/server/api/src/modules/previews/refusals.ts
@@ -66,13 +66,17 @@ export const SCREEN_LAUNCH_REMEDIES: Readonly> = {
/**
* The remedy for a screen launch the NODE refused: the paired sentence when
- * the word is known (plus the node's own detail, verbatim), null for detail
- * this mirror does not speak.
+ * the word is known, plus the node's own detail verbatim. The word ALWAYS
+ * rides along, bare form included: the sentence explains, the word is what a
+ * reader greps and what the dashboard matches to decide the refusal belongs
+ * beside the Virtual display control rather than at the bottom of the form
+ * (`isDisplayLaunchRefusal`). A refusal this mirror does not speak is null,
+ * untouched.
*/
export function screenLaunchRemedy(detail: string): string | null {
for (const [word, remedy] of Object.entries(SCREEN_LAUNCH_REMEDIES)) {
if (detail === word || detail.startsWith(`${word}: `)) {
- return detail === word ? remedy : `${remedy} (${detail})`;
+ return `${remedy} (${detail})`;
}
}
return null;
diff --git a/apps/server/api/src/modules/subshells/subshells.service.ts b/apps/server/api/src/modules/subshells/subshells.service.ts
index ebbd79b17..03681f4d2 100644
--- a/apps/server/api/src/modules/subshells/subshells.service.ts
+++ b/apps/server/api/src/modules/subshells/subshells.service.ts
@@ -684,7 +684,7 @@ export class SubshellsService extends BaseService {
if (screen && !(await getVirtualDisplayEnabled(resolvedNodeId))) {
throw new SubshellCreateError(
"bad_request",
- "Virtual displays are disabled on this node. Enable Allow virtual displays in node settings, or launch without a virtual display.",
+ "Virtual displays are disabled on this node. Enable Allow virtual displays in node settings, or launch without a virtual display. (preview-node-off)",
400,
);
}
diff --git a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx
index 1487ca713..8267d9b04 100644
--- a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx
+++ b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx
@@ -23,7 +23,7 @@ import {
} from "@/components/ui/dialog";
import { useCreateSubshell } from "@/hooks/use-create-subshell";
import { invalidateWorkspaceMembership } from "@/hooks/use-workspaces";
-import { createSubshellErrorMessage } from "@/lib/create-subshell-error";
+import { createSubshellErrorMessage, isDisplayLaunchRefusal } from "@/lib/create-subshell-error";
import { currentWorkspaceId } from "@/lib/current-workspace";
import { SUBSHELLS_QUERY_KEY } from "@/lib/query-keys";
import type { sshConnectSearch } from "@/lib/ssh-connect-search";
@@ -61,6 +61,9 @@ export function LaunchSubshellDialog({
...(initialNodeId ? { nodeId: initialNodeId } : {}),
}));
const [error, setError] = useState(null);
+ // A display refusal reads as the checkbox's own result, so the field
+ // renders it; everything else keeps the form-level line.
+ const displayLaunchError = error !== null && isDisplayLaunchRefusal(error) ? error : null;
function reset() {
setSshDraft(sshSessionDraft(initialSsh));
@@ -166,8 +169,9 @@ export function LaunchSubshellDialog({
value={form}
onChange={setForm}
onLeave={dismiss}
+ launchError={displayLaunchError}
/>
- {error &&
{error}
}
+ {error && !displayLaunchError &&
{error}
}
)}
- {error &&
{error}
}
+ {error && !displayLaunchError &&
{error}
}
{!createdId && selected.length > 0 && (
{selected.length === 1 ? "1 subshell" : `${selected.length} subshells`} to add
diff --git a/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx
index 008d75ea4..5da1b8a5e 100644
--- a/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx
+++ b/apps/server/web/src/components/subshell-picker/__tests__/new-subshell-form.test.tsx
@@ -79,12 +79,12 @@ async function settle(): Promise {
}
/** A controlled parent like /new and the dialog. */
-async function renderForm(initial: NewSubshellFormValue) {
+async function renderForm(initial: NewSubshellFormValue, launchError?: string | null) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function Harness() {
const [value, setValue] = useState(initial);
setter = setValue;
- return ;
+ return ;
}
const rootRoute = createRootRoute();
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", component: Harness });
@@ -147,6 +147,31 @@ describe("NewSubshellForm working-dir pre-fill", () => {
});
});
+describe("NewSubshellForm launch-error placement", () => {
+ afterEach(cleanup);
+
+ it("hands a display refusal to the Virtual display field", async () => {
+ // The dialog's split (`isDisplayLaunchRefusal` at the call sites) picks
+ // which line is the field's; this pins the form actually threading it
+ // down, so a refusal like the AI PC's lands beside its checkbox.
+ const restore = mockEndpoints([]);
+ try {
+ await renderForm(
+ emptyNewSubshellForm(),
+ "API 400: That node's virtual display did not come up inside the launch budget, so the screen pane was refused. Try again, or launch without a screen. (preview-display-not-ready: display :90 / rfb 5910 not answering within 10000ms (Xvfb socket never appeared))",
+ );
+ const alert = await screen.findByRole("alert");
+ expect(alert.getAttribute("id")).toBe("picker-virtual-display-launch-error");
+ expect(alert.textContent).toContain("preview-display-not-ready");
+ expect(screen.getByRole("checkbox", { name: "Virtual display" }).getAttribute("aria-describedby")).toContain(
+ "picker-virtual-display-launch-error",
+ );
+ } finally {
+ restore();
+ }
+ });
+});
+
/** A well-formed agent-node row the picker can select. */
const BOX = {
id: "node-9",
diff --git a/apps/server/web/src/components/subshell-picker/__tests__/virtual-display-field.test.tsx b/apps/server/web/src/components/subshell-picker/__tests__/virtual-display-field.test.tsx
index ff3d4771c..ce72d24cc 100644
--- a/apps/server/web/src/components/subshell-picker/__tests__/virtual-display-field.test.tsx
+++ b/apps/server/web/src/components/subshell-picker/__tests__/virtual-display-field.test.tsx
@@ -184,3 +184,25 @@ it("preserves a selected display while revalidating the same machine", async ()
await waitFor(() => expect(screen.getByRole("checkbox").getAttribute("aria-disabled")).not.toBe("true"));
expect(changes).toEqual([]);
});
+
+describe("launch refusals at the control", () => {
+ const refusal =
+ "That node's virtual display did not come up inside the launch budget, so the screen pane was refused. Try again, or launch without a screen. (preview-display-not-ready: display :90 / rfb 5910 not answering within 10000ms (Xvfb socket never appeared))";
+ it("renders the refusal beside the checkbox and names it to assistive tech", async () => {
+ respond(ready);
+ render( {}} launchError={refusal} />, {
+ wrapper,
+ });
+ const alert = await screen.findByRole("alert");
+ expect(alert.textContent).toContain("preview-display-not-ready");
+ expect(alert.getAttribute("id")).toBe("display-launch-error");
+ expect(screen.getByRole("checkbox").getAttribute("aria-describedby")).toContain("display-launch-error");
+ });
+ it("says nothing when no launch was refused", async () => {
+ respond(ready);
+ render( {}} />, { wrapper });
+ await waitFor(() => expect(screen.getByRole("checkbox").getAttribute("aria-disabled")).not.toBe("true"));
+ expect(screen.queryByRole("alert") === null).toBe(true);
+ expect((screen.getByRole("checkbox").getAttribute("aria-describedby") ?? "").includes("launch-error")).toBe(false);
+ });
+});
diff --git a/apps/server/web/src/components/subshell-picker/add-subshell-dialog.tsx b/apps/server/web/src/components/subshell-picker/add-subshell-dialog.tsx
index 1b0f41101..38d890658 100644
--- a/apps/server/web/src/components/subshell-picker/add-subshell-dialog.tsx
+++ b/apps/server/web/src/components/subshell-picker/add-subshell-dialog.tsx
@@ -23,7 +23,7 @@ import {
import { Segmented } from "@/components/ui/segmented";
import { useCreateSubshell } from "@/hooks/use-create-subshell";
import { useSubshellsList } from "@/hooks/use-subshells";
-import { createSubshellErrorMessage } from "@/lib/create-subshell-error";
+import { createSubshellErrorMessage, isDisplayLaunchRefusal } from "@/lib/create-subshell-error";
import type { SplitDirection } from "@/types/workspace";
/** Which half of the dialog is showing. */
@@ -87,6 +87,9 @@ export function AddSubshellDialog({
const [busyId, setBusyId] = useState(null);
const [creating, setCreating] = useState(false);
const [error, setError] = useState(null);
+ // A refusal naming the virtual display is the checkbox's own result, so it
+ // renders inside the field; everything else keeps the form-level line.
+ const displayLaunchError = error !== null && isDisplayLaunchRefusal(error) ? error : null;
const excluded = new Set(excludeSubshellIds);
const available = (subshells ?? []).filter((s) => s.crossAgent !== true && !excluded.has(s.id));
@@ -242,12 +245,12 @@ export function AddSubshellDialog({
}}
/>
) : (
-
+
)}
)}
- {error &&
{error}
}
+ {error && !displayLaunchError &&
{error}
}
{visibleMode === "new" && kind === "agent" && (
diff --git a/apps/server/web/src/components/subshell-picker/new-subshell-form.tsx b/apps/server/web/src/components/subshell-picker/new-subshell-form.tsx
index 289f8d286..386e636bf 100644
--- a/apps/server/web/src/components/subshell-picker/new-subshell-form.tsx
+++ b/apps/server/web/src/components/subshell-picker/new-subshell-form.tsx
@@ -85,11 +85,20 @@ export function NewSubshellForm({
firstRun = false,
skipPriorLaunch = false,
onLeave,
+ launchError,
}: {
value: NewSubshellFormValue;
onChange: (value: NewSubshellFormValue) => void;
/** Field element ids; defaults to the dialog's (e2e-pinned) set. */
ids?: NewSubshellFormIds;
+ /**
+ * The create failure this launch's Virtual display control owns, when the
+ * server refused the display itself (`isDisplayLaunchRefusal` picks these
+ * out). It renders under the checkbox rather than in the dialog's
+ * form-level line: the refusal answers the one control that asked for the
+ * screen, and a warning belongs at the decision point.
+ */
+ launchError?: string | null;
/**
* The setup assistant's first launch: the Agent gets the one hint that
* teaches the word, and the Preset row is hidden (there are no presets yet
@@ -404,6 +413,7 @@ export function NewSubshellForm({
node={selectedNode}
id={ids.virtualDisplay}
resetReason={value.displayResetReason}
+ launchError={launchError}
provider={value.displayProvider}
onProviderChange={(displayProvider) => onChange({ ...value, displayProvider, displayResetReason: undefined })}
checked={value.virtualDisplay === true}
diff --git a/apps/server/web/src/components/subshell-picker/virtual-display-field.tsx b/apps/server/web/src/components/subshell-picker/virtual-display-field.tsx
index d00cba35e..cb4e2cbda 100644
--- a/apps/server/web/src/components/subshell-picker/virtual-display-field.tsx
+++ b/apps/server/web/src/components/subshell-picker/virtual-display-field.tsx
@@ -16,6 +16,7 @@ export function VirtualDisplayField({
provider,
onProviderChange,
resetReason,
+ launchError,
}: {
node: Node | null;
id: string;
@@ -25,6 +26,14 @@ export function VirtualDisplayField({
provider?: string;
onProviderChange?: (provider: string | undefined) => void;
resetReason?: string;
+ /**
+ * The launch this control lost: a create the node refused over the display
+ * (the budget, a provider that does not run here, the switch). It arrives
+ * from the dialog because the refusal names the whole launch, but it reads
+ * as this checkbox's own result, so it renders here, not at the form's
+ * foot. `isDisplayLaunchRefusal` decides which errors are this kind.
+ */
+ launchError?: string | null;
}) {
const { check, reason, needsSetup, shouldCheck } = useNodeDisplayReadiness(node, provider);
// A provider this machine's OS cannot drive is not a choice, so it is not
@@ -50,6 +59,7 @@ export function VirtualDisplayField({
`${id}-cost`,
...(reason ? [`${id}-reason`] : []),
...(resetReason ? [`${id}-reset`] : []),
+ ...(launchError ? [`${id}-launch-error`] : []),
].join(" ")}
/>
@@ -95,6 +105,11 @@ export function VirtualDisplayField({
{resetReason}
)}
+ {launchError && (
+
+ {launchError}
+
+ )}
Uses extra memory and CPU. Not required for web app previews.
diff --git a/apps/server/web/src/lib/__tests__/create-subshell-error.test.ts b/apps/server/web/src/lib/__tests__/create-subshell-error.test.ts
index fe15e171d..19d0fc4ca 100644
--- a/apps/server/web/src/lib/__tests__/create-subshell-error.test.ts
+++ b/apps/server/web/src/lib/__tests__/create-subshell-error.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test";
import { ApiError } from "@internal/node-admin";
-import { createSubshellErrorMessage } from "@/lib/create-subshell-error";
+import { createSubshellErrorMessage, isDisplayLaunchRefusal } from "@/lib/create-subshell-error";
/**
* Launch-failure copy (spec 2026-08-31 §6.6): the create path answers 409
@@ -51,3 +51,38 @@ describe("createSubshellErrorMessage", () => {
expect(createSubshellErrorMessage(null, "Failed to create subshell")).toBe("Failed to create subshell");
});
});
+
+/**
+ * Which create failures belong to the Virtual display control (and so render
+ * inside the field, not at the form's foot). The contract is the refusal WORD
+ * the server mirrors: `screenLaunchRemedy` always names it in parentheses,
+ * and the service's switch preflight carries `preview-node-off`.
+ */
+describe("isDisplayLaunchRefusal", () => {
+ it("routes the budget refusal the AI PC actually answered", () => {
+ expect(
+ isDisplayLaunchRefusal(
+ "API 400: That node's virtual display did not come up inside the launch budget, so the screen pane was refused. Try again, or launch without a screen. (preview-display-not-ready: display :90 / rfb 5910 not answering within 10000ms (Xvfb socket never appeared))",
+ ),
+ ).toBe(true);
+ });
+ it("routes every other display-owned word, bare or suffixed", () => {
+ expect(isDisplayLaunchRefusal("… (preview-display-unsupported)")).toBe(true);
+ expect(isDisplayLaunchRefusal("… (preview-display-missing-requirements: x11vnc)")).toBe(true);
+ expect(isDisplayLaunchRefusal("… (preview-display-provider-disabled)")).toBe(true);
+ expect(isDisplayLaunchRefusal("… (preview-display-provider-conflict: …)")).toBe(true);
+ expect(isDisplayLaunchRefusal("Virtual displays are disabled on this node. (preview-node-off)")).toBe(true);
+ });
+ it("leaves every other failure at the form line", () => {
+ // A display MENTION is not a display REFUSAL: only the server's words
+ // route, so an offline node or a bad directory never lands on the field.
+ expect(isDisplayLaunchRefusal("That node is offline. Start its Subshell daemon or choose another node.")).toBe(
+ false,
+ );
+ expect(isDisplayLaunchRefusal("API 409: That harness is disabled or not installed on that node")).toBe(false);
+ expect(
+ isDisplayLaunchRefusal("This preset’s display settings are invalid. Edit the preset before launching."),
+ ).toBe(false);
+ expect(isDisplayLaunchRefusal("")).toBe(false);
+ });
+});
diff --git a/apps/server/web/src/lib/create-subshell-error.ts b/apps/server/web/src/lib/create-subshell-error.ts
index 026d151b0..8cf8a6c55 100644
--- a/apps/server/web/src/lib/create-subshell-error.ts
+++ b/apps/server/web/src/lib/create-subshell-error.ts
@@ -1,6 +1,30 @@
import { BackendErrorCodes } from "@internal/backend-errors";
import { ApiError, errMessage } from "@internal/node-admin";
+/**
+ * The words a create-path refusal carries when the VIRTUAL DISPLAY is what
+ * failed, either as the thrown detail or inside the plane's remedy sentence
+ * (the screen-launch mirror in `apps/server/api/src/modules/previews/refusals.ts`
+ * names every node word, and the service's switch preflight names the same
+ * `preview-node-off` voice the node's own launch gate uses). A refusal
+ * carrying one of these is shown beside the Virtual display control it
+ * belongs to, not at the bottom of the form: the design rule is that a
+ * warning sits at the decision point, and these refusals are that checkbox's
+ * result. `preview-node-off` is display-owned HERE because nothing else on
+ * the create path can answer with it: browser previews are refused by the
+ * view routes, not by `POST /api/subshells`.
+ */
+const DISPLAY_REFUSAL_MARKERS = ["preview-display-", "preview-node-off"] as const;
+
+/**
+ * Is this create-failure text a virtual-display refusal? True for any message
+ * naming one of the display refusal words, so the dialog can route it to the
+ * field's own error slot instead of the form-level line.
+ */
+export function isDisplayLaunchRefusal(message: string): boolean {
+ return DISPLAY_REFUSAL_MARKERS.some((marker) => message.includes(marker));
+}
+
/**
* Display text for a failed `POST /api/subshells` — the one copy both launch
* surfaces (`/new` and the workspace dialog) show under the form.
From 582453d7d23bc79a46107134e2286e21a030f583 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 23:13:47 -0700
Subject: [PATCH 006/120] chore: cover the refusal placement in the display
changeset
---
.changeset/virtual-display-rail-entry.md | 3 +++
1 file changed, 3 insertions(+)
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
index 1931bcda5..5ef75c389 100644
--- a/.changeset/virtual-display-rail-entry.md
+++ b/.changeset/virtual-display-rail-entry.md
@@ -2,9 +2,12 @@
"@internal/desktop-client": minor
"@internal/desktop-server": minor
"@internal/server": patch
+"@internal/node": patch
"@internal/docs": patch
---
Add a **Virtual Displays** section to both desktop assistants' sidebar, shown only where a display helper exists, so the helper is reachable from the window a person is already looking at instead of only from the macOS menu bar. The screen carries the steps the menu item never showed (enable the helper, grant Screen Recording and Accessibility to Subshell Display Helper, and what to do when macOS asks for a restart) above one button that opens the helper's own settings panel and a link to the setup guide. Document the sidebar as the way in.
In the web dashboard, a node that cannot serve previews now says so in gold when the reason is its **Web previews & screens** switch, which an owner can turn on, with a link to the guide that says how; an offline or outdated node stays muted, because no setting fixes that. A launch form, a preset, and the provider list no longer offer a virtual display provider the selected machine's operating system cannot run.
+
+A launch refused over its virtual display now reports the problem beside the **Virtual display** checkbox it belongs to, not under Save as preset. On the machine, the display allocation says which readiness gate stayed shut (the Xvfb socket, the VNC port, or the listener's owner), logs what Xvfb and x11vnc printed before cleaning up, and gives a cold box 10 seconds instead of 5.
From fd743b0089b4f31c55033ac1ef295f017b8de2c4 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 23:32:10 -0700
Subject: [PATCH 007/120] fix(desktop): the helper's codesign requirement was
read as a filename
verify() passed -R a bare requirement string; codesign treats an unprefixed
-R value as a FILE to load from, so every 'Open Display Helper settings'
press died with ': No such file or directory' and
'invalid requirement specification' before the helper could install or open.
The value must start with = to be a literal spec. The module is macOS-only,
which is how it survived review uncompiled on Linux CI; the new test pins
the prefix in both debug and release shapes.
---
crates/desktop-core/src/display_helper.rs | 44 +++++++++++++++--------
1 file changed, 30 insertions(+), 14 deletions(-)
diff --git a/crates/desktop-core/src/display_helper.rs b/crates/desktop-core/src/display_helper.rs
index 132e9c6a2..f18ebdbb6 100644
--- a/crates/desktop-core/src/display_helper.rs
+++ b/crates/desktop-core/src/display_helper.rs
@@ -43,23 +43,26 @@ fn version(bundle: &Path) -> Result {
Ok(version.into())
}
+/// The `-R` value for verifying the helper's designated requirement.
+///
+/// The leading `=` is not decoration: `codesign` reads an unprefixed `-R`
+/// argument as a FILE to load requirements from, so a bare spec fails with
+/// `"": No such file or directory` before the helper
+/// ever opens. `=` says "this literal string is the requirement". Debug
+/// builds drop the publisher anchor because dev bundles are ad-hoc signed.
+fn requirement_test_arg(development: bool) -> String {
+ if development {
+ format!("=identifier \"{ID}\"")
+ } else {
+ format!("=anchor apple generic and identifier \"{ID}\" and certificate leaf[subject.OU] = \"DVRYX6BV6J\"")
+ }
+}
+
fn verify(bundle: &Path) -> Result<(), String> {
// The outer desktop bundle authenticates its resource too. Verify the
// standalone copy before reusing it, including its publisher in releases.
- let requirement = if cfg!(debug_assertions) {
- format!("identifier \"{ID}\"")
- } else {
- format!("anchor apple generic and identifier \"{ID}\" and certificate leaf[subject.OU] = \"DVRYX6BV6J\"")
- };
- run(&[
- "/usr/bin/codesign",
- "--verify",
- "--strict",
- "-R",
- &requirement,
- path(bundle)?,
- ])
- .map(|_| ())
+ let requirement = requirement_test_arg(cfg!(debug_assertions));
+ run(&["/usr/bin/codesign", "--verify", "--strict", "-R", &requirement, path(bundle)?]).map(|_| ())
}
struct InstallLock(File);
@@ -202,6 +205,19 @@ mod tests {
use super::*;
use std::os::unix::fs::symlink;
+ #[test]
+ fn the_codesign_requirement_is_a_literal_not_a_filename() {
+ // The original shipped without the `=` prefix and codesign read the
+ // whole requirement as a file path, so no bundle ever verified and
+ // "Open Display Helper settings" failed on every press.
+ for development in [true, false] {
+ let arg = requirement_test_arg(development);
+ assert!(arg.starts_with("=identifier") || arg.starts_with("=anchor apple generic"));
+ assert!(arg.contains(&format!("identifier \"{ID}\"")));
+ assert_eq!(arg.contains("DVRYX6BV6J"), !development);
+ }
+ }
+
#[test]
fn release_updates_require_a_newer_shared_helper_version() {
assert!(!may_replace_version("1.0.0", "1.0.0", false));
From 4f50c70f3fd3d051207663d820830dc3c8e38b5f Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 23:35:13 -0700
Subject: [PATCH 008/120] chore: note the display helper settings fix
---
.changeset/virtual-display-rail-entry.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
index 5ef75c389..65c4f3621 100644
--- a/.changeset/virtual-display-rail-entry.md
+++ b/.changeset/virtual-display-rail-entry.md
@@ -11,3 +11,5 @@ Add a **Virtual Displays** section to both desktop assistants' sidebar, shown on
In the web dashboard, a node that cannot serve previews now says so in gold when the reason is its **Web previews & screens** switch, which an owner can turn on, with a link to the guide that says how; an offline or outdated node stays muted, because no setting fixes that. A launch form, a preset, and the provider list no longer offer a virtual display provider the selected machine's operating system cannot run.
A launch refused over its virtual display now reports the problem beside the **Virtual display** checkbox it belongs to, not under Save as preset. On the machine, the display allocation says which readiness gate stayed shut (the Xvfb socket, the VNC port, or the listener's owner), logs what Xvfb and x11vnc printed before cleaning up, and gives a cold box 10 seconds instead of 5.
+
+The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (It reached the releases because the code is macOS-only and Linux CI never compiles it.)
From 2361e35cbf5c7fe4645f91a393ceed30488e85da Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 23:39:31 -0700
Subject: [PATCH 009/120] fix(desktop): read the helper's CDHash at the
verbosity that prints it
codesign shows CDHash= only from -dvvv; at -dvv it exits 0 without the line,
so the same-or-older-copy digest comparison answered 'Couldn't verify the
display helper's code identity' on every settings press that found an
installed helper. First install escaped it only because the missing-copy
branch never reaches the hash. macOS-only module; verified on the M5.
---
crates/desktop-core/src/display_helper.rs | 34 +++++++++++++++++++----
1 file changed, 29 insertions(+), 5 deletions(-)
diff --git a/crates/desktop-core/src/display_helper.rs b/crates/desktop-core/src/display_helper.rs
index f18ebdbb6..0e0d74c78 100644
--- a/crates/desktop-core/src/display_helper.rs
+++ b/crates/desktop-core/src/display_helper.rs
@@ -62,7 +62,15 @@ fn verify(bundle: &Path) -> Result<(), String> {
// The outer desktop bundle authenticates its resource too. Verify the
// standalone copy before reusing it, including its publisher in releases.
let requirement = requirement_test_arg(cfg!(debug_assertions));
- run(&["/usr/bin/codesign", "--verify", "--strict", "-R", &requirement, path(bundle)?]).map(|_| ())
+ run(&[
+ "/usr/bin/codesign",
+ "--verify",
+ "--strict",
+ "-R",
+ &requirement,
+ path(bundle)?,
+ ])
+ .map(|_| ())
}
struct InstallLock(File);
@@ -93,11 +101,17 @@ impl Drop for InstallLock {
}
}
+/// The argv for reading a bundle's CDHash. The verbosity is load-bearing:
+/// `codesign` prints `CDHash=` only from `-dvvv` up, and `-dvv` prints just
+/// `Signature=` and friends, so the digest check below used to fail with
+/// "Couldn't verify the display helper's code identity" on every press that
+/// had an installed copy to compare against.
+fn code_hash_argv(bundle: &str) -> [String; 3] {
+ ["/usr/bin/codesign".into(), "-dvvv".into(), bundle.into()]
+}
+
fn code_hash(bundle: &Path) -> Result {
- let result = crate::proc::run(
- &["/usr/bin/codesign".into(), "-dvv".into(), path(bundle)?.into()],
- Duration::from_secs(10),
- );
+ let result = crate::proc::run(&code_hash_argv(path(bundle)?), Duration::from_secs(10));
if !result.ok() {
return Err(result.detail());
}
@@ -218,6 +232,16 @@ mod tests {
}
}
+ #[test]
+ fn the_digest_read_uses_the_verbosity_that_prints_cdhash() {
+ // `-dvv` exits 0 but shows no `CDHash=` line, which read as a broken
+ // code identity and blocked every settings press that had an
+ // installed copy to compare. Only `-dvvv` and up emit it.
+ let argv = code_hash_argv("/Applications/Subshell Display Helper.app");
+ assert_eq!(argv[1], "-dvvv");
+ assert_eq!(argv[2], "/Applications/Subshell Display Helper.app");
+ }
+
#[test]
fn release_updates_require_a_newer_shared_helper_version() {
assert!(!may_replace_version("1.0.0", "1.0.0", false));
From bf81bcd57ecdd47a220801e9c17487df4853847d Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 23:43:28 -0700
Subject: [PATCH 010/120] fix(helper): the consent store trapped the helper
before its first window
HelperConsent's default argument was UserDefaults(suiteName: bundleID) with
the app's OWN bundle id: Foundation rejects that (it logs 'does not make
sense and will not work') and returns nil, so the force-unwrap raised
EXC_BREAKPOINT inside DisplayHelperApp.init. open exited 0, so the desktop
app reported success while the helper died instantly and no panel appeared.
The default is .standard, which for the bundled helper already IS its own
domain; consent still reaches the daemon over the helper's socket. The test
now exercises the DEFAULT initializer against the standard domain, which the
old code failed.
---
native/display-helper/Consent.swift | 9 ++++++++-
native/display-helper/tests/HelperTests.swift | 9 +++++++++
2 files changed, 17 insertions(+), 1 deletion(-)
diff --git a/native/display-helper/Consent.swift b/native/display-helper/Consent.swift
index 5955b75e1..782225519 100644
--- a/native/display-helper/Consent.swift
+++ b/native/display-helper/Consent.swift
@@ -5,7 +5,14 @@ import Foundation
final class HelperConsent {
static let bundleID = "dev.subshell.display-helper"
private let defaults: UserDefaults
- init(defaults: UserDefaults = UserDefaults(suiteName: bundleID)!) { self.defaults = defaults }
+ // The default is `standard`, which for this bundled helper IS its own
+ // domain (`dev.subshell.display-helper.plist`). Handing `suiteName:` the
+ // app's own bundle identifier is not a no-op: Foundation rejects it, logs
+ // "Using your own bundle identifier as an NSUserDefaults suite name does
+ // not make sense and will not work", and returns nil — so the old
+ // force-unwrap trapped (`EXC_BREAKPOINT`) inside `DisplayHelperApp.init`,
+ // before any window or status item existed, and `open` still exited 0.
+ init(defaults: UserDefaults = .standard) { self.defaults = defaults }
var enabled: Bool { defaults.bool(forKey: "enabled") }
func setEnabled(_ enabled: Bool) { defaults.set(enabled, forKey: "enabled") }
}
diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift
index abbd4ca77..fe42a9bec 100644
--- a/native/display-helper/tests/HelperTests.swift
+++ b/native/display-helper/tests/HelperTests.swift
@@ -40,6 +40,15 @@ struct HelperTests {
precondition(!consent.enabled)
consent.setEnabled(true); precondition(HelperConsent(defaults: defaults).enabled)
consent.setEnabled(false); precondition(!consent.enabled)
+ // The DEFAULT initializer must read the standard (own) domain. The
+ // released code handed `suiteName:` the app's own bundle id, which
+ // Foundation rejects, and the force-unwrap trapped in
+ // DisplayHelperApp.init before any panel could open.
+ UserDefaults.standard.removeObject(forKey: "enabled")
+ precondition(!HelperConsent().enabled, "A fresh helper starts without consent")
+ UserDefaults.standard.set(true, forKey: "enabled")
+ precondition(HelperConsent().enabled, "The default init must read the app's own domain")
+ UserDefaults.standard.removeObject(forKey: "enabled")
precondition(DisplayInput.keyCode(symbol: 0x63) == 8) // Command-C
precondition(DisplayInput.keyCode(symbol: 0x43) == 8)
precondition(DisplayInput.keyCode(symbol: 0x01000063) == 8)
From a06623afa4cc0dab0c8da724a18ba29f06f6a0bf Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Mon, 26 Oct 2026 23:46:30 -0700
Subject: [PATCH 011/120] fix(desktop): staging the helper could no longer stop
rebuilding itself
build_support::build() rewrote src-tauri/resources/Subshell Display Helper.app
every time the build script ran, and tauri dev watches src-tauri - so one
edit under native/display-helper put bun run dev:desktop-client into an
endless 'File ... changed. Rebuilding application...' loop. The bundle is now
built and signed in OUT_DIR, synced into resources only when bytes differ,
and gated by a content fingerprint kept outside the watched tree. The script's
own tests run from a shim test target (cargo never tests build scripts).
---
.../src-tauri/tests/display_helper_staging.rs | 12 ++
native/display-helper/build_support.rs | 193 ++++++++++++++++--
2 files changed, 188 insertions(+), 17 deletions(-)
create mode 100644 apps/client/desktop/src-tauri/tests/display_helper_staging.rs
diff --git a/apps/client/desktop/src-tauri/tests/display_helper_staging.rs b/apps/client/desktop/src-tauri/tests/display_helper_staging.rs
new file mode 100644
index 000000000..2bc4080c4
--- /dev/null
+++ b/apps/client/desktop/src-tauri/tests/display_helper_staging.rs
@@ -0,0 +1,12 @@
+//! The staging script's own tests.
+//!
+//! `native/display-helper/build_support.rs` is included only by the two apps'
+//! `build.rs`, and `cargo test` never builds build scripts as test targets, so
+//! without this shim its content-gating checks (the fingerprint, and
+//! write-only-if-different) would compile nothing and run nowhere. The
+//! `build()` body reads `OUT_DIR` and shells out to `xcrun`, which is fine: it
+//! is never called here, only the pure helpers are.
+#![allow(dead_code)]
+
+#[path = "../../../../../native/display-helper/build_support.rs"]
+mod build_support;
diff --git a/native/display-helper/build_support.rs b/native/display-helper/build_support.rs
index 4298074a6..f5e6036de 100644
--- a/native/display-helper/build_support.rs
+++ b/native/display-helper/build_support.rs
@@ -1,7 +1,67 @@
//! Included by both desktop build scripts. Building never launches the helper.
-use std::path::PathBuf;
+//!
+//! Staging is CONTENT-GATED on purpose. `tauri dev` watches the whole
+//! `src-tauri` directory, and the staged bundle lives inside it
+//! (`src-tauri/resources/Subshell Display Helper.app`), so a build script that
+//! rewrites those files on every pass triggers its own watcher forever: one
+//! edit under `native/display-helper` used to livelock `bun run
+//! dev:desktop-client` in "File … changed. Rebuilding application…" loops.
+//! The fingerprint (kept in `OUT_DIR`, outside the watched tree) plus
+//! write-only-if-different copies make a rebuild touch the bundle exactly when
+//! its bytes would change, so the loop settles after the one real update.
+use std::path::{Path, PathBuf};
use std::process::Command;
+const BUNDLE_NAME: &str = "Subshell Display Helper.app";
+const HELPER_ID: &str = "dev.subshell.display-helper";
+
+/// FNV-1a over every byte that decides the bundle's content. Cheap, stable,
+/// and dependency-free; a mismatch costs one rebuild, never correctness.
+fn fingerprint(parts: &[&[u8]]) -> u64 {
+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
+ for part in parts {
+ for byte in part.iter().chain([0xff].iter()) {
+ hash ^= u64::from(*byte);
+ hash = hash.wrapping_mul(0x100_0000_01b3);
+ }
+ }
+ hash
+}
+
+fn read_parts(sources: &[PathBuf], plist: &Path, architecture: &str, identity: &str, keychain: &str) -> Vec> {
+ let mut parts: Vec> = vec![
+ architecture.as_bytes().to_vec(),
+ identity.as_bytes().to_vec(),
+ keychain.as_bytes().to_vec(),
+ ];
+ for path in sources {
+ parts.push(
+ path.file_name()
+ .expect("named source file")
+ .to_string_lossy()
+ .into_owned()
+ .into_bytes(),
+ );
+ parts.push(std::fs::read(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display())));
+ }
+ parts.push(std::fs::read(plist).unwrap_or_else(|error| panic!("read {}: {error}", plist.display())));
+ parts
+}
+
+/// Copy `from` over `to` only when the bytes differ, so an unchanged rebuild
+/// leaves no new mtime for the dev watcher to chase. Returns whether it wrote.
+fn copy_if_changed(from: &Path, to: &Path) -> bool {
+ let wanted = std::fs::read(from).unwrap_or_else(|error| panic!("read {}: {error}", from.display()));
+ if std::fs::read(to).is_ok_and(|existing| existing == wanted) {
+ return false;
+ }
+ if let Some(parent) = to.parent() {
+ std::fs::create_dir_all(parent).unwrap_or_else(|error| panic!("create {}: {error}", parent.display()));
+ }
+ std::fs::write(to, wanted).unwrap_or_else(|error| panic!("write {}: {error}", to.display()));
+ true
+}
+
pub fn build() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") {
return;
@@ -12,48 +72,147 @@ pub fn build() {
println!("cargo:rerun-if-changed={}", source.display());
println!("cargo:rerun-if-env-changed=APPLE_SIGNING_IDENTITY");
println!("cargo:rerun-if-env-changed=APPLE_SIGNING_KEYCHAIN");
- let bundle = manifest.join("resources/Subshell Display Helper.app");
- let contents = bundle.join("Contents");
- std::fs::create_dir_all(contents.join("MacOS")).expect("helper bundle directory");
- std::fs::copy(source.join("Info.plist"), contents.join("Info.plist")).expect("helper Info.plist");
+ let bundle = manifest.join("resources").join(BUNDLE_NAME);
let architecture = match std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() {
Ok("aarch64") => "arm64",
Ok("x86_64") => "x86_64",
_ => panic!("unsupported display helper architecture"),
};
+ let identity = std::env::var("APPLE_SIGNING_IDENTITY").unwrap_or_else(|_| "-".into());
+ let keychain = std::env::var("APPLE_SIGNING_KEYCHAIN").unwrap_or_default();
let mut sources: Vec<_> = std::fs::read_dir(&source)
.expect("helper sources")
.map(|entry| entry.expect("helper source entry").path())
.filter(|path| path.extension().is_some_and(|extension| extension == "swift"))
.collect();
sources.sort();
+ let parts = read_parts(&sources, &source.join("Info.plist"), architecture, &identity, &keychain);
+ let bytes: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect();
+ let stamp_value = fingerprint(&bytes).to_string();
+ let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
+ let stamp = out_dir.join("display-helper.stamp");
+ let staged = bundle.join("Contents/MacOS/subshell-display-helper");
+ let staged_plist = bundle.join("Contents/Info.plist");
+ if std::fs::read_to_string(&stamp).is_ok_and(|stored| stored == stamp_value)
+ && staged.is_file()
+ && staged_plist.is_file()
+ {
+ // Nothing about the bundle changed since the last pass: touch no file,
+ // which is what keeps `tauri dev` from rebuilding itself forever.
+ return;
+ }
+
+ // Build and sign in OUT_DIR (outside the watched tree), then sync the
+ // bytes in. Signing a half-written bundle in place would leave the
+ // watcher seeing a valid signature over old contents for one instant.
+ let stage = out_dir.join(BUNDLE_NAME);
+ let stage_contents = stage.join("Contents");
+ if stage.exists() {
+ std::fs::remove_dir_all(&stage).expect("clear staged helper");
+ }
+ std::fs::create_dir_all(stage_contents.join("MacOS")).expect("staged helper directory");
+ std::fs::copy(source.join("Info.plist"), stage_contents.join("Info.plist")).expect("staged helper Info.plist");
let status = Command::new("/usr/bin/xcrun")
.args(["swiftc", "-O", "-target", &format!("{architecture}-apple-macos14.0")])
- .args(sources)
+ .args(&sources)
.arg("-o")
- .arg(contents.join("MacOS/subshell-display-helper"))
+ .arg(stage_contents.join("MacOS/subshell-display-helper"))
.status()
.expect("start Swift compiler");
assert!(status.success(), "display helper compilation failed");
- let identity = std::env::var("APPLE_SIGNING_IDENTITY").unwrap_or_else(|_| "-".into());
assert!(std::env::var("PROFILE").as_deref() != Ok("release") || identity != "-",
"release display helpers require APPLE_SIGNING_IDENTITY; ad-hoc signatures cannot preserve macOS permission grants");
let mut sign = Command::new("/usr/bin/codesign");
- sign.args([
- "--force",
- "--sign",
- &identity,
- "--identifier",
- "dev.subshell.display-helper",
- ]);
+ sign.args(["--force", "--sign", &identity, "--identifier", HELPER_ID]);
if identity != "-" {
sign.args(["--options", "runtime", "--timestamp"]);
- if let Ok(keychain) = std::env::var("APPLE_SIGNING_KEYCHAIN") {
+ if !keychain.is_empty() {
sign.args(["--keychain", &keychain]);
}
}
assert!(
- sign.arg(&bundle).status().expect("sign display helper").success(),
+ sign.arg(&stage).status().expect("sign display helper").success(),
"display helper signing failed"
);
+
+ std::fs::create_dir_all(bundle.join("Contents/MacOS")).expect("helper bundle directory");
+ let mut wrote = copy_if_changed(&stage_contents.join("Info.plist"), &staged_plist);
+ wrote = copy_if_changed(&stage_contents.join("MacOS/subshell-display-helper"), &staged) || wrote;
+ for entry in std::fs::read_dir(stage_contents.join("_CodeSignature")).expect("staged code signature") {
+ let path = entry.expect("signature entry").path();
+ wrote = copy_if_changed(
+ &path,
+ &bundle
+ .join("Contents/_CodeSignature")
+ .join(path.file_name().expect("named")),
+ ) || wrote;
+ }
+ // A previous staging pass may have left a stale file (a renamed source, an
+ // older signature layout); drop anything the current bundle does not have.
+ if let Ok(entries) = std::fs::read_dir(bundle.join("Contents/_CodeSignature")) {
+ for entry in entries.flatten() {
+ if !stage_contents.join("_CodeSignature").join(entry.file_name()).is_file() {
+ let _ = std::fs::remove_file(entry.path());
+ }
+ }
+ }
+ // Record the fingerprint whether or not anything was copied, so the next
+ // pass short-circuits before the swiftc call.
+ std::fs::write(&stamp, stamp_value).expect("write helper stamp");
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_fingerprint_follows_every_input_byte_and_their_order() {
+ let base = fingerprint(&[b"Consent.swift", b"let x = 1"]);
+ assert_eq!(base, fingerprint(&[b"Consent.swift", b"let x = 1"]));
+ assert_ne!(base, fingerprint(&[b"Consent.swift", b"let x = 2"]));
+ assert_ne!(base, fingerprint(&[b"Settings.swift", b"let x = 1"]));
+ assert_ne!(base, fingerprint(&[b"let x = 1", b"Consent.swift"]));
+ }
+
+ #[test]
+ fn an_unchanged_rebuild_touches_nothing_and_a_changed_one_writes() {
+ let directory = std::env::temp_dir().join(format!("subshell-helper-stage-{}", std::process::id()));
+ std::fs::create_dir_all(&directory).unwrap();
+ let from = directory.join("from");
+ let to = directory.join("to");
+ std::fs::write(&from, b"v1").unwrap();
+ assert!(copy_if_changed(&from, &to), "a missing target must be written");
+ assert!(
+ !copy_if_changed(&from, &to),
+ "identical bytes must not rewrite the file"
+ );
+ let before = std::fs::metadata(&to).unwrap().modified().unwrap();
+ std::fs::write(&from, b"v2-longer").unwrap();
+ assert!(copy_if_changed(&from, &to));
+ assert_eq!(std::fs::read(&to).unwrap(), b"v2-longer");
+ assert!(std::fs::metadata(&to).unwrap().modified().unwrap() > before);
+ std::fs::remove_dir_all(directory).unwrap();
+ }
+
+ #[test]
+ fn read_parts_carries_sources_plist_arch_and_identity() {
+ let directory = std::env::temp_dir().join(format!("subshell-helper-parts-{}", std::process::id()));
+ std::fs::create_dir_all(&directory).unwrap();
+ let source = directory.join("Consent.swift");
+ let plist = directory.join("Info.plist");
+ std::fs::write(&source, b"final class HelperConsent {}").unwrap();
+ std::fs::write(&plist, b"").unwrap();
+ let first = read_parts(std::slice::from_ref(&source), &plist, "arm64", "-", "");
+ let second = read_parts(std::slice::from_ref(&source), &plist, "x86_64", "-", "");
+ assert_ne!(
+ fingerprint(&first.iter().map(Vec::as_slice).collect::>()),
+ fingerprint(&second.iter().map(Vec::as_slice).collect::>())
+ );
+ let signed = read_parts(&[source], &plist, "arm64", "Developer ID Application: X", "");
+ assert_ne!(
+ fingerprint(&first.iter().map(Vec::as_slice).collect::>()),
+ fingerprint(&signed.iter().map(Vec::as_slice).collect::>())
+ );
+ std::fs::remove_dir_all(directory).unwrap();
+ }
}
From 0b9ae8a3f47b1fefb22e1bad6a4eafa934d0f7d1 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 00:00:57 -0700
Subject: [PATCH 012/120] fix(preview): log what the helper actually answered
when a screen is refused
The missing list could not distinguish 'no helper at all' from 'helper
enabled, permissions on, BetterDisplay lookup empty', and that distinction
decides whether the fault is the helper, BetterDisplay, or the caller.
---
.../pane-runtime/src/preview/betterdisplay-provider.ts | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/packages/pane-runtime/src/preview/betterdisplay-provider.ts b/packages/pane-runtime/src/preview/betterdisplay-provider.ts
index 2fa7bc49d..5deac7bdf 100644
--- a/packages/pane-runtime/src/preview/betterdisplay-provider.ts
+++ b/packages/pane-runtime/src/preview/betterdisplay-provider.ts
@@ -191,8 +191,16 @@ export class BetterDisplayProvider implements VirtualDisplayProvider {
if (!this.supported()) throw new Error("preview-display-unsupported");
const status = await this.status();
const readiness = await this.checkReadiness(status);
- if (readiness.missing.length)
+ if (readiness.missing.length) {
+ // The missing list alone could not tell us whether the helper answered
+ // nothing or answered `{enabled:true,…,betterDisplayExecutable:null}`;
+ // that distinction cost a long debugging session, so the raw answer
+ // rides the refusal.
+ this.deps.log?.(
+ `betterdisplay refused pane ${id}: missing [${readiness.missing.join("; ")}] status ${JSON.stringify(status)}`,
+ );
throw new Error(`preview-display-missing-requirements: ${readiness.missing.join(", ")}`);
+ }
if (![size.width, size.height].every((n) => Number.isInteger(n) && n >= 320 && n <= 4096))
throw new Error("Invalid display dimensions");
if (!status?.betterDisplayExecutable) throw new Error(PREVIEW_DISPLAY_NOT_READY);
From 239b7d6b76dc81502dc01dec64da440d8b0bddfe Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 00:08:55 -0700
Subject: [PATCH 013/120] fix(preview): log the helper's raw answer at the
readiness gate too
The launch refusal is raised by the runtime wrapper around the provider, so
the allocation-site log never fired. Both gates now say what the helper
actually answered.
---
.../src/preview/betterdisplay-provider.ts | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/packages/pane-runtime/src/preview/betterdisplay-provider.ts b/packages/pane-runtime/src/preview/betterdisplay-provider.ts
index 5deac7bdf..b0447f3b1 100644
--- a/packages/pane-runtime/src/preview/betterdisplay-provider.ts
+++ b/packages/pane-runtime/src/preview/betterdisplay-provider.ts
@@ -82,7 +82,17 @@ export class BetterDisplayProvider implements VirtualDisplayProvider {
);
}
async readiness(): Promise {
- return this.checkReadiness((this.supported() && (await this.status())) || null);
+ // `supported()` first, and it decides whether the helper is asked at all:
+ // a Linux host has no helper to query (pinned by test).
+ const status = this.supported() ? await this.status() : null;
+ const result = await this.checkReadiness(status);
+ if (result.missing.length) {
+ // Same reason as the allocation log: the missing list alone cannot say
+ // whether the helper answered nothing, or answered with BetterDisplay
+ // absent from LaunchServices.
+ this.deps.log?.(`betterdisplay readiness refused: ${JSON.stringify({ status, missing: result.missing })}`);
+ }
+ return result;
}
private async checkReadiness(status: HelperStatus | null): Promise {
if (!this.supported()) return { supported: false, missing: [] };
From a9650e083e38ab2cc567d3227fcc876c76271082 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 00:15:02 -0700
Subject: [PATCH 014/120] feat(web): badge the preview icon with what there is
to open
A pane whose app started a port nobody saved, or that has a screen or a
browser watch sitting there, looked identical to an empty one. The count
comes from the picker's own reads (same query keys), and the rule is a pure
function: discovered ports minus Saved services rows, plus the screen and the
browser watch when offered. The trigger's aria-label carries the number.
---
.changeset/virtual-display-rail-entry.md | 2 +
.../virtual-display-indicator.test.tsx | 13 ++++
.../web/src/components/pane-focus-header.tsx | 10 +++-
.../components/virtual-display-indicator.tsx | 31 +++++++++-
.../web/src/hooks/use-subshell-services.ts | 11 +++-
.../src/hooks/use-unsaved-preview-count.ts | 17 ++++++
.../src/lib/__tests__/preview-badge.test.ts | 59 +++++++++++++++++++
apps/server/web/src/lib/preview-badge.ts | 27 +++++++++
apps/server/web/src/routes/subshells_.$id.tsx | 8 ++-
9 files changed, 171 insertions(+), 7 deletions(-)
create mode 100644 apps/server/web/src/hooks/use-unsaved-preview-count.ts
create mode 100644 apps/server/web/src/lib/__tests__/preview-badge.test.ts
create mode 100644 apps/server/web/src/lib/preview-badge.ts
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
index 65c4f3621..396e79b59 100644
--- a/.changeset/virtual-display-rail-entry.md
+++ b/.changeset/virtual-display-rail-entry.md
@@ -12,4 +12,6 @@ In the web dashboard, a node that cannot serve previews now says so in gold when
A launch refused over its virtual display now reports the problem beside the **Virtual display** checkbox it belongs to, not under Save as preset. On the machine, the display allocation says which readiness gate stayed shut (the Xvfb socket, the VNC port, or the listener's owner), logs what Xvfb and x11vnc printed before cleaning up, and gives a cold box 10 seconds instead of 5.
+The preview icon in a pane's header now wears a count when the pane has things you could open but have not saved: discovered app ports with no Saved services row, plus the screen and the browser watch when the pane offers them. It is the picker's own data, so the number and the list cannot disagree.
+
The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (It reached the releases because the code is macOS-only and Linux CI never compiles it.)
diff --git a/apps/server/web/src/components/__tests__/virtual-display-indicator.test.tsx b/apps/server/web/src/components/__tests__/virtual-display-indicator.test.tsx
index ff6fb634b..123c0f747 100644
--- a/apps/server/web/src/components/__tests__/virtual-display-indicator.test.tsx
+++ b/apps/server/web/src/components/__tests__/virtual-display-indicator.test.tsx
@@ -24,6 +24,19 @@ it("opens the display from an icon-only header shortcut", () => {
expect(opened).toBe(1);
});
+it("says on the icon how much there is to open or save", () => {
+ render( {}} />);
+ expect(screen.getByRole("button", { name: "Open web preview or screen; 3 to open or save" })).toBeTruthy();
+ expect(screen.getByText("3")).toBeTruthy();
+ cleanup();
+ render( {}} />);
+ expect(screen.getByText("9+")).toBeTruthy();
+ cleanup();
+ render( {}} />);
+ expect(screen.getByRole("button", { name: "Open web preview or screen" })).toBeTruthy();
+ expect(screen.queryByText("0") === null).toBe(true);
+});
+
it("keeps the header picker available for subshells without a virtual display", () => {
let opened = 0;
render(
diff --git a/apps/server/web/src/components/pane-focus-header.tsx b/apps/server/web/src/components/pane-focus-header.tsx
index 4fc841e80..ba083eca7 100644
--- a/apps/server/web/src/components/pane-focus-header.tsx
+++ b/apps/server/web/src/components/pane-focus-header.tsx
@@ -21,6 +21,7 @@ import { usePaneCopyMode } from "@/hooks/use-pane-copy-mode";
import { usePaneDevices } from "@/hooks/use-pane-devices";
import { useSubshellRow } from "@/hooks/use-subshell-row";
import { useTrustNotices } from "@/hooks/use-trust-notices";
+import { useUnsavedPreviewCount } from "@/hooks/use-unsaved-preview-count";
import { invalidateAfterRename } from "@/hooks/use-workspaces";
import { FALLBACK_NODE_ID, nodeLabelFor } from "@/lib/subshell-node-groups";
import { useWorkspaceFocusedId } from "@/lib/workspace-focus";
@@ -88,6 +89,7 @@ export function PaneFocusHeader({
const focusedPane = panes.find((pane) => pane.subshellId === focusedId);
const subshell = useSubshellRow(focusedPane?.subshellId ?? "");
const openVirtualDisplay = useOpenVirtualDisplay(subshell);
+ const unsavedPreview = useUnsavedPreviewCount(focusedPane?.subshellId, subshell);
const devices = usePaneDevices(focusedPane?.id);
const trustNotices = useTrustNotices(focusedPane ? subshell : undefined);
const stacked = useIsStackedHeader();
@@ -158,7 +160,13 @@ export function PaneFocusHeader({
}
actions={
<>
- {isPane && }
+ {isPane && (
+
+ )}
{isPane && subshell && }
{isPane && }
{devices && }
diff --git a/apps/server/web/src/components/virtual-display-indicator.tsx b/apps/server/web/src/components/virtual-display-indicator.tsx
index bec5e77d7..54f9868a1 100644
--- a/apps/server/web/src/components/virtual-display-indicator.tsx
+++ b/apps/server/web/src/components/virtual-display-indicator.tsx
@@ -1,36 +1,49 @@
import { AppWindow, Globe } from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+/** Above this the badge says `9+`; the exact list is the picker's job. */
+const BADGE_CAP = 9;
+
export function VirtualDisplayIndicator({
display,
compact = false,
+ unsavedCount = 0,
onOpen,
}: {
display?: string | null;
compact?: boolean;
+ /**
+ * Items the picker could offer that this pane has no saved service for
+ * (`unsavedPreviewItemCount`). A number sits on the icon so a pane doing
+ * something previewable says so before anyone opens the picker.
+ */
+ unsavedCount?: number;
onOpen?: () => void;
}) {
if (!display && (compact || !onOpen)) return null;
const label = `Virtual display ${display}`;
if (compact)
return (
-
+
+ {unsavedCount > 0 && }
);
+ const count = unsavedCount > 0 ? `; ${unsavedCount > BADGE_CAP ? "9+" : unsavedCount} to open or save` : "";
return (
{display ? (
) : (
)}
+ {unsavedCount > 0 && }
{display ? "Virtual display enabled - click to activate" : "Open web preview or screen"}
@@ -38,3 +51,15 @@ export function VirtualDisplayIndicator({
);
}
+
+/** The count chip. Decorative: the trigger's aria-label already carries it. */
+function PreviewBadge({ count }: { count: number }) {
+ return (
+
+ {count > BADGE_CAP ? "9+" : count}
+
+ );
+}
diff --git a/apps/server/web/src/hooks/use-subshell-services.ts b/apps/server/web/src/hooks/use-subshell-services.ts
index b3a58fc48..0a52b31e9 100644
--- a/apps/server/web/src/hooks/use-subshell-services.ts
+++ b/apps/server/web/src/hooks/use-subshell-services.ts
@@ -8,11 +8,18 @@ export interface SavedService {
port: number;
protocol: "http" | "https";
}
-export function useSubshellServices(id: string) {
+export function useSubshellServices(id: string, enabled = true) {
const client = useQueryClient();
const path = `/api/subshells/${encodeURIComponent(id)}/services`;
const queryKey = ["subshell-services", id];
- const query = useQuery({ queryKey, queryFn: () => apiFetch(path), refetchInterval: 5000 });
+ // `enabled` lets a caller that may not have an id yet (a header rendering
+ // before its row) share this hook without ever asking for `/api/subshells//`.
+ const query = useQuery({
+ queryKey,
+ queryFn: () => apiFetch(path),
+ refetchInterval: 5000,
+ enabled,
+ });
async function save(input: Omit) {
const service = await apiFetch(path, { method: "POST", body: JSON.stringify(input) });
await client.invalidateQueries({ queryKey });
diff --git a/apps/server/web/src/hooks/use-unsaved-preview-count.ts b/apps/server/web/src/hooks/use-unsaved-preview-count.ts
new file mode 100644
index 000000000..96e10e68a
--- /dev/null
+++ b/apps/server/web/src/hooks/use-unsaved-preview-count.ts
@@ -0,0 +1,17 @@
+import { useSubshellPreview } from "@/hooks/use-subshell-preview";
+import { useSubshellServices } from "@/hooks/use-subshell-services";
+import { unsavedPreviewItemCount } from "@/lib/preview-badge";
+import type { SubshellView } from "@/types/subshell";
+
+/**
+ * The header badge's number: items the preview picker could offer that this
+ * pane has no saved service for. Both reads are the ones the picker itself
+ * uses (same query keys), so a header and an open tab never disagree, and a
+ * header with no row yet asks for nothing.
+ */
+export function useUnsavedPreviewCount(id: string | undefined, subshell: SubshellView | undefined): number {
+ const preview = useSubshellPreview(id ?? "", id ? subshell : undefined);
+ const services = useSubshellServices(id ?? "", id !== undefined && subshell !== undefined);
+ if (!id || !subshell) return 0;
+ return unsavedPreviewItemCount(preview.info, services.data);
+}
diff --git a/apps/server/web/src/lib/__tests__/preview-badge.test.ts b/apps/server/web/src/lib/__tests__/preview-badge.test.ts
new file mode 100644
index 000000000..2d57091f3
--- /dev/null
+++ b/apps/server/web/src/lib/__tests__/preview-badge.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from "bun:test";
+import type { PreviewInfo } from "@/hooks/use-subshell-preview";
+import type { SavedService } from "@/hooks/use-subshell-services";
+import { unsavedPreviewItemCount } from "@/lib/preview-badge";
+
+/**
+ * The header badge's rule: discovered items with no saved service row. The
+ * cases are the ones a wrong answer would actually ship: double-counting a
+ * port seen on both IPv4 and IPv6, badge-noising a pane whose node has
+ * previews off, and a browser watch whose CDP port is also a saved app.
+ */
+function info(over: Partial = {}): PreviewInfo {
+ return {
+ ports: [],
+ cdpPort: null,
+ screen: null,
+ nodeEnabled: true,
+ ...over,
+ };
+}
+
+function port(p: number, proto: "tcp" | "tcp6" = "tcp"): PreviewInfo["ports"][number] {
+ return { proto, port: p, pid: 100 + p };
+}
+
+function saved(p: number): SavedService {
+ return { id: `s${p}`, name: `app ${p}`, host: "127.0.0.1", port: p, protocol: "http" };
+}
+
+describe("unsavedPreviewItemCount", () => {
+ it("counts nothing before the first answer, or when the node cannot serve previews", () => {
+ expect(unsavedPreviewItemCount(undefined, [])).toBe(0);
+ expect(unsavedPreviewItemCount(info({ nodeEnabled: false }), [])).toBe(0);
+ expect(unsavedPreviewItemCount(info({ reason: "node-offline", ports: [port(3000)] }), [])).toBe(0);
+ });
+
+ it("counts each discovered thing the pane has no saved row for", () => {
+ expect(unsavedPreviewItemCount(info({ ports: [port(3000), port(3001)] }), [])).toBe(2);
+ expect(unsavedPreviewItemCount(info({ ports: [port(3000), port(3001)] }), [saved(3000)])).toBe(1);
+ expect(unsavedPreviewItemCount(info({ ports: [port(3000)] }), [saved(3000)])).toBe(0);
+ });
+
+ it("counts a dual-stack port once", () => {
+ expect(unsavedPreviewItemCount(info({ ports: [port(3000), port(3000, "tcp6")] }), [])).toBe(1);
+ });
+
+ it("counts the screen and the browser watch as one item each", () => {
+ const screen: PreviewInfo["screen"] = { display: ":90", viewer: { protocol: "vnc", port: 5910 } };
+ expect(unsavedPreviewItemCount(info({ screen }), [])).toBe(1);
+ expect(unsavedPreviewItemCount(info({ cdpPort: 9222 }), [])).toBe(1);
+ expect(unsavedPreviewItemCount(info({ ports: [port(3000)], cdpPort: 9222 }), [saved(3000)])).toBe(1);
+ });
+
+ it("treats a missing services read as nothing saved, never as zero", () => {
+ // The list is still in flight: hiding the badge would tell the person
+ // there is nothing to open when there may be several things.
+ expect(unsavedPreviewItemCount(info({ ports: [port(3000)] }), undefined)).toBe(1);
+ });
+});
diff --git a/apps/server/web/src/lib/preview-badge.ts b/apps/server/web/src/lib/preview-badge.ts
new file mode 100644
index 000000000..ea351598c
--- /dev/null
+++ b/apps/server/web/src/lib/preview-badge.ts
@@ -0,0 +1,27 @@
+import type { PreviewInfo } from "@/hooks/use-subshell-preview";
+import type { SavedService } from "@/hooks/use-subshell-services";
+
+/**
+ * How many things the preview picker could offer that this pane has no saved
+ * service for - the number behind the header icon's badge.
+ *
+ * "Saved" means a row in the pane's Saved services list, which is what the
+ * person chose to keep; a discovered port with no row is something they have
+ * not decided about yet. The screen and the browser watch are offered whole
+ * (one item each) whenever the pane has them: nothing saves those, so the
+ * badge is "there is something here you can open", and it clears with the
+ * rest once the node switch or the reason says there is nothing to offer.
+ * A port discovered on both IPv4 and IPv6 counts once.
+ */
+export function unsavedPreviewItemCount(info: PreviewInfo | undefined, services: SavedService[] | undefined): number {
+ if (!info?.nodeEnabled || info.reason) return 0;
+ const savedPorts = new Set((services ?? []).map((service) => service.port));
+ const discoveredPorts = new Set(info.ports.map((row) => row.port));
+ let count = 0;
+ for (const port of discoveredPorts) {
+ if (!savedPorts.has(port)) count += 1;
+ }
+ if (info.screen) count += 1;
+ if (info.cdpPort !== null) count += 1;
+ return count;
+}
diff --git a/apps/server/web/src/routes/subshells_.$id.tsx b/apps/server/web/src/routes/subshells_.$id.tsx
index a1ba7d431..495adc351 100644
--- a/apps/server/web/src/routes/subshells_.$id.tsx
+++ b/apps/server/web/src/routes/subshells_.$id.tsx
@@ -38,6 +38,7 @@ import { useSubshellLog } from "@/hooks/use-subshell-log";
import { useSubshellMutations } from "@/hooks/use-subshell-mutations";
import { useSwipeNav } from "@/hooks/use-swipe-nav";
import { useTrustNotices } from "@/hooks/use-trust-notices";
+import { useUnsavedPreviewCount } from "@/hooks/use-unsaved-preview-count";
import { invalidateAfterRename } from "@/hooks/use-workspaces";
import { paneDiagnosticsIds, setPaneDiagnosticsIds, togglePaneDiagnostics } from "@/lib/pane-diagnostics-pref";
import { ACTIVITY_TICK_MS } from "@/lib/subshell-indicator";
@@ -120,6 +121,7 @@ function SubshellPage() {
// names on a blip is the bug that shape caused once.
const { data: nodesData } = useNodes();
const openVirtualDisplay = useOpenVirtualDisplay(subshell);
+ const unsavedPreview = useUnsavedPreviewCount(id, subshell);
const nodeLabel = useMemo(() => {
if (!subshell) return null;
const nodeId = subshell.nodeId || FALLBACK_NODE_ID;
@@ -343,7 +345,11 @@ function SubshellPage() {
which removed the section under the terminal): it carries this
pane into its workspace, where the dialog it raises lists the
same rows and reasons and the preview opens as a tab. */}
-
+
{/* Permanent disclosure: whose machine this runs on, and who
else can read it. Never suppressible — see
From 2e2b673234d52a8544934f56cc625cf5479de062 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 00:26:56 -0700
Subject: [PATCH 015/120] feat(helper): style the panel like the app, say who
is watching, and name the exits
The settings panel was raw AppKit: default fonts on a system background, a
title that did not say the product, and no way to get out of the window
except closing it. It now draws the dashboard's own palette (Theme.swift
converts the oklch tokens from styles.css rather than retyping hexes, and the
helper test pins them), uses the heading/label/body/detail roles, calls
itself Subshell virtual display helper, and offers Minimize helper next to
Quit helper in the panel and the menu bar.
The menu bar icon now says when the machine is being looked at: a live capture
tints it gold and adds a dot, the tooltip names the viewer count, and the
panel carries the same line in gold. Runtime announces capture start and end
so the badge tracks the truth instead of the 2s poll.
---
native/display-helper/Runtime.swift | 16 +-
native/display-helper/Settings.swift | 196 ++++++++++++++++--
native/display-helper/Theme.swift | 105 ++++++++++
native/display-helper/main.swift | 27 ++-
native/display-helper/tests/HelperTests.swift | 20 ++
5 files changed, 333 insertions(+), 31 deletions(-)
create mode 100644 native/display-helper/Theme.swift
diff --git a/native/display-helper/Runtime.swift b/native/display-helper/Runtime.swift
index 67e00241b..4b7ccc4fc 100644
--- a/native/display-helper/Runtime.swift
+++ b/native/display-helper/Runtime.swift
@@ -21,6 +21,12 @@ final class HelperRuntime {
func listenForStatus() throws { try listener.start() }
+ /// How many subshell screens are being captured right now. The menu bar
+ /// badge, the tooltip, and the panel's live line all read this, so the
+ /// person sitting at the Mac can see when someone is looking at a virtual
+ /// display rather than having to infer it.
+ var viewers: Int { captures.count }
+
func shutdown() {
listener.stop()
for connection in Array(connections.values) { connection.close() }
@@ -55,7 +61,12 @@ final class HelperRuntime {
private func remove(_ id: UUID) {
inputs.removeValue(forKey: id)?.release()
- captures.removeValue(forKey: id)?.stop()
+ if let capture = captures.removeValue(forKey: id) {
+ capture.stop()
+ // The menu bar badge and the panel's live line report who is
+ // being watched, so a capture ending has to announce itself.
+ onStateChange?()
+ }
connections.removeValue(forKey: id)
pending.remove(id); reservations.removeValue(forKey: id); rates.removeValue(forKey: id)
}
@@ -128,7 +139,8 @@ final class HelperRuntime {
captures[connection.id] = capture
inputs[connection.id] = DisplayInput(uuid: uuid, width: width, height: height, allowed: allowed)
try await capture.start()
- guard allowed() else { capture.stop(); return }
+ guard allowed() else { capture.stop(); onStateChange?(); return }
+ onStateChange?()
pending.remove(connection.id)
connection.send(["type": "started", "width": width, "height": height])
} catch {
diff --git a/native/display-helper/Settings.swift b/native/display-helper/Settings.swift
index abcb28b4c..857dbbd9e 100644
--- a/native/display-helper/Settings.swift
+++ b/native/display-helper/Settings.swift
@@ -2,6 +2,15 @@ import AppKit
import ApplicationServices
import ServiceManagement
+/// The helper's own panel. It is the consent surface, so it is native AppKit:
+/// a local prompt the person can trust should not be HTML, and the buttons
+/// that open System Settings must be the real thing.
+///
+/// It is styled from `Theme`, the mirror of the dashboard's token file, so a
+/// person who knows Subshell from the browser recognizes the product here:
+/// the void ground, the plum accent, heading/label/body/detail at the sizes
+/// the web uses, gold for what the owner can fix and red for what the machine
+/// refused.
@MainActor
private final class SettingsStack: NSStackView {
override var isFlipped: Bool { true }
@@ -12,8 +21,10 @@ final class HelperSettings: NSObject {
private let consent: HelperConsent
private let enable: () throws -> Void
private let disable: () -> Void
+ private let accessCount: () -> Int
private var window: NSWindow?
private let status = NSTextField(wrappingLabelWithString: "")
+ private let access = NSTextField(wrappingLabelWithString: "")
private let error = NSTextField(wrappingLabelWithString: "")
private let captureStatus = NSTextField(labelWithString: "")
private let inputStatus = NSTextField(labelWithString: "")
@@ -22,12 +33,21 @@ final class HelperSettings: NSObject {
private let input = NSButton(title: "Allow accessibility…", target: nil, action: nil)
private var timer: Timer?
- init(consent: HelperConsent, enable: @escaping () throws -> Void, disable: @escaping () -> Void) {
+ init(consent: HelperConsent,
+ enable: @escaping () throws -> Void,
+ disable: @escaping () -> Void,
+ accessCount: @escaping () -> Int = { 0 })
+ {
self.consent = consent
self.enable = enable
self.disable = disable
+ self.accessCount = accessCount
}
+ /// Whether the panel is on screen; the menu bar's Minimize item answers
+ /// to this so it never offers something that does nothing.
+ var isShowing: Bool { window?.isVisible ?? false }
+
func show() {
if window == nil { build() }
refresh()
@@ -35,55 +55,176 @@ final class HelperSettings: NSObject {
NSApp.activate(ignoringOtherApps: true)
}
+ /// Hide the panel and leave everything else alone: consent, permissions,
+ /// and any screen being watched keep running. The menu bar item is still
+ /// there to bring it back.
+ func minimize() { window?.orderOut(nil) }
+
+ private func heading(_ text: String) -> NSTextField {
+ let label = NSTextField(labelWithString: text)
+ label.font = Theme.headingFont
+ label.textColor = Theme.foreground
+ return label
+ }
+
+ private func section(_ text: String) -> NSTextField {
+ let label = NSTextField(labelWithString: text)
+ label.font = Theme.labelFont
+ label.textColor = Theme.foreground
+ return label
+ }
+
+ private func body(_ text: String) -> NSTextField {
+ let label = NSTextField(wrappingLabelWithString: text)
+ label.font = Theme.bodyFont
+ label.textColor = Theme.foreground
+ label.isSelectable = false
+ return label
+ }
+
+ private func detail(_ text: String) -> NSTextField {
+ let label = NSTextField(labelWithString: text)
+ label.font = Theme.detailFont
+ label.textColor = Theme.mutedForeground
+ return label
+ }
+
+ /// A 1px rule in the border token, the same separation the dashboard's
+ /// cards use between sections.
+ private func rule() -> NSView {
+ let line = NSView()
+ line.wantsLayer = true
+ line.layer?.backgroundColor = Theme.border.cgColor
+ line.translatesAutoresizingMaskIntoConstraints = false
+ line.heightAnchor.constraint(equalToConstant: 1).isActive = true
+ return line
+ }
+
+ /// The primary control: the plum fill, dark ink on it, the same pairing
+ /// `--primary` and `--primary-foreground` declare.
+ private func stylePrimary(_ button: NSButton) {
+ button.bezelStyle = .regularSquare
+ button.isBordered = true
+ button.bezelColor = Theme.primary
+ button.contentTintColor = Theme.primaryForeground
+ button.attributedTitle = NSAttributedString(string: button.title, attributes: [
+ .foregroundColor: Theme.primaryForeground,
+ .font: Theme.labelFont,
+ ])
+ }
+
+ /// A secondary control: the sunk plate, brand ink, the border token.
+ private func styleSecondary(_ button: NSButton) {
+ button.bezelStyle = .regularSquare
+ button.isBordered = true
+ button.bezelColor = Theme.secondary
+ button.attributedTitle = NSAttributedString(string: button.title, attributes: [
+ .foregroundColor: Theme.foreground,
+ .font: Theme.bodyFont,
+ ])
+ }
+
private func build() {
let panel = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 620, height: 680),
- styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
- panel.title = "Virtual display helper"
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered, defer: false)
+ panel.title = "Subshell virtual display helper"
+ panel.titlebarAppearsTransparent = false
panel.isReleasedWhenClosed = false
+ // The web theme is dark-only, so the panel pins it rather than mixing a
+ // light-mode aqua control onto the void ground.
+ panel.appearance = NSAppearance(named: .darkAqua)
+ panel.backgroundColor = Theme.background
+ panel.minSize = NSSize(width: 570, height: 500)
+
let stack = SettingsStack()
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 16
stack.edgeInsets = NSEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
- panel.minSize = NSSize(width: 570, height: 500)
+ stack.translatesAutoresizingMaskIntoConstraints = false
let scroll = NSScrollView(frame: panel.contentView!.bounds)
scroll.hasVerticalScroller = true
scroll.autoresizingMask = [.width, .height]
- stack.translatesAutoresizingMaskIntoConstraints = false
+ scroll.drawsBackground = false
scroll.documentView = stack
panel.contentView = scroll
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
- stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor)
+ stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
])
- let intro = NSTextField(wrappingLabelWithString:
- "Allow Subshell to capture and control virtual displays on this Mac. The helper is shared by Subshell Client and Subshell Server. It stays off until you enable it here.")
- stack.addArrangedSubview(intro)
- let warning = NSTextField(wrappingLabelWithString:
- "macOS apps share your desktop, keyboard focus, and pointer. Other windows or notifications on a virtual display can appear in its stream. Only enable this for people you trust to control this Mac.")
- warning.textColor = .systemOrange
+
+ stack.addArrangedSubview(heading("Subshell virtual display helper"))
+ stack.addArrangedSubview(body(
+ "Allow Subshell to capture and control virtual displays on this Mac. The helper is shared by Subshell Client and Subshell Server. It stays off until you enable it here."
+ ))
+ let warning = body(
+ "macOS apps share your desktop, keyboard focus, and pointer. Other windows or notifications on a virtual display can appear in its stream. Only enable this for people you trust to control this Mac."
+ )
+ warning.textColor = Theme.warning
stack.addArrangedSubview(warning)
+ stack.addArrangedSubview(rule())
+
+ stack.addArrangedSubview(section("Access"))
+ status.font = Theme.bodyFont
+ status.textColor = Theme.mutedForeground
stack.addArrangedSubview(status)
- toggle.target = self; toggle.action = #selector(toggleHelper)
+ access.font = Theme.detailFont
+ access.textColor = Theme.mutedForeground
+ stack.addArrangedSubview(access)
+ toggle.target = self
+ toggle.action = #selector(toggleHelper)
+ stylePrimary(toggle)
stack.addArrangedSubview(toggle)
- stack.addArrangedSubview(NSTextField(wrappingLabelWithString:
- "Once enabled, the helper runs in the background and starts when you sign in. Disable it here at any time to stop capture and input and prevent it from starting again. This doesn't close your subshells or revoke macOS permissions."))
- stack.addArrangedSubview(NSButton(title: "Open login items…", target: self, action: #selector(openLoginItems)))
+ stack.addArrangedSubview(detail(
+ "Once enabled, the helper runs in the background and starts when you sign in. Disable it here at any time to stop capture and input and prevent it from starting again. This doesn't close your subshells or revoke macOS permissions."
+ ))
+ let loginItems = NSButton(title: "Open login items…", target: self, action: #selector(openLoginItems))
+ styleSecondary(loginItems)
+ stack.addArrangedSubview(loginItems)
+ stack.addArrangedSubview(rule())
+
+ stack.addArrangedSubview(section("macOS permissions"))
+ captureStatus.font = Theme.detailFont
+ captureStatus.textColor = Theme.mutedForeground
stack.addArrangedSubview(captureStatus)
- capture.target = self; capture.action = #selector(requestCapture)
+ capture.target = self
+ capture.action = #selector(requestCapture)
+ styleSecondary(capture)
stack.addArrangedSubview(capture)
+ inputStatus.font = Theme.detailFont
+ inputStatus.textColor = Theme.mutedForeground
stack.addArrangedSubview(inputStatus)
- input.target = self; input.action = #selector(requestInput)
+ input.target = self
+ input.action = #selector(requestInput)
+ styleSecondary(input)
stack.addArrangedSubview(input)
+ stack.addArrangedSubview(rule())
+
let docs = NSButton(title: "Setup instructions…", target: self, action: #selector(openDocs))
+ styleSecondary(docs)
stack.addArrangedSubview(docs)
- stack.addArrangedSubview(NSButton(title: "Quit helper", target: self, action: #selector(quitHelper)))
- error.textColor = .systemRed
+ error.font = Theme.detailFont
+ error.textColor = Theme.destructive
stack.addArrangedSubview(error)
+
+ // The two ways out, together at the foot: minimize keeps the helper
+ // running, quit stops the process (and revokes consent on the way).
+ let foot = NSStackView()
+ foot.orientation = .horizontal
+ foot.spacing = 12
+ let minimize = NSButton(title: "Minimize helper", target: self, action: #selector(minimizePanel))
+ styleSecondary(minimize)
+ let quit = NSButton(title: "Quit helper", target: self, action: #selector(quitHelper))
+ styleSecondary(quit)
+ foot.addArrangedSubview(minimize)
+ foot.addArrangedSubview(quit)
+ stack.addArrangedSubview(foot)
+
for view in stack.arrangedSubviews {
view.setContentCompressionResistancePriority(.required, for: .vertical)
- if let label = view as? NSTextField {
+ if let label = view as? NSTextField, label.lineBreakMode == .byWordWrapping {
label.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -48).isActive = true
}
}
@@ -98,17 +239,24 @@ final class HelperSettings: NSObject {
let enabled = consent.enabled
let recording = CGPreflightScreenCaptureAccess()
let accessibility = AXIsProcessTrusted()
- status.stringValue = enabled ? "Helper enabled. Capture and control also require both macOS permissions below." : "Helper disabled. No capture or input is allowed."
+ status.stringValue = enabled
+ ? "Helper enabled. Capture and control also require both macOS permissions below."
+ : "Helper disabled. No capture or input is allowed."
if enabled && SMAppService.mainApp.status == .requiresApproval {
status.stringValue += " Allow background startup in System Settings → General → Login Items to resume it after signing in."
}
+ let line = AccessReport.panelLine(viewers: accessCount())
+ access.stringValue = line.text
+ access.textColor = line.warning ? Theme.warning : Theme.mutedForeground
toggle.title = enabled ? "Disable helper" : "Enable helper"
+ stylePrimary(toggle)
captureStatus.stringValue = "Screen Recording: \(recording ? "Allowed" : "Not allowed")"
inputStatus.stringValue = "Accessibility: \(accessibility ? "Allowed" : "Not allowed")"
capture.isEnabled = enabled
input.isEnabled = enabled
let reason = enabled ? nil : "Enable the helper before requesting macOS permissions."
- capture.toolTip = reason; input.toolTip = reason
+ capture.toolTip = reason
+ input.toolTip = reason
if !enabled { status.stringValue += " Enable it before requesting macOS permissions." }
}
@@ -149,6 +297,8 @@ final class HelperSettings: NSObject {
@objc private func openLoginItems() { SMAppService.openSystemSettingsLoginItems() }
+ @objc private func minimizePanel() { minimize() }
+
@objc private func quitHelper() { disableHelper(); NSApp.terminate(nil) }
@objc private func openDocs() {
diff --git a/native/display-helper/Theme.swift b/native/display-helper/Theme.swift
new file mode 100644
index 000000000..2864f7522
--- /dev/null
+++ b/native/display-helper/Theme.swift
@@ -0,0 +1,105 @@
+import AppKit
+
+/// The Subshell palette and type roles, mirrored from
+/// `apps/server/web/src/styles.css` so this panel reads as the same product as
+/// the dashboard rather than as a foreign system dialog.
+///
+/// The numbers are the CSS tokens themselves (L, C, H) converted here, not
+/// retyped hexes: a re-typed hex is a second source of truth that drifts the
+/// moment the web palette moves, and `lint:design` only guards the web and
+/// mobile halves. The conversion is pure and tested.
+///
+/// The web theme is dark-only, so the panel pins `darkAqua` instead of
+/// following the system appearance: a light-mode aqua button sitting on the
+/// void ground looks broken, not themed.
+enum Theme {
+ /// Token name to (oklch L, C, H) exactly as `styles.css` declares them.
+ static let tokens: [String: (l: Double, c: Double, h: Double)] = [
+ "background": (0.224, 0.035, 296),
+ "card": (0.255, 0.032, 296),
+ "foreground": (0.92, 0.03, 312),
+ "primary": (0.75, 0.17, 322),
+ "primary-foreground": (0.16, 0.05, 322),
+ "secondary": (0.29, 0.034, 296),
+ "muted-foreground": (0.74, 0.04, 310),
+ "destructive": (0.65, 0.2, 25),
+ "warning": (0.828, 0.189, 84.429),
+ "success": (0.765, 0.177, 163.223),
+ "border": (0.33, 0.035, 296),
+ ]
+
+ /// The panel's ground: the same void the dashboard draws.
+ static let background = color("background")
+ /// Section plates, the token cards use.
+ static let card = color("card")
+ static let foreground = color("foreground")
+ /// Secondary text: metadata, permission lines, timestamps.
+ static let mutedForeground = color("muted-foreground")
+ /// Brand accent: links, the enabled state, the primary control.
+ static let primary = color("primary")
+ static let primaryForeground = color("primary-foreground")
+ static let secondary = color("secondary")
+ /// Gold: the operator can fix this (the same role `text-warning` holds).
+ static let warning = color("warning")
+ /// Red: what the machine refused (the same role `text-destructive` holds).
+ static let destructive = color("destructive")
+ static let success = color("success")
+ static let border = color("border")
+
+ /// Type roles at two weights, the sizes the web token file declares:
+ /// heading 20/600, label 15/600, body 14/400, detail 13/400. There is no
+ /// 12px: detail is the floor, same rule the dashboard obeys.
+ static let headingFont = NSFont.systemFont(ofSize: 20, weight: .semibold)
+ static let labelFont = NSFont.systemFont(ofSize: 15, weight: .semibold)
+ static let bodyFont = NSFont.systemFont(ofSize: 14, weight: .regular)
+ static let detailFont = NSFont.systemFont(ofSize: 13, weight: .regular)
+
+ /// The token as sRGB components. Oklab's inverse transform, then the
+ /// sRGB transfer curve, clamped to the gamut the way CSS does.
+ static func srgb(_ name: String) -> (red: Double, green: Double, blue: Double) {
+ guard let token = tokens[name] else { preconditionFailure("unknown token \(name)") }
+ let h = token.h * Double.pi / 180
+ let a = token.c * cos(h)
+ let b = token.c * sin(h)
+ let l_ = token.l + 0.396_337_777_4 * a + 0.215_803_757_3 * b
+ let m_ = token.l - 0.105_561_345_8 * a - 0.063_854_172_8 * b
+ let s_ = token.l - 0.089_484_177_5 * a - 1.291_485_548 * b
+ let l = l_ * l_ * l_
+ let m = m_ * m_ * m_
+ let s = s_ * s_ * s_
+ func encode(_ v: Double) -> Double {
+ let x = min(max(v, 0), 1)
+ return x <= 0.003_130_8 ? 12.92 * x : 1.055 * pow(x, 1 / 2.4) - 0.055
+ }
+ return (encode(4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s),
+ encode(-1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s),
+ encode(-0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701 * s))
+ }
+
+ static func color(_ name: String) -> NSColor {
+ let rgb = srgb(name)
+ return NSColor(srgbRed: rgb.red, green: rgb.green, blue: rgb.blue, alpha: 1)
+ }
+}
+
+/// What the menu bar and the panel say while someone is watching a screen.
+/// Pure so the wording is testable without a window or a status item.
+enum AccessReport {
+ /// The tooltip on the status item: honest about who is doing what.
+ static func tooltip(enabled: Bool, viewers: Int) -> String {
+ if viewers > 0 {
+ return "Subshell is viewing a virtual display on this Mac"
+ + (viewers == 1 ? "" : " (\(viewers) viewers)")
+ }
+ return enabled ? "Subshell virtual display helper enabled" : "Subshell virtual display helper disabled"
+ }
+
+ /// The panel's live line. Gold while anyone is watching, muted otherwise:
+ /// same role split the dashboard uses for "the machine's owner should see
+ /// this" versus plain metadata.
+ static func panelLine(viewers: Int) -> (text: String, warning: Bool) {
+ guard viewers > 0 else { return ("No one is viewing a virtual display on this Mac.", false) }
+ let plural = viewers == 1 ? "viewer is" : "viewers are"
+ return ("A Subshell \(plural) watching this Mac's virtual display right now.", true)
+ }
+}
diff --git a/native/display-helper/main.swift b/native/display-helper/main.swift
index f45e242a9..e9c90cabd 100644
--- a/native/display-helper/main.swift
+++ b/native/display-helper/main.swift
@@ -5,7 +5,8 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate {
private let consent = HelperConsent()
private lazy var runtime = HelperRuntime(consent: consent)
private lazy var settings = HelperSettings(consent: consent,
- enable: { [weak self] in try self?.runtime.enable() }, disable: { [weak self] in self?.runtime.disable() })
+ enable: { [weak self] in try self?.runtime.enable() }, disable: { [weak self] in self?.runtime.disable() },
+ accessCount: { [weak self] in self?.runtime.viewers ?? 0 })
private var statusItem: NSStatusItem?
private var installLock: Int32 = -1
private var started = false
@@ -49,24 +50,38 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate {
private func updateMenu() {
if statusItem == nil {
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem?.button?.image = NSImage(systemSymbolName: "display", accessibilityDescription: "Subshell virtual display helper")
}
+ let viewers = runtime.viewers
let menu = NSMenu()
menu.autoenablesItems = false
- let settingsItem = NSMenuItem(title: "Virtual Display Helper Settings…", action: #selector(showSettings), keyEquivalent: "")
+ let settingsItem = NSMenuItem(title: "Settings…", action: #selector(showSettings), keyEquivalent: "")
settingsItem.target = self; menu.addItem(settingsItem)
- let disable = NSMenuItem(title: "Disable Helper", action: #selector(disableHelper), keyEquivalent: "")
+ let minimize = NSMenuItem(title: "Minimize helper", action: #selector(minimizeSettings), keyEquivalent: "m")
+ minimize.target = self; minimize.isEnabled = settings.isShowing; menu.addItem(minimize)
+ let disable = NSMenuItem(title: "Disable helper", action: #selector(disableHelper), keyEquivalent: "")
disable.target = self; disable.isEnabled = consent.enabled; menu.addItem(disable)
menu.addItem(.separator())
- let quit = NSMenuItem(title: "Quit Helper", action: #selector(quitHelper), keyEquivalent: "")
+ let quit = NSMenuItem(title: "Quit helper", action: #selector(quitHelper), keyEquivalent: "")
quit.target = self; menu.addItem(quit)
statusItem?.menu = menu
- statusItem?.button?.toolTip = consent.enabled ? "Subshell display helper enabled" : "Subshell display helper disabled"
+ statusItem?.button?.toolTip = AccessReport.tooltip(enabled: consent.enabled, viewers: viewers)
+ // Someone is watching a screen: the icon takes the warning gold and
+ // gains a dot, so the awareness survives a click elsewhere on the
+ // menu bar and does not depend on reading the tooltip.
+ statusItem?.button?.contentTintColor = viewers > 0 ? Theme.warning : nil
+ statusItem?.button?.attributedTitle = viewers > 0
+ ? NSAttributedString(string: " ●", attributes: [
+ .foregroundColor: Theme.warning,
+ .font: NSFont.systemFont(ofSize: 9),
+ ])
+ : NSAttributedString(string: "")
settings.refresh()
}
@objc private func showSettings() { settings.show() }
+ @objc private func minimizeSettings() { settings.minimize() }
@objc private func disableHelper() { settings.disableHelper(); settings.show() }
@objc private func quitHelper() { settings.disableHelper(); NSApp.terminate(nil) }
}
diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift
index fe42a9bec..b5cde7553 100644
--- a/native/display-helper/tests/HelperTests.swift
+++ b/native/display-helper/tests/HelperTests.swift
@@ -49,6 +49,26 @@ struct HelperTests {
UserDefaults.standard.set(true, forKey: "enabled")
precondition(HelperConsent().enabled, "The default init must read the app's own domain")
UserDefaults.standard.removeObject(forKey: "enabled")
+ // The palette mirrors apps/server/web/src/styles.css; these are the
+ // tokens the panel actually paints with. If the web file moves and
+ // this drifts, the fix is to move the numbers here, not to pass.
+ func close(_ a: Double, _ b: Double) -> Bool { abs(a - b) < 0.02 }
+ let primary = Theme.srgb("primary")
+ precondition(close(primary.red, 0.875) && close(primary.green, 0.525) && close(primary.blue, 0.929), "primary token drifted")
+ let warning = Theme.srgb("warning")
+ precondition(close(warning.red, 1.0) && close(warning.green, 0.727) && close(warning.blue, 0.0), "warning token drifted")
+ let ground = Theme.srgb("background")
+ precondition(close(ground.red, 0.113) && close(ground.green, 0.094) && close(ground.blue, 0.165), "background token drifted")
+ // The awareness copy: a dot on the menu bar is only useful if the
+ // words say who is doing what, and the idle state must not claim
+ // anyone is watching.
+ precondition(AccessReport.tooltip(enabled: true, viewers: 2) == "Subshell is viewing a virtual display on this Mac (2 viewers)")
+ precondition(AccessReport.tooltip(enabled: true, viewers: 1) == "Subshell is viewing a virtual display on this Mac")
+ precondition(AccessReport.tooltip(enabled: false, viewers: 0) == "Subshell virtual display helper disabled")
+ let watching = AccessReport.panelLine(viewers: 1)
+ precondition(watching.warning && watching.text.contains("viewer is"))
+ let quiet = AccessReport.panelLine(viewers: 0)
+ precondition(!quiet.warning && quiet.text.contains("No one"))
precondition(DisplayInput.keyCode(symbol: 0x63) == 8) // Command-C
precondition(DisplayInput.keyCode(symbol: 0x43) == 8)
precondition(DisplayInput.keyCode(symbol: 0x01000063) == 8)
From bd328eb84f45fa0350bf3f57ff47f8ab7ab26494 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 00:35:26 -0700
Subject: [PATCH 016/120] fix(helper): never leave the person at the Mac
without a cursor
Pointer control posts real cursor events onto the virtual display, so when
that screen goes away mid-session (pane closed, display discarded, helper
quit) the local user is left with a pointer parked off every visible
display. Measured on the M5, where it cost them the cursor entirely.
The helper now restores a stranded pointer to the built-in display when a
capture session ends, when it quits, and when the provider discards the
screen it was on. The stranding test is pure, so it runs in the helper suite.
---
native/display-helper/Cursor.swift | 30 ++++++++++++++++++
native/display-helper/Runtime.swift | 11 ++++++-
native/display-helper/main.swift | 3 ++
native/display-helper/tests/HelperTests.swift | 9 ++++++
.../preview/betterdisplay-provider.test.ts | 29 +++++++++++++++++
.../src/preview/betterdisplay-provider.ts | 16 ++++++++--
.../pane-runtime/src/preview/macos-helper.ts | 31 +++++++++++++++++++
7 files changed, 126 insertions(+), 3 deletions(-)
create mode 100644 native/display-helper/Cursor.swift
diff --git a/native/display-helper/Cursor.swift b/native/display-helper/Cursor.swift
new file mode 100644
index 000000000..16f648231
--- /dev/null
+++ b/native/display-helper/Cursor.swift
@@ -0,0 +1,30 @@
+import AppKit
+import CoreGraphics
+
+/// Whether the pointer is stranded: it has to fall inside at least one
+/// screen's frame, or the person sitting at this Mac cannot see it at all.
+///
+/// Pure so the rule is testable without a display or a cursor.
+func cursorIsStranded(point: CGPoint, screens: [CGRect]) -> Bool {
+ !screens.contains { $0.contains(point) }
+}
+
+/// Put the pointer back on the built-in display when a viewing session has
+/// left it stranded.
+///
+/// A viewer's pointer events move the REAL system cursor onto the virtual
+/// display; that is how shared control works on a single macOS desktop. If the
+/// phantom screen disappears while the cursor is on it (pane closed, display
+/// discarded, helper quit), the local person is left with no visible cursor
+/// and no way to know why, which is exactly what happened on the M5. Only
+/// warping when stranded leaves a person's own pointer where they put it.
+@MainActor func restoreStrandedCursor() {
+ let point = NSEvent.mouseLocation
+ let frames = NSScreen.screens.map(\.frame)
+ guard cursorIsStranded(point: point, screens: frames) else { return }
+ // The display carrying the origin is the one macOS treats as home.
+ let home = frames.first(where: { $0.origin == .zero }) ?? frames.first
+ guard let home else { return }
+ CGWarpMouseCursorPosition(CGPoint(x: home.midX, y: home.midY))
+ CGAssociateMouseAndMouseCursorPosition(1)
+}
diff --git a/native/display-helper/Runtime.swift b/native/display-helper/Runtime.swift
index 4b7ccc4fc..dbdd6d838 100644
--- a/native/display-helper/Runtime.swift
+++ b/native/display-helper/Runtime.swift
@@ -72,7 +72,13 @@ final class HelperRuntime {
}
private func handle(_ request: [String: Any], _ connection: HelperConnection) {
- guard let operation = request["op"] as? String, consent.enabled || operation == "status" else { connection.close(); return }
+ // `rescue-cursor` is allowed without consent: by the time the daemon
+ // asks for it the display it stranded the pointer on is already gone,
+ // and the action only puts the cursor back where the person at this
+ // Mac can see it.
+ guard let operation = request["op"] as? String,
+ consent.enabled || operation == "status" || operation == "rescue-cursor"
+ else { connection.close(); return }
let now = ProcessInfo.processInfo.systemUptime
let previous = rates[connection.id] ?? (now, 0)
let rate = now - previous.0 >= 1 ? (now, 1) : (previous.0, previous.1 + 1)
@@ -116,6 +122,9 @@ final class HelperRuntime {
catch { input.release(); connection.send(["type": "input_blocked", "message": error.localizedDescription]) }
case "release": inputs[connection.id]?.release()
case "stop": connection.close()
+ case "rescue-cursor":
+ restoreStrandedCursor()
+ connection.send(["type": "rescued", "protocol": 1])
default: throw HelperFailure.message("Unknown helper request.")
}
} catch {
diff --git a/native/display-helper/main.swift b/native/display-helper/main.swift
index e9c90cabd..1d911af88 100644
--- a/native/display-helper/main.swift
+++ b/native/display-helper/main.swift
@@ -46,6 +46,9 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate {
// resume it. Closing the listener tears down every capture and key.
guard started else { return }
runtime.shutdown()
+ // A viewer may have left the pointer on a virtual display this quit
+ // ends; do not hand the person at the Mac a missing cursor.
+ restoreStrandedCursor()
}
private func updateMenu() {
diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift
index b5cde7553..6f9bafeff 100644
--- a/native/display-helper/tests/HelperTests.swift
+++ b/native/display-helper/tests/HelperTests.swift
@@ -69,6 +69,15 @@ struct HelperTests {
precondition(watching.warning && watching.text.contains("viewer is"))
let quiet = AccessReport.panelLine(viewers: 0)
precondition(!quiet.warning && quiet.text.contains("No one"))
+ // The pointer rescue: a viewer's cursor lives on the virtual display,
+ // so losing that screen strands it off every display the person at
+ // this Mac can see. Only a point inside no frame counts as stranded.
+ let builtIn = CGRect(x: 0, y: 0, width: 1512, height: 982)
+ precondition(cursorIsStranded(point: CGPoint(x: 2400, y: 1800), screens: [builtIn]), "a point off every screen is stranded")
+ precondition(!cursorIsStranded(point: CGPoint(x: 100, y: 100), screens: [builtIn]), "a point on the built-in display is fine")
+ precondition(!cursorIsStranded(point: CGPoint(x: 1800, y: 1800), screens: [builtIn, CGRect(x: 1512, y: 982, width: 1280, height: 800)]),
+ "a point on any connected screen is fine")
+ precondition(cursorIsStranded(point: CGPoint(x: 0, y: 0), screens: []), "no screens means nowhere visible")
precondition(DisplayInput.keyCode(symbol: 0x63) == 8) // Command-C
precondition(DisplayInput.keyCode(symbol: 0x43) == 8)
precondition(DisplayInput.keyCode(symbol: 0x01000063) == 8)
diff --git a/packages/pane-runtime/src/preview/betterdisplay-provider.test.ts b/packages/pane-runtime/src/preview/betterdisplay-provider.test.ts
index 2b1abee19..86c73710b 100644
--- a/packages/pane-runtime/src/preview/betterdisplay-provider.test.ts
+++ b/packages/pane-runtime/src/preview/betterdisplay-provider.test.ts
@@ -24,6 +24,7 @@ function fixture() {
const calls: string[][] = [];
let failedCreate = false;
let stopCount = 0;
+ let rescues = 0;
const bridge = {
port: 5922,
start: async () => {},
@@ -37,6 +38,9 @@ function fixture() {
kernelRelease: "23.0.0",
status: async () => status,
alive: () => false,
+ rescue: async () => {
+ rescues++;
+ },
bridge: () => bridge,
run: async (args: string[]) => {
calls.push(args);
@@ -95,6 +99,9 @@ function fixture() {
get stops() {
return stopCount;
},
+ get rescueCount() {
+ return rescues;
+ },
};
}
@@ -236,3 +243,25 @@ test("allocation reuses its readiness status without a second helper handshake",
expect(probes).toBe(1);
await f.provider.closeFor(pane);
});
+
+test("discarding the display hands the pointer back to the person at the Mac", async () => {
+ // A viewer's pointer lives on the screen being discarded, so the teardown
+ // must ask the helper to put it back on a visible display (measured on the
+ // M5: the local user lost their cursor entirely).
+ const f = fixture();
+ await f.provider.openFor(pane, { width: 1280, height: 800 });
+ expect(f.rescueCount).toBe(0);
+ await f.provider.closeFor(pane);
+ expect(f.rescueCount).toBe(1);
+});
+
+test("a display that vanished under a viewer still gets the pointer rescued", async () => {
+ // The screen is already gone, which is precisely when the local cursor can
+ // be stranded on it, so the ask happens even though there is nothing left
+ // to discard.
+ const f = fixture();
+ await f.provider.openFor(pane, { width: 1280, height: 800 });
+ f.disappear();
+ await f.provider.closeFor(pane);
+ expect(f.rescueCount).toBe(1);
+});
diff --git a/packages/pane-runtime/src/preview/betterdisplay-provider.ts b/packages/pane-runtime/src/preview/betterdisplay-provider.ts
index b0447f3b1..2c674c77c 100644
--- a/packages/pane-runtime/src/preview/betterdisplay-provider.ts
+++ b/packages/pane-runtime/src/preview/betterdisplay-provider.ts
@@ -25,7 +25,7 @@ import {
type PreviewScreenHandle,
type VirtualDisplayProvider,
} from "./display-provider.js";
-import { type HelperStatus, helperStatus } from "./macos-helper.js";
+import { type HelperStatus, helperStatus, rescueStrandedCursor } from "./macos-helper.js";
import { MacOSRfb } from "./macos-rfb.js";
type Journal = {
@@ -45,6 +45,12 @@ export interface BetterDisplayDeps {
bridge?: (size: VirtualDisplayRequest, display: OwnedDisplay) => Bridge;
alive?: (pid: number) => boolean;
log?: (line: string) => void;
+ /**
+ * Put the local pointer back on a visible screen after a display is
+ * discarded. Defaults to {@link rescueStrandedCursor}; a host without a
+ * helper (tests, and any non-macOS caller) can pass a no-op.
+ */
+ rescue?: () => Promise;
}
/** macOS shares one login desktop. This provider is not an isolated X session. */
@@ -285,7 +291,13 @@ export class BetterDisplayProvider implements VirtualDisplayProvider {
}
return;
}
- if (display) await removeDisplay(run, display);
+ if (display) {
+ await removeDisplay(run, display);
+ // A viewer's pointer lives on the screen being discarded; without this
+ // the person at the Mac is left chasing a cursor that is off any
+ // display they can see.
+ await (this.deps.rescue ?? rescueStrandedCursor)();
+ }
rmSync(this.directory(id, root), { recursive: true });
}
closeFor(id: string) {
diff --git a/packages/pane-runtime/src/preview/macos-helper.ts b/packages/pane-runtime/src/preview/macos-helper.ts
index 00f3830c3..ca21f5474 100644
--- a/packages/pane-runtime/src/preview/macos-helper.ts
+++ b/packages/pane-runtime/src/preview/macos-helper.ts
@@ -101,3 +101,34 @@ export function helperStatus(path = MACOS_HELPER_SOCKET): Promise {
+ return new Promise((resolve) => {
+ const helper = new MacOSHelper(path);
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ clearTimeout(timer);
+ helper.close();
+ resolve();
+ };
+ const timer = setTimeout(finish, 1500);
+ helper.onClose = finish;
+ helper.onMessage = (message) => {
+ if (message.type === "rescued") finish();
+ };
+ helper.send({ op: "rescue-cursor" });
+ });
+}
From 65078d1a12c474954817c4b4634ed29bb77fd988 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 00:35:59 -0700
Subject: [PATCH 017/120] test(helper): put the second screen's test point
inside its real bounds
---
native/display-helper/tests/HelperTests.swift | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift
index 6f9bafeff..a3fe63065 100644
--- a/native/display-helper/tests/HelperTests.swift
+++ b/native/display-helper/tests/HelperTests.swift
@@ -75,7 +75,7 @@ struct HelperTests {
let builtIn = CGRect(x: 0, y: 0, width: 1512, height: 982)
precondition(cursorIsStranded(point: CGPoint(x: 2400, y: 1800), screens: [builtIn]), "a point off every screen is stranded")
precondition(!cursorIsStranded(point: CGPoint(x: 100, y: 100), screens: [builtIn]), "a point on the built-in display is fine")
- precondition(!cursorIsStranded(point: CGPoint(x: 1800, y: 1800), screens: [builtIn, CGRect(x: 1512, y: 982, width: 1280, height: 800)]),
+ precondition(!cursorIsStranded(point: CGPoint(x: 1800, y: 1500), screens: [builtIn, CGRect(x: 1512, y: 982, width: 1280, height: 800)]),
"a point on any connected screen is fine")
precondition(cursorIsStranded(point: CGPoint(x: 0, y: 0), screens: []), "no screens means nowhere visible")
precondition(DisplayInput.keyCode(symbol: 0x63) == 8) // Command-C
From 82555ec61f51f70ee075deb4ce376a3eb3748cf7 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 00:41:36 -0700
Subject: [PATCH 018/120] fix(helper): lay the panel out as a settings card,
not a report
420pt wide instead of 620, one measurement column, standard push buttons
(the old bezelColor-on-regularSquare controls rendered as mismatched tall
boxes), the two permission states aligned in a grid, secondary actions and
the exits grouped at the foot, and prose trimmed to what a person reads.
---
native/display-helper/Settings.swift | 197 ++++++++++++++-------------
1 file changed, 106 insertions(+), 91 deletions(-)
diff --git a/native/display-helper/Settings.swift b/native/display-helper/Settings.swift
index 857dbbd9e..4643690cf 100644
--- a/native/display-helper/Settings.swift
+++ b/native/display-helper/Settings.swift
@@ -16,6 +16,28 @@ private final class SettingsStack: NSStackView {
override var isFlipped: Bool { true }
}
+@MainActor
+private final class FlippedView: NSView {
+ override var isFlipped: Bool { true }
+}
+
+/// One column, one rhythm. The panel is a settings card, not a report: a
+/// width this text can actually be read at, and everything measured from it.
+private enum Layout {
+ static let windowWidth: CGFloat = 420
+ static let inset: CGFloat = 20
+ static var contentWidth: CGFloat { windowWidth - 2 * inset }
+}
+
+private extension NSTextField {
+ /// The gold the design system reserves for "the person who owns this can
+ /// fix it here" - the same role the dashboard's `text-warning` holds.
+ func withWarningColor() -> NSTextField {
+ textColor = Theme.warning
+ return self
+ }
+}
+
@MainActor
final class HelperSettings: NSObject {
private let consent: HelperConsent
@@ -28,9 +50,11 @@ final class HelperSettings: NSObject {
private let error = NSTextField(wrappingLabelWithString: "")
private let captureStatus = NSTextField(labelWithString: "")
private let inputStatus = NSTextField(labelWithString: "")
- private let toggle = NSButton(title: "Enable helper", target: nil, action: nil)
- private let capture = NSButton(title: "Allow screen recording…", target: nil, action: nil)
- private let input = NSButton(title: "Allow accessibility…", target: nil, action: nil)
+ // Built once in `build()`; these placeholders exist so a state change
+ // arriving before the first `show()` has something safe to address.
+ private var toggle = NSButton()
+ private var capture = NSButton()
+ private var input = NSButton()
private var timer: Timer?
init(consent: HelperConsent,
@@ -79,6 +103,7 @@ final class HelperSettings: NSObject {
label.font = Theme.bodyFont
label.textColor = Theme.foreground
label.isSelectable = false
+ label.preferredMaxLayoutWidth = Layout.contentWidth
return label
}
@@ -89,145 +114,136 @@ final class HelperSettings: NSObject {
return label
}
- /// A 1px rule in the border token, the same separation the dashboard's
- /// cards use between sections.
+ /// A 1px rule in the border token: the only separator the panel needs,
+ /// because grouping is carried by headings and spacing, not by lines.
private func rule() -> NSView {
let line = NSView()
line.wantsLayer = true
line.layer?.backgroundColor = Theme.border.cgColor
line.translatesAutoresizingMaskIntoConstraints = false
line.heightAnchor.constraint(equalToConstant: 1).isActive = true
+ line.widthAnchor.constraint(equalToConstant: Layout.contentWidth).isActive = true
return line
}
- /// The primary control: the plum fill, dark ink on it, the same pairing
- /// `--primary` and `--primary-foreground` declare.
- private func stylePrimary(_ button: NSButton) {
- button.bezelStyle = .regularSquare
- button.isBordered = true
- button.bezelColor = Theme.primary
- button.contentTintColor = Theme.primaryForeground
- button.attributedTitle = NSAttributedString(string: button.title, attributes: [
- .foregroundColor: Theme.primaryForeground,
- .font: Theme.labelFont,
- ])
- }
-
- /// A secondary control: the sunk plate, brand ink, the border token.
- private func styleSecondary(_ button: NSButton) {
- button.bezelStyle = .regularSquare
- button.isBordered = true
- button.bezelColor = Theme.secondary
- button.attributedTitle = NSAttributedString(string: button.title, attributes: [
- .foregroundColor: Theme.foreground,
- .font: Theme.bodyFont,
- ])
+ /// Standard push buttons: native chrome, consistent height, alignment the
+ /// layout can trust. `isDefault` makes the window's default button, which
+ /// is how macOS shows the primary action - fake filled bezels (setting
+ /// `bezelColor` on `.regularSquare`) render as mismatched tall boxes,
+ /// which is what made this panel look broken.
+ private func push(_ title: String, _ action: Selector, `default`: Bool = false) -> NSButton {
+ let button = NSButton(title: title, target: self, action: action)
+ button.bezelStyle = .rounded
+ button.controlSize = .regular
+ button.font = Theme.bodyFont
+ if `default` {
+ button.keyEquivalent = "\r"
+ }
+ return button
}
private func build() {
- let panel = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 620, height: 680),
- styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ let panel = NSWindow(contentRect: NSRect(x: 0, y: 0, width: Layout.windowWidth, height: 480),
+ styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered, defer: false)
panel.title = "Subshell virtual display helper"
- panel.titlebarAppearsTransparent = false
panel.isReleasedWhenClosed = false
// The web theme is dark-only, so the panel pins it rather than mixing a
// light-mode aqua control onto the void ground.
panel.appearance = NSAppearance(named: .darkAqua)
panel.backgroundColor = Theme.background
- panel.minSize = NSSize(width: 570, height: 500)
let stack = SettingsStack()
stack.orientation = .vertical
stack.alignment = .leading
- stack.spacing = 16
- stack.edgeInsets = NSEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
+ stack.spacing = 10
stack.translatesAutoresizingMaskIntoConstraints = false
- let scroll = NSScrollView(frame: panel.contentView!.bounds)
- scroll.hasVerticalScroller = true
- scroll.autoresizingMask = [.width, .height]
- scroll.drawsBackground = false
- scroll.documentView = stack
- panel.contentView = scroll
- NSLayoutConstraint.activate([
- stack.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
- stack.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
- stack.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
- ])
- stack.addArrangedSubview(heading("Subshell virtual display helper"))
+ // Header: the brand mark, the name, and what it does in one line.
+ let mark = NSImageView()
+ mark.image = NSImage(systemSymbolName: "display", accessibilityDescription: nil)
+ mark.contentTintColor = Theme.primary
+ mark.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
+ mark.setFrameSize(NSSize(width: 24, height: 24))
+ let header = NSStackView(views: [mark, heading("Subshell virtual display helper")])
+ header.orientation = .horizontal
+ header.spacing = 10
+
+ stack.addArrangedSubview(header)
+ stack.addArrangedSubview(detail("Capture and control of Subshell's virtual displays on this Mac."))
stack.addArrangedSubview(body(
- "Allow Subshell to capture and control virtual displays on this Mac. The helper is shared by Subshell Client and Subshell Server. It stays off until you enable it here."
- ))
- let warning = body(
- "macOS apps share your desktop, keyboard focus, and pointer. Other windows or notifications on a virtual display can appear in its stream. Only enable this for people you trust to control this Mac."
- )
- warning.textColor = Theme.warning
- stack.addArrangedSubview(warning)
+ "macOS shares one desktop: a viewer's pointer is the real cursor, and other windows can appear in the stream. Enable this only for people you trust to control this Mac."
+ ).withWarningColor())
stack.addArrangedSubview(rule())
stack.addArrangedSubview(section("Access"))
- status.font = Theme.bodyFont
+ status.font = Theme.detailFont
status.textColor = Theme.mutedForeground
+ status.preferredMaxLayoutWidth = Layout.contentWidth
stack.addArrangedSubview(status)
access.font = Theme.detailFont
access.textColor = Theme.mutedForeground
+ access.preferredMaxLayoutWidth = Layout.contentWidth
stack.addArrangedSubview(access)
- toggle.target = self
- toggle.action = #selector(toggleHelper)
- stylePrimary(toggle)
+ toggle = push("Enable helper", #selector(toggleHelper), default: true)
stack.addArrangedSubview(toggle)
stack.addArrangedSubview(detail(
- "Once enabled, the helper runs in the background and starts when you sign in. Disable it here at any time to stop capture and input and prevent it from starting again. This doesn't close your subshells or revoke macOS permissions."
+ "While enabled the helper runs in the background and starts at sign-in. Disabling stops capture and input; your subshells and macOS permissions stay."
))
- let loginItems = NSButton(title: "Open login items…", target: self, action: #selector(openLoginItems))
- styleSecondary(loginItems)
- stack.addArrangedSubview(loginItems)
stack.addArrangedSubview(rule())
+ // Permissions read as a table: the state on the left, its action on
+ // the same row, so the two states line up instead of floating.
stack.addArrangedSubview(section("macOS permissions"))
captureStatus.font = Theme.detailFont
captureStatus.textColor = Theme.mutedForeground
- stack.addArrangedSubview(captureStatus)
- capture.target = self
- capture.action = #selector(requestCapture)
- styleSecondary(capture)
- stack.addArrangedSubview(capture)
inputStatus.font = Theme.detailFont
inputStatus.textColor = Theme.mutedForeground
- stack.addArrangedSubview(inputStatus)
- input.target = self
- input.action = #selector(requestInput)
- styleSecondary(input)
- stack.addArrangedSubview(input)
+ capture = push("Allow screen recording…", #selector(requestCapture))
+ input = push("Allow accessibility…", #selector(requestInput))
+ let grid = NSGridView(views: [[captureStatus, capture], [inputStatus, input]])
+ grid.rowSpacing = 10
+ grid.columnSpacing = 14
+ grid.column(at: 0).xPlacement = .leading
+ grid.column(at: 1).xPlacement = .trailing
+ grid.translatesAutoresizingMaskIntoConstraints = false
+ stack.addArrangedSubview(grid)
stack.addArrangedSubview(rule())
- let docs = NSButton(title: "Setup instructions…", target: self, action: #selector(openDocs))
- styleSecondary(docs)
- stack.addArrangedSubview(docs)
error.font = Theme.detailFont
error.textColor = Theme.destructive
+ error.preferredMaxLayoutWidth = Layout.contentWidth
stack.addArrangedSubview(error)
- // The two ways out, together at the foot: minimize keeps the helper
- // running, quit stops the process (and revokes consent on the way).
- let foot = NSStackView()
- foot.orientation = .horizontal
- foot.spacing = 12
- let minimize = NSButton(title: "Minimize helper", target: self, action: #selector(minimizePanel))
- styleSecondary(minimize)
- let quit = NSButton(title: "Quit helper", target: self, action: #selector(quitHelper))
- styleSecondary(quit)
- foot.addArrangedSubview(minimize)
- foot.addArrangedSubview(quit)
+ // Secondary actions left, the way out right.
+ let docs = push("Setup instructions…", #selector(openDocs))
+ let loginItems = push("Open login items…", #selector(openLoginItems))
+ let minimize = push("Minimize helper", #selector(minimizePanel))
+ let quit = push("Quit helper", #selector(quitHelper))
+ let leading = NSStackView(views: [docs, loginItems])
+ leading.orientation = .horizontal
+ leading.spacing = 10
+ let trailing = NSStackView(views: [minimize, quit])
+ trailing.orientation = .horizontal
+ trailing.spacing = 10
+ let foot = NSGridView(views: [[leading, trailing]])
+ foot.column(at: 0).xPlacement = .leading
+ foot.column(at: 1).xPlacement = .trailing
+ foot.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(foot)
- for view in stack.arrangedSubviews {
- view.setContentCompressionResistancePriority(.required, for: .vertical)
- if let label = view as? NSTextField, label.lineBreakMode == .byWordWrapping {
- label.widthAnchor.constraint(equalTo: stack.widthAnchor, constant: -48).isActive = true
- }
- }
+ let container = FlippedView()
+ container.addSubview(stack)
+ NSLayoutConstraint.activate([
+ container.widthAnchor.constraint(equalToConstant: Layout.windowWidth),
+ stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Layout.inset),
+ stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Layout.inset),
+ stack.topAnchor.constraint(equalTo: container.topAnchor, constant: Layout.inset),
+ stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Layout.inset),
+ ])
+ panel.contentView = container
+ panel.layoutIfNeeded()
+ panel.setContentSize(NSSize(width: Layout.windowWidth, height: max(360, stack.fittingSize.height + 2 * Layout.inset)))
panel.center()
window = panel
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
@@ -249,7 +265,6 @@ final class HelperSettings: NSObject {
access.stringValue = line.text
access.textColor = line.warning ? Theme.warning : Theme.mutedForeground
toggle.title = enabled ? "Disable helper" : "Enable helper"
- stylePrimary(toggle)
captureStatus.stringValue = "Screen Recording: \(recording ? "Allowed" : "Not allowed")"
inputStatus.stringValue = "Accessibility: \(accessibility ? "Allowed" : "Not allowed")"
capture.isEnabled = enabled
From 14773cb907145dc595153869483bcb5fc6cd275c Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 00:54:09 -0700
Subject: [PATCH 019/120] refactor(design): one palette file, imported by every
surface
The colours were copied into each surface's stylesheet and a checker compared
the copies, which is how the assistant once shipped six near-miss values under
Dreamframe's names. They now live in packages/assistant/tokens.css and the
three CSS surfaces @import it.
packages/, not apps/server/, is deliberate: the macOS display helper's panel
(Apache, under native/) must be able to import the same file, and the AGPL
boundary runs through apps/server/. Type roles stay literal in each @theme
block because var() indirection does not work there; lint:design names any
surface that re-declares a shared value differently, and the client's parity
test now proves the import, the roles, and the absence of a local shadow.
Docs and the auto-loaded rule tell people where a token goes now.
---
.claude/rules/design-system.md | 4 +-
.../ui/src/__tests__/tokens-match-spa.test.ts | 77 +++++++++++++---
apps/client/desktop/ui/src/styles.css | 39 +-------
apps/server/desktop/ui/src/styles.css | 19 +---
apps/server/web/src/styles.css | 39 +-------
docs/design-system.md | 17 +++-
packages/assistant/tokens.css | 92 +++++++++++++++++++
scripts/__tests__/design-tokens.test.ts | 56 +++++++++++
scripts/design-tokens.ts | 85 ++++++++++++++++-
9 files changed, 316 insertions(+), 112 deletions(-)
create mode 100644 packages/assistant/tokens.css
diff --git a/.claude/rules/design-system.md b/.claude/rules/design-system.md
index b46effba6..d11a3a101 100644
--- a/.claude/rules/design-system.md
+++ b/.claude/rules/design-system.md
@@ -26,7 +26,9 @@ Colours by their shadcn names (`--foreground`, `--muted-foreground`,
`--border`, …) on every surface; mobile in camelCase from `tokens.ts`.
Spacing on the 4px grid. A `text-[13px]`, a `font-size: 14.5px`, a
`fontSize: 17` or a hex outside a token file is refused by the check; if
-the system lacks what you need, add the token to every surface in one change.
+the system lacks what you need, add the colour to `packages/assistant/tokens.css`
+once (every surface imports it); a type role is restated in each surface's
+`@theme` block, so change those together.
**Aim for one or two short sentences per UI explanation.** Use more when
consequences or limits need it. Keep warnings at the decision point; move
diff --git a/apps/client/desktop/ui/src/__tests__/tokens-match-spa.test.ts b/apps/client/desktop/ui/src/__tests__/tokens-match-spa.test.ts
index a9e1fbe64..1f7d35cca 100644
--- a/apps/client/desktop/ui/src/__tests__/tokens-match-spa.test.ts
+++ b/apps/client/desktop/ui/src/__tests__/tokens-match-spa.test.ts
@@ -3,21 +3,40 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
- * This stylesheet's palette is "COPIED VERBATIM from apps/server/web/src/styles.css"
- * — its own header says so, and until 2026-09-14 nothing checked it. Mobile
- * made the same claim and drifted a whole palette behind it (spec
- * 2026-09-14 § 1). A copy held together by a comment is a copy that has
- * already started to differ; this is the test that comment should have been.
+ * Until 2026-10-27 this stylesheet's palette was "COPIED VERBATIM from
+ * apps/server/web/src/styles.css", its own header said so, and nothing checked
+ * it until mobile made the same claim and drifted a whole palette behind it
+ * (spec 2026-09-14 § 1). The colours now live in one file,
+ * `packages/assistant/tokens.css`, which every surface imports, so "verbatim"
+ * is structural rather than a promise. What is left to check is that both
+ * surfaces really do import it, that the shared file carries the roles, and
+ * that each surface's own block still matches the other's.
*
- * Compared: every `--name: value;` declaration in the `:root, .dark` block and
+ * Compared per block: every `--name: value;` declaration in `:root, .dark` and
* every one in `@theme inline`, EXCEPT the terminal trio (`--terminal-*` and
- * their `--color-terminal-*` aliases) — this page has no terminal and does not
- * carry them. Comments and blank lines are not compared: the two files are
- * free to explain themselves differently.
+ * their `--color-terminal-*` aliases), which this page has no terminal for.
+ * Comments and blank lines are not compared; the two files may explain
+ * themselves differently.
*/
const HERE = import.meta.dir;
const CLIENT = join(HERE, "../styles.css");
const SPA = join(HERE, "../../../../../server/web/src/styles.css");
+// HERE is ui/src/__tests__: five levels up is apps/, six is the repo root.
+const SHARED = join(HERE, "../../../../../../packages/assistant/tokens.css");
+
+/** The ten colour roles the design system requires of every surface. */
+const ROLES = [
+ "background",
+ "card",
+ "border",
+ "foreground",
+ "muted-foreground",
+ "primary",
+ "primary-foreground",
+ "success",
+ "warning",
+ "destructive",
+] as const;
/** The declarations of one named block, normalised to `--name: value`. */
function declarations(css: string, opener: RegExp): string[] {
@@ -34,6 +53,40 @@ function declarations(css: string, opener: RegExp): string[] {
describe("the client's tokens are the SPA's", () => {
const client = readFileSync(CLIENT, "utf8");
const spa = readFileSync(SPA, "utf8");
+ const shared = readFileSync(SHARED, "utf8");
+
+ test("both surfaces import the one palette rather than restating it", () => {
+ expect(spa).toContain('@import "../../../../packages/assistant/tokens.css";');
+ expect(client).toContain('@import "../../../../../packages/assistant/tokens.css";');
+ });
+
+ test("the shared file declares every colour role the system requires", () => {
+ const declared = declarations(shared, /^:root \{/m);
+ for (const role of ROLES) {
+ expect(
+ declared.some((d) => d.startsWith(`--${role}:`)),
+ `--${role} missing from ${SHARED}`,
+ ).toBe(true);
+ }
+ });
+
+ test("neither surface shadows a shared colour with its own value", () => {
+ // A local restatement wins on cascade order, which is how a surface gets a
+ // private palette while importing everyone else's.
+ const sharedDecls = new Set(declarations(shared, /^:root \{/m));
+ for (const [name, css] of [
+ ["spa", spa],
+ ["client", client],
+ ] as const) {
+ for (const decl of declarations(css, /^:root,\n\.dark \{/m)) {
+ if (decl.includes("var(")) continue;
+ const role = decl.slice(2, decl.indexOf(":")).trim();
+ if (ROLES.includes(role as (typeof ROLES)[number])) {
+ expect(sharedDecls.has(decl), `${name} re-declares --${role} locally`).toBe(true);
+ }
+ }
+ }
+ });
test(":root, .dark declares the same values, terminal trio aside", () => {
expect(declarations(client, /^:root,\n\.dark \{/m)).toEqual(declarations(spa, /^:root,\n\.dark \{/m));
@@ -45,8 +98,10 @@ describe("the client's tokens are the SPA's", () => {
test("and there is something to compare", () => {
// A regex that stops matching would make both lists empty and the
- // equality above vacuous.
- expect(declarations(spa, /^:root,\n\.dark \{/m).length).toBeGreaterThan(20);
+ // equalities above vacuous. The colours left for the shared file, so the
+ // floor is the surface's own extras (button gradients, tints, aliases).
+ expect(declarations(spa, /^:root,\n\.dark \{/m).length).toBeGreaterThan(10);
expect(declarations(spa, /^@theme inline \{/m).length).toBeGreaterThan(20);
+ expect(declarations(shared, /^:root \{/m).length).toBeGreaterThan(ROLES.length - 1);
});
});
diff --git a/apps/client/desktop/ui/src/styles.css b/apps/client/desktop/ui/src/styles.css
index e39d81db4..d4f48b182 100644
--- a/apps/client/desktop/ui/src/styles.css
+++ b/apps/client/desktop/ui/src/styles.css
@@ -6,6 +6,7 @@
nothing. */
@import "tailwindcss" source(none);
@import "tw-animate-css";
+@import "../../../../../packages/assistant/tokens.css";
@source "./";
@source "../index.html";
@source "../../../../../packages/assistant/src";
@@ -27,42 +28,7 @@
*/
:root,
.dark {
- /* Ground is the operator-approved slate-indigo #1d182a (oklch 0.224); the ramp
- steps around it, hue 296° — indigo side of the family, calmer than the
- 318° magenta first pass. */
- --background: oklch(0.224 0.035 296);
- --foreground: oklch(0.92 0.03 312);
-
- --card: oklch(0.255 0.032 296);
- --card-foreground: oklch(0.92 0.03 312);
-
- --popover: oklch(0.255 0.032 296);
- --popover-foreground: oklch(0.92 0.03 312);
-
- --primary: oklch(0.75 0.17 322);
- --primary-foreground: oklch(0.16 0.05 322);
-
- --secondary: oklch(0.29 0.034 296);
- --secondary-foreground: oklch(0.9 0.025 312);
-
- --muted: oklch(0.29 0.034 296);
- --muted-foreground: oklch(0.74 0.04 310);
-
- --accent: oklch(0.33 0.04 296);
- --accent-foreground: oklch(0.95 0.02 315);
-
- --destructive: oklch(0.65 0.2 25);
- --destructive-foreground: oklch(0.98 0 0);
-
- --border: oklch(0.33 0.035 296);
- --input: oklch(0.33 0.035 296);
- --ring: oklch(0.62 0.13 322);
-
- /* Status colors for anything outside Badge (dots, inline confirmations).
- Seeded from ui/badge.tsx's success/warning variant values (Tailwind
- emerald-400 / amber-400) so chips and dots finally share one palette. */
- --success: oklch(0.765 0.177 163.223);
- --warning: oklch(0.828 0.189 84.429);
+ /* The palette, and why each value is what it is, lives in packages/assistant/tokens.css. */
/* The modal scrim, behind a dialog or sheet. Hex with alpha rather than a
role colour: it is BLACK at 70%, not a tint of the palette, and every
@@ -70,7 +36,6 @@
`lint:design` compares them. It was `bg-black/70` written twice at the
callsites, which is the one colour literal Tailwind's own utility made
invisible to the scanner (it sees hex and oklch, not colour WORDS). */
- --scrim: #000000b3;
/* Emphasis surfaces the role palette has no name for: the sunk-plum
primary-button gradient, the sidebar's active-nav gradient and the
diff --git a/apps/server/desktop/ui/src/styles.css b/apps/server/desktop/ui/src/styles.css
index ccdcf8575..ac3437e97 100644
--- a/apps/server/desktop/ui/src/styles.css
+++ b/apps/server/desktop/ui/src/styles.css
@@ -1,4 +1,5 @@
@import "tailwindcss";
+@import "../../../../../packages/assistant/tokens.css";
/* The shared Frame (`@internal/assistant`) renders with Tailwind utilities but
lives OUTSIDE this app's Vite root, so Tailwind v4's auto-detection never
scans it and every utility it names generated nothing — measured in the dev
@@ -23,24 +24,6 @@
(spec 2026-09-14 § 3.2) — so a rule here reads the same as one in the
SPA, and `lint:design` can compare the three files by name. Values are
unchanged. */
- --background: oklch(0.224 0.035 296);
- --card: oklch(0.255 0.032 296);
- --border: oklch(0.33 0.035 296);
- --foreground: oklch(0.92 0.03 312);
- --muted-foreground: oklch(0.74 0.04 310);
- --primary: oklch(0.75 0.17 322);
- --primary-foreground: oklch(0.16 0.05 322);
- --success: oklch(0.765 0.177 163.223);
- --warning: oklch(0.828 0.189 84.429);
- --destructive: oklch(0.65 0.2 25);
- --destructive-foreground: oklch(0.98 0 0);
- --secondary: oklch(0.29 0.034 296);
- --secondary-foreground: oklch(0.9 0.025 312);
- --muted: oklch(0.29 0.034 296);
- --accent: oklch(0.33 0.04 296);
- --accent-foreground: oklch(0.95 0.02 315);
- --input: oklch(0.33 0.035 296);
- --ring: oklch(0.62 0.13 322);
/* The primary button's "sunk plum" gradient (Dreamframe: quiet at the
component, bright at the token). Named here because `lint:design`
refuses colour literals outside a token block, and a gradient IS a
diff --git a/apps/server/web/src/styles.css b/apps/server/web/src/styles.css
index 8ef429244..71182220b 100644
--- a/apps/server/web/src/styles.css
+++ b/apps/server/web/src/styles.css
@@ -1,6 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./styles/dockview-theme.css";
+@import "../../../../packages/assistant/tokens.css";
/*
* The shared UI package has to be named, or half its styling silently vanishes.
@@ -58,42 +59,7 @@
*/
:root,
.dark {
- /* Ground is the operator-approved slate-indigo #1d182a (oklch 0.224); the ramp
- steps around it, hue 296° — indigo side of the family, calmer than the
- 318° magenta first pass. */
- --background: oklch(0.224 0.035 296);
- --foreground: oklch(0.92 0.03 312);
-
- --card: oklch(0.255 0.032 296);
- --card-foreground: oklch(0.92 0.03 312);
-
- --popover: oklch(0.255 0.032 296);
- --popover-foreground: oklch(0.92 0.03 312);
-
- --primary: oklch(0.75 0.17 322);
- --primary-foreground: oklch(0.16 0.05 322);
-
- --secondary: oklch(0.29 0.034 296);
- --secondary-foreground: oklch(0.9 0.025 312);
-
- --muted: oklch(0.29 0.034 296);
- --muted-foreground: oklch(0.74 0.04 310);
-
- --accent: oklch(0.33 0.04 296);
- --accent-foreground: oklch(0.95 0.02 315);
-
- --destructive: oklch(0.65 0.2 25);
- --destructive-foreground: oklch(0.98 0 0);
-
- --border: oklch(0.33 0.035 296);
- --input: oklch(0.33 0.035 296);
- --ring: oklch(0.62 0.13 322);
-
- /* Status colors for anything outside Badge (dots, inline confirmations).
- Seeded from ui/badge.tsx's success/warning variant values (Tailwind
- emerald-400 / amber-400) so chips and dots finally share one palette. */
- --success: oklch(0.765 0.177 163.223);
- --warning: oklch(0.828 0.189 84.429);
+ /* The palette, and why each value is what it is, lives in packages/assistant/tokens.css. */
/* The modal scrim, behind a dialog or sheet. Hex with alpha rather than a
role colour: it is BLACK at 70%, not a tint of the palette, and every
@@ -101,7 +67,6 @@
`lint:design` compares them. It was `bg-black/70` written twice at the
callsites, which is the one colour literal Tailwind's own utility made
invisible to the scanner (it sees hex and oklch, not colour WORDS). */
- --scrim: #000000b3;
/* Emphasis surfaces the role palette has no name for: the sunk-plum
primary-button gradient, the sidebar's active-nav gradient and the
diff --git a/docs/design-system.md b/docs/design-system.md
index 2e3d18ba3..8513645f9 100644
--- a/docs/design-system.md
+++ b/docs/design-system.md
@@ -7,6 +7,15 @@ living reference; the decision record is
`docs/superpowers/specs/2026-09-14-design-system-design.md`, and the check
that enforces it is `bun run lint:design` (`scripts/design-tokens.ts`).
+**Colours live in one file**: `packages/assistant/tokens.css`, imported by every
+CSS surface. It sits in `packages/` (Apache-2.0) rather than beside the surfaces
+so that anything rendering the product may import it, including the macOS
+display helper's panel; the old alternative was each surface keeping a copy and
+the checker policing the copies, which is how the assistant once came to carry
+six near-miss values under Dreamframe's names. Type roles stay declared
+literally inside each surface's Tailwind `@theme` block (`var()` indirection
+does not work there), and the check compares them to the shared scale.
+
**The one rule:** pick a ROLE, never a number. Sizes, weights and colours are
tokens; a literal outside a token file is refused. Refused by the scanner
specifically means: class literals (`text-[13px]`), CSS `font-size:` /
@@ -452,6 +461,8 @@ admitted here.
## When you need something the system lacks
-Add the token, with its reason, to every surface's token file in one change,
-and let `lint:design` confirm they agree. Do not add a literal at the callsite;
-that is the exact move that produced eleven font sizes.
+A colour goes in `packages/assistant/tokens.css` once, with its reason, and
+every surface picks it up. A type role has to be restated in each surface's
+`@theme` block, so change them together; `lint:design` fails the run if one
+surface's value disagrees with the shared scale. Do not add a literal at the
+callsite: that is the exact move that produced eleven font sizes.
diff --git a/packages/assistant/tokens.css b/packages/assistant/tokens.css
new file mode 100644
index 000000000..757ac248b
--- /dev/null
+++ b/packages/assistant/tokens.css
@@ -0,0 +1,92 @@
+/**
+ * The Subshell palette and type scale: one source, imported by every surface
+ * that renders the product — the SPA, both desktop assistants, the node
+ * dashboard, and the macOS display helper's panel.
+ *
+ * This file deliberately lives in `packages/` (Apache-2.0) rather than beside
+ * the surfaces that use it. The alternative, keeping the block in
+ * `apps/server/web/src/styles.css` and importing it from Apache code, would
+ * pull AGPL text into an Apache artifact; the repo's licence line runs through
+ * `apps/server/`, and a stylesheet is implementation, not types, so the
+ * API Type Surface exception does not reach it. Copying the values instead
+ * (what `apps/node/web` did first) leaves N palettes that a checker must keep
+ * equal; one file leaves nothing to drift.
+ *
+ * Spec: docs/superpowers/specs/2026-09-14-design-system-design.md § 3.
+ * `bun run lint:design` checks the roles, the scale, and the contrast.
+ */
+
+/*
+ * Ground is the operator-approved slate-indigo #1d182a (oklch 0.224); the ramp
+ * steps around it at hue 296°, the indigo side of the family, calmer than the
+ * 318° magenta first pass. `--primary` stays bright so text-primary, links and
+ * border-primary glows read on the void; primary BUTTONS get a quieter sunk
+ * plum gradient in each surface's own button rule rather than riding the token.
+ *
+ * Status colors for anything outside Badge (dots, inline confirmations) are
+ * seeded from Badge's success/warning variants (Tailwind emerald-400 and
+ * amber-400) so chips and dots share one palette.
+ *
+ * `--scrim` is not a tint of the palette: it is BLACK at 70%, and every
+ * surface that draws a modal draws the same one.
+ *
+ * The theme is dark-only, so there is no light block to keep in step.
+ */
+:root {
+ --background: oklch(0.224 0.035 296);
+ --foreground: oklch(0.92 0.03 312);
+
+ --card: oklch(0.255 0.032 296);
+ --card-foreground: oklch(0.92 0.03 312);
+
+ --popover: oklch(0.255 0.032 296);
+ --popover-foreground: oklch(0.92 0.03 312);
+
+ --primary: oklch(0.75 0.17 322);
+ --primary-foreground: oklch(0.16 0.05 322);
+
+ --secondary: oklch(0.29 0.034 296);
+ --secondary-foreground: oklch(0.9 0.025 312);
+
+ --muted: oklch(0.29 0.034 296);
+ --muted-foreground: oklch(0.74 0.04 310);
+
+ --accent: oklch(0.33 0.04 296);
+ --accent-foreground: oklch(0.95 0.02 315);
+
+ --destructive: oklch(0.65 0.2 25);
+ --destructive-foreground: oklch(0.98 0 0);
+
+ --border: oklch(0.33 0.035 296);
+ --input: oklch(0.33 0.035 296);
+ --ring: oklch(0.62 0.13 322);
+
+ --success: oklch(0.765 0.177 163.223);
+ --warning: oklch(0.828 0.189 84.429);
+
+ --scrim: #000000b3;
+}
+
+/*
+ * Five type roles at two weights. There is no 12px: `detail` (13) is the
+ * floor, and `text-xs`/`text-caption` are refused by the check. Surfaces write
+ * the roles, never a number — `text-label font-strong`,
+ * `font-size: var(--text-label)`, or mobile's `font("label")`.
+ */
+:root {
+ --text-display: 30px;
+ --text-display--line-height: 1.2;
+ --text-heading: 20px;
+ --text-heading--line-height: 1.2;
+ --text-label: 15px;
+ --text-label--line-height: 1.5;
+ --text-body: 14px;
+ --text-body--line-height: 1.5;
+ --text-detail: 13px;
+ --text-detail--line-height: 1.5;
+
+ --font-weight-strong: 600;
+ --font-weight-regular: 400;
+
+ --default-font-family: ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif;
+}
diff --git a/scripts/__tests__/design-tokens.test.ts b/scripts/__tests__/design-tokens.test.ts
index 6aae8d2f3..32e1cb2f3 100644
--- a/scripts/__tests__/design-tokens.test.ts
+++ b/scripts/__tests__/design-tokens.test.ts
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
import {
agreementProblems,
COLOR_ROLES,
+ CSS_SURFACES,
contrastProblems,
contrastRatio,
cssColorAgreement,
@@ -12,6 +15,9 @@ import {
oklchToSrgb,
parseCssTokens,
parseOklch,
+ REPO_ROOT,
+ SHARED_TOKENS,
+ sharedRedeclarationProblems,
TYPE_ROLES,
WEB_SCALE,
} from "../design-tokens";
@@ -389,3 +395,53 @@ describe("contrastProblems", () => {
expect(problems[0]).toContain("4.5");
});
});
+
+describe("the shared token file", () => {
+ const shared = readFileSync(join(REPO_ROOT, SHARED_TOKENS), "utf8");
+
+ test("every colour role the system requires is declared once, in the shared file", () => {
+ const t = parseCssTokens(shared);
+ for (const role of COLOR_ROLES) expect(t.colors[role]).toBeDefined();
+ // scrim is optional per surface but must have one value to be optional against.
+ expect(t.colors.scrim).toBe("#000000b3");
+ });
+
+ test("names a surface that re-declares a shared colour with a near-miss value", () => {
+ // The assistant once carried six "near-Dreamframe" values under Dreamframe's
+ // names; comparing names only called that agreement. Same guard, now that
+ // the colours live in one file.
+ const tuned = ":root {\n --warning: oklch(0.828 0.189 90);\n}";
+ expect(sharedRedeclarationProblems("assistant", shared, tuned)).toEqual([
+ expect.stringContaining("assistant: --warning is oklch(0.828 0.189 90)"),
+ ]);
+ });
+
+ test("tolerates a surface restating a shared value identically", () => {
+ // Tailwind's @theme block re-states the type roles literally in several
+ // files; that is a build convention, and var() indirection does not work
+ // there. Only a different value is drift.
+ const t = parseCssTokens(shared);
+ const same = `:root {\n --warning: ${t.colors.warning};\n --text-label: 15px;\n}`;
+ expect(sharedRedeclarationProblems("client", shared, same)).toEqual([]);
+ });
+
+ test("names a type role or weight a surface restates at a different value", () => {
+ expect(sharedRedeclarationProblems("spa", shared, ":root {\n --text-detail: 12px;\n}")).toEqual([
+ expect.stringContaining("--text-detail is 12px"),
+ ]);
+ expect(sharedRedeclarationProblems("spa", shared, ":root {\n --font-weight-strong: 700;\n}")).toEqual([
+ expect.stringContaining("--font-weight-strong is 700"),
+ ]);
+ });
+
+ test("the three real surfaces still resolve a complete palette through the import", () => {
+ // The refactor's risk is a role that existed only in a surface file and now
+ // exists nowhere: each surface read as shared-plus-own must still satisfy
+ // every required role and clear contrast.
+ for (const surface of ["spa", "client", "assistant"] as const) {
+ const merged = `${shared}\n${readFileSync(join(REPO_ROOT, CSS_SURFACES[surface]), "utf8")}`;
+ expect(agreementProblems(surface, parseCssTokens(merged), WEB_SCALE)).toEqual([]);
+ }
+ expect(contrastProblems(`${shared}\n${readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8")}`)).toEqual([]);
+ });
+});
diff --git a/scripts/design-tokens.ts b/scripts/design-tokens.ts
index 5e711dcbf..f80d90cc1 100644
--- a/scripts/design-tokens.ts
+++ b/scripts/design-tokens.ts
@@ -489,6 +489,67 @@ export const CSS_SURFACES: Record, string> = {
export const MOBILE_TOKENS = "apps/client/mobile/src/lib/tokens.ts";
+/**
+ * The palette and type scale, one file, imported by every CSS surface.
+ *
+ * It lives in `packages/` (Apache-2.0) so the surfaces that may import it are
+ * not limited by the `apps/server/` licence line — the macOS display helper's
+ * panel is Apache and renders the same design system. The surfaces read it
+ * with a CSS `@import`; the checker reads it here and merges, so a role can
+ * be declared exactly once and a surface that re-declares it differently is
+ * reported rather than silently winning on cascade order.
+ */
+export const SHARED_TOKENS = "packages/assistant/tokens.css";
+
+/** One surface's tokens: the shared file plus its own declarations. */
+export function readSurfaceCss(repoRoot: string, surface: Exclude, sharedCss: string): string {
+ return `${sharedCss}\n${readFileSync(join(repoRoot, CSS_SURFACES[surface]), "utf8")}`;
+}
+
+/**
+ * A surface re-declaring something the shared file already sets, with a
+ * different value.
+ *
+ * Identical duplicates are not reported: Tailwind's `@theme` block re-states
+ * the type roles in several files, and that is a build convention, not drift.
+ * A different value is the thing that must never happen — it means one screen
+ * renders the brand and the next renders a near-miss of it, which is the
+ * failure the whole token system exists to end.
+ */
+export function sharedRedeclarationProblems(surface: string, sharedCss: string, ownCss: string): string[] {
+ const norm = (v: string) => v.replace(/\s+/g, " ").replace(/\(\s/g, "(").replace(/\s\)/g, ")").trim();
+ const shared = parseCssTokens(sharedCss);
+ const own = parseCssTokens(ownCss);
+ const problems: string[] = [];
+ for (const [role, value] of Object.entries(own.colors)) {
+ const baseline = shared.colors[role];
+ if (baseline !== undefined && norm(baseline) !== norm(value)) {
+ problems.push(`${surface}: --${role} is ${norm(value)}, but ${SHARED_TOKENS} sets ${norm(baseline)}`);
+ }
+ }
+ for (const role of TYPE_ROLES) {
+ const want = shared.text[role];
+ const have = own.text[role];
+ if (!want || !have) continue;
+ if (Number.isFinite(want.size) && Number.isFinite(have.size) && have.size !== want.size) {
+ problems.push(`${surface}: --text-${role} is ${have.size}px, but ${SHARED_TOKENS} sets ${want.size}px`);
+ }
+ if (want.lineHeight !== undefined && have.lineHeight !== undefined && have.lineHeight !== want.lineHeight) {
+ problems.push(
+ `${surface}: --text-${role}--line-height is ${have.lineHeight}, but ${SHARED_TOKENS} sets ${want.lineHeight}`,
+ );
+ }
+ }
+ for (const key of ["strong", "regular"] as const) {
+ const want = shared.weights[key];
+ const have = own.weights[key];
+ if (want !== undefined && have !== undefined && have !== want) {
+ problems.push(`${surface}: --font-weight-${key} is ${have}, but ${SHARED_TOKENS} sets ${want}`);
+ }
+ }
+ return problems;
+}
+
/** The agreement half of the check, all four surfaces. */
/**
* The three CSS surfaces must agree on colour VALUES, not only names.
@@ -520,15 +581,25 @@ export function cssColorAgreement(spaCss: string, others: Record
export async function allAgreement(only?: Surface): Promise {
const problems: string[] = [];
- const css = Object.fromEntries(
- (Object.entries(CSS_SURFACES) as [Exclude, string][]).map(([surface, rel]) => [
+ const sharedCss = readFileSync(join(REPO_ROOT, SHARED_TOKENS), "utf8");
+ const own = Object.fromEntries(
+ (Object.keys(CSS_SURFACES) as Exclude[]).map((surface) => [
surface,
- readFileSync(join(REPO_ROOT, rel), "utf8"),
+ readFileSync(join(REPO_ROOT, CSS_SURFACES[surface]), "utf8"),
]),
) as Record, string>;
+ // Every surface is read as the shared file plus its own: a role declared
+ // once in the palette counts as declared by all three.
+ const css = Object.fromEntries(
+ (Object.keys(own) as Exclude[]).map((surface) => [surface, `${sharedCss}\n${own[surface]}`]),
+ ) as Record, string>;
for (const surface of Object.keys(css) as Exclude[]) {
if (only && only !== surface) continue;
problems.push(...agreementProblems(surface, parseCssTokens(css[surface]), WEB_SCALE));
+ // A surface that re-declares a shared role with a different value wins on
+ // cascade order and quietly changes what that screen looks like; that must
+ // be a red run, not a design review finding.
+ problems.push(...sharedRedeclarationProblems(surface, sharedCss, own[surface]));
}
// Values, not just names: the SPA is the source and the other two must
// match it. Scoped by --only so a single-surface run reports only its own.
@@ -545,7 +616,7 @@ export async function allAgreement(only?: Surface): Promise {
colors: Record;
type: Record;
};
- problems.push(...mobileAgreement(readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8"), mod));
+ problems.push(...mobileAgreement(css.spa, mod));
}
return problems;
}
@@ -594,7 +665,11 @@ if (import.meta.main) {
const agreement = await allAgreement(only);
const escapes = allEscapes(only);
const contrast =
- only && only !== "spa" ? [] : contrastProblems(readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8"));
+ only && only !== "spa"
+ ? []
+ : contrastProblems(
+ `${readFileSync(join(REPO_ROOT, SHARED_TOKENS), "utf8")}\n${readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8")}`,
+ );
const total = agreement.length + escapes.length + contrast.length;
if (total === 0) {
console.log("✓ design system: tokens agree, no escapes, contrast clears AA");
From d5b935e207227687722a2e68491d0a3992d8b49d Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 01:20:36 -0700
Subject: [PATCH 020/120] feat(helper): render the display helper's panel on
the design system
The native AppKit layout kept arriving at the Mac looking wrong: boxed
controls of mismatched height, rows clipped by the window, and a mirrored
copy of the palette nothing checked. It is now a local page in
packages/helper-panel, built on packages/assistant/tokens.css, and the
helper hosts it in a web view behind the same HelperSettings API.
The page holds no authority. It loads with loadFileURL scoped to its own
directory, from a non-persistent data store, cannot open a window or
navigate off-bundle, and every act it posts is re-checked in Swift.
What follows from that:
- lint:design scans the package under the assistant rules, and a test
pins the registration, because a scan root that silently matches
nothing is the same hole as no scan root.
- lint:prose sweeps its string literals: consent copy is shipped text.
- The Swift Theme keeps only the three colours and one type role the
native chrome still paints, and AccessReport loses the panel line the
page now owns.
- build_support.rs stages dist into Contents/Resources/panel, before the
signature, and prunes hashed assets a previous build left behind. A
release refuses to bundle without the page; a debug build carries a
placeholder naming the command, since the CI Rust lanes have no
node_modules to build it from.
- Both desktop apps take @internal/helper-panel as a devDependency, so
turbo builds it before either bundles the helper.
---
.changeset/virtual-display-rail-entry.md | 6 +-
apps/client/desktop/package.json | 1 +
apps/server/desktop/package.json | 1 +
bun.lock | 14 +
native/display-helper/Settings.swift | 461 +++++++++---------
native/display-helper/Theme.swift | 60 +--
native/display-helper/build_support.rs | 235 ++++++++-
native/display-helper/tests/HelperTests.swift | 14 +-
packages/helper-panel/biome.json | 4 +
packages/helper-panel/index.html | 12 +
packages/helper-panel/package.json | 22 +
.../src/__tests__/strings.test.ts | 83 ++++
packages/helper-panel/src/main.ts | 134 +++++
packages/helper-panel/src/state.ts | 42 ++
packages/helper-panel/src/strings.ts | 47 ++
packages/helper-panel/src/styles.css | 168 +++++++
packages/helper-panel/src/vite-env.d.ts | 1 +
packages/helper-panel/tsconfig.json | 11 +
packages/helper-panel/turbo.json | 18 +
packages/helper-panel/vite.config.ts | 21 +
scripts/__tests__/design-tokens.test.ts | 17 +-
scripts/design-tokens.ts | 10 +-
scripts/prose-dashes.ts | 4 +
scripts/rust-check.sh | 10 +
24 files changed, 1103 insertions(+), 293 deletions(-)
create mode 100644 packages/helper-panel/biome.json
create mode 100644 packages/helper-panel/index.html
create mode 100644 packages/helper-panel/package.json
create mode 100644 packages/helper-panel/src/__tests__/strings.test.ts
create mode 100644 packages/helper-panel/src/main.ts
create mode 100644 packages/helper-panel/src/state.ts
create mode 100644 packages/helper-panel/src/strings.ts
create mode 100644 packages/helper-panel/src/styles.css
create mode 100644 packages/helper-panel/src/vite-env.d.ts
create mode 100644 packages/helper-panel/tsconfig.json
create mode 100644 packages/helper-panel/turbo.json
create mode 100644 packages/helper-panel/vite.config.ts
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
index 396e79b59..7525506b0 100644
--- a/.changeset/virtual-display-rail-entry.md
+++ b/.changeset/virtual-display-rail-entry.md
@@ -14,4 +14,8 @@ A launch refused over its virtual display now reports the problem beside the **V
The preview icon in a pane's header now wears a count when the pane has things you could open but have not saved: discovered app ports with no Saved services row, plus the screen and the browser watch when the pane offers them. It is the picker's own data, so the number and the list cannot disagree.
-The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (It reached the releases because the code is macOS-only and Linux CI never compiles it.)
+The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (It reached the releases because the code is macOS-only and Linux CI never compiles it.) Pressing it then opened nothing either: the helper read its own preferences through a `UserDefaults` suite named after its bundle identifier, which Foundation refuses, so the process trapped before any window existed. Both are fixed, and a helper that cannot find its panel now says so instead of showing an empty window.
+
+The panel itself is now the same product as the rest of Subshell: heading, label, body, and detail from the shared palette, gold for what you can fix and red for what macOS refused, a live line naming how many people are watching this Mac's screens, and buttons for the exits with the names they deserve. It is a small local page bundled into the helper, so the design system's own checker guards it, and the palette it draws on moved to `packages/assistant/tokens.css` where every surface imports the same one instead of copying it.
+
+Discarding the last virtual display on a Mac also hands the pointer back. A viewer driving that screen leaves the real cursor on a display that no longer exists, which looked like a vanished cursor to whoever was sitting at the machine.
diff --git a/apps/client/desktop/package.json b/apps/client/desktop/package.json
index d2e69a400..4346491a4 100644
--- a/apps/client/desktop/package.json
+++ b/apps/client/desktop/package.json
@@ -38,6 +38,7 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "21.0.4",
+ "@internal/helper-panel": "workspace:*",
"@internal/tsconfig": "workspace:*",
"@tailwindcss/vite": "4.3.3",
"@tauri-apps/cli": "2.11.4",
diff --git a/apps/server/desktop/package.json b/apps/server/desktop/package.json
index eb53fc6bf..dff2e2f35 100644
--- a/apps/server/desktop/package.json
+++ b/apps/server/desktop/package.json
@@ -35,6 +35,7 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "21.0.4",
+ "@internal/helper-panel": "workspace:*",
"@internal/tsconfig": "workspace:*",
"@tailwindcss/vite": "4.3.3",
"@tauri-apps/cli": "2.11.4",
diff --git a/bun.lock b/bun.lock
index 5a93954f1..e751f9a1f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -51,6 +51,7 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "21.0.4",
+ "@internal/helper-panel": "workspace:*",
"@internal/tsconfig": "workspace:*",
"@tailwindcss/vite": "4.3.3",
"@tauri-apps/cli": "2.11.4",
@@ -245,6 +246,7 @@
},
"devDependencies": {
"@happy-dom/global-registrator": "21.0.4",
+ "@internal/helper-panel": "workspace:*",
"@internal/tsconfig": "workspace:*",
"@tailwindcss/vite": "4.3.3",
"@tauri-apps/cli": "2.11.4",
@@ -413,6 +415,16 @@
"tsdown": "0.22.14",
},
},
+ "packages/helper-panel": {
+ "name": "@internal/helper-panel",
+ "version": "1.0.0",
+ "devDependencies": {
+ "@internal/tsconfig": "workspace:*",
+ "@types/bun": "1.3.14",
+ "typescript": "7.0.2",
+ "vite": "8.2.4",
+ },
+ },
"packages/mcp-core": {
"name": "@internal/mcp-core",
"version": "1.0.0",
@@ -1246,6 +1258,8 @@
"@internal/e2e": ["@internal/e2e@workspace:e2e"],
+ "@internal/helper-panel": ["@internal/helper-panel@workspace:packages/helper-panel"],
+
"@internal/mcp-core": ["@internal/mcp-core@workspace:packages/mcp-core"],
"@internal/mobile": ["@internal/mobile@workspace:apps/client/mobile"],
diff --git a/native/display-helper/Settings.swift b/native/display-helper/Settings.swift
index 4643690cf..bcfe5462c 100644
--- a/native/display-helper/Settings.swift
+++ b/native/display-helper/Settings.swift
@@ -1,61 +1,65 @@
import AppKit
import ApplicationServices
import ServiceManagement
+import WebKit
-/// The helper's own panel. It is the consent surface, so it is native AppKit:
-/// a local prompt the person can trust should not be HTML, and the buttons
-/// that open System Settings must be the real thing.
+/// Where the panel's own files live inside the helper bundle.
///
-/// It is styled from `Theme`, the mirror of the dashboard's token file, so a
-/// person who knows Subshell from the browser recognizes the product here:
-/// the void ground, the plum accent, heading/label/body/detail at the sizes
-/// the web uses, gold for what the owner can fix and red for what the machine
-/// refused.
-@MainActor
-private final class SettingsStack: NSStackView {
- override var isFlipped: Bool { true }
-}
-
-@MainActor
-private final class FlippedView: NSView {
- override var isFlipped: Bool { true }
-}
+/// File-scope rather than a member of `HelperSettings` because the navigation
+/// check has to run on whatever thread WebKit calls the delegate on, and a
+/// `@MainActor` type's statics are main-actor state.
+private enum PanelBundle {
+ /// Staged by `build_support.rs` from `packages/helper-panel/dist`.
+ static func indexURL() -> URL? {
+ Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "panel")
+ }
-/// One column, one rhythm. The panel is a settings card, not a report: a
-/// width this text can actually be read at, and everything measured from it.
-private enum Layout {
- static let windowWidth: CGFloat = 420
- static let inset: CGFloat = 20
- static var contentWidth: CGFloat { windowWidth - 2 * inset }
-}
+ /// The one directory the page may load from, and the only one the file
+ /// read is scoped to.
+ static func directoryURL() -> URL? { indexURL()?.deletingLastPathComponent() }
-private extension NSTextField {
- /// The gold the design system reserves for "the person who owns this can
- /// fix it here" - the same role the dashboard's `text-warning` holds.
- func withWarningColor() -> NSTextField {
- textColor = Theme.warning
- return self
+ /// Allow a main-frame navigation only while the document stays inside the
+ /// bundle. The bridge can enable capture, so a page loaded from anywhere
+ /// else would be handed that button.
+ static func allows(_ url: URL?) -> Bool {
+ guard let url, let directory = directoryURL(), url.isFileURL else { return false }
+ return url.path.hasPrefix(directory.path + "/") || url.path == directory.path
}
}
+/// The helper's panel: its one consent surface.
+///
+/// The page is `packages/helper-panel`, built on the shared design system
+/// (`packages/assistant/tokens.css`), so the panel reads as the same product as
+/// the dashboard instead of as a hand-rolled approximation of it. It is still
+/// not an authority. It is loaded with `loadFileURL` scoped to its own
+/// directory, from a non-persistent data store, with no window opening and no
+/// navigation off-bundle, so it can fetch nothing and hold nothing: what it
+/// shows comes from the helper process, and every act it offers is re-checked
+/// in Swift. A page that somehow posts `requestCapture` while consent is off
+/// gets the same refusal as a person who cannot click it.
@MainActor
-final class HelperSettings: NSObject {
+final class HelperSettings: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
+ private static let bridge = "subshell"
+ private static let docsURL = "https://docs.subshell.sh/nodes/manage#macos-display-helper"
+
private let consent: HelperConsent
private let enable: () throws -> Void
private let disable: () -> Void
private let accessCount: () -> Int
+ private var web: WKWebView?
private var window: NSWindow?
- private let status = NSTextField(wrappingLabelWithString: "")
- private let access = NSTextField(wrappingLabelWithString: "")
- private let error = NSTextField(wrappingLabelWithString: "")
- private let captureStatus = NSTextField(labelWithString: "")
- private let inputStatus = NSTextField(labelWithString: "")
- // Built once in `build()`; these placeholders exist so a state change
- // arriving before the first `show()` has something safe to address.
- private var toggle = NSButton()
- private var capture = NSButton()
- private var input = NSButton()
+ /// The page's own entry point, so a state push before it exists is skipped
+ /// rather than swallowed by a rejected script.
+ private var loaded = false
+ /// The last JSON handed to the page. The poll runs every two seconds, and
+ /// re-rendering an unchanged panel would move the caret and the selection.
+ private var lastPushed = ""
+ private var errorMessage = ""
private var timer: Timer?
+ /// What the person sees when the bundle has no panel: a named problem on
+ /// the window itself, not an empty dark rectangle.
+ private let fallback = NSTextField(wrappingLabelWithString: "")
init(consent: HelperConsent,
enable: @escaping () throws -> Void,
@@ -84,239 +88,228 @@ final class HelperSettings: NSObject {
/// there to bring it back.
func minimize() { window?.orderOut(nil) }
- private func heading(_ text: String) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.font = Theme.headingFont
- label.textColor = Theme.foreground
- return label
- }
-
- private func section(_ text: String) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.font = Theme.labelFont
- label.textColor = Theme.foreground
- return label
- }
-
- private func body(_ text: String) -> NSTextField {
- let label = NSTextField(wrappingLabelWithString: text)
- label.font = Theme.bodyFont
- label.textColor = Theme.foreground
- label.isSelectable = false
- label.preferredMaxLayoutWidth = Layout.contentWidth
- return label
- }
-
- private func detail(_ text: String) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.font = Theme.detailFont
- label.textColor = Theme.mutedForeground
- return label
- }
-
- /// A 1px rule in the border token: the only separator the panel needs,
- /// because grouping is carried by headings and spacing, not by lines.
- private func rule() -> NSView {
- let line = NSView()
- line.wantsLayer = true
- line.layer?.backgroundColor = Theme.border.cgColor
- line.translatesAutoresizingMaskIntoConstraints = false
- line.heightAnchor.constraint(equalToConstant: 1).isActive = true
- line.widthAnchor.constraint(equalToConstant: Layout.contentWidth).isActive = true
- return line
- }
-
- /// Standard push buttons: native chrome, consistent height, alignment the
- /// layout can trust. `isDefault` makes the window's default button, which
- /// is how macOS shows the primary action - fake filled bezels (setting
- /// `bezelColor` on `.regularSquare`) render as mismatched tall boxes,
- /// which is what made this panel look broken.
- private func push(_ title: String, _ action: Selector, `default`: Bool = false) -> NSButton {
- let button = NSButton(title: title, target: self, action: action)
- button.bezelStyle = .rounded
- button.controlSize = .regular
- button.font = Theme.bodyFont
- if `default` {
- button.keyEquivalent = "\r"
- }
- return button
- }
+ /// Push the machine's current truth to the page. Cheap to call: identical
+ /// state is not sent twice.
+ func refresh() { pushState() }
private func build() {
- let panel = NSWindow(contentRect: NSRect(x: 0, y: 0, width: Layout.windowWidth, height: 480),
+ let panel = NSWindow(contentRect: NSRect(x: 0, y: 0, width: Layout.windowWidth, height: Layout.initialHeight),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered, defer: false)
panel.title = "Subshell virtual display helper"
panel.isReleasedWhenClosed = false
// The web theme is dark-only, so the panel pins it rather than mixing a
- // light-mode aqua control onto the void ground.
+ // light-mode aqua title bar onto the void ground.
panel.appearance = NSAppearance(named: .darkAqua)
panel.backgroundColor = Theme.background
- let stack = SettingsStack()
- stack.orientation = .vertical
- stack.alignment = .leading
- stack.spacing = 10
- stack.translatesAutoresizingMaskIntoConstraints = false
-
- // Header: the brand mark, the name, and what it does in one line.
- let mark = NSImageView()
- mark.image = NSImage(systemSymbolName: "display", accessibilityDescription: nil)
- mark.contentTintColor = Theme.primary
- mark.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
- mark.setFrameSize(NSSize(width: 24, height: 24))
- let header = NSStackView(views: [mark, heading("Subshell virtual display helper")])
- header.orientation = .horizontal
- header.spacing = 10
-
- stack.addArrangedSubview(header)
- stack.addArrangedSubview(detail("Capture and control of Subshell's virtual displays on this Mac."))
- stack.addArrangedSubview(body(
- "macOS shares one desktop: a viewer's pointer is the real cursor, and other windows can appear in the stream. Enable this only for people you trust to control this Mac."
- ).withWarningColor())
- stack.addArrangedSubview(rule())
-
- stack.addArrangedSubview(section("Access"))
- status.font = Theme.detailFont
- status.textColor = Theme.mutedForeground
- status.preferredMaxLayoutWidth = Layout.contentWidth
- stack.addArrangedSubview(status)
- access.font = Theme.detailFont
- access.textColor = Theme.mutedForeground
- access.preferredMaxLayoutWidth = Layout.contentWidth
- stack.addArrangedSubview(access)
- toggle = push("Enable helper", #selector(toggleHelper), default: true)
- stack.addArrangedSubview(toggle)
- stack.addArrangedSubview(detail(
- "While enabled the helper runs in the background and starts at sign-in. Disabling stops capture and input; your subshells and macOS permissions stay."
- ))
- stack.addArrangedSubview(rule())
-
- // Permissions read as a table: the state on the left, its action on
- // the same row, so the two states line up instead of floating.
- stack.addArrangedSubview(section("macOS permissions"))
- captureStatus.font = Theme.detailFont
- captureStatus.textColor = Theme.mutedForeground
- inputStatus.font = Theme.detailFont
- inputStatus.textColor = Theme.mutedForeground
- capture = push("Allow screen recording…", #selector(requestCapture))
- input = push("Allow accessibility…", #selector(requestInput))
- let grid = NSGridView(views: [[captureStatus, capture], [inputStatus, input]])
- grid.rowSpacing = 10
- grid.columnSpacing = 14
- grid.column(at: 0).xPlacement = .leading
- grid.column(at: 1).xPlacement = .trailing
- grid.translatesAutoresizingMaskIntoConstraints = false
- stack.addArrangedSubview(grid)
- stack.addArrangedSubview(rule())
-
- error.font = Theme.detailFont
- error.textColor = Theme.destructive
- error.preferredMaxLayoutWidth = Layout.contentWidth
- stack.addArrangedSubview(error)
-
- // Secondary actions left, the way out right.
- let docs = push("Setup instructions…", #selector(openDocs))
- let loginItems = push("Open login items…", #selector(openLoginItems))
- let minimize = push("Minimize helper", #selector(minimizePanel))
- let quit = push("Quit helper", #selector(quitHelper))
- let leading = NSStackView(views: [docs, loginItems])
- leading.orientation = .horizontal
- leading.spacing = 10
- let trailing = NSStackView(views: [minimize, quit])
- trailing.orientation = .horizontal
- trailing.spacing = 10
- let foot = NSGridView(views: [[leading, trailing]])
- foot.column(at: 0).xPlacement = .leading
- foot.column(at: 1).xPlacement = .trailing
- foot.translatesAutoresizingMaskIntoConstraints = false
- stack.addArrangedSubview(foot)
-
- let container = FlippedView()
- container.addSubview(stack)
+ let container = NSView()
+ container.translatesAutoresizingMaskIntoConstraints = false
+
+ let config = WKWebViewConfiguration()
+ // Nothing persists: no cookies, no storage, no cache. The panel is a
+ // render of the helper's state, and it has no business remembering
+ // anything between launches.
+ config.websiteDataStore = .nonPersistent()
+ config.userContentController.add(self, name: Self.bridge)
+ config.preferences.javaScriptCanOpenWindowsAutomatically = false
+
+ let view = WKWebView(frame: .zero, configuration: config)
+ view.navigationDelegate = self
+ view.underPageBackgroundColor = Theme.background
+ view.translatesAutoresizingMaskIntoConstraints = false
+
+ fallback.font = Theme.detailFont
+ fallback.textColor = Theme.mutedForeground
+ fallback.maximumNumberOfLines = 0
+ fallback.isSelectable = true
+ fallback.preferredMaxLayoutWidth = Layout.windowWidth - 2 * Layout.inset
+ fallback.isHidden = true
+ fallback.translatesAutoresizingMaskIntoConstraints = false
+
+ container.addSubview(view)
+ container.addSubview(fallback)
NSLayoutConstraint.activate([
container.widthAnchor.constraint(equalToConstant: Layout.windowWidth),
- stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Layout.inset),
- stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Layout.inset),
- stack.topAnchor.constraint(equalTo: container.topAnchor, constant: Layout.inset),
- stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -Layout.inset),
+ view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+ view.topAnchor.constraint(equalTo: container.topAnchor),
+ view.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ fallback.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Layout.inset),
+ fallback.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Layout.inset),
+ fallback.topAnchor.constraint(equalTo: container.topAnchor, constant: Layout.inset),
])
panel.contentView = container
- panel.layoutIfNeeded()
- panel.setContentSize(NSSize(width: Layout.windowWidth, height: max(360, stack.fittingSize.height + 2 * Layout.inset)))
- panel.center()
+
+ if let index = PanelBundle.indexURL(), let directory = PanelBundle.directoryURL() {
+ view.loadFileURL(index, allowingReadAccessTo: directory)
+ } else {
+ reportMissingBundle()
+ }
+
+ web = view
window = panel
+ panel.center()
+ // Permissions are granted in System Settings, two clicks and a dialog
+ // away from this panel, and macOS sends no notification when they
+ // change. Polling the two preflight calls is how the row catches up.
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
- MainActor.assumeIsolated { self?.refresh() }
+ MainActor.assumeIsolated {
+ guard let self, self.window?.isVisible == true else { return }
+ self.pushState()
+ }
}
}
- func refresh() {
- let enabled = consent.enabled
- let recording = CGPreflightScreenCaptureAccess()
- let accessibility = AXIsProcessTrusted()
- status.stringValue = enabled
- ? "Helper enabled. Capture and control also require both macOS permissions below."
- : "Helper disabled. No capture or input is allowed."
- if enabled && SMAppService.mainApp.status == .requiresApproval {
- status.stringValue += " Allow background startup in System Settings → General → Login Items to resume it after signing in."
- }
- let line = AccessReport.panelLine(viewers: accessCount())
- access.stringValue = line.text
- access.textColor = line.warning ? Theme.warning : Theme.mutedForeground
- toggle.title = enabled ? "Disable helper" : "Enable helper"
- captureStatus.stringValue = "Screen Recording: \(recording ? "Allowed" : "Not allowed")"
- inputStatus.stringValue = "Accessibility: \(accessibility ? "Allowed" : "Not allowed")"
- capture.isEnabled = enabled
- input.isEnabled = enabled
- let reason = enabled ? nil : "Enable the helper before requesting macOS permissions."
- capture.toolTip = reason
- input.toolTip = reason
- if !enabled { status.stringValue += " Enable it before requesting macOS permissions." }
+ private func reportMissingBundle() {
+ errorMessage = "The panel files are missing from this helper bundle. Reinstall Subshell to restore them."
+ fallback.stringValue = errorMessage
+ fallback.isHidden = false
+ web?.isHidden = true
}
- @objc private func toggleHelper() {
- error.stringValue = ""
- if consent.enabled {
+ /// The state shape `packages/helper-panel/src/state.ts` renders. The keys
+ /// are the contract between the two, so they stay in step by name.
+ private func snapshot() -> [String: Any] {
+ [
+ "enabled": consent.enabled,
+ "screenRecording": CGPreflightScreenCaptureAccess(),
+ "accessibility": AXIsProcessTrusted(),
+ "viewers": accessCount(),
+ "error": errorMessage,
+ "loginItemNeedsApproval": consent.enabled && SMAppService.mainApp.status == .requiresApproval,
+ ]
+ }
+
+ private func pushState(force: Bool = false) {
+ guard loaded, let view = web,
+ let data = try? JSONSerialization.data(withJSONObject: snapshot()),
+ let json = String(data: data, encoding: .utf8) else { return }
+ if !force, json == lastPushed { return }
+ lastPushed = json
+ view.evaluateJavaScript("window.subshellSetState(\(json))", completionHandler: nil)
+ }
+
+ private func resize(toHeight height: Double) {
+ guard height > 0, let window else { return }
+ let wanted = min(max(CGFloat(height), Layout.minHeight), Layout.maxHeight)
+ window.setContentSize(NSSize(width: Layout.windowWidth, height: wanted))
+ }
+
+ private func handle(action: String) {
+ switch action {
+ case "enable": enableHelper()
+ case "disable": disableHelper()
+ case "requestCapture": requestCapture()
+ case "requestInput": requestInput()
+ case "openLoginItems": SMAppService.openSystemSettingsLoginItems()
+ case "openDocs":
+ if let url = URL(string: Self.docsURL) { NSWorkspace.shared.open(url) }
+ case "minimize": minimize()
+ case "quit":
disableHelper()
- } else {
- do {
- try enable()
- try SMAppService.mainApp.register()
- } catch {
- disable()
- self.error.stringValue = "Couldn't enable the helper: \(error.localizedDescription)"
- }
+ NSApp.terminate(nil)
+ // An action this binary does not know is a page newer than the
+ // helper: ignore it rather than guess at what it asked for.
+ default: break
+ }
+ pushState(force: true)
+ }
+
+ private func enableHelper() {
+ errorMessage = ""
+ do {
+ try enable()
+ try SMAppService.mainApp.register()
+ } catch {
+ // A half-registered helper is worse than a refused one: the login
+ // item would outlive the consent it stands for.
+ disable()
+ errorMessage = "Couldn't enable the helper: \(error.localizedDescription)"
}
- refresh()
}
func disableHelper() {
disable()
+ errorMessage = ""
do { try SMAppService.mainApp.unregister() }
- catch { self.error.stringValue = "The helper is disabled. Remove its login item in macOS System Settings if it still appears there." }
- refresh()
+ catch { errorMessage = "The helper is disabled. Remove its login item in macOS System Settings if it still appears there." }
+ pushState(force: true)
}
- @objc private func requestCapture() {
+ private func requestCapture() {
+ // Consent gates the prompt as well as the capture: a helper that is
+ // off has no business asking macOS for someone's screen.
guard consent.enabled else { return }
_ = CGRequestScreenCaptureAccess()
- refresh()
}
- @objc private func requestInput() {
+ private func requestInput() {
guard consent.enabled else { return }
_ = AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary)
- refresh()
}
- @objc private func openLoginItems() { SMAppService.openSystemSettingsLoginItems() }
+ // MARK: - WebKit bridge
+
+ nonisolated func userContentController(_ userContentController: WKUserContentController,
+ didReceive message: WKScriptMessage) {
+ // Read everything out of the message here, then hop: the bridge is the
+ // page's only door into the helper, so it carries a name and a number
+ // and nothing else, and no object the page controls crosses onto main.
+ guard let body = message.body as? [String: Any] else { return }
+ let action = body["action"] as? String
+ let height = (body["height"] as? NSNumber)?.doubleValue
+ Task { @MainActor in
+ if action == "resize" {
+ if let height { self.resize(toHeight: height) }
+ } else if let action {
+ self.handle(action: action)
+ }
+ }
+ }
+
+ nonisolated func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+ decisionHandler(PanelBundle.allows(navigationAction.request.url) ? .allow : .cancel)
+ }
- @objc private func minimizePanel() { minimize() }
+ nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ Task { @MainActor in
+ self.loaded = true
+ self.fallback.isHidden = true
+ self.web?.isHidden = false
+ self.pushState(force: true)
+ }
+ }
- @objc private func quitHelper() { disableHelper(); NSApp.terminate(nil) }
+ nonisolated func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) {
+ reportLoadFailure(error.localizedDescription)
+ }
- @objc private func openDocs() {
- NSWorkspace.shared.open(URL(string: "https://docs.subshell.sh/nodes/manage#macos-display-helper")!)
+ nonisolated func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
+ withError error: any Error) {
+ reportLoadFailure(error.localizedDescription)
}
+
+ private nonisolated func reportLoadFailure(_ words: String) {
+ Task { @MainActor in
+ // Only the first load can leave the person with a blank panel; a
+ // failure after that is some subresource the page no longer needs.
+ guard !self.loaded else { return }
+ self.errorMessage = "The panel failed to load: \(words)"
+ self.fallback.stringValue = self.errorMessage
+ self.fallback.isHidden = false
+ }
+ }
+}
+
+/// One width, measured from the page: `packages/helper-panel/src/styles.css`
+/// sets `.panel { width: var(--panel-width) }` at 420px, so the window and the
+/// document agree and no horizontal scrollbar appears.
+private enum Layout {
+ static let windowWidth: CGFloat = 420
+ static let inset: CGFloat = 20
+ static let initialHeight: CGFloat = 480
+ static let minHeight: CGFloat = 200
+ static let maxHeight: CGFloat = 1400
}
diff --git a/native/display-helper/Theme.swift b/native/display-helper/Theme.swift
index 2864f7522..4b49a97c5 100644
--- a/native/display-helper/Theme.swift
+++ b/native/display-helper/Theme.swift
@@ -1,8 +1,13 @@
import AppKit
-/// The Subshell palette and type roles, mirrored from
-/// `apps/server/web/src/styles.css` so this panel reads as the same product as
-/// the dashboard rather than as a foreign system dialog.
+/// The three colours and one type role the native chrome still needs.
+///
+/// The panel's own page draws the palette itself, from
+/// `packages/assistant/tokens.css`, so this file is no longer a mirror of the
+/// whole theme: what is left is what AppKit must still paint by hand, the
+/// window's ground behind the web view, the muted text and `detail` size of the
+/// "the bundle has no panel" label, and the gold the status item takes on while
+/// someone is watching.
///
/// The numbers are the CSS tokens themselves (L, C, H) converted here, not
/// retyped hexes: a re-typed hex is a second source of truth that drifts the
@@ -10,48 +15,26 @@ import AppKit
/// mobile halves. The conversion is pure and tested.
///
/// The web theme is dark-only, so the panel pins `darkAqua` instead of
-/// following the system appearance: a light-mode aqua button sitting on the
+/// following the system appearance: a light-mode aqua title bar sitting on the
/// void ground looks broken, not themed.
enum Theme {
- /// Token name to (oklch L, C, H) exactly as `styles.css` declares them.
+ /// Token name to (oklch L, C, H) exactly as `packages/assistant/tokens.css`
+ /// declares them. Add a row only when native chrome has to draw it.
static let tokens: [String: (l: Double, c: Double, h: Double)] = [
"background": (0.224, 0.035, 296),
- "card": (0.255, 0.032, 296),
- "foreground": (0.92, 0.03, 312),
- "primary": (0.75, 0.17, 322),
- "primary-foreground": (0.16, 0.05, 322),
- "secondary": (0.29, 0.034, 296),
"muted-foreground": (0.74, 0.04, 310),
- "destructive": (0.65, 0.2, 25),
"warning": (0.828, 0.189, 84.429),
- "success": (0.765, 0.177, 163.223),
- "border": (0.33, 0.035, 296),
]
/// The panel's ground: the same void the dashboard draws.
static let background = color("background")
- /// Section plates, the token cards use.
- static let card = color("card")
- static let foreground = color("foreground")
- /// Secondary text: metadata, permission lines, timestamps.
+ /// Secondary text: metadata and anything the page could not render.
static let mutedForeground = color("muted-foreground")
- /// Brand accent: links, the enabled state, the primary control.
- static let primary = color("primary")
- static let primaryForeground = color("primary-foreground")
- static let secondary = color("secondary")
/// Gold: the operator can fix this (the same role `text-warning` holds).
static let warning = color("warning")
- /// Red: what the machine refused (the same role `text-destructive` holds).
- static let destructive = color("destructive")
- static let success = color("success")
- static let border = color("border")
- /// Type roles at two weights, the sizes the web token file declares:
- /// heading 20/600, label 15/600, body 14/400, detail 13/400. There is no
- /// 12px: detail is the floor, same rule the dashboard obeys.
- static let headingFont = NSFont.systemFont(ofSize: 20, weight: .semibold)
- static let labelFont = NSFont.systemFont(ofSize: 15, weight: .semibold)
- static let bodyFont = NSFont.systemFont(ofSize: 14, weight: .regular)
+ /// The `detail` role (13px at 400). There is no 12px: detail is the floor,
+ /// the same rule the dashboard obeys.
static let detailFont = NSFont.systemFont(ofSize: 13, weight: .regular)
/// The token as sRGB components. Oklab's inverse transform, then the
@@ -82,8 +65,10 @@ enum Theme {
}
}
-/// What the menu bar and the panel say while someone is watching a screen.
-/// Pure so the wording is testable without a window or a status item.
+/// What the menu bar says while someone is watching a screen. Pure so the
+/// wording is testable without a status item. The panel's own copy lives in
+/// `packages/helper-panel/src/strings.ts`; keep a sentence in one of the two,
+/// not both.
enum AccessReport {
/// The tooltip on the status item: honest about who is doing what.
static func tooltip(enabled: Bool, viewers: Int) -> String {
@@ -93,13 +78,4 @@ enum AccessReport {
}
return enabled ? "Subshell virtual display helper enabled" : "Subshell virtual display helper disabled"
}
-
- /// The panel's live line. Gold while anyone is watching, muted otherwise:
- /// same role split the dashboard uses for "the machine's owner should see
- /// this" versus plain metadata.
- static func panelLine(viewers: Int) -> (text: String, warning: Bool) {
- guard viewers > 0 else { return ("No one is viewing a virtual display on this Mac.", false) }
- let plural = viewers == 1 ? "viewer is" : "viewers are"
- return ("A Subshell \(plural) watching this Mac's virtual display right now.", true)
- }
}
diff --git a/native/display-helper/build_support.rs b/native/display-helper/build_support.rs
index f5e6036de..3a6efd0b4 100644
--- a/native/display-helper/build_support.rs
+++ b/native/display-helper/build_support.rs
@@ -48,10 +48,119 @@ fn read_parts(sources: &[PathBuf], plist: &Path, architecture: &str, identity: &
parts
}
-/// Copy `from` over `to` only when the bytes differ, so an unchanged rebuild
+/// Where vite puts the panel page, relative to the workspace root.
+fn panel_dist(root: &Path) -> PathBuf {
+ root.join("packages/helper-panel/dist")
+}
+
+/// Every regular file under `dir`, sorted by its path relative to `dir`, so
+/// the fingerprint and the copy do not depend on the order the OS hands out
+/// directory entries.
+fn tree_files(dir: &Path) -> Vec {
+ let mut found: Vec = Vec::new();
+ let mut stack: Vec = vec![dir.to_path_buf()];
+ while let Some(current) = stack.pop() {
+ let Ok(entries) = std::fs::read_dir(¤t) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let Ok(file_type) = entry.file_type() else { continue };
+ // A symlinked or nested directory is not something this bundle
+ // should carry; only real files inside the tree are copied.
+ if file_type.is_dir() {
+ stack.push(path);
+ } else if file_type.is_file() {
+ found.push(path);
+ }
+ }
+ }
+ found.sort();
+ found
+}
+
+/// What a debug bundle carries when the page was never built: a message that
+/// names the command instead of a blank window. The release path refuses to
+/// build at all, so this text can never reach a person who downloaded Subshell.
+const PLACEHOLDER: &str = "\n\
+\n\
+\n\
+\n\
+Subshell virtual display helper\n\
+
The panel was not built. Run bunx turbo run build \
+--filter=@internal/helper-panel, then rebuild this helper.
\n";
+
+/// The sentence every "no panel" path shares, so the fix is named in one place.
+fn missing_panel_message(dist: &Path) -> String {
+ format!(
+ "{} has no index.html: build the display helper's panel before bundling it \
+ (`bunx turbo run build --filter=@internal/helper-panel`).",
+ dist.display()
+ )
+}
+
+/// Every file of the panel's build, as `(relative path, bytes)` sorted by path.
+///
+/// The error is a value rather than a panic so it is quotable in a test, and so
+/// the release/debug split can decide what to do with it.
+fn dist_files(dist: &Path) -> Result)>, String> {
+ if !dist.join("index.html").is_file() {
+ return Err(missing_panel_message(dist));
+ }
+ Ok(tree_files(dist)
+ .into_iter()
+ .map(|path| {
+ let rel = path.strip_prefix(dist).expect("path under the panel dist");
+ let bytes = std::fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
+ (rel.to_string_lossy().into_owned(), bytes)
+ })
+ .collect())
+}
+
+/// What goes into `Contents/Resources/panel`.
+///
+/// A release must not ship a helper whose consent page was never built: the
+/// panel is the only place a person can say yes, and a bundle without it is a
+/// dark window. A debug build carries the placeholder instead, because the CI
+/// Rust lanes and a bare `cargo clippy` run with no `bun install` at all, and
+/// failing them over a file no Rust test reads would trade a real guard for a
+/// red lane nobody can act on.
+fn panel_files(root: &Path, release: bool) -> Vec<(String, Vec)> {
+ let dist = panel_dist(root);
+ if let Ok(files) = dist_files(&dist) {
+ return files;
+ }
+ let reason = missing_panel_message(&dist);
+ assert!(
+ !release,
+ "{reason} A release must not ship a helper whose consent page was never built."
+ );
+ println!("cargo:warning={reason}");
+ vec![("index.html".to_string(), PLACEHOLDER.as_bytes().to_vec())]
+}
+
+/// Make `to` hold exactly `files`, writing only what differs and removing what
+/// the list no longer has. The removal matters: vite names its assets by
+/// content hash, so appending would leave every bundle carrying the pages of
+/// its predecessors, and the signature would cover all of them.
+fn sync_panel(files: &[(String, Vec)], to: &Path) -> bool {
+ let mut wrote = false;
+ for (name, bytes) in files {
+ wrote = write_if_changed(bytes, &to.join(name)) || wrote;
+ }
+ for stale in tree_files(to) {
+ let rel = stale.strip_prefix(to).expect("path under the panel directory");
+ if !files.iter().any(|(name, _)| Path::new(name) == rel) {
+ let _ = std::fs::remove_file(&stale);
+ wrote = true;
+ }
+ }
+ wrote
+}
+
+/// Write `wanted` to `to` only when the bytes differ, so an unchanged rebuild
/// leaves no new mtime for the dev watcher to chase. Returns whether it wrote.
-fn copy_if_changed(from: &Path, to: &Path) -> bool {
- let wanted = std::fs::read(from).unwrap_or_else(|error| panic!("read {}: {error}", from.display()));
+fn write_if_changed(wanted: &[u8], to: &Path) -> bool {
if std::fs::read(to).is_ok_and(|existing| existing == wanted) {
return false;
}
@@ -62,6 +171,12 @@ fn copy_if_changed(from: &Path, to: &Path) -> bool {
true
}
+/// The same, from a file on disk.
+fn copy_if_changed(from: &Path, to: &Path) -> bool {
+ let wanted = std::fs::read(from).unwrap_or_else(|error| panic!("read {}: {error}", from.display()));
+ write_if_changed(&wanted, to)
+}
+
pub fn build() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") {
return;
@@ -70,6 +185,11 @@ pub fn build() {
let root = manifest.ancestors().nth(4).expect("desktop workspace root");
let source = root.join("native/display-helper");
println!("cargo:rerun-if-changed={}", source.display());
+ // The page is part of the bundle, so a panel rebuild has to re-run this
+ // script. Cargo watches a directory recursively, and the fingerprint below
+ // carries the bytes anyway.
+ let dist = panel_dist(root);
+ println!("cargo:rerun-if-changed={}", dist.display());
println!("cargo:rerun-if-env-changed=APPLE_SIGNING_IDENTITY");
println!("cargo:rerun-if-env-changed=APPLE_SIGNING_KEYCHAIN");
let bundle = manifest.join("resources").join(BUNDLE_NAME);
@@ -86,16 +206,27 @@ pub fn build() {
.filter(|path| path.extension().is_some_and(|extension| extension == "swift"))
.collect();
sources.sort();
- let parts = read_parts(&sources, &source.join("Info.plist"), architecture, &identity, &keychain);
+ let release = std::env::var("PROFILE").as_deref() == Ok("release");
+ // The page is bundle content, so its bytes decide whether this pass has to
+ // touch anything. Read once, then carried into both the fingerprint and the
+ // copy, which is what keeps the two from disagreeing.
+ let panel = panel_files(root, release);
+ let mut parts = read_parts(&sources, &source.join("Info.plist"), architecture, &identity, &keychain);
+ for (name, bytes) in &panel {
+ parts.push(name.clone().into_bytes());
+ parts.push(bytes.clone());
+ }
let bytes: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect();
let stamp_value = fingerprint(&bytes).to_string();
let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR"));
let stamp = out_dir.join("display-helper.stamp");
let staged = bundle.join("Contents/MacOS/subshell-display-helper");
let staged_plist = bundle.join("Contents/Info.plist");
+ let bundled_panel = bundle.join("Contents/Resources/panel");
if std::fs::read_to_string(&stamp).is_ok_and(|stored| stored == stamp_value)
&& staged.is_file()
&& staged_plist.is_file()
+ && bundled_panel.join("index.html").is_file()
{
// Nothing about the bundle changed since the last pass: touch no file,
// which is what keeps `tauri dev` from rebuilding itself forever.
@@ -120,7 +251,10 @@ pub fn build() {
.status()
.expect("start Swift compiler");
assert!(status.success(), "display helper compilation failed");
- assert!(std::env::var("PROFILE").as_deref() != Ok("release") || identity != "-",
+ // Carried into the stage BEFORE signing, so the signature covers the page
+ // and a swapped asset fails `codesign --verify` the way a swapped binary does.
+ sync_panel(&panel, &stage_contents.join("Resources/panel"));
+ assert!(!release || identity != "-",
"release display helpers require APPLE_SIGNING_IDENTITY; ad-hoc signatures cannot preserve macOS permission grants");
let mut sign = Command::new("/usr/bin/codesign");
sign.args(["--force", "--sign", &identity, "--identifier", HELPER_ID]);
@@ -156,6 +290,10 @@ pub fn build() {
}
}
}
+ // The page into the bundle, from the same bytes that were signed a moment
+ // ago. Per file rather than wholesale, so an unchanged rebuild stamps no
+ // mtime for `tauri dev` to chase.
+ sync_panel(&panel, &bundled_panel);
// Record the fingerprint whether or not anything was copied, so the next
// pass short-circuits before the swiftc call.
std::fs::write(&stamp, stamp_value).expect("write helper stamp");
@@ -194,6 +332,93 @@ mod tests {
std::fs::remove_dir_all(directory).unwrap();
}
+ fn scratch(name: &str) -> PathBuf {
+ let directory = std::env::temp_dir().join(format!("subshell-helper-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&directory);
+ std::fs::create_dir_all(&directory).unwrap();
+ directory
+ }
+
+ fn write(path: &Path, bytes: &[u8]) {
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, bytes).unwrap();
+ }
+
+ #[test]
+ fn dist_files_lists_the_built_page_and_refuses_an_absent_build_by_command() {
+ let empty = scratch("dist-empty");
+ let error = dist_files(&empty).expect_err("a dist with no page must not pass");
+ assert!(
+ error.contains("bunx turbo run build --filter=@internal/helper-panel"),
+ "{error}"
+ );
+
+ let dist = scratch("dist-built");
+ write(&dist.join("index.html"), b"");
+ write(&dist.join("assets/index-abc.js"), b"js");
+ write(&dist.join("assets/index-abc.css"), b"css");
+ let files = dist_files(&dist).unwrap();
+ let names: Vec<&str> = files.iter().map(|(name, _)| name.as_str()).collect();
+ assert_eq!(names, ["assets/index-abc.css", "assets/index-abc.js", "index.html"]);
+ assert_eq!(files[2].1, b"");
+ std::fs::remove_dir_all(empty).unwrap();
+ std::fs::remove_dir_all(dist).unwrap();
+ }
+
+ #[test]
+ fn a_debug_bundle_carries_a_placeholder_that_names_the_build_and_a_release_refuses() {
+ // The natural reading of "the bundle has no panel" is that someone
+ // deleted it, so both paths have to say what to run instead.
+ let root = scratch("panel-missing");
+ let files = panel_files(&root, false);
+ assert_eq!(files.len(), 1);
+ assert_eq!(files[0].0, "index.html");
+ let page = String::from_utf8(files[0].1.clone()).expect("utf-8 placeholder");
+ assert!(
+ page.contains("bunx turbo run build --filter=@internal/helper-panel"),
+ "{page}"
+ );
+
+ // The release path refuses rather than placeholdering. Quietly, and
+ // with the directory removed, because a panic that prints a stack and
+ // leaves a temp tree behind is noise a looped run accumulates.
+ let hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(|_| {}));
+ let refused = std::panic::catch_unwind(|| panel_files(&root, true));
+ std::panic::set_hook(hook);
+ assert!(
+ refused.is_err(),
+ "a release must not ship a helper whose consent page was never built"
+ );
+ std::fs::remove_dir_all(root).ok();
+ }
+
+ #[test]
+ fn sync_panel_replaces_stale_assets_and_writes_nothing_when_already_current() {
+ let to = scratch("sync-to");
+ let v1: Vec<(String, Vec)> = vec![
+ ("index.html".into(), b"v1".to_vec()),
+ ("assets/new.js".into(), b"js".to_vec()),
+ ];
+ // A previous vite pass emitted a differently hashed file; the bundle
+ // must not accumulate the pages of its predecessors.
+ write(&to.join("assets/old.js"), b"stale");
+
+ assert!(sync_panel(&v1, &to), "new files must be written");
+ assert!(!to.join("assets/old.js").exists(), "the stale hashed asset is pruned");
+ assert_eq!(std::fs::read(to.join("index.html")).unwrap(), b"v1");
+ assert!(
+ !sync_panel(&v1, &to),
+ "an identical set must leave no new mtime for the dev watcher"
+ );
+
+ let v2: Vec<(String, Vec)> = vec![("index.html".into(), b"v2".to_vec())];
+ assert!(sync_panel(&v2, &to));
+ assert_eq!(std::fs::read(to.join("index.html")).unwrap(), b"v2");
+ assert!(!to.join("assets/new.js").exists(), "a dropped asset leaves with it");
+ std::fs::remove_dir_all(to).unwrap();
+ }
+
#[test]
fn read_parts_carries_sources_plist_arch_and_identity() {
let directory = std::env::temp_dir().join(format!("subshell-helper-parts-{}", std::process::id()));
diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift
index a3fe63065..dfd8f6ae8 100644
--- a/native/display-helper/tests/HelperTests.swift
+++ b/native/display-helper/tests/HelperTests.swift
@@ -49,26 +49,22 @@ struct HelperTests {
UserDefaults.standard.set(true, forKey: "enabled")
precondition(HelperConsent().enabled, "The default init must read the app's own domain")
UserDefaults.standard.removeObject(forKey: "enabled")
- // The palette mirrors apps/server/web/src/styles.css; these are the
- // tokens the panel actually paints with. If the web file moves and
- // this drifts, the fix is to move the numbers here, not to pass.
+ // The three tokens the native chrome still paints with, mirrored from
+ // packages/assistant/tokens.css. If that file moves and this drifts,
+ // the fix is to move the numbers here, not to pass.
func close(_ a: Double, _ b: Double) -> Bool { abs(a - b) < 0.02 }
- let primary = Theme.srgb("primary")
- precondition(close(primary.red, 0.875) && close(primary.green, 0.525) && close(primary.blue, 0.929), "primary token drifted")
let warning = Theme.srgb("warning")
precondition(close(warning.red, 1.0) && close(warning.green, 0.727) && close(warning.blue, 0.0), "warning token drifted")
let ground = Theme.srgb("background")
precondition(close(ground.red, 0.113) && close(ground.green, 0.094) && close(ground.blue, 0.165), "background token drifted")
+ let muted = Theme.srgb("muted-foreground")
+ precondition(close(muted.red, 0.700) && close(muted.green, 0.644) && close(muted.blue, 0.746), "muted-foreground token drifted")
// The awareness copy: a dot on the menu bar is only useful if the
// words say who is doing what, and the idle state must not claim
// anyone is watching.
precondition(AccessReport.tooltip(enabled: true, viewers: 2) == "Subshell is viewing a virtual display on this Mac (2 viewers)")
precondition(AccessReport.tooltip(enabled: true, viewers: 1) == "Subshell is viewing a virtual display on this Mac")
precondition(AccessReport.tooltip(enabled: false, viewers: 0) == "Subshell virtual display helper disabled")
- let watching = AccessReport.panelLine(viewers: 1)
- precondition(watching.warning && watching.text.contains("viewer is"))
- let quiet = AccessReport.panelLine(viewers: 0)
- precondition(!quiet.warning && quiet.text.contains("No one"))
// The pointer rescue: a viewer's cursor lives on the virtual display,
// so losing that screen strands it off every display the person at
// this Mac can see. Only a point inside no frame counts as stranded.
diff --git a/packages/helper-panel/biome.json b/packages/helper-panel/biome.json
new file mode 100644
index 000000000..14a6bfa47
--- /dev/null
+++ b/packages/helper-panel/biome.json
@@ -0,0 +1,4 @@
+{
+ "root": false,
+ "extends": ["../../biome.json"]
+}
diff --git a/packages/helper-panel/index.html b/packages/helper-panel/index.html
new file mode 100644
index 000000000..45b2dc4c7
--- /dev/null
+++ b/packages/helper-panel/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Subshell virtual display helper
+
+
+
+
+
+
diff --git a/packages/helper-panel/package.json b/packages/helper-panel/package.json
new file mode 100644
index 000000000..8c6230c27
--- /dev/null
+++ b/packages/helper-panel/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@internal/helper-panel",
+ "version": "1.0.0",
+ "license": "Apache-2.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "build": "vite build",
+ "clean": "rm -rf .turbo node_modules dist",
+ "lint": "biome check --no-errors-on-unmatched --write --unsafe src",
+ "lint:check": "biome check --no-errors-on-unmatched src",
+ "lint:staged": "biome check --no-errors-on-unmatched --write --unsafe --staged src",
+ "test": "bash \"$(git rev-parse --show-toplevel)/scripts/par-test.sh\" 4 bun test",
+ "verify-types": "tsc --noEmit"
+ },
+ "devDependencies": {
+ "@internal/tsconfig": "workspace:*",
+ "@types/bun": "1.3.14",
+ "typescript": "7.0.2",
+ "vite": "8.2.4"
+ }
+}
diff --git a/packages/helper-panel/src/__tests__/strings.test.ts b/packages/helper-panel/src/__tests__/strings.test.ts
new file mode 100644
index 000000000..0ef35c837
--- /dev/null
+++ b/packages/helper-panel/src/__tests__/strings.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, test } from "bun:test";
+import { EMPTY_STATE, type PanelState } from "../state";
+import { permissionLine, stateLine, strings, viewerLine } from "../strings";
+
+/**
+ * The panel's copy is its consent interface, so the words are the behavior
+ * under test: a line that overstates what is running (or hides that someone is
+ * watching) leads a person to leave the helper on. Each assertion below names
+ * the misreading it prevents.
+ */
+function stateWith(overrides: Partial): PanelState {
+ return { ...EMPTY_STATE, ...overrides };
+}
+
+describe("the consent warning", () => {
+ test("says plainly what enabling exposes, so the decision is informed", () => {
+ // The defect this guards: a warning that reads like a generic privacy
+ // notice, so someone enables capture without knowing a viewer drives the
+ // real cursor and sees their other windows.
+ const warning = strings.warning;
+ expect(warning).toContain("pointer");
+ expect(warning).toContain("real cursor");
+ expect(warning).toContain("other windows");
+ expect(warning).toContain("trust");
+ });
+
+ test("keeps every control label to the action it takes", () => {
+ // "Minimize helper" once read as "stop the helper", so a person thought
+ // capture had ended while it had not. The label names the window.
+ expect(strings.minimize).toBe("Minimize helper");
+ expect(strings.quit).toBe("Quit helper");
+ expect(strings.enable).toBe("Enable helper");
+ expect(strings.disable).toBe("Disable helper");
+ });
+});
+
+describe("stateLine", () => {
+ test("denies both capture and input while disabled", () => {
+ const line = stateLine(stateWith({ enabled: false, screenRecording: true, accessibility: true }));
+ expect(line).toContain("Disabled");
+ expect(line).toContain("Nothing is captured");
+ expect(line).toContain("no input is accepted");
+ });
+
+ test("does not promise a working display while a permission is missing", () => {
+ // Enabled alone is not enough: saying "Enabled and capturing" here would
+ // tell someone their subshells have a screen when macOS still refuses.
+ const line = stateLine(stateWith({ enabled: true }));
+ expect(line).toContain("Enabled");
+ expect(line).toContain("also need both macOS permissions");
+ });
+
+ test("names where to fix a login item macOS has not approved", () => {
+ const line = stateLine(stateWith({ enabled: true, loginItemNeedsApproval: true }));
+ expect(line).toContain("Login Items");
+ expect(line).toContain("sign in");
+ expect(stateLine(stateWith({ enabled: true }))).not.toContain("Login Items");
+ });
+});
+
+describe("viewerLine", () => {
+ test("says nobody is watching when no capture is held", () => {
+ expect(viewerLine(stateWith({ enabled: true, viewers: 0 }))).toContain("No one is viewing");
+ });
+
+ test("carries the count and agrees with it in number", () => {
+ // A gold line reading "1 viewers" reads as a bug, and a person who stops
+ // trusting the count stops reading it at all.
+ const one = viewerLine(stateWith({ viewers: 1 }));
+ expect(one).toContain("A Subshell viewer");
+ expect(one).not.toContain("viewers are");
+ const three = viewerLine(stateWith({ viewers: 3 }));
+ expect(three).toContain("3");
+ expect(three).toContain("viewers are");
+ });
+});
+
+describe("permissionLine", () => {
+ test("states the label and the machine's answer in one readable row", () => {
+ expect(permissionLine("Screen Recording", true)).toBe("Screen Recording: Allowed");
+ expect(permissionLine("Accessibility", false)).toBe("Accessibility: Not allowed");
+ });
+});
diff --git a/packages/helper-panel/src/main.ts b/packages/helper-panel/src/main.ts
new file mode 100644
index 000000000..a7bfe2330
--- /dev/null
+++ b/packages/helper-panel/src/main.ts
@@ -0,0 +1,134 @@
+import "./styles.css";
+import { EMPTY_STATE, type PanelAction, type PanelState } from "./state";
+import { permissionLine, stateLine, strings, viewerLine } from "./strings";
+
+/** The bridge the Swift side installs. Absent in a browser, which is how the
+ * page stays previewable while every real act needs the helper process. */
+interface PanelBridge {
+ postMessage(message: { action: PanelAction } | { action: "resize"; height: number }): void;
+}
+declare global {
+ interface Window {
+ subshellSetState?: (state: PanelState) => void;
+ webkit?: { messageHandlers?: { subshell?: PanelBridge } };
+ }
+}
+
+function bridge(): PanelBridge | undefined {
+ return window.webkit?.messageHandlers?.subshell;
+}
+
+function button(label: string, action: PanelAction, primary = false): HTMLButtonElement {
+ const el = document.createElement("button");
+ el.type = "button";
+ el.textContent = label;
+ el.className = primary ? "btn btn-primary" : "btn";
+ el.addEventListener("click", () => bridge()?.postMessage({ action }));
+ return el;
+}
+
+/** A paragraph-class element with the right tag for its role. */
+function line(tag: "p" | "h1" | "span", className: string, content: string): HTMLElement {
+ const el = document.createElement(tag);
+ el.className = className;
+ el.textContent = content;
+ return el;
+}
+
+/** A section heading with the trailing rule: the assistant's `FormSectionTitle` shape. */
+function sectionTitle(label: string): HTMLElement {
+ const heading = document.createElement("h2");
+ heading.className = "section-title";
+ const caption = document.createElement("span");
+ caption.textContent = label;
+ const rule = document.createElement("span");
+ rule.setAttribute("aria-hidden", "true");
+ rule.className = "section-rule";
+ heading.append(caption, rule);
+ return heading;
+}
+
+/**
+ * A permission row: the machine's answer on the left, the request on the
+ * right. The buttons are `disabled` rather than hidden while consent is off,
+ * with the reason as the title, because a control that is not there cannot be
+ * explained. The helper refuses these requests as well, so a page that
+ * somehow fires one still gets a refusal instead of a prompt.
+ */
+function permissionRow(label: string, granted: boolean, requestLabel: string, action: PanelAction, consented: boolean) {
+ const row = document.createElement("div");
+ row.className = "row";
+ const status = line("span", "detail", permissionLine(label, granted));
+ const btn = button(requestLabel, action);
+ btn.disabled = !consented;
+ if (!consented) btn.title = strings.permissionNeedsConsent;
+ row.append(status, btn);
+ return row;
+}
+
+function render(state: PanelState): void {
+ const root = document.getElementById("panel");
+ if (!root) return;
+ root.replaceChildren();
+
+ const head = document.createElement("header");
+ head.className = "head";
+ head.append(line("h1", "title", strings.title), line("span", "detail", strings.subtitle));
+ root.append(head);
+
+ root.append(line("p", "warning", strings.warning));
+
+ const access = document.createElement("section");
+ access.className = "section";
+ access.append(
+ sectionTitle(strings.access),
+ line("p", "body", stateLine(state)),
+ line("p", state.viewers > 0 ? "viewers-live" : "viewers", viewerLine(state)),
+ button(state.enabled ? strings.disable : strings.enable, state.enabled ? "disable" : "enable", true),
+ line("p", "detail", strings.enableNote),
+ );
+ const login = button(strings.openLoginItems, "openLoginItems");
+ login.classList.add("alone");
+ access.append(login);
+ root.append(access);
+
+ const permissions = document.createElement("section");
+ permissions.className = "section";
+ permissions.append(
+ sectionTitle(strings.permissions),
+ permissionRow("Screen Recording", state.screenRecording, strings.requestCapture, "requestCapture", state.enabled),
+ permissionRow("Accessibility", state.accessibility, strings.requestInput, "requestInput", state.enabled),
+ );
+ root.append(permissions);
+
+ // role=alert so a refusal that arrives while the panel is open is announced,
+ // not just painted red.
+ if (state.error) {
+ const err = line("p", "error", state.error);
+ err.setAttribute("role", "alert");
+ root.append(err);
+ }
+
+ const foot = document.createElement("footer");
+ foot.className = "foot";
+ const right = document.createElement("div");
+ right.className = "foot-right";
+ right.append(button(strings.minimize, "minimize"), button(strings.quit, "quit"));
+ foot.append(button(strings.openDocs, "openDocs"), right);
+ root.append(foot);
+
+ // The Swift window sizes to the content, so the page reports what it needs.
+ bridge()?.postMessage({ action: "resize", height: Math.ceil(root.getBoundingClientRect().height) });
+}
+
+let current: PanelState = EMPTY_STATE;
+
+/** Called by the helper after every change: consent, a permission grant, a
+ * viewer arriving or leaving, a refusal. The page keeps no state of its own,
+ * so a missed push is impossible: the last one wins. */
+window.subshellSetState = (state: PanelState) => {
+ current = { ...EMPTY_STATE, ...state };
+ render(current);
+};
+
+render(current);
diff --git a/packages/helper-panel/src/state.ts b/packages/helper-panel/src/state.ts
new file mode 100644
index 000000000..76dc5807f
--- /dev/null
+++ b/packages/helper-panel/src/state.ts
@@ -0,0 +1,42 @@
+/**
+ * The state the helper pushes into the page. The page owns no state of its
+ * own: every field is a fact about the machine that only the Swift side can
+ * know, and the panel is a render of the latest one.
+ */
+export interface PanelState {
+ /** The person's own consent, stored by the helper. */
+ enabled: boolean;
+ screenRecording: boolean;
+ accessibility: boolean;
+ /** Virtual displays being captured right now, so the local user can see when someone is looking. */
+ viewers: number;
+ /** The last refusal, shown in red. Empty when there is nothing to report. */
+ error: string;
+ /** macOS lists the login item but has not been told to start it. */
+ loginItemNeedsApproval: boolean;
+}
+
+export const EMPTY_STATE: PanelState = {
+ enabled: false,
+ screenRecording: false,
+ accessibility: false,
+ viewers: 0,
+ error: "",
+ loginItemNeedsApproval: false,
+};
+
+/** What the page sends back. One field, an action name, keeps the bridge small enough to audit. */
+export type PanelAction =
+ | "enable"
+ | "disable"
+ | "requestCapture"
+ | "requestInput"
+ | "openLoginItems"
+ | "openDocs"
+ | "minimize"
+ | "quit";
+
+/** Everything the helper must refuse while consent is off. */
+export function isPermissionAction(action: PanelAction): boolean {
+ return action === "requestCapture" || action === "requestInput";
+}
diff --git a/packages/helper-panel/src/strings.ts b/packages/helper-panel/src/strings.ts
new file mode 100644
index 000000000..9708debcb
--- /dev/null
+++ b/packages/helper-panel/src/strings.ts
@@ -0,0 +1,47 @@
+import type { PanelState } from "./state";
+
+/**
+ * Every word the panel shows, in one file. The copy is the interface: a
+ * consent surface that does not say plainly what it enables has not asked for
+ * consent. Keeping it pure is also what makes it testable without a web view.
+ */
+export const strings = {
+ title: "Subshell virtual display helper",
+ subtitle: "Capture and control of Subshell's virtual displays on this Mac.",
+ warning:
+ "macOS shares one desktop: a viewer's pointer is the real cursor, and other windows can appear in the stream. Enable this only for people you trust to control this Mac.",
+ access: "Access",
+ permissions: "macOS permissions",
+ enable: "Enable helper",
+ disable: "Disable helper",
+ enableNote:
+ "While enabled the helper runs in the background and starts at sign-in. Disabling stops capture and input; your subshells and macOS permissions stay.",
+ openLoginItems: "Open login items…",
+ openDocs: "Setup instructions…",
+ minimize: "Minimize helper",
+ quit: "Quit helper",
+ requestCapture: "Allow screen recording…",
+ requestInput: "Allow accessibility…",
+ permissionNeedsConsent: "Enable the helper before requesting macOS permissions.",
+} as const;
+
+/** The one-line answer to "is anyone looking at this machine right now". */
+export function stateLine(state: PanelState): string {
+ if (!state.enabled) return "Disabled. Nothing is captured and no input is accepted.";
+ const base = "Enabled. Capture and input also need both macOS permissions below.";
+ return state.loginItemNeedsApproval
+ ? `${base} Allow background startup in System Settings under General, Login Items so it resumes after you sign in.`
+ : base;
+}
+
+/** Gold while a viewer holds a screen, muted when nobody does. */
+export function viewerLine(state: PanelState): string {
+ if (state.viewers <= 0) return "No one is viewing a virtual display on this Mac.";
+ return state.viewers === 1
+ ? "A Subshell viewer is watching this Mac's virtual display right now."
+ : `${state.viewers} Subshell viewers are watching this Mac's virtual displays right now.`;
+}
+
+export function permissionLine(label: string, granted: boolean): string {
+ return `${label}: ${granted ? "Allowed" : "Not allowed"}`;
+}
diff --git a/packages/helper-panel/src/styles.css b/packages/helper-panel/src/styles.css
new file mode 100644
index 000000000..a8d2874f8
--- /dev/null
+++ b/packages/helper-panel/src/styles.css
@@ -0,0 +1,168 @@
+/* The macOS display helper's panel. Colours and type come from the one shared
+ palette; nothing here restates a value, which is what keeps the panel from
+ becoming the fourth surface that drifts (spec 2026-09-14 § 1). */
+@import "../../assistant/tokens.css";
+
+:root {
+ --panel-width: 420px;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--default-font-family);
+ font-size: var(--text-body);
+ line-height: var(--text-body--line-height);
+ -webkit-font-smoothing: antialiased;
+}
+
+.panel {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: var(--panel-width);
+ padding: 20px;
+}
+
+.head {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.title {
+ margin: 0;
+ font-size: var(--text-heading);
+ line-height: var(--text-heading--line-height);
+ font-weight: var(--font-weight-strong);
+}
+
+.section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding-top: 4px;
+ border-top: 1px solid var(--border);
+}
+
+/* The assistant's section heading: label role, a trailing rule that carries
+ the weight the text does not. */
+.section-title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 0;
+ font-size: var(--text-label);
+ line-height: var(--text-label--line-height);
+ font-weight: var(--font-weight-strong);
+}
+
+.section-rule {
+ flex: 1;
+ border-top: 1px solid var(--border);
+}
+
+.body,
+.warning,
+.detail,
+.viewers,
+.viewers-live,
+.error {
+ margin: 0;
+}
+
+.detail,
+.viewers {
+ color: var(--muted-foreground);
+ font-size: var(--text-detail);
+ line-height: var(--text-detail--line-height);
+}
+
+/* Gold is "the person who owns this can act on it", the same role the
+ dashboard gives text-warning. A live capture is the one thing this panel
+ must make impossible to miss. */
+.warning,
+.viewers-live {
+ color: var(--warning);
+}
+
+.viewers-live {
+ font-size: var(--text-detail);
+ line-height: var(--text-detail--line-height);
+ font-weight: var(--font-weight-strong);
+}
+
+.error {
+ color: var(--destructive);
+ font-size: var(--text-detail);
+ line-height: var(--text-detail--line-height);
+}
+
+.btn {
+ align-self: flex-start;
+ padding: 4px 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--secondary);
+ color: var(--foreground);
+ font: inherit;
+ font-size: var(--text-body);
+}
+
+.btn:hover {
+ background: var(--accent);
+}
+
+.btn:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
+.btn:disabled {
+ color: var(--muted-foreground);
+ opacity: 0.6;
+}
+
+/* The one primary action, in the palette's own accent. */
+.btn-primary {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: var(--primary-foreground);
+ font-weight: var(--font-weight-strong);
+}
+
+.btn-primary:hover {
+ background: var(--primary);
+ filter: brightness(1.06);
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-top: 4px;
+ border-top: 1px solid var(--border);
+}
+
+.foot-right {
+ display: flex;
+ gap: 8px;
+}
+
+.alone {
+ align-self: flex-start;
+}
diff --git a/packages/helper-panel/src/vite-env.d.ts b/packages/helper-panel/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/packages/helper-panel/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/helper-panel/tsconfig.json b/packages/helper-panel/tsconfig.json
new file mode 100644
index 000000000..ae682b70f
--- /dev/null
+++ b/packages/helper-panel/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@internal/tsconfig/tsconfig.json",
+ "include": ["src/**/*", "vite.config.ts"],
+ "compilerOptions": {
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "types": ["bun"],
+ "noEmit": true
+ }
+}
diff --git a/packages/helper-panel/turbo.json b/packages/helper-panel/turbo.json
new file mode 100644
index 000000000..fb139b71c
--- /dev/null
+++ b/packages/helper-panel/turbo.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "clean": {},
+ "build": {
+ "dependsOn": ["lint"],
+ "inputs": ["*.json", "*.ts", "*.html", "src/**"],
+ "outputs": ["dist/**"]
+ },
+ "lint": {
+ "inputs": ["*.ts", "*.json", "src/**"]
+ },
+ "verify-types": {
+ "inputs": ["*.ts", "*.json", "src/**"]
+ }
+ }
+}
diff --git a/packages/helper-panel/vite.config.ts b/packages/helper-panel/vite.config.ts
new file mode 100644
index 000000000..038d97321
--- /dev/null
+++ b/packages/helper-panel/vite.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig } from "vite";
+
+/**
+ * The macOS display helper's settings panel, built to `dist/` and copied into
+ * the helper bundle by `native/display-helper/build_support.rs`.
+ *
+ * `base: "./"` is not a style choice: the Swift side loads `index.html` with
+ * `loadFileURL`, so the asset URLs have to be relative to the bundle directory
+ * and nothing may reach for a server. The page gets no network access beyond
+ * that directory (the read scope is the panel folder), which is the whole
+ * security property a consent surface needs: what it shows comes from the
+ * helper process, and it can fetch nothing else.
+ */
+export default defineConfig({
+ base: "./",
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ target: "es2022",
+ },
+});
diff --git a/scripts/__tests__/design-tokens.test.ts b/scripts/__tests__/design-tokens.test.ts
index 32e1cb2f3..bbb09ab3f 100644
--- a/scripts/__tests__/design-tokens.test.ts
+++ b/scripts/__tests__/design-tokens.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { readFileSync } from "node:fs";
+import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import {
agreementProblems,
@@ -16,7 +16,9 @@ import {
parseCssTokens,
parseOklch,
REPO_ROOT,
+ SCAN_ROOTS,
SHARED_TOKENS,
+ SURFACE_GLOBS,
sharedRedeclarationProblems,
TYPE_ROLES,
WEB_SCALE,
@@ -445,3 +447,16 @@ describe("the shared token file", () => {
expect(contrastProblems(`${shared}\n${readFileSync(join(REPO_ROOT, CSS_SURFACES.spa), "utf8")}`)).toEqual([]);
});
});
+
+describe("a package page that joins a surface", () => {
+ test("the helper panel is walked under the assistant rules, by name", () => {
+ // Nothing else checks the registration itself. A scan root pointing at a
+ // moved directory, or a stylesheet whose name the surface's include glob
+ // never matches, leaves that page unswept while `lint:design` still prints
+ // its green tick, which is how "extracted into a package" has historically
+ // meant "escaped the role system".
+ expect(SCAN_ROOTS.assistant).toContain("packages/helper-panel/src");
+ expect(existsSync(join(REPO_ROOT, "packages/helper-panel/src/styles.css"))).toBe(true);
+ expect(SURFACE_GLOBS.assistant.include.test("styles.css")).toBe(true);
+ });
+});
diff --git a/scripts/design-tokens.ts b/scripts/design-tokens.ts
index f80d90cc1..2e03db148 100644
--- a/scripts/design-tokens.ts
+++ b/scripts/design-tokens.ts
@@ -437,9 +437,17 @@ export const SURFACE_GLOBS: Record> = {
+export const SCAN_ROOTS: Partial> = {
spa: ["packages/node-admin/src", "apps/node/web/src"],
+ assistant: ["packages/helper-panel/src"],
};
// ---------------------------------------------------------------------------
diff --git a/scripts/prose-dashes.ts b/scripts/prose-dashes.ts
index 16d91dda0..06f2a4879 100644
--- a/scripts/prose-dashes.ts
+++ b/scripts/prose-dashes.ts
@@ -96,6 +96,10 @@ export function proseFiles(cwd: string): string[] {
"apps/website/app",
"apps/website/components",
"apps/website/lib",
+ // The display helper's consent copy lives in a package because the
+ // helper bundle is not under any apps/ directory. Its string literals
+ // are visitor-facing like the website's, so they carry the same rule.
+ "packages/helper-panel/src",
],
{ cwd },
);
diff --git a/scripts/rust-check.sh b/scripts/rust-check.sh
index f79c61cb4..425edb4b2 100755
--- a/scripts/rust-check.sh
+++ b/scripts/rust-check.sh
@@ -32,6 +32,16 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
env -u SHELLOPTS -u BASHOPTS bash "$ROOT/native/display-helper/test.sh"
+# The helper bundle carries the panel page (see native/display-helper/build_support.rs).
+# A debug build tolerates its absence with a placeholder that names this very
+# command, because the CI Rust lanes run with no node_modules at all; when the
+# workspace IS installed, build it, so clippy and any local `cargo build`
+# bundle the real page rather than the placeholder.
+if [ -d "$ROOT/node_modules" ]; then
+ echo "==> packages/helper-panel"
+ (cd "$ROOT" && bunx turbo run build --filter=@internal/helper-panel)
+fi
+
TRIPLE="$(rustc --print host-tuple)"
# dir : sidecar stem. Each app's tauri.conf.json carries the same stem; the stem names the binary the app WRAPS, not the app.
From 361c35bf788d2f4674da19b15ea05bd2b3ca6eed Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 01:23:14 -0700
Subject: [PATCH 021/120] fix(helper): read the web view's arguments on the
main actor
The SDK declares the delegate methods unisolated and the properties they
hand us main-actor, so the first cut of the bridge read `message.body` and
`navigationAction.request` off main and produced two warnings. A Task where
the work may wait a runloop turn, and `assumeIsolated` where WebKit has to
have the answer synchronously.
---
native/display-helper/Settings.swift | 30 ++++++++++++++++++----------
1 file changed, 19 insertions(+), 11 deletions(-)
diff --git a/native/display-helper/Settings.swift b/native/display-helper/Settings.swift
index bcfe5462c..455dcabf7 100644
--- a/native/display-helper/Settings.swift
+++ b/native/display-helper/Settings.swift
@@ -250,19 +250,24 @@ final class HelperSettings: NSObject, WKScriptMessageHandler, WKNavigationDelega
}
// MARK: - WebKit bridge
+ //
+ // The delegate methods stay `nonisolated` because that is how the SDK
+ // declares them, while the properties they hand us (`WKScriptMessage.body`,
+ // `WKNavigationAction.request`) are main-actor state. So each one reads its
+ // argument on main rather than from here: a `Task` where the work may wait a
+ // runloop turn, and `assumeIsolated` where the answer has to be synchronous,
+ // which WebKit guarantees by delivering both calls on the thread that owns
+ // the view, the main thread, since `build()` created it there.
nonisolated func userContentController(_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage) {
- // Read everything out of the message here, then hop: the bridge is the
- // page's only door into the helper, so it carries a name and a number
- // and nothing else, and no object the page controls crosses onto main.
- guard let body = message.body as? [String: Any] else { return }
- let action = body["action"] as? String
- let height = (body["height"] as? NSNumber)?.doubleValue
Task { @MainActor in
+ // The bridge is the page's only door into the helper, so it carries a
+ // name and a number and nothing else.
+ guard let body = message.body as? [String: Any], let action = body["action"] as? String else { return }
if action == "resize" {
- if let height { self.resize(toHeight: height) }
- } else if let action {
+ if let height = (body["height"] as? NSNumber)?.doubleValue { self.resize(toHeight: height) }
+ } else {
self.handle(action: action)
}
}
@@ -270,7 +275,9 @@ final class HelperSettings: NSObject, WKScriptMessageHandler, WKNavigationDelega
nonisolated func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
- decisionHandler(PanelBundle.allows(navigationAction.request.url) ? .allow : .cancel)
+ MainActor.assumeIsolated {
+ decisionHandler(PanelBundle.allows(navigationAction.request.url) ? .allow : .cancel)
+ }
}
nonisolated func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
@@ -292,12 +299,13 @@ final class HelperSettings: NSObject, WKScriptMessageHandler, WKNavigationDelega
}
private nonisolated func reportLoadFailure(_ words: String) {
+ let message = "The panel failed to load: \(words)"
Task { @MainActor in
// Only the first load can leave the person with a blank panel; a
// failure after that is some subresource the page no longer needs.
guard !self.loaded else { return }
- self.errorMessage = "The panel failed to load: \(words)"
- self.fallback.stringValue = self.errorMessage
+ self.errorMessage = message
+ self.fallback.stringValue = message
self.fallback.isHidden = false
}
}
From 5fe323dffb73e76a2476802b07c6f036b6a470eb Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 01:29:26 -0700
Subject: [PATCH 022/120] fix(helper): ship the panel as one document that
fetches nothing
The web view opened white. The page was fine; its two assets were not.
`loadFileURL` gives the document an opaque origin, so vite's
``)
+ .replace(STYLE_TAG, ``);
+ // Anything still pointing at ./assets/ escaped the fold, and would be
+ // refused by WebKit exactly as the two that this function handles.
+ const leftover = /(?:src|href)="\.\/assets\/[^"]+"/.exec(inlined);
+ if (leftover !== null) {
+ throw new Error(`the panel document still fetches ${leftover[0]}; inlineInto does not handle it`);
+ }
+ return inlined;
+}
+
+/** A vite plugin that leaves `dist/` holding nothing but `index.html`. */
+export function panelSingleFile(): Plugin {
+ return {
+ name: "helper-panel-single-file",
+ apply: "build",
+ enforce: "post",
+ generateBundle(_options, bundle) {
+ const entries = Object.values(bundle);
+ const script = entries.find((chunk) => chunk.type === "chunk");
+ const style = entries.find((chunk) => chunk.type === "asset" && chunk.fileName.endsWith(".css"));
+ const document = entries.find((chunk) => chunk.type === "asset" && chunk.fileName.endsWith(".html"));
+ if (script?.type !== "chunk" || style?.type !== "asset" || document?.type !== "asset") {
+ this.error("the panel build emitted something other than one script, one stylesheet and one document");
+ }
+ document.source = inlineInto(String(document.source), script.code, String(style.source));
+ delete bundle[script.fileName];
+ delete bundle[style.fileName];
+ },
+ };
+}
diff --git a/packages/helper-panel/src/__tests__/inline.test.ts b/packages/helper-panel/src/__tests__/inline.test.ts
new file mode 100644
index 000000000..8f360876c
--- /dev/null
+++ b/packages/helper-panel/src/__tests__/inline.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, test } from "bun:test";
+import { inlineInto } from "../../inline";
+
+/**
+ * The white panel this exists to prevent: a `file://` document has an opaque
+ * origin, so vite's `crossorigin` script and stylesheet are refused and the
+ * window paints nothing. The build therefore has to leave a document that
+ * references no file at all, and these are the ways that can fail.
+ */
+const VITE_HTML = `
+
+Subshell virtual display helper
+
+
+`;
+
+describe("inlineInto", () => {
+ test("carries both the behaviour and the palette inside the document", () => {
+ const doc = inlineInto(VITE_HTML, "document.body.dataset.ready = 1;", ":root { --background: oklch(0.2 0 0); }");
+ expect(doc).toContain("document.body.dataset.ready");
+ expect(doc).toContain("--background");
+ expect(doc).not.toContain("./assets/");
+ expect(doc).not.toContain("crossorigin");
+ });
+
+ test("leaves the markup the page renders into alone", () => {
+ const doc = inlineInto(VITE_HTML, "void 0;", "body{}");
+ expect(doc).toContain('');
+ expect(doc).toContain("");
+ });
+
+ test("escapes a close tag that would end the element early", () => {
+ // A stylesheet or bundle containing `` would otherwise close the
+ // element mid-content and dump the rest of the file in as text.
+ const doc = inlineInto(VITE_HTML, 'const note = "";', '/* "" */');
+ expect(doc.match(/
-
+
+
diff --git a/packages/helper-panel/src/main.ts b/packages/helper-panel/src/main.ts
index 152120ff4..d6a59953d 100644
--- a/packages/helper-panel/src/main.ts
+++ b/packages/helper-panel/src/main.ts
@@ -177,7 +177,19 @@ function quitDialog(): HTMLElement {
foot.append(cancel, confirm);
dialog.append(title, body, foot);
scrim.append(dialog);
- // Focus the way out, not the act.
+ // Focus the way out, not the act. `aria-modal` is a promise the background
+ // cannot be reached, and the scrim has no inert attribute to enforce it, so
+ // Tab is kept inside the two controls here rather than left to the browser.
+ scrim.tabIndex = -1;
+ scrim.addEventListener("keydown", (event) => {
+ if (event.key !== "Tab") return;
+ const controls = [cancel, confirm];
+ const at = controls.indexOf(document.activeElement as HTMLButtonElement);
+ const step = event.shiftKey ? -1 : 1;
+ const next = (at + step + controls.length) % controls.length;
+ event.preventDefault();
+ controls[next]?.focus();
+ });
queueMicrotask(() => cancel.focus());
return scrim;
}
diff --git a/packages/helper-panel/src/state.ts b/packages/helper-panel/src/state.ts
index fd9ae0f6e..1ed981411 100644
--- a/packages/helper-panel/src/state.ts
+++ b/packages/helper-panel/src/state.ts
@@ -26,8 +26,6 @@ export interface PanelState {
*/
export type LoginItemState = "registered" | "needsApproval" | "notRegistered";
-export const LOGIN_ITEM_STATES: readonly LoginItemState[] = ["registered", "needsApproval", "notRegistered"];
-
export const EMPTY_STATE: PanelState = {
enabled: false,
screenRecording: false,
@@ -68,8 +66,3 @@ export type PanelAction =
* the document reports what it needs and nothing inside it scrolls.
*/
export type PanelMessage = { action: PanelAction } | { action: "resize"; width: number; height: number };
-
-/** Everything the helper must refuse while consent is off. */
-export function isPermissionAction(action: PanelAction): boolean {
- return action === "requestCapture" || action === "requestInput";
-}
diff --git a/packages/pane-runtime/src/preview/xvfb-display-provider.ts b/packages/pane-runtime/src/preview/xvfb-display-provider.ts
index d7be123ad..d31342b4b 100644
--- a/packages/pane-runtime/src/preview/xvfb-display-provider.ts
+++ b/packages/pane-runtime/src/preview/xvfb-display-provider.ts
@@ -3,11 +3,13 @@ import {
chmodSync,
closeSync,
existsSync,
+ fstatSync,
lstatSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
+ readSync,
rmSync,
unlinkSync,
writeFileSync,
@@ -82,6 +84,9 @@ const DEFAULT_READY_BUDGET_MS = 10_000;
const READY_POLL_MS = 100;
/** How many trailing lines each child log keeps in the daemon log on a refusal. */
const REFUSAL_TAIL_LINES = 30;
+/** How far back into a child's log the refusal read goes: enough for the lines
+ * above, bounded so a chatty stranger cannot make the daemon size its whole file. */
+const REFUSAL_TAIL_BYTES = 16_384;
/**
* The X auth file for one pane's display, byte-derived from `dataDir` the
@@ -799,12 +804,25 @@ function writeRecord(dir: string, rec: Record): void {
* is reaped, so a missing or empty file must be a quiet no, never a throw.
*/
function readTail(path: string): string {
+ let handle = -1;
try {
- const content = readFileSync(path, "utf8");
- const lines = content.split("\n").filter((line) => line.trim().length > 0);
+ // The tail, not the file. These logs are seconds old in practice, but a
+ // chatty x11vnc repeating a rejected client can grow one, and a refusal is
+ // the wrong moment to allocate a stranger's entire output in the daemon.
+ handle = openSync(path, "r");
+ const size = fstatSync(handle).size;
+ const start = Math.max(0, size - REFUSAL_TAIL_BYTES);
+ const buffer = new Uint8Array(size - start);
+ readSync(handle, buffer, 0, buffer.length, start);
+ const lines = new TextDecoder()
+ .decode(buffer)
+ .split("\n")
+ .filter((line) => line.trim().length > 0);
return lines.slice(-REFUSAL_TAIL_LINES).join("\n");
} catch {
return "";
+ } finally {
+ if (handle >= 0) closeSync(handle);
}
}
diff --git a/packages/subshell-protocol/src/display-providers.ts b/packages/subshell-protocol/src/display-providers.ts
index b4de4847f..c4d34ed93 100644
--- a/packages/subshell-protocol/src/display-providers.ts
+++ b/packages/subshell-protocol/src/display-providers.ts
@@ -2,10 +2,20 @@
* The operating system one bundled provider can drive. It sits in the catalog
* rather than in each surface that asks, because the answer is a fact about the
* provider: Xvfb is Linux's display server and BetterDisplay is macOS's, and a
- * picker that offers the other one is offering a choice the machine refuses.
+ * picker that offers the other one is offering a choice the machine refuses. The
+ * catalog `satisfies` it, so a provider added without a platform is a compile
+ * error rather than an entry that silently matches no OS.
*/
export type DisplayProviderPlatform = "linux" | "darwin";
+/** One row of the catalog. `platform` is the type's whole point: see above. */
+export interface DisplayProviderCatalogEntry {
+ id: string;
+ name: string;
+ description: string;
+ platform: DisplayProviderPlatform;
+}
+
/** Bundled display choices. Add implementations to pane-runtime's registry too. */
export const VIRTUAL_DISPLAY_PROVIDERS = [
{
@@ -20,7 +30,7 @@ export const VIRTUAL_DISPLAY_PROVIDERS = [
description: "macOS virtual screens using BetterDisplay and the opt-in Subshell Display Helper.",
platform: "darwin",
},
-] as const;
+] as const satisfies readonly DisplayProviderCatalogEntry[];
export const DEFAULT_VIRTUAL_DISPLAY_PROVIDER = "xvfb-x11vnc";
/**
diff --git a/scripts/__tests__/design-tokens.test.ts b/scripts/__tests__/design-tokens.test.ts
index bbb09ab3f..332f6c4ee 100644
--- a/scripts/__tests__/design-tokens.test.ts
+++ b/scripts/__tests__/design-tokens.test.ts
@@ -448,6 +448,20 @@ describe("the shared token file", () => {
});
});
+describe("the shared palette as a build input", () => {
+ test("turbo treats it as a global dependency", () => {
+ // Every CSS surface imports `packages/assistant/tokens.css`, and the macOS
+ // helper panel ships the built result inside a signed bundle. Without this,
+ // a commit that edits only the palette hits a warm cache, serves the old
+ // inlined stylesheet, and `bun run lint:design` still passes: the checker
+ // reads sources, and the shipped bytes are whatever the cache held.
+ const turbo = JSON.parse(readFileSync(join(REPO_ROOT, "turbo.json"), "utf8")) as {
+ globalDependencies?: string[];
+ };
+ expect(turbo.globalDependencies).toContain(SHARED_TOKENS);
+ });
+});
+
describe("a package page that joins a surface", () => {
test("the helper panel is walked under the assistant rules, by name", () => {
// Nothing else checks the registration itself. A scan root pointing at a
diff --git a/turbo.json b/turbo.json
index 9bad5da4a..c9153014b 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,6 +1,9 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "stream",
+ "globalDependencies": [
+ "packages/assistant/tokens.css"
+ ],
"tasks": {
"clean": {
"cache": false
From b7a893446805bbcffd5c7ae8223c3757276142bf Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 09:09:45 -0700
Subject: [PATCH 037/120] fix(helper,web): close the second review round's
minors
The status menu's Quit helper now asks the panel's question before ending
the process, so a person leaving the menu is warned like one leaving the
panel; the words live in Theme.swift's QuitCopy, pinned to the panel's own
strings by the helper-panel suite and to the alert's shape by the Swift
tests. docs/macos-display-helper.md said Enable registered the login item,
Disable unregistered it, and Quit disabled everything: the branch reversed
all three, and both desktop AGENTS.md files route maintainers through that
page, so it described a helper that no longer exists.
The header badge's services poll gated on nodeEnabled alone, and
preview-ports-unsupported answers with nodeEnabled: true plus a reason, so
every macOS pane paid a five-second read whose result the count always
throws away. One predicate now answers for both the poll and the count,
tested on the switch-on refusal specifically.
Co-Authored-By: Claude
---
.changeset/virtual-display-rail-entry.md | 4 +-
.../src/hooks/use-unsaved-preview-count.ts | 12 +++---
.../src/lib/__tests__/preview-badge.test.ts | 19 +++++++++-
.../web/src/lib/display-refusal-routing.ts | 2 +-
apps/server/web/src/lib/preview-badge.ts | 16 +++++++-
docs/macos-display-helper.md | 38 ++++++++++++-------
native/display-helper/Theme.swift | 13 +++++++
native/display-helper/build_support.rs | 21 ++++++++++
native/display-helper/main.swift | 23 +++++++++--
native/display-helper/tests/HelperTests.swift | 5 +++
.../src/__tests__/strings.test.ts | 28 ++++++++++++++
11 files changed, 155 insertions(+), 26 deletions(-)
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
index c0db90aaf..b36d7b203 100644
--- a/.changeset/virtual-display-rail-entry.md
+++ b/.changeset/virtual-display-rail-entry.md
@@ -18,8 +18,8 @@ The desktop apps' **Open Display Helper settings** control works: the helper's s
The panel itself is now the same product as the rest of Subshell, built from the components a person already knows in the dashboard: a card plate with its border and radius, `Fact` rows (a strong muted label above a readable value) for the login item and the two macOS permissions, and a form-section heading with its trailing rule for each group. Gold marks what you can still fix and plain ink what is settled, red what macOS refused, a live line names how many people are watching this Mac's screens, help sits at the bottom left and the exits at the bottom right in one shape, and a disabled control's reason is drawn in gold rather than hidden where a person who cannot hover will never find it. It is a small local page bundled into the helper, so the design system's own checker guards it, and the palette it draws on moved to `packages/assistant/tokens.css` where every surface imports the same one instead of copying it. The menu bar's menu now says it belongs to the Subshell virtual display helper instead of leaving you to guess from an icon, its **Minimize helper** item follows whether the panel is actually on screen, and the consent item turns itself into **Enable helper** once you disable the helper, raising the panel so you can read what you agreed to. It used to offer only Disable, greyed out once consent was off, which left the settings window as the only way back to running.
-**Quit helper** asks first, and the question answers the part the button never said: watching stops now, your consent and the macOS permissions stay, and the helper returns at sign-in or from Subshell's virtual display settings, so disabling is the way to stop it coming back. Quitting also stopped revoking your consent, which it had been doing silently along with unregistering the login item: an app's Close button destroying the very permission the panel exists to collect, so every reopen started from Disabled.
+**Quit helper** asks first, from the panel and from the menu bar's menu alike, and the question answers the part the button never said: watching stops now, your consent and the macOS permissions stay, and the helper returns at sign-in or from Subshell's virtual display settings, so disabling is the way to stop it coming back. Quitting also stopped revoking your consent, which it had been doing silently along with unregistering the login item: an app's Close button destroying the very permission the panel exists to collect, so every reopen started from Disabled.
-Enabling the helper no longer adds it to macOS's sign-in list, and disabling no longer takes it off. **Launch at sign-in** is its own row with its own button, and the row says which of the three states macOS actually reports, registered, waiting for your approval, or not registered at all. Reading the last two as "approved" is what let a turned-off sign-in start keep claiming it starts at sign-in. The panel re-reads the machine every five seconds while it is open, in every run-loop mode and with App Nap held off, so a switch you flip in System Settings changes what you see without reopening anything.
+Enabling the helper no longer adds it to macOS's sign-in list, and disabling no longer takes it off. **Start at sign-in** is its own row with its own button, and the row says which of the three states macOS actually reports, registered, waiting for your approval, or not registered at all. Reading the last two as "approved" is what let a turned-off sign-in start keep claiming it starts at sign-in. The panel re-reads the machine every five seconds while it is open, in every run-loop mode and with App Nap held off, so a switch you flip in System Settings changes what you see without reopening anything.
Discarding the last virtual display on a Mac also hands the pointer back. A viewer driving that screen leaves the real cursor on a display that no longer exists, which looked like a vanished cursor to whoever was sitting at the machine.
diff --git a/apps/server/web/src/hooks/use-unsaved-preview-count.ts b/apps/server/web/src/hooks/use-unsaved-preview-count.ts
index c3420b187..c48f429dd 100644
--- a/apps/server/web/src/hooks/use-unsaved-preview-count.ts
+++ b/apps/server/web/src/hooks/use-unsaved-preview-count.ts
@@ -1,6 +1,6 @@
import { useSubshellPreview } from "@/hooks/use-subshell-preview";
import { useSubshellServices } from "@/hooks/use-subshell-services";
-import { unsavedPreviewItemCount } from "@/lib/preview-badge";
+import { shouldPollSavedServices, unsavedPreviewItemCount } from "@/lib/preview-badge";
import type { SubshellView } from "@/types/subshell";
/**
@@ -16,9 +16,11 @@ export function useUnsavedPreviewCount(id: string | undefined, subshell: Subshel
// the path `/api/subshells//services` under a key no one else uses.
const usable = id !== undefined && id !== "" && subshell !== undefined;
const preview = useSubshellPreview(id ?? "", usable ? subshell : undefined);
- // The saved-services poll waits for the node to say it can serve previews at
- // all: with the switch off, or the node offline, the count is 0 either way and
- // an idle workspace stops costing two reads every five seconds.
- const services = useSubshellServices(id ?? "", usable && preview.info?.nodeEnabled === true);
+ // The saved-services poll asks the same question the badge asks before
+ // counting: node switched on and no reason attached. `preview-ports-
+ // unsupported` answers with both at once, which is why the switch alone is
+ // not the gate: on a macOS pane it would hold a five-second poll whose data
+ // is always discarded.
+ const services = useSubshellServices(id ?? "", usable && shouldPollSavedServices(preview.info));
return usable ? unsavedPreviewItemCount(preview.info, services.data) : 0;
}
diff --git a/apps/server/web/src/lib/__tests__/preview-badge.test.ts b/apps/server/web/src/lib/__tests__/preview-badge.test.ts
index 2d57091f3..2957c8022 100644
--- a/apps/server/web/src/lib/__tests__/preview-badge.test.ts
+++ b/apps/server/web/src/lib/__tests__/preview-badge.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "bun:test";
import type { PreviewInfo } from "@/hooks/use-subshell-preview";
import type { SavedService } from "@/hooks/use-subshell-services";
-import { unsavedPreviewItemCount } from "@/lib/preview-badge";
+import { shouldPollSavedServices, unsavedPreviewItemCount } from "@/lib/preview-badge";
/**
* The header badge's rule: discovered items with no saved service row. The
@@ -57,3 +57,20 @@ describe("unsavedPreviewItemCount", () => {
expect(unsavedPreviewItemCount(info({ ports: [port(3000)] }), undefined)).toBe(1);
});
});
+
+describe("shouldPollSavedServices", () => {
+ it("holds the services poll off in exactly the states the count ignores", () => {
+ expect(shouldPollSavedServices(undefined)).toBe(false);
+ expect(shouldPollSavedServices(info({ nodeEnabled: false }))).toBe(false);
+ expect(shouldPollSavedServices(info({ reason: "node-offline" }))).toBe(false);
+ expect(shouldPollSavedServices(info())).toBe(true);
+ });
+
+ it("holds the poll off for a switch-on refusal like preview-ports-unsupported", () => {
+ // The route answers `nodeEnabled: true` alongside the reason whenever the
+ // node has no /proc to scan, which is every macOS pane. Gating the poll on
+ // the switch alone left those panes asking for a list for a count that is
+ // always 0; the reason is what says there is nothing to ask.
+ expect(shouldPollSavedServices(info({ nodeEnabled: true, reason: "preview-ports-unsupported" }))).toBe(false);
+ });
+});
diff --git a/apps/server/web/src/lib/display-refusal-routing.ts b/apps/server/web/src/lib/display-refusal-routing.ts
index d7c7f2eff..f5d1ee91b 100644
--- a/apps/server/web/src/lib/display-refusal-routing.ts
+++ b/apps/server/web/src/lib/display-refusal-routing.ts
@@ -6,7 +6,7 @@ import { isDisplayLaunchRefusal } from "@/lib/create-subshell-error";
* A refusal about the virtual display belongs beside the **Virtual display**
* checkbox that caused it, because that is the control the person would change.
* The checkbox is mounted only by the agent branch of the launch form, and the
- * mode and kind toggles can unmount it while the error stays set — so the rule
+ * mode and kind toggles can unmount it while the error stays set, so the rule
* needs both halves: the field owns it while it exists, and the form line takes
* it back when it does not. A refusal nobody can read is worse than one shown
* in two places, and this is what "exactly one place" has to mean.
diff --git a/apps/server/web/src/lib/preview-badge.ts b/apps/server/web/src/lib/preview-badge.ts
index ea351598c..60b4d4ba0 100644
--- a/apps/server/web/src/lib/preview-badge.ts
+++ b/apps/server/web/src/lib/preview-badge.ts
@@ -1,6 +1,20 @@
import type { PreviewInfo } from "@/hooks/use-subshell-preview";
import type { SavedService } from "@/hooks/use-subshell-services";
+/**
+ * Whether the pane's saved-services read is worth asking for, which is the
+ * same test the badge makes before counting anything: a node that has not
+ * switched previews on, or one that answered with a reason, can only produce a
+ * count of 0. `preview-ports-unsupported` is the case that shows why both
+ * halves matter: it arrives with `nodeEnabled: true` on every macOS pane, so
+ * gating on the switch alone would keep polling a list whose result is thrown
+ * away. One predicate, so the poll and the count can never disagree about when
+ * there is nothing to ask.
+ */
+export function shouldPollSavedServices(info: PreviewInfo | undefined): boolean {
+ return info?.nodeEnabled === true && info.reason === undefined;
+}
+
/**
* How many things the preview picker could offer that this pane has no saved
* service for - the number behind the header icon's badge.
@@ -14,7 +28,7 @@ import type { SavedService } from "@/hooks/use-subshell-services";
* A port discovered on both IPv4 and IPv6 counts once.
*/
export function unsavedPreviewItemCount(info: PreviewInfo | undefined, services: SavedService[] | undefined): number {
- if (!info?.nodeEnabled || info.reason) return 0;
+ if (!info || !shouldPollSavedServices(info)) return 0;
const savedPorts = new Set((services ?? []).map((service) => service.port));
const discoveredPorts = new Set(info.ports.map((row) => row.port));
let count = 0;
diff --git a/docs/macos-display-helper.md b/docs/macos-display-helper.md
index fbce63d3a..854c3ef14 100644
--- a/docs/macos-display-helper.md
+++ b/docs/macos-display-helper.md
@@ -8,19 +8,31 @@ settings using LaunchServices. This identity is distinct from the experimental
## Consent and lifecycle
-Installation and first-run setup never call the helper. The native menu opens
-settings without opting in. Only the AppKit Enable button writes the shared
-UserDefaults consent flag and registers `SMAppService.mainApp`. OS privacy
-prompts have their own buttons, available only after consent. A remote web
-page has no Tauri command for any of these operations.
-
-After consent, login may resume the helper. Disable clears consent first,
-closes every capture and input connection, releases held keys/buttons, and
-unregisters startup. Quit also disables; normal logout preserves consent.
-Disabling does not revoke TCC grants, close terminals, or guarantee removal
-of virtual screens while BetterDisplay is unreachable. A disabled running
-helper answers read-only status requests, but refuses all other RPCs. No RPC
-can enable it, register login startup, or request a privacy prompt.
+Installation and first-run setup never call the helper. The status menu opens
+the panel without opting in. The panel is a `WKWebView` over a bundled page: it
+draws the controls and posts actions, and Swift re-checks every one of them, so
+the page is presentation and never authority. Consent is written only by the
+enable act, whether it arrives from the panel's **Enable helper** button or from
+the status menu's toggle. Starting at sign-in is its own act with its own
+button (**Start at sign-in** / **Stop at sign-in**), offered by the panel and
+the menu alike; enabling never registers it, because agreeing to be captured
+and agreeing to launch at sign-in are two choices. OS privacy prompts have
+their own buttons, available only after consent. A remote web page has no
+Tauri command for any of these operations.
+
+After consent, login may resume the helper. Disable clears consent first and
+closes every capture and input connection, releasing held keys/buttons; it
+leaves the login item alone, so a helper that returns at the next sign-in is
+found disabled and exits at once. **Quit helper** asks first, in the panel and
+in the status menu alike, then ends the process: captures close, consent and
+the TCC grants stay, and a registered login item brings it back at the next
+sign-in, so disabling rather than quitting is how you stop it returning.
+Normal logout preserves consent. Disabling does not revoke TCC grants, close
+terminals, or guarantee removal of virtual screens while BetterDisplay is
+unreachable. A disabled running helper answers the read-only `status` request
+and accepts `rescue-cursor` (which does nothing while no viewer held the
+pointer there), but refuses every other RPC. No RPC can enable it, register
+login startup, or request a privacy prompt.
User procedures belong in [node management](../apps/docs/content/docs/nodes/manage.mdx#macos-display-helper).
diff --git a/native/display-helper/Theme.swift b/native/display-helper/Theme.swift
index e5f4b5f71..403a87546 100644
--- a/native/display-helper/Theme.swift
+++ b/native/display-helper/Theme.swift
@@ -119,3 +119,16 @@ enum AccessReport {
return enabled ? "Subshell virtual display helper enabled" : "Subshell virtual display helper disabled"
}
}
+
+/// The quit confirmation, worded once for both doors. The panel's Quit and the
+/// status menu's Quit ask the same question, because the consequence (everyone
+/// watching loses the screen now) belongs to the act, not to which control
+/// started it. `packages/helper-panel/src/strings.ts` carries the originals the
+/// panel renders; the helper-panel suite fails if these drift from them.
+enum QuitCopy {
+ static let title = "Quit the display helper?"
+ static let body =
+ "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay, and the helper starts again at sign-in, or sooner from Subshell's virtual display settings. To stop it coming back, disable it first."
+ static let confirm = "Quit helper"
+ static let cancel = "Cancel"
+}
diff --git a/native/display-helper/build_support.rs b/native/display-helper/build_support.rs
index e48e21a3a..14ff4e160 100644
--- a/native/display-helper/build_support.rs
+++ b/native/display-helper/build_support.rs
@@ -399,6 +399,27 @@ mod tests {
covered.contains(HELPER_ID),
"the fingerprint omits the bundle identifier"
);
+ // The other direction, which is the half that bites: a switch added to
+ // a builder without a name in the constants above changes what lands
+ // in the bundle while the fingerprint, and so the replacement check,
+ // stays put. Every dash-lead token either command passes must appear in
+ // one of the declared lists.
+ let mut declared: Vec = COMPILE_FLAGS.iter().map(|flag| (*flag).to_string()).collect();
+ declared.extend(SIGN_FLAGS.iter().map(|flag| (*flag).to_string()));
+ declared.extend(["-target", "-o", "--force", "--sign", "--identifier", "--keychain"].map(str::to_string));
+ for probe in [
+ argv,
+ codesign_argv("Developer ID Application: X", "keychain", &PathBuf::from("bundle")),
+ ] {
+ for token in probe {
+ if token.starts_with('-') {
+ assert!(
+ declared.contains(&token),
+ "an undeclared flag {token} reaches the bundle without reaching the fingerprint"
+ );
+ }
+ }
+ }
}
#[test]
diff --git a/native/display-helper/main.swift b/native/display-helper/main.swift
index d3aed3967..ff0a33281 100644
--- a/native/display-helper/main.swift
+++ b/native/display-helper/main.swift
@@ -114,9 +114,26 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate {
}
settings.show()
}
- // Same split as the panel's button: leaving the menu's Quit does not revoke
- // consent, and `applicationWillTerminate` tears down every capture.
- @objc private func quitHelper() { NSApp.terminate(nil) }
+ /// The menu's Quit asks the same question the panel asks, because the
+ /// consequence lives in the act rather than in the control. An accessory
+ /// app has no window to host a sheet while the panel is closed, so this is
+ /// a native alert; Cancel is the button Return presses, the way the panel
+ /// focuses the way out rather than the act. `applicationWillTerminate`
+ /// closes every capture, and leaving consent alone is deliberate: a quit is
+ /// not a revocation.
+ @objc private func quitHelper() {
+ let alert = NSAlert()
+ alert.messageText = QuitCopy.title
+ alert.informativeText = QuitCopy.body
+ alert.alertStyle = .warning
+ // Added first, so Return and the rightmost position land on the way out.
+ alert.addButton(withTitle: QuitCopy.cancel)
+ let confirm = alert.addButton(withTitle: QuitCopy.confirm)
+ confirm.hasDestructiveAction = true
+ NSApp.activate(ignoringOtherApps: true)
+ if alert.runModal() == .alertFirstButtonReturn { return }
+ NSApp.terminate(nil)
+ }
}
umask(0o077)
diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift
index 5d11980e1..282d67458 100644
--- a/native/display-helper/tests/HelperTests.swift
+++ b/native/display-helper/tests/HelperTests.swift
@@ -82,6 +82,11 @@ struct HelperTests {
precondition(AccessReport.toggleTitle(enabled: false) == "Enable helper")
// The bar item is an icon, so the menu it opens has to say what it is.
precondition(AccessReport.heading == "Subshell virtual display helper", "the status menu must name the helper")
+ // Both Quit doors ask, and the menu's copy is this. The panel's own
+ // strings are compared against it by the helper-panel suite.
+ precondition(QuitCopy.title.hasSuffix("?"), "a confirmation names the act as a question")
+ precondition(QuitCopy.body.contains("loses it now"), "the question says what stopping means")
+ precondition(QuitCopy.confirm != QuitCopy.cancel && !QuitCopy.cancel.isEmpty)
// The pointer rescue: a viewer's cursor lives on the virtual display,
// so losing that screen strands it off every display the person at
// this Mac can see. Only a point inside no frame counts as stranded.
diff --git a/packages/helper-panel/src/__tests__/strings.test.ts b/packages/helper-panel/src/__tests__/strings.test.ts
index b3b278f8f..89e8c8872 100644
--- a/packages/helper-panel/src/__tests__/strings.test.ts
+++ b/packages/helper-panel/src/__tests__/strings.test.ts
@@ -166,3 +166,31 @@ describe("permissionValue", () => {
expect(permissionValue(false)).toEqual({ text: "Not allowed", blocked: true });
});
});
+
+describe("the menu's quit confirmation", () => {
+ // The status menu asks the panel's question before ending the helper, in
+ // Swift. Two copies of a consent sentence drift, and the drift is invisible
+ // to both sides: the menu would quietly stop explaining what quitting costs
+ // while the panel still did. Pin the menu's words to these.
+ const swiftPath = new URL("../../../../native/display-helper/Theme.swift", import.meta.url);
+ test("the native menu carries the same question, body, and buttons", async () => {
+ // Whitespace collapsed, so swift-format-style re-wrapping on either side cannot
+ // fail a check that is about the words.
+ const swift = (await Bun.file(swiftPath).text()).replace(/\s+/g, " ");
+ for (const [name, value] of Object.entries({
+ title: strings.quitTitle,
+ body: strings.quitBody,
+ confirm: strings.quit,
+ cancel: strings.cancel,
+ })) {
+ expect(swift).toContain(`static let ${name} = ${JSON.stringify(value)}`);
+ }
+ }, 10_000);
+
+ test("the panel's own strings keep the shape the alert depends on", () => {
+ // The Swift side treats the first button as the safe one; an empty label
+ // would hand Return to the destructive act.
+ expect(strings.cancel).toBeTruthy();
+ expect(strings.quit).not.toBe(strings.cancel);
+ });
+});
From 7e4686034fda143f079facfb753864292463f502 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 09:11:53 -0700
Subject: [PATCH 038/120] fix(helper): use the current activation call in the
quit alert
The helper targets macOS 14, where activate(ignoringOtherApps:) is the
deprecated shape; an accessory app still has to come forward for its alert
to be the thing a person can answer.
Co-Authored-By: Claude
---
native/display-helper/main.swift | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/native/display-helper/main.swift b/native/display-helper/main.swift
index ff0a33281..8febcf17b 100644
--- a/native/display-helper/main.swift
+++ b/native/display-helper/main.swift
@@ -130,7 +130,10 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate {
alert.addButton(withTitle: QuitCopy.cancel)
let confirm = alert.addButton(withTitle: QuitCopy.confirm)
confirm.hasDestructiveAction = true
- NSApp.activate(ignoringOtherApps: true)
+ // An accessory app has no key window to hang the alert on, so it comes
+ // forward first. `activate(ignoringOtherApps:)` is the deprecated shape
+ // of this call on the helper's own deployment target.
+ NSApp.activate()
if alert.runModal() == .alertFirstButtonReturn { return }
NSApp.terminate(nil)
}
From 68592c1844f384b030c2dad5b6ac220963c41530 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 09:13:19 -0700
Subject: [PATCH 039/120] docs(security): describe the consent split the helper
actually ships
The threat model said the native panel owned consent and login registration
and that Disable unregistered startup. Sign-in is its own act now, so
neither half of that is true, and this file is the authority a maintainer
reads when they change the consent surface.
Co-Authored-By: Claude
---
docs/security.md | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/docs/security.md b/docs/security.md
index b230fef65..1f48e83d2 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -4205,10 +4205,16 @@ against a hostile OS account rebinding a port after a check.
**macOS helper consent is local and independent of node policy.** The
BetterDisplay provider uses a signed native helper bundled with both desktop
apps. Neither first-run setup, a remote page, node configuration, nor MCP can
-enable it or request OS privacy prompts. The native settings panel owns consent
-and login registration. Disable clears consent before closing captures and
-releasing held input; it unregisters startup without revoking existing TCC
-grants. A disabled running helper exposes status only. Same-UID Unix socket
+enable it or request OS privacy prompts. Consent belongs to the helper's own
+panel (a bundled local page in a `WKWebView`, where Swift re-checks every posted
+action, so the page is presentation and never authority): the panel's enable act
+is the only writer of the consent flag. Starting at sign-in is a separate act
+with its own control, so enabling never registers a login item and disabling
+never removes one; a helper that returns at the next sign-in is found disabled
+and exits. Disable clears consent before closing captures and releasing held
+input, and leaves existing TCC grants alone. A disabled running helper exposes
+status, and accepts the pointer rescue, which does nothing while no viewer held
+the cursor on that screen. Same-UID Unix socket
peers are trusted, with a 0700 directory and 0600 socket; the node's localhost
RFB adapter retains the local-user limitation above. Both protocols bound
buffers, concurrency, and startup waits.
From c3df870cf1757de62db0fb9dbd557b111e35db32 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 09:18:46 -0700
Subject: [PATCH 040/120] fix(helper): tell the quit truth, and keep the
unregister door open
Three of these were the same mistake seen from different sides: the panel
and the menu both promised the helper "starts again at sign-in" whatever
macOS says, and named disabling as the way to stop it. Enabling never
registered a login item and disabling never removes one, so that sentence
was true of one of the three states and the remedy was true of none. The
body now reads the registration it is standing next to: registered says it
comes back and points at Stop at sign-in; needsApproval says macOS is still
waiting; notRegistered says nothing starts it. Swift carries the same three
sentences, pinned literal for literal by the helper-panel suite and shaped
by the native tests.
The section that row lives in was drawn only while consent was on, so the
standard stop-access path (disable, then change your mind about sign-in)
deleted the only Stop at sign-in button and made the person re-consent to
capture to remove a login item. It now stays up for any registration that
outlived its consent.
Docs and comments that still described the pre-split helper: the menu was
credited with a sign-in button it never had, the shared crate said
open_settings is called only from a native menu though the assistants'
Virtual Displays screens reach it too, the published enablement step named
two buttons with an ellipsis the panel does not draw, and the threat model
still had Disable unregistering startup.
Co-Authored-By: Claude
---
.changeset/virtual-display-rail-entry.md | 5 +-
apps/docs/content/docs/nodes/manage.mdx | 8 +-
crates/desktop-core/src/display_helper.rs | 7 +-
docs/macos-display-helper.md | 10 ++-
native/display-helper/Settings.swift | 3 +-
native/display-helper/Theme.swift | 28 ++++++-
native/display-helper/main.swift | 2 +-
native/display-helper/tests/HelperTests.swift | 10 ++-
.../src/__tests__/strings.test.ts | 73 ++++++++++++++++---
packages/helper-panel/src/main.ts | 34 ++++++---
packages/helper-panel/src/strings.ts | 52 ++++++++++---
11 files changed, 183 insertions(+), 49 deletions(-)
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
index b36d7b203..17a9f7a79 100644
--- a/.changeset/virtual-display-rail-entry.md
+++ b/.changeset/virtual-display-rail-entry.md
@@ -18,7 +18,10 @@ The desktop apps' **Open Display Helper settings** control works: the helper's s
The panel itself is now the same product as the rest of Subshell, built from the components a person already knows in the dashboard: a card plate with its border and radius, `Fact` rows (a strong muted label above a readable value) for the login item and the two macOS permissions, and a form-section heading with its trailing rule for each group. Gold marks what you can still fix and plain ink what is settled, red what macOS refused, a live line names how many people are watching this Mac's screens, help sits at the bottom left and the exits at the bottom right in one shape, and a disabled control's reason is drawn in gold rather than hidden where a person who cannot hover will never find it. It is a small local page bundled into the helper, so the design system's own checker guards it, and the palette it draws on moved to `packages/assistant/tokens.css` where every surface imports the same one instead of copying it. The menu bar's menu now says it belongs to the Subshell virtual display helper instead of leaving you to guess from an icon, its **Minimize helper** item follows whether the panel is actually on screen, and the consent item turns itself into **Enable helper** once you disable the helper, raising the panel so you can read what you agreed to. It used to offer only Disable, greyed out once consent was off, which left the settings window as the only way back to running.
-**Quit helper** asks first, from the panel and from the menu bar's menu alike, and the question answers the part the button never said: watching stops now, your consent and the macOS permissions stay, and the helper returns at sign-in or from Subshell's virtual display settings, so disabling is the way to stop it coming back. Quitting also stopped revoking your consent, which it had been doing silently along with unregistering the login item: an app's Close button destroying the very permission the panel exists to collect, so every reopen started from Disabled.
+**Quit helper** asks first, from the panel and from the menu bar's menu alike, and the question answers the part the button never said: watching stops now, your consent and the macOS permissions stay, and the helper
+says what it will actually do next: it returns at sign-in only if you registered
+it there, and the panel's **Stop at sign-in** button, not disabling, is what
+takes it off that list. Quitting also stopped revoking your consent, which it had been doing silently along with unregistering the login item: an app's Close button destroying the very permission the panel exists to collect, so every reopen started from Disabled.
Enabling the helper no longer adds it to macOS's sign-in list, and disabling no longer takes it off. **Start at sign-in** is its own row with its own button, and the row says which of the three states macOS actually reports, registered, waiting for your approval, or not registered at all. Reading the last two as "approved" is what let a turned-off sign-in start keep claiming it starts at sign-in. The panel re-reads the machine every five seconds while it is open, in every run-loop mode and with App Nap held off, so a switch you flip in System Settings changes what you see without reopening anything.
diff --git a/apps/docs/content/docs/nodes/manage.mdx b/apps/docs/content/docs/nodes/manage.mdx
index eaca7377c..18ed4a55b 100644
--- a/apps/docs/content/docs/nodes/manage.mdx
+++ b/apps/docs/content/docs/nodes/manage.mdx
@@ -74,10 +74,10 @@ virtual screen; click that window before typing.
the helper stays disabled until you choose otherwise.
2. Read the shared-desktop warning, then choose **Enable helper**. Capture and
input need that consent and both macOS permissions; nothing else starts on its
- own. If you want the helper to come back when you sign in, use the panel's
- **Launch at sign-in** row to add it to macOS's login list, and choose **Open
- login items…** if the row says macOS is still waiting for your approval.
-3. Choose **Allow screen recording…** and **Allow accessibility…**. In macOS
+ own. If you want the helper to come back when you sign in, choose **Start at
+ sign-in** in the panel's **Launch at sign-in** section, and choose **Open
+ login items…** if that row says macOS is still waiting for your approval.
+3. Choose **Allow screen recording** and **Allow accessibility**. In macOS
System Settings, grant both permissions to **Subshell Display Helper**.
Screen Recording lets it capture the virtual screen; Accessibility lets it
send keyboard and pointer input. Subshell can't grant these permissions
diff --git a/crates/desktop-core/src/display_helper.rs b/crates/desktop-core/src/display_helper.rs
index cbfea5397..70a1e3171 100644
--- a/crates/desktop-core/src/display_helper.rs
+++ b/crates/desktop-core/src/display_helper.rs
@@ -1,4 +1,5 @@
-//! The two native menus install the same helper. Nothing calls this at startup.
+//! The native menus and both assistants' Virtual Displays screens install the
+//! same helper. Nothing calls this at startup.
//! Consent and permission prompts belong to the helper's own native panel.
use std::fs::{self, File, OpenOptions};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
@@ -158,7 +159,9 @@ fn may_replace_version(bundled: &str, installed: &str, development: bool) -> boo
crate::version::version_lt(installed, bundled) || (development && bundled == installed)
}
-/// Called only from a native menu selection. Opening settings does not enable
+/// Called from a native menu selection or from either assistant's Virtual
+/// Displays screen, which reach it through a command granted to the bundled page
+/// only. Opening settings does not enable
/// capture, input, login registration, or a node's virtual display setting.
pub fn open_settings(resource_dir: &Path, home: &Path) -> Result<(), String> {
let bundled = if resource_dir.file_name().is_some_and(|name| name == "Resources") {
diff --git a/docs/macos-display-helper.md b/docs/macos-display-helper.md
index 854c3ef14..2ab9ff415 100644
--- a/docs/macos-display-helper.md
+++ b/docs/macos-display-helper.md
@@ -14,9 +14,10 @@ draws the controls and posts actions, and Swift re-checks every one of them, so
the page is presentation and never authority. Consent is written only by the
enable act, whether it arrives from the panel's **Enable helper** button or from
the status menu's toggle. Starting at sign-in is its own act with its own
-button (**Start at sign-in** / **Stop at sign-in**), offered by the panel and
-the menu alike; enabling never registers it, because agreeing to be captured
-and agreeing to launch at sign-in are two choices. OS privacy prompts have
+button (**Start at sign-in** / **Stop at sign-in**), in the panel: the menu
+carries consent and the exits, and leaves the login list to the row that can
+read macOS's answer back. Enabling never registers it, because agreeing to be
+captured and agreeing to launch at sign-in are two choices. OS privacy prompts have
their own buttons, available only after consent. A remote web page has no
Tauri command for any of these operations.
@@ -26,7 +27,8 @@ leaves the login item alone, so a helper that returns at the next sign-in is
found disabled and exits at once. **Quit helper** asks first, in the panel and
in the status menu alike, then ends the process: captures close, consent and
the TCC grants stay, and a registered login item brings it back at the next
-sign-in, so disabling rather than quitting is how you stop it returning.
+sign-in. Removing its sign-in start (the panel's **Stop at sign-in**) is what
+keeps it from coming back at all; disabling only makes the next start idle.
Normal logout preserves consent. Disabling does not revoke TCC grants, close
terminals, or guarantee removal of virtual screens while BetterDisplay is
unreachable. A disabled running helper answers the read-only `status` request
diff --git a/native/display-helper/Settings.swift b/native/display-helper/Settings.swift
index 663eab76b..809e35ac1 100644
--- a/native/display-helper/Settings.swift
+++ b/native/display-helper/Settings.swift
@@ -269,7 +269,8 @@ final class HelperSettings: NSObject, WKScriptMessageHandler, WKNavigationDelega
/// login item, because agreeing to be captured and agreeing to launch at
/// sign-in are two choices and macOS's list is not the place to discover
/// the second one was made for you. `registerLoginItem` is its own act with
- /// its own button, offered by the panel and by the status menu alike.
+ /// its own button, in the panel: the status menu carries consent and the
+ /// exits, and leaves the sign-in list to the row that can read it back.
func enableHelper() {
errorMessage = ""
do { try enable() }
diff --git a/native/display-helper/Theme.swift b/native/display-helper/Theme.swift
index 403a87546..cc5b68682 100644
--- a/native/display-helper/Theme.swift
+++ b/native/display-helper/Theme.swift
@@ -79,6 +79,13 @@ import ServiceManagement
/// leaves behind. Reading both as approved is how a disabled sign-in start kept
/// claiming it starts at sign-in.
enum LoginItem {
+ /// macOS's answer right now, in the word the page uses. Callers that are
+ /// not the web view (the quit alert, mainly) read the registration through
+ /// this rather than importing ServiceManagement themselves.
+ static func currentPageState() -> String {
+ pageState(SMAppService.mainApp.status)
+ }
+
static func pageState(_ status: SMAppService.Status) -> String {
switch status {
case .enabled: return "registered"
@@ -127,8 +134,25 @@ enum AccessReport {
/// panel renders; the helper-panel suite fails if these drift from them.
enum QuitCopy {
static let title = "Quit the display helper?"
- static let body =
- "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay, and the helper starts again at sign-in, or sooner from Subshell's virtual display settings. To stop it coming back, disable it first."
static let confirm = "Quit helper"
static let cancel = "Cancel"
+
+ /// The body reads the registration state rather than assuming one: enabling
+ /// never registered a login item and disabling never removes one, so
+ /// promising a return at sign-in to someone who never asked for it would be
+ /// a warning that misleads instead of informing. The three sentences match
+ /// `quitBody` in the panel's `strings.ts`, which the helper-panel suite
+ /// compares these against.
+ // Each state is one literal rather than a shared prefix, so the parity test
+ // can compare whole sentences between the two files.
+ static func body(loginItem: String) -> String {
+ switch loginItem {
+ case "registered":
+ return "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. The helper starts again at sign-in, or sooner from Subshell's virtual display settings. To stop it coming back, use the panel's Stop at sign-in button first."
+ case "needsApproval":
+ return "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. macOS is still waiting for your approval before it would start at sign-in, so it comes back when you open Subshell's virtual display settings."
+ default:
+ return "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. Nothing starts it at sign-in; it comes back when you open Subshell's virtual display settings."
+ }
+ }
}
diff --git a/native/display-helper/main.swift b/native/display-helper/main.swift
index 8febcf17b..306c0a208 100644
--- a/native/display-helper/main.swift
+++ b/native/display-helper/main.swift
@@ -124,7 +124,7 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate {
@objc private func quitHelper() {
let alert = NSAlert()
alert.messageText = QuitCopy.title
- alert.informativeText = QuitCopy.body
+ alert.informativeText = QuitCopy.body(loginItem: LoginItem.currentPageState())
alert.alertStyle = .warning
// Added first, so Return and the rightmost position land on the way out.
alert.addButton(withTitle: QuitCopy.cancel)
diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift
index 282d67458..fce9fc516 100644
--- a/native/display-helper/tests/HelperTests.swift
+++ b/native/display-helper/tests/HelperTests.swift
@@ -85,7 +85,15 @@ struct HelperTests {
// Both Quit doors ask, and the menu's copy is this. The panel's own
// strings are compared against it by the helper-panel suite.
precondition(QuitCopy.title.hasSuffix("?"), "a confirmation names the act as a question")
- precondition(QuitCopy.body.contains("loses it now"), "the question says what stopping means")
+ for state in ["registered", "needsApproval", "notRegistered"] {
+ precondition(QuitCopy.body(loginItem: state).contains("loses it now"), "every variant says what stopping means")
+ }
+ // The sentence that used to be shared: only a registered item comes
+ // back at sign-in, and only Stop at sign-in stops it, not disabling.
+ precondition(QuitCopy.body(loginItem: "registered").contains("starts again at sign-in"))
+ precondition(QuitCopy.body(loginItem: "registered").contains("Stop at sign-in"))
+ precondition(!QuitCopy.body(loginItem: "notRegistered").contains("starts again at sign-in"))
+ precondition(!QuitCopy.body(loginItem: "needsApproval").contains("starts again at sign-in"))
precondition(QuitCopy.confirm != QuitCopy.cancel && !QuitCopy.cancel.isEmpty)
// The pointer rescue: a viewer's cursor lives on the virtual display,
// so losing that screen strands it off every display the person at
diff --git a/packages/helper-panel/src/__tests__/strings.test.ts b/packages/helper-panel/src/__tests__/strings.test.ts
index 89e8c8872..5456de627 100644
--- a/packages/helper-panel/src/__tests__/strings.test.ts
+++ b/packages/helper-panel/src/__tests__/strings.test.ts
@@ -1,6 +1,14 @@
import { describe, expect, test } from "bun:test";
import { EMPTY_STATE, type PanelState } from "../state";
-import { loginItemLine, permissionValue, stateLine, strings, viewerLine } from "../strings";
+import {
+ loginItemLine,
+ permissionValue,
+ quitBody,
+ showsSignInSection,
+ stateLine,
+ strings,
+ viewerLine,
+} from "../strings";
/**
* The panel's copy is its consent interface, so the words are the behavior
@@ -127,18 +135,35 @@ describe("viewerLine", () => {
});
describe("quit confirmation", () => {
- test("asks, then says what stops, what stays, and how to get back", () => {
+ test("says what stops and what stays, in every sign-in state", () => {
// Quitting ends every screen being watched and the window does not come
// back on its own, so the act needs the consequences in front of the
// person BEFORE they take it, not in the docs after.
expect(strings.quitTitle.endsWith("?")).toBe(true);
expect(strings.quitTitle).toContain("Quit");
- const body = strings.quitBody;
- expect(body).toContain("loses it now");
- expect(body).toContain("consent");
- expect(body).toContain("starts again at sign-in");
- expect(body).toContain("virtual display settings");
- expect(body).toContain("disable it first");
+ for (const state of ["registered", "needsApproval", "notRegistered"] as const) {
+ const body = quitBody(state);
+ expect(body).toContain("loses it now");
+ expect(body).toContain("consent");
+ expect(body).toContain("virtual display settings");
+ }
+ });
+
+ test("promises a sign-in return only to the person who asked for one", () => {
+ // Enabling never registered a login item and disabling never removes one,
+ // so "it starts again at sign-in" is true of exactly one of the three
+ // states. A dialog that said it of all three told someone their helper was
+ // coming back when nothing would bring it, and told a person with a
+ // registered item that disabling was the way to stop it, which it is not.
+ const registered = quitBody("registered");
+ expect(registered).toContain("starts again at sign-in");
+ expect(registered).toContain("Stop at sign-in");
+ expect(registered).not.toContain("disable it first");
+ for (const state of ["needsApproval", "notRegistered"] as const) {
+ expect(quitBody(state)).not.toContain("starts again at sign-in");
+ }
+ expect(quitBody("notRegistered")).toContain("Nothing starts it at sign-in");
+ expect(quitBody("needsApproval")).toContain("waiting for your approval");
});
test("gives the granted row a control that says where it goes", () => {
@@ -157,6 +182,20 @@ describe("quit confirmation", () => {
});
});
+describe("showsSignInSection", () => {
+ test("keeps the unregister door reachable while the helper is off", () => {
+ // The row holds the only Stop at sign-in button, and disabling leaves the
+ // registration in place on purpose. Hiding the section with the consent
+ // toggle strands a registered helper: the person would have to accept
+ // capture again to take it off macOS's list.
+ expect(showsSignInSection(stateWith({ enabled: false, loginItem: "registered" }))).toBe(true);
+ expect(showsSignInSection(stateWith({ enabled: false, loginItem: "needsApproval" }))).toBe(true);
+ expect(showsSignInSection(stateWith({ enabled: false, loginItem: "notRegistered" }))).toBe(false);
+ // Enabled is where the choice gets made, whatever the registration says.
+ expect(showsSignInSection(stateWith({ enabled: true, loginItem: "notRegistered" }))).toBe(true);
+ });
+});
+
describe("permissionValue", () => {
test("says the state, and marks the one the person can still act on", () => {
// Gold for "Not allowed" and plain ink for "Allowed" is the whole point:
@@ -173,13 +212,12 @@ describe("the menu's quit confirmation", () => {
// to both sides: the menu would quietly stop explaining what quitting costs
// while the panel still did. Pin the menu's words to these.
const swiftPath = new URL("../../../../native/display-helper/Theme.swift", import.meta.url);
- test("the native menu carries the same question, body, and buttons", async () => {
- // Whitespace collapsed, so swift-format-style re-wrapping on either side cannot
- // fail a check that is about the words.
+ test("the native menu carries the same question and buttons", async () => {
+ // Whitespace collapsed, so a re-wrapping on either side cannot fail a check
+ // that is about the words.
const swift = (await Bun.file(swiftPath).text()).replace(/\s+/g, " ");
for (const [name, value] of Object.entries({
title: strings.quitTitle,
- body: strings.quitBody,
confirm: strings.quit,
cancel: strings.cancel,
})) {
@@ -187,6 +225,17 @@ describe("the menu's quit confirmation", () => {
}
}, 10_000);
+ test("the native menu answers the sign-in question state by state, like the panel", async () => {
+ // The bodies are the part that used to lie: one sentence for all three
+ // states promised a sign-in return to people who never registered one. If
+ // Swift's copy of a state's sentence drifts from this one, the menu warns
+ // about a future the panel says will not happen.
+ const swift = (await Bun.file(swiftPath).text()).replace(/\s+/g, " ");
+ for (const state of ["registered", "needsApproval", "notRegistered"] as const) {
+ expect(swift).toContain(JSON.stringify(quitBody(state)));
+ }
+ }, 10_000);
+
test("the panel's own strings keep the shape the alert depends on", () => {
// The Swift side treats the first button as the safe one; an empty label
// would hand Return to the destructive act.
diff --git a/packages/helper-panel/src/main.ts b/packages/helper-panel/src/main.ts
index d6a59953d..4b09d990c 100644
--- a/packages/helper-panel/src/main.ts
+++ b/packages/helper-panel/src/main.ts
@@ -1,6 +1,21 @@
import "./styles.css";
-import { EMPTY_STATE, type PanelAction, type PanelMessage, type PanelState, type StateValue } from "./state";
-import { loginItemLine, permissionValue, stateLine, strings, viewerLine } from "./strings";
+import {
+ EMPTY_STATE,
+ type LoginItemState,
+ type PanelAction,
+ type PanelMessage,
+ type PanelState,
+ type StateValue,
+} from "./state";
+import {
+ loginItemLine,
+ permissionValue,
+ quitBody,
+ showsSignInSection,
+ stateLine,
+ strings,
+ viewerLine,
+} from "./strings";
/** The bridge the Swift side installs. Absent in a browser, which is how the
* page stays previewable while every real act needs the helper process. */
@@ -160,7 +175,7 @@ function closeQuitDialog(): void {
* quit never told anyone. Cancel is focused, and Escape dismisses, because the
* safe answer must be the easy one.
*/
-function quitDialog(): HTMLElement {
+function quitDialog(loginItem: LoginItemState): HTMLElement {
const scrim = element("div", "scrim");
const dialog = element("div", "dialog");
dialog.setAttribute("role", "dialog");
@@ -169,7 +184,7 @@ function quitDialog(): HTMLElement {
dialog.setAttribute("aria-describedby", "quit-body");
const title = element("h2", "dialog-title", strings.quitTitle);
title.id = "quit-title";
- const body = element("p", "dialog-body", strings.quitBody);
+ const body = element("p", "dialog-body", quitBody(loginItem));
body.id = "quit-body";
const foot = element("div", "dialog-foot");
const cancel = pageButton(strings.cancel, "outline", closeQuitDialog);
@@ -243,11 +258,10 @@ function render(state: PanelState): void {
]),
);
- // macOS only lists the login item once the helper registers it, so the whole
- // section appears with the consent that created it. While the helper is off
- // there is nothing registered to approve, and a heading over an empty row is
- // how a panel starts lying about itself.
- if (state.enabled) {
+ // The section appears with the consent that usually created a registration,
+ // and stays for a registration that outlived it: disabling leaves the person's
+ // sign-in choice alone, and this row is the only way to take it back.
+ if (showsSignInSection(state)) {
content.append(
section(strings.signInSection, [
// One control, named for the state it is leaving: register, stop
@@ -316,7 +330,7 @@ function render(state: PanelState): void {
card.append(bar);
root.append(card);
- if (confirmingQuit) root.append(quitDialog());
+ if (confirmingQuit) root.append(quitDialog(state.loginItem));
// The Swift window sizes to the document, so the page reports both of its
// own dimensions rather than the window guessing and the content scrolling.
diff --git a/packages/helper-panel/src/strings.ts b/packages/helper-panel/src/strings.ts
index 3d8aa2ef4..6c9b723f8 100644
--- a/packages/helper-panel/src/strings.ts
+++ b/packages/helper-panel/src/strings.ts
@@ -36,13 +36,9 @@ export const strings = {
openDocs: "Setup instructions",
minimize: "Minimize helper",
quit: "Quit helper",
- /* The confirmation names the action, then the body says what stops, what
- * stays, and how to get back in. Quitting also unregisters the login item's
- * running copy, so "it comes back" is the fact a person needs most and the
- * one a silent quit would never tell them. */
+ /* The confirmation's shape. Its body is `quitBody` below, because what the
+ * helper does after a sign-in depends on a state the panel can already see. */
quitTitle: "Quit the display helper?",
- quitBody:
- "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay, and the helper starts again at sign-in, or sooner from Subshell's virtual display settings. To stop it coming back, disable it first.",
cancel: "Cancel",
/* What the button says, and what it says to a screen reader. The visible
* label is one word because the row already names the permission; the
@@ -76,11 +72,9 @@ export function viewerLine(state: PanelState): string {
}
/**
- * The login item's own line, shown only while the helper is enabled: a person
- * who turned it off has not registered anything to approve. Registered is a
- * settled fact and reads in the page's own ink; awaiting macOS is the thing
- * still waiting on them, so it reads in gold, the same split the permission
- * rows use.
+ * The login item's own line. Registered is a settled fact and reads in the
+ * page's own ink; awaiting macOS is the thing still waiting on them, so it
+ * reads in gold, the same split the permission rows use.
*/
export function loginItemLine(state: LoginItemState): StateValue {
switch (state) {
@@ -93,6 +87,42 @@ export function loginItemLine(state: LoginItemState): StateValue {
}
}
+/**
+ * Whether the **Launch at sign-in** section earns its heading. Consent is what
+ * usually creates a registration, but it is not the only thing that keeps one:
+ * disabling leaves the person's sign-in choice alone on purpose, and that row
+ * holds the only button that takes it back off macOS's list. Hiding the section
+ * with the consent toggle would strand a registered helper with no way to
+ * unregister it short of enabling capture again.
+ */
+export function showsSignInSection(state: PanelState): boolean {
+ return state.enabled || state.loginItem !== "notRegistered";
+}
+
+/**
+ * The quit confirmation's body, which has to answer the sign-in question from
+ * the state the panel already holds. Enabling never registered a login item and
+ * disabling never removes one, so whether this process returns at the next
+ * sign-in is a fact about that row, not a promise a dialog may make: saying
+ * "it comes back at sign-in" to someone who never asked for it is how the
+ * warning that exists to inform a decision ends up misleading instead. Every
+ * variant names what stops, what stays, and how to get it back.
+ */
+const QUIT_BODIES: Record = {
+ // One whole sentence per state rather than a shared prefix, so the helper's
+ // Swift copy can be compared to these literal for literal.
+ registered:
+ "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. The helper starts again at sign-in, or sooner from Subshell's virtual display settings. To stop it coming back, use the panel's Stop at sign-in button first.",
+ needsApproval:
+ "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. macOS is still waiting for your approval before it would start at sign-in, so it comes back when you open Subshell's virtual display settings.",
+ notRegistered:
+ "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. Nothing starts it at sign-in; it comes back when you open Subshell's virtual display settings.",
+};
+
+export function quitBody(loginItem: LoginItemState): string {
+ return QUIT_BODIES[loginItem];
+}
+
/**
* The state word, and whether it is something the person can still act on.
* Gold is the design system's "the owner can fix this here"; white is a
From 14771bf306016622517a90671431c3896c70152a Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 09:28:36 -0700
Subject: [PATCH 041/120] fix(helper): answer the sign-in question with consent
in hand, and rescue on start
The quit confirmation keyed its sentence on the login item alone, so a
registered helper that the person had switched off was told "it starts again
at sign-in" and sent to Stop at sign-in to prevent a return that captures
nothing. A consentless start launches and exits (main.swift terminates it),
which is the documented state right after Disable helper, so the fourth
variant says that plainly instead. Both copies, panel and menu, pick from
the pair the helper already reads, and both suites pin all four.
A viewer's cursor can also be stranded by a screen that outlives the helper:
the teardown asks the socket to warp it back, and a quit or crashed helper
answers nothing, so the pointer stayed parked on a display that no longer
existed. Any start now runs the same rescue, and it moves the cursor only
when it sits inside no screen's frame, so an ordinary launch changes nothing.
The changeset also said the codesign and UserDefaults escapes happened
because Linux CI never compiles that code. Both fail at runtime on the
machine with a real bundle, and macOS release builds compiled them fine;
what no lane ever did was open the panel.
Co-Authored-By: Claude
---
.changeset/virtual-display-rail-entry.md | 4 +-
docs/macos-display-helper.md | 7 ++-
native/display-helper/Theme.swift | 19 ++++---
native/display-helper/main.swift | 11 +++-
native/display-helper/tests/HelperTests.swift | 25 +++++++---
.../src/__tests__/strings.test.ts | 50 ++++++++++++-------
packages/helper-panel/src/main.ts | 15 ++----
packages/helper-panel/src/strings.ts | 40 +++++++++++----
.../pane-runtime/src/preview/macos-helper.ts | 9 ++--
9 files changed, 120 insertions(+), 60 deletions(-)
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
index 17a9f7a79..f13dc1a06 100644
--- a/.changeset/virtual-display-rail-entry.md
+++ b/.changeset/virtual-display-rail-entry.md
@@ -14,7 +14,7 @@ A launch refused over its virtual display now reports the problem beside the **V
The preview icon in a pane's header now wears a count when the pane has things you could open but have not saved: discovered app ports with no Saved services row, plus the screen and the browser watch when the pane offers them. It is the picker's own data, so the number and the list cannot disagree.
-The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (It reached the releases because the code is macOS-only and Linux CI never compiles it.) Pressing it then opened nothing either: the helper read its own preferences through a `UserDefaults` suite named after its bundle identifier, which Foundation refuses, so the process trapped before any window existed. Both are fixed, and a helper that cannot find its panel now says so instead of showing an empty window.
+The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (Both reached the releases because they fail at runtime, on the machine that has a real bundle: no lane opens the helper's panel, so nothing pressed the path that checks a signature or reads a preference.) Pressing it then opened nothing either: the helper read its own preferences through a `UserDefaults` suite named after its bundle identifier, which Foundation refuses, so the process trapped before any window existed. Both are fixed, and a helper that cannot find its panel now says so instead of showing an empty window.
The panel itself is now the same product as the rest of Subshell, built from the components a person already knows in the dashboard: a card plate with its border and radius, `Fact` rows (a strong muted label above a readable value) for the login item and the two macOS permissions, and a form-section heading with its trailing rule for each group. Gold marks what you can still fix and plain ink what is settled, red what macOS refused, a live line names how many people are watching this Mac's screens, help sits at the bottom left and the exits at the bottom right in one shape, and a disabled control's reason is drawn in gold rather than hidden where a person who cannot hover will never find it. It is a small local page bundled into the helper, so the design system's own checker guards it, and the palette it draws on moved to `packages/assistant/tokens.css` where every surface imports the same one instead of copying it. The menu bar's menu now says it belongs to the Subshell virtual display helper instead of leaving you to guess from an icon, its **Minimize helper** item follows whether the panel is actually on screen, and the consent item turns itself into **Enable helper** once you disable the helper, raising the panel so you can read what you agreed to. It used to offer only Disable, greyed out once consent was off, which left the settings window as the only way back to running.
@@ -25,4 +25,4 @@ takes it off that list. Quitting also stopped revoking your consent, which it ha
Enabling the helper no longer adds it to macOS's sign-in list, and disabling no longer takes it off. **Start at sign-in** is its own row with its own button, and the row says which of the three states macOS actually reports, registered, waiting for your approval, or not registered at all. Reading the last two as "approved" is what let a turned-off sign-in start keep claiming it starts at sign-in. The panel re-reads the machine every five seconds while it is open, in every run-loop mode and with App Nap held off, so a switch you flip in System Settings changes what you see without reopening anything.
-Discarding the last virtual display on a Mac also hands the pointer back. A viewer driving that screen leaves the real cursor on a display that no longer exists, which looked like a vanished cursor to whoever was sitting at the machine.
+Discarding the last virtual display on a Mac also hands the pointer back. A viewer driving that screen leaves the real cursor on a display that no longer exists, which looked like a vanished cursor to whoever was sitting at the machine. A virtual screen can outlive the helper, so the rescue runs on any helper start as well as on teardown: whoever opens the panel next finds their cursor back where they can see it.
diff --git a/docs/macos-display-helper.md b/docs/macos-display-helper.md
index 2ab9ff415..86935a57a 100644
--- a/docs/macos-display-helper.md
+++ b/docs/macos-display-helper.md
@@ -34,7 +34,12 @@ terminals, or guarantee removal of virtual screens while BetterDisplay is
unreachable. A disabled running helper answers the read-only `status` request
and accepts `rescue-cursor` (which does nothing while no viewer held the
pointer there), but refuses every other RPC. No RPC can enable it, register
-login startup, or request a privacy prompt.
+login startup, or request a privacy prompt. Because a virtual screen can
+outlive the helper, the stranded-pointer rescue also runs on any start: a
+cursor left on a display that went away while nothing was listening is put back
+on the main screen the next time the helper launches, from sign-in or from a
+desktop app alike. It moves the cursor only when it sits inside no screen's
+frame, so an ordinary launch changes nothing.
User procedures belong in [node management](../apps/docs/content/docs/nodes/manage.mdx#macos-display-helper).
diff --git a/native/display-helper/Theme.swift b/native/display-helper/Theme.swift
index cc5b68682..06bd591ab 100644
--- a/native/display-helper/Theme.swift
+++ b/native/display-helper/Theme.swift
@@ -143,13 +143,20 @@ enum QuitCopy {
/// a warning that misleads instead of informing. The three sentences match
/// `quitBody` in the panel's `strings.ts`, which the helper-panel suite
/// compares these against.
- // Each state is one literal rather than a shared prefix, so the parity test
- // can compare whole sentences between the two files.
- static func body(loginItem: String) -> String {
- switch loginItem {
- case "registered":
+ // One whole literal per reachable pair rather than a shared prefix, so the
+ // parity test can compare sentences between the two files. Consent is part
+ // of the answer because a registered helper that is switched off does come
+ // back at sign-in and then exits without capturing anything: telling that
+ // person "it starts again at sign-in" describes a return that delivers no
+ // screen, and sending them to Stop at sign-in to prevent it asks them to
+ // fix something that is not broken.
+ static func body(loginItem: String, consented: Bool) -> String {
+ switch (loginItem, consented) {
+ case ("registered", true):
return "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. The helper starts again at sign-in, or sooner from Subshell's virtual display settings. To stop it coming back, use the panel's Stop at sign-in button first."
- case "needsApproval":
+ case ("registered", false):
+ return "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. The helper is disabled, so at sign-in it starts and exits without capturing anything. To take it out of macOS's login list, choose Stop at sign-in in the panel first."
+ case ("needsApproval", _):
return "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. macOS is still waiting for your approval before it would start at sign-in, so it comes back when you open Subshell's virtual display settings."
default:
return "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. Nothing starts it at sign-in; it comes back when you open Subshell's virtual display settings."
diff --git a/native/display-helper/main.swift b/native/display-helper/main.swift
index 306c0a208..f4df711b1 100644
--- a/native/display-helper/main.swift
+++ b/native/display-helper/main.swift
@@ -37,6 +37,15 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate {
started = true
runtime.onStateChange = { [weak self] in self?.updateMenu() }
settings.onVisibilityChange = { [weak self] in self?.updateMenu() }
+ // A viewer's pointer is the real cursor, so the screen it was parked on
+ // can go away while this process is not running: the docs promise an
+ // existing virtual screen may outlive the helper, and whoever closes the
+ // last pane over it cannot ask a dead socket to warp the cursor back. Any
+ // start, at sign-in or from the panel, heals that here rather than
+ // leaving the person at the Mac without a pointer. It moves the cursor
+ // only when it is inside no screen's frame, so a normal launch does
+ // nothing at all.
+ restoreStrandedCursor()
if consent.enabled {
do { try runtime.enable() }
catch { runtime.disable() }
@@ -124,7 +133,7 @@ final class DisplayHelperApp: NSObject, NSApplicationDelegate {
@objc private func quitHelper() {
let alert = NSAlert()
alert.messageText = QuitCopy.title
- alert.informativeText = QuitCopy.body(loginItem: LoginItem.currentPageState())
+ alert.informativeText = QuitCopy.body(loginItem: LoginItem.currentPageState(), consented: consent.enabled)
alert.alertStyle = .warning
// Added first, so Return and the rightmost position land on the way out.
alert.addButton(withTitle: QuitCopy.cancel)
diff --git a/native/display-helper/tests/HelperTests.swift b/native/display-helper/tests/HelperTests.swift
index fce9fc516..d0c3b56a2 100644
--- a/native/display-helper/tests/HelperTests.swift
+++ b/native/display-helper/tests/HelperTests.swift
@@ -86,14 +86,25 @@ struct HelperTests {
// strings are compared against it by the helper-panel suite.
precondition(QuitCopy.title.hasSuffix("?"), "a confirmation names the act as a question")
for state in ["registered", "needsApproval", "notRegistered"] {
- precondition(QuitCopy.body(loginItem: state).contains("loses it now"), "every variant says what stopping means")
+ for consented in [true, false] {
+ let body = QuitCopy.body(loginItem: state, consented: consented)
+ precondition(body.contains("loses it now"), "every variant says what stopping means")
+ precondition(body.contains("virtual display settings") || body.contains("login list"),
+ "every variant says how the helper comes back")
+ }
+ }
+ // The sentences that used to be shared: only a registered helper that is
+ // switched on comes back at sign-in, and only Stop at sign-in removes it.
+ precondition(QuitCopy.body(loginItem: "registered", consented: true).contains("starts again at sign-in"))
+ let registeredOff = QuitCopy.body(loginItem: "registered", consented: false)
+ precondition(registeredOff.contains("without capturing anything"), "a disabled start captures nothing")
+ precondition(registeredOff.contains("Stop at sign-in"))
+ precondition(!registeredOff.contains("starts again at sign-in"))
+ for state in ["notRegistered", "needsApproval"] {
+ for consented in [true, false] {
+ precondition(!QuitCopy.body(loginItem: state, consented: consented).contains("starts again at sign-in"))
+ }
}
- // The sentence that used to be shared: only a registered item comes
- // back at sign-in, and only Stop at sign-in stops it, not disabling.
- precondition(QuitCopy.body(loginItem: "registered").contains("starts again at sign-in"))
- precondition(QuitCopy.body(loginItem: "registered").contains("Stop at sign-in"))
- precondition(!QuitCopy.body(loginItem: "notRegistered").contains("starts again at sign-in"))
- precondition(!QuitCopy.body(loginItem: "needsApproval").contains("starts again at sign-in"))
precondition(QuitCopy.confirm != QuitCopy.cancel && !QuitCopy.cancel.isEmpty)
// The pointer rescue: a viewer's cursor lives on the virtual display,
// so losing that screen strands it off every display the person at
diff --git a/packages/helper-panel/src/__tests__/strings.test.ts b/packages/helper-panel/src/__tests__/strings.test.ts
index 5456de627..c181fb7f0 100644
--- a/packages/helper-panel/src/__tests__/strings.test.ts
+++ b/packages/helper-panel/src/__tests__/strings.test.ts
@@ -142,28 +142,40 @@ describe("quit confirmation", () => {
expect(strings.quitTitle.endsWith("?")).toBe(true);
expect(strings.quitTitle).toContain("Quit");
for (const state of ["registered", "needsApproval", "notRegistered"] as const) {
- const body = quitBody(state);
- expect(body).toContain("loses it now");
- expect(body).toContain("consent");
- expect(body).toContain("virtual display settings");
+ for (const enabled of [true, false]) {
+ const body = quitBody(state, enabled);
+ expect(body).toContain("loses it now");
+ expect(body).toContain("consent");
+ // Every variant also has to say how the helper comes back: the sign-in
+ // list, or the settings screen that launches it. A sentence that
+ // mentions neither leaves the person guessing whether quitting is
+ // recoverable, which is the question the dialog exists to answer.
+ expect(body.includes("virtual display settings") || body.includes("login list")).toBe(true);
+ }
}
});
- test("promises a sign-in return only to the person who asked for one", () => {
- // Enabling never registered a login item and disabling never removes one,
- // so "it starts again at sign-in" is true of exactly one of the three
- // states. A dialog that said it of all three told someone their helper was
- // coming back when nothing would bring it, and told a person with a
- // registered item that disabling was the way to stop it, which it is not.
- const registered = quitBody("registered");
- expect(registered).toContain("starts again at sign-in");
- expect(registered).toContain("Stop at sign-in");
- expect(registered).not.toContain("disable it first");
+ test("promises a sign-in return only to the person who asked for one and switched it on", () => {
+ // Two halves decide the sentence. Enabling never registered a login item
+ // and disabling never removes one, so "registered" is its own choice; and a
+ // registered helper that is switched off does launch at sign-in, then exits
+ // without capturing anything, so "it starts again at sign-in" describes a
+ // return that delivers no screen. Only one pair may claim it.
+ const ready = quitBody("registered", true);
+ expect(ready).toContain("starts again at sign-in");
+ expect(ready).toContain("Stop at sign-in");
+ expect(ready).not.toContain("disable it first");
+ const disabled = quitBody("registered", false);
+ expect(disabled).toContain("without capturing anything");
+ expect(disabled).toContain("Stop at sign-in");
+ expect(disabled).not.toContain("starts again at sign-in");
for (const state of ["needsApproval", "notRegistered"] as const) {
- expect(quitBody(state)).not.toContain("starts again at sign-in");
+ for (const enabled of [true, false]) {
+ expect(quitBody(state, enabled)).not.toContain("starts again at sign-in");
+ }
}
- expect(quitBody("notRegistered")).toContain("Nothing starts it at sign-in");
- expect(quitBody("needsApproval")).toContain("waiting for your approval");
+ expect(quitBody("notRegistered", true)).toContain("Nothing starts it at sign-in");
+ expect(quitBody("needsApproval", true)).toContain("waiting for your approval");
});
test("gives the granted row a control that says where it goes", () => {
@@ -232,7 +244,9 @@ describe("the menu's quit confirmation", () => {
// about a future the panel says will not happen.
const swift = (await Bun.file(swiftPath).text()).replace(/\s+/g, " ");
for (const state of ["registered", "needsApproval", "notRegistered"] as const) {
- expect(swift).toContain(JSON.stringify(quitBody(state)));
+ for (const enabled of [true, false]) {
+ expect(swift).toContain(JSON.stringify(quitBody(state, enabled)));
+ }
}
}, 10_000);
diff --git a/packages/helper-panel/src/main.ts b/packages/helper-panel/src/main.ts
index 4b09d990c..ea77840de 100644
--- a/packages/helper-panel/src/main.ts
+++ b/packages/helper-panel/src/main.ts
@@ -1,12 +1,5 @@
import "./styles.css";
-import {
- EMPTY_STATE,
- type LoginItemState,
- type PanelAction,
- type PanelMessage,
- type PanelState,
- type StateValue,
-} from "./state";
+import { EMPTY_STATE, type PanelAction, type PanelMessage, type PanelState, type StateValue } from "./state";
import {
loginItemLine,
permissionValue,
@@ -175,7 +168,7 @@ function closeQuitDialog(): void {
* quit never told anyone. Cancel is focused, and Escape dismisses, because the
* safe answer must be the easy one.
*/
-function quitDialog(loginItem: LoginItemState): HTMLElement {
+function quitDialog(state: PanelState): HTMLElement {
const scrim = element("div", "scrim");
const dialog = element("div", "dialog");
dialog.setAttribute("role", "dialog");
@@ -184,7 +177,7 @@ function quitDialog(loginItem: LoginItemState): HTMLElement {
dialog.setAttribute("aria-describedby", "quit-body");
const title = element("h2", "dialog-title", strings.quitTitle);
title.id = "quit-title";
- const body = element("p", "dialog-body", quitBody(loginItem));
+ const body = element("p", "dialog-body", quitBody(state.loginItem, state.enabled));
body.id = "quit-body";
const foot = element("div", "dialog-foot");
const cancel = pageButton(strings.cancel, "outline", closeQuitDialog);
@@ -330,7 +323,7 @@ function render(state: PanelState): void {
card.append(bar);
root.append(card);
- if (confirmingQuit) root.append(quitDialog(state.loginItem));
+ if (confirmingQuit) root.append(quitDialog(state));
// The Swift window sizes to the document, so the page reports both of its
// own dimensions rather than the window guessing and the content scrolling.
diff --git a/packages/helper-panel/src/strings.ts b/packages/helper-panel/src/strings.ts
index 6c9b723f8..869ddb04b 100644
--- a/packages/helper-panel/src/strings.ts
+++ b/packages/helper-panel/src/strings.ts
@@ -108,19 +108,37 @@ export function showsSignInSection(state: PanelState): boolean {
* warning that exists to inform a decision ends up misleading instead. Every
* variant names what stops, what stays, and how to get it back.
*/
-const QUIT_BODIES: Record = {
- // One whole sentence per state rather than a shared prefix, so the helper's
- // Swift copy can be compared to these literal for literal.
- registered:
- "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. The helper starts again at sign-in, or sooner from Subshell's virtual display settings. To stop it coming back, use the panel's Stop at sign-in button first.",
- needsApproval:
- "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. macOS is still waiting for your approval before it would start at sign-in, so it comes back when you open Subshell's virtual display settings.",
- notRegistered:
- "Anyone watching a virtual display on this Mac loses it now. Your consent and the macOS permissions stay. Nothing starts it at sign-in; it comes back when you open Subshell's virtual display settings.",
+const LOSS = "Anyone watching a virtual display on this Mac loses it now.";
+const STAYS = "Your consent and the macOS permissions stay.";
+
+/**
+ * One whole sentence per reachable pair rather than a shared prefix, so the
+ * helper's Swift copy can be compared to these literal for literal.
+ *
+ * The consent half matters as much as the registration half. A registered
+ * helper that is switched off does come back at sign-in, and then exits
+ * without capturing anything (`main.swift` terminates a consentless start), so
+ * promising "it starts again at sign-in" to that person describes a return that
+ * delivers no screen and sends them to a button they do not need. Naming the
+ * state is what makes the sentence actionable in the right direction.
+ */
+const QUIT_BODIES: Record = {
+ registered: {
+ enabled: `${LOSS} ${STAYS} The helper starts again at sign-in, or sooner from Subshell's virtual display settings. To stop it coming back, use the panel's Stop at sign-in button first.`,
+ disabled: `${LOSS} ${STAYS} The helper is disabled, so at sign-in it starts and exits without capturing anything. To take it out of macOS's login list, choose Stop at sign-in in the panel first.`,
+ },
+ needsApproval: {
+ enabled: `${LOSS} ${STAYS} macOS is still waiting for your approval before it would start at sign-in, so it comes back when you open Subshell's virtual display settings.`,
+ disabled: `${LOSS} ${STAYS} macOS is still waiting for your approval before it would start at sign-in, so it comes back when you open Subshell's virtual display settings.`,
+ },
+ notRegistered: {
+ enabled: `${LOSS} ${STAYS} Nothing starts it at sign-in; it comes back when you open Subshell's virtual display settings.`,
+ disabled: `${LOSS} ${STAYS} Nothing starts it at sign-in; it comes back when you open Subshell's virtual display settings.`,
+ },
};
-export function quitBody(loginItem: LoginItemState): string {
- return QUIT_BODIES[loginItem];
+export function quitBody(loginItem: LoginItemState, enabled: boolean): string {
+ return QUIT_BODIES[loginItem][enabled ? "enabled" : "disabled"];
}
/**
diff --git a/packages/pane-runtime/src/preview/macos-helper.ts b/packages/pane-runtime/src/preview/macos-helper.ts
index ca21f5474..e313855b9 100644
--- a/packages/pane-runtime/src/preview/macos-helper.ts
+++ b/packages/pane-runtime/src/preview/macos-helper.ts
@@ -109,9 +109,12 @@ export function helperStatus(path = MACOS_HELPER_SOCKET): Promise {
return new Promise((resolve) => {
From 47dcb49f069eeb5207994a6b59ff9c16025f5526 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 09:30:17 -0700
Subject: [PATCH 042/120] docs(changeset): keep the true half of the codesign
escape story
The helper module is cfg'd to macOS, so no Linux lane builds it and that was
never false. What the earlier wording left out is why the macOS lanes missed
it too: those tests compile and pass, and the bug only shows when something
actually opens the panel on a machine with a real bundle, which no lane does.
Co-Authored-By: Claude
---
.changeset/virtual-display-rail-entry.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.changeset/virtual-display-rail-entry.md b/.changeset/virtual-display-rail-entry.md
index f13dc1a06..14277bb17 100644
--- a/.changeset/virtual-display-rail-entry.md
+++ b/.changeset/virtual-display-rail-entry.md
@@ -14,7 +14,7 @@ A launch refused over its virtual display now reports the problem beside the **V
The preview icon in a pane's header now wears a count when the pane has things you could open but have not saved: discovered app ports with no Saved services row, plus the screen and the browser watch when the pane offers them. It is the picker's own data, so the number and the list cannot disagree.
-The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (Both reached the releases because they fail at runtime, on the machine that has a real bundle: no lane opens the helper's panel, so nothing pressed the path that checks a signature or reads a preference.) Pressing it then opened nothing either: the helper read its own preferences through a `UserDefaults` suite named after its bundle identifier, which Foundation refuses, so the process trapped before any window existed. Both are fixed, and a helper that cannot find its panel now says so instead of showing an empty window.
+The desktop apps' **Open Display Helper settings** control works: the helper's signature check handed `codesign` a bare requirement string, which it read as a file name, so the panel failed to open on every press. (The module is macOS-only, so no Linux lane even builds it, and the macOS lanes that do only fail at runtime on a machine with a real bundle: nothing in CI opens the helper's panel, so no lane ever pressed the path that checks a signature or reads a preference.) Pressing it then opened nothing either: the helper read its own preferences through a `UserDefaults` suite named after its bundle identifier, which Foundation refuses, so the process trapped before any window existed. Both are fixed, and a helper that cannot find its panel now says so instead of showing an empty window.
The panel itself is now the same product as the rest of Subshell, built from the components a person already knows in the dashboard: a card plate with its border and radius, `Fact` rows (a strong muted label above a readable value) for the login item and the two macOS permissions, and a form-section heading with its trailing rule for each group. Gold marks what you can still fix and plain ink what is settled, red what macOS refused, a live line names how many people are watching this Mac's screens, help sits at the bottom left and the exits at the bottom right in one shape, and a disabled control's reason is drawn in gold rather than hidden where a person who cannot hover will never find it. It is a small local page bundled into the helper, so the design system's own checker guards it, and the palette it draws on moved to `packages/assistant/tokens.css` where every surface imports the same one instead of copying it. The menu bar's menu now says it belongs to the Subshell virtual display helper instead of leaving you to guess from an icon, its **Minimize helper** item follows whether the panel is actually on screen, and the consent item turns itself into **Enable helper** once you disable the helper, raising the panel so you can read what you agreed to. It used to offer only Disable, greyed out once consent was off, which left the settings window as the only way back to running.
From d8849e6d950b955a94fb724ec4328eace3142077 Mon Sep 17 00:00:00 2001
From: Theo Gravity
Date: Tue, 27 Oct 2026 09:39:28 -0700
Subject: [PATCH 043/120] fix(web,helper,preview): close the fifth review
round's minors
A display refusal became unreadable the moment the SSH tab was open: the
line that carries a refusal the checkbox cannot host sat inside the agent
branch of the launch dialog, so switching kind unmounted the field AND the
fallback, leaving a refused launch with no message anywhere. The other two
dialogs already render it at top level; this one now matches.
The matcher that decides that routing trusted a bare substring, and the
create path interpolates a person-chosen preset name into the message: a
preset called preview-display-test moved an unrelated "Preset is disabled"
409 under the Virtual display checkbox, blaming the screen toggle for a
preset switch. Matching now requires the mirrored tail group the server
actually prints, with the name case pinned.
The status menu's Quit could stack duplicate confirmations, because a
status-item menu stays clickable through the app-modal session: answer once
and a second identical question was still waiting on a consent surface that
exists to get one informed answer. One alert at a time now.
A display child that dies on spawn burned the whole (doubled) readiness
budget before refusing, spending a third of the plane's 60 s launch window
on a state that can never become ready. Readiness now races the poll
against both children's exit, refuses the moment one is gone, and names
which child, with the same teardown.
docs/security.md and docs/macos-display-helper.md both said the consentless
rescue "does nothing while no viewer held the pointer"; the condition is
geometric (the cursor sits inside no screen's frame), and a person who
unplugs a monitor with their own cursor on it is rescued too. The two
sentences now agree with each other and with Cursor.swift.
Co-Authored-By: Claude
---
.../src/__tests__/preview-display.test.ts | 21 ++++++++
.../sidebar/launch-subshell-dialog.tsx | 6 ++-
.../__tests__/create-subshell-error.test.ts | 10 ++++
.../web/src/lib/create-subshell-error.ts | 20 ++++++--
docs/macos-display-helper.md | 5 +-
docs/security.md | 4 +-
native/display-helper/main.swift | 13 +++++
.../src/preview/xvfb-display-provider.ts | 51 +++++++++++++++----
8 files changed, 109 insertions(+), 21 deletions(-)
diff --git a/apps/node/agent/src/__tests__/preview-display.test.ts b/apps/node/agent/src/__tests__/preview-display.test.ts
index 91b45b760..a1ce44c43 100644
--- a/apps/node/agent/src/__tests__/preview-display.test.ts
+++ b/apps/node/agent/src/__tests__/preview-display.test.ts
@@ -117,6 +117,9 @@ interface DepOverrides {
xSocketReady?: boolean;
/** Readiness budget override. */
readyBudgetMs?: number;
+ /** Exit this child the moment it spawns: the way a display server that dies
+ * on its own (missing peer, exhausted /dev/shm, bad auth) presents. */
+ exitsEarly?: "Xvfb" | "x11vnc";
}
/**
@@ -150,6 +153,7 @@ function makeDeps(over: DepOverrides = {}) {
spawn: (argv, opts) => {
const child = new FakeChild(pidSeq++);
alivePids.add(child.pid);
+ if (over.exitsEarly && String(argv[0]).endsWith(over.exitsEarly)) child.fireExit(1);
const rfbIdx = argv.indexOf("-rfbport");
if (over.x11vncListens !== false && rfbIdx >= 0) busy.add(Number(argv[rfbIdx + 1]));
// The real spawn appends both child streams to `logPath`; a refusal
@@ -424,6 +428,23 @@ describe("PreviewDisplays.openFor", () => {
expect(reg.getFor(PANE)).toBeNull();
});
+ it("refuses the moment a display child dies, rather than waiting out the budget", async () => {
+ // A child that has already exited can never become ready, and the loop
+ // used to have exactly one way out. Every spawn-time death - a missing
+ // peer, an exhausted /dev/shm, bad auth - therefore burned the whole
+ // readiness budget per launch, doubling the wait a Create spinner shows
+ // and eating a third of the plane's 60 s launch window to learn nothing
+ // the first poll had not already told.
+ const dataDir = onDir("child-dies");
+ const { deps, killPids } = makeDeps({ xSocketReady: false, exitsEarly: "Xvfb", readyBudgetMs: 10_000 });
+ const reg = new PreviewDisplays(dataDir, deps);
+ await expect(reg.openFor(PANE, { width: 640, height: 480 })).rejects.toThrow(/gave up: Xvfb exited/);
+ // Same teardown as a budget expiry: both children signalled, dir and lock gone.
+ expect(killPids().length).toBe(2);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", PANE))).toBe(false);
+ expect(existsSync(join(dataDir, "display-providers", "xvfb-x11vnc", "displays", ".display-90.lock"))).toBe(false);
+ });
+
it("an X socket that comes up but an rfb server that never listens also refuses preview-display-not-ready", async () => {
// The second readiness phase, with its own pin: xSocketReady true, but the
// x11vnc double never occupies the port, so the rfb poll exhausts the
diff --git a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx
index 1f796321b..caf6bfed8 100644
--- a/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx
+++ b/apps/server/web/src/components/sidebar/launch-subshell-dialog.tsx
@@ -174,7 +174,6 @@ export function LaunchSubshellDialog({
onLeave={dismiss}
launchError={displayLaunchError}
/>
- {formLaunchError &&