diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e9fc513..b8e93ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,6 +65,17 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Run unit tests and typecheck + run: | + bun test + bun run typecheck + + - name: Verify Electron session persistence + if: matrix.os != 'windows-latest' + run: | + bun run build + bun run verify:session-persistence:built + - name: Build and package (${{ matrix.dist_script }}) run: bun run ${{ matrix.dist_script }} env: diff --git a/README.md b/README.md index befd68b..dea8daf 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,19 @@ A normal web page or mobile browser cannot directly spawn a local macOS shell. F Do not expose the PTY WebSocket server to an untrusted network. It gives clients shell access on the machine where the server runs. +## Current persistence model + +On the Electron desktop runtime, Baton now keeps PTYs in a detached local session host instead of inside Electron main. That means: + +- switching workspaces keeps the same live shell session; +- quitting and reopening Baton reattaches to the same live shell session on the same machine/user account; +- closing a terminal window still kills that specific session; +- deleting a workspace still kills the sessions owned by that workspace. + +Workspace/layout metadata is now persisted to an app-owned JSON store under Electron `userData` and mirrored into renderer `localStorage` as a cache for fast boot. The web/mobile WebSocket backend still behaves differently: it does not yet support attach/reattach of existing sessions across browser reconnects. + +The design rationale and future hardening path are documented in `docs/adrs/0002-terminal-session-persistence.md`. + ## Prerequisites - Bun 1.3+ recommended. @@ -81,7 +94,7 @@ The packaged artifacts are written to `release/`. ## Continuous builds on GitHub Actions -The workflow in `.github/workflows/build.yml` builds the app for macOS, Windows, and Linux on every push to `main` (and on pull requests targeting `main`). Each job uploads its installers as a workflow artifact (`baton-macos`, `baton-windows`, `baton-linux`) which you can download from the Actions run summary. The builds are unsigned; see "Notes for production hardening" below for signing and notarization. +The workflow in `.github/workflows/build.yml` builds the app for macOS, Windows, and Linux on every push to `main` (and on pull requests targeting `main`). It now also runs `bun test`, `bun run typecheck`, and, on macOS/Linux, the Electron session-persistence verification before packaging. Each job uploads its installers as a workflow artifact (`baton-macos`, `baton-windows`, `baton-linux`) which you can download from the Actions run summary. The builds are unsigned; see "Notes for production hardening" below for signing and notarization. ## Run the web/mobile renderer with the demo terminal @@ -126,16 +139,17 @@ Again: only do this on a trusted network. ## Project structure ```text -src/main/ Electron main process; owns real PTYs +src/main/ Electron main process, detached session host, and JSON stores src/preload/ Safe bridge between renderer and Electron IPC src/renderer/ React canvas UI; also builds for web/mobile -src/shared/ Shared terminal protocol types +src/shared/ Shared terminal/session protocol types server/pty-websocket.ts Optional WebSocket PTY bridge for web/mobile +scripts/verify-session-persistence.mjs Built-runtime verification for Electron session reattach ``` ## Notes for production hardening - Add application signing and notarization for macOS distribution. - Replace the demo PTY WebSocket server with an authenticated, audited backend if web terminals are needed outside localhost. -- Consider workspace persistence in SQLite or a local JSON store if layouts should survive localStorage clearing. -- Add terminal session restoration if you need buffer/process persistence across app restarts. +- Harden the detached session host further (crash recovery, TTL/GC, optional disk-backed scrollback, and WebSocket attach support); see `docs/adrs/0002-terminal-session-persistence.md`. +- Consider making the WebSocket backend share the same durable session model if browser reconnect reattach becomes a product requirement. diff --git a/docs/adrs/0002-terminal-session-persistence.md b/docs/adrs/0002-terminal-session-persistence.md new file mode 100644 index 0000000..3e96279 --- /dev/null +++ b/docs/adrs/0002-terminal-session-persistence.md @@ -0,0 +1,291 @@ +# ADR 0002 — Terminal session persistence and reattachment + +- **Status**: Accepted +- **Date**: 2026-04-21 +- **Deciders**: Baton maintainers + +## Implementation status + +Implemented for the Electron desktop runtime: + +- PTYs now live in a detached local session host. +- Terminal windows persist their `terminalId` and reattach on restore. +- Quitting/reopening Baton reattaches to the same live shell session. +- Workspace/layout metadata is saved in an app-owned JSON store under `userData` and mirrored to renderer `localStorage` as a cache. +- CI/automation has a built-runtime verification script that proves PID-stable detach/reattach behavior. + +Still deferred: + +- WebSocket attach/reattach support for the web/mobile backend +- crash-recovery hardening, GC/TTL, and optional disk-backed scrollback + +## Context + +Baton already preserves a useful subset of state today, but only inside a +single app lifetime: + +- `src/renderer/src/persistence.ts` stores workspace layout in renderer + `localStorage`. +- That same file intentionally strips `terminalId` and rewrites terminal + status to `'starting'` when state is saved or loaded, so a relaunch + respawns fresh shells instead of reconnecting to existing ones. +- `src/main/index.ts` keeps live `node-pty` instances in an in-process + `Map` and kills them on `before-quit` and `window-all-closed`. +- `server/pty-websocket.ts` does the same per WebSocket connection and + kills terminals when the socket closes. +- `BufferedTerminalClient` keeps a scrollback buffer in memory, which is + enough to make workspace switching feel stateful while the renderer and + PTY owner are both still alive. + +That means the current product can preserve **layout** across reloads and +can preserve **live shell state** only while the owning process stays up. +It cannot satisfy the stronger expectation that a user can quit Baton, +relaunch it, and continue the exact same shell processes. + +A tempting shortcut is to "spawn terminals outside the main process and +reconnect by PID later". That is not sufficient. A PID alone does not let +Baton recover the PTY master, the controlling terminal, the buffered +output, or the resize/data plumbing. In the common case, when the PTY +owner exits, the shell receives SIGHUP / console teardown and exits as +well. Even if the child process survives briefly, `node-pty` does not +provide a supported "reattach to existing PTY by PID" flow. + +We therefore need to separate two different product promises: + +1. **Workspace persistence** — canvas, terminal window geometry, active + workspace, settings, and the mapping from each window to its backing + session. +2. **Live shell persistence** — the actual PTY-backed process tree, + scrollback, and the ability to detach the UI and later reattach. + +We also need to be explicit about scope: + +- **Realistic and achievable**: switch workspaces, close Baton, reopen + Baton, and resume the same live terminal sessions on the same machine + and same user account. +- **Not generally realistic for arbitrary shells**: survive OS reboot, + logout, or daemon crash with zero loss. That requires either the OS to + keep the process tree alive or a shell/session multiplexer such as + `tmux`/`zellij` on Unix-like hosts. + +## Decision + +### 1. Introduce a long-lived Baton Session Host + +Baton should own PTYs in a **separate local background process** rather +than inside the Electron main process or a per-WebSocket-connection +handler. + +This Baton Session Host is the sole owner of: + +- `node-pty` instances +- the terminal/session registry +- scrollback ring buffers +- session lifecycle and garbage collection +- local IPC for attach / detach / write / resize / close + +Electron becomes a client of the host, not the PTY owner. + +This is the minimum architecture that makes "close app, reopen app, +resume same shell" reliable. Merely detaching the child by PID is not. +The PTY owner itself must outlive the UI. + +### 2. Persist stable workspace + session identities + +Each terminal window needs two separate identities: + +- `terminalWindowId`: the canvas/window object +- `sessionId`: the live terminal session owned by the Session Host + +Each workspace persists its terminal windows and references the +`sessionId` for each retained shell. On relaunch, Baton restores the +workspace graph first, then asks the Session Host to reattach each saved +`sessionId`. + +Explicit close semantics become: + +- **Close terminal window**: kill that `sessionId`, remove it from the + workspace, delete its retained metadata. +- **Delete workspace**: kill all sessions attached to that workspace and + remove their windows. +- **Quit/close Baton UI**: detach clients only; do **not** kill sessions. + +### 3. Use an app-owned durable state store, not renderer localStorage + +Workspace state should move out of renderer `localStorage` into an +app-owned store under `app.getPath('userData')`. + +For Baton's current scale, the recommended shape is: + +- one versioned manifest file for workspace/session metadata, written + atomically (`tmp` + rename) +- optional per-session transcript/ring-buffer files if disk-backed + scrollback is desired later + +A single-writer daemon/process keeps this simple and reliable without +immediately forcing SQLite into the packaging story. If future needs add +multi-process readers, query-heavy views, or large session indexes, +SQLite remains a valid upgrade path. + +### 4. The Session Host speaks local-only IPC with stable session ids + +Use a local transport only: + +- Unix domain socket on macOS/Linux +- named pipe on Windows + +Requirements: + +- same-user-only access +- ephemeral auth token or OS-level path permissions +- versioned protocol +- idempotent `attach`, `detach`, `close`, and `listSessions` +- a session record that includes at least: + - `sessionId` + - `workspaceId` + - shell label / shell id + - launch cwd + - last-known cwd (best effort) + - PID + - exit status + - timestamps (`createdAt`, `lastAttachedAt`, `lastOutputAt`) + +Recommended RPC surface: + +- `createSession` +- `attachSession` +- `detachSession` +- `writeSession` +- `resizeSession` +- `closeSession` +- `listSessions` +- `getSessionBuffer` + +### 5. Retain enough scrollback to make reattachment feel native + +The Session Host should keep an in-memory ring buffer per session and +replay it to newly attached renderer clients before switching them to the +live data stream. + +This keeps the existing "switch workspace and see prior output" UX, but +moves it to the durable session owner instead of the renderer. + +Disk-backed scrollback is optional in phase 1. It improves recovery from +Session Host crashes, but it is not required for app-close/app-reopen +reattachment. + +### 6. Track cwd via shell integration, not process guessing alone + +If Baton wants titles and workspace summaries to reflect the shell's +current directory after reattach, the reliable path is lightweight shell +integration that emits cwd changes (for example via OSC 7 / prompt hooks) +rather than trying to infer cwd from arbitrary child PIDs. + +Fallback order: + +1. shell-reported cwd +2. session launch cwd +3. no cwd shown + +### 7. Offer optional Unix multiplexer support for "maximum survival" + +For macOS/Linux, Baton may optionally launch shells inside `tmux` or +`zellij` when installed. That is **not** the baseline architecture, but +it is the best available answer for users who want sessions to survive +more than a Baton UI restart. + +Why optional, not mandatory: + +- not available by default on Windows +- adds a second layer of lifecycle semantics users must understand +- changes keybindings and shell startup assumptions + +Recommended positioning: + +- **Baseline guarantee**: Baton Session Host survives Baton UI restarts. +- **Enhanced guarantee on Unix**: Baton + `tmux`/`zellij` can survive + some Session Host failures and app upgrades more gracefully. + +## Consequences + +### Positive + +- Baton can genuinely resume the same live shell after app relaunch. +- Workspace switching becomes a detach/reattach operation rather than a + renderer-local illusion. +- Terminal lifecycle semantics become intuitive: only explicit close or + workspace deletion kills the shell. +- The WebSocket bridge and Electron runtime can converge on the same + session model instead of each owning PTYs separately. +- Baton gains a clean seam for future multi-window or remote-control + features. + +### Negative / trade-offs + +- Adds a background process and protocol to maintain. +- Requires careful cleanup of orphaned sessions and stale sockets. +- Windows needs extra lifecycle care so the Session Host is not dragged + down with Electron's process tree or Job Object. +- App upgrades must account for an already-running host using an older + protocol version. +- True persistence across OS reboot is still outside the baseline + guarantee. + +## Alternatives considered + +1. **Keep localStorage + respawn shells on launch.** Rejected. Good + enough for layout restore, not good enough for live-session resume. +2. **Detach shell processes and recover by PID later.** Rejected. The + PTY/control-terminal problem remains unsolved. +3. **Store PTYs in Electron main only.** Rejected. Quitting Electron + still kills the PTY owner. +4. **Always wrap every shell in `tmux`.** Rejected as the baseline. + Strong on Unix, poor cross-platform fit, and too opinionated for the + default UX. +5. **SQLite first for everything.** Deferred. Viable later, but not + necessary for the first durable single-writer architecture. + +## Rollout + +### Phase 1 — make the state model ready + +- Move workspace persistence from renderer `localStorage` to an + app-owned store in `userData`. +- Add `sessionId` to terminal window state. +- Introduce backend APIs around session lifecycle (`create`, `attach`, + `detach`, `close`, `list`) even if they are temporarily satisfied by + the current in-process owner. +- Stop persisting only renderer-local `terminalId` values. + +This phase improves reliability and reduces renderer ownership, but it +still does **not** satisfy full app-close/app-reopen resume by itself. + +### Phase 2 — extract the Session Host + +- Run the PTY owner as a detached Baton background process. +- Switch Electron and the web bridge to that local IPC protocol. +- Reattach saved `sessionId`s on startup. +- Kill sessions only on explicit close/delete or retention-GC. + +This is the phase that satisfies the main product expectation. + +### Phase 3 — harden recovery + +- Add disk-backed scrollback if desired. +- Add shell integration for cwd tracking. +- Add orphan/session TTL and explicit "resume last session" UX. +- Optionally add `tmux` / `zellij` integration on Unix hosts. + +## Acceptance criteria for the eventual implementation + +1. Switching away from a workspace and back reattaches to the exact same + live shell session. +2. Quitting Baton and relaunching Baton reattaches to the exact same + live shell session, with prior scrollback visible. +3. Closing a terminal window kills only that terminal's session. +4. Deleting a workspace kills only sessions owned by that workspace. +5. If a session exited while Baton was closed, the relaunched UI shows it + as exited rather than silently respawning a fresh shell. +6. If the user reboots the machine, Baton restores the layout and can + optionally offer to start fresh shells, but it must not claim that the + old interactive processes were preserved. diff --git a/docs/verification/session-persistence.md b/docs/verification/session-persistence.md new file mode 100644 index 0000000..faae461 --- /dev/null +++ b/docs/verification/session-persistence.md @@ -0,0 +1,190 @@ +# Session persistence — verification plan + +Covers the eventual implementation of ADR 0002 end-to-end: durable +workspace state, detached PTY ownership, app relaunch reattachment, and +explicit session lifecycle semantics. + +Run this after any change that touches: + +- `src/main/index.ts` +- `src/preload/index.ts` +- `src/shared/terminal-types.ts` +- `src/renderer/src/persistence.ts` +- `src/renderer/src/App.tsx` +- `src/renderer/src/components/TerminalWindow.tsx` +- `src/renderer/src/services/terminalClient.ts` +- any future Session Host / local IPC files +- `server/pty-websocket.ts` if it is adapted to the shared session model + +## Preflight: automated gates + +```bash +bun test +bun run typecheck +bun run build +bun run verify:session-persistence +``` + +Expected: tests pass, TypeScript exits 0, the desktop bundles build +cleanly, and the verification script proves that a terminal PID survives +client disconnect + reattach while preserving buffered output. + +--- + +## Scenario 1 — Workspace switch preserves the same live shell + +### Preconditions + +1. Launch Baton. +2. Create workspace A and workspace B. +3. In workspace A, spawn one terminal. +4. In that terminal, run: + +```bash +printf 'pid=%s\n' "$BASHPID" 2>/dev/null || echo "pid=$$" +pwd +sleep 9999 +``` + +5. Note the visible PID and current directory. + +### Steps + +| # | Action | Expected | +|---|--------|----------| +| 1 | Switch to workspace B. | Workspace A disappears from view. | +| 2 | Spawn a different terminal in workspace B. | A new, distinct session appears. | +| 3 | Switch back to workspace A. | The original shell is still present. | +| 4 | Inspect the terminal in workspace A. | The prior scrollback is visible, including the noted PID and cwd. | +| 5 | Interrupt the long-running command in workspace A (`Ctrl+C`). | Control returns to the same shell prompt. | + +### Pass criteria + +Workspace switching reattaches to the same live shell rather than +spawning a fresh one. + +--- + +## Scenario 2 — Quit Baton and relaunch Baton resumes the same shell + +### Preconditions + +1. Start from scenario 1 or create a fresh workspace with one terminal. +2. In the terminal, run a command that proves continuity, for example: + +```bash +echo "marker=$(date +%s)" +export BATON_SESSION_TEST=alive +printf 'pid=%s\n' "$BASHPID" 2>/dev/null || echo "pid=$$" +``` + +3. Note the marker and PID. + +### Steps + +| # | Action | Expected | +|---|--------|----------| +| 1 | Quit Baton completely. | The UI closes. | +| 2 | Relaunch Baton. | The same workspace layout reappears. | +| 3 | Reopen the saved terminal/session. | Prior scrollback is visible immediately or after attach replay. | +| 4 | Run `echo "$BATON_SESSION_TEST"`. | Prints `alive`. | +| 5 | Run the same PID command as before. | The PID matches the one from before quit. | + +### Pass criteria + +A full Baton quit/relaunch preserves the same live shell session. + +--- + +## Scenario 3 — Close terminal kills only that session + +### Preconditions + +1. Launch Baton. +2. In one workspace, spawn terminal A and terminal B. +3. In each terminal, print its PID. + +### Steps + +| # | Action | Expected | +|---|--------|----------| +| 1 | Close terminal A from the window chrome. | Terminal A disappears. | +| 2 | Inspect terminal B. | Terminal B remains alive and interactive. | +| 3 | Quit Baton and relaunch. | Workspace restores with terminal B only. | +| 4 | Reattach terminal B. | Terminal B still has the same PID as before. | + +### Pass criteria + +Explicit close kills only the selected terminal's session. + +--- + +## Scenario 4 — Delete workspace kills only that workspace's sessions + +### Preconditions + +1. Launch Baton. +2. Workspace A contains at least one live terminal. +3. Workspace B contains at least one different live terminal. +4. Record the PIDs in both workspaces. + +### Steps + +| # | Action | Expected | +|---|--------|----------| +| 1 | Delete workspace A. | Workspace A disappears. | +| 2 | Switch to workspace B. | Workspace B still exists. | +| 3 | Inspect the terminal(s) in workspace B. | They remain interactive. | +| 4 | Quit Baton and relaunch. | Workspace B restores; workspace A does not. | +| 5 | Re-check the PID in workspace B. | It matches the value from before workspace A was deleted. | + +### Pass criteria + +Workspace deletion kills only the deleted workspace's sessions. + +--- + +## Scenario 5 — Exited sessions do not silently respawn + +### Preconditions + +1. Launch Baton. +2. Spawn a terminal and note its PID. +3. Exit the shell normally (`exit`). + +### Steps + +| # | Action | Expected | +|---|--------|----------| +| 1 | Observe the terminal before quitting Baton. | It shows exited status. | +| 2 | Quit Baton and relaunch. | Workspace layout restores. | +| 3 | Inspect the same terminal window/session entry. | It still shows exited state. | +| 4 | Compare with a freshly spawned terminal. | Only the fresh spawn gets a new PID/new shell. | + +### Pass criteria + +Exited sessions remain exited on restore; Baton must not silently replace +them with new shells. + +--- + +## Scenario 6 — Machine reboot is handled honestly + +### Preconditions + +1. Launch Baton and create at least one live terminal. +2. Record its PID and print an obvious marker. +3. Reboot the machine. + +### Steps + +| # | Action | Expected | +|---|--------|----------| +| 1 | Relaunch Baton after login. | Workspace layout restores. | +| 2 | Inspect the saved terminal/session entry. | Baton does not claim the old process survived. | +| 3 | If Baton offers resume/restart actions, use them. | A new shell starts explicitly as a fresh session. | + +### Pass criteria + +After reboot, Baton restores layout and communicates that old live +processes are gone unless an external multiplexer preserved them. diff --git a/package.json b/package.json index 2998b94..5b6b59a 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "web": "vite --host 0.0.0.0 --config vite.web.config.ts", "web:terminal": "concurrently -k \"bun run terminal:server\" \"cross-env VITE_TERMINAL_WS_URL=ws://127.0.0.1:8787 vite --host 0.0.0.0 --config vite.web.config.ts\"", "terminal:server": "tsx server/pty-websocket.ts", + "verify:session-persistence": "bun run build && bun scripts/verify-session-persistence.mjs", + "verify:session-persistence:built": "bun scripts/verify-session-persistence.mjs", "typecheck": "tsc --noEmit", "build": "bun run typecheck && electron-vite build", "build:web": "bun run typecheck && vite build --config vite.web.config.ts", diff --git a/scripts/verify-session-persistence.mjs b/scripts/verify-session-persistence.mjs new file mode 100644 index 0000000..48a49e7 --- /dev/null +++ b/scripts/verify-session-persistence.mjs @@ -0,0 +1,258 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { spawn } from 'node:child_process' + +const HOST_START_TIMEOUT_MS = 8_000 +const MESSAGE_TIMEOUT_MS = 8_000 + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function getSessionHostEndpoint(userDataPath) { + const hash = crypto.createHash('sha256').update(userDataPath).digest('hex').slice(0, 16) + + if (process.platform === 'win32') { + return `\\\\.\\pipe\\baton-session-host-${hash}` + } + + const baseDir = process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0 + ? process.env.XDG_RUNTIME_DIR + : os.tmpdir() + + return path.join(baseDir, `baton-session-host-${hash}.sock`) +} + +function buildCommand(shellId, marker) { + if (shellId === 'cmd') return `echo ${marker}\r` + if (shellId === 'pwsh' || shellId === 'powershell') return `Write-Output '${marker}'\r` + return `printf '${marker}\\n'\r` +} + +function send(socket, message) { + socket.write(`${JSON.stringify(message)}\n`) +} + +function createMessageStream(socket) { + let rawBuffer = '' + const queue = [] + const waiters = [] + + const onMessage = (message) => { + for (let index = 0; index < waiters.length; index += 1) { + const waiter = waiters[index] + if (!waiter.predicate(message)) continue + waiters.splice(index, 1) + clearTimeout(waiter.timeout) + waiter.resolve(message) + return + } + queue.push(message) + } + + socket.setEncoding('utf8') + socket.on('data', (chunk) => { + rawBuffer += chunk.toString() + while (true) { + const newlineIndex = rawBuffer.indexOf('\n') + if (newlineIndex === -1) break + const line = rawBuffer.slice(0, newlineIndex).trim() + rawBuffer = rawBuffer.slice(newlineIndex + 1) + if (!line) continue + onMessage(JSON.parse(line)) + } + }) + + return { + waitFor(predicate, timeoutMs = MESSAGE_TIMEOUT_MS) { + const queuedIndex = queue.findIndex(predicate) + if (queuedIndex !== -1) { + return Promise.resolve(queue.splice(queuedIndex, 1)[0]) + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const waiterIndex = waiters.findIndex((waiter) => waiter.resolve === resolve) + if (waiterIndex !== -1) waiters.splice(waiterIndex, 1) + reject(new Error('Timed out waiting for session-host message')) + }, timeoutMs) + + waiters.push({ predicate, resolve, timeout }) + }) + }, + } +} + +async function connectWithRetries(endpoint, timeoutMs = HOST_START_TIMEOUT_MS) { + const deadline = Date.now() + timeoutMs + let lastError = null + + while (Date.now() < deadline) { + try { + const socket = await new Promise((resolve, reject) => { + const connection = net.createConnection(endpoint) + const cleanup = () => { + connection.removeListener('connect', onConnect) + connection.removeListener('error', onError) + } + const onConnect = () => { + cleanup() + resolve(connection) + } + const onError = (error) => { + cleanup() + connection.destroy() + reject(error) + } + connection.once('connect', onConnect) + connection.once('error', onError) + }) + return socket + } catch (error) { + lastError = error + await delay(100) + } + } + + throw lastError ?? new Error('Unable to connect to session host') +} + +async function main() { + const buildEntryPath = path.join(process.cwd(), 'out/main/index.js') + await fs.access(buildEntryPath) + + const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), 'baton-session-verify-')) + const endpoint = getSessionHostEndpoint(userDataPath) + + let stdout = '' + let stderr = '' + const child = spawn(process.execPath, ['x', 'electron', buildEntryPath, '--baton-session-host'], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + BATON_SESSION_HOST_ENDPOINT: endpoint, + BATON_SESSION_HOST_IDLE_EXIT_MS: '1000', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + child.stdout?.setEncoding('utf8') + child.stdout?.on('data', (chunk) => { + stdout += chunk.toString() + }) + child.stderr?.setEncoding('utf8') + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString() + }) + + let firstSocket + let secondSocket + + try { + firstSocket = await connectWithRetries(endpoint) + const firstStream = createMessageStream(firstSocket) + + send(firstSocket, { + type: 'create', + clientId: 'create-1', + cols: 80, + rows: 24, + cwd: userDataPath, + ...(process.platform === 'win32' ? { shellId: 'powershell' } : { shellId: 'sh' }), + }) + + const created = await firstStream.waitFor((message) => message.type === 'created' && message.clientId === 'create-1') + if (!created.pid || typeof created.pid !== 'number') { + throw new Error(`Expected create response to include a numeric pid: ${JSON.stringify(created)}`) + } + + const firstMarker = 'BATON_BEFORE_DETACH' + send(firstSocket, { + type: 'write', + terminalId: created.terminalId, + data: buildCommand(created.shellId, firstMarker), + }) + + const firstData = await firstStream.waitFor( + (message) => message.type === 'data' && message.terminalId === created.terminalId && String(message.data).includes(firstMarker), + ) + if (!String(firstData.data).includes(firstMarker)) { + throw new Error('Did not receive the expected marker before detach') + } + + await new Promise((resolve) => firstSocket.end(resolve)) + + secondSocket = await connectWithRetries(endpoint) + const secondStream = createMessageStream(secondSocket) + + send(secondSocket, { + type: 'attach', + clientId: 'attach-1', + terminalId: created.terminalId, + }) + + const attached = await secondStream.waitFor((message) => message.type === 'attached' && message.clientId === 'attach-1') + if (attached.pid !== created.pid) { + throw new Error(`Expected attach pid ${attached.pid} to match create pid ${created.pid}`) + } + if (attached.status !== 'running') { + throw new Error(`Expected attached session to be running: ${JSON.stringify(attached)}`) + } + if (!String(attached.buffer).includes(firstMarker)) { + throw new Error('Expected attached buffer to replay output from before detach') + } + + const secondMarker = 'BATON_AFTER_ATTACH' + send(secondSocket, { + type: 'write', + terminalId: created.terminalId, + data: buildCommand(created.shellId, secondMarker), + }) + + const secondData = await secondStream.waitFor( + (message) => message.type === 'data' && message.terminalId === created.terminalId && String(message.data).includes(secondMarker), + ) + if (!String(secondData.data).includes(secondMarker)) { + throw new Error('Did not receive the expected marker after reattach') + } + + send(secondSocket, { + type: 'close', + clientId: 'close-1', + terminalId: created.terminalId, + }) + + const closed = await secondStream.waitFor((message) => message.type === 'closed' && message.clientId === 'close-1') + if (!closed.ok) { + throw new Error(`Expected terminal close to succeed: ${JSON.stringify(closed)}`) + } + + await new Promise((resolve) => secondSocket.end(resolve)) + await new Promise((resolve) => child.once('exit', resolve)) + + console.log('Session persistence verification passed') + console.log(`- terminal pid survived detach/reattach: ${created.pid}`) + console.log(`- replay buffer contained: ${firstMarker}`) + console.log(`- live output after reattach contained: ${secondMarker}`) + } catch (error) { + if (firstSocket && !firstSocket.destroyed) firstSocket.destroy() + if (secondSocket && !secondSocket.destroyed) secondSocket.destroy() + child.kill('SIGTERM') + throw new Error([ + error instanceof Error ? error.message : String(error), + stdout ? `\n[session-host stdout]\n${stdout}` : '', + stderr ? `\n[session-host stderr]\n${stderr}` : '', + ].join('')) + } finally { + try { + await fs.rm(userDataPath, { recursive: true, force: true }) + } catch { + // Ignore temp cleanup failures. + } + } +} + +await main() diff --git a/src/main/app-state-store.test.ts b/src/main/app-state-store.test.ts new file mode 100644 index 0000000..4fbee9d --- /dev/null +++ b/src/main/app-state-store.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { createAppStateStore } from './app-state-store' + +describe('createAppStateStore', () => { + let tempDir: string + let filePath: string + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'baton-app-state-')) + filePath = path.join(tempDir, 'app-state.json') + }) + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + test('load returns null when the file is missing', async () => { + const store = createAppStateStore(filePath) + + await expect(store.load()).resolves.toBeNull() + }) + + test('save then load round-trips arbitrary JSON data', async () => { + const store = createAppStateStore(filePath) + const state = { + workspaces: [{ id: 'ws-1', name: 'Main' }], + activeWorkspaceId: 'ws-1', + sidebarCollapsed: true, + themePreference: 'dark', + } + + await store.save(state) + + await expect(store.load()).resolves.toEqual(state) + }) + + test('save creates the parent directory if needed', async () => { + const nestedPath = path.join(tempDir, 'nested', 'state', 'app-state.json') + const store = createAppStateStore(nestedPath) + + await store.save({ hello: 'world' }) + + await expect(fs.readFile(nestedPath, 'utf8')).resolves.toContain('world') + }) + + test('load recovers from invalid JSON by returning null', async () => { + await fs.writeFile(filePath, '{not-json', 'utf8') + const store = createAppStateStore(filePath) + + await expect(store.load()).resolves.toBeNull() + }) + + test('exists reflects whether the file has been written', async () => { + const store = createAppStateStore(filePath) + + await expect(store.exists()).resolves.toBe(false) + await store.save({ ok: true }) + await expect(store.exists()).resolves.toBe(true) + }) + + test('concurrent saves serialize and leave the latest value on disk', async () => { + const store = createAppStateStore(filePath) + + await Promise.all([ + store.save({ seq: 1 }), + store.save({ seq: 2 }), + store.save({ seq: 3 }), + ]) + + await expect(store.load()).resolves.toEqual({ seq: 3 }) + await expect(fs.readFile(filePath, 'utf8')).resolves.toContain('3') + }) +}) diff --git a/src/main/app-state-store.ts b/src/main/app-state-store.ts new file mode 100644 index 0000000..4da5ff4 --- /dev/null +++ b/src/main/app-state-store.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +export interface AppStateStore { + load(): Promise + save(next: unknown): Promise + exists(): Promise +} + +export function createAppStateStore(filePath: string): AppStateStore { + let cacheLoaded = false + let cache: unknown | null = null + let writeSequence = Promise.resolve() + let writeCount = 0 + + async function load(): Promise { + if (cacheLoaded) return cache + + try { + const raw = await fs.readFile(filePath, 'utf8') + cache = JSON.parse(raw) as unknown + } catch { + cache = null + } + + cacheLoaded = true + return cache + } + + async function save(next: unknown): Promise { + cache = next + cacheLoaded = true + + const currentWrite = ++writeCount + writeSequence = writeSequence + .catch(() => undefined) + .then(async () => { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + const tmp = `${filePath}.${process.pid}.${currentWrite}.tmp` + await fs.writeFile(tmp, JSON.stringify(next, null, 2), 'utf8') + await fs.rename(tmp, filePath) + }) + + await writeSequence + } + + async function exists(): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } + } + + return { load, save, exists } +} diff --git a/src/main/electron-main.ts b/src/main/electron-main.ts new file mode 100644 index 0000000..9cbcb3d --- /dev/null +++ b/src/main/electron-main.ts @@ -0,0 +1,262 @@ +import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell } from 'electron' +import path from 'node:path' +import fs from 'node:fs' +import type { + TerminalAttachRequest, + TerminalAttachResponse, + TerminalCloseRequest, + TerminalCreateRequest, + TerminalCreateResponse, + TerminalListShellsResponse, + TerminalResizeRequest, + TerminalWriteRequest, +} from '../shared/terminal-types' +import type { AppPreferences } from '../shared/preferences-types' +import type { ShellDescriptor } from '../shared/shell-registry' +import { detectPreferredShellId } from './shell-resolver' +import { detectShells } from './shell-detection' +import { createPreferencesStore, migratePreferences, type PreferencesStore } from './preferences' +import { createAppStateStore, type AppStateStore } from './app-state-store' +import { SessionHostClient } from './session-host-client' +import { getSessionHostEndpoint } from './session-host-path' + +let mainWindow: BrowserWindow | null = null + +let shellRegistry: ShellDescriptor[] = [] +let preferencesStore: PreferencesStore | null = null +let appStateStore: AppStateStore | null = null +let preferencesWereFreshlyCreated = false +let sessionHostClient: SessionHostClient | null = null + +const isDevelopment = Boolean(process.env.ELECTRON_RENDERER_URL) + +function resolveWindowIconPath(): string | null { + // In a packaged app, extraResources are placed under process.resourcesPath. + // In dev, we fall back to the repo's build/icons/icon.png. + const packaged = path.join(process.resourcesPath, 'icon.png') + if (fs.existsSync(packaged)) return packaged + const dev = path.resolve(__dirname, '../../build/icons/icon.png') + if (fs.existsSync(dev)) return dev + return null +} + +function createWindow(): void { + const iconPath = resolveWindowIconPath() + const icon = iconPath ? nativeImage.createFromPath(iconPath) : undefined + + mainWindow = new BrowserWindow({ + width: 1440, + height: 920, + minWidth: 900, + minHeight: 600, + title: 'Baton', + backgroundColor: '#080b12', + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: 18, y: 18 }, + icon, + webPreferences: { + preload: path.join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }) + + if (isDevelopment && process.platform === 'darwin' && icon && app.dock) { + app.dock.setIcon(icon) + } + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url) + return { action: 'deny' } + }) + + const broadcastFullScreen = (isFullScreen: boolean): void => { + if (!mainWindow || mainWindow.webContents.isDestroyed()) return + mainWindow.webContents.send('window:fullscreen-changed', isFullScreen) + } + mainWindow.on('enter-full-screen', () => broadcastFullScreen(true)) + mainWindow.on('leave-full-screen', () => broadcastFullScreen(false)) + + mainWindow.on('closed', () => { + mainWindow = null + }) + + if (isDevelopment && process.env.ELECTRON_RENDERER_URL) { + void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) + if (process.env.BATON_DEVTOOLS === '1') { + mainWindow.webContents.openDevTools({ mode: 'detach' }) + } + } else { + void mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) + } +} + +function broadcast(channel: string, payload: unknown): void { + for (const window of BrowserWindow.getAllWindows()) { + if (!window.webContents.isDestroyed()) { + window.webContents.send(channel, payload) + } + } +} + +async function resolveEffectiveShellId(request: TerminalCreateRequest): Promise { + if (request.shellId && request.shellId !== 'auto') return request.shellId + + if (preferencesStore) { + const prefs = await preferencesStore.load() + if (prefs.terminal.defaultShellId && prefs.terminal.defaultShellId !== 'auto') { + return prefs.terminal.defaultShellId + } + } + + return 'auto' +} + +async function createTerminal(request: TerminalCreateRequest): Promise { + if (!sessionHostClient) throw new Error('Session host not initialised') + const effectiveId = await resolveEffectiveShellId(request) + return sessionHostClient.create({ + ...request, + ...(effectiveId ? { shellId: effectiveId } : {}), + }) +} + +async function attachTerminal(request: TerminalAttachRequest): Promise { + if (!sessionHostClient) throw new Error('Session host not initialised') + if (!request || typeof request.terminalId !== 'string' || request.terminalId.length === 0) { + throw new Error('A valid terminalId is required') + } + return sessionHostClient.attach(request.terminalId) +} + +export async function runElectronMain(): Promise { + ipcMain.handle('terminal:create', (_event, request: TerminalCreateRequest) => { + return createTerminal(request) + }) + + ipcMain.handle('terminal:attach', (_event, request: TerminalAttachRequest) => { + return attachTerminal(request) + }) + + ipcMain.handle('terminal:list-shells', (): TerminalListShellsResponse => { + const defaultShellId = detectPreferredShellId({ + platform: process.platform, + env: process.env, + registry: shellRegistry, + }) + return { + shells: shellRegistry.map((d) => ({ + id: d.id, + label: d.label, + kind: d.kind, + ...(d.meta?.wslDistro ? { wslDistro: d.meta.wslDistro } : {}), + })), + defaultShellId, + } + }) + + ipcMain.handle('preferences:get', async (): Promise => { + if (!preferencesStore) throw new Error('Preferences not initialised') + return preferencesStore.load() + }) + + ipcMain.handle('preferences:set', async (_event, next: AppPreferences): Promise => { + if (!preferencesStore) throw new Error('Preferences not initialised') + const migrated = migratePreferences(next) + await preferencesStore.save(migrated) + preferencesWereFreshlyCreated = false + return migrated + }) + + ipcMain.handle('preferences:was-freshly-created', (): boolean => preferencesWereFreshlyCreated) + + ipcMain.handle('app-state:get', async (): Promise => { + if (!appStateStore) throw new Error('App state store not initialised') + return appStateStore.load() + }) + + ipcMain.handle('app-state:set', async (_event, next: unknown): Promise => { + if (!appStateStore) throw new Error('App state store not initialised') + await appStateStore.save(next) + return next + }) + + ipcMain.on('terminal:write', (_event, request: TerminalWriteRequest) => { + if (!sessionHostClient) return + if (!request || typeof request.terminalId !== 'string' || typeof request.data !== 'string') return + if (request.data.length > 65536) return + sessionHostClient.write(request.terminalId, request.data) + }) + + ipcMain.on('terminal:resize', (_event, request: TerminalResizeRequest) => { + if (!sessionHostClient) return + if (!request || typeof request.terminalId !== 'string') return + sessionHostClient.resize(request.terminalId, request.cols, request.rows) + }) + + ipcMain.handle('workspace:pick-directory', async (event) => { + const sender = BrowserWindow.fromWebContents(event.sender) + const result = await (sender + ? dialog.showOpenDialog(sender, { properties: ['openDirectory', 'dontAddToRecent'] }) + : dialog.showOpenDialog({ properties: ['openDirectory', 'dontAddToRecent'] })) + + if (result.canceled || result.filePaths.length === 0) { + return { canceled: true } + } + + return { canceled: false, path: result.filePaths[0] } + }) + + ipcMain.handle('window:is-fullscreen', (event) => { + const sender = BrowserWindow.fromWebContents(event.sender) + return sender ? sender.isFullScreen() : false + }) + + ipcMain.handle('terminal:close', async (_event, request: TerminalCloseRequest) => { + if (!sessionHostClient) return false + if (!request || typeof request.terminalId !== 'string') return false + return sessionHostClient.close(request.terminalId) + }) + + await app.whenReady() + + shellRegistry = detectShells() + const userDataPath = app.getPath('userData') + const preferencesPath = path.join(userDataPath, 'preferences.json') + preferencesStore = createPreferencesStore(preferencesPath) + preferencesWereFreshlyCreated = !(await preferencesStore.exists()) + appStateStore = createAppStateStore(path.join(userDataPath, 'app-state.json')) + + const sessionHostEntryPath = path.join(__dirname, 'index.js') + sessionHostClient = new SessionHostClient({ + endpoint: getSessionHostEndpoint(userDataPath), + entryScriptPath: sessionHostEntryPath, + }) + + sessionHostClient.onData((event) => { + broadcast('terminal:data', event) + }) + sessionHostClient.onExit((event) => { + broadcast('terminal:exit', event) + }) + + await sessionHostClient.ensureConnected().catch((error) => { + console.error('[baton-session-host]', error) + }) + + createWindow() + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow() + }) + + app.on('before-quit', () => { + sessionHostClient?.dispose() + sessionHostClient = null + }) + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 2c362fa..847be51 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,317 +1,15 @@ -import { app, BrowserWindow, dialog, ipcMain, nativeImage, shell } from 'electron' -import path from 'node:path' -import os from 'node:os' -import fs from 'node:fs' -import crypto from 'node:crypto' -import * as pty from 'node-pty' -import type { - TerminalCloseRequest, - TerminalCreateRequest, - TerminalCreateResponse, - TerminalListShellsResponse, - TerminalResizeRequest, - TerminalWriteRequest -} from '../shared/terminal-types' -import type { AppPreferences } from '../shared/preferences-types' -import type { ShellDescriptor } from '../shared/shell-registry' -import { detectPreferredShellId, resolveShell } from './shell-resolver' -import { detectShells } from './shell-detection' -import { createPreferencesStore, migratePreferences, type PreferencesStore } from './preferences' - -const terminals = new Map() -let mainWindow: BrowserWindow | null = null - -let shellRegistry: ShellDescriptor[] = [] -let preferencesStore: PreferencesStore | null = null -let preferencesWereFreshlyCreated = false - -const isDevelopment = Boolean(process.env.ELECTRON_RENDERER_URL) - -function resolveWindowIconPath(): string | null { - // In a packaged app, extraResources are placed under process.resourcesPath. - // In dev, we fall back to the repo's build/icons/icon.png. - const packaged = path.join(process.resourcesPath, 'icon.png') - if (fs.existsSync(packaged)) return packaged - const dev = path.resolve(__dirname, '../../build/icons/icon.png') - if (fs.existsSync(dev)) return dev - return null -} - -function clampInteger(value: unknown, min: number, max: number, fallback: number): number { - if (typeof value !== 'number' || !Number.isFinite(value)) return fallback - return Math.max(min, Math.min(max, Math.floor(value))) -} - -function expandHomePrefix(input: string, home: string): string { - if (input === '~') return home - if (input.startsWith('~/')) return path.join(home, input.slice(2)) - if (process.platform === 'win32' && input.startsWith('~\\')) { - return path.join(home, input.slice(2)) - } - return input -} - -function expandEnvVars(input: string): string { - if (process.platform === 'win32') { - return input.replace(/%([^%]+)%/g, (_match, name: string) => process.env[name] ?? '') - } - return input.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced?: string, bare?: string) => { - const name = braced ?? bare - if (!name) return '' - return process.env[name] ?? '' - }) -} - -function resolveWorkspaceCwd(requested?: string): string { - const home = os.homedir() - if (!requested) return home - - const trimmed = requested.trim() - if (!trimmed) return home - - const expanded = path.normalize(expandEnvVars(expandHomePrefix(trimmed, home))) - if (!path.isAbsolute(expanded)) return home - - try { - const stat = fs.statSync(expanded) - if (!stat.isDirectory()) return home - } catch { - return home +async function bootstrap(): Promise { + if (process.argv.includes('--baton-session-host')) { + const { runSessionHost } = await import('./session-host-process') + await runSessionHost() + return } - return expanded + const { runElectronMain } = await import('./electron-main') + await runElectronMain() } -function createWindow(): void { - const iconPath = resolveWindowIconPath() - const icon = iconPath ? nativeImage.createFromPath(iconPath) : undefined - - mainWindow = new BrowserWindow({ - width: 1440, - height: 920, - minWidth: 900, - minHeight: 600, - title: 'Baton', - backgroundColor: '#080b12', - titleBarStyle: 'hiddenInset', - trafficLightPosition: { x: 18, y: 18 }, - icon, - webPreferences: { - preload: path.join(__dirname, '../preload/index.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: false - } - }) - - if (isDevelopment && process.platform === 'darwin' && icon && app.dock) { - app.dock.setIcon(icon) - } - - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url) - return { action: 'deny' } - }) - - const broadcastFullScreen = (isFullScreen: boolean): void => { - if (!mainWindow || mainWindow.webContents.isDestroyed()) return - mainWindow.webContents.send('window:fullscreen-changed', isFullScreen) - } - mainWindow.on('enter-full-screen', () => broadcastFullScreen(true)) - mainWindow.on('leave-full-screen', () => broadcastFullScreen(false)) - - mainWindow.on('closed', () => { - mainWindow = null - }) - - if (isDevelopment && process.env.ELECTRON_RENDERER_URL) { - void mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) - if (process.env.BATON_DEVTOOLS === '1') { - mainWindow.webContents.openDevTools({ mode: 'detach' }) - } - } else { - void mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) - } -} - -async function resolveEffectiveShellId(request: TerminalCreateRequest): Promise { - if (request.shellId && request.shellId !== 'auto') return request.shellId - - if (preferencesStore) { - const prefs = await preferencesStore.load() - if (prefs.terminal.defaultShellId && prefs.terminal.defaultShellId !== 'auto') { - return prefs.terminal.defaultShellId - } - } - - return 'auto' -} - -async function createTerminal( - event: Electron.IpcMainInvokeEvent, - request: TerminalCreateRequest, -): Promise { - const terminalId = crypto.randomUUID() - const cols = clampInteger(request.cols, 10, 500, 100) - const rows = clampInteger(request.rows, 4, 200, 30) - const cwd = resolveWorkspaceCwd(request.cwd) - - const effectiveId = await resolveEffectiveShellId(request) - const resolved = resolveShell({ - id: effectiveId, - registry: shellRegistry, - cwd, - platform: process.platform, - env: { - ...process.env, - HOME: process.env.HOME || os.homedir(), - }, - }) - - const terminal = pty.spawn(resolved.file, resolved.args, { - name: 'xterm-256color', - cols, - rows, - cwd: resolved.cwd, - env: resolved.env, - }) - - terminals.set(terminalId, terminal) - - terminal.onData((data) => { - if (!event.sender.isDestroyed()) { - event.sender.send('terminal:data', { terminalId, data }) - } - }) - - terminal.onExit(({ exitCode, signal }) => { - terminals.delete(terminalId) - if (!event.sender.isDestroyed()) { - event.sender.send('terminal:exit', { terminalId, exitCode, signal }) - } - }) - - return { - terminalId, - shell: resolved.descriptor.label, - shellId: resolved.descriptor.id, - pid: terminal.pid, - cwd: resolved.cwd, - } -} - -ipcMain.handle('terminal:create', (event, request: TerminalCreateRequest) => { - return createTerminal(event, request) -}) - -ipcMain.handle('terminal:list-shells', (): TerminalListShellsResponse => { - const defaultShellId = detectPreferredShellId({ - platform: process.platform, - env: process.env, - registry: shellRegistry, - }) - return { - shells: shellRegistry.map((d) => ({ - id: d.id, - label: d.label, - kind: d.kind, - ...(d.meta?.wslDistro ? { wslDistro: d.meta.wslDistro } : {}), - })), - defaultShellId, - } -}) - -ipcMain.handle('preferences:get', async (): Promise => { - if (!preferencesStore) throw new Error('Preferences not initialised') - return preferencesStore.load() -}) - -ipcMain.handle('preferences:set', async (_event, next: AppPreferences): Promise => { - if (!preferencesStore) throw new Error('Preferences not initialised') - const migrated = migratePreferences(next) - await preferencesStore.save(migrated) - preferencesWereFreshlyCreated = false - return migrated -}) - -ipcMain.handle('preferences:was-freshly-created', (): boolean => preferencesWereFreshlyCreated) - -ipcMain.on('terminal:write', (_event, request: TerminalWriteRequest) => { - if (!request || typeof request.terminalId !== 'string' || typeof request.data !== 'string') return - if (request.data.length > 65536) return - terminals.get(request.terminalId)?.write(request.data) -}) - -ipcMain.on('terminal:resize', (_event, request: TerminalResizeRequest) => { - if (!request || typeof request.terminalId !== 'string') return - const terminal = terminals.get(request.terminalId) - if (!terminal) return - - const cols = clampInteger(request.cols, 10, 500, 100) - const rows = clampInteger(request.rows, 4, 200, 30) - terminal.resize(cols, rows) -}) - -ipcMain.handle('workspace:pick-directory', async (event) => { - const sender = BrowserWindow.fromWebContents(event.sender) - const result = await (sender - ? dialog.showOpenDialog(sender, { properties: ['openDirectory', 'dontAddToRecent'] }) - : dialog.showOpenDialog({ properties: ['openDirectory', 'dontAddToRecent'] })) - - if (result.canceled || result.filePaths.length === 0) { - return { canceled: true } - } - - return { canceled: false, path: result.filePaths[0] } -}) - -ipcMain.handle('window:is-fullscreen', (event) => { - const sender = BrowserWindow.fromWebContents(event.sender) - return sender ? sender.isFullScreen() : false -}) - -ipcMain.handle('terminal:close', (_event, request: TerminalCloseRequest) => { - if (!request || typeof request.terminalId !== 'string') return false - const terminal = terminals.get(request.terminalId) - if (!terminal) return false - - try { - terminal.kill() - } finally { - terminals.delete(request.terminalId) - } - - return true -}) - -function killAllTerminals(): void { - for (const [terminalId, terminal] of terminals) { - try { - terminal.kill() - } catch { - // Ignore shutdown errors. - } finally { - terminals.delete(terminalId) - } - } -} - -void app.whenReady().then(async () => { - shellRegistry = detectShells() - const preferencesPath = path.join(app.getPath('userData'), 'preferences.json') - preferencesStore = createPreferencesStore(preferencesPath) - preferencesWereFreshlyCreated = !(await preferencesStore.exists()) - - createWindow() - - app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) createWindow() - }) -}) - -app.on('before-quit', killAllTerminals) - -app.on('window-all-closed', () => { - killAllTerminals() - if (process.platform !== 'darwin') app.quit() +void bootstrap().catch((error) => { + console.error(error) + process.exit(1) }) diff --git a/src/main/session-host-client.ts b/src/main/session-host-client.ts new file mode 100644 index 0000000..6d3fb19 --- /dev/null +++ b/src/main/session-host-client.ts @@ -0,0 +1,363 @@ +import crypto from 'node:crypto' +import net from 'node:net' +import { spawn } from 'node:child_process' +import type { + HostClientMessage, + HostServerMessage, +} from '../shared/session-host-protocol' +import type { + TerminalAttachResponse, + TerminalCreateRequest, + TerminalCreateResponse, + TerminalDataEvent, + TerminalExitEvent, +} from '../shared/terminal-types' + +interface PendingRequest { + resolve: (value: T) => void + reject: (error: Error) => void + timeout: NodeJS.Timeout +} + +type Cleanup = () => void + +type Listener = (event: T) => void + +interface SessionHostClientOptions { + endpoint: string + entryScriptPath: string +} + +export class SessionHostClient { + private socket: net.Socket | null = null + private connectPromise: Promise | null = null + private spawnPromise: Promise | null = null + private readonly pendingCreates = new Map>() + private readonly pendingAttaches = new Map>() + private readonly pendingCloses = new Map>() + private readonly dataListeners = new Set>() + private readonly exitListeners = new Set>() + private rawBuffer = '' + + constructor(private readonly options: SessionHostClientOptions) {} + + async ensureConnected(): Promise { + if (this.socket && !this.socket.destroyed) return + if (this.connectPromise) return this.connectPromise + + this.connectPromise = this.connectOrSpawn() + try { + await this.connectPromise + } finally { + this.connectPromise = null + } + } + + async create(request: TerminalCreateRequest): Promise { + await this.ensureConnected() + const clientId = crypto.randomUUID() + const promise = createPending((pending) => { + this.pendingCreates.set(clientId, pending) + }, () => this.pendingCreates.delete(clientId)) + + this.sendMessage({ type: 'create', clientId, ...request }) + return promise + } + + async attach(terminalId: string): Promise { + await this.ensureConnected() + const clientId = crypto.randomUUID() + const promise = createPending((pending) => { + this.pendingAttaches.set(clientId, pending) + }, () => this.pendingAttaches.delete(clientId)) + + this.sendMessage({ type: 'attach', clientId, terminalId }) + return promise + } + + write(terminalId: string, data: string): void { + if (data.length > 65536) return + void this.ensureConnected() + .then(() => this.sendMessage({ type: 'write', terminalId, data })) + .catch(() => undefined) + } + + resize(terminalId: string, cols: number, rows: number): void { + void this.ensureConnected() + .then(() => this.sendMessage({ type: 'resize', terminalId, cols, rows })) + .catch(() => undefined) + } + + async close(terminalId: string): Promise { + await this.ensureConnected() + const clientId = crypto.randomUUID() + const promise = createPending((pending) => { + this.pendingCloses.set(clientId, pending) + }, () => this.pendingCloses.delete(clientId)) + + this.sendMessage({ type: 'close', clientId, terminalId }) + return promise + } + + onData(listener: Listener): Cleanup { + this.dataListeners.add(listener) + return () => this.dataListeners.delete(listener) + } + + onExit(listener: Listener): Cleanup { + this.exitListeners.add(listener) + return () => this.exitListeners.delete(listener) + } + + dispose(): void { + this.socket?.destroy() + this.socket = null + this.rejectAllPending(new Error('Session host client disposed')) + } + + private async connectOrSpawn(): Promise { + try { + await this.openSocket() + return + } catch { + await this.spawnHost() + await this.waitForSocket() + } + } + + private openSocket(): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(this.options.endpoint) + let settled = false + + const cleanup = (): void => { + socket.removeListener('connect', onConnect) + socket.removeListener('error', onError) + } + + const onConnect = (): void => { + if (settled) return + settled = true + cleanup() + this.attachSocket(socket) + resolve() + } + + const onError = (error: Error): void => { + if (settled) return + settled = true + cleanup() + socket.destroy() + reject(error) + } + + socket.once('connect', onConnect) + socket.once('error', onError) + }) + } + + private attachSocket(socket: net.Socket): void { + this.rawBuffer = '' + socket.setEncoding('utf8') + socket.on('data', (chunk: string | Buffer) => { + this.rawBuffer += chunk.toString() + while (true) { + const newlineIndex = this.rawBuffer.indexOf('\n') + if (newlineIndex === -1) break + const line = this.rawBuffer.slice(0, newlineIndex).trim() + this.rawBuffer = this.rawBuffer.slice(newlineIndex + 1) + if (!line) continue + + let message: HostServerMessage + try { + message = JSON.parse(line) as HostServerMessage + } catch { + continue + } + + this.handleMessage(message) + } + }) + + socket.on('close', () => { + if (this.socket !== socket) return + this.socket = null + this.rejectAllPending(new Error('Session host connection closed')) + }) + + socket.on('error', () => { + // The close handler owns cleanup and pending rejection. + }) + + this.socket = socket + } + + private handleMessage(message: HostServerMessage): void { + if (message.type === 'created') { + const pending = this.pendingCreates.get(message.clientId) + if (!pending) return + clearTimeout(pending.timeout) + this.pendingCreates.delete(message.clientId) + pending.resolve({ + terminalId: message.terminalId, + shell: message.shell, + shellId: message.shellId, + pid: message.pid, + cwd: message.cwd, + }) + return + } + + if (message.type === 'attached') { + const pending = this.pendingAttaches.get(message.clientId) + if (!pending) return + clearTimeout(pending.timeout) + this.pendingAttaches.delete(message.clientId) + pending.resolve({ + terminalId: message.terminalId, + shell: message.shell, + shellId: message.shellId, + pid: message.pid, + cwd: message.cwd, + status: message.status, + exitCode: message.exitCode, + buffer: message.buffer, + }) + return + } + + if (message.type === 'closed') { + const pending = this.pendingCloses.get(message.clientId) + if (!pending) return + clearTimeout(pending.timeout) + this.pendingCloses.delete(message.clientId) + pending.resolve(message.ok) + return + } + + if (message.type === 'data') { + for (const listener of this.dataListeners) { + listener({ terminalId: message.terminalId, data: message.data }) + } + return + } + + if (message.type === 'exit') { + for (const listener of this.exitListeners) { + listener({ terminalId: message.terminalId, exitCode: message.exitCode, signal: message.signal }) + } + return + } + + if (message.type === 'error' && message.clientId) { + const pendingCreate = this.pendingCreates.get(message.clientId) + if (pendingCreate) { + clearTimeout(pendingCreate.timeout) + this.pendingCreates.delete(message.clientId) + pendingCreate.reject(new Error(message.message)) + return + } + + const pendingAttach = this.pendingAttaches.get(message.clientId) + if (pendingAttach) { + clearTimeout(pendingAttach.timeout) + this.pendingAttaches.delete(message.clientId) + pendingAttach.reject(new Error(message.message)) + return + } + + const pendingClose = this.pendingCloses.get(message.clientId) + if (pendingClose) { + clearTimeout(pendingClose.timeout) + this.pendingCloses.delete(message.clientId) + pendingClose.reject(new Error(message.message)) + } + } + } + + private sendMessage(message: HostClientMessage): void { + if (!this.socket || this.socket.destroyed) { + throw new Error('Session host connection is not available') + } + this.socket.write(`${JSON.stringify(message)}\n`) + } + + private async spawnHost(): Promise { + if (this.spawnPromise) return this.spawnPromise + + this.spawnPromise = new Promise((resolve, reject) => { + const child = spawn(process.execPath, [this.options.entryScriptPath, '--baton-session-host'], { + detached: true, + stdio: 'ignore', + windowsHide: true, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + BATON_SESSION_HOST_ENDPOINT: this.options.endpoint, + }, + }) + + child.once('error', reject) + child.once('spawn', () => { + child.removeListener('error', reject) + child.unref() + resolve() + }) + }).finally(() => { + this.spawnPromise = null + }) + + return this.spawnPromise + } + + private async waitForSocket(): Promise { + let lastError: Error | null = null + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + await this.openSocket() + return + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unable to connect to session host') + await delay(100) + } + } + + throw lastError ?? new Error('Unable to connect to session host') + } + + private rejectAllPending(error: Error): void { + for (const pending of this.pendingCreates.values()) { + clearTimeout(pending.timeout) + pending.reject(error) + } + for (const pending of this.pendingAttaches.values()) { + clearTimeout(pending.timeout) + pending.reject(error) + } + for (const pending of this.pendingCloses.values()) { + clearTimeout(pending.timeout) + pending.reject(error) + } + this.pendingCreates.clear() + this.pendingAttaches.clear() + this.pendingCloses.clear() + } +} + +function createPending( + register: (pending: PendingRequest) => void, + unregister: () => void, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unregister() + reject(new Error('Timed out waiting for session host')) + }, 10_000) + + register({ resolve, reject, timeout }) + }) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/main/session-host-path.ts b/src/main/session-host-path.ts new file mode 100644 index 0000000..41f68d3 --- /dev/null +++ b/src/main/session-host-path.ts @@ -0,0 +1,17 @@ +import crypto from 'node:crypto' +import os from 'node:os' +import path from 'node:path' + +export function getSessionHostEndpoint(userDataPath: string): string { + const hash = crypto.createHash('sha256').update(userDataPath).digest('hex').slice(0, 16) + + if (process.platform === 'win32') { + return `\\\\.\\pipe\\baton-session-host-${hash}` + } + + const baseDir = process.env.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR.length > 0 + ? process.env.XDG_RUNTIME_DIR + : os.tmpdir() + + return path.join(baseDir, `baton-session-host-${hash}.sock`) +} diff --git a/src/main/session-host-process.ts b/src/main/session-host-process.ts new file mode 100644 index 0000000..5085212 --- /dev/null +++ b/src/main/session-host-process.ts @@ -0,0 +1,322 @@ +import crypto from 'node:crypto' +import fs from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import * as pty from 'node-pty' +import type { Socket } from 'node:net' +import type { HostClientMessage, HostServerMessage } from '../shared/session-host-protocol' +import { detectShells } from './shell-detection' +import { resolveShell } from './shell-resolver' +import { clampInteger, resolveWorkspaceCwd } from './terminal-runtime' + +interface SessionRecord { + terminalId: string + shell: string + shellId: string + pid?: number + cwd?: string + pty?: pty.IPty + buffer: string + status: 'running' | 'exited' + exitCode: number | null + signal?: number | null + attachments: Set +} + +const MAX_BUFFER_BYTES = 300_000 +const idleExitMsEnv = process.env.BATON_SESSION_HOST_IDLE_EXIT_MS +const parsedIdleExitMs = idleExitMsEnv === undefined ? 30_000 : Number(idleExitMsEnv) +const IDLE_EXIT_MS = Number.isFinite(parsedIdleExitMs) ? parsedIdleExitMs : 30_000 + +export async function runSessionHost(): Promise { + const endpoint = process.env.BATON_SESSION_HOST_ENDPOINT + if (!endpoint) { + throw new Error('BATON_SESSION_HOST_ENDPOINT is required') + } + + const shellRegistry = detectShells() + const sessions = new Map() + let idleTimer: NodeJS.Timeout | null = null + let connectionCount = 0 + + const server = net.createServer((socket) => { + connectionCount += 1 + if (idleTimer) { + clearTimeout(idleTimer) + idleTimer = null + } + + let rawBuffer = '' + + socket.setEncoding('utf8') + + socket.on('data', (chunk: string | Buffer) => { + rawBuffer += chunk.toString() + while (true) { + const newlineIndex = rawBuffer.indexOf('\n') + if (newlineIndex === -1) break + + const line = rawBuffer.slice(0, newlineIndex).trim() + rawBuffer = rawBuffer.slice(newlineIndex + 1) + if (!line) continue + + let message: HostClientMessage + try { + message = JSON.parse(line) as HostClientMessage + } catch { + send(socket, { type: 'error', message: 'Invalid JSON message' }) + continue + } + + if (message.type === 'create') { + const terminalId = crypto.randomUUID() + const cols = clampInteger(message.cols, 10, 500, 100) + const rows = clampInteger(message.rows, 4, 200, 30) + const cwd = resolveWorkspaceCwd(message.cwd) + const requestedId = message.shellId && message.shellId.length > 0 ? message.shellId : 'auto' + + try { + const resolved = resolveShell({ + id: requestedId, + registry: shellRegistry, + cwd, + platform: process.platform, + env: { + ...process.env, + HOME: process.env.HOME || os.homedir(), + }, + }) + + const terminal = pty.spawn(resolved.file, resolved.args, { + name: 'xterm-256color', + cols, + rows, + cwd: resolved.cwd, + env: resolved.env, + }) + + const session: SessionRecord = { + terminalId, + shell: resolved.descriptor.label, + shellId: resolved.descriptor.id, + pid: terminal.pid, + cwd: resolved.cwd, + pty: terminal, + buffer: '', + status: 'running', + exitCode: null, + attachments: new Set([socket]), + } + + terminal.onData((data) => { + session.buffer = appendToRingBuffer(session.buffer, data) + for (const attachment of session.attachments) { + send(attachment, { type: 'data', terminalId, data }) + } + }) + + terminal.onExit(({ exitCode, signal }) => { + session.pty = undefined + session.status = 'exited' + session.exitCode = exitCode + session.signal = signal + for (const attachment of session.attachments) { + send(attachment, { type: 'exit', terminalId, exitCode, signal }) + } + }) + + sessions.set(terminalId, session) + send(socket, { + type: 'created', + clientId: message.clientId, + terminalId, + shell: session.shell, + shellId: session.shellId, + pid: session.pid, + cwd: session.cwd, + }) + } catch (error) { + send(socket, { + type: 'error', + clientId: message.clientId, + message: error instanceof Error ? error.message : 'Unable to create terminal', + }) + } + continue + } + + if (message.type === 'attach') { + const session = sessions.get(message.terminalId) + if (!session) { + send(socket, { + type: 'error', + clientId: message.clientId, + message: `Terminal session "${message.terminalId}" is no longer available`, + }) + continue + } + + session.attachments.add(socket) + send(socket, { + type: 'attached', + clientId: message.clientId, + terminalId: session.terminalId, + shell: session.shell, + shellId: session.shellId, + pid: session.pid, + cwd: session.cwd, + status: session.status, + exitCode: session.exitCode, + buffer: session.buffer, + }) + continue + } + + if (message.type === 'write') { + const session = sessions.get(message.terminalId) + if (!session?.pty) continue + if (typeof message.data !== 'string' || message.data.length > 65536) continue + session.pty.write(message.data) + continue + } + + if (message.type === 'resize') { + const session = sessions.get(message.terminalId) + if (!session?.pty) continue + session.pty.resize( + clampInteger(message.cols, 10, 500, 100), + clampInteger(message.rows, 4, 200, 30), + ) + continue + } + + if (message.type === 'close') { + const session = sessions.get(message.terminalId) + if (!session) { + send(socket, { type: 'closed', clientId: message.clientId, ok: false }) + continue + } + + try { + session.pty?.kill() + } catch { + // Ignore shutdown errors. + } finally { + sessions.delete(message.terminalId) + session.attachments.clear() + send(socket, { type: 'closed', clientId: message.clientId, ok: true }) + scheduleIdleExitIfNeeded(sessions, connectionCount, idleTimer, () => { + idleTimer = setTimeout(() => { + void shutdown(server, endpoint) + }, IDLE_EXIT_MS) + }) + } + } + } + }) + + socket.on('close', () => { + connectionCount = Math.max(0, connectionCount - 1) + for (const session of sessions.values()) { + session.attachments.delete(socket) + } + scheduleIdleExitIfNeeded(sessions, connectionCount, idleTimer, () => { + idleTimer = setTimeout(() => { + void shutdown(server, endpoint) + }, IDLE_EXIT_MS) + }) + }) + }) + + server.on('error', (error) => { + console.error('[baton-session-host]', error) + }) + + await listenOnEndpoint(server, endpoint) +} + +function appendToRingBuffer(current: string, data: string): string { + const next = `${current}${data}` + return next.length > MAX_BUFFER_BYTES ? next.slice(-MAX_BUFFER_BYTES) : next +} + +function send(socket: Socket, message: HostServerMessage): void { + if (socket.destroyed || !socket.writable) return + socket.write(`${JSON.stringify(message)}\n`) +} + +function scheduleIdleExitIfNeeded( + sessions: Map, + connectionCount: number, + idleTimer: NodeJS.Timeout | null, + schedule: () => void, +): void { + if (sessions.size > 0 || connectionCount > 0 || idleTimer) return + schedule() +} + +async function shutdown(server: net.Server, endpoint: string): Promise { + await new Promise((resolve) => { + server.close(() => resolve()) + }) + + if (process.platform !== 'win32') { + try { + await fs.unlink(endpoint) + } catch { + // Ignore missing socket cleanup. + } + } + + process.exit(0) +} + +async function listenOnEndpoint(server: net.Server, endpoint: string): Promise { + if (process.platform !== 'win32') { + await fs.mkdir(path.dirname(endpoint), { recursive: true }) + } + + await new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException): void => { + server.off('listening', onListening) + reject(error) + } + const onListening = (): void => { + server.off('error', onError) + resolve() + } + + server.once('error', onError) + server.once('listening', onListening) + server.listen(endpoint) + }).catch(async (error: unknown) => { + const err = error as NodeJS.ErrnoException + if (process.platform !== 'win32' && err.code === 'EADDRINUSE') { + const reachable = await canConnect(endpoint) + if (!reachable) { + await fs.unlink(endpoint).catch(() => undefined) + return listenOnEndpoint(server, endpoint) + } + } + throw err + }) + + if (process.platform !== 'win32') { + await fs.chmod(endpoint, 0o600).catch(() => undefined) + } +} + +async function canConnect(endpoint: string): Promise { + return new Promise((resolve) => { + const socket = net.createConnection(endpoint) + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', () => { + socket.destroy() + resolve(false) + }) + }) +} diff --git a/src/main/terminal-runtime.ts b/src/main/terminal-runtime.ts new file mode 100644 index 0000000..eed3a4d --- /dev/null +++ b/src/main/terminal-runtime.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export function clampInteger(value: unknown, min: number, max: number, fallback: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback + return Math.max(min, Math.min(max, Math.floor(value))) +} + +function expandHomePrefix(input: string, home: string): string { + if (input === '~') return home + if (input.startsWith('~/')) return path.join(home, input.slice(2)) + if (process.platform === 'win32' && input.startsWith('~\\')) { + return path.join(home, input.slice(2)) + } + return input +} + +function expandEnvVars(input: string): string { + if (process.platform === 'win32') { + return input.replace(/%([^%]+)%/g, (_match, name: string) => process.env[name] ?? '') + } + return input.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced?: string, bare?: string) => { + const name = braced ?? bare + if (!name) return '' + return process.env[name] ?? '' + }) +} + +export function resolveWorkspaceCwd(requested?: string): string { + const home = os.homedir() + if (!requested) return home + + const trimmed = requested.trim() + if (!trimmed) return home + + const expanded = path.normalize(expandEnvVars(expandHomePrefix(trimmed, home))) + if (!path.isAbsolute(expanded)) return home + + try { + const stat = fs.statSync(expanded) + if (!stat.isDirectory()) return home + } catch { + return home + } + + return expanded +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 0d38d53..f16462a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,7 @@ import { contextBridge, ipcRenderer } from 'electron' import type { + TerminalAttachRequest, + TerminalAttachResponse, TerminalCloseRequest, TerminalCreateRequest, TerminalCreateResponse, @@ -40,6 +42,10 @@ const api = { create(request: TerminalCreateRequest): Promise { return ipcRenderer.invoke('terminal:create', request) as Promise }, + attach(terminalId: string): Promise { + const payload: TerminalAttachRequest = { terminalId } + return ipcRenderer.invoke('terminal:attach', payload) as Promise + }, write(terminalId: string, data: string): void { const payload: TerminalWriteRequest = { terminalId, data } ipcRenderer.send('terminal:write', payload) @@ -76,6 +82,14 @@ const api = { wasFreshlyCreated(): Promise { return ipcRenderer.invoke('preferences:was-freshly-created') as Promise } + }, + appState: { + get(): Promise { + return ipcRenderer.invoke('app-state:get') as Promise + }, + set(next: unknown): Promise { + return ipcRenderer.invoke('app-state:set', next) as Promise + } } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 84865de..d79d9d7 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -16,7 +16,7 @@ import { type WorkspaceSettings, type WorkspaceState, } from "./domain"; -import { loadAppState, saveAppState } from "./persistence"; +import { hydrateAppState, loadAppState, saveAppState } from "./persistence"; import { TerminalClientContext, useTerminalClient } from "./services/terminalContext"; import { createBufferedTerminalClient } from "./services/terminalClient"; import { ThemeProvider } from "./services/themeContext"; @@ -78,18 +78,50 @@ function AppShell() { backendDefaultShellId, ); + const [storageHydrated, setStorageHydrated] = useState( + !window.baton?.appState, + ); + const activeWorkspace = workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? workspaces[0]; useEffect(() => { - saveAppState({ - workspaces, - activeWorkspaceId, - sidebarCollapsed, - themePreference, - }); - }, [workspaces, activeWorkspaceId, sidebarCollapsed, themePreference]); + let cancelled = false; + + void hydrateAppState() + .then((hydrated) => { + if (cancelled || !hydrated) return; + setWorkspaces(hydrated.workspaces); + setActiveWorkspaceId(hydrated.activeWorkspaceId); + setSidebarCollapsed(hydrated.sidebarCollapsed); + setThemePreference(hydrated.themePreference); + }) + .finally(() => { + if (!cancelled) setStorageHydrated(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!storageHydrated) return; + + const handle = window.setTimeout(() => { + saveAppState({ + workspaces, + activeWorkspaceId, + sidebarCollapsed, + themePreference, + }); + }, 120); + + return () => { + window.clearTimeout(handle); + }; + }, [storageHydrated, workspaces, activeWorkspaceId, sidebarCollapsed, themePreference]); const handleThemeChange = useCallback((next: ThemePreference) => { setThemePreference(next); diff --git a/src/renderer/src/components/TerminalWindow.tsx b/src/renderer/src/components/TerminalWindow.tsx index be4459b..2bfc46e 100644 --- a/src/renderer/src/components/TerminalWindow.tsx +++ b/src/renderer/src/components/TerminalWindow.tsx @@ -53,14 +53,37 @@ export function TerminalWindow(props: TerminalWindowProps) { }, [props.workspaceSettings]); useEffect(() => { - if ( - terminal.terminalId || hasStartedRef.current || - terminal.status === "exited" - ) return; + if (hasStartedRef.current || terminal.status !== "starting") return; hasStartedRef.current = true; let cancelled = false; + if (terminal.terminalId) { + client.attachTerminal(terminal.terminalId) + .then((response) => { + if (cancelled) return; + + onPatchRef.current({ + title: `${response.shell} · ${response.cwd ?? "~"}`, + status: response.status, + exitCode: response.exitCode, + }); + }) + .catch((error: unknown) => { + hasStartedRef.current = false; + onPatchRef.current({ + status: "error", + title: error instanceof Error + ? error.message + : "Terminal session is unavailable", + }); + }); + + return () => { + cancelled = true; + }; + } + const settings = settingsRef.current; const cwd = settings.defaultCwd?.trim() || undefined; const startCommand = settings.startCommand?.trim() || undefined; @@ -84,6 +107,7 @@ export function TerminalWindow(props: TerminalWindowProps) { terminalId: response.terminalId, title: `${response.shell} · ${response.cwd ?? "~"}`, status: "running", + exitCode: null, }); if (startCommand) { @@ -106,7 +130,7 @@ export function TerminalWindow(props: TerminalWindowProps) { return () => { cancelled = true; }; - }, [client, terminal.status, terminal.terminalId]); + }, [client, props.appDefaultShellId, terminal.status, terminal.terminalId]); function startDrag(event: PointerEvent): void { const target = event.target as HTMLElement; @@ -272,11 +296,11 @@ export function TerminalWindow(props: TerminalWindowProps) { {!terminal.minimized && (
- {terminal.terminalId + {terminal.terminalId && terminal.status !== "starting" ? : (
- Starting terminal… + {terminal.status === "error" ? "Terminal unavailable" : "Starting terminal…"}
)}
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 4f699f6..3ddea91 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -1,4 +1,5 @@ import type { + TerminalAttachResponse, TerminalCreateRequest, TerminalCreateResponse, TerminalDataEvent, @@ -23,6 +24,7 @@ interface BatonBridge { } terminal: { create(request: TerminalCreateRequest): Promise + attach(terminalId: string): Promise write(terminalId: string, data: string): void resize(terminalId: string, cols: number, rows: number): void close(terminalId: string): Promise @@ -35,6 +37,10 @@ interface BatonBridge { set(next: AppPreferences): Promise wasFreshlyCreated(): Promise } + appState: { + get(): Promise + set(next: unknown): Promise + } } declare global { diff --git a/src/renderer/src/persistence.test.ts b/src/renderer/src/persistence.test.ts index 03ea9f3..0494eb1 100644 --- a/src/renderer/src/persistence.test.ts +++ b/src/renderer/src/persistence.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { loadAppState, saveAppState } from './persistence' +import { hydrateAppState, loadAppState, saveAppState } from './persistence' interface StorageShim { getItem: (key: string) => string | null @@ -36,6 +36,29 @@ function installStorage(storage: StorageShim): () => void { } } +function installWindowAppStateBridge(bridge: { get: () => Promise; set: (next: unknown) => Promise }): () => void { + const originalWindow = globalThis.window + const nextWindow = { + ...(originalWindow ?? {}), + baton: { + ...(originalWindow?.baton ?? {}), + appState: bridge, + }, + } + + // @ts-expect-error minimal window shim for tests + globalThis.window = nextWindow + + return () => { + if (originalWindow) { + globalThis.window = originalWindow + } else { + // @ts-expect-error cleanup when the original was undefined + delete globalThis.window + } + } +} + describe('persistence theme preference', () => { let storage: StorageShim let restore: () => void @@ -80,9 +103,80 @@ describe('persistence theme preference', () => { }) }) +describe('persistence app-state bridge hydration', () => { + let storage: StorageShim + let restoreStorage: () => void + let restoreWindow: (() => void) | null = null + + beforeEach(() => { + storage = createStorage() + restoreStorage = installStorage(storage) + }) + + afterEach(() => { + restoreWindow?.() + restoreWindow = null + restoreStorage() + }) + + test('hydrateAppState loads Electron app state and refreshes the local cache', async () => { + restoreWindow = installWindowAppStateBridge({ + async get() { + return { + workspaces: [ + { + id: 'ws-bridge', + name: 'Recovered', + viewport: { x: 1, y: 2, scale: 1 }, + terminals: [], + settings: {}, + createdAt: 1, + updatedAt: 1, + }, + ], + activeWorkspaceId: 'ws-bridge', + sidebarCollapsed: true, + themePreference: 'light', + } + }, + async set(next: unknown) { + return next + }, + }) + + const hydrated = await hydrateAppState() + expect(hydrated?.activeWorkspaceId).toBe('ws-bridge') + expect(loadAppState().activeWorkspaceId).toBe('ws-bridge') + expect(loadAppState().themePreference).toBe('light') + }) + + test('saveAppState mirrors the serialised state into the Electron app-state bridge', async () => { + let captured: unknown = null + restoreWindow = installWindowAppStateBridge({ + async get() { + return null + }, + async set(next: unknown) { + captured = next + return next + }, + }) + + const initial = loadAppState() + saveAppState({ + ...initial, + themePreference: 'dark', + }) + + await Promise.resolve() + expect(captured).toMatchObject({ themePreference: 'dark' }) + }) +}) + describe('persistence workspace shell settings', () => { let storage: StorageShim let restore: () => void + let restoreWindow: (() => void) | null = null beforeEach(() => { storage = createStorage() @@ -90,6 +184,8 @@ describe('persistence workspace shell settings', () => { }) afterEach(() => { + restoreWindow?.() + restoreWindow = null restore() }) @@ -163,4 +259,166 @@ describe('persistence workspace shell settings', () => { expect(state.workspaces[0]!.settings.shellId).toBeUndefined() expect(state.workspaces[0]!.settings.wslDistro).toBeUndefined() }) + + test('saveAppState strips terminalId on non-Electron runtimes so reload spawns a fresh session', () => { + const initial = loadAppState() + const [workspace] = initial.workspaces + + saveAppState({ + ...initial, + workspaces: [ + { + ...workspace, + terminals: [ + { + id: 'terminal-1', + title: 'bash · /tmp', + x: 10, + y: 20, + width: 400, + height: 300, + z: 1, + minimized: false, + terminalId: 'session-123', + status: 'running', + exitCode: null, + }, + ], + }, + ], + }) + + const reloaded = loadAppState() + expect(reloaded.workspaces[0]!.terminals[0]!.terminalId).toBeUndefined() + expect(reloaded.workspaces[0]!.terminals[0]!.status).toBe('starting') + }) + + test('saveAppState preserves terminalId when the Electron app-state bridge is available', () => { + restoreWindow = installWindowAppStateBridge({ + async get() { + return null + }, + async set(next: unknown) { + return next + }, + }) + + const initial = loadAppState() + const [workspace] = initial.workspaces + + saveAppState({ + ...initial, + workspaces: [ + { + ...workspace, + terminals: [ + { + id: 'terminal-1', + title: 'bash · /tmp', + x: 10, + y: 20, + width: 400, + height: 300, + z: 1, + minimized: false, + terminalId: 'session-123', + status: 'running', + exitCode: null, + }, + ], + }, + ], + }) + + const reloaded = loadAppState() + expect(reloaded.workspaces[0]!.terminals[0]!.terminalId).toBe('session-123') + expect(reloaded.workspaces[0]!.terminals[0]!.status).toBe('starting') + }) + + test('loadAppState strips saved terminalId on non-Electron runtimes', () => { + storage.setItem( + 'baton.state.v1', + JSON.stringify({ + workspaces: [ + { + id: 'ws-1', + name: 'Main', + viewport: { x: 0, y: 0, scale: 1 }, + terminals: [ + { + id: 'terminal-1', + title: 'bash · /tmp', + x: 10, + y: 20, + width: 400, + height: 300, + z: 1, + minimized: false, + terminalId: 'session-restore', + status: 'running', + exitCode: 0, + }, + ], + settings: {}, + createdAt: 1, + updatedAt: 1, + }, + ], + activeWorkspaceId: 'ws-1', + sidebarCollapsed: false, + }), + ) + + const state = loadAppState() + expect(state.workspaces[0]!.terminals[0]!.terminalId).toBeUndefined() + expect(state.workspaces[0]!.terminals[0]!.status).toBe('starting') + }) + + test('loadAppState preserves saved terminalId and forces reattach on Electron restore', () => { + restoreWindow = installWindowAppStateBridge({ + async get() { + return null + }, + async set(next: unknown) { + return next + }, + }) + + storage.setItem( + 'baton.state.v1', + JSON.stringify({ + workspaces: [ + { + id: 'ws-1', + name: 'Main', + viewport: { x: 0, y: 0, scale: 1 }, + terminals: [ + { + id: 'terminal-1', + title: 'bash · /tmp', + x: 10, + y: 20, + width: 400, + height: 300, + z: 1, + minimized: false, + terminalId: 'session-restore', + status: 'exited', + exitCode: 0, + }, + ], + settings: {}, + createdAt: 1, + updatedAt: 1, + }, + ], + activeWorkspaceId: 'ws-1', + sidebarCollapsed: false, + }), + ) + + const state = loadAppState() + expect(state.workspaces[0]!.terminals[0]!.terminalId).toBe('session-restore') + expect(state.workspaces[0]!.terminals[0]!.status).toBe('starting') + }) }) diff --git a/src/renderer/src/persistence.ts b/src/renderer/src/persistence.ts index f3fe605..7f886a0 100644 --- a/src/renderer/src/persistence.ts +++ b/src/renderer/src/persistence.ts @@ -3,6 +3,12 @@ import { sanitizeThemePreference, type ThemePreference } from './theme' const STORAGE_KEY = 'baton.state.v1' +function sanitizeTerminalId(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined + const trimmed = raw.trim() + return trimmed.length > 0 ? trimmed : undefined +} + export interface PersistedAppState { workspaces: WorkspaceState[] activeWorkspaceId: string @@ -10,6 +16,16 @@ export interface PersistedAppState { themePreference: ThemePreference } +function createFallbackAppState(): PersistedAppState { + const fallbackWorkspace = createWorkspace('Main') + return { + workspaces: [fallbackWorkspace], + activeWorkspaceId: fallbackWorkspace.id, + sidebarCollapsed: false, + themePreference: sanitizeThemePreference(undefined), + } +} + function sanitizeSettings(raw: unknown): WorkspaceSettings { if (!raw || typeof raw !== 'object') return {} const source = raw as Record @@ -38,79 +54,130 @@ function sanitizeSettings(raw: unknown): WorkspaceSettings { return settings } -function sanitizeWorkspace(workspace: WorkspaceState): WorkspaceState { +function supportsPersistedTerminalReattach(): boolean { + return Boolean(globalThis.window?.baton?.appState) +} + +function sanitizeWorkspace( + workspace: WorkspaceState, + options: { persistTerminalIds: boolean }, +): WorkspaceState { return { ...workspace, viewport: { x: Number.isFinite(workspace.viewport?.x) ? workspace.viewport.x : 160, y: Number.isFinite(workspace.viewport?.y) ? workspace.viewport.y : 120, - scale: Number.isFinite(workspace.viewport?.scale) ? workspace.viewport.scale : 1 + scale: Number.isFinite(workspace.viewport?.scale) ? workspace.viewport.scale : 1, }, settings: sanitizeSettings((workspace as { settings?: unknown }).settings), terminals: Array.isArray(workspace.terminals) - ? workspace.terminals.map((terminal, index) => ({ + ? workspace.terminals.map((terminal, index) => { + const terminalId = options.persistTerminalIds + ? sanitizeTerminalId(terminal.terminalId) + : undefined + return { + ...terminal, + title: terminal.title || `Terminal ${index + 1}`, + terminalId, + status: terminalId ? 'starting' : terminal.status === 'exited' ? 'exited' : 'starting', + minimized: Boolean(terminal.minimized), + z: Number.isFinite(terminal.z) ? terminal.z : index + 1, + } + }) + : [], + } +} + +export function sanitizePersistedAppState( + raw: unknown, + options: { persistTerminalIds?: boolean } = {}, +): PersistedAppState { + const fallback = createFallbackAppState() + if (!raw || typeof raw !== 'object') return fallback + + const persistTerminalIds = options.persistTerminalIds ?? supportsPersistedTerminalReattach() + const parsed = raw as Partial + const workspaces = Array.isArray(parsed.workspaces) && parsed.workspaces.length > 0 + ? parsed.workspaces.map((workspace) => sanitizeWorkspace(workspace, { persistTerminalIds })) + : fallback.workspaces + + const activeWorkspaceId = workspaces.some((workspace) => workspace.id === parsed.activeWorkspaceId) + ? String(parsed.activeWorkspaceId) + : workspaces[0].id + + return { + workspaces, + activeWorkspaceId, + sidebarCollapsed: Boolean(parsed.sidebarCollapsed), + themePreference: sanitizeThemePreference(parsed.themePreference), + } +} + +export function serializeAppState( + state: PersistedAppState, + options: { persistTerminalIds?: boolean } = {}, +): PersistedAppState { + const persistTerminalIds = options.persistTerminalIds ?? supportsPersistedTerminalReattach() + + return { + ...state, + workspaces: state.workspaces.map((workspace) => ({ + ...workspace, + terminals: workspace.terminals.map((terminal) => { + const terminalId = persistTerminalIds + ? sanitizeTerminalId(terminal.terminalId) + : undefined + return { ...terminal, - title: terminal.title || `Terminal ${index + 1}`, - terminalId: undefined, - status: 'starting', - minimized: Boolean(terminal.minimized), - z: Number.isFinite(terminal.z) ? terminal.z : index + 1 - })) - : [] + terminalId, + status: terminalId ? 'starting' : terminal.status === 'exited' ? 'exited' : 'starting', + } + }), + })), + } +} + +function loadFromLocalStorage(): PersistedAppState { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return createFallbackAppState() + return sanitizePersistedAppState(JSON.parse(raw) as unknown) + } catch { + return createFallbackAppState() } } +function saveToLocalStorage(state: PersistedAppState): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) +} + export function loadAppState(): PersistedAppState { - const fallbackWorkspace = createWorkspace('Main') + return loadFromLocalStorage() +} + +export async function hydrateAppState(): Promise { + const bridge = globalThis.window?.baton?.appState + if (!bridge) return null try { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) { - return { - workspaces: [fallbackWorkspace], - activeWorkspaceId: fallbackWorkspace.id, - sidebarCollapsed: false, - themePreference: sanitizeThemePreference(undefined) - } - } - - const parsed = JSON.parse(raw) as Partial - const workspaces = Array.isArray(parsed.workspaces) && parsed.workspaces.length > 0 - ? parsed.workspaces.map(sanitizeWorkspace) - : [fallbackWorkspace] - - const activeWorkspaceId = workspaces.some((workspace) => workspace.id === parsed.activeWorkspaceId) - ? String(parsed.activeWorkspaceId) - : workspaces[0].id - - return { - workspaces, - activeWorkspaceId, - sidebarCollapsed: Boolean(parsed.sidebarCollapsed), - themePreference: sanitizeThemePreference(parsed.themePreference) - } + const raw = await bridge.get() + if (raw === null) return null + const hydrated = sanitizePersistedAppState(raw) + saveToLocalStorage(hydrated) + return hydrated } catch { - return { - workspaces: [fallbackWorkspace], - activeWorkspaceId: fallbackWorkspace.id, - sidebarCollapsed: false, - themePreference: sanitizeThemePreference(undefined) - } + return null } } export function saveAppState(state: PersistedAppState): void { - const serializable: PersistedAppState = { - ...state, - workspaces: state.workspaces.map((workspace) => ({ - ...workspace, - terminals: workspace.terminals.map((terminal) => ({ - ...terminal, - terminalId: undefined, - status: terminal.status === 'exited' ? 'exited' : 'starting' - })) - })) + const serializable = serializeAppState(state) + saveToLocalStorage(serializable) + + const bridge = globalThis.window?.baton?.appState + if (bridge) { + void bridge.set(serializable).catch(() => { + // Keep local cache even if the main-process save fails. + }) } - - localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable)) } diff --git a/src/renderer/src/services/terminalClient.test.ts b/src/renderer/src/services/terminalClient.test.ts index ade6a24..2f5a63a 100644 --- a/src/renderer/src/services/terminalClient.test.ts +++ b/src/renderer/src/services/terminalClient.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { BufferedTerminalClient, type TerminalClient } from './terminalClient' import type { + TerminalAttachResponse, TerminalCreateRequest, TerminalCreateResponse, TerminalDataEvent, @@ -33,6 +34,17 @@ function makeStubClient(): TerminalClient & { cwd: '/tmp', } }, + async attachTerminal(terminalId: string): Promise { + return { + terminalId, + shell: 'pwsh', + shellId: 'pwsh', + cwd: '/tmp', + status: 'running', + exitCode: null, + buffer: 'hello from host', + } + }, write() {}, resize() {}, async close() { @@ -68,6 +80,16 @@ describe('BufferedTerminalClient', () => { expect(inner.calls.createTerminal[0]!.wslDistro).toBe('Ubuntu') }) + test('attachTerminal seeds the replay buffer from the host response', async () => { + const inner = makeStubClient() + const buffered = new BufferedTerminalClient(inner) + + const response = await buffered.attachTerminal('pty-existing') + + expect(response.status).toBe('running') + expect(buffered.getBuffer('pty-existing')).toBe('hello from host') + }) + test('listShells delegates to the inner client', async () => { const inner = makeStubClient() const buffered = new BufferedTerminalClient(inner) diff --git a/src/renderer/src/services/terminalClient.ts b/src/renderer/src/services/terminalClient.ts index 21678f4..ba53274 100644 --- a/src/renderer/src/services/terminalClient.ts +++ b/src/renderer/src/services/terminalClient.ts @@ -1,4 +1,5 @@ import type { + TerminalAttachResponse, TerminalCreateRequest, TerminalCreateResponse, TerminalDataEvent, @@ -14,6 +15,7 @@ type Cleanup = () => void export interface TerminalClient { readonly mode: 'electron' | 'websocket' | 'demo' createTerminal(request: TerminalCreateRequest): Promise + attachTerminal(terminalId: string): Promise write(terminalId: string, data: string): void resize(terminalId: string, cols: number, rows: number): void close(terminalId: string): Promise @@ -42,6 +44,10 @@ class ElectronTerminalClient implements TerminalClient { return window.baton!.terminal.create(request) } + attachTerminal(terminalId: string): Promise { + return window.baton!.terminal.attach(terminalId) + } + write(terminalId: string, data: string): void { window.baton!.terminal.write(terminalId, data) } @@ -149,6 +155,10 @@ class WebSocketTerminalClient implements TerminalClient { return promise } + async attachTerminal(_terminalId: string): Promise { + throw new Error('Attaching existing sessions is not supported by the WebSocket terminal backend yet') + } + write(terminalId: string, data: string): void { this.send({ type: 'write', terminalId, data }) } @@ -334,6 +344,22 @@ class DemoTerminalClient implements TerminalClient { } } + async attachTerminal(terminalId: string): Promise { + if (!this.lineBuffers.has(terminalId)) { + throw new Error('Demo terminal session is no longer available') + } + + return { + terminalId, + shell: 'demo', + shellId: 'demo', + cwd: '~', + status: 'running', + exitCode: null, + buffer: '', + } + } + write(terminalId: string, data: string): void { if (!this.lineBuffers.has(terminalId)) return @@ -476,6 +502,16 @@ export class BufferedTerminalClient implements TerminalClient { }) } + attachTerminal(terminalId: string): Promise { + return this.inner.attachTerminal(terminalId).then((response) => { + const next = response.buffer.length > this.maxBufferBytes + ? response.buffer.slice(-this.maxBufferBytes) + : response.buffer + this.buffers.set(terminalId, next) + return { ...response, buffer: next } + }) + } + write(terminalId: string, data: string): void { this.inner.write(terminalId, data) } diff --git a/src/shared/session-host-protocol.ts b/src/shared/session-host-protocol.ts new file mode 100644 index 0000000..93e0223 --- /dev/null +++ b/src/shared/session-host-protocol.ts @@ -0,0 +1,97 @@ +export interface HostCreateRequest { + type: 'create' + clientId: string + cols?: number + rows?: number + cwd?: string + shellId?: string + wslDistro?: string +} + +export interface HostAttachRequest { + type: 'attach' + clientId: string + terminalId: string +} + +export interface HostWriteRequest { + type: 'write' + terminalId: string + data: string +} + +export interface HostResizeRequest { + type: 'resize' + terminalId: string + cols?: number + rows?: number +} + +export interface HostCloseRequest { + type: 'close' + clientId: string + terminalId: string +} + +export type HostClientMessage = + | HostCreateRequest + | HostAttachRequest + | HostWriteRequest + | HostResizeRequest + | HostCloseRequest + +export interface HostCreatedMessage { + type: 'created' + clientId: string + terminalId: string + shell: string + shellId: string + pid?: number + cwd?: string +} + +export interface HostAttachedMessage { + type: 'attached' + clientId: string + terminalId: string + shell: string + shellId: string + pid?: number + cwd?: string + status: 'running' | 'exited' + exitCode: number | null + buffer: string +} + +export interface HostClosedMessage { + type: 'closed' + clientId: string + ok: boolean +} + +export interface HostDataMessage { + type: 'data' + terminalId: string + data: string +} + +export interface HostExitMessage { + type: 'exit' + terminalId: string + exitCode: number | null + signal?: number | null +} + +export interface HostErrorMessage { + type: 'error' + clientId?: string + message: string +} + +export type HostServerMessage = + | HostCreatedMessage + | HostAttachedMessage + | HostClosedMessage + | HostDataMessage + | HostExitMessage + | HostErrorMessage diff --git a/src/shared/terminal-types.ts b/src/shared/terminal-types.ts index 3f76b07..f824892 100644 --- a/src/shared/terminal-types.ts +++ b/src/shared/terminal-types.ts @@ -14,6 +14,21 @@ export interface TerminalCreateResponse { cwd?: string } +export interface TerminalAttachRequest { + terminalId: string +} + +export interface TerminalAttachResponse { + terminalId: string + shell: string + shellId: string + pid?: number + cwd?: string + status: 'running' | 'exited' + exitCode: number | null + buffer: string +} + export interface ShellDescriptorDTO { id: string label: string