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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,18 @@ Source of truth: `Arrival` in `standalone/src-tauri/src/routing.rs`; `begin_arri

Source of truth: `window_at` in `standalone/src-tauri/src/routing.rs`; `standalone/src/workspace-drag.ts`.

### Console window parenting

On Windows the window holding a PTY owns its ConPTY console window, so a dialog a console program owns by `GetAncestor(GetConsoleWindow(), GA_ROOTOWNER)`, as MSAL sign-in does, stays above that window instead of falling behind Dormouse (rationale).

- **Only Rust names a window handle.** `pty:spawn` carries `parentWindow`, the HWND's native-order bytes as hex, beside the webview's options and never in them; the sidecar hands it to node-pty's `setParentWindow` in the spawn's own tick, before the shell launches. **Never persist it.**
- **Must re-parent where ownership settles**, with `pty:parentWindow {ids, parentWindow}`: at `adopt_done` to the target, at every hand-back to the source (§Arrival queue); never at a transfer's start, when a tear-out's target has no window yet.
- **A refused or failed parent never blocks a terminal**: the sidecar logs it and the PTY runs unparented.
- **A modal dialog disables its owner until dismissed**: the one window holding that terminal, every pane in it included; sibling windows stay usable.
- **Must re-enable a window that stays disabled, unminimized, with no visible window in its owner chain** over consecutive recovery ticks, so a dead dialog — its process the shell or a child the shell outlives — never strands the window (microsoft/node-pty#996 recovers only on shell exit). A minimized window hides the dialogs it owns, so it is never judged. **Never take foreground back** after a recovery: Windows has already activated another app, and the user clicks back in.

Source of truth: `standalone/src-tauri/src/console_parent.rs`; `pty_spawn` / `send_parent_window` / `start_console_parent_recovery` in `standalone/src-tauri/src/lib.rs`; `setParentWindow` in `standalone/sidecar/pty-core.js`. `standalone/scripts/conpty-dialog-probe.ps1` opens such a dialog by hand.

## Persistence

**One `PersistedWindow` per window, every Workspace in it**, restored on the next launch (`docs/specs/transport.md` → "The governing rule"). The Window collector's writer is `TauriAdapter.saveWindowState`; `getWindowState` is the boot reader. Collector and cwd probes: `docs/specs/transport.md` → "Persisted session types".
Expand Down Expand Up @@ -475,6 +487,10 @@ Source of truth: `standalone/scripts/dev-agent-browser.mjs`; `standalone/src/bro

## Future

**Scope: console-parenting-vscode** (§Console window parenting)

- **VS Code**, once it offers a supported way to name the window hosting a webview to Dormouse's own PTY host: `window.nativeHandle` is a proposed, Insiders-only API tracking the active window.

**Scope: siri-setting**

- **A setting to allow the Siri affordance**, for users who want Siri in Dormouse. The override stays installed and `disallow` reads the setting, answering from `WKWebView`'s own implementation when it allows Siri: the runtime cannot remove a method, so toggling is a flag, not a second swap.
12 changes: 12 additions & 0 deletions docs/specs/standalone.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ Spiked before the drag was built, because it was the one unverified platform ass

A WKWebView pointer captured on an element keeps receiving `pointermove` and `pointerup` far outside the window, with client coordinates that run past the edges and negative rather than clamping — measured against macOS 26.6 / WKWebView, 2026-09, by synthesizing an AppKit drag whose later points fall outside a 400×300 window and reading the page's own event log: `down` inside the element, then moves at client x 600 and (900, −152), then the release. So the gesture stays the webview's throughout and no polling loop is needed.

## Console window parenting

