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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,46 @@ sessions, and controlled external-agent access. It is designed for WSL2, native
Windows, macOS, and Linux, with browser-based terminal tabs that stay attached
to the StandTerm server process across page reloads.

![StandTerm Demo](standterm_demo.gif)
**Desktop evaluation builds are available for Windows x64 and macOS Apple Silicon.**
[Download and install StandTerm Desktop](#desktop-downloads-evaluation), or use
the [browser-based Core quick start](#quick-start).

![StandTerm Desktop with terminal rendering tests, local and SSH tabs, and a floating PowerShell terminal](standterm_desktop.png)

*Desktop development preview. The toolbar shown above is newer than the published
Desktop 0.4.3 installers.*

## Desktop Downloads (Evaluation)

[StandTerm Desktop 0.4.3](https://github.com/askac/standterm/releases/tag/desktop-v0.4.3)
is available as an evaluation pre-release, not a production-qualified release.

| Platform | Download | Required before installation |
| --- | --- | --- |
| Windows x64, including Windows + WSL | [Windows installer (.exe)](https://github.com/askac/standterm/releases/download/desktop-v0.4.3/StandTerm-Desktop-0.4.3-win32-x64-Setup.exe) | Python 3.10+ with venv/ensurepip in each selected environment; WSL mode also needs an existing WSL distribution. |
| macOS Apple Silicon | [macOS installer (.dmg)](https://github.com/askac/standterm/releases/download/desktop-v0.4.3/StandTerm-Desktop-0.4.3-mac-arm64.dmg) | Native arm64 Python 3.10+ with venv/ensurepip. Intel/Rosetta is not qualified. |

Packages include Electron and Core. **Git, Node.js and npm are not required**;
Python and its virtual environment are not bundled.

1. Download the package for your platform and check the release's
[SHA256SUMS](https://github.com/askac/standterm/releases/download/desktop-v0.4.3/SHA256SUMS).
2. On Windows, run the installer and choose **Windows only**, **Windows + WSL**
or **WSL only**. Native Windows mode needs 64-bit Windows Python; installing
Windows Python does not satisfy WSL mode. On macOS, copy the app to a
user-owned Applications folder, then launch it.
3. Approve environment preparation and wait for Core's private venv and Python
dependencies to finish installing. This requires network access. StandTerm
does not install system Python or WSL automatically.

Windows builds are unsigned; macOS builds are ad-hoc signed without notarization,
so OS security warnings or launch restrictions are possible. Checksums detect
corruption but do not replace publisher signing. Before upgrading, save your work
and fully quit Desktop, including tray windows; updates are installed manually.

See the [Desktop guide](desktop/README.md) for setup, shortcuts, diagnostics and
known limitations. For browser-based use or platforms without a Desktop package,
continue with Core below.

## Quick Start

Expand Down
90 changes: 81 additions & 9 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,11 +344,13 @@ layout is shared with Core Web and does not change approval policy or payloads.

## Capture (desktop-only add-on)

Use the native **Capture** menu:
Use the direct capture buttons in the Desktop toolbar. Capture shortcuts are
also available under **View**; there is no separate Capture dropdown:

- **Copy screenshot** (Ctrl/Cmd+Alt+S): PNG to the OS clipboard.
- **Save screenshot as PNG...**: choose a new file with the native Save dialog.
- **Start recording (WebM)...**: choose a new file before recording starts.
- **Save screenshot (PNG)**: save directly to the configured screenshot folder.
- **Start recording (WebM)**: record directly to the configured recording folder.
- **Pause / resume recording**: suspend capture without finishing the file.
- **Stop and save recording** (Ctrl/Cmd+Alt+R): finalize the silent WebM.

Capture includes the visible StandTerm page, including any open in-page panels
Expand All @@ -357,7 +359,8 @@ native dialogs, detached PiP windows, off-screen scrollback, microphone or syste
audio. Review visible secrets before sharing a capture. Switching StandTerm tabs
while recording records the newly visible tab as well.

The native title shows `[REC mm:ss]` and the menu shows recording status. Closing
The native title and Desktop toolbar show recording status and elapsed active
recording time. The stop button remains available while paused. Closing
or quitting asks whether to keep recording or stop and save. Hiding, minimizing,
reloading or entering fullscreen stops and saves automatically, because hidden
pages can stop producing frames or hide the indicator. Leave fullscreen before
Expand All @@ -380,17 +383,83 @@ If publication fails, the error reports the retained partial file. Unix files
are created with mode `0600`; Windows access follows the destination directory's
ACL. Screenshot files use the same no-overwrite publication policy.

The save directory starts at the desktop OS Downloads directory and remembers
the last selection for this app run only. With Windows Electron + WSL Core,
The first save uses a native folder chooser, initially suggesting Downloads.
Canceling does not save a file or start a recording. The chosen folder is shared
by PNG and WebM until customized in **StandTerm > Capture Settings...**. Folder
preferences persist in `capture-settings.json` in Desktop's user-data directory,
independent of backend origins/ports; settings contain paths only, no credentials.
Captures use timestamped, randomized filenames. An unavailable folder prompts for
a replacement rather than silently changing destinations. With Windows Electron + WSL Core,
captures and clipboard belong to **Windows**, not the WSL filesystem/clipboard.
Format, save dialogs and clipboard behavior stay entirely in `desktop/`; Core,
Format, folder dialogs and clipboard behavior stay entirely in `desktop/`; Core,
browser UI and external agents gain no capture API or filesystem authority.

Video runs in a separate app-owned, sandboxed local recorder page with no backend
cookies, Node access, preload or IPC bridge. A one-use main-process grant selects
only the existing StandTerm main frame; it never enumerates desktop windows or
grants camera/microphone access. The terminal page cannot initiate capture.

## Desktop menus and shared terminal toolbar

Core and Web use the same persistent tab row: adjacent New Tab, an independently
reachable Pause Agent button, and right-aligned Agent Panel, Files, Settings and
More actions. Close all terminal tabs retains confirmation; the last terminal's
individual close button is hidden. Main-toolbar Pause always targets the main
active terminal; Panel and PiP Pause target their own terminal.

Desktop adds Settings, tab actions, Files and Agent controls to native menus.
Terminal-scoped commands require the main window to be focused and recheck Core's
typed action availability and terminal ID at invocation. Core reloads and older
Core versions without the action interface disable those commands. Query-string
flags never grant native capabilities.

**StandTerm > Browser Access** offers Open in browser, Copy browser authorization
URL, Copy access URL and Copy access token. Token/URL copies are sensitive and
requested on demand from the authenticated Core session, never copied from its
session cookie. Authorizing another browser requires confirmation and the existing
launcher-authorized endpoint; the launcher credential stays only in the main
process. The link contains an access token as well as a one-time authorization
grant, so the entire URL must not be described as short-lived. These actions do
not relax the separate external-link opener's loopback restrictions. Diagnostics
and Agent connection info remain credential-free.

The Desktop toolbar is a bundled local page, separate from the authenticated Core
WebContentsView. Only the toolbar has a narrowly scoped preload; its private
session has no backend cookies, no network access, and no camera/microphone grants.
IPC validates the exact sender and main frame, and accepts only fixed menu/capture
actions. Core and floating windows remain sandboxed with no Node or preload.
Windows/Linux show menu buttons beside capture controls; macOS retains its system
application menu and shows capture controls in the window's Desktop toolbar.

Toolbar SVG artwork is original StandTerm geometric artwork under the project
license. No third-party icon paths, icon package, web font or remote image is used.
Transient toolbar messages use a blue-gray bordered status area, distinct from
menu buttons. Normal notices fade after five seconds; red error notices remain
for ten seconds. The reserved space prevents controls from shifting. Long text
is truncated with the full message available on hover, and reduced-motion
preferences disable the fade animation. Recording updates do not replay old
messages. This shared renderer behavior leaves the macOS system menu unchanged.

The toolbar change is a local evaluation snapshot, not the published 0.4.3 release. Source validation
covers Windows Electron with both native Windows and WSL Core, native menu target guards, browser access,
compact layout, PNG/clipboard and decodable silent WebM with pause/resume. The Core
browser suite includes a regression for main-toolbar Pause with a different Agent
Panel target. WSLg floating/capture checks run, but its window manager can refuse
automated main-window activation; the focus-sensitive smoke remains unqualified
there rather than bypassing the guard. Native macOS toolbar placement, Retina
rendering, menu focus and folder dialogs still require a Mac acceptance run.
The final Windows and Windows-with-WSL source runs passed screenshot and video
checks, as did the packaged Windows-with-WSL run. The native Windows packaged smoke
still intermittently fails its Core preview with Chromium `UnknownVizError`; its
native cause is not established and this remains an evaluation limitation.
Separately, the WSL test's stale download timer and unintended native browser
prompt were corrected; those test dialogs could interrupt subsequent checks.
Screenshot requests now have a bounded timeout and record only window
dimensions and visibility/focus flags on failure, never image or terminal content.
Recording elapsed time is shown in the toolbar; it freezes while paused and
excludes paused intervals after resuming. Both the timer logic and real toolbar
display are covered by tests.

## Agent prompts and bundled Core

The top-level **Agent** menu is the first-use entrypoint; no prior StandTerm
Expand Down Expand Up @@ -490,11 +559,14 @@ profile may write a cookie to disk during a run, so this is not a promise that
credentials never touch disk. HTTP cache is disabled. The desktop bootstrap
does not pass authentication secrets through
URLs, arguments, environment variables or renderer JavaScript. The existing
user-requested **Show/Copy Access URL** action remains available. Other clients still need normal
user-requested **Show/Copy Access URL** action remains available. Explicit native
**Browser Access** actions can copy sensitive access information or open an
authorization URL in the OS browser after confirmation; this is separate from
the credential-free Desktop startup URL. Other clients still need normal
StandTerm authentication. External-agent discovery files retain their existing
permission model and are separate from desktop-login credentials.

The UI has Node integration disabled, context isolation, renderer sandboxing
The authenticated Core UI has Node integration disabled, context isolation, renderer sandboxing
and web security enabled, and no preload or IPC bridge. Network requests are
limited to the owned loopback HTTP/WebSocket origin and local data/blob images.
Other navigation, arbitrary page-created child windows, webviews and device permissions are denied.
Expand Down
3 changes: 2 additions & 1 deletion desktop/agent-menu.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ const PROMPTS = {
+ 'Do not fall back to terminal-stream rescue without a new explicit instruction. ',
};

function agentMenu({ origin, mode, instanceId, copyText, showHelp }) {
function agentMenu({ origin, mode, instanceId, copyText, showHelp, uiItems = [] }) {
const info = agentConnectionInfo({ origin, mode, instanceId });
const json = JSON.stringify(info, null, 2);
const prompt = kind => PROMPTS[kind] + BOUNDARY + '\n\n' + json;
return { id: 'agent-menu', label: 'Agent', submenu: [
...uiItems,
{ id: 'agent-help', label: 'Getting started...', click: showHelp },
{ type: 'separator' },
{ id: 'agent-copy-usage', label: 'Copy usage prompt', click: () => copyText(prompt('usage')) },
Expand Down
80 changes: 80 additions & 0 deletions desktop/browser-access.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
'use strict';

function validateAccessUrl(value, origin, authorization = false) {
if (typeof value !== 'string' || value.length > 8192) throw new Error('Invalid access response.');
const url = new URL(value);
if (url.origin !== origin || url.pathname !== '/' || url.username || url.password || url.hash
|| !url.searchParams.get('token') || url.searchParams.getAll('token').length !== 1
|| (authorization && (!url.searchParams.get('authorize') || url.searchParams.getAll('authorize').length !== 1))
|| [...url.searchParams.keys()].some(key => !['token', 'authorize'].includes(key))) {
throw new Error('Invalid access response.');
}
return url;
}

function createBrowserAccess({ origin, session, launcherToken, available, confirm, copy, open, notify }) {
const base = new URL(origin);
if (base.origin !== origin || base.protocol !== 'http:' || base.hostname !== '127.0.0.1'
|| !base.port || typeof launcherToken !== 'string' || !launcherToken) throw new Error('Invalid browser access authority.');
let pending = false;
async function request(route, options = {}) {
if (!available()) throw new Error('Desktop is unavailable.');
const response = await session.fetch(`${origin}${route}`, {
...options, credentials: 'include', redirect: 'error', signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error('Browser access request failed.');
const reader = response.body.getReader();
const chunks = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 16384) throw new Error('Invalid access response.');
chunks.push(Buffer.from(value));
}
} finally { await reader.cancel().catch(() => {}); }
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (data?.status !== 'ok' || !available()) throw new Error('Browser access request failed.');
return data;
}
async function run(action) {
if (!['open', 'copy-auth', 'copy-url', 'copy-token'].includes(action) || pending || !available()) return false;
pending = true;
try {
if (['open', 'copy-auth'].includes(action) && !await confirm(action)) return false;
const data = await request('/access-url');
let url = validateAccessUrl(data.access_url, origin);
if (['open', 'copy-auth'].includes(action)) {
const grant = await request('/launcher/browser_authorization_url', {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-StandTerm-Launcher-Token': launcherToken },
body: new URLSearchParams({ access_url: url.href }).toString(),
});
url = validateAccessUrl(grant.authorization_url, origin, true);
}
if (!available()) return false;
if (action === 'open') await open(url.href);
else copy(action === 'copy-token' ? url.searchParams.get('token') : url.href);
await notify(action === 'open' ? 'Browser authorization opened in the default browser.' : 'Access information copied. Treat it as a password.');
return true;
} catch {
// Never forward URL-bearing network errors, access tokens or grants to logs/UI.
await notify('Could not prepare browser access. Check that this Desktop backend is still running.', true);
return false;
} finally { pending = false; }
}
return {
run,
dispose: () => { launcherToken = ''; },
menu: { label: 'Browser Access', submenu: [
{ label: 'Open in browser...', click: () => run('open') },
{ label: 'Copy browser authorization URL...', click: () => run('copy-auth') },
{ type: 'separator' },
{ label: 'Copy access URL (sensitive)', click: () => run('copy-url') },
{ label: 'Copy access token (sensitive)', click: () => run('copy-token') },
] },
};
}

module.exports = { createBrowserAccess, validateAccessUrl };
2 changes: 1 addition & 1 deletion desktop/capture-file.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class CaptureFile {
if (!this.bytes) throw new Error('No capture frames were recorded.');
if (this.handle) await this.handle.sync();
await this.close();
// Publish without overwriting a file created after the Save dialog. A hard
// Publish without overwriting a file created after capture started. A hard
// link in the same directory does not copy the recording's data again.
await fs.link(this.partial, this.destination);
await fs.unlink(this.partial);
Expand Down
53 changes: 53 additions & 0 deletions desktop/capture-settings.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');
const { randomUUID } = require('node:crypto');

class CaptureSettings {
constructor(file) {
this.file = file;
this.directories = {};
try {
if (fs.statSync(file).size > 16384) return;
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
if (data.version !== 1) return;
for (const kind of ['png', 'webm']) {
if (typeof data[kind] === 'string' && path.isAbsolute(data[kind]) && !data[kind].includes('\0')) {
this.directories[kind] = data[kind];
}
}
} catch { /* Missing or invalid preferences require a new folder selection. */ }
}

get(kind) {
if (!['png', 'webm'].includes(kind)) throw new Error('Invalid capture format.');
return this.directories[kind] || null;
}

set(kind, directory) {
this.get(kind);
if (typeof directory !== 'string' || !path.isAbsolute(directory) || directory.includes('\0')) {
throw new Error('Choose an absolute capture folder.');
}
const next = { ...this.directories, [kind]: directory };
// The first chosen folder is shared until either format is customized.
for (const format of ['png', 'webm']) if (!next[format]) next[format] = directory;
fs.mkdirSync(path.dirname(this.file), { recursive: true });
const temporary = `${this.file}.${randomUUID()}.tmp`;
try {
fs.writeFileSync(temporary, JSON.stringify({ version: 1, ...next }, null, 2), { flag: 'wx', mode: 0o600 });
fs.renameSync(temporary, this.file);
this.directories = next;
} finally {
try { fs.unlinkSync(temporary); } catch (error) { if (error.code !== 'ENOENT') throw error; }
}
}
}

function captureName(extension, now = new Date()) {
if (!['png', 'webm'].includes(extension)) throw new Error('Invalid capture format.');
return `StandTerm-${now.toISOString().replace(/[:.]/g, '-')}-${randomUUID().slice(0, 8)}.${extension}`;
}

module.exports = { CaptureSettings, captureName };
Loading
Loading