Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/scripts/check-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
59 changes: 59 additions & 0 deletions app/src/styles/terminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 45 additions & 1 deletion app/src/terminal/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -166,10 +167,28 @@ export function TerminalPane({

const [status, setStatus] = useState<ConnectionStatus>("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<HostState | null>(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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="pane" data-active={active} data-renderer={renderer} aria-hidden={!active}>
Expand Down Expand Up @@ -643,6 +675,18 @@ export function TerminalPane({
<div className="pane-banner pane-watching">Watching. Only the owner and assignees can type.</div>
)}

{/*
* 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 && (
<div className={notice.showingKeptScreen ? "pane-offline over-screen" : "pane-offline"} role="status">
<strong>{notice.heading}</strong>
<span>{notice.body}</span>
</div>
)}

{status === "disconnected" && (
<div className="pane-banner">
<ArrowClockwise size={14} />
Expand Down
64 changes: 62 additions & 2 deletions app/src/terminal/connection.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
Expand Down Expand Up @@ -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([]);
});
});
39 changes: 38 additions & 1 deletion app/src/terminal/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
MOBILE_TERMINAL_GRID,
type TerminalGrid,
} from "./terminal-grid";
import type { HostPresence } from "./host-presence";

export type ConnectionStatus =
| "connecting"
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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");
}
Expand Down
Loading
Loading