Without parenting, ConPTY's hidden console window has no owner, so a dialog owned by its root owner belongs to nothing Dormouse shows: it opens on top only while its process holds foreground rights, and the first click into Dormouse buries it. With parenting, the root owner is the Dormouse window, and an owned window always stays above its owner. Measured in a dev build on Windows 11 26300, 2026-10, with `standalone/scripts/conpty-dialog-probe.ps1` (`MessageBoxW` owned by `GetAncestor(GetConsoleWindow(), GA_ROOTOWNER)`). The mechanism is node-pty#995 (`setParentWindow`, which calls bundled conpty.dll's `ConptyReparentPseudoConsole`), the same one VS Code's integrated terminal enables by default since microsoft/vscode#342784.

- Off: the dialog's owner was the console window, and Dormouse stayed enabled. Clicking Dormouse raised it above the dialog.
- On: the owner was the window holding the pane — for a split pane, a torn-out Workspace's new window, and `main` again after moving it back. Only that window was disabled. A click on it left the dialog on top, and dismissing it returned foreground and keyboard focus to the xterm.
- Killing only the dialog's process while the shell lived left the window disabled with nothing to dismiss. That is node-pty#996's failure in the case its proposed shell-exit recovery misses. Recovery re-enabled the window about 1 s later. `dor kill` on the pane closed the dialog with its console, and recovery re-enabled the window within 0.2 s. No probe process outlived either kill.
- After a dead dialog, Windows activated another application, not Dormouse, so the user clicks back in. Taking foreground back would be stealing it, so recovery only re-enables.
- It is on by default, as VS Code's is, and with recovery VS Code lacks: the modal locking its whole window is what keeps the dialog above it, and every owned modal does the same.

Two consecutive ticks, not one, because an owner is disabled before its dialog is created, and one tick can fall in that gap. The owner-chain walk, not a direct-owner check, is needed because a dialog may be owned by the console window rather than by its root owner.

## Persistence

