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("");