diff --git a/README.md b/README.md index 3b88071..c65ee60 100644 --- a/README.md +++ b/README.md @@ -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. - +**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). + + + +*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 diff --git a/desktop/README.md b/desktop/README.md index 0f3d949..cc22db8 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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 @@ -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 @@ -380,10 +383,15 @@ 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 @@ -391,6 +399,67 @@ cookies, Node access, preload or IPC bridge. A one-use main-process grant select 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 @@ -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. diff --git a/desktop/agent-menu.cjs b/desktop/agent-menu.cjs index d51bc11..bdbf942 100644 --- a/desktop/agent-menu.cjs +++ b/desktop/agent-menu.cjs @@ -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')) }, diff --git a/desktop/browser-access.cjs b/desktop/browser-access.cjs new file mode 100644 index 0000000..9893a4a --- /dev/null +++ b/desktop/browser-access.cjs @@ -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 }; diff --git a/desktop/capture-file.cjs b/desktop/capture-file.cjs index 6da8911..d04714f 100644 --- a/desktop/capture-file.cjs +++ b/desktop/capture-file.cjs @@ -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); diff --git a/desktop/capture-settings.cjs b/desktop/capture-settings.cjs new file mode 100644 index 0000000..d4af6e4 --- /dev/null +++ b/desktop/capture-settings.cjs @@ -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 }; diff --git a/desktop/capture.cjs b/desktop/capture.cjs index a6df070..1bfcb38 100644 --- a/desktop/capture.cjs +++ b/desktop/capture.cjs @@ -1,10 +1,12 @@ 'use strict'; -const { app, BrowserWindow, Menu, clipboard, ClipboardItem, dialog, session } = require('electron'); +const { app, BrowserWindow, Menu, clipboard, ClipboardItem, dialog, session, shell } = require('electron'); +const fs = require('node:fs/promises'); const path = require('node:path'); const { pathToFileURL } = require('node:url'); const { randomUUID } = require('node:crypto'); const { CaptureFile, MAX_CHUNK_BYTES } = require('./capture-file.cjs'); +const { CaptureSettings, captureName } = require('./capture-settings.cjs'); const RECORDER_URL = pathToFileURL(path.join(__dirname, 'recorder.html')).href; const RECORDER_SCRIPT_URL = pathToFileURL(path.join(__dirname, 'recorder.js')).href; @@ -20,15 +22,19 @@ async function bounded(promise) { } class DesktopCapture { - constructor(win, { onChange = () => {}, notify = null } = {}) { + constructor(win, { contents = win.webContents, onChange = () => {}, onDiagnostic = () => {}, notify = null } = {}) { this.win = win; + this.contents = contents; this.onChange = onChange; + this.onDiagnostic = onDiagnostic; this.notify = notify || ((message, error = false) => dialog.showMessageBox(win, { type: error ? 'error' : 'info', title: 'StandTerm Capture', message, })); this.state = 'idle'; this.job = null; this.directory = app.getPath('downloads'); + this.settings = new CaptureSettings(path.join(app.getPath('userData'), 'capture-settings.json')); + this.folderSelection = null; this.screenshotBusy = false; // Reuse one private partition; Electron retains sessions for the app lifetime. this.recorderPartition = `standterm-recorder-${randomUUID()}`; @@ -38,25 +44,27 @@ class DesktopCapture { update() { const menu = Menu.getApplicationMenu(); - const elapsed = this.job?.startedAt ? Math.floor((Date.now() - this.job.startedAt) / 1000) : 0; + const elapsed = this.job?.startedAt ? Math.floor(((this.job.pausedAt || Date.now()) - this.job.startedAt - (this.job.pausedMs || 0)) / 1000) : 0; const clock = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`; - const label = this.state === 'recording' ? `Recording ${clock}` + const label = this.state === 'paused' ? `Recording paused ${clock}` : this.state === 'recording' ? `Recording ${clock}` : this.state === 'idle' ? 'Not recording' : `${this.state === 'starting' ? 'Starting' : 'Saving'} recording...`; const status = menu?.getMenuItemById('capture-status'); if (status) status.label = label; const start = menu?.getMenuItemById('capture-start'); if (start) start.enabled = !this.active; const stop = menu?.getMenuItemById('capture-stop'); - if (stop) stop.enabled = this.state === 'recording'; - this.onChange(this.active ? `REC ${clock}` : ''); + if (stop) stop.enabled = ['recording', 'paused'].includes(this.state); + this.onChange(this.active ? `${this.state === 'paused' ? 'PAUSED' : 'REC'} ${clock}` : '', { + state: this.state, label, screenshotBusy: this.screenshotBusy, + }); } menu() { return { label: 'Capture', submenu: [ { label: 'Copy screenshot', accelerator: 'CommandOrControl+Alt+S', click: () => this.screenshot('clipboard') }, - { label: 'Save screenshot as PNG...', click: () => this.screenshot('file') }, + { label: 'Save screenshot (PNG)', click: () => this.screenshot('file') }, { type: 'separator' }, - { id: 'capture-start', label: 'Start recording (WebM)...', click: () => this.start() }, + { id: 'capture-start', label: 'Start recording (WebM)', click: () => this.start() }, { id: 'capture-stop', label: 'Stop and save recording', accelerator: 'CommandOrControl+Alt+R', enabled: false, click: () => this.stop() }, { id: 'capture-status', label: 'Not recording', enabled: false }, @@ -64,32 +72,79 @@ class DesktopCapture { } async chooseFile(extension) { - const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - const result = await dialog.showSaveDialog(this.win, { - title: extension === 'png' ? 'Save StandTerm screenshot' : 'Save silent StandTerm recording', - message: 'Choose a new filename. Existing files are not replaced.', - defaultPath: path.join(this.directory, `StandTerm-${stamp}.${extension}`), - filters: [{ name: extension.toUpperCase(), extensions: [extension] }], - buttonLabel: extension === 'png' ? 'Save screenshot' : 'Start recording', - }); - if (result.canceled || !result.filePath) return null; - const destination = result.filePath.toLowerCase().endsWith(`.${extension}`) - ? result.filePath : `${result.filePath}.${extension}`; - this.directory = path.dirname(destination); - return destination; + let directory = this.settings.get(extension); + if (directory) { + try { + if (!(await fs.stat(directory)).isDirectory()) throw new Error('Not a folder.'); + await fs.access(directory, fs.constants.W_OK); + } catch { + await this.notify('The saved capture folder is unavailable. Choose a writable folder again.', true); + directory = null; + } + } + if (!directory) directory = await this.chooseDirectory(extension); + return directory ? path.join(directory, captureName(extension)) : null; + } + + async chooseDirectory(extension) { + if (this.folderSelection) { await this.folderSelection; return this.settings.get(extension); } + this.folderSelection = (async () => { + const result = await dialog.showOpenDialog(this.win, { + title: extension === 'png' ? 'Choose screenshot folder' : 'Choose recording folder', + message: 'Future captures save here automatically. Change this in StandTerm > Capture Settings.', + defaultPath: this.settings.get(extension) || this.directory, + properties: ['openDirectory', 'createDirectory'], buttonLabel: 'Use this folder', + }); + if (result.canceled || !result.filePaths?.[0]) return null; + const directory = result.filePaths[0]; + if (!(await fs.stat(directory)).isDirectory()) throw new Error('Choose a folder.'); + await fs.access(directory, fs.constants.W_OK); + this.settings.set(extension, directory); + return directory; + })(); + try { return await this.folderSelection; } finally { this.folderSelection = null; } + } + + async configure() { + try { + const { response } = await dialog.showMessageBox(this.win, { + title: 'Capture Settings', message: 'Desktop capture folders', + detail: `Screenshots: ${this.settings.get('png') || 'Choose on first save'}\nRecordings: ${this.settings.get('webm') || 'Choose on first recording'}\n\nPNG screenshots and silent WebM recordings are saved automatically.`, + buttons: ['Done', 'Screenshot folder...', 'Recording folder...', 'Open screenshot folder', 'Open recording folder'], + defaultId: 0, cancelId: 0, noLink: true, + }); + if (response === 1 || response === 2) await this.chooseDirectory(response === 1 ? 'png' : 'webm'); + if (response === 3 || response === 4) { + const directory = this.settings.get(response === 3 ? 'png' : 'webm'); + if (!directory) return; + const error = await shell.openPath(directory); + if (error) throw new Error('Could not open the capture folder.'); + } + } catch (error) { await this.notify(error.message, true); } } async screenshot(kind, destination = null) { if (this.screenshotBusy) return; this.screenshotBusy = true; + this.update(); let output; try { if (this.win.isDestroyed() || !this.win.isVisible() || this.win.isMinimized()) { throw new Error('Show the StandTerm window before capturing it.'); } // Capture before opening a dialog so the saved image is the requested view. - const image = await this.win.webContents.capturePage(); - if (image.isEmpty()) throw new Error('The window did not produce an image.'); + let image; + try { + image = await bounded(this.contents.capturePage()); + if (image.isEmpty()) throw new Error('Empty capture.'); + } catch (error) { + if (!this.win.isDestroyed()) { + const [width, height] = this.win.getContentSize(); + this.onDiagnostic({ width, height, visible: this.win.isVisible(), + minimized: this.win.isMinimized(), focused: this.win.isFocused() }); + } + throw new Error('The terminal view could not be captured. Bring StandTerm to the foreground and retry. Window state is available in Diagnostics.', { cause: error }); + } const png = image.toPNG(); if (kind === 'clipboard') { await clipboard.write([new ClipboardItem({ 'image/png': new Blob([png], { type: 'image/png' }) })]); @@ -109,7 +164,7 @@ class DesktopCapture { await output?.close().catch(() => {}); await this.notify(`${error.message}${output?.partial ? `\nUnfinished file retained at:\n${output.partial}` : ''}`, true); return { error: true }; - } finally { this.screenshotBusy = false; } + } finally { this.screenshotBusy = false; this.update(); } } start(destination = null) { @@ -154,7 +209,7 @@ class DesktopCapture { && request.frame?.url === RECORDER_URL && !this.win.isDestroyed(); if (!allowed) { callback({}); return; } job.grantPending = false; - callback({ video: this.win.webContents.mainFrame }); + callback({ video: this.contents.mainFrame }); }); isolated.webRequest.onBeforeRequest((details, callback) => callback({ cancel: ![RECORDER_URL, RECORDER_SCRIPT_URL].includes(details.url), @@ -168,7 +223,7 @@ class DesktopCapture { job.recorder.webContents.on('will-attach-webview', event => event.preventDefault()); job.recorder.webContents.on('render-process-gone', () => { job.error = new Error('The recording process stopped unexpectedly.'); - if (this.state === 'recording') void this.stop(); + if (['recording', 'paused'].includes(this.state)) void this.stop(); }); await bounded(job.recorder.loadURL(RECORDER_URL)); job.grantPending = true; @@ -198,11 +253,11 @@ class DesktopCapture { await job.output.write(new Uint8Array(chunk)); } if (data.error) throw new Error(`Recording stopped: ${data.error}`); - if (data.stopped && this.state === 'recording') throw new Error('The recording stream ended.'); + if (data.stopped && ['recording', 'paused'].includes(this.state)) throw new Error('The recording stream ended.'); } async pump(job) { - if (this.job !== job || this.state !== 'recording' || job.pumping) return; + if (this.job !== job || !['recording', 'paused'].includes(this.state) || job.pumping) return; job.pumping = this.drain(job); try { await job.pumping; } catch (error) { job.error = error; @@ -234,6 +289,22 @@ class DesktopCapture { try { return await this.stopping; } finally { this.stopping = null; } } + async togglePause() { + const job = this.job; + if (!job || this.pausePending || !['recording', 'paused'].includes(this.state)) return; + const pause = this.state === 'recording'; + this.pausePending = true; + try { + await bounded(job.recorder.webContents.executeJavaScript(`window.recorder.${pause ? 'pause' : 'resume'}()`)); + if (this.job !== job || !['recording', 'paused'].includes(this.state)) return; + if (pause) job.pausedAt = Date.now(); + else { job.pausedMs = (job.pausedMs || 0) + Date.now() - job.pausedAt; job.pausedAt = null; } + this.state = pause ? 'paused' : 'recording'; + this.update(); + } catch (error) { job.error = error; if (this.job === job) await this.stop(); } + finally { this.pausePending = false; } + } + async finish(job, publish) { clearInterval(job.timer); job.grantPending = false; diff --git a/desktop/diagnostics.cjs b/desktop/diagnostics.cjs index 0163ae3..6a7aba6 100644 --- a/desktop/diagnostics.cjs +++ b/desktop/diagnostics.cjs @@ -4,7 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const EVENTS = new Set(['startup', 'setup_start', 'setup_ready', 'backend_launch', 'backend_ready', 'backend_exit', 'backend_spawn_failed', 'backend_verify_retry', 'backend_verified', - 'host_port_rejected', 'port_change', 'window_ready', 'startup_failed', 'shutdown', 'devtools_opened']); + 'host_port_rejected', 'port_change', 'window_ready', 'startup_failed', 'shutdown', 'devtools_opened', 'capture_failed']); const CODES = new Set(['EACCES', 'EADDRINUSE', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOENT', 'EPIPE', 'HOST_PORT_UNAVAILABLE', 'PORT_IN_USE', 'SETUP_CANCELED']); const MAX_LOG_BYTES = 256 * 1024; @@ -21,11 +21,14 @@ function createDiagnostics(directory, { mode, version }) { if (/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) record.version = version; // Whitelist structured fields. Never persist errors, stdout/stderr, URLs, // terminal data, environment values, credential objects or arbitrary text. - for (const key of ['port', 'candidate', 'attempt', 'elapsedMs', 'exitCode']) { + for (const key of ['port', 'candidate', 'attempt', 'elapsedMs', 'exitCode', 'width', 'height']) { if (Number.isSafeInteger(details[key]) && details[key] >= 0 && details[key] <= 2147483647) record[key] = details[key]; } if (CODES.has(details.code)) record.code = details.code; if (typeof details.expected === 'boolean') record.expected = details.expected; + for (const key of ['visible', 'minimized', 'focused']) { + if (typeof details[key] === 'boolean') record[key] = details[key]; + } recent.push(record); if (recent.length > 200) recent.shift(); try { @@ -52,7 +55,7 @@ function agentConnectionInfo({ origin, mode, instanceId }) { function diagnosticsMenu({ origin, mode, instanceId, version, coreVersion, logger, openLogs, openTools, openStatus, copyText, persistent = false }) { const info = agentConnectionInfo({ origin, mode, instanceId }); - return { label: 'Diagnostics', submenu: [ + return { id: 'diagnostics', label: 'Diagnostics', submenu: [ { id: 'diagnostics-status', label: 'Status and recent events...', click: openStatus }, { id: 'diagnostics-version', label: `StandTerm Desktop ${version}`, enabled: false }, { id: 'diagnostics-core-version', label: `Core version: ${coreVersion || 'Unknown (older Core)'}`, enabled: false }, diff --git a/desktop/electron-builder.cjs b/desktop/electron-builder.cjs index 7398439..6bfd119 100644 --- a/desktop/electron-builder.cjs +++ b/desktop/electron-builder.cjs @@ -7,8 +7,8 @@ module.exports = { // Keep development app copies out of macOS Spotlight application results. directories: { output: process.platform === 'darwin' ? 'out.noindex' : 'out', buildResources: '.' }, asar: true, - files: ['*.cjs', '*.html', 'recorder.js', 'package.json', 'README.md', 'LICENSE', - 'test/capture-smoke.cjs', 'test/floating-smoke.cjs', 'test/external-links-smoke.cjs', '!electron-builder.cjs', '!stage-windows.cjs', '!build-icon.cjs'], + files: ['*.cjs', '*.html', 'recorder.js', 'toolbar.js', 'toolbar.css', 'package.json', 'README.md', 'LICENSE', + 'test/capture-smoke.cjs', 'test/floating-smoke.cjs', 'test/external-links-smoke.cjs', 'test/toolbar-smoke.cjs', '!electron-builder.cjs', '!stage-windows.cjs', '!build-icon.cjs'], extraResources: [{ from: 'bundle', to: 'bundle' }], mac: { icon: 'standterm.icns', diff --git a/desktop/floating-windows.cjs b/desktop/floating-windows.cjs index b372d53..bdd656b 100644 --- a/desktop/floating-windows.cjs +++ b/desktop/floating-windows.cjs @@ -2,28 +2,28 @@ const { allowedFloatingWindow, allowedFilesDownload, allowedNavigation } = require('./policy.cjs'); -function installFloatingWindows(opener, origin, openExternal = () => {}) { +function installFloatingWindows(opener, origin, openExternal = () => {}, contents = opener.webContents) { const children = new Set(); const closing = new WeakSet(); - const download = (contents, details) => { - if (!details.postBody && allowedNavigation(opener.webContents.getURL(), origin)) { - if (allowedFilesDownload(details.url, origin)) contents.downloadURL(details.url); - else void openExternal(details.url, contents); + const download = (target, details) => { + if (!details.postBody && allowedNavigation(contents.getURL(), origin)) { + if (allowedFilesDownload(details.url, origin)) target.downloadURL(details.url); + else void openExternal(details.url, target); } return { action: 'deny' }; }; - opener.webContents.setWindowOpenHandler(details => { - if (allowedFilesDownload(details.url, origin)) return download(opener.webContents, details); - if (details.url !== 'about:blank') return download(opener.webContents, details); + contents.setWindowOpenHandler(details => { + if (allowedFilesDownload(details.url, origin)) return download(contents, details); + if (details.url !== 'about:blank') return download(contents, details); if ([...children].some(child => !closing.has(child)) - || !allowedFloatingWindow(details, opener.webContents.getURL(), origin)) return { action: 'deny' }; + || !allowedFloatingWindow(details, contents.getURL(), origin)) return { action: 'deny' }; // about:blank inherits the opener's sandbox, no-Node/no-preload preferences // and private session. Do not create a second privileged renderer bridge. return { action: 'allow', outlivesOpener: false, overrideBrowserWindowOptions: { autoHideMenuBar: true, alwaysOnTop: true, minimizable: false, maximizable: false, fullscreenable: false } }; }); - opener.webContents.on('did-create-window', child => { + contents.on('did-create-window', child => { children.add(child); child.removeMenu(); child.webContents.setWindowOpenHandler(details => download(child.webContents, details)); @@ -42,11 +42,11 @@ function installFloatingWindows(opener, origin, openExternal = () => {}) { const closeChildren = () => { for (const child of children) if (!child.isDestroyed()) child.close(); }; - opener.webContents.on('did-start-navigation', (_event, _url, inPlace, isMainFrame) => { + contents.on('did-start-navigation', (_event, _url, inPlace, isMainFrame) => { if (isMainFrame && !inPlace) closeChildren(); }); opener.once('closed', closeChildren); - opener.webContents.once('render-process-gone', closeChildren); + contents.once('render-process-gone', closeChildren); } module.exports = { installFloatingWindows }; diff --git a/desktop/main.cjs b/desktop/main.cjs index 1e57b3a..d81158e 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -1,6 +1,6 @@ 'use strict'; -const { app, BrowserWindow, Menu, Tray, nativeImage, dialog, session, shell, clipboard } = require('electron'); +const { app, BrowserWindow, WebContentsView, Menu, Tray, nativeImage, dialog, session, shell, clipboard } = require('electron'); const { spawn } = require('node:child_process'); const http = require('node:http'); const path = require('node:path'); @@ -19,6 +19,9 @@ const { agentMenu } = require('./agent-menu.cjs'); const { browserSessionOptions, resetBrowserAuthentication } = require('./browser-session.cjs'); const { createStatusWindow } = require('./diagnostics-window.cjs'); const { createExternalOpener } = require('./external-links.cjs'); +const { createUiCommands } = require('./ui-commands.cjs'); +const { installToolbar } = require('./toolbar.cjs'); +const { createBrowserAccess } = require('./browser-access.cjs'); let maintenance; try { maintenance = installerRequest(process.argv); } catch (error) { @@ -278,6 +281,9 @@ async function start() { httpOnly: true, sameSite: 'strict', path: '/', }); handoff.session_token = ''; + // Retain launcher authority only in the main process for explicit native + // Browser Access actions; it is never passed to either renderer or persisted. + const launcherToken = handoff.launcher_token; handoff.launcher_token = ''; let icon; if (!smoke) { @@ -287,21 +293,45 @@ async function start() { title: MODES[mode], width: 1280, height: 850, minWidth: 640, minHeight: 480, show: false, icon, webPreferences: { - session: desktopSession, nodeIntegration: false, contextIsolation: true, + partition: 'standterm-desktop-toolbar', preload: path.join(__dirname, 'toolbar-preload.cjs'), + nodeIntegration: false, contextIsolation: true, sandbox: true, webSecurity: true, webviewTag: false, allowRunningInsecureContent: false, devTools: true, }, }); + const coreView = new WebContentsView({ webPreferences: { + session: desktopSession, nodeIntegration: false, contextIsolation: true, + sandbox: true, webSecurity: true, webviewTag: false, allowRunningInsecureContent: false, devTools: true, + } }); + const contents = coreView.webContents; + const commands = createUiCommands(win, contents, handoff.origin); + let toolbar; const updateTitle = () => { if (!win.isDestroyed()) win.setTitle(`${captureTitle ? `[${captureTitle}] ` : ''}${MODES[mode]} - ${pageTitle}`); }; capture = new DesktopCapture(win, { - onChange: value => { captureTitle = value; updateTitle(); }, + contents, + onDiagnostic: state => diagnostics.write('capture_failed', state), + onChange: (value, state) => { captureTitle = value; updateTitle(); toolbar?.send(state); }, + notify: async (message, error) => toolbar?.notify(message, error), ...(smoke ? { notify: async (message, error) => { if (error) console.error(message); } } : {}), }); - win.on('page-title-updated', (event, title) => { + toolbar = installToolbar(win, coreView, capture, commands); + const browserAccess = createBrowserAccess({ origin: handoff.origin, session: desktopSession, launcherToken, + available: () => !win.isDestroyed() && !contents.isDestroyed() && allowedNavigation(contents.getURL(), handoff.origin), + confirm: async () => { + const result = await dialog.showMessageBox(win, { type: 'warning', title: 'Authorize another browser', + message: 'Allow another browser to access this StandTerm instance?', + detail: 'The authorization URL contains sensitive access information. Share it only with your own trusted browser. Opening it may store it in browser history. A new authorization link replaces the previous unused link.', + buttons: ['Cancel', 'Continue'], defaultId: 0, cancelId: 0, noLink: true }); + return result.response === 1; + }, + copy: value => clipboard.writeText(value), open: value => shell.openExternal(value), notify: toolbar.notify, + }); + win.once('closed', browserAccess.dispose); + contents.on('page-title-updated', (event, title) => { event.preventDefault(); pageTitle = title; updateTitle(); @@ -321,27 +351,35 @@ async function start() { ['Platform', `${process.platform} / ${process.arch}`], ['Electron / Chromium / Node', `${process.versions.electron} / ${process.versions.chrome} / ${process.versions.node}`], ['Backend process', child && child.exitCode === null && child.signalCode === null ? 'Running' : 'Stopped'], - ['Renderer process', win.webContents.isCrashed() ? 'Crashed' : 'Running'], + ['Renderer process', contents.isCrashed() ? 'Crashed' : 'Running'], ['Browser storage', smoke ? 'Temporary test profile' : 'Persistent per origin; same as Core'], ['Profile directory', app.getPath('userData')], ['Port settings', settingsPath], ['Diagnostic log', diagnostics.file], ['Log writable', diagnostics.available ? 'Yes' : 'No'], - ['Security', 'Sandbox on; Node/preload off; external preview network blocked'], + ['Security', 'Core sandbox on; Node/preload off; isolated Desktop toolbar; external preview network blocked'], ], events: diagnostics.snapshot() }), { copyUrl: () => clipboard.writeText(connectionInfo.base_url), copyConnectionInfo: () => clipboard.writeText(JSON.stringify(connectionInfo, null, 2)), }); Menu.setApplicationMenu(Menu.buildFromTemplate([ - ...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []), - { label: 'StandTerm', submenu: [ + { id: 'standterm', label: 'StandTerm', submenu: [ + commands.item('settings'), + { label: 'Capture Settings...', click: () => capture.configure() }, + browserAccess.menu, + { type: 'separator' }, + commands.item('newTab'), commands.item('closeTab'), commands.item('closeAll'), + commands.item('files'), commands.item('pip'), + { type: 'separator' }, { id: 'desktop-about', label: 'About StandTerm Desktop', click: () => dialog.showMessageBox(win, { type: 'info', title: 'About StandTerm Desktop', message: `StandTerm Desktop ${app.getVersion()}`, detail: aboutDetails, buttons: ['OK'], noLink: true, }) }, { label: 'Show window', click: showWindow }, + ...(process.platform === 'darwin' ? [{ role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] : []), { label: 'Quit StandTerm', accelerator: 'CommandOrControl+Q', click: () => app.quit() }, ] }, - { role: 'editMenu' }, + { id: 'edit', role: 'editMenu' }, agentMenu({ origin: handoff.origin, mode, instanceId: handoff.instance_id, + uiItems: [commands.item('agentPanel'), commands.item('pauseAgent'), { type: 'separator' }], copyText: text => clipboard.writeText(text), showHelp: () => dialog.showMessageBox(win, { type: 'info', title: 'StandTerm Agent', message: 'Give your agent a StandTerm prompt', @@ -354,8 +392,10 @@ async function start() { buttons: ['OK'], noLink: true, }), }), - capture.menu(), - { label: 'View', submenu: [{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'togglefullscreen' }] }, + { id: 'view', label: 'View', submenu: [{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'togglefullscreen' }, + { type: 'separator' }, + ...capture.menu().submenu, + ] }, diagnosticsMenu({ origin: handoff.origin, mode, instanceId: handoff.instance_id, version: app.getVersion(), coreVersion: handoff.core_version, logger: diagnostics, persistent: !smoke, copyText: text => clipboard.writeText(text), @@ -367,7 +407,7 @@ async function start() { if (error) await dialog.showMessageBox(win, { type: 'warning', message: 'Could not open the diagnostics folder.', detail: diagnostics.directory }); }, openTools: async () => { - const opened = await openDeveloperTools(win.webContents, async () => { + const opened = await openDeveloperTools(contents, async () => { const answer = await dialog.showMessageBox(win, { type: 'warning', title: 'Developer Tools', message: 'Open Developer Tools for this authenticated terminal UI?', detail: 'Console code can read page data and operate connected terminals. Only run code you trust. ' @@ -379,7 +419,7 @@ async function start() { }, }), ])); - const openExternal = createExternalOpener({ origin: handoff.origin, owner: win.webContents, + const openExternal = createExternalOpener({ origin: handoff.origin, owner: contents, confirm: async url => { const answer = await dialog.showMessageBox(win, { type: 'question', title: 'Open in default browser', message: `Open ${new URL(url).host} in your default browser?`, @@ -391,14 +431,14 @@ async function start() { notify: () => dialog.showMessageBox(win, { type: 'warning', message: 'Could not open the default browser.', detail: 'Check the default HTTP/HTTPS browser in your operating system settings.' }), }); - installFloatingWindows(win, handoff.origin, openExternal); - win.webContents.on('will-navigate', (event, url) => { + installFloatingWindows(win, handoff.origin, openExternal, contents); + contents.on('will-navigate', (event, url) => { if (!allowedNavigation(url, handoff.origin)) event.preventDefault(); }); - win.webContents.on('will-redirect', (event, url) => { + contents.on('will-redirect', (event, url) => { if (!allowedNavigation(url, handoff.origin)) event.preventDefault(); }); - win.webContents.on('will-attach-webview', event => event.preventDefault()); + contents.on('will-attach-webview', event => event.preventDefault()); win.on('close', event => { if (capture.active) { event.preventDefault(); @@ -421,21 +461,27 @@ async function start() { win.on('minimize', () => { if (capture.active) void capture.stop(); }); win.on('hide', () => { if (capture.active) void capture.stop(); }); win.on('enter-full-screen', () => { if (capture.active) void capture.stop(); }); - win.webContents.on('did-start-navigation', (_event, _url, _inPlace, isMainFrame) => { + contents.on('did-start-navigation', (_event, _url, _inPlace, isMainFrame) => { if (isMainFrame && capture.active) void capture.stop(); }); - win.webContents.on('render-process-gone', () => { + contents.on('render-process-gone', () => { exitCode = 1; app.quit(); }); - await win.loadURL(`${handoff.origin}/${smoke ? '?debug=1' : ''}`); + await win.loadURL(toolbar.url); + if (process.platform !== 'darwin') win.setMenuBarVisibility(false); + toolbar.layout(); + await contents.loadURL(`${handoff.origin}/${smoke ? '?debug=1' : ''}`); diagnostics.write('window_ready', { port: Number(new URL(handoff.origin).port) }); booting = false; if (smoke) { - await require('./smoke.cjs').run(win, handoff.origin); + // WebContentsView visibility follows its owner; exercise a real visible UI. + showWindow(); + contents.focus(); + await require('./smoke.cjs').run(win, handoff.origin, contents, browserAccess); if (captureSmoke) { showWindow(); - await require('./test/capture-smoke.cjs').run(win, capture); + await require('./test/capture-smoke.cjs').run(win, capture, contents); } console.log('Desktop smoke: authenticated terminal, sandbox and navigation checks passed.'); app.quit(); @@ -498,7 +544,7 @@ if (!app.requestSingleInstanceLock()) { diagnostics.write('startup_failed', { code: error.code }); if (error.code === 'SETUP_CANCELED') { app.quit(); return; } exitCode = 1; - if (smoke) console.error(error.message); + if (smoke) console.error(error.stack || error.message); else dialog.showErrorBox('StandTerm Desktop could not start', `${error.message}\n\nDiagnostics: ${diagnostics.file}`); app.quit(); }); diff --git a/desktop/recorder.js b/desktop/recorder.js index 115c7e7..4feceb9 100644 --- a/desktop/recorder.js +++ b/desktop/recorder.js @@ -70,6 +70,14 @@ window.recorder = (() => { return { chunks: data, error, stopped }; }, + pause() { + if (recorder?.state === 'recording') recorder.pause(); + }, + + resume() { + if (recorder?.state === 'paused') recorder.resume(); + }, + async stop() { if (recorder && recorder.state !== 'inactive') recorder.stop(); if (stoppedPromise) await stoppedPromise; diff --git a/desktop/smoke.cjs b/desktop/smoke.cjs index 31abe7c..a867f5d 100644 --- a/desktop/smoke.cjs +++ b/desktop/smoke.cjs @@ -4,16 +4,16 @@ const assert = require('node:assert/strict'); const http = require('node:http'); const { Menu, dialog, BrowserWindow, clipboard } = require('electron'); -async function waitFor(win, predicate) { +async function waitFor(contents, predicate) { const deadline = Date.now() + 15000; while (Date.now() < deadline) { - if (await win.webContents.executeJavaScript(predicate)) return; + if (await contents.executeJavaScript(predicate)) return; await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('Desktop smoke timed out waiting for terminal state.'); } -async function run(win, origin) { +async function run(win, origin, contents = win.webContents, browserAccess) { const menu = Menu.getApplicationMenu(); assert.equal(menu.getMenuItemById('diagnostics-origin').label, `URL: ${origin}`); const coreVersionLabel = menu.getMenuItemById('diagnostics-core-version').label; @@ -47,7 +47,7 @@ async function run(win, origin) { assert.equal(connection.base_url, origin); assert.equal(connection.agentinfo_url, origin + '/agentinfo'); assert.ok(connection.instance_id); - const agentinfo = await win.webContents.executeJavaScript('fetch("/agentinfo").then(response => response.json())'); + const agentinfo = await contents.executeJavaScript('fetch("/agentinfo").then(response => response.json())'); assert.equal(agentinfo.instance_id, connection.instance_id); assert.equal(agentinfo.agentinfo_url, connection.agentinfo_url); for (const name of ['standterm-external-agent', 'standterm-file-transfer', 'standterm-privileged-hitl']) { @@ -63,11 +63,11 @@ async function run(win, origin) { assert.ok(['windows', 'wsl', 'macos'].includes(connection.backend_mode)); assert.deepEqual(Object.keys(connection).sort(), ['schema', 'schema_version', 'base_url', 'agentinfo_url', 'instance_id', 'backend_mode'].sort()); - assert.equal(win.webContents.isDevToolsOpened(), false); + assert.equal(contents.isDevToolsOpened(), false); menu.getMenuItemById('diagnostics-status').click(); const status = BrowserWindow.getAllWindows().find(candidate => candidate !== win); - await waitFor(status, "document.body?.innerText.includes('Recent startup events') === true"); - assert.notEqual(status.webContents.session, win.webContents.session); + await waitFor(status.webContents, "document.body?.innerText.includes('Recent startup events') === true"); + assert.notEqual(status.webContents.session, contents.session); assert.equal(status.webContents.getLastWebPreferences().sandbox, true); assert.equal(status.webContents.getLastWebPreferences().preload, undefined); const diagnosticState = await status.webContents.executeJavaScript(`(async () => ({ @@ -85,15 +85,15 @@ async function run(win, origin) { menu.getMenuItemById('diagnostics-status').click(); const reopened = BrowserWindow.getAllWindows().filter(candidate => candidate !== win); assert.equal(reopened.length, 1); - await waitFor(reopened[0], "document.body?.innerText.includes('Recent startup events') === true"); + await waitFor(reopened[0].webContents, "document.body?.innerText.includes('Recent startup events') === true"); reopened[0].destroy(); const originalMessage = dialog.showMessageBox; dialog.showMessageBox = async () => ({ response: 0 }); try { await menu.getMenuItemById('diagnostics-devtools').click(); } finally { dialog.showMessageBox = originalMessage; } - assert.equal(win.webContents.isDevToolsOpened(), false); - await waitFor(win, '!!window.terminalTest && window.terminalTest.getSocketState().connected'); - const isolated = await win.webContents.executeJavaScript(`({ + assert.equal(contents.isDevToolsOpened(), false); + await waitFor(contents, '!!window.terminalTest && window.terminalTest.getSocketState().connected'); + const isolated = await contents.executeJavaScript(`({ requireType: typeof require, processType: typeof process, cookie: document.cookie, url: location.href, loginVisible: !!document.getElementById('access-token') @@ -103,7 +103,7 @@ async function run(win, origin) { assert.equal(isolated.loginVisible, false); assert.equal(isolated.cookie, ''); assert.equal(new URL(isolated.url).searchParams.has('token'), false); - const prefs = win.webContents.getLastWebPreferences(); + const prefs = contents.getLastWebPreferences(); assert.equal(prefs.sandbox, true); assert.equal(prefs.contextIsolation, true); assert.equal(prefs.nodeIntegration, false); @@ -111,30 +111,31 @@ async function run(win, origin) { http.get(origin, res => { res.resume(); resolve(res.statusCode); }).on('error', reject); }); assert.equal(unauthenticatedStatus, 401); - await waitFor(win, "!!document.querySelector('#connectBtn:not([disabled])')"); - await win.webContents.executeJavaScript("document.getElementById('connectBtn').click()"); - await waitFor(win, 'window.terminalTest.getActiveAgentState()?.connected === true'); - await win.webContents.executeJavaScript(`window.terminalTest.emitSocket('ssh_input', { + await waitFor(contents, "!!document.querySelector('#connectBtn:not([disabled])')"); + await contents.executeJavaScript("document.getElementById('connectBtn').click()"); + await waitFor(contents, 'window.terminalTest.getActiveAgentState()?.connected === true'); + await contents.executeJavaScript(`window.terminalTest.emitSocket('ssh_input', { terminal_id: window.terminalTest.getTerminalTabsState().activeTerminalId, data: 'echo STANDTERM_SMOKE_IO\\r' })`); - await waitFor(win, `Array.from({length: 100}, (_, row) => + await waitFor(contents, `Array.from({length: 100}, (_, row) => (window.terminalTest.getActiveTerminalBufferCellsForTest(row) || []).map(cell => cell?.chars || '').join('').trim() ).includes('STANDTERM_SMOKE_IO')`); // Reload must reattach the existing backend terminal, not create a new shell. - await win.loadURL(`${origin}/?debug=1`); - await waitFor(win, '!!window.terminalTest && window.terminalTest.getActiveAgentState()?.connected === true'); - assert.equal(await win.webContents.executeJavaScript( + await contents.loadURL(`${origin}/?debug=1`); + await waitFor(contents, '!!window.terminalTest && window.terminalTest.getActiveAgentState()?.connected === true'); + assert.equal(await contents.executeJavaScript( 'window.terminalTest.getTerminalTabsState().tabs.length', ), 1); - await require('./test/external-links-smoke.cjs').run(win.webContents, true); - const popup = await win.webContents.executeJavaScript("window.open('file:///blocked') === null"); + await require('./test/external-links-smoke.cjs').run(contents, true); + const popup = await contents.executeJavaScript("window.open('file:///blocked') === null"); assert.equal(popup, true); // A second loopback service is outside the allowed origin too. - const denied = await win.webContents.executeJavaScript(`fetch('http://127.0.0.1:1/') + const denied = await contents.executeJavaScript(`fetch('http://127.0.0.1:1/') .then(() => false, () => true)`); assert.equal(denied, true); - await require('./test/floating-smoke.cjs').run(win, origin); + await require('./test/floating-smoke.cjs').run(win, origin, contents); + await require('./test/toolbar-smoke.cjs').run(win, contents, browserAccess); } module.exports = { run }; diff --git a/desktop/stage-windows.cjs b/desktop/stage-windows.cjs index 31f8007..7f27ea5 100644 --- a/desktop/stage-windows.cjs +++ b/desktop/stage-windows.cjs @@ -26,6 +26,10 @@ const shellFiles = [ 'browser-session.cjs', 'diagnostics-window.cjs', 'external-links.cjs', 'test/external-links-smoke.cjs', + 'capture-settings.cjs', 'ui-commands.cjs', 'toolbar.cjs', 'toolbar-preload.cjs', + 'toolbar.html', 'toolbar.js', 'toolbar.css', + 'test/toolbar-smoke.cjs', + 'browser-access.cjs', ]; fs.mkdirSync(path.join(__dirname, 'dist'), { recursive: true }); const stage = fs.mkdtempSync(path.join(__dirname, 'dist', `${platform}-build-`)); diff --git a/desktop/test/browser-access.test.cjs b/desktop/test/browser-access.test.cjs new file mode 100644 index 0000000..ea8f7c9 --- /dev/null +++ b/desktop/test/browser-access.test.cjs @@ -0,0 +1,54 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createBrowserAccess, validateAccessUrl } = require('../browser-access.cjs'); + +const origin = 'http://127.0.0.1:64487'; +function fixture({ consent = true, badUrl = false } = {}) { + const requests = [], copied = [], opened = [], notices = []; + const access = createBrowserAccess({ origin, launcherToken: 'launcher-test', available: () => true, + confirm: async () => consent, copy: value => copied.push(value), open: async value => opened.push(value), + notify: async value => notices.push(value), session: { fetch: async (url, options) => { + requests.push({ url, options }); + return new Response(JSON.stringify(url.endsWith('/access-url') + ? { status: 'ok', access_url: badUrl ? 'https://example.com/?token=access-test' : `${origin}/?token=access-test` } + : { status: 'ok', authorization_url: `${origin}/?token=access-test&authorize=grant-test` })); + } }, + }); + return { access, requests, copied, opened, notices }; +} + +test('access token is copied on demand, never the Desktop session cookie', async () => { + const f = fixture(); + assert.equal(f.requests.length, 0); + assert.equal(await f.access.run('copy-token'), true); + assert.deepEqual(f.copied, ['access-test']); + assert.equal(f.requests.length, 1); + assert.equal(f.requests[0].options.redirect, 'error'); + assert.equal(f.requests[0].options.credentials, 'include'); + assert.ok(!f.notices.join().includes('access-test')); +}); + +test('new browser authorization requires consent and stays bound to the backend origin', async () => { + const f = fixture(); + assert.equal(await f.access.run('open'), true); + assert.deepEqual(f.opened, [`${origin}/?token=access-test&authorize=grant-test`]); + assert.equal(f.requests[1].options.headers['X-StandTerm-Launcher-Token'], 'launcher-test'); + const canceled = fixture({ consent: false }); + assert.equal(await canceled.access.run('copy-auth'), false); + assert.equal(canceled.requests.length, 0); + const bad = fixture({ badUrl: true }); + assert.equal(await bad.access.run('open'), false); + assert.equal(bad.opened.length, 0); + assert.equal(bad.copied.length, 0); + assert.ok(!bad.notices.join().includes('access-test')); +}); + +test('access URLs reject other origins, paths, credentials and unexpected query fields', () => { + for (const value of [`${origin}/other?token=a`, `${origin}/?token=a&unexpected=b`, + 'http://user:password@127.0.0.1:64487/?token=a', `${origin}/?token=a#fragment`, `${origin}/`]) { + assert.throws(() => validateAccessUrl(value, origin)); + } + assert.throws(() => validateAccessUrl(`${origin}/?token=a`, origin, true)); +}); diff --git a/desktop/test/capture-settings.test.cjs b/desktop/test/capture-settings.test.cjs new file mode 100644 index 0000000..b73db60 --- /dev/null +++ b/desktop/test/capture-settings.test.cjs @@ -0,0 +1,38 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { CaptureSettings, captureName } = require('../capture-settings.cjs'); + +test('capture folders persist independently of backend origin and may diverge', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-capture-settings-')); + const file = path.join(root, 'capture.json'); + const prefs = new CaptureSettings(file); + assert.equal(prefs.get('png'), null); + prefs.set('png', path.join(root, 'screenshots')); + assert.equal(prefs.get('webm'), prefs.get('png')); + prefs.set('webm', path.join(root, 'recordings')); + assert.equal(new CaptureSettings(file).get('png'), path.join(root, 'screenshots')); + assert.equal(new CaptureSettings(file).get('webm'), path.join(root, 'recordings')); + assert.deepEqual(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8'))).sort(), ['png', 'version', 'webm']); + assert.throws(() => prefs.set('png', 'relative'), /absolute/); + assert.throws(() => prefs.set('unknown', root), /format/); +}); + +test('invalid settings require selection and generated names avoid collisions', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'standterm-capture-invalid-')); + const file = path.join(root, 'capture.json'); + for (const content of ['invalid', '{"version":2}', '{"version":1,"png":"relative","webm":42}']) { + fs.writeFileSync(file, content); + assert.equal(new CaptureSettings(file).get('png'), null); + assert.equal(new CaptureSettings(file).get('webm'), null); + } + const now = new Date('2026-09-10T00:00:00.000Z'); + const name = captureName('png', now); + assert.match(name, /^StandTerm-2026-09-10T00-00-00-000Z-[a-f0-9]{8}\.png$/); + assert.notEqual(name, captureName('png', now)); + assert.throws(() => captureName('../png'), /format/); +}); diff --git a/desktop/test/capture-smoke.cjs b/desktop/test/capture-smoke.cjs index f4b6b43..fcd92e2 100644 --- a/desktop/test/capture-smoke.cjs +++ b/desktop/test/capture-smoke.cjs @@ -13,7 +13,15 @@ async function until(check) { } } -async function run(win, capture) { +async function run(win, capture, contents = win.webContents) { + const evaluate = async script => { + let timer; + try { + return await Promise.race([contents.executeJavaScript(script), new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Core capture test did not respond; visible=${win.isVisible()}, minimized=${win.isMinimized()}, focused=${win.isFocused()}`)), 15000); + })]); + } finally { clearTimeout(timer); } + }; const outputRoot = app.isPackaged ? app.getPath('temp') : path.join(__dirname, '..', 'dist'); await fs.mkdir(outputRoot, { recursive: true }); const directory = await fs.mkdtemp(path.join(outputRoot, 'capture-smoke-')); @@ -33,21 +41,32 @@ async function run(win, capture) { try { assert.equal((await capture.screenshot('clipboard')).copied, true); } finally { clipboard.write = originalWrite; } assert.equal(clipboardBytes.subarray(0, 8).toString('hex'), '89504e470d0a1a0a'); - const originalSave = dialog.showSaveDialog; - dialog.showSaveDialog = async () => ({ canceled: true }); + const originalOpen = dialog.showOpenDialog; + dialog.showOpenDialog = async () => ({ canceled: true }); try { assert.equal(await capture.start(), null); assert.equal(await capture.screenshot('file'), null); assert.equal(capture.active, false); let cancelDialog; - dialog.showSaveDialog = () => new Promise(resolve => { cancelDialog = resolve; }); + dialog.showOpenDialog = () => new Promise(resolve => { cancelDialog = resolve; }); const starting = capture.start(); const stopping = capture.stop(); cancelDialog({ canceled: true }); assert.deepEqual(await Promise.all([starting, stopping]), [null, null]); assert.equal(capture.active, false); - } finally { dialog.showSaveDialog = originalSave; } - await win.webContents.executeJavaScript(`{ + } finally { dialog.showOpenDialog = originalOpen; } + let folderPrompts = 0; + dialog.showOpenDialog = async () => { folderPrompts++; return { canceled: false, filePaths: [directory] }; }; + try { + const first = await capture.screenshot('file'); + const second = await capture.screenshot('file'); + assert.equal(path.dirname(first.destination), directory); + assert.equal(path.dirname(second.destination), directory); + assert.notEqual(first.destination, second.destination); + assert.equal(folderPrompts, 1, 'the capture folder is chosen only once'); + assert.equal(capture.settings.get('webm'), directory); + } finally { dialog.showOpenDialog = originalOpen; } + await contents.executeJavaScript(`{ const marker = document.createElement('div'); marker.id = 'capture-smoke-marker'; marker.style.cssText = 'position:fixed;inset:0 auto auto 0;width:100px;height:100px;background:rgb(0,240,0);z-index:2147483647'; document.body.append(marker); @@ -58,6 +77,23 @@ async function run(win, capture) { console.log('Capture smoke: recording started.'); assert.equal(started.destination, video); assert.equal(capture.state, 'recording'); + const timerLabel = () => win.webContents.executeJavaScript("document.getElementById('recording-status').textContent"); + await new Promise(resolve => setTimeout(resolve, 1200)); + assert.match(await timerLabel(), /^Recording 00:0[1-9]$/); + await capture.togglePause(); + assert.equal(capture.state, 'paused'); + const pausedLabel = await timerLabel(); + assert.match(pausedLabel, /^Recording paused /); + await new Promise(resolve => setTimeout(resolve, 1200)); + assert.equal(await timerLabel(), pausedLabel, 'the displayed timer must freeze while paused'); + await fs.writeFile(path.join(directory, 'toolbar-paused.png'), (await win.webContents.capturePage({ x: 0, y: 0, width: win.getContentSize()[0], height: 36 })).toPNG(), { flag: 'wx' }); + assert.equal(await capture.job.recorder.webContents.executeJavaScript('window.recorder.drain().then(data => data.error)'), null); + await capture.togglePause(); + assert.equal(capture.state, 'recording'); + await new Promise(resolve => setTimeout(resolve, 1200)); + assert.match(await timerLabel(), /^Recording /); + assert.notEqual((await timerLabel()).replace('Recording ', ''), pausedLabel.replace('Recording paused ', ''), 'the timer must resume'); + await fs.writeFile(path.join(directory, 'toolbar-recording.png'), (await win.webContents.capturePage({ x: 0, y: 0, width: win.getContentSize()[0], height: 36 })).toPNG(), { flag: 'wx' }); assert.equal(Menu.getApplicationMenu().getMenuItemById('capture-start').enabled, false); assert.equal(Menu.getApplicationMenu().getMenuItemById('capture-stop').enabled, true); assert.equal((await capture.start(path.join(directory, 'duplicate.webm'))).destination, video); @@ -66,7 +102,7 @@ async function run(win, capture) { assert.equal(privatePrefs.sandbox, true); assert.equal(privatePrefs.nodeIntegration, false); assert.equal(privatePrefs.preload, undefined); - assert.equal(await win.webContents.executeJavaScript('typeof window.recorder'), 'undefined'); + assert.equal(await contents.executeJavaScript('typeof window.recorder'), 'undefined'); assert.equal(await capture.job.recorder.webContents.executeJavaScript('document.cookie'), ''); const denied = await capture.job.recorder.webContents.executeJavaScript(` navigator.mediaDevices.getDisplayMedia({video: true}).then(stream => { @@ -74,15 +110,15 @@ async function run(win, capture) { }, () => true)`, true); assert.equal(denied, true, 'a second media request must not reuse the native recording grant'); console.log('Capture smoke: repeated display capture denied.'); - for (const contents of [win.webContents, capture.job.recorder.webContents]) { - const cameraDenied = await contents.executeJavaScript(` + for (const target of [contents, capture.job.recorder.webContents]) { + const cameraDenied = await target.executeJavaScript(` navigator.mediaDevices.getUserMedia({video: true, audio: true}).then(stream => { stream.getTracks().forEach(track => track.stop()); return false; }, () => true)`, true); assert.equal(cameraDenied, true, 'camera/microphone access must remain denied'); console.log('Capture smoke: camera/microphone request denied.'); } - const pageDenied = await win.webContents.executeJavaScript(` + const pageDenied = await contents.executeJavaScript(` navigator.mediaDevices.getDisplayMedia({video: true}).then(stream => { stream.getTracks().forEach(track => track.stop()); return false; }, () => true)`, true); @@ -96,10 +132,12 @@ async function run(win, capture) { assert.equal(win.isDestroyed(), false); assert.equal(capture.state, 'recording', 'canceling close keeps recording'); } finally { dialog.showMessageBox = originalMessage; } + console.log('Capture smoke: close cancellation passed; writing recorded frames.'); for (let index = 0; index < 4; index++) { - await win.webContents.executeJavaScript(`window.terminalTest.writeTerminalOutput(${JSON.stringify('\r\nCapture smoke frame ') } + ${index})`); + await evaluate(`window.terminalTest.writeTerminalOutput(${JSON.stringify('\r\nCapture smoke frame ') } + ${index}); void 0`); await new Promise(resolve => setTimeout(resolve, 750)); } + console.log('Capture smoke: stopping and saving.'); const [stopped, concurrent] = await Promise.all([capture.stop(), capture.stop()]); assert.deepEqual(stopped, concurrent); assert.equal(stopped.destination, video); @@ -108,7 +146,8 @@ async function run(win, capture) { const bytes = await fs.readFile(video); assert.equal(bytes.subarray(0, 4).toString('hex'), '1a45dfa3'); // Decode the result inside Chromium, without enabling file access in Core. - const decoded = await win.webContents.executeJavaScript(`new Promise((resolve, reject) => { + console.log('Capture smoke: decoding the recording.'); + const decoded = await evaluate(`new Promise((resolve, reject) => { const bytes = Uint8Array.from(atob(${JSON.stringify(bytes.toString('base64'))}), c => c.charCodeAt(0)); const url = URL.createObjectURL(new Blob([bytes], {type: 'video/webm'})); const video = document.createElement('video'); @@ -133,7 +172,7 @@ async function run(win, capture) { await fs.writeFile(path.join(directory, 'decoded-frame.png'), Buffer.from(decoded.png.split(',')[1], 'base64'), { flag: 'wx' }); assert.ok(decoded.markerPixels > 300, 'video must contain the marker from the StandTerm page, not the recorder or desktop'); - await win.webContents.executeJavaScript("document.getElementById('capture-smoke-marker').remove()"); + await contents.executeJavaScript("document.getElementById('capture-smoke-marker').remove()"); assert.equal((await fs.readdir(directory)).some(name => name.endsWith('.partial')), false); const originalNotify = capture.notify; capture.notify = async () => {}; diff --git a/desktop/test/capture-status.test.cjs b/desktop/test/capture-status.test.cjs new file mode 100644 index 0000000..0176cd0 --- /dev/null +++ b/desktop/test/capture-status.test.cjs @@ -0,0 +1,54 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { createRequire } = require('node:module'); + +function fixture() { + const filename = path.join(__dirname, '..', 'capture.cjs'); + const localRequire = createRequire(filename); + let now = 13000, status, diagnostic, message; + class Clock extends Date { static now() { return now; } } + const context = { module: { exports: {} }, __dirname: path.dirname(filename), Date: Clock, + setTimeout, clearTimeout, + require: name => name === 'electron' ? { Menu: { getApplicationMenu: () => null } } : localRequire(name) }; + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), context); + const capture = Object.create(context.module.exports.DesktopCapture.prototype); + Object.assign(capture, { + state: 'recording', job: { startedAt: 1000 }, + onChange: (_title, state) => { status = state; }, + onDiagnostic: state => { diagnostic = state; }, + notify: async value => { message = value; }, + win: { isDestroyed: () => false, isVisible: () => true, isMinimized: () => false, + isFocused: () => true, getContentSize: () => [624, 561] }, + contents: { capturePage: async () => { throw new Error('Native capture failed: sensitive fixture'); } }, + }); + return { capture, advance: ms => { now += ms; }, now: () => now, + status: () => status, diagnostic: () => diagnostic, message: () => message }; +} + +test('recording time advances, freezes while paused, and excludes paused intervals', () => { + const f = fixture(); + const c = f.capture; + c.update(); assert.equal(f.status().label, 'Recording 00:12'); + c.state = 'paused'; c.job.pausedAt = f.now(); + f.advance(10000); c.update(); assert.equal(f.status().label, 'Recording paused 00:12'); + c.state = 'recording'; c.job.pausedMs = 10000; c.job.pausedAt = null; + f.advance(3000); c.update(); assert.equal(f.status().label, 'Recording 00:15'); + f.advance(60000); c.update(); assert.equal(f.status().label, 'Recording 01:15'); + c.state = 'idle'; c.job = null; c.update(); assert.equal(f.status().label, 'Not recording'); +}); + +test('failed capture reports only window metadata and releases the screenshot action', async () => { + const f = fixture(); + assert.equal((await f.capture.screenshot('file')).error, true); + assert.deepEqual(JSON.parse(JSON.stringify(f.diagnostic())), { + width: 624, height: 561, visible: true, minimized: false, focused: true, + }); + assert.match(f.message(), /foreground and retry/); + assert.ok(!f.message().includes('sensitive fixture')); + assert.equal(f.capture.screenshotBusy, false); +}); diff --git a/desktop/test/diagnostics.test.cjs b/desktop/test/diagnostics.test.cjs index 95fb5dc..bcafe39 100644 --- a/desktop/test/diagnostics.test.cjs +++ b/desktop/test/diagnostics.test.cjs @@ -47,6 +47,19 @@ test('diagnostic rotation is bounded and write failure does not block startup', assert.equal(blocked.available, false); }); +test('capture diagnostics record window state without error payloads', t => { + const { logger } = fixture(t); + logger.write('capture_failed', { width: 624, height: 561, visible: true, minimized: false, + focused: true, message: 'private path or terminal data', screenshot: 'private bytes' }); + const entry = logger.snapshot()[0]; + assert.equal(entry.width, 624); + assert.equal(entry.height, 561); + assert.equal(entry.visible, true); + assert.equal(entry.minimized, false); + assert.equal(entry.focused, true); + assert.ok(!JSON.stringify(entry).includes('private')); +}); + test('diagnostics menu shows the actual owned endpoint without an access URL', t => { const { logger } = fixture(t); const options = { origin: 'http://127.0.0.1:64487', mode: 'wsl', version: '0.3.3', logger, diff --git a/desktop/test/floating-smoke.cjs b/desktop/test/floating-smoke.cjs index df29e63..3b790e2 100644 --- a/desktop/test/floating-smoke.cjs +++ b/desktop/test/floating-smoke.cjs @@ -3,8 +3,15 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -async function run(win, origin) { - const evaluate = script => win.webContents.executeJavaScript(script, true); +async function run(win, origin, contents = win.webContents) { + const evaluate = async script => { + let timer; + try { + return await Promise.race([contents.executeJavaScript(script, true), new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(`Floating script timed out: ${script.slice(0, 180)}`)), 10000); + })]); + } finally { clearTimeout(timer); } + }; async function until(script) { const end = Date.now() + 15000; while (Date.now() < end) { @@ -21,7 +28,7 @@ async function run(win, origin) { } const created = []; const record = child => created.push(child); - win.webContents.on('did-create-window', record); + contents.on('did-create-window', record); try { await evaluate('window.__floatingAlerts = []; window.alert = message => window.__floatingAlerts.push(message); void 0'); const terminalId = await evaluate('window.terminalTest.getTerminalTabsState().activeTerminalId'); @@ -46,7 +53,7 @@ async function run(win, origin) { await evaluate('window.open = window.__countedOpen; void 0'); assert.equal(created.length, 1, 'real PiP must be covered by did-create-window guards'); const child = created.at(-1); - assert.equal(child.webContents.session, win.webContents.session); + assert.equal(child.webContents.session, contents.session); const prefs = child.webContents.getLastWebPreferences(); assert.equal(prefs.sandbox, true); assert.equal(prefs.contextIsolation, true); @@ -70,21 +77,28 @@ async function run(win, origin) { await child.webContents.executeJavaScript("document.querySelector('[data-entry-name=\"fixture.bin\"]').click()", true); await until("!window.terminalTest.getFloatingWindowForTest().document.querySelector('.sftp-file-download').disabled"); const output = path.join(fixture, 'downloaded.bin'); - let item; + const downloadSession = child.webContents.session; + let item, timer, onDownload; const completed = new Promise(resolve => { - const onDownload = (_event, download) => { + onDownload = (_event, download) => { item = download; download.setSavePath(output); download.once('done', (_doneEvent, state) => resolve(state)); }; - child.webContents.session.once('will-download', onDownload); - setTimeout(() => { - child.webContents.session.removeListener('will-download', onDownload); + downloadSession.once('will-download', onDownload); + timer = setTimeout(() => { + downloadSession.removeListener('will-download', onDownload); resolve('timeout'); }, 15000).unref(); }); - await child.webContents.executeJavaScript("document.querySelector('.sftp-file-download').click()", true); - const result = await completed; + let result; + try { + await child.webContents.executeJavaScript("document.querySelector('.sftp-file-download').click()", true); + result = await completed; + } finally { + clearTimeout(timer); + downloadSession.removeListener('will-download', onDownload); + } console.log(`Floating smoke: download ${result}.`); if (result !== 'completed') item?.cancel(); assert.equal(result, 'completed', 'Files ticket download must complete using the private session'); @@ -92,7 +106,9 @@ async function run(win, origin) { assert.equal(created.length, 1, 'Download must not create another window'); } assert.equal(await child.webContents.executeJavaScript("window.open('about:blank') === null"), true); - assert.equal(await evaluate("window.open('https://example.com') === null && window.open('about:blank') === null"), true); + // External URLs are covered by the consent test above; do not open a real + // browser-confirmation dialog outside its scoped dialog/OS-open mocks. + assert.equal(await evaluate("window.open('about:blank') === null"), true); for (const destination of ['https://example.com/', 'data:text/html,blocked', `${origin}/?forbidden=1`]) { const attempts = []; const recordNavigation = event => attempts.push(event.defaultPrevented); @@ -122,10 +138,13 @@ async function run(win, origin) { } assert.equal(await evaluate('window.__floatingAlerts.length'), 0); const beforeReload = created.at(-1); - await win.loadURL(`${origin}/?debug=1`); + console.log('Floating smoke: reloading Core with an open child.'); + await contents.loadURL(`${origin}/?debug=1`); await until('!!window.terminalTest && window.terminalTest.getSocketState().connected'); + console.log('Floating smoke: Core reloaded.'); assert.equal(beforeReload.isDestroyed(), true, 'opener reload must not orphan its child'); await evaluate("if (window.terminalTest.getTerminalTabsState().tabs.length < 2) document.getElementById('new-tab-btn').click()"); + console.log('Floating smoke: checking blocked-window fallback.'); await evaluate(`window.terminalTest.switchTerminalForTest(${JSON.stringify(terminalId)}); window.__floatingAlerts = []; window.__originalAlert = window.alert; window.alert = message => window.__floatingAlerts.push(message); @@ -136,6 +155,7 @@ async function run(win, origin) { await evaluate("document.getElementById('sftp-send-option').click()"); await until('window.__floatingAlerts.length === 1'); } + console.log('Floating smoke: checking blocked PiP fallback.'); await evaluate("document.getElementById('pip-option').click()"); await until(`window.__floatingAlerts.length === ${filesAvailable ? 2 : 1}`); assert.equal(await evaluate('window.terminalTest.getTerminalTabsState().tabs.some(tab => tab.inPip)'), false); @@ -145,7 +165,7 @@ async function run(win, origin) { console.error(error.stack); throw error; } finally { - win.webContents.removeListener('did-create-window', record); + contents.removeListener('did-create-window', record); for (const child of created) if (!child.isDestroyed()) child.destroy(); } } diff --git a/desktop/test/recorder.test.cjs b/desktop/test/recorder.test.cjs index e107fad..1333899 100644 --- a/desktop/test/recorder.test.cjs +++ b/desktop/test/recorder.test.cjs @@ -27,6 +27,8 @@ function fixture({ supported = true, audio = false } = {}) { this.state = 'inactive'; } start(slice) { assert.equal(slice, 1000); this.state = 'recording'; } + pause() { this.state = 'paused'; } + resume() { this.state = 'recording'; } stop() { this.state = 'inactive'; this.dispatchEvent(new Event('stop')); } chunk(data) { const event = new Event('dataavailable'); event.data = data; @@ -85,3 +87,18 @@ test('private recorder rejects missing codecs or unexpected audio tracks', async await assert.rejects(audio.api.start(), /video-only/); assert.ok(audio.stoppedTracks() > 0); }); + +test('private recorder can pause, resume, and stop while paused', async () => { + const f = fixture(); + await f.api.start(); + f.api.pause(); + assert.equal(f.encoder().state, 'paused'); + f.api.pause(); + f.api.resume(); + assert.equal(f.encoder().state, 'recording'); + f.api.pause(); + await f.api.stop(); + assert.equal(f.encoder().state, 'inactive'); + f.api.resume(); + assert.equal(f.encoder().state, 'inactive'); +}); diff --git a/desktop/test/toolbar-notice.test.cjs b/desktop/test/toolbar-notice.test.cjs new file mode 100644 index 0000000..ed0ccd7 --- /dev/null +++ b/desktop/test/toolbar-notice.test.cjs @@ -0,0 +1,81 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function fixture() { + const elements = new Map(); + for (const [, id] of fs.readFileSync(path.join(__dirname, '..', 'toolbar.html'), 'utf8').matchAll(/id="([^"]+)"/g)) { + const classes = new Set(); + elements.set(id, { textContent: '', title: '', hidden: false, dataset: {}, + classList: { add: name => classes.add(name), remove: name => classes.delete(name), + contains: name => classes.has(name), toggle: (name, on) => on ? classes.add(name) : classes.delete(name) }, + setAttribute: () => {}, addEventListener: (_type, handler) => { elements.get(id).click = handler; } }); + } + const timers = new Map(); + let time = 0, next = 0, receive, fail = false; + const source = fs.readFileSync(path.join(__dirname, '..', 'toolbar.js'), 'utf8'); + vm.runInNewContext(source, { + document: { getElementById: id => elements.get(id), querySelectorAll: () => [elements.get('save')] }, + window: { desktopToolbar: { onState: callback => { receive = callback; }, + invoke: () => fail ? Promise.reject(new Error('Fixture failure')) : Promise.resolve(true) } }, + setTimeout: (callback, delay) => { timers.set(++next, { callback, at: time + delay }); return next; }, + clearTimeout: id => timers.delete(id), + }); + return { element: id => elements.get(id), receive, fail: () => { fail = true; }, + tick: ms => { + const end = time + ms; + for (;;) { + const entry = [...timers].sort((a, b) => a[1].at - b[1].at)[0]; + if (!entry || entry[1].at > end) break; + time = entry[1].at; timers.delete(entry[0]); entry[1].callback(); + } + time = end; + } }; +} + +test('notice fades after five seconds and recording updates never replay it', () => { + const f = fixture(); + const state = { notice: 'Screenshot saved.', noticeId: 1 }; + f.receive(state); + assert.equal(f.element('notice').textContent, state.notice); + assert.ok(f.element('notice-area').classList.contains('visible')); + f.tick(4900); f.receive({ ...state, state: 'recording', label: 'Recording 00:04' }); + f.tick(100); + assert.equal(f.element('notice-area').classList.contains('visible'), false); + f.tick(350); + assert.equal(f.element('notice').textContent, ''); + assert.equal(f.element('notice-area').title, ''); + f.receive(state); + assert.equal(f.element('notice').textContent, ''); +}); + +test('a new error cancels pending fade cleanup and remains visible for ten seconds', () => { + const f = fixture(); + f.receive({ notice: 'Saved.', noticeId: 1 }); f.tick(5100); + f.receive({ notice: 'Could not save.', noticeId: 2, error: true }); f.tick(300); + assert.equal(f.element('notice').textContent, 'Could not save.'); + assert.ok(f.element('notice-area').classList.contains('error')); + f.tick(9600); assert.ok(f.element('notice-area').classList.contains('visible')); + f.tick(100); assert.equal(f.element('notice-area').classList.contains('visible'), false); +}); + +test('failed toolbar actions use the same timed error presentation', async () => { + const f = fixture(); f.fail(); f.element('save').click(); await Promise.resolve(); + assert.match(f.element('notice').textContent, /Action unavailable/); + assert.ok(f.element('notice-area').classList.contains('error')); + f.tick(10350); assert.equal(f.element('notice').textContent, ''); +}); + +test('macOS keeps the system menu layout while notices use the shared renderer', () => { + const f = fixture(); f.receive({ mac: true, notice: 'Copied.', noticeId: 1 }); + assert.equal(f.element('menus').hidden, true); + assert.equal(f.element('mac-title').hidden, false); + assert.equal(f.element('notice').textContent, 'Copied.'); + f.tick(5350); assert.equal(f.element('notice').textContent, ''); + f.receive({ mac: false }); + assert.equal(f.element('menus').hidden, false); +}); diff --git a/desktop/test/toolbar-smoke.cjs b/desktop/test/toolbar-smoke.cjs new file mode 100644 index 0000000..f1a9fea --- /dev/null +++ b/desktop/test/toolbar-smoke.cjs @@ -0,0 +1,100 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const { app, BrowserWindow, Menu, clipboard, dialog } = require('electron'); + +async function run(win, contents, browserAccess) { + const evaluate = script => contents.executeJavaScript(script, true); + const until = async (check, stage) => { + const deadline = Date.now() + 10000; + while (!await check()) { + if (Date.now() > deadline) throw new Error(`Desktop toolbar smoke timed out: ${stage}; ${JSON.stringify({ + visible: win.isVisible(), minimized: win.isMinimized(), focused: win.isFocused(), + expectedWindow: win.id, focusedWindow: BrowserWindow.getFocusedWindow()?.id, + })}`); + await new Promise(resolve => setTimeout(resolve, 50)); + } + }; + const menu = Menu.getApplicationMenu(); + assert.deepEqual(menu.items.map(item => item.id), ['standterm', 'edit', 'agent-menu', 'view', 'diagnostics']); + assert.notEqual(win.webContents.session, contents.session); + assert.deepEqual(await win.webContents.session.cookies.get({}), []); + assert.equal(await evaluate('typeof window.desktopToolbar'), 'undefined'); + assert.equal(contents.getLastWebPreferences().preload, undefined); + assert.equal(await win.webContents.executeJavaScript('typeof require'), 'undefined'); + assert.equal(await win.webContents.executeJavaScript("window.desktopToolbar.invoke('unknown')"), false); + assert.equal(await win.webContents.executeJavaScript("fetch('https://example.com').then(() => false, () => true)"), true); + await until(() => evaluate('window.standtermUi?.snapshot().ready === true'), 'Core ready'); + assert.equal(await evaluate('innerHeight'), win.getContentSize()[1] - 36, 'initial Core bounds must exclude the Desktop toolbar'); + const copied = []; + const originalCopy = clipboard.writeText; + const originalDialog = dialog.showMessageBox; + clipboard.writeText = value => copied.push(value); + dialog.showMessageBox = async () => ({ response: 1 }); + try { + assert.equal(await browserAccess.run('copy-token'), true, 'native access-token retrieval must use the authenticated Core session'); + assert.equal(await browserAccess.run('copy-auth'), true, 'native browser authorization must pass the launcher check'); + assert.equal(copied.length, 2); + const auth = new URL(copied[1]); + assert.ok(auth.searchParams.get('token') === copied[0]); + assert.ok(auth.searchParams.get('authorize')); + assert.equal(auth.origin, new URL(contents.getURL()).origin); + } finally { clipboard.writeText = originalCopy; dialog.showMessageBox = originalDialog; } + const originalSize = win.getSize(); + if (win.isMinimized()) win.restore(); + win.show(); win.moveTop(); win.focus(); contents.focus(); + await until(() => BrowserWindow.getFocusedWindow() === win, 'main focus'); + await until(() => menu.getMenuItemById('ui-settings').enabled, 'Settings enabled'); + menu.getMenuItemById('ui-settings').click(); + await until(() => evaluate("document.getElementById('settings-modal').classList.contains('open')"), 'Settings opened'); + await evaluate("document.getElementById('settings-close').click()"); + const original = await evaluate('window.terminalTest.getTerminalTabsState().tabs.length'); + await until(() => menu.getMenuItemById('ui-newTab').enabled, 'New Tab enabled'); + menu.getMenuItemById('ui-newTab').click(); + await until(async () => await evaluate('window.terminalTest.getTerminalTabsState().tabs.length') === original + 1, 'New Tab created'); + assert.equal(await evaluate("window.standtermUi.run('closeTab', 'wrong-target')"), false); + const other = new BrowserWindow({ width: 300, height: 200, show: false, + webPreferences: { sandbox: true, nodeIntegration: false, contextIsolation: true } }); + try { + await other.loadURL('data:text/html,
Auxiliary window focus fixture
'); + other.show(); other.focus(); + await until(() => BrowserWindow.getFocusedWindow() === other, 'auxiliary focus'); + menu.getMenuItemById('ui-closeTab').click(); + await new Promise(resolve => setTimeout(resolve, 100)); + assert.equal(await evaluate('window.terminalTest.getTerminalTabsState().tabs.length'), original + 1); + } finally { other.destroy(); } + win.focus(); contents.focus(); + await until(() => BrowserWindow.getFocusedWindow() === win, 'restored main focus'); + await until(() => menu.getMenuItemById('ui-closeTab').enabled, 'Close Tab enabled'); + menu.getMenuItemById('ui-closeTab').click(); + await until(async () => await evaluate('window.terminalTest.getTerminalTabsState().tabs.length') === original, 'Tab closed'); + win.setSize(640, 600); + await new Promise(resolve => setTimeout(resolve, 300)); + assert.equal(await evaluate(`['new-tab-btn', 'agent-toggle-btn', 'sftp-status-btn', 'quick-settings'].every(id => { + const element = document.getElementById(id); + if (element.hidden) return true; + const bounds = element.getBoundingClientRect(); + return bounds.width > 0 && bounds.x >= 0 && bounds.right <= innerWidth && bounds.top >= 0; + })`), true); + assert.equal(await evaluate("document.querySelector('#status-bar #new-tab-btn, #status-bar #quick-settings, #status-bar #agent-pause-btn') === null"), true); + assert.equal(await evaluate('innerHeight'), win.getContentSize()[1] - 36); + const directory = await fs.mkdtemp(path.join(app.isPackaged ? app.getPath('temp') : path.join(__dirname, '..', 'dist'), 'toolbar-smoke-')); + const screenshotCss = await contents.insertCSS('#debug-hud, #policy-debug-panel, #payload-log { display: none !important; }'); + console.log('Desktop toolbar smoke: capturing the Core preview.'); + try { + await fs.writeFile(path.join(directory, 'core.png'), (await contents.capturePage()).toPNG(), { flag: 'wx' }); + } catch (error) { + console.error('Core preview state:', JSON.stringify({ visible: win.isVisible(), focused: win.isFocused(), + minimized: win.isMinimized(), page: await evaluate('({visibility:document.visibilityState,width:innerWidth,height:innerHeight})') })); + throw error; + } + console.log('Desktop toolbar smoke: capturing the toolbar preview.'); + await fs.writeFile(path.join(directory, 'toolbar.png'), (await win.webContents.capturePage({ x: 0, y: 0, width: win.getContentSize()[0], height: 36 })).toPNG(), { flag: 'wx' }); + await contents.removeInsertedCSS(screenshotCss); + win.setSize(...originalSize); + console.log(`Desktop toolbar smoke: isolated SVG toolbar, focus guards, native Settings/tab actions and compact layout passed (${directory}).`); +} + +module.exports = { run }; diff --git a/desktop/test/toolbar.test.cjs b/desktop/test/toolbar.test.cjs new file mode 100644 index 0000000..296b723 --- /dev/null +++ b/desktop/test/toolbar.test.cjs @@ -0,0 +1,70 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { EventEmitter } = require('node:events'); + +function fixture(platform = 'win32') { + let handler, requestFilter, permission, device, popup = 0, calls = 0, focused; + const contents = new EventEmitter(); + Object.assign(contents, { mainFrame: {}, isDestroyed: () => false, send: () => {}, setWindowOpenHandler: () => {}, + session: { setPermissionRequestHandler: fn => { permission = fn; }, setPermissionCheckHandler: () => {}, + setDevicePermissionHandler: fn => { device = fn; }, webRequest: { onBeforeRequest: fn => { requestFilter = fn; } } } }); + const win = new EventEmitter(); + Object.assign(win, { webContents: contents, getContentSize: () => [640, 480], contentView: { addChildView: () => {}, on: () => {} }, + isDestroyed: () => false, isVisible: () => true, isMinimized: () => false }); + focused = win; + const core = { setBounds: bounds => { core.bounds = bounds; }, webContents: { focus: () => {}, isDestroyed: () => true } }; + const api = { exports: {} }; + const electron = { BrowserWindow: { getFocusedWindow: () => focused }, + ipcMain: { handle: (_name, fn) => { handler = fn; }, removeHandler: () => { handler = null; } }, + Menu: { getApplicationMenu: () => ({ getMenuItemById: () => ({ submenu: { popup: () => { popup++; } } }) }) } }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '..', 'toolbar.cjs'), 'utf8'), { + require: name => name === 'electron' ? electron : require(name), module: api, + __dirname: path.join(__dirname, '..'), process: { platform }, + }); + const capture = { screenshot: async () => { calls++; }, start: async () => { calls++; }, + togglePause: async () => { calls++; }, stop: async () => { calls++; } }; + const toolbar = api.exports.installToolbar(win, core, capture, { refresh: async () => {} }); + contents.mainFrame.url = toolbar.url; + const event = { sender: contents, senderFrame: contents.mainFrame }; + return { invoke: (action, source = event) => handler(source, action), event, win, core, contents, toolbar, + unfocus: () => { focused = null; }, calls: () => calls, popup: () => popup, + filter: url => new Promise(resolve => requestFilter({ url }, resolve)), + permission: () => new Promise(resolve => permission(contents, 'media', resolve)), device: () => device() }; +} + +test('only the bundled toolbar main frame can invoke fixed native capture/menu actions', async () => { + const f = fixture(); + assert.equal(await f.invoke('record'), true); + assert.equal(f.calls(), 1); + assert.equal(await f.invoke('record', { sender: f.core.webContents, senderFrame: f.event.senderFrame }), false); + assert.equal(await f.invoke('record', { sender: f.contents, senderFrame: { url: f.toolbar.url } }), false); + assert.equal(await f.invoke('execute-code'), false); + assert.equal(await f.invoke({ action: 'record' }), false); + assert.equal(await f.invoke('menu:standterm'), true); + assert.equal(await f.invoke('menu:unknown'), false); + assert.equal(f.popup(), 1); + f.unfocus(); + assert.equal(await f.invoke('record'), false); + assert.equal(f.calls(), 1); +}); + +test('toolbar resource and permission boundaries are identical on Windows and macOS', async () => { + for (const platform of ['win32', 'darwin']) { + const f = fixture(platform); + assert.equal((await f.filter(f.toolbar.url)).cancel, false); + for (const url of ['https://example.com/', 'http://127.0.0.1:5000/', 'file:///private.txt']) { + assert.equal((await f.filter(url)).cancel, true); + } + assert.equal(await f.permission(), false); + assert.equal(f.device(), false); + assert.equal(f.core.bounds.y, 36); + assert.equal(f.core.bounds.height, 444); + f.contents.mainFrame.url = 'https://example.com/'; + assert.equal(await f.invoke('record'), false); + } +}); diff --git a/desktop/toolbar-preload.cjs b/desktop/toolbar-preload.cjs new file mode 100644 index 0000000..b5a3093 --- /dev/null +++ b/desktop/toolbar-preload.cjs @@ -0,0 +1,9 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +// Loaded only in the bundled Desktop toolbar, never Core or a floating window. +contextBridge.exposeInMainWorld('desktopToolbar', Object.freeze({ + invoke: action => ipcRenderer.invoke('standterm-toolbar-action', action), + onState: callback => ipcRenderer.on('standterm-toolbar-state', (_event, state) => callback(state)), +})); diff --git a/desktop/toolbar.cjs b/desktop/toolbar.cjs new file mode 100644 index 0000000..b8d0d06 --- /dev/null +++ b/desktop/toolbar.cjs @@ -0,0 +1,68 @@ +'use strict'; + +const { ipcMain, Menu, BrowserWindow } = require('electron'); +const path = require('node:path'); +const { pathToFileURL } = require('node:url'); + +const TOOLBAR_HEIGHT = 36; +const TOOLBAR_URL = pathToFileURL(path.join(__dirname, 'toolbar.html')).href; +const TOOLBAR_ASSETS = ['toolbar.html', 'toolbar.js', 'toolbar.css'].map(file => pathToFileURL(path.join(__dirname, file)).href); +const TOOLBAR_MENUS = ['standterm', 'edit', 'agent-menu', 'view', 'diagnostics']; + +function installToolbar(win, coreView, capture, commands) { + const contents = win.webContents; + let state = { mac: process.platform === 'darwin', state: 'idle', label: '' }; + let noticeId = 0; + const send = value => { + state = { ...state, ...value }; + if (!contents.isDestroyed() && contents.mainFrame.url === TOOLBAR_URL) contents.send('standterm-toolbar-state', state); + }; + const layout = () => { + if (win.isDestroyed()) return; + const [width, height] = win.getContentSize(); + coreView.setBounds({ x: 0, y: TOOLBAR_HEIGHT, width, height: Math.max(0, height - TOOLBAR_HEIGHT) }); + }; + win.contentView.addChildView(coreView); + win.on('resize', layout); + win.contentView.on('bounds-changed', layout); + layout(); + contents.session.setPermissionRequestHandler((_contents, _permission, callback) => callback(false)); + contents.session.setPermissionCheckHandler(() => false); + contents.session.setDevicePermissionHandler(() => false); + contents.session.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !TOOLBAR_ASSETS.includes(details.url) })); + contents.setWindowOpenHandler(() => ({ action: 'deny' })); + for (const event of ['will-navigate', 'will-frame-navigate', 'will-redirect', 'will-attach-webview']) { + contents.on(event, event => event.preventDefault()); + } + ipcMain.handle('standterm-toolbar-action', async (event, action) => { + if (event.sender !== contents || event.senderFrame !== contents.mainFrame + || event.senderFrame.url !== TOOLBAR_URL || typeof action !== 'string' || win.isDestroyed()) return false; + if (action === 'ready') { send(state); return true; } + if (BrowserWindow.getFocusedWindow() !== win || !win.isVisible() || win.isMinimized()) return false; + if (action.startsWith('menu:')) { + const id = action.slice(5); + if (!TOOLBAR_MENUS.includes(id)) return false; + await commands.refresh(); + if (win.isDestroyed() || BrowserWindow.getFocusedWindow() !== win) return false; + coreView.webContents.focus(); + Menu.getApplicationMenu()?.getMenuItemById(id)?.submenu?.popup({ window: win }); + return true; + } + switch (action) { + case 'screenshot-file': await capture.screenshot('file'); break; + case 'screenshot-clipboard': await capture.screenshot('clipboard'); break; + case 'record': await capture.start(); break; + case 'pause-recording': await capture.togglePause(); break; + case 'stop-recording': await capture.stop(); break; + default: return false; + } + return true; + }); + win.once('closed', () => { + ipcMain.removeHandler('standterm-toolbar-action'); + if (!coreView.webContents.isDestroyed()) coreView.webContents.close(); + }); + return { send, layout, url: TOOLBAR_URL, notify: async (notice, error = false) => send({ notice, error, noticeId: ++noticeId }) }; +} + +module.exports = { installToolbar, TOOLBAR_URL, TOOLBAR_HEIGHT }; diff --git a/desktop/toolbar.css b/desktop/toolbar.css new file mode 100644 index 0000000..51feefa --- /dev/null +++ b/desktop/toolbar.css @@ -0,0 +1,19 @@ +:root { color-scheme: dark; font: 12px system-ui, sans-serif; } +body { margin: 0; height: 36px; display: flex; align-items: center; gap: 6px; padding: 0 8px; background: #202020; color: #ddd; box-sizing: border-box; border-bottom: 1px solid #3b3b3b; overflow: hidden; } +nav, #capture-tools { display: flex; align-items: center; flex-shrink: 0; } +button { font: inherit; color: inherit; background: transparent; border: 0; border-radius: 4px; height: 28px; padding: 0 8px; cursor: pointer; } +button:hover { background: #414141; } +button:focus-visible { outline: 2px solid #5ea8ff; outline-offset: -2px; } +button:disabled { opacity: .4; cursor: default; } +[hidden] { display: none !important; } +#notice-area { flex: 1; min-width: 26px; height: 26px; box-sizing: border-box; display: flex; align-items: center; gap: 6px; padding: 0 7px; border: 1px solid #45617b; border-radius: 4px; background: #283a4a; color: #d6eaff; opacity: 0; visibility: hidden; transition: opacity 350ms ease, visibility 0s linear 350ms; } +#notice-area.visible { opacity: 1; visibility: visible; transition-delay: 0s; } +#notice-area.error { border-color: #995453; background: #4b2c2d; color: #ffd9d6; } +#notice { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#notice-indicator { flex-shrink: 0; font-weight: bold; } +#recording-status { white-space: nowrap; color: #ff9393; } +#capture-tools { border-left: 1px solid #555; padding-left: 6px; margin-left: auto; } +svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; vertical-align: middle; } +#record svg, #stop svg { fill: #ed6666; stroke: #ed6666; } +@media (max-width: 750px) { button { padding: 0 5px; } } +@media (prefers-reduced-motion: reduce) { #notice-area { transition: none; } } diff --git a/desktop/toolbar.html b/desktop/toolbar.html new file mode 100644 index 0000000..fc5b0a4 --- /dev/null +++ b/desktop/toolbar.html @@ -0,0 +1,29 @@ + + + + + +