Skip to content

Commit 40d24ea

Browse files
gershamclaude
andcommitted
Vendor screen online state and compose textShadow
Refresh vendored OpenAPI and protocol from backend 6ead1108379151a0325bcb3db21c8ddad49d9c31. Screen requires online and may carry last_online_at and last_ip. Native pairing is 72 hours unclaimed plus a fresh 72-hour collection window after claim. RuntimeManifest may emit playlistName. ScreenPatch cannot write observation, online, last_online_at, or last_ip. Optional Text textShadow is local compose render paint only. It is not screenrig.canvas/v1 and not a player feature. vendor/ is a snapshot refresh from backend 6ead110, not authored protocol: vendor/openapi.yaml 4acf9f8a86e123bd5ba0bee76a0adca7a09330e2bbee19ce0a6a1c2113af9007 vendor/protocol/openapi.gen.ts 4ad837a89687fdd7a5c5516a5cac8ea0fa0802fb4788fb0a78ae43a0fbf5d370 Gates: implementer reported check:public, vendor:check, vendor:check:drift vs backend 6ead110, typecheck, lint, smoke:mock, pack:dry. npm test: 231 pass, 1 fail rejects duplicate case-folded names in dist/pack.test.js (macOS APFS; not in this write set), 1 skip. Did not re-run npm test. Public hygiene npm run check:public re-run before push. Known: APFS pack case-fold fail does not hold this pre-production push. Skipped: npm ci, smoke:server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 908a48b commit 40d24ea

17 files changed

Lines changed: 528 additions & 38 deletions

‎AGENTS.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,11 @@ installation, players, backend services, the site, or production deployment.
283283
render envelope is paths and layout metadata only. Never put PNG bytes in
284284
stdout, the envelope, or human text. `--open` opens a local path through
285285
`CliRuntime.openPath`; it is not the agent vision loop.
286+
- Optional Text `textShadow` is local `compose render` paint only. Author
287+
`{ x, y, blur?, color }` in px; omit it to paint without a shadow. It is
288+
not `screenrig.canvas/v1` and not a player feature. Layout `x`/`y` on Text
289+
stay forbidden. This is **source-ready** in the working tree. It is not in
290+
the locked plugin bundle. Do not claim marketplace or deployed.
286291

287292
## Verification
288293

