Files
web-terminal/public/launcher.ts
Yaojia Wang d22dcd24f7 fix: address review report across security, architecture, quality, tests
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
2026-06-20 18:27:45 +02:00

123 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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()
},
}
}