Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review). typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage thresholds (80%) enforced in vitest.config.ts. Critical: - multi-device approval race: release held approval only when the last client detaches (closing one mirror no longer cancels another's prompt) - unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS), enforced in manager via the existing M4 exit(-1) path - signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close() - terminal-session initialInput timer tracked + cleared on dispose - tabs.addEntry null-as-cast type hole removed (build session before entry) Should-fix: - security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id] - history.ts converted to fs/promises (async /sessions handler) - removed dead clientDims map + blur protocol message end-to-end - per-connection WS message rate limit (Config.maxMsgsPerSec) - /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7) Tests: - new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites - extended history/config/manager/integration coverage incl. regressions Hygiene: - parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation - log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped - operational constants moved into Config - extracted public/preview-grid.ts (DRY launcher/manage) - doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
123 lines
3.5 KiB
TypeScript
123 lines
3.5 KiB
TypeScript
/**
|
||
* public/launcher.ts — the home "session chooser" shown when no tab is open.
|
||
*
|
||
* Opening the app no longer auto-creates or auto-restores tabs. Instead this
|
||
* start screen lists the host's running sessions as live preview thumbnails
|
||
* (read-only xterm, same as the manage page) so the user picks which to open —
|
||
* or starts a new one. Sessions persist server-side, so opening one replays its
|
||
* full scrollback. The thumbnail card + preview plumbing is shared with the
|
||
* manage page via public/preview-grid.ts (DRY).
|
||
*/
|
||
|
||
import type { LiveSessionInfo } from '../src/types.js'
|
||
import {
|
||
el,
|
||
relTime,
|
||
makePreviewCard,
|
||
updatePreviewCard,
|
||
loadPreviewInto,
|
||
fetchLiveSessions,
|
||
type PreviewCard,
|
||
} from './preview-grid.js'
|
||
|
||
export interface LauncherHooks {
|
||
onOpen: (id: string) => void
|
||
onNew: () => void
|
||
}
|
||
|
||
const THUMB_W = 320
|
||
const THUMB_MAX_H = 200
|
||
const REFRESH_MS = 5000
|
||
|
||
export interface Launcher {
|
||
setVisible(v: boolean): void
|
||
refresh(): void
|
||
}
|
||
|
||
export function mountLauncher(host: HTMLElement, hooks: LauncherHooks): Launcher {
|
||
const root = el('div', 'launcher')
|
||
root.style.display = 'none'
|
||
host.appendChild(root)
|
||
|
||
const head = el('div', 'launcher-head')
|
||
head.append(el('div', 'launcher-title', 'Your sessions'))
|
||
const sub = el('div', 'launcher-sub', '')
|
||
const actions = el('div', 'launcher-actions')
|
||
const newBtn = el('button', 'launcher-new', '+ New session')
|
||
newBtn.addEventListener('click', () => hooks.onNew())
|
||
const manage = el('a', 'mg-btn', '🗂 Manage') as HTMLAnchorElement
|
||
manage.href = '/manage.html'
|
||
actions.append(newBtn, manage)
|
||
head.append(sub, actions)
|
||
root.append(head)
|
||
|
||
const grid = el('div', 'mg-grid')
|
||
root.append(grid)
|
||
|
||
const cards = new Map<string, PreviewCard>()
|
||
let timer: ReturnType<typeof setInterval> | null = null
|
||
|
||
function makeCard(s: LiveSessionInfo): PreviewCard {
|
||
return makePreviewCard(s, { onOpen: (id) => hooks.onOpen(id) })
|
||
}
|
||
|
||
async function refresh(): Promise<void> {
|
||
const sessions = await fetchLiveSessions()
|
||
|
||
sub.textContent = sessions.length
|
||
? `${sessions.length} running on this host — pick one to open`
|
||
: 'No sessions running yet'
|
||
|
||
const seen = new Set<string>()
|
||
for (const s of sessions) {
|
||
seen.add(s.id)
|
||
let card = cards.get(s.id)
|
||
if (!card) {
|
||
card = makeCard(s)
|
||
cards.set(s.id, card)
|
||
grid.append(card.el)
|
||
}
|
||
updatePreviewCard(card, s)
|
||
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old`
|
||
void loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
|
||
}
|
||
for (const [id, card] of cards) {
|
||
if (!seen.has(id)) {
|
||
card.term.dispose()
|
||
card.el.remove()
|
||
cards.delete(id)
|
||
}
|
||
}
|
||
|
||
grid.querySelector('.mg-empty')?.remove()
|
||
if (sessions.length === 0) {
|
||
grid.append(el('div', 'mg-empty', 'No running sessions. Click “New session” to start one.'))
|
||
}
|
||
}
|
||
|
||
return {
|
||
setVisible(v: boolean): void {
|
||
root.style.display = v ? 'block' : 'none'
|
||
if (v) {
|
||
void refresh()
|
||
if (timer === null) {
|
||
timer = setInterval(() => {
|
||
if (root.style.display !== 'none') void refresh()
|
||
}, REFRESH_MS)
|
||
}
|
||
} else {
|
||
if (timer !== null) {
|
||
clearInterval(timer)
|
||
timer = null
|
||
}
|
||
for (const card of cards.values()) card.term.dispose()
|
||
cards.clear()
|
||
grid.replaceChildren()
|
||
}
|
||
},
|
||
refresh(): void {
|
||
void refresh()
|
||
},
|
||
}
|
||
}
|