‎README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ The ordinary pair command currently accepts six canonical characters:
5555
screenrig --json screen pair ABC234
5656
```
5757

58+
Native player pairing codes last 72 hours while unclaimed. A successful claim
59+
starts a fresh independent 72-hour collection window. `screen pair` claims the
60+
code on the control plane; the CLI does not time the code locally.
61+
5862
The separate homepage handoff command accepts either `ABC-234` or `ABC234`,
5963
normalizes it, and returns only safe status fields:
6064

@@ -217,7 +221,9 @@ The spec is a fail-closed tree of `Frame`, `Column`, `Row`, `Box`, `Spacer`,
217221
`Text`, and `Image`. Roles are `display`, `title`, `body`, `caption`, and
218222
`label`. Spacing tokens are `xs`, `s`, `m`, `l`, and `xl`. Pins are `top`,
219223
`bottom`, `left`, and `right`. Do not author `x`/`y` except on the Frame
220-
canvas. Do not author `fontSize`. `Image.src` is a local filesystem path
224+
canvas. Do not author `fontSize`. Optional Text `textShadow` is
225+
`{ "x": 2, "y": 2, "blur": 4, "color": "#00000080" }`; omit it to paint
226+
without a shadow. `Image.src` is a local filesystem path
221227
relative to the spec file. The CLI does not fetch URLs.
222228

223229
`--open` is only for viewing the still on this computer. Agent vision reads

‎src/adapters/protocol.test.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,11 @@ test("adapter shapes mirror generated OpenAPI contract fields and Capabilities l
213213
"created_at",
214214
"id",
215215
"label",
216+
"last_ip",
217+
"last_online_at",
216218
"manifest_revision",
217219
"observation",
220+
"online",
218221
"playlist_id",
219222
"public_id",
220223
"revision",
@@ -228,6 +231,12 @@ test("adapter shapes mirror generated OpenAPI contract fields and Capabilities l
228231
assert.match(screen, /"timezone"\?: string/);
229232
// Observation is player-reported and absent until the first report.
230233
assert.match(screen, /"observation"\?: ScreenObservation/);
234+
// online is required and false until the first paired runtime connect.
235+
// last_online_at and last_ip stay optional until that first connect.
236+
assert.match(screen, /"online": boolean/);
237+
assert.doesNotMatch(screen, /"online"\?: boolean/);
238+
assert.match(screen, /"last_online_at"\?: string/);
239+
assert.match(screen, /"last_ip"\?: string/);
231240
assert.doesNotMatch(source, /Bootstrap/);
232241
assert.deepEqual(quotedProperties(interfaceBody(source, "ScreenPatch")), [
233242
"name",
@@ -348,6 +357,42 @@ test("screen observation is optional, read-only, and absent from ScreenPatch", (
348357
assert.match(commands, /screen update requires <id>, --if-match, and --name, --playlist-id, or --timezone/);
349358
});
350359

360+
test("screen online is required, last_online_at and last_ip are optional, and ScreenPatch cannot write them", () => {
361+
const generated = readFileSync(GENERATED_CONTRACT, "utf8");
362+
const openapi = readFileSync(OPENAPI_CONTRACT, "utf8");
363+
const adapter = readFileSync(
364+
join(dirname(fileURLToPath(import.meta.url)), "../../src/adapters/protocol.ts"),
365+
"utf8",
366+
);
367+
const commands = readFileSync(
368+
join(dirname(fileURLToPath(import.meta.url)), "../../src/commands.ts"),
369+
"utf8",
370+
);
371+
372+
const screen = interfaceBody(generated, "Screen");
373+
assert.match(screen, /"online": boolean/);
374+
assert.doesNotMatch(screen, /"online"\?: boolean/);
375+
assert.match(screen, /"last_online_at"\?: string/);
376+
assert.match(screen, /"last_ip"\?: string/);
377+
378+
const patch = interfaceBody(generated, "ScreenPatch");
379+
assert.deepEqual(quotedProperties(patch), ["name", "playlist_id", "timezone"]);
380+
assert.doesNotMatch(patch, /online|last_online_at|last_ip/);
381+
assert.doesNotMatch(interfaceBody(generated, "PairScreen"), /online|last_online_at|last_ip/);
382+
assert.doesNotMatch(interfaceBody(adapter, "ScreenPatch"), /online|last_online_at|last_ip/);
383+
assert.match(adapter, /online: boolean/);
384+
assert.match(adapter, /last_online_at\?: string/);
385+
assert.match(adapter, /last_ip\?: string/);
386+
387+
const screenSchema = openapi.slice(openapi.indexOf(" Screen:\n"), openapi.indexOf(" ScreenList:"));
388+
assert.match(screenSchema, /state, online, created_at, updated_at/);
389+
assert.match(screenSchema, /not a player heartbeat or presence\.write report/);
390+
assert.match(screenSchema, /maxLength: 45/);
391+
assert.match(screenSchema, /ScreenPatch, pairing bodies, session[\s\S]*runtime manifest cannot write it/);
392+
assert.doesNotMatch(commands, /--online|--last-online-at|--last-ip/);
393+
assert.match(commands, /screen update requires <id>, --if-match, and --name, --playlist-id, or --timezone/);
394+
});
395+
351396
test("published problem codes include payment_required at 402", () => {
352397
const source = readFileSync(OPENAPI_CONTRACT, "utf8");
353398
const listed = source.match(/x-problem-codes: \[([^\]]+)\]/);

‎src/adapters/protocol.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,24 @@ export interface Screen {
171171
created_at: string;
172172
id: string;
173173
label: string;
174+
/**
175+
* Last client IP at a paired runtime events connect. Absent until the first
176+
* connect. Operational metadata; IDs and IPs are never authorization.
177+
*/
178+
last_ip?: string;
179+
/**
180+
* Instant a paired runtime events stream last became connected. Present
181+
* after the first connect. When offline, last time the screen was online.
182+
*/
183+
last_online_at?: string;
174184
manifest_revision: number;
175185
observation?: ScreenObservation;
186+
/**
187+
* True while at least one paired runtime events stream is connected,
188+
* including a short reconnect window. Always present; false until first
189+
* connect. Read-only; not a player heartbeat.
190+
*/
191+
online: boolean;
176192
playlist_id?: string;
177193
public_id: string;
178194
revision: number;
@@ -188,8 +204,8 @@ export interface Screen {
188204
/**
189205
* The screen patch body. Every member is optional and the server requires at
190206
* least one, which is why each command builds only the members it was asked
191-
* for rather than sending undefined placeholders. Observation is not a
192-
* patchable field.
207+
* for rather than sending undefined placeholders. Observation, online,
208+
* last_online_at, and last_ip are not patchable fields.
193209
*/
194210
export interface ScreenPatch {
195211
name?: string;

‎src/cli.test.ts‎

Lines changed: 204 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -477,9 +477,15 @@ test("browser setup --open opens only the public handoff URL by argv", async ()
477477
await rm(result.configDir, { recursive: true, force: true });
478478
});
479479

480-
test("browser setup fails closed on extra delivery fields without echoing them", async () => {
480+
const BROWSER_CLAIM_SCREEN = {
481+
id: "scr_browser_link",
482+
public_id: "browser-link-screen",
483+
state: "pairing_pending" as const,
484+
public_url: "https://play.screenrig.ai/s/browser-link-screen",
485+
};
486+
487+
function browserSetupClaimTransport(body: unknown): FakeTransport {
481488
const transport = new FakeTransport();
482-
const secret = `https://play.screenrig.ai/s/browser-link-screen#provision=${"S".repeat(43)}`;
483489
transport.on("POST", "/api/v1/enrollments", () => ({
484490
status: 201,
485491
headers: { "cache-control": "private, no-store" },
@@ -494,24 +500,61 @@ test("browser setup fails closed on extra delivery fields without echoing them",
494500
transport.on("POST", "/api/v1/account/browser-links/claim", () => ({
495501
status: 201,
496502
headers: { "cache-control": "private, no-store" },
497-
body: {
498-
session_id: "bls_fixture",
499-
status: "claimed",
500-
screen: {
501-
id: "scr_browser_link",
502-
public_id: "browser-link-screen",
503-
state: "pairing_pending",
504-
public_url: "https://play.screenrig.ai/s/browser-link-screen",
503+
body,
504+
}));
505+
return transport;
506+
}
507+
508+
test("browser setup ignores extra claim and screen keys without echoing them", async () => {
509+
const secret = `https://play.screenrig.ai/s/browser-link-screen#provision=${"S".repeat(43)}`;
510+
const transport = browserSetupClaimTransport({
511+
session_id: "bls_fixture",
512+
status: "claimed",
513+
screen: {
514+
...BROWSER_CLAIM_SCREEN,
515+
timezone: "America/Los_Angeles",
516+
observation: {
517+
observed_at: "2026-08-14T17:00:00.000Z",
518+
surfaces: [{ id: "primary", width: 1920, height: 1080, pixel_ratio: 1, presentation: "output" }],
505519
},
506-
provisioning_url: secret,
507520
},
508-
}));
521+
provisioning_url: secret,
522+
});
509523
const result = await withRuntime(["--json", "browser", "setup", "--code", "ABC234"], transport);
510-
assert.equal(result.code, ExitCode.Usage);
511-
assert.doesNotMatch(result.stdout, /#provision=|provisioning_url|SSSSSS/i);
524+
assert.equal(result.code, 0, result.stdout);
525+
const envelope = JSON.parse(result.stdout) as { data: Record<string, unknown> };
526+
assert.deepEqual(envelope.data, {
527+
code: "ABC-234",
528+
status: "claimed",
529+
player_public_url: "https://play.screenrig.ai/s/browser-link-screen",
530+
});
531+
assert.doesNotMatch(result.stdout, /#provision=|provisioning_url|SSSSSS|timezone|observation|America\/Los_Angeles/i);
512532
await rm(result.configDir, { recursive: true, force: true });
513533
});
514534

535+
test("browser setup rejects missing required claim fields, bad status, and unsafe public URLs", async () => {
536+
const cases: Array<{ name: string; body: Record<string, unknown> }> = [
537+
{ name: "missing session_id", body: { status: "claimed", screen: BROWSER_CLAIM_SCREEN } },
538+
{ name: "bad status", body: { session_id: "bls_fixture", status: "waiting", screen: BROWSER_CLAIM_SCREEN } },
539+
{ name: "missing screen", body: { session_id: "bls_fixture", status: "claimed" } },
540+
{ name: "missing screen id", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, id: "" } } },
541+
{ name: "missing public_id", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, public_id: "" } } },
542+
{ name: "bad screen state", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, state: "active" } } },
543+
{ name: "missing public_url", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, public_url: "" } } },
544+
{ name: "hash in public_url", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, public_url: "https://play.screenrig.ai/s/browser-link-screen#provision=SSS" } } },
545+
{ name: "search in public_url", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, public_url: "https://play.screenrig.ai/s/browser-link-screen?q=1" } } },
546+
{ name: "userinfo in public_url", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, public_url: "https://user:pass@play.screenrig.ai/s/browser-link-screen" } } },
547+
{ name: "wrong origin", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, public_url: "https://example.invalid/s/browser-link-screen" } } },
548+
{ name: "pathname mismatch", body: { session_id: "bls_fixture", status: "claimed", screen: { ...BROWSER_CLAIM_SCREEN, public_url: "https://play.screenrig.ai/s/other-id" } } },
549+
];
550+
for (const item of cases) {
551+
const result = await withRuntime(["--json", "browser", "setup", "--code", "ABC234"], browserSetupClaimTransport(item.body));
552+
assert.equal(result.code, ExitCode.Usage, item.name);
553+
assert.doesNotMatch(result.stdout, /#provision=|user:pass|token|cookie|proof/i, item.name);
554+
await rm(result.configDir, { recursive: true, force: true });
555+
}
556+
});
557+
515558
test("browser setup rejects malformed codes before claim and keeps exact ambiguous retry state", async () => {
516559
const invalid = await withRuntime(["--json", "browser", "setup", "--code", "ABC10I"], memoryBackend());
517560
assert.equal(invalid.code, ExitCode.Usage);
@@ -2693,6 +2736,153 @@ test("screen update cannot send observation", async () => {
26932736
await rm(configDir, { recursive: true, force: true });
26942737
});
26952738

2739+
const DOCUMENTATION_IPV4 = "192.0.2.10";
2740+
const DOCUMENTATION_IPV6 = "2001:db8::1";
2741+
2742+
test("screen show includes online and optional last_online_at and last_ip", async () => {
2743+
const transport = new FakeTransport();
2744+
const screen = {
2745+
content_access_generation: 1,
2746+
created_at: "2026-08-14T17:00:00.000Z",
2747+
id: "scr_PAIRINGAAAAAAAAAAAAAAAA",
2748+
label: "Lobby",
2749+
last_ip: DOCUMENTATION_IPV4,
2750+
last_online_at: "2026-08-19T12:00:00Z",
2751+
manifest_revision: 1,
2752+
online: true,
2753+
public_id: "scr_public_pairing",
2754+
revision: 1,
2755+
state: "active" as const,
2756+
updated_at: "2026-08-14T17:00:00.000Z",
2757+
};
2758+
transport.on("GET", "/api/v1/screens/scr_PAIRINGAAAAAAAAAAAAAAAA", () => ({
2759+
status: 200,
2760+
headers: { "x-request-id": "req_show_online" },
2761+
body: screen,
2762+
}));
2763+
const configDir = await testTemp("screen-show-online-");
2764+
const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } };
2765+
await writeConfigAtomic(
2766+
path.join(configDir, "screenrig", "config.json"),
2767+
{ api_url: "https://api.screenrig.ai", token: "sr_live_existing_secret" },
2768+
fsLike,
2769+
);
2770+
2771+
const jsonResult = await withRuntime(
2772+
["--json", "screen", "show", "scr_PAIRINGAAAAAAAAAAAAAAAA"],
2773+
transport,
2774+
{ fs: fsLike },
2775+
);
2776+
assert.equal(jsonResult.code, ExitCode.Success, jsonResult.stdout);
2777+
const envelope = JSON.parse(jsonResult.stdout) as {
2778+
ok: boolean;
2779+
data: { online?: boolean; last_online_at?: string; last_ip?: string };
2780+
};
2781+
assert.equal(envelope.ok, true);
2782+
assert.equal(envelope.data.online, true);
2783+
assert.equal(envelope.data.last_online_at, "2026-08-19T12:00:00Z");
2784+
assert.equal(envelope.data.last_ip, DOCUMENTATION_IPV4);
2785+
2786+
const humanResult = await withRuntime(
2787+
["screen", "show", "scr_PAIRINGAAAAAAAAAAAAAAAA"],
2788+
transport,
2789+
{ fs: fsLike },
2790+
);
2791+
assert.equal(humanResult.code, ExitCode.Success, humanResult.stdout);
2792+
assert.match(humanResult.stdout, /^Screen\n/);
2793+
assert.match(humanResult.stdout, /"online": true/);
2794+
assert.match(humanResult.stdout, /"last_online_at": "2026-08-19T12:00:00Z"/);
2795+
assert.match(humanResult.stdout, /"last_ip": "192.0.2.10"/);
2796+
2797+
screen.last_ip = DOCUMENTATION_IPV6;
2798+
const v6Result = await withRuntime(
2799+
["--json", "screen", "show", "scr_PAIRINGAAAAAAAAAAAAAAAA"],
2800+
transport,
2801+
{ fs: fsLike },
2802+
);
2803+
assert.equal(v6Result.code, ExitCode.Success, v6Result.stdout);
2804+
const v6Envelope = JSON.parse(v6Result.stdout) as { ok: boolean; data: { last_ip?: string } };
2805+
assert.equal(v6Envelope.data.last_ip, DOCUMENTATION_IPV6);
2806+
await rm(configDir, { recursive: true, force: true });
2807+
});
2808+
2809+
test("screen show still works when last_online_at and last_ip are absent", async () => {
2810+
const transport = new FakeTransport();
2811+
const screen = {
2812+
content_access_generation: 1,
2813+
created_at: "2026-08-14T17:00:00.000Z",
2814+
id: "scr_PAIRINGAAAAAAAAAAAAAAAA",
2815+
label: "Lobby",
2816+
manifest_revision: 1,
2817+
online: false,
2818+
public_id: "scr_public_pairing",
2819+
revision: 1,
2820+
state: "pairing_pending" as const,
2821+
updated_at: "2026-08-14T17:00:00.000Z",
2822+
};
2823+
transport.on("GET", "/api/v1/screens/scr_PAIRINGAAAAAAAAAAAAAAAA", () => ({
2824+
status: 200,
2825+
headers: { "x-request-id": "req_show_offline" },
2826+
body: screen,
2827+
}));
2828+
const configDir = await testTemp("screen-show-offline-");
2829+
const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } };
2830+
await writeConfigAtomic(
2831+
path.join(configDir, "screenrig", "config.json"),
2832+
{ api_url: "https://api.screenrig.ai", token: "sr_live_existing_secret" },
2833+
fsLike,
2834+
);
2835+
2836+
const result = await withRuntime(
2837+
["--json", "screen", "show", "scr_PAIRINGAAAAAAAAAAAAAAAA"],
2838+
transport,
2839+
{ fs: fsLike },
2840+
);
2841+
assert.equal(result.code, ExitCode.Success, result.stdout);
2842+
const envelope = JSON.parse(result.stdout) as {
2843+
ok: boolean;
2844+
data: { id: string; online?: boolean; last_online_at?: string; last_ip?: string };
2845+
};
2846+
assert.equal(envelope.ok, true);
2847+
assert.equal(envelope.data.id, "scr_PAIRINGAAAAAAAAAAAAAAAA");
2848+
assert.equal(envelope.data.online, false);
2849+
assert.equal(envelope.data.last_online_at, undefined);
2850+
assert.equal(envelope.data.last_ip, undefined);
2851+
assert.equal(JSON.stringify(envelope.data).includes("last_online_at"), false);
2852+
assert.equal(JSON.stringify(envelope.data).includes("last_ip"), false);
2853+
await rm(configDir, { recursive: true, force: true });
2854+
});
2855+
2856+
test("screen update cannot send online, last_online_at, or last_ip", async () => {
2857+
const transport = memoryBackend();
2858+
const { code, configDir } = await withRuntime(["--json", "screen", "pair", "ABC234"], transport);
2859+
assert.equal(code, ExitCode.Success);
2860+
const fsLike = { mkdir, open, rename, rm, chmod, stat, homedir: () => configDir, env: { XDG_CONFIG_HOME: configDir } };
2861+
transport.calls.length = 0;
2862+
2863+
const result = await withRuntime(
2864+
[
2865+
"--json", "screen", "update", "scr_PAIRINGAAAAAAAAAAAAAAAA",
2866+
"--name", "Lobby",
2867+
"--if-match", "1",
2868+
"--online", "true",
2869+
"--last-online-at", "2026-08-19T12:00:00Z",
2870+
"--last-ip", DOCUMENTATION_IPV4,
2871+
],
2872+
transport,
2873+
{ fs: fsLike },
2874+
);
2875+
assert.equal(result.code, ExitCode.Success, result.stdout);
2876+
const patch = transport.calls.find((call) => call.method === "PATCH");
2877+
assert.equal(patch?.path, "/api/v1/screens/scr_PAIRINGAAAAAAAAAAAAAAAA");
2878+
assert.deepEqual(patch?.body, { name: "Lobby" });
2879+
const patchJson = JSON.stringify(patch?.body);
2880+
assert.equal(patchJson.includes("online"), false);
2881+
assert.equal(patchJson.includes("last_online_at"), false);
2882+
assert.equal(patchJson.includes("last_ip"), false);
2883+
await rm(configDir, { recursive: true, force: true });
2884+
});
2885+
26962886
test("adding a schedule to a playlist an unzoned screen already runs is refused", async () => {
26972887
const transport = memoryBackend();
26982888
const { configDir, fsLike, file } = await scheduledPlaylistFixture(false, "tz-playlist-update-");

0 commit comments

Comments
 (0)