mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
tabs: a machine you can watch, not only type into
A pane shows a machine. Until now there was one way to look at one — a
shell — so the binding that says WHAT a pane shows only ever said which
tmux session. A desktop sandbox has an X server, and nothing here could
show it.
So a binding has a second face. `{kind:'screen', machine}` carries no
name because a machine has one display where it has as many shells as you
ask for, and the machine's own class decides whether it is offered at all
— `desktop` runs an X server, the other classes do not, and a name is
never read as a capability.
ONE MINT FOR BOTH DOORS. The ticket answers the address it opens, so
`frameUrl` asks for a door and frames what the server named, rather than
composing a URL a client could spell wrong. The bearer still never leaves
lib/api; what a pane holds is the minted URL.
One button, one intention: Desktop opens a screen where a desktop is
running and starts one where none is. A disabled button beside a second
button that makes it work would be two controls for one thought.
This commit is contained in:
+22
-10
@@ -22,13 +22,13 @@ import {
|
||||
type SandboxMachine,
|
||||
type Session,
|
||||
createSandbox,
|
||||
frameUrl,
|
||||
machineName,
|
||||
machines,
|
||||
sandboxes,
|
||||
sandboxTerminal,
|
||||
sessions,
|
||||
terminalTicket,
|
||||
} from '@/lib/api';
|
||||
import { type Binding } from '@/lib/panes';
|
||||
import { renew, session, signIn, signOut } from '@/lib/iam';
|
||||
import { Workspace, type TerminalHost } from '@/components/workspace';
|
||||
|
||||
@@ -122,18 +122,30 @@ export default function App() {
|
||||
// stable, unique machine name.
|
||||
for (const s of data.b) {
|
||||
const key = machineName(s);
|
||||
if (!out.has(key)) out.set(key, { machine: key, sandbox: s.id, status: 'online' });
|
||||
if (!out.has(key)) {
|
||||
// `screen` is the machine's own class, not a guess: only a desktop
|
||||
// sandbox runs an X server, so only a desktop has pixels to frame.
|
||||
out.set(key, {
|
||||
machine: key,
|
||||
sandbox: s.id,
|
||||
screen: s.class === 'desktop',
|
||||
status: 'online',
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...out.values()];
|
||||
}, [data]);
|
||||
|
||||
/** A fresh framed-terminal URL for a sandbox pane — ticket minted here, where
|
||||
* the token lives, so the workspace stays credential-free. */
|
||||
/** A fresh URL for a sandbox pane — ticket minted here, where the token
|
||||
* lives, so the workspace stays credential-free. What the pane SHOWS picks
|
||||
* the door: a shell attaches to its tmux session by name, a screen has one
|
||||
* display and needs no name. */
|
||||
const mint = useCallback(
|
||||
async (sandbox: string, shell: string) => {
|
||||
async (sandbox: string, what: Binding) => {
|
||||
if (!token) throw new Error('signed out');
|
||||
const ticket = await terminalTicket(token, sandbox);
|
||||
return sandboxTerminal(sandbox, ticket, shell);
|
||||
return what.kind === 'screen'
|
||||
? frameUrl(token, sandbox, 'screen')
|
||||
: frameUrl(token, sandbox, 'terminal', what.kind === 'shell' ? what.shell.name : undefined);
|
||||
},
|
||||
[token],
|
||||
);
|
||||
@@ -197,8 +209,8 @@ export default function App() {
|
||||
// opens a shell on the name it gets, and a pane can only mint a
|
||||
// ticket for a machine the registry has already handed back, so the
|
||||
// refresh is what stands between the two.
|
||||
onLaunch={async () => {
|
||||
const box = await createSandbox(token);
|
||||
onLaunch={async (kind) => {
|
||||
const box = await createSandbox(token, kind);
|
||||
await refresh(token);
|
||||
return machineName(box);
|
||||
}}
|
||||
|
||||
+138
-58
@@ -34,7 +34,7 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Cloud, Columns2, Plus, Rows2, X } from 'lucide-react';
|
||||
import { Cloud, Columns2, Monitor, Plus, Rows2, X } from 'lucide-react';
|
||||
|
||||
import {
|
||||
type Dir,
|
||||
@@ -52,10 +52,12 @@ import {
|
||||
stackFor,
|
||||
} from '@/lib/tiles';
|
||||
import {
|
||||
DEADLINE,
|
||||
DOT,
|
||||
TERM_DEADLINE,
|
||||
type Binding,
|
||||
isTermReady,
|
||||
isReady,
|
||||
label,
|
||||
machineOf,
|
||||
mintName,
|
||||
rescued,
|
||||
shellUrl,
|
||||
@@ -66,9 +68,13 @@ export interface TerminalHost {
|
||||
machine: string;
|
||||
/** The share URL `hanzo link` published. Absent ⇒ nothing to frame. */
|
||||
base?: string;
|
||||
/** A dev sandbox's id. Its terminal URL is MINTED per open (single-use
|
||||
* ticket) rather than published, so a sandbox host has this and no base. */
|
||||
/** A sandbox's id. Its URLs are MINTED per open (single-use ticket) rather
|
||||
* than published, so a sandbox host has this and no base. */
|
||||
sandbox?: string;
|
||||
/** Whether this machine has a DISPLAY to watch. It is the machine's own
|
||||
* answer — a `desktop` sandbox runs an X server and a VNC server, the other
|
||||
* classes have neither — and never inferred from a name. */
|
||||
screen?: boolean;
|
||||
status: string;
|
||||
label?: string;
|
||||
}
|
||||
@@ -98,11 +104,12 @@ export function Workspace({
|
||||
onLaunch,
|
||||
}: {
|
||||
hosts: TerminalHost[];
|
||||
/** A fresh framed-terminal URL for a sandbox pane. The caller owns the
|
||||
* credential; the workspace only ever holds the minted, ticket-bearing URL. */
|
||||
mint?: (sandbox: string, shell: string) => Promise<string>;
|
||||
/** Start a cloud machine, and answer the name it goes by here. */
|
||||
onLaunch?: () => Promise<string>;
|
||||
/** A fresh URL for a sandbox pane, for whatever that pane shows. The caller
|
||||
* owns the credential; the workspace only ever holds the minted,
|
||||
* ticket-bearing URL. */
|
||||
mint?: (sandbox: string, what: Binding) => Promise<string>;
|
||||
/** Start a cloud machine of one class, and answer the name it goes by here. */
|
||||
onLaunch?: (kind: 'dev' | 'desktop') => Promise<string>;
|
||||
}) {
|
||||
// A machine with no `base` serves no terminal, and there is nothing to open on
|
||||
// one of those — except a sandbox, whose URL is minted on bind rather than
|
||||
@@ -111,6 +118,10 @@ export function Workspace({
|
||||
() => hosts.filter((h) => (h.base || h.sandbox) && h.status !== 'offline'),
|
||||
[hosts],
|
||||
);
|
||||
/** The live machines with a display. A linked machine has none to offer —
|
||||
* what `hanzo link` publishes is a terminal — so this is the sandboxes that
|
||||
* were started as desktops. */
|
||||
const watchable = useMemo(() => live.filter((h) => h.screen), [live]);
|
||||
|
||||
const [tile, setTile] = useState<Tile | null>(null);
|
||||
const [bind, setBind] = useState<Record<string, Binding>>({});
|
||||
@@ -180,25 +191,36 @@ export function Workspace({
|
||||
[bind],
|
||||
);
|
||||
|
||||
/** Open a shell in a NEW pane beside `target` — or as the whole layout. */
|
||||
const open = useCallback(
|
||||
(machine: string, dir: Dir | null, target: string | null) => {
|
||||
const id = `p${seq.current++}`;
|
||||
const name = mintName(takenOn(machine));
|
||||
setBind((b) => ({ ...b, [id]: { kind: 'shell', shell: { machine, name } } }));
|
||||
setTile((t) => (t && target && dir ? splitPane(t, target, dir, id) : (t ?? pane(id))));
|
||||
setFocus(id);
|
||||
},
|
||||
/** What a machine looks like in a pane, each way of looking at one. Two
|
||||
* functions and not a flag, because only one of them has a name to mint. */
|
||||
const shellOn = useCallback(
|
||||
(machine: string): Binding => ({
|
||||
kind: 'shell',
|
||||
shell: { machine, name: mintName(takenOn(machine)) },
|
||||
}),
|
||||
[takenOn],
|
||||
);
|
||||
const screenOn = useCallback((machine: string): Binding => ({ kind: 'screen', machine }), []);
|
||||
|
||||
/** Start a machine and open its first shell. The point of the button is the
|
||||
* terminal: a machine that arrives with nothing framed on it is a row in a
|
||||
* list, and you would have to go and ask for the shell you already asked for. */
|
||||
const launch = useCallback(async () => {
|
||||
if (!onLaunch) return;
|
||||
open(await onLaunch(), focus ? 'row' : null, focus);
|
||||
}, [onLaunch, open, focus]);
|
||||
/** Open a NEW pane showing `what`, beside `target` — or as the whole layout. */
|
||||
const open = useCallback((what: Binding, dir: Dir | null, target: string | null) => {
|
||||
const id = `p${seq.current++}`;
|
||||
setBind((b) => ({ ...b, [id]: what }));
|
||||
setTile((t) => (t && target && dir ? splitPane(t, target, dir, id) : (t ?? pane(id))));
|
||||
setFocus(id);
|
||||
}, []);
|
||||
|
||||
/** Start a machine and open it. The point of the button is what you get to
|
||||
* look at: a machine that arrives with nothing framed on it is a row in a
|
||||
* list, and you would have to go and ask for the thing you already asked for. */
|
||||
const launch = useCallback(
|
||||
async (kind: 'dev' | 'desktop') => {
|
||||
if (!onLaunch) return;
|
||||
const machine = await onLaunch(kind);
|
||||
open(kind === 'desktop' ? screenOn(machine) : shellOn(machine), focus ? 'row' : null, focus);
|
||||
},
|
||||
[onLaunch, open, focus, shellOn, screenOn],
|
||||
);
|
||||
|
||||
const doClose = useCallback((id: string) => {
|
||||
setTile((t) => (t ? closePane(t, id) : null));
|
||||
@@ -303,7 +325,7 @@ export function Workspace({
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (!isTermReady(e.data)) return;
|
||||
if (!isReady(e.data)) return;
|
||||
// The sender identifies the pane: a message is trusted only when it comes
|
||||
// from the window of a frame this workspace actually rendered. The origin
|
||||
// is not pinned because every machine publishes on its own tunnel host.
|
||||
@@ -323,13 +345,21 @@ export function Workspace({
|
||||
if (!el) return;
|
||||
// Give the terminal a moment to boot before calling it absent — a rescue that
|
||||
// flashes over every pane on every load is its own defect.
|
||||
const t = setTimeout(() => setWaited((w) => (w[id] ? w : { ...w, [id]: true })), TERM_DEADLINE);
|
||||
const t = setTimeout(() => setWaited((w) => (w[id] ? w : { ...w, [id]: true })), DEADLINE);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const refused = useMemo(() => rescued(waited, alive), [waited, alive]);
|
||||
|
||||
const [picking, setPicking] = useState<null | { dir: Dir | null; target: string | null }>(null);
|
||||
// A pick is "which machine", and what to MAKE of the one picked comes with
|
||||
// the question — so the same picker asks for a shell's machine and a
|
||||
// screen's, and neither knows about the other.
|
||||
const [picking, setPicking] = useState<null | {
|
||||
make: (m: string) => Binding;
|
||||
from: TerminalHost[];
|
||||
dir: Dir | null;
|
||||
target: string | null;
|
||||
}>(null);
|
||||
const rendered = useMemo(() => order.filter((id) => bind[id]), [order, bind]);
|
||||
|
||||
// Sandbox panes: the URL is minted, not published. Minted per PANE and kept
|
||||
@@ -343,11 +373,12 @@ export function Workspace({
|
||||
if (!mint) return;
|
||||
for (const id of rendered) {
|
||||
const b = bind[id];
|
||||
if (!b || b.kind !== 'shell') continue;
|
||||
const h = hostOf(b.shell.machine);
|
||||
const m = b && (b.kind === 'shell' || b.kind === 'screen') ? machineOf(b) : null;
|
||||
if (!b || !m) continue;
|
||||
const h = hostOf(m);
|
||||
if (!h?.sandbox || minted[id] || minting.current.has(id)) continue;
|
||||
minting.current.add(id);
|
||||
mint(h.sandbox, b.shell.name)
|
||||
mint(h.sandbox, b)
|
||||
.then((src) => setMinted((m) => ({ ...m, [id]: src })))
|
||||
// A failed mint leaves the pane on its "waiting" face; the deadline
|
||||
// then offers reconnect, which is the retry.
|
||||
@@ -377,33 +408,66 @@ export function Workspace({
|
||||
</p>
|
||||
{/* The header is not rendered in this branch, and someone with no machine
|
||||
at all is exactly who has nowhere else to get one. */}
|
||||
{onLaunch ? <Launch run={launch} /> : null}
|
||||
{onLaunch ? (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<Launch run={() => launch('dev')} icon={<Cloud className="h-3.5 w-3.5" />} label="New cloud machine" busyLabel="Starting a machine…" />
|
||||
<Launch run={() => launch('desktop')} icon={<Monitor className="h-3.5 w-3.5" />} label="New desktop" busyLabel="Starting a desktop…" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Never make someone choose from a set of one. */
|
||||
const openHere = (dir: Dir | null, target: string | null) => {
|
||||
if (live.length === 1) open(live[0]!.machine, dir, target);
|
||||
else setPicking({ dir, target });
|
||||
const openHere = (make: (m: string) => Binding, from: TerminalHost[], dir: Dir | null, target: string | null) => {
|
||||
if (from.length === 1) open(make(from[0]!.machine), dir, target);
|
||||
else setPicking({ make, from, dir, target });
|
||||
};
|
||||
|
||||
/** A shell goes on any live machine. */
|
||||
const openShell = (dir: Dir | null, target: string | null) => openHere(shellOn, live, dir, target);
|
||||
|
||||
/** A screen goes only where there IS one — and where there is none, the
|
||||
* button starts the machine that has one. One press, one meaning: show me a
|
||||
* desktop. */
|
||||
const openScreen = (dir: Dir | null, target: string | null) =>
|
||||
watchable.length ? openHere(screenOn, watchable, dir, target) : launch('desktop');
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col gap-2">
|
||||
{/* One row. Actions, not a status report. */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openHere(focus ? 'row' : null, focus)}
|
||||
onClick={() => openShell(focus ? 'row' : null, focus)}
|
||||
className="inline-flex min-h-9 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> New shell
|
||||
</button>
|
||||
{onLaunch ? <Launch run={launch} /> : null}
|
||||
{/* One button for the screen, whether or not a machine with one exists
|
||||
yet: with a desktop live it opens it, without one it starts it. The
|
||||
alternative — a disabled button beside a second button that makes it
|
||||
work — is two controls for one intention. */}
|
||||
{watchable.length || onLaunch ? (
|
||||
<Launch
|
||||
run={async () => openScreen(focus ? 'row' : null, focus)}
|
||||
icon={<Monitor className="h-3.5 w-3.5" />}
|
||||
label="Desktop"
|
||||
busyLabel="Starting a desktop…"
|
||||
/>
|
||||
) : null}
|
||||
{onLaunch ? (
|
||||
<Launch
|
||||
run={() => launch('dev')}
|
||||
icon={<Cloud className="h-3.5 w-3.5" />}
|
||||
label="New cloud machine"
|
||||
busyLabel="Starting a machine…"
|
||||
/>
|
||||
) : null}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => focus && openHere('row', focus)}
|
||||
onClick={() => focus && openShell('row', focus)}
|
||||
disabled={!focus}
|
||||
title="Split right"
|
||||
className="inline-flex min-h-9 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted disabled:opacity-40"
|
||||
@@ -413,7 +477,7 @@ export function Workspace({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => focus && openHere('col', focus)}
|
||||
onClick={() => focus && openShell('col', focus)}
|
||||
disabled={!focus}
|
||||
title="Split down"
|
||||
className="inline-flex min-h-9 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted disabled:opacity-40"
|
||||
@@ -430,7 +494,7 @@ export function Workspace({
|
||||
{Array.from({ length: pages }, (_, i) => {
|
||||
const first = geo.rects.find((r) => Math.round(r.left / 100) === i);
|
||||
const b = first ? bind[first.id] : undefined;
|
||||
const label = b && b.kind !== 'empty' ? `${b.shell.machine}·${b.shell.name}` : `${i + 1}`;
|
||||
const name = b ? label(b) : String(i + 1);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
@@ -443,7 +507,7 @@ export function Workspace({
|
||||
i === page ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -471,16 +535,21 @@ export function Workspace({
|
||||
if (!r) return null;
|
||||
const b = bind[id]!;
|
||||
const on = id === focus;
|
||||
const shell = b.kind === 'empty' ? null : b.shell;
|
||||
const host = shell ? hostOf(shell.machine) : undefined;
|
||||
const machine = machineOf(b);
|
||||
const host = machine ? hostOf(machine) : undefined;
|
||||
const title = label(b);
|
||||
// A sandbox pane frames its minted, ticket-bearing URL; a linked
|
||||
// machine's pane derives from the published tunnel. `minted` is
|
||||
// per-pane state because the ticket is spent on first load —
|
||||
// deriving would re-spend it every render.
|
||||
//
|
||||
// A SCREEN IS ALWAYS MINTED. A tunnel publishes a terminal and
|
||||
// nothing else, so a linked machine has no screen to derive — the
|
||||
// pane waits, which is the truth about that machine.
|
||||
const url = host?.sandbox
|
||||
? (minted[id] ?? null)
|
||||
: host?.base && shell
|
||||
? shellUrl(host.base, shell.name)
|
||||
: host?.base && b.kind === 'shell'
|
||||
? shellUrl(host.base, b.shell.name)
|
||||
: null;
|
||||
// Every rect is page-relative; the track is `pages * 100%` wide, so a
|
||||
// page occupies `100/pages` of it.
|
||||
@@ -511,12 +580,12 @@ export function Workspace({
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{shell ? `${shell.machine} · ${shell.name}` : 'New shell'}
|
||||
{title}
|
||||
</span>
|
||||
<span className="ml-auto hidden items-center gap-1 sm:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openHere('row', id)}
|
||||
onClick={() => openShell('row', id)}
|
||||
title="Split right"
|
||||
className="inline-flex size-6 items-center justify-center rounded hover:bg-muted-foreground/20"
|
||||
>
|
||||
@@ -524,7 +593,7 @@ export function Workspace({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openHere('col', id)}
|
||||
onClick={() => openShell('col', id)}
|
||||
title="Split down"
|
||||
className="inline-flex size-6 items-center justify-center rounded hover:bg-muted-foreground/20"
|
||||
>
|
||||
@@ -549,7 +618,7 @@ export function Workspace({
|
||||
<iframe
|
||||
data-pane={id}
|
||||
src={url}
|
||||
title={shell ? `${shell.machine} · ${shell.name}` : 'terminal'}
|
||||
title={title}
|
||||
className="absolute inset-0 h-full w-full bg-black"
|
||||
// Scripts (a terminal is one), and `allow-same-origin` gives
|
||||
// the frame ITS OWN origin — which ttyd needs for its socket
|
||||
@@ -596,8 +665,9 @@ export function Workspace({
|
||||
// which offers to sign you in to a terminal at `#`.
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center px-4 text-center">
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
Waiting for <span className="text-foreground">{b.shell.machine}</span> to
|
||||
serve a terminal. It appears here as soon as the machine links.
|
||||
Waiting for <span className="text-foreground">{machine}</span> to{' '}
|
||||
{b.kind === 'screen' ? 'show its screen' : 'serve a terminal'}. It appears
|
||||
here as soon as the machine is up.
|
||||
</p>
|
||||
</div>
|
||||
) : refused[id] ? (
|
||||
@@ -608,8 +678,8 @@ export function Workspace({
|
||||
// the sign-in, and opening a dead one teaches nothing.
|
||||
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black px-4 text-center">
|
||||
<p className="max-w-xs text-xs text-muted-foreground">
|
||||
The terminal did not come up. Its ticket lasts thirty seconds —
|
||||
reconnecting mints a fresh one.
|
||||
{b.kind === 'screen' ? 'The desktop' : 'The terminal'} did not come up.
|
||||
Its ticket lasts thirty seconds — reconnecting mints a fresh one.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -690,9 +760,9 @@ export function Workspace({
|
||||
{picking ? (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-4">
|
||||
<Picker
|
||||
hosts={live}
|
||||
hosts={picking.from}
|
||||
onPick={(m) => {
|
||||
open(m, picking.dir, picking.target);
|
||||
open(picking.make(m), picking.dir, picking.target);
|
||||
setPicking(null);
|
||||
}}
|
||||
onCancel={() => setPicking(null)}
|
||||
@@ -713,7 +783,17 @@ export function Workspace({
|
||||
* plane, and a workspace full of working terminals is not broken because it is
|
||||
* down.
|
||||
*/
|
||||
function Launch({ run }: { run: () => Promise<void> }) {
|
||||
function Launch({
|
||||
run,
|
||||
icon,
|
||||
label,
|
||||
busyLabel,
|
||||
}: {
|
||||
run: () => Promise<void>;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
busyLabel: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState<string | null>(null);
|
||||
return (
|
||||
@@ -730,7 +810,7 @@ function Launch({ run }: { run: () => Promise<void> }) {
|
||||
}}
|
||||
className="inline-flex min-h-9 shrink-0 items-center gap-1 rounded-md border border-border px-2.5 text-foreground hover:bg-muted disabled:opacity-40"
|
||||
>
|
||||
<Cloud className="h-3.5 w-3.5" /> {busy ? 'Starting a machine…' : 'New cloud machine'}
|
||||
{icon} {busy ? busyLabel : label}
|
||||
</button>
|
||||
{failed ? <span className="truncate text-muted-foreground">{failed}</span> : null}
|
||||
</>
|
||||
|
||||
+51
-28
@@ -63,6 +63,9 @@ export interface SandboxMachine {
|
||||
id: string;
|
||||
status: string;
|
||||
project?: string;
|
||||
/** `dev` shells; `desktop` also has an X server, so it has a SCREEN to watch.
|
||||
* It is the machine's own answer and not a guess from its name. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export const sandboxes = (token: string) =>
|
||||
@@ -74,32 +77,39 @@ export const sandboxes = (token: string) =>
|
||||
* keyed on, so it is the name that means the same box tomorrow. */
|
||||
export const machineName = (s: SandboxMachine) => s.project || `box-${s.id.slice(0, 6)}`;
|
||||
|
||||
/** The project a first cloud machine takes; the ones after it count up. */
|
||||
const PROJECT = 'tabs';
|
||||
/** What a first cloud machine of each class is called; the ones after it count
|
||||
* up. Two names because the project keys the DISK and the server keeps one
|
||||
* live sandbox per project — so a desktop that borrowed the shell machines'
|
||||
* name would be the same box's second attempt rather than a second box. */
|
||||
const PROJECT: Record<Class, string> = { dev: 'tabs', desktop: 'desk' };
|
||||
|
||||
/** The two machines tabs starts. `dev` is the one you shell into — a toolchain,
|
||||
* a home, and a disk that outlives the lease; `desktop` is that plus an X
|
||||
* server, a window manager and a VNC server, so it has a screen. Neither is
|
||||
* `exec`, the code interpreter's scratch pod, which tabs never opens. */
|
||||
export type Class = 'dev' | 'desktop';
|
||||
|
||||
/**
|
||||
* Start one, and answer it once it is RUNNING.
|
||||
*
|
||||
* `class: 'dev'` is the machine you shell into — a toolchain, a home, and a disk
|
||||
* that outlives the lease — as against `exec`, which is the code interpreter's
|
||||
* scratch pod. A dev sandbox is named by its project, and the project is what
|
||||
* the disk is keyed on: one live sandbox per project is the server's rule, so
|
||||
* the name is picked against what is already live rather than made up. That is
|
||||
* what makes a second press a second machine instead of a second attempt at the
|
||||
* first one.
|
||||
* A sandbox is named by its project, and the project is what the disk is keyed
|
||||
* on: one live sandbox per project is the server's rule, so the name is picked
|
||||
* against what is already live rather than made up. That is what makes a second
|
||||
* press a second machine instead of a second attempt at the first one.
|
||||
*
|
||||
* The lease is the server's to set. Tabs has no opinion about how long a machine
|
||||
* should live, and a `ttlSec` here would be a second answer to drift from the one
|
||||
* in the class table.
|
||||
*/
|
||||
export const createSandbox = async (token: string): Promise<SandboxMachine> => {
|
||||
export const createSandbox = async (token: string, kind: Class = 'dev'): Promise<SandboxMachine> => {
|
||||
const taken = new Set((await sandboxes(token)).map(machineName));
|
||||
let project = PROJECT;
|
||||
for (let n = 2; taken.has(project); n++) project = `${PROJECT}-${n}`;
|
||||
const first = PROJECT[kind];
|
||||
let project = first;
|
||||
for (let n = 2; taken.has(project); n++) project = `${first}-${n}`;
|
||||
const res = await fetch(`${API}/v1/sandboxes`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ class: 'dev', project }),
|
||||
body: JSON.stringify({ class: kind, project }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
// Starting a machine fails for reasons a person can act on — an image that
|
||||
@@ -113,23 +123,36 @@ export const createSandbox = async (token: string): Promise<SandboxMachine> => {
|
||||
return (await res.json()) as SandboxMachine;
|
||||
};
|
||||
|
||||
/** Mint the single-use ticket for a sandbox's terminal. Spent on first use. */
|
||||
export const terminalTicket = async (token: string, id: string): Promise<string> => {
|
||||
const res = await fetch(`${API}/v1/sandboxes/${encodeURIComponent(id)}/terminal/ticket`, {
|
||||
/** The two ways to look at a machine: its shell, or its screen. Same sandbox,
|
||||
* same credential, two pages — see cloud's apps/sandbox. */
|
||||
export type Door = 'terminal' | 'screen';
|
||||
|
||||
/**
|
||||
* Mint a single-use ticket and answer the page to frame.
|
||||
*
|
||||
* The URL is the SERVER'S, not composed here: the mint answers the path its own
|
||||
* ticket opens, so a client cannot spell a door's address wrong or send a
|
||||
* ticket somewhere it does not work. All this adds is the host it was already
|
||||
* talking to, and `arg` — the tmux session a terminal attaches to, which a
|
||||
* screen has no use for because a machine has one display.
|
||||
*
|
||||
* The ticket is spent by the first load and lasts thirty seconds, so this is
|
||||
* called once per pane per connection, never per render. The bearer never
|
||||
* leaves this module: what the workspace holds is the minted URL.
|
||||
*/
|
||||
export const frameUrl = async (
|
||||
token: string,
|
||||
id: string,
|
||||
door: Door,
|
||||
arg?: string,
|
||||
): Promise<string> => {
|
||||
const res = await fetch(`${API}/v1/sandboxes/${encodeURIComponent(id)}/${door}/ticket`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`terminal ticket → ${res.status}`);
|
||||
const body = (await res.json()) as { ticket?: string };
|
||||
if (!body.ticket) throw new Error('terminal ticket → empty answer');
|
||||
return body.ticket;
|
||||
if (!res.ok) throw new Error(`${door} ticket → ${res.status}`);
|
||||
const body = (await res.json()) as { url?: string };
|
||||
if (!body.url) throw new Error(`${door} ticket → empty answer`);
|
||||
return `${API}${body.url}` + (arg ? `&arg=${encodeURIComponent(arg)}` : '');
|
||||
};
|
||||
|
||||
/** The framed terminal's address — ticket in the URL, bearer never. */
|
||||
export function sandboxTerminal(id: string, ticket: string, name: string): string {
|
||||
return (
|
||||
`${API}/v1/sandboxes/${encodeURIComponent(id)}/terminal` +
|
||||
`?ticket=${encodeURIComponent(ticket)}&arg=${encodeURIComponent(name)}`
|
||||
);
|
||||
}
|
||||
|
||||
+53
-13
@@ -28,12 +28,44 @@ export interface Shell {
|
||||
|
||||
export type Binding =
|
||||
| { kind: 'shell'; shell: Shell }
|
||||
/** The machine's SCREEN — its X display, as pixels. No name, because a
|
||||
* machine has one display where it has as many shells as you ask for. */
|
||||
| { kind: 'screen'; machine: string }
|
||||
/** Split made, machine not chosen yet. */
|
||||
| { kind: 'empty' }
|
||||
/** The machine went away. The pane STAYS, because tmux is still holding the
|
||||
* session and the layout is the reader's, not a function of someone's uptime. */
|
||||
| { kind: 'gone'; shell: Shell };
|
||||
|
||||
/** What a pane calls itself: the machine, then which of its faces this is.
|
||||
* One function, so the header, the frame's title and the phone's pager cannot
|
||||
* disagree about what a person is looking at. */
|
||||
export function label(b: Binding): string {
|
||||
switch (b.kind) {
|
||||
case 'shell':
|
||||
return `${b.shell.machine} · ${b.shell.name}`;
|
||||
case 'gone':
|
||||
return `${b.shell.machine} · ${b.shell.name}`;
|
||||
case 'screen':
|
||||
return `${b.machine} · desktop`;
|
||||
default:
|
||||
return 'New shell';
|
||||
}
|
||||
}
|
||||
|
||||
/** The machine a pane is bound to, whichever way it is looking at it. */
|
||||
export function machineOf(b: Binding): string | null {
|
||||
switch (b.kind) {
|
||||
case 'shell':
|
||||
case 'gone':
|
||||
return b.shell.machine;
|
||||
case 'screen':
|
||||
return b.machine;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The default tmux session a machine's first pane opens — matches the CLI's. */
|
||||
export const DEFAULT_SHELL = 'hanzo';
|
||||
|
||||
@@ -89,12 +121,20 @@ export const DOT: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The terminal readiness contract — BOTH halves, in one place.
|
||||
* The readiness contract — BOTH halves, in one place.
|
||||
*
|
||||
* `TERM_READY` is the `source` our terminal page posts to its parent once xterm
|
||||
* has opened (hanzo/cli `assets/term/client.js`). It lives here rather than in
|
||||
* the component because it is a wire value shared with another repository: one
|
||||
* side changing it silently is a workspace where every pane looks dead.
|
||||
* `READY` are the `source` values our framed pages post to their parent once
|
||||
* they are up: the terminal's when xterm has opened (hanzo/cli
|
||||
* `assets/term/client.js`, cloud `apps/sandbox/term/page.html`), the screen's
|
||||
* when noVNC has connected (cloud `apps/sandbox/screen/page.html`). They live
|
||||
* here rather than in the component because they are wire values shared with
|
||||
* another repository: one side changing one silently is a workspace where every
|
||||
* pane of that kind looks dead.
|
||||
*
|
||||
* Two names and one meaning, on purpose. Each page says WHAT it is, because
|
||||
* that costs nothing and a page that lied about itself would be worse; what a
|
||||
* parent asks is only whether the thing it framed came up, so the question has
|
||||
* one answer for both.
|
||||
*
|
||||
* The rule this enables is small and the reason for it is not. A parent cannot
|
||||
* tell a healthy cross-origin frame from a refused one — the browser substitutes
|
||||
@@ -103,19 +143,19 @@ export const DOT: Record<string, string> = {
|
||||
* gate, a `frame-ancestors` refusal, a dead tunnel, an offline machine) collapses
|
||||
* to the same silence and the same answer.
|
||||
*/
|
||||
export const TERM_READY = 'hanzo-term';
|
||||
export const READY = ['hanzo-term', 'hanzo-screen'];
|
||||
|
||||
/** How long a pane waits to hear from its terminal before offering the way out.
|
||||
* Long enough that a rescue never flashes over a terminal that is merely still
|
||||
* connecting, which would be its own defect. */
|
||||
export const TERM_DEADLINE = 6000;
|
||||
/** How long a pane waits to hear from what it framed before offering the way
|
||||
* out. Long enough that a rescue never flashes over a terminal that is merely
|
||||
* still connecting, which would be its own defect. */
|
||||
export const DEADLINE = 6000;
|
||||
|
||||
/** Whether a posted message is our terminal announcing itself. */
|
||||
export function isTermReady(data: unknown): boolean {
|
||||
/** Whether a posted message is one of our pages announcing itself. */
|
||||
export function isReady(data: unknown): boolean {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
(data as { source?: unknown }).source === TERM_READY
|
||||
READY.includes((data as { source?: unknown }).source as string)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hanzo/tabs",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hanzo/tabs",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hanzo/design": "^0.4.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/tabs",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"description": "Keep tabs on your agents. A browser terminal workspace for machines you have linked.",
|
||||
|
||||
+39
-12
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
DEADLINE,
|
||||
DEFAULT_SHELL,
|
||||
DOT,
|
||||
TERM_DEADLINE,
|
||||
TERM_READY,
|
||||
isTermReady,
|
||||
READY,
|
||||
isReady,
|
||||
label,
|
||||
machineOf,
|
||||
mintName,
|
||||
rescued,
|
||||
safeName,
|
||||
@@ -81,15 +83,18 @@ describe('a machine’s next shell has a name nobody has to invent', () => {
|
||||
* blocked terminal read as fine, and the user got an opaque black rectangle with
|
||||
* the way out hidden underneath it.
|
||||
*/
|
||||
describe('a terminal announces itself, and silence is the rescue', () => {
|
||||
it('recognises our terminal and nothing else', () => {
|
||||
expect(isTermReady({ source: TERM_READY, ready: true })).toBe(true);
|
||||
describe('a framed page announces itself, and silence is the rescue', () => {
|
||||
it('recognises our pages and nothing else', () => {
|
||||
// Both of them: a terminal and a screen fail the same way and are proved
|
||||
// the same way, so one predicate answers for both.
|
||||
for (const source of READY) expect(isReady({ source, ready: true })).toBe(true);
|
||||
expect(READY).toEqual(['hanzo-term', 'hanzo-screen']);
|
||||
// Everything a hostile or unrelated frame might post.
|
||||
expect(isTermReady({ source: 'other-app' })).toBe(false);
|
||||
expect(isTermReady({ ready: true })).toBe(false);
|
||||
expect(isTermReady('hanzo-term')).toBe(false);
|
||||
expect(isTermReady(null)).toBe(false);
|
||||
expect(isTermReady(undefined)).toBe(false);
|
||||
expect(isReady({ source: 'other-app' })).toBe(false);
|
||||
expect(isReady({ ready: true })).toBe(false);
|
||||
expect(isReady('hanzo-term')).toBe(false);
|
||||
expect(isReady(null)).toBe(false);
|
||||
expect(isReady(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a pane alone until its terminal has had time to boot', () => {
|
||||
@@ -110,6 +115,28 @@ describe('a terminal announces itself, and silence is the rescue', () => {
|
||||
});
|
||||
|
||||
it('waits long enough that a slow connect is not called dead', () => {
|
||||
expect(TERM_DEADLINE).toBeGreaterThanOrEqual(3000);
|
||||
expect(DEADLINE).toBeGreaterThanOrEqual(3000);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A pane shows a machine, one of two ways: its shell or its screen. Both are
|
||||
* bound to a machine and neither is a special case of the other — which is the
|
||||
* whole reason the binding says which it is instead of the URL implying it.
|
||||
*/
|
||||
describe('a pane bound to a screen is still bound to a machine', () => {
|
||||
it('names the machine whichever face it is showing', () => {
|
||||
expect(machineOf({ kind: 'shell', shell: { machine: 'desk', name: 'hanzo' } })).toBe('desk');
|
||||
expect(machineOf({ kind: 'screen', machine: 'desk' })).toBe('desk');
|
||||
expect(machineOf({ kind: 'gone', shell: { machine: 'desk', name: 'hanzo' } })).toBe('desk');
|
||||
// A split with nothing chosen yet is bound to nothing, and saying so is what
|
||||
// stops a mint being attempted for it.
|
||||
expect(machineOf({ kind: 'empty' })).toBeNull();
|
||||
});
|
||||
|
||||
it('says what it is showing, so the header and the frame agree', () => {
|
||||
expect(label({ kind: 'shell', shell: { machine: 'desk', name: 'build' } })).toBe('desk · build');
|
||||
expect(label({ kind: 'screen', machine: 'desk' })).toBe('desk · desktop');
|
||||
expect(label({ kind: 'empty' })).toBe('New shell');
|
||||
});
|
||||
});
|
||||
|
||||
+62
-13
@@ -1,29 +1,58 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { API, createSandbox, machineName, sandboxTerminal } from '@/lib/api';
|
||||
import { API, createSandbox, frameUrl, machineName } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* A sandbox joins the workspace as a machine whose URL is MINTED, not
|
||||
* published — the one terminal contract every Hanzo surface frames
|
||||
* (/v1/sandboxes/:id/terminal, single-use thirty-second ticket). These pin the
|
||||
* two properties that would fail silently: the URL's only credential is the
|
||||
* ticket, and a pane never mints twice for one bind.
|
||||
* A sandbox joins the workspace as a machine whose URLs are MINTED, not
|
||||
* published — the one contract every Hanzo surface frames (a single-use
|
||||
* thirty-second ticket, and the page the mint names). These pin the properties
|
||||
* that would fail silently: the URL's only credential is the ticket, the door
|
||||
* asked for is the door opened, and a pane never mints twice for one bind.
|
||||
*/
|
||||
describe('the framed terminal URL', () => {
|
||||
it('carries the ticket and the shell name, both encoded, and nothing else', () => {
|
||||
const u = new URL(sandboxTerminal('box 1/x', 'tk+&=', 'build'));
|
||||
describe('the framed URL', () => {
|
||||
const mint = (url: string, ok = true) => {
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
global.fetch = (async (u: string, init?: RequestInit) => {
|
||||
calls.push({ url: String(u), init });
|
||||
return { ok, status: ok ? 201 : 409, json: async () => ({ url }) };
|
||||
}) as unknown as typeof fetch;
|
||||
return calls;
|
||||
};
|
||||
|
||||
it('asks the door it wants and frames the address that door answered', async () => {
|
||||
const calls = mint('/v1/sandboxes/box%201/terminal?ticket=tk%2B');
|
||||
const got = await frameUrl('bearer', 'box 1', 'terminal', 'build');
|
||||
expect(calls[0]!.url).toBe(`${API}/v1/sandboxes/box%201/terminal/ticket`);
|
||||
expect(calls[0]!.init!.method).toBe('POST');
|
||||
const u = new URL(got);
|
||||
expect(u.origin).toBe(new URL(API).origin);
|
||||
expect(u.pathname).toBe('/v1/sandboxes/box%201%2Fx/terminal');
|
||||
expect(u.searchParams.get('ticket')).toBe('tk+&=');
|
||||
expect(u.pathname).toBe('/v1/sandboxes/box%201/terminal');
|
||||
expect(u.searchParams.get('ticket')).toBe('tk+');
|
||||
expect(u.searchParams.get('arg')).toBe('build');
|
||||
expect([...u.searchParams.keys()].sort()).toEqual(['arg', 'ticket']);
|
||||
});
|
||||
|
||||
it('never carries a bearer — the ticket IS the credential', () => {
|
||||
const u = sandboxTerminal('b', 't', 's');
|
||||
it('mints a SCREEN at the screen door, and gives it no shell name to attach to', async () => {
|
||||
// A machine has one display. An `arg` here would be a tmux session name sent
|
||||
// to something that has no tmux.
|
||||
const calls = mint('/v1/sandboxes/b/screen?ticket=tk');
|
||||
const got = await frameUrl('bearer', 'b', 'screen');
|
||||
expect(calls[0]!.url).toBe(`${API}/v1/sandboxes/b/screen/ticket`);
|
||||
expect(new URL(got).pathname).toBe('/v1/sandboxes/b/screen');
|
||||
expect([...new URL(got).searchParams.keys()]).toEqual(['ticket']);
|
||||
});
|
||||
|
||||
it('never carries a bearer — the ticket IS the credential', async () => {
|
||||
mint('/v1/sandboxes/b/terminal?ticket=tk');
|
||||
const u = await frameUrl('a-real-looking-bearer-token', 'b', 'terminal', 's');
|
||||
expect(u).not.toMatch(/token|bearer|authorization/i);
|
||||
});
|
||||
|
||||
it('refuses to frame anything when the mint refuses', async () => {
|
||||
mint('/v1/sandboxes/b/screen?ticket=tk', false);
|
||||
await expect(frameUrl('t', 'b', 'screen')).rejects.toThrow('screen ticket → 409');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the workspace mints once per pane', () => {
|
||||
@@ -36,6 +65,13 @@ describe('the workspace mints once per pane', () => {
|
||||
expect(src).toContain('minting.current.has(id)');
|
||||
});
|
||||
|
||||
it('a screen is always minted, never derived from a published tunnel', () => {
|
||||
// `hanzo link` publishes a terminal and nothing else, so a screen has no
|
||||
// base to derive from. Deriving one would frame a terminal and call it a
|
||||
// desktop.
|
||||
expect(src).toContain("host?.base && b.kind === 'shell'");
|
||||
});
|
||||
|
||||
it('reconnect forgets the spent URL AND the readiness history', () => {
|
||||
expect(src).toContain('setMinted(drop)');
|
||||
expect(src).toContain('setAlive(drop)');
|
||||
@@ -88,6 +124,19 @@ describe('a new cloud machine', () => {
|
||||
expect(JSON.parse(String(post.init!.body))).toEqual({ class: 'dev', project: 'tabs' });
|
||||
});
|
||||
|
||||
it('asks for a desktop under its OWN name — the project is what the disk is keyed on', async () => {
|
||||
// Borrowing the shell machines' project would be the same box's second
|
||||
// attempt (one live sandbox per project), not a second box.
|
||||
answer([{ id: 'b1', status: 'running', project: 'tabs' }], {
|
||||
id: 'b2',
|
||||
status: 'running',
|
||||
project: 'desk',
|
||||
});
|
||||
await createSandbox('t', 'desktop');
|
||||
const post = calls.find((c) => c.init?.method === 'POST')!;
|
||||
expect(JSON.parse(String(post.init!.body))).toEqual({ class: 'desktop', project: 'desk' });
|
||||
});
|
||||
|
||||
it('counts past the boxes already live, so pressing twice makes two', async () => {
|
||||
answer(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user