From 520ef49a38588cef6ce44c8853cf90f3ae311b44 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Tue, 15 Sep 2026 19:52:26 -0700 Subject: [PATCH] Say when a session's machine is offline, and keep its last screen A viewer could be perfectly connected to a session whose machine was not there, and be shown nothing at all. The relay has always reported the machine's state on every status message; neither client acted on it. The standalone page dimmed a dot in the header to "Offline" and left the terminal blank, and the app pane ignored every status except "exited", so it stayed on "connected" and drew an empty screen with no banner. Both read as a terminal that failed to load rather than a laptop that closed. Reproduced with a session whose host was killed outright: it lives on the relay for its remaining twelve hours, and every viewer since gets a blank page. That is what a reboot, a lid, a dead battery or a lost network looks like from the browser today. Both surfaces now say it, in the same words, from one module: - The relay reports hostLastSeenAt and lastScreenAt alongside the status. - The relay keeps the last full screen a host sent, chunked under one storage prefix because a snapshot is larger than a Durable Object value, and replays it to a viewer that arrives while the machine is away. For an encrypted session those bytes are ciphertext it cannot read: the opcode is authenticated as associated data, so the frame is stored and replayed untouched. It goes with the session when that expires. - A host only produces a screen when asked, so the alarm asks for one every five minutes on behalf of viewer 0, which no viewer is. That caps how stale a kept screen can be at the cost of one snapshot per five minutes of a live session. Writes are throttled so a room filling up does not write one per person arriving. - The notice says which it is: waiting for a machine that has never connected, or temporarily offline with when it was last seen and how old the screen below it is. A kept screen is dimmed so it cannot be mistaken for a live one, and it all clears itself when the machine comes back. Verified end to end against a local relay: a live session renders, a SIGKILLed host turns the open viewer's screen into a dated kept screen with the notice, a viewer arriving afterwards gets both where it used to get a blank page, and a restarted persistent host clears the notice and goes live. --- app/scripts/check-protocol.mjs | 1 + app/src/styles/terminal.css | 59 ++++++++++ app/src/terminal/TerminalPane.tsx | 46 +++++++- app/src/terminal/connection.test.ts | 64 ++++++++++- app/src/terminal/connection.ts | 39 ++++++- app/src/terminal/host-presence.ts | 98 ++++++++++++++++ shared/host-presence.ts | 95 ++++++++++++++++ tests/host-presence.test.ts | 79 +++++++++++++ web/main.ts | 71 ++++++++++++ web/style.css | 67 +++++++++++ worker/index.ts | 166 ++++++++++++++++++++++++++-- 11 files changed, 774 insertions(+), 11 deletions(-) create mode 100644 app/src/terminal/host-presence.ts create mode 100644 shared/host-presence.ts create mode 100644 tests/host-presence.test.ts diff --git a/app/scripts/check-protocol.mjs b/app/scripts/check-protocol.mjs index 24d9df6..ce84e68 100755 --- a/app/scripts/check-protocol.mjs +++ b/app/scripts/check-protocol.mjs @@ -22,6 +22,7 @@ const FILES = [ { vendored: "src/terminal/terminal-grid.ts", source: "shared/terminal-grid.ts" }, { vendored: "src/terminal/terminal-fit.ts", source: "web/terminal-fit.ts" }, { vendored: "src/terminal/terminal-metrics.ts", source: "web/terminal-metrics.ts" }, + { vendored: "src/terminal/host-presence.ts", source: "shared/host-presence.ts" }, ]; /* Copies kept in step within this repository rather than with shell.online. */ diff --git a/app/src/styles/terminal.css b/app/src/styles/terminal.css index c5b47d0..eeea708 100644 --- a/app/src/styles/terminal.css +++ b/app/src/styles/terminal.css @@ -345,6 +345,65 @@ button.tab { animation: spin 1.4s linear infinite; } +/* + * ---- The machine behind the session is away ---- + * + * Not the same thing as this viewer losing its socket, and not a dead session + * either: the laptop is asleep, rebooting or off a network, and it comes back. + * Centred while there is nothing to look at, a strip over the kept screen when + * there is, so an empty terminal never has to explain itself. + */ +.pane-offline { + position: absolute; + z-index: 3; + top: 50%; + left: 50%; + display: grid; + gap: 6px; + width: min(420px, calc(100% - 40px)); + padding: 18px 20px; + transform: translate(-50%, -50%); + border: 1px solid var(--line); + border-radius: var(--radius-panel); + background: var(--white); + box-shadow: 0 22px 60px rgb(26 31 22 / 14%); + color: var(--muted); + font-size: 13px; + line-height: 1.55; + text-wrap: pretty; +} + +.pane-offline strong { + color: var(--ink); + font-size: 14.5px; + font-weight: 500; +} + +.pane-offline.over-screen { + top: 30px; + width: min(540px, calc(100% - 24px)); + padding: 11px 15px; + transform: translateX(-50%); + background: color-mix(in srgb, var(--white) 94%, transparent); + backdrop-filter: blur(3px); + font-size: 12.5px; +} + +/* A kept screen is not a live one, and should not be read as one. */ +.pane:has(.pane-offline) .pane-screen { opacity: 0.55; } + +@media (max-width: 640px) { + .pane-offline { + width: calc(100% - 20px); + padding: 15px 16px; + } + + .pane-offline.over-screen { + top: 26px; + width: calc(100% - 16px); + } +} + /* ---- Gate shown over the screen for a password, or a dead session ---- */ .pane-gate { diff --git a/app/src/terminal/TerminalPane.tsx b/app/src/terminal/TerminalPane.tsx index 9d39112..a4b3edc 100644 --- a/app/src/terminal/TerminalPane.tsx +++ b/app/src/terminal/TerminalPane.tsx @@ -3,7 +3,8 @@ import { ArrowClockwise, LockKey } from "@phosphor-icons/react"; import "@xterm/xterm/css/xterm.css"; import "../../../web/vendor/refstream/v0.1.0-alpha.5/refstream.css"; import "../../../web/vendor/refstream/v0.1.0-alpha.5/ui.css"; -import { TerminalConnection, type ConnectionStatus } from "./connection"; +import { TerminalConnection, type ConnectionStatus, type HostState } from "./connection"; +import { hostIsAway, hostNotice } from "./host-presence"; import { DESKTOP_TERMINAL_GRID, type TerminalGrid } from "./terminal-grid"; import { fittedTerminal, type TerminalCell } from "./terminal-fit"; import { cellMeasurer, terminalBox } from "./terminal-metrics"; @@ -166,10 +167,28 @@ export function TerminalPane({ const [status, setStatus] = useState("connecting"); const [detail, setDetail] = useState(""); + /* + * The machine's own state, reported by the relay. It is not this viewer's + * connection: a pane can be fully connected to a session whose machine is + * asleep, and saying nothing about that is how a terminal appears to be + * missing rather than paused. + */ + const [hostState, setHostState] = useState(null); + const [hasScreen, setHasScreen] = useState(false); + /* Re-read on a timer so "4 minutes ago" keeps being true on an idle page. */ + const [now, setNow] = useState(() => Date.now()); const [readOnly, setReadOnly] = useState(false); const [password, setPassword] = useState(""); const [unlocking, setUnlocking] = useState(false); + /* Ticks only while the machine is away, and only to keep "ago" honest. */ + useEffect(() => { + if (!hostIsAway(hostState?.presence)) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, [hostState?.presence, hostState?.lastSeenAt]); + /* * Assignment can change while this pane is open. Read the current answer * from a ref inside xterm's long-lived input callback, rather than rebuilding @@ -369,7 +388,9 @@ export function TerminalPane({ */ if (worked.source !== "vault") void keepIfMissing(sessionId, worked.password); }, + onHostState: (next) => { setHostState(next); }, onData: (bytes, reset) => { + if (bytes.byteLength > 0) setHasScreen(true); if (!reset) { term.write(bytes); return; @@ -561,6 +582,17 @@ export function TerminalPane({ } const locked = status === "needs-password"; + const sessionOver = status === "ended" || status === "missing" || status === "error"; + const machineAway = !locked && !sessionOver && hostIsAway(hostState?.presence); + const notice = machineAway + ? hostNotice({ + status: hostState?.presence, + hostLastSeenAt: hostState?.lastSeenAt, + screenCapturedAt: hostState?.screenCapturedAt, + hasScreen, + now, + }) + : null; return (
@@ -643,6 +675,18 @@ export function TerminalPane({
Watching. Only the owner and assignees can type.
)} + {/* + * The machine, not this viewer. Shown over a kept screen as a strip, and + * in the middle of the pane when there is no screen to keep, so an empty + * terminal is never left to speak for itself. + */} + {notice && ( +
+ {notice.heading} + {notice.body} +
+ )} + {status === "disconnected" && (
diff --git a/app/src/terminal/connection.test.ts b/app/src/terminal/connection.test.ts index aeee1b8..3e197f8 100644 --- a/app/src/terminal/connection.test.ts +++ b/app/src/terminal/connection.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { TerminalConnection, type ConnectionStatus } from "./connection"; +import { TerminalConnection, type ConnectionStatus, type HostState } from "./connection"; import { Opcode, encodeFrame } from "./protocol"; import { BrowserFrameCipher } from "./e2ee"; import { @@ -68,10 +68,11 @@ interface Recorded { writes: { text: string; reset: boolean }[]; readOnly: boolean[]; grids: TerminalGrid[]; + hostStates: HostState[]; } function connect(fragment = "") { - const recorded: Recorded = { statuses: [], writes: [], readOnly: [], grids: [] }; + const recorded: Recorded = { statuses: [], writes: [], readOnly: [], grids: [], hostStates: [] }; const connection = new TerminalConnection({ url: "ws://localhost:5173/relay/api/sessions/x/ws", fragment, @@ -81,6 +82,7 @@ function connect(fragment = "") { onData: (bytes, reset) => recorded.writes.push({ text: new TextDecoder().decode(bytes), reset }), onReadOnly: (value) => recorded.readOnly.push(value), onGrid: (grid) => recorded.grids.push(grid), + onHostState: (state) => recorded.hostStates.push(state), }, }); return { connection, recorded }; @@ -478,3 +480,61 @@ describe("the session grid", () => { expect(connection.grid).toEqual(MOBILE_TERMINAL_GRID); }); }); + +/* + * The relay reports the machine's state on every status message. Before this + * the app read only "exited" and left the rest on the floor, so a viewer whose + * machine had gone offline sat in front of a blank terminal that claimed to be + * connected. The reproduction was a laptop that rebooted mid-session: the + * session stayed on the relay for twelve hours, and every viewer since got an + * empty screen with nothing said about it. + */ +describe("the machine behind the session", () => { + async function connected() { + const { connection, recorded } = connect(); + await connection.start(); + FakeSocket.last!.opened(); + return { connection, recorded, socket: FakeSocket.last! }; + } + + it("reports a machine that is away, with when it was last seen", async () => { + const { recorded, socket } = await connected(); + socket.control({ + type: "status", + status: "disconnected", + hostLastSeenAt: "2026-09-15T20:42:00.000Z", + lastScreenAt: "2026-09-15T20:41:00.000Z", + }); + + expect(recorded.hostStates).toEqual([{ + presence: "disconnected", + lastSeenAt: "2026-09-15T20:42:00.000Z", + screenCapturedAt: "2026-09-15T20:41:00.000Z", + }]); + /* The viewer's own socket is fine; only the machine is away. */ + expect(recorded.statuses.at(-1)?.status).toBe("connected"); + }); + + it("reports a session whose machine has never connected", async () => { + const { recorded, socket } = await connected(); + socket.control({ type: "status", status: "waiting" }); + expect(recorded.hostStates).toEqual([{ + presence: "waiting", + lastSeenAt: undefined, + screenCapturedAt: undefined, + }]); + }); + + it("reports the machine coming back", async () => { + const { recorded, socket } = await connected(); + socket.control({ type: "status", status: "disconnected" }); + socket.control({ type: "status", status: "connected" }); + expect(recorded.hostStates.map((state) => state.presence)).toEqual(["disconnected", "connected"]); + }); + + it("ignores a status it does not know", async () => { + const { recorded, socket } = await connected(); + socket.control({ type: "status", status: "something-new" }); + expect(recorded.hostStates).toEqual([]); + }); +}); diff --git a/app/src/terminal/connection.ts b/app/src/terminal/connection.ts index e54ca9d..c4855ed 100644 --- a/app/src/terminal/connection.ts +++ b/app/src/terminal/connection.ts @@ -6,6 +6,7 @@ import { MOBILE_TERMINAL_GRID, type TerminalGrid, } from "./terminal-grid"; +import type { HostPresence } from "./host-presence"; export type ConnectionStatus = | "connecting" @@ -17,8 +18,23 @@ export type ConnectionStatus = | "missing" | "error"; +/** What the relay says about the machine hosting the session. */ +export interface HostState { + presence: HostPresence; + /** ISO timestamp of when that machine was last connected. */ + lastSeenAt?: string; + /** ISO timestamp of the kept screen the relay may replay while it is away. */ + screenCapturedAt?: string; +} + export interface ConnectionEvents { onStatus(status: ConnectionStatus, detail?: string): void; + /* + * The machine's state, which is not this viewer's connection state. A viewer + * can be connected to a session whose machine is asleep; saying so is the + * difference between an explained pause and a terminal that never appears. + */ + onHostState?(state: HostState): void; /** Terminal bytes to write. `reset` means the screen should be cleared first. */ onData(bytes: Uint8Array, reset: boolean): void; onReadOnly(readOnly: boolean): void; @@ -50,6 +66,12 @@ export interface ConnectionOptions { const MAX_BACKOFF_MS = 10_000; +const HOST_PRESENCES: readonly HostPresence[] = ["waiting", "connected", "disconnected", "exited"]; + +function isHostPresence(value: unknown): value is HostPresence { + return typeof value === "string" && (HOST_PRESENCES as readonly string[]).includes(value); +} + const CLOSE_ENDED = 4000; const CLOSE_MISSING = 4004; const CLOSE_DECRYPT_FAILED = 4003; @@ -273,7 +295,15 @@ export class TerminalConnection { } private handleControl(raw: string): void { - let message: { readOnly?: unknown; status?: unknown; type?: unknown; cols?: unknown; rows?: unknown }; + let message: { + readOnly?: unknown; + status?: unknown; + type?: unknown; + cols?: unknown; + rows?: unknown; + hostLastSeenAt?: unknown; + lastScreenAt?: unknown; + }; try { message = JSON.parse(raw) as typeof message; } catch { @@ -283,6 +313,13 @@ export class TerminalConnection { this.readOnly = message.readOnly; this.options.events.onReadOnly(message.readOnly); } + if (message.type === "status" && isHostPresence(message.status)) { + this.options.events.onHostState?.({ + presence: message.status, + lastSeenAt: typeof message.hostLastSeenAt === "string" ? message.hostLastSeenAt : undefined, + screenCapturedAt: typeof message.lastScreenAt === "string" ? message.lastScreenAt : undefined, + }); + } if (message.status === "exited") { this.options.events.onStatus("ended"); } diff --git a/app/src/terminal/host-presence.ts b/app/src/terminal/host-presence.ts new file mode 100644 index 0000000..71a08bb --- /dev/null +++ b/app/src/terminal/host-presence.ts @@ -0,0 +1,98 @@ +/* + * Vendored verbatim from shell.online: shared/host-presence.ts + */ +/* + * What a viewer is told when the machine behind a session is not there. + * + * A viewer's own connection and the machine's connection are different things, + * and conflating them is how a perfectly connected viewer ends up staring at an + * empty terminal with nothing to explain it. The relay reports the machine's + * state on every status message; this turns that into something to read. + */ + +/** The relay's view of the machine hosting a session. */ +export type HostPresence = "waiting" | "connected" | "disconnected" | "exited"; + +export interface HostNotice { + /** A short headline, e.g. "Temporarily offline". */ + heading: string; + /** One or two sentences saying what is being shown and what happens next. */ + body: string; + /** True when the terminal below is a kept screen rather than a live one. */ + showingKeptScreen: boolean; +} + +export interface HostNoticeInput { + status: string | undefined; + /** ISO timestamp of when the machine was last connected, if it ever was. */ + hostLastSeenAt?: string; + /** ISO timestamp of the screen the viewer is looking at, if it is a kept one. */ + screenCapturedAt?: string; + /** Whether anything at all has been drawn in the terminal. */ + hasScreen: boolean; + now: number; +} + +/** True while the machine is not connected and the session has not ended. */ +export function hostIsAway(status: string | undefined): boolean { + return status === "disconnected" || status === "waiting"; +} + +/* + * "4 minutes ago" while that is the useful answer, a clock time once it is not. + * Returns undefined for a timestamp that is missing or unreadable rather than + * inventing a moment the viewer would then trust. + */ +export function describeSince(now: number, timestamp: string | undefined): string | undefined { + if (timestamp === undefined) return undefined; + const then = Date.parse(timestamp); + if (!Number.isFinite(then)) return undefined; + const seconds = Math.max(0, Math.round((now - then) / 1_000)); + if (seconds < 45) return "just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; +} + +/** + * The notice to show, or null when the machine is there and nothing needs + * saying. + */ +export function hostNotice(input: HostNoticeInput): HostNotice | null { + if (!hostIsAway(input.status)) return null; + + if (input.status === "waiting") { + return { + heading: "Waiting for this machine", + body: "This session has been created but its machine has not connected yet. " + + "The terminal appears the moment it does.", + showingKeptScreen: false, + }; + } + + const lastSeen = describeSince(input.now, input.hostLastSeenAt); + const seenSentence = lastSeen === undefined + ? "The machine sharing this terminal is not connected right now." + : `The machine sharing this terminal went offline ${lastSeen}.`; + + if (input.hasScreen) { + const captured = describeSince(input.now, input.screenCapturedAt); + return { + heading: "Temporarily offline", + body: `${seenSentence} You are looking at the last screen it sent` + + `${captured === undefined ? "" : `, from ${captured}`}. ` + + "It reconnects on its own, and the terminal goes live again when it does.", + showingKeptScreen: true, + }; + } + + return { + heading: "Temporarily offline", + body: `${seenSentence} Nothing is lost: the terminal comes back on its own ` + + "once that machine is awake and online again.", + showingKeptScreen: false, + }; +} diff --git a/shared/host-presence.ts b/shared/host-presence.ts new file mode 100644 index 0000000..948844e --- /dev/null +++ b/shared/host-presence.ts @@ -0,0 +1,95 @@ +/* + * What a viewer is told when the machine behind a session is not there. + * + * A viewer's own connection and the machine's connection are different things, + * and conflating them is how a perfectly connected viewer ends up staring at an + * empty terminal with nothing to explain it. The relay reports the machine's + * state on every status message; this turns that into something to read. + */ + +/** The relay's view of the machine hosting a session. */ +export type HostPresence = "waiting" | "connected" | "disconnected" | "exited"; + +export interface HostNotice { + /** A short headline, e.g. "Temporarily offline". */ + heading: string; + /** One or two sentences saying what is being shown and what happens next. */ + body: string; + /** True when the terminal below is a kept screen rather than a live one. */ + showingKeptScreen: boolean; +} + +export interface HostNoticeInput { + status: string | undefined; + /** ISO timestamp of when the machine was last connected, if it ever was. */ + hostLastSeenAt?: string; + /** ISO timestamp of the screen the viewer is looking at, if it is a kept one. */ + screenCapturedAt?: string; + /** Whether anything at all has been drawn in the terminal. */ + hasScreen: boolean; + now: number; +} + +/** True while the machine is not connected and the session has not ended. */ +export function hostIsAway(status: string | undefined): boolean { + return status === "disconnected" || status === "waiting"; +} + +/* + * "4 minutes ago" while that is the useful answer, a clock time once it is not. + * Returns undefined for a timestamp that is missing or unreadable rather than + * inventing a moment the viewer would then trust. + */ +export function describeSince(now: number, timestamp: string | undefined): string | undefined { + if (timestamp === undefined) return undefined; + const then = Date.parse(timestamp); + if (!Number.isFinite(then)) return undefined; + const seconds = Math.max(0, Math.round((now - then) / 1_000)); + if (seconds < 45) return "just now"; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; +} + +/** + * The notice to show, or null when the machine is there and nothing needs + * saying. + */ +export function hostNotice(input: HostNoticeInput): HostNotice | null { + if (!hostIsAway(input.status)) return null; + + if (input.status === "waiting") { + return { + heading: "Waiting for this machine", + body: "This session has been created but its machine has not connected yet. " + + "The terminal appears the moment it does.", + showingKeptScreen: false, + }; + } + + const lastSeen = describeSince(input.now, input.hostLastSeenAt); + const seenSentence = lastSeen === undefined + ? "The machine sharing this terminal is not connected right now." + : `The machine sharing this terminal went offline ${lastSeen}.`; + + if (input.hasScreen) { + const captured = describeSince(input.now, input.screenCapturedAt); + return { + heading: "Temporarily offline", + body: `${seenSentence} You are looking at the last screen it sent` + + `${captured === undefined ? "" : `, from ${captured}`}. ` + + "It reconnects on its own, and the terminal goes live again when it does.", + showingKeptScreen: true, + }; + } + + return { + heading: "Temporarily offline", + body: `${seenSentence} Nothing is lost: the terminal comes back on its own ` + + "once that machine is awake and online again.", + showingKeptScreen: false, + }; +} diff --git a/tests/host-presence.test.ts b/tests/host-presence.test.ts new file mode 100644 index 0000000..7197e85 --- /dev/null +++ b/tests/host-presence.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { describeSince, hostIsAway, hostNotice } from "../shared/host-presence"; + +const NOW = Date.parse("2026-09-15T20:00:00.000Z"); + +describe("hostIsAway", () => { + it("is true only while the machine is not there and the session lives on", () => { + expect(hostIsAway("disconnected")).toBe(true); + expect(hostIsAway("waiting")).toBe(true); + expect(hostIsAway("connected")).toBe(false); + /* An ended session has its own notice; this one would contradict it. */ + expect(hostIsAway("exited")).toBe(false); + expect(hostIsAway(undefined)).toBe(false); + }); +}); + +describe("describeSince", () => { + it("counts in the unit a person would use", () => { + expect(describeSince(NOW, "2026-09-15T19:59:40.000Z")).toBe("just now"); + expect(describeSince(NOW, "2026-09-15T19:59:00.000Z")).toBe("1 minute ago"); + expect(describeSince(NOW, "2026-09-15T19:40:00.000Z")).toBe("20 minutes ago"); + expect(describeSince(NOW, "2026-09-15T17:00:00.000Z")).toBe("3 hours ago"); + expect(describeSince(NOW, "2026-09-13T20:00:00.000Z")).toBe("2 days ago"); + }); + + it("says nothing rather than inventing a moment", () => { + expect(describeSince(NOW, undefined)).toBeUndefined(); + expect(describeSince(NOW, "not a date")).toBeUndefined(); + }); + + /* A clock that is behind the relay's must not produce "in 3 minutes". */ + it("never counts forwards", () => { + expect(describeSince(NOW, "2026-09-15T20:05:00.000Z")).toBe("just now"); + }); +}); + +describe("hostNotice", () => { + it("says nothing while the machine is connected", () => { + expect(hostNotice({ status: "connected", hasScreen: true, now: NOW })).toBeNull(); + }); + + it("explains an empty terminal whose machine went away", () => { + const notice = hostNotice({ + status: "disconnected", + hostLastSeenAt: "2026-09-15T18:00:00.000Z", + hasScreen: false, + now: NOW, + }); + expect(notice?.heading).toBe("Temporarily offline"); + expect(notice?.body).toContain("went offline 2 hours ago"); + expect(notice?.body).toContain("comes back on its own"); + expect(notice?.showingKeptScreen).toBe(false); + }); + + it("marks a kept screen as kept, and dates it", () => { + const notice = hostNotice({ + status: "disconnected", + hostLastSeenAt: "2026-09-15T19:30:00.000Z", + screenCapturedAt: "2026-09-15T19:25:00.000Z", + hasScreen: true, + now: NOW, + }); + expect(notice?.showingKeptScreen).toBe(true); + expect(notice?.body).toContain("last screen it sent, from 35 minutes ago"); + }); + + it("does not claim a machine went offline when it was never on", () => { + const notice = hostNotice({ status: "waiting", hasScreen: false, now: NOW }); + expect(notice?.heading).toBe("Waiting for this machine"); + expect(notice?.body).not.toContain("offline"); + }); + + /* An older relay sends no timestamp; the notice still has to make sense. */ + it("works without a last-seen time", () => { + const notice = hostNotice({ status: "disconnected", hasScreen: false, now: NOW }); + expect(notice?.body).toContain("is not connected right now"); + expect(notice?.body).not.toContain("undefined"); + }); +}); diff --git a/web/main.ts b/web/main.ts index 220bbdc..5a6b356 100644 --- a/web/main.ts +++ b/web/main.ts @@ -10,6 +10,7 @@ import { Opcode, } from "../shared/protocol"; import { readOnlyFromControlMessage } from "../shared/session-access"; +import { hostIsAway, hostNotice } from "../shared/host-presence"; import { isSessionFullClose, MAX_SESSION_VIEWERS, @@ -707,6 +708,7 @@ function renderTerminal(sessionId: string): void {
+ ${showRefstreamNotice ? '
Refstream is an experimental alpha renderer and may still be unstable.
' : ""}
@@ -810,6 +812,7 @@ function renderTerminal(sessionId: string): void { const terminalWrap = requiredElement("terminal-wrap"); const refstreamToolbar = requiredElement("refstream-toolbar"); const terminalInputWarning = requiredElement("terminal-input-warning"); + const offlineNotice = requiredElement("session-offline"); const mobileKeyButtons = Array.from( document.querySelectorAll("#mobile-terminal-keys [data-terminal-key]"), ); @@ -960,6 +963,17 @@ function renderTerminal(sessionId: string): void { let waitingForCapacity = false; let outgoingFrames = Promise.resolve(); let incomingFrames = Promise.resolve(); + /* + * The machine's own state, which is not this viewer's connection state. A + * viewer can be perfectly connected to a session whose machine has closed + * its lid, and until this was shown that read as a terminal that simply + * failed to appear. + */ + let hostStatus: string | undefined; + let hostLastSeenAt: string | undefined; + let screenCapturedAt: string | undefined; + let hasTerminalContent = false; + let offlineNoticeTimer: number | undefined; const syncMobileKeys = (): void => { const disabled = stopped || readOnly || waitingForEncryptionKey || @@ -1221,6 +1235,51 @@ function renderTerminal(sessionId: string): void { renderLatencyGraph(); }; + /* + * Says out loud that the machine is away. Without it the only sign is a grey + * dot in the header, next to a terminal that has drawn nothing, which reads + * as a broken page rather than a sleeping laptop. + */ + const renderHostPresence = (): void => { + window.clearTimeout(offlineNoticeTimer); + const ended = stopped || lastStatus === "exited" || lastStatus === "missing"; + const away = !ended && hostIsAway(hostStatus); + sessionPage.classList.toggle("session-host-away", away); + if (!away) { + offlineNotice.hidden = true; + offlineNotice.replaceChildren(); + return; + } + + const notice = hostNotice({ + status: hostStatus, + hostLastSeenAt, + screenCapturedAt: screenCapturedAt, + hasScreen: hasTerminalContent, + now: Date.now(), + }); + if (!notice) { + offlineNotice.hidden = true; + return; + } + + offlineNotice.classList.toggle("over-screen", notice.showingKeptScreen); + const heading = document.createElement("strong"); + heading.textContent = notice.heading; + const body = document.createElement("span"); + body.textContent = notice.body; + offlineNotice.replaceChildren(heading, body); + offlineNotice.hidden = false; + /* "4 minutes ago" has to keep being true while nobody touches the page. */ + offlineNoticeTimer = window.setTimeout(renderHostPresence, 30_000); + }; + + const markTerminalContent = (): void => { + if (hasTerminalContent) return; + hasTerminalContent = true; + renderHostPresence(); + }; + const setStatus = (status: string): void => { const wasConnected = lastStatus === "connected"; lastStatus = status; @@ -1231,6 +1290,7 @@ function renderTerminal(sessionId: string): void { scheduleLatencyProbe(0); } renderConnectionStatus(); + renderHostPresence(); syncMobileKeys(); }; @@ -1496,7 +1556,9 @@ function renderTerminal(sessionId: string): void { if (generation === terminalSnapshotGeneration) rendererInputSuppressed = false; }); snapshotRequestPending = false; + markTerminalContent(); } else if (frame[0] === Opcode.Output) { + markTerminalContent(); if (!terminalWrites.enqueue(frame.subarray(1)) && !snapshotRequestPending) { snapshotRequestPending = true; if (socket?.readyState === WebSocket.OPEN) { @@ -1544,6 +1606,8 @@ function renderTerminal(sessionId: string): void { type?: unknown; status?: unknown; label?: unknown; + hostLastSeenAt?: unknown; + lastScreenAt?: unknown; viewerId?: unknown; viewers?: unknown; localTypingAt?: unknown; @@ -1600,6 +1664,13 @@ function renderTerminal(sessionId: string): void { } if (message.type === "status" && typeof message.status === "string") { + /* + * The relay's status is the machine's, not this viewer's socket, which is + * why it is kept apart from lastStatus. + */ + hostStatus = message.status; + hostLastSeenAt = typeof message.hostLastSeenAt === "string" ? message.hostLastSeenAt : undefined; + screenCapturedAt = typeof message.lastScreenAt === "string" ? message.lastScreenAt : undefined; setStatus(message.status); if (typeof message.label === "string") { labelElement.textContent = message.label; diff --git a/web/style.css b/web/style.css index 3937c34..2b7a617 100644 --- a/web/style.css +++ b/web/style.css @@ -1617,6 +1617,73 @@ a { .terminal-input-warning[hidden] { display: none; } +/* + * The machine behind a session can be asleep, rebooting or off a network while + * the viewer stays perfectly connected. Centred while there is nothing to look + * at; a strip over the kept screen when there is. + */ +.session-offline { + position: absolute; + z-index: 6; + top: 50%; + left: 50%; + display: grid; + gap: 6px; + width: min(430px, calc(100% - 40px)); + padding: 16px 18px; + transform: translate(-50%, -50%); + border: 1px solid #3c4657; + border-radius: 12px; + background: #171d27; + box-shadow: 0 18px 44px rgb(0 0 0 / 34%); + color: #aab4c4; + font: 500 12px/1.55 "Uncut Sans", sans-serif; + text-wrap: pretty; +} + +.session-offline strong { + color: #f2f5fa; + font-weight: 620; + font-size: 13px; +} + +.session-offline.over-screen { + top: 14px; + left: 50%; + width: min(560px, calc(100% - 28px)); + padding: 10px 14px; + transform: translateX(-50%); + background: rgb(23 29 39 / 94%); +} + +.session-offline[hidden] { display: none; } + +/* A kept screen is not a live one, and should not be mistaken for one. */ +.session-page.session-host-away .terminal { opacity: 0.55; } + +.theme-light .session-offline { + border-color: #d5dbe4; + background: #fff; + box-shadow: 0 18px 44px rgb(35 45 61 / 14%); + color: #5a6474; +} + +.theme-light .session-offline strong { color: #1d2430; } + +.theme-light .session-offline.over-screen { background: rgb(255 255 255 / 94%); } + +@media (max-width: 760px) { + .session-offline { + width: calc(100% - 20px); + padding: 13px 15px; + } + + .session-offline.over-screen { + top: 8px; + width: calc(100% - 16px); + } +} + .mobile-terminal-keys { display: none; } diff --git a/worker/index.ts b/worker/index.ts index 249fce1..f0de980 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -60,6 +60,24 @@ const MAX_LIVE_FRAME_BYTES = 64 * 1024; const MAX_INPUT_FRAME_BYTES = 16 * 1024 + 1; const MAX_SNAPSHOT_BYTES = 512 * 1024; const MAX_ENCRYPTION_OVERHEAD_BYTES = 29; +/* + * The last screen a host sent is kept so a viewer arriving while that machine + * is away sees what it was doing instead of a blank terminal. Durable Object + * values stop at 128 KiB, so a snapshot is stored in chunks under one prefix + * and deleted with the rest of the session when it expires. + */ +const SCREEN_CHUNK_BYTES = 64 * 1024; +const SCREEN_CHUNK_PREFIX = "screen:"; +const MAX_SCREEN_CHUNKS = 16; +/* How stale a cached screen may get while a host is connected. */ +const SCREEN_REFRESH_MS = 5 * 60 * 1_000; +/* The shortest gap between two keeps, so a busy session does not write on every join. */ +const SCREEN_WRITE_INTERVAL_MS = 2_000; +/* + * The viewer id a keep-the-screen request is addressed to. No viewer is ever + * given it, so the reply is cached and delivered to nobody. + */ +const SCREEN_CACHE_VIEWER_ID = 0; const TRAFFIC_WINDOW_MS = 10_000; const HOST_WINDOW_BYTES = 40 * 1024 * 1024; const VIEWER_WINDOW_BYTES = 1024 * 1024; @@ -120,6 +138,14 @@ interface SessionMeta { presenceKey?: string; localAttached?: boolean; persistent: boolean; + /* + * When a host socket was last open. A viewer that finds the machine away is + * told how long it has been away rather than left reading an empty screen. + */ + hostLastSeenAt?: number; + /** When the cached screen below was captured, and how many chunks it spans. */ + lastScreenAt?: number; + lastScreenChunks?: number; } interface SocketAttachment { @@ -1021,7 +1047,7 @@ export class TerminalSession extends DurableObject { : analyticsContext; const attachment: SocketAttachment = { role, - id: role === "viewer" ? randomUint32() : 0, + id: role === "viewer" ? randomViewerId() : 0, guestNumber, colorIndex: guestNumber === undefined ? undefined : (guestNumber - 1) % 8, device: analyticsContext.device, @@ -1039,6 +1065,7 @@ export class TerminalSession extends DurableObject { const firstStart = this.meta.startedAt === undefined; if (firstStart) this.meta.startedAt = Date.now(); this.meta.status = "connected"; + this.meta.hostLastSeenAt = Date.now(); this.meta.expiresAt = Date.now() + (this.meta.persistent ? PERSISTENT_TTL_MS : SESSION_TTL_MS); delete this.meta.exitCode; await this.persistMeta(); @@ -1091,6 +1118,8 @@ export class TerminalSession extends DurableObject { for (const host of this.state.getWebSockets("host")) { sendJson(host, { type: "snapshot_request", viewerId: attachment.id }); } + /* Nobody to ask: show the last screen this session was seen at. */ + if (!this.hostIsConnected()) await this.sendCachedScreen(server); this.broadcastPresence(); } @@ -1159,6 +1188,15 @@ export class TerminalSession extends DurableObject { if (now - (attachment.snapshotRequestedAt ?? 0) < 1_000) return; attachment.snapshotRequestedAt = now; socket.serializeAttachment(attachment); + /* + * A viewer asks again once it can decrypt, which is usually after the + * password gate. With the machine away there is nobody to ask, so the + * kept screen answers instead. + */ + if (!this.hostIsConnected()) { + await this.sendCachedScreen(socket); + return; + } for (const host of this.state.getWebSockets("host")) { sendJson(host, { type: "snapshot_request", viewerId: attachment.id }); } @@ -1253,15 +1291,20 @@ export class TerminalSession extends DurableObject { return; } const targetId = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32(1); + const outbound = new Uint8Array(frame.byteLength - 4); + outbound[0] = Opcode.Snapshot; + outbound.set(frame.subarray(5), 1); const target = this.state .getWebSockets("viewer") .find((candidate) => readAttachment(candidate)?.id === targetId); - if (target) { - const outbound = new Uint8Array(frame.byteLength - 4); - outbound[0] = Opcode.Snapshot; - outbound.set(frame.subarray(5), 1); - safeSend(target, outbound); - } + if (target) safeSend(target, outbound); + /* + * Kept whether or not a viewer was waiting for it: the periodic refresh + * addresses viewer 0, which no viewer ever is, precisely so a screen can + * be kept without disturbing anyone. Throttled, because a room filling + * up produces one of these per person arriving. + */ + await this.cacheScreen(outbound); return; } @@ -1273,6 +1316,8 @@ export class TerminalSession extends DurableObject { // Preserve the opcode because E2EE authenticates it as associated data. // The browser treats FinalSnapshot as a full screen replacement too. this.broadcastBinary(frame, "viewer"); + /* The last thing this session ever drew, so it is kept unconditionally. */ + await this.cacheScreen(frame, true); return; case Opcode.BroadcastSnapshot: @@ -1283,6 +1328,7 @@ export class TerminalSession extends DurableObject { // Preserve the opcode: encrypted frames authenticate it as associated // data, and rewriting it makes a valid recovery snapshot undecryptable. this.broadcastBinary(frame, "viewer"); + await this.cacheScreen(frame); return; case Opcode.Pong: @@ -1456,6 +1502,7 @@ export class TerminalSession extends DurableObject { return; } this.meta.status = "disconnected"; + this.meta.hostLastSeenAt = Date.now(); this.meta.expiresAt = disconnectedSessionExpiry(Date.now(), this.meta.persistent); await this.persistMeta(); await this.refreshLivePresence(true, socket); @@ -1470,7 +1517,19 @@ export class TerminalSession extends DurableObject { .some((socket) => socket.readyState === 1); if (hostIsOpen) { this.meta.status = "connected"; + this.meta.hostLastSeenAt = Date.now(); this.meta.expiresAt = Date.now() + (this.meta.persistent ? PERSISTENT_TTL_MS : SESSION_TTL_MS); + /* + * A host only sends a screen when it is asked, so without this the kept + * screen would be as old as the last viewer to join. Asking on behalf of + * viewer 0 caps how stale it can be for the cost of one snapshot per + * five minutes of a live session. + */ + if (Date.now() - (this.meta.lastScreenAt ?? 0) >= SCREEN_REFRESH_MS) { + for (const host of this.state.getWebSockets("host")) { + sendJson(host, { type: "snapshot_request", viewerId: SCREEN_CACHE_VIEWER_ID }); + } + } await this.persistMeta(); await this.refreshLivePresence(true); await this.scheduleNextAlarm(); @@ -1631,9 +1690,93 @@ export class TerminalSession extends DurableObject { persistent: this.meta?.persistent === true, exitCode: this.meta?.exitCode, expiresAt: this.meta ? new Date(this.meta.expiresAt).toISOString() : undefined, + /* + * A viewer cannot tell an idle terminal from an absent machine. These two + * say which it is looking at: when the machine was last connected, and how + * old the screen it is being shown is. + */ + hostLastSeenAt: this.meta?.hostLastSeenAt === undefined + ? undefined + : new Date(this.meta.hostLastSeenAt).toISOString(), + lastScreenAt: this.meta?.lastScreenAt === undefined + ? undefined + : new Date(this.meta.lastScreenAt).toISOString(), }; } + private hostIsConnected(): boolean { + return this.state.getWebSockets("host").some((socket) => socket.readyState === 1); + } + + /* + * Keeps the most recent full screen, exactly as a viewer would have received + * it. For an encrypted session that is ciphertext the relay cannot read: the + * opcode is authenticated as associated data, so the bytes are stored and + * replayed untouched rather than re-framed. + */ + private async cacheScreen(frame: Uint8Array, force = false): Promise { + if (!this.meta || frame.byteLength === 0) return; + /* + * Sixteen people opening a link at once produces sixteen of these. Writing + * every one would cost half a megabyte of storage each for no better + * answer, so only the first in a window is kept. + */ + if (!force && Date.now() - (this.meta.lastScreenAt ?? 0) < SCREEN_WRITE_INTERVAL_MS) return; + const chunkCount = Math.ceil(frame.byteLength / SCREEN_CHUNK_BYTES); + if (chunkCount > MAX_SCREEN_CHUNKS) return; + + const writes: Record = {}; + for (let index = 0; index < chunkCount; index += 1) { + const start = index * SCREEN_CHUNK_BYTES; + writes[`${SCREEN_CHUNK_PREFIX}${index}`] = frame + .slice(start, Math.min(start + SCREEN_CHUNK_BYTES, frame.byteLength)) + .buffer; + } + await this.state.storage.put(writes); + + const previousChunks = this.meta.lastScreenChunks ?? 0; + if (previousChunks > chunkCount) { + await this.state.storage.delete( + Array.from({ length: previousChunks - chunkCount }, (_, offset) => + `${SCREEN_CHUNK_PREFIX}${chunkCount + offset}`), + ); + } + this.meta.lastScreenAt = Date.now(); + this.meta.lastScreenChunks = chunkCount; + await this.persistMeta(); + } + + private async cachedScreen(): Promise { + const chunkCount = this.meta?.lastScreenChunks ?? 0; + if (chunkCount === 0) return undefined; + const keys = Array.from({ length: chunkCount }, (_, index) => `${SCREEN_CHUNK_PREFIX}${index}`); + const stored = await this.state.storage.get(keys); + const parts: Uint8Array[] = []; + let total = 0; + for (const key of keys) { + const value = stored.get(key); + /* A partially written cache is not a screen; showing nothing beats showing half. */ + if (value === undefined) return undefined; + const part = new Uint8Array(value); + parts.push(part); + total += part.byteLength; + } + const frame = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + frame.set(part, offset); + offset += part.byteLength; + } + return frame; + } + + /* Replays the last screen to one viewer while its machine is away. */ + private async sendCachedScreen(socket: WebSocket): Promise { + if (this.hostIsConnected()) return; + const screen = await this.cachedScreen(); + if (screen) safeSend(socket, screen); + } + private isReadOnly(): boolean { return this.meta?.readOnly === true; } @@ -1883,6 +2026,15 @@ function randomUint32(): number { return crypto.getRandomValues(new Uint32Array(1))[0]; } +/* + * Viewer ids skip zero, which addresses the relay's own screen-keeping request + * rather than a person. Without that a one-in-four-billion viewer would be sent + * a screen refresh it never asked for. + */ +function randomViewerId(): number { + return randomUint32() || 1; +} + async function sha256Hex(value: string): Promise { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");