**The WKWebView WAL measurement.** WKWebView stores `localStorage` as SQLite in WAL mode, and WebKit pins that WAL with a long-lived reader that never advances during a running session — so it is never checkpointed, and an external checkpoint is blocked by the same reader. Rewriting the multi-MB scrollback-bearing session blob on every save grew the WAL to ~1 GB within a few hours (recorded 2026-07); a days-long session made it pathological. The Rust file store that replaced it has no WAL and rewrites the same file each time.
Expand Down
1 change: 1 addition & 0 deletions lib/src/host/remote/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ async function boot(): Promise<void> {
resize: () => {},
hasPty: () => false,
spawn: () => {},
setParentWindow: () => {},
kill: () => {},
gracefulKill: () => {},
list: () => {},
Expand Down
13 changes: 13 additions & 0 deletions lib/src/host/remote/sidecar-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,7 @@ describe('the sidecar host', () => {
calls.push({ op: 'spawn', args: [id, options], state: stateOf(id) });
duringSpawn?.(id);
},
setParentWindow: record('setParentWindow'),
write: record('write', true),
resize: record('resize', true),
kill: record('kill'),
Expand Down Expand Up @@ -775,6 +776,18 @@ describe('the sidecar host', () => {
expect(states('pty-1').at(-1)).toMatchObject({ todo: true, notification: REPORT });
});

it('parents a console window only from the host-stamped field, never the options', () => {
host.handleCommand('pty:spawn', { id: 'pty-1', options: { parentWindow: 'ff' } });
host.handleCommand('pty:spawn', { id: 'pty-2', options: {}, parentWindow: '0102' });
host.handleCommand('pty:parentWindow', { ids: ['pty-1', 7], parentWindow: '0304' });
expect(calls.map(({ op, args }) => [op, ...args])).toEqual([
['spawn', 'pty-1', { parentWindow: 'ff' }],
['spawn', 'pty-2', {}],
['setParentWindow', ['pty-2'], '0102'],
['setParentWindow', ['pty-1'], '0304'],
]);
});

it('keeps a helper `pty-core` reports at its spawn inert', () => {
duringSpawn = (id) => host.alerts.setHelper(id, true);
host.handleCommand('pty:spawn', { id: 'helper-1', options: { helper: { parentId: 'pty-1', command: 'git status' } } });
Expand Down
10 changes: 10 additions & 0 deletions lib/src/host/remote/sidecar-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ export function createSidecarSurfaceBridge(
/** The slice of `pty-core`'s manager the host's own commands drive. */
export interface SidecarHostPtyManager extends SidecarPtyManager {
spawn(id: string, options?: unknown): void;
/** `parentWindow`: an HWND's native-order bytes as hex. */
setParentWindow(ids: string[], parentWindow: string): void;
kill(id: string): void;
gracefulKill(ids: string[], timeout?: unknown): void;
list(ids: unknown, forWindow: unknown, requestId: unknown, marks: unknown): void;
Expand Down Expand Up @@ -545,8 +547,16 @@ export function createSidecarHost(options: SidecarHostOptions): SidecarHost {
bridge.onPtySpawn(id);
alertHost.respawn(id, alert);
mgr.spawn(id, spawnOptions);
// Rust's, never the webview's options: in the same tick as the
// spawn, so ConPTY has it before the shell launches.
if (isString(detail.parentWindow)) mgr.setParentWindow([id], detail.parentWindow);
return true;
}
// Console window parenting (docs/specs/standalone.md): the PTYs'
// window changed.
case 'pty:parentWindow':
if (Array.isArray(detail.ids) && isString(detail.parentWindow)) mgr.setParentWindow(detail.ids.filter(isString), detail.parentWindow);
return true;
case 'pty:input':
if (detail.launch === true && isString(id) && isString(detail.data)) bridge.onPtyLaunch(id, detail.data);
pty.write(id as string, detail.data as string, {
Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"docs/specs/security-supply-chain.md": 1650,
"docs/specs/security.md": 2900,
"docs/specs/shortcuts.md": 1150,
"docs/specs/standalone.md": 6450,
"docs/specs/standalone.md": 6800,
"docs/specs/terminal-context.md": 1000,
"docs/specs/terminal-escapes.md": 1850,
"docs/specs/terminal-state.md": 2450,
Expand Down
28 changes: 28 additions & 0 deletions standalone/scripts/conpty-dialog-probe.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Manual probe for ConPTY window parenting (docs/specs/standalone.md -> "Console window parenting").
#
# Opens a modal message box owned the way MSAL/WAM sign-in (Connect-AzAccount) owns its
# account picker: GetAncestor(GetConsoleWindow(), GA_ROOTOWNER). Without parenting that is
# ConPTY's hidden console window, so the box can open behind Dormouse; with it, the box is
# owned by (and disables) the Dormouse window holding the pane.
#
# powershell -NoProfile -File conpty-dialog-probe.ps1 # dialog in a child of the shell
# . .\conpty-dialog-probe.ps1 # dialog in the shell itself
#
# Printing the PID first lets a second terminal `Stop-Process` only the dialog's process.
param([string]$Caption = 'Dormouse ConPTY probe')

Add-Type -Namespace DormouseProbe -Name Win32 -MemberDefinition @'
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
[DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr hwnd, uint flags);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int MessageBoxW(IntPtr owner, string text, string caption, uint type);
'@

$console = [DormouseProbe.Win32]::GetConsoleWindow()
$owner = [DormouseProbe.Win32]::GetAncestor($console, 3) # GA_ROOTOWNER
$ownerPid = [uint32]0
[void][DormouseProbe.Win32]::GetWindowThreadProcessId($owner, [ref]$ownerPid)
$ownerName = (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue).ProcessName
'probe pid {0}; console 0x{1:X}; root owner 0x{2:X} ({3} pid {4})' -f $PID, $console.ToInt64(), $owner.ToInt64(), $ownerName, $ownerPid
$answer = [DormouseProbe.Win32]::MessageBoxW($owner, "Owned by 0x$('{0:X}' -f $owner.ToInt64()) ($ownerName). Dismiss me.", $Caption, 0)
"dismissed ($answer)"
19 changes: 18 additions & 1 deletion standalone/sidecar/pty-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -1696,6 +1696,23 @@ module.exports.create = function create(send, ptyModule, { platform = process.pl
console.error(`[pty-core] spawned: ${id} (${config.shell}, ${config.cols}x${config.rows})`);
}

// Console window parenting (docs/specs/standalone.md): `hex` is an HWND's
// native-order bytes, which only the host can name. Best effort: a refusal is
// logged and the terminal runs on unparented.
function setParentWindow(ids, hex) {
if (typeof hex !== 'string' || !/^(?:[0-9a-f]{2})+$/.test(hex)) return;
const handle = Buffer.from(hex, 'hex');
for (const id of Array.isArray(ids) ? ids : []) {
const p = ptys.get(id);
if (typeof p?.setParentWindow !== 'function') continue;
try {
if (!p.setParentWindow(handle)) console.error(`[pty-core] ConPTY did not take a parent window for ${id}`);
} catch (error) {
console.error(`[pty-core] setting ${id}'s parent window failed:`, error.message);
}
}
}

function write(id, data, { paced = false } = {}) {
const p = ptys.get(id);
if (!p) return;
Expand Down Expand Up @@ -2040,7 +2057,7 @@ module.exports.create = function create(send, ptyModule, { platform = process.pl
send('shells', { shells: await detectAvailableShells(), requestId });
}

return { spawn, write, resize, hasPty, kill, killAll, list, context,
return { spawn, setParentWindow, write, resize, hasPty, kill, killAll, list, context,
getHelperParentId: (id) => helpers.get(id)?.parentId,
getCwd, getCwds, getOpenPorts, getOpenPortsMany, interrupt, gracefulKill, getShells,
liveIds, receivedChars, outputSince, mark };
Expand Down
32 changes: 32 additions & 0 deletions standalone/sidecar/pty-core.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2246,6 +2246,38 @@ test('getCwds answers a key for every requested id, null for one with no PTY', a
});


test('setParentWindow hands node-pty the HWND bytes and never lets a refusal escape', (t) => {
t.mock.method(console, 'error', () => {});
const handles = [];
const answers = { a: true, b: false, c: 'throw' };
const mgr = create(() => {}, {
spawn(shell, args, opts) {
const id = opts.env.DORMOUSE_SURFACE_ID;
const p = { pid: 1, onData() {}, onExit() {}, resize() {}, write() {}, kill() {} };
// Absent, as on a node-pty without the API (or a Unix terminal stub).
if (id === 'd') return p;
return { ...p, setParentWindow(handle) {
handles.push([id, [...handle]]);
if (answers[id] === 'throw') throw new Error('hwnd must be a Buffer the size of an HWND');
return answers[id];
} };
},
});
for (const id of ['a', 'b', 'c', 'd']) mgr.spawn(id);
console.error.mock.resetCalls();

mgr.setParentWindow(['a', 'b', 'c', 'd', 'gone'], '0a0b0c0d00000000');
mgr.setParentWindow(['a'], 'not hex');
mgr.setParentWindow(['a'], 'abc');

assert.deepEqual(handles, [
['a', [10, 11, 12, 13, 0, 0, 0, 0]],
['b', [10, 11, 12, 13, 0, 0, 0, 0]],
['c', [10, 11, 12, 13, 0, 0, 0, 0]],
]);
assert.equal(console.error.mock.callCount(), 2);
});

// --- Per-window list / replay / kill (docs/specs/standalone.md -> "Windows") ---

function fakePtyModule() {
Expand Down
3 changes: 3 additions & 0 deletions standalone/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ windows = { version = "0.62", features = [
# restrict_to_owner's Windows arm: the DACL that stands in for 0700/0600.
"Win32_Security",
"Win32_Security_Authorization",
# console_parent: owner chains, and re-enabling a window a dead dialog disabled.
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Input_KeyboardAndMouse",
] }

[target.'cfg(target_os = "macos")'.dependencies]
Expand Down
Loading
Loading