From 4d48d53782d6e689a46141daeed1a5b15ae735b4 Mon Sep 17 00:00:00 2001 From: Ned Date: Tue, 27 Oct 2026 09:42:32 -0700 Subject: [PATCH 1/2] Parent ConPTY console windows to the Dormouse window on Windows A dialog a console program owns by its console window's root owner (MSAL sign-in, Connect-AzAccount) had no tie to Dormouse and fell behind it on the first click. Rust now hands each PTY's window HWND to node-pty's setParentWindow at spawn, and again where a Workspace transfer settles (adoption or hand-back). A recovery thread re-enables a window left disabled by a dialog whose process died (microsoft/node-pty#996), shell or child. Co-Authored-By: Claude Opus 5.5 --- docs/specs/standalone.md | 16 ++ docs/specs/standalone.rationale.md | 12 ++ lib/src/host/remote/outbound.test.ts | 1 + lib/src/host/remote/sidecar-entry.test.ts | 13 ++ lib/src/host/remote/sidecar-entry.ts | 10 ++ scripts/spec-word-budgets.json | 2 +- standalone/scripts/conpty-dialog-probe.ps1 | 28 ++++ standalone/sidecar/pty-core.js | 19 ++- standalone/sidecar/pty-core.test.js | 32 ++++ standalone/src-tauri/Cargo.toml | 3 + standalone/src-tauri/src/console_parent.rs | 178 +++++++++++++++++++++ standalone/src-tauri/src/lib.rs | 101 +++++++++++- 12 files changed, 408 insertions(+), 7 deletions(-) create mode 100644 standalone/scripts/conpty-dialog-probe.ps1 create mode 100644 standalone/src-tauri/src/console_parent.rs diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index a7d06ca27..dc8e18474 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -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". @@ -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. diff --git a/docs/specs/standalone.rationale.md b/docs/specs/standalone.rationale.md index c5ea9bb3b..90bedc15b 100644 --- a/docs/specs/standalone.rationale.md +++ b/docs/specs/standalone.rationale.md @@ -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. diff --git a/lib/src/host/remote/outbound.test.ts b/lib/src/host/remote/outbound.test.ts index 9076e0c5d..cbeabfc68 100644 --- a/lib/src/host/remote/outbound.test.ts +++ b/lib/src/host/remote/outbound.test.ts @@ -122,6 +122,7 @@ async function boot(): Promise { resize: () => {}, hasPty: () => false, spawn: () => {}, + setParentWindow: () => {}, kill: () => {}, gracefulKill: () => {}, list: () => {}, diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index f839c1b36..85c2ae390 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -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'), @@ -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' } } }); diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index fe6474038..d862006de 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -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; @@ -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, { diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 846c0ba1d..8d48df771 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -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, diff --git a/standalone/scripts/conpty-dialog-probe.ps1 b/standalone/scripts/conpty-dialog-probe.ps1 new file mode 100644 index 000000000..4bf009923 --- /dev/null +++ b/standalone/scripts/conpty-dialog-probe.ps1 @@ -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)" diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index a0d945136..859f598f0 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -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; @@ -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 }; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 6c0f8c97a..687467a81 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -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() { diff --git a/standalone/src-tauri/Cargo.toml b/standalone/src-tauri/Cargo.toml index 35da9b004..aa6b77755 100644 --- a/standalone/src-tauri/Cargo.toml +++ b/standalone/src-tauri/Cargo.toml @@ -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] diff --git a/standalone/src-tauri/src/console_parent.rs b/standalone/src-tauri/src/console_parent.rs new file mode 100644 index 000000000..9d51458f5 --- /dev/null +++ b/standalone/src-tauri/src/console_parent.rs @@ -0,0 +1,178 @@ +//! Console window parenting (docs/specs/standalone.md -> "Console window +//! parenting"): the window holding a PTY owns its ConPTY console window, so a +//! dialog a console program parents to it opens above Dormouse. +//! +//! `Recovery` is pure over its own state, like `routing`; `win` holds the Win32 +//! reads and the `EnableWindow` that `lib.rs` drives from its thread. + +use std::collections::HashMap; +use std::time::Duration; + +/// An HWND as node-pty's `setParentWindow` takes it — its bytes in native +/// order — hex-encoded for the JSON line. +pub fn handle_hex(raw: isize) -> String { + raw.to_ne_bytes().iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// How often recovery looks at every window. +pub const TICK: Duration = Duration::from_millis(500); +/// Consecutive ticks a window must be disabled with nothing visible owning it +/// before it is re-enabled: a dialog is created after its owner is disabled, +/// and one tick could land in that gap. +pub const STRIKES: u8 = 2; + +/// One window as a tick sees it. +#[derive(Debug, Clone, Copy)] +pub struct HostState { + pub hwnd: isize, + pub disabled: bool, + /// Minimizing a window hides the windows it owns, its dialog included. + pub minimized: bool, + /// Some visible top-level window has this one in its owner chain. + pub owns_visible: bool, +} + +/// A modal dialog disables its owner and re-enables it when dismissed, so a +/// dialog whose process dies leaves its owner disabled for good +/// (microsoft/node-pty#996). A disabled window owning no visible window is +/// that case, whichever process the dialog lived in — unless it is minimized, +/// which hides a live dialog too. +#[derive(Default)] +pub struct Recovery { + strikes: HashMap, +} + +impl Recovery { + /// The windows to re-enable now. + pub fn tick(&mut self, hosts: &[HostState]) -> Vec { + let mut reenable = Vec::new(); + let mut strikes = HashMap::new(); + for host in hosts.iter().filter(|host| host.disabled && !host.minimized && !host.owns_visible) { + let count = self.strikes.get(&host.hwnd).copied().unwrap_or(0) + 1; + if count >= STRIKES { + reenable.push(host.hwnd); + } else { + strikes.insert(host.hwnd, count); + } + } + self.strikes = strikes; + reenable + } +} + +#[cfg(windows)] +pub mod win { + use super::HostState; + use std::collections::HashSet; + use windows::core::BOOL; + use windows::Win32::Foundation::{HWND, LPARAM}; + use windows::Win32::UI::Input::KeyboardAndMouse::{EnableWindow, IsWindowEnabled}; + use windows::Win32::UI::WindowsAndMessaging::{EnumWindows, GetWindow, IsIconic, IsWindowVisible, GW_OWNER}; + + /// Owner chains are short; this bounds a cycle a hostile window could make. + const MAX_OWNER_DEPTH: usize = 16; + + fn hwnd(raw: isize) -> HWND { + HWND(raw as *mut core::ffi::c_void) + } + + unsafe extern "system" fn collect(window: HWND, list: LPARAM) -> BOOL { + unsafe { (*(list.0 as *mut Vec)).push(window) }; + true.into() + } + + /// Of `hosts`, those that own a visible top-level window, directly or + /// through owned windows (a dialog owned by the hidden console window). + fn owning_visible(hosts: &[isize]) -> HashSet { + let mut all: Vec = Vec::new(); + // An enumeration that fails part-way still judges what it saw; a + // window it missed reads as owning nothing for at most one tick. + let _ = unsafe { EnumWindows(Some(collect), LPARAM(&mut all as *mut Vec as isize)) }; + let mut owning = HashSet::new(); + for window in all.into_iter().filter(|&window| unsafe { IsWindowVisible(window) }.as_bool()) { + let mut current = window; + for _ in 0..MAX_OWNER_DEPTH { + let Ok(owner) = (unsafe { GetWindow(current, GW_OWNER) }) else { break }; + if hosts.contains(&(owner.0 as isize)) { + owning.insert(owner.0 as isize); + break; + } + current = owner; + } + } + owning + } + + pub fn states(hosts: &[isize]) -> Vec { + let disabled: Vec = + hosts.iter().copied().filter(|&raw| !unsafe { IsWindowEnabled(hwnd(raw)) }.as_bool()).collect(); + // The steady state: nothing disabled, nothing to enumerate. + let owning = if disabled.is_empty() { HashSet::new() } else { owning_visible(&disabled) }; + hosts + .iter() + .map(|&raw| HostState { + hwnd: raw, + disabled: disabled.contains(&raw), + minimized: unsafe { IsIconic(hwnd(raw)) }.as_bool(), + owns_visible: owning.contains(&raw), + }) + .collect() + } + + pub fn enable(raw: isize) { + let _ = unsafe { EnableWindow(hwnd(raw), true) }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn host(hwnd: isize, disabled: bool, owns_visible: bool) -> HostState { + HostState { hwnd, disabled, minimized: false, owns_visible } + } + + #[test] + fn handle_bytes_are_native_order() { + let hex = handle_hex(0x0012_3456); + assert_eq!(hex.len(), 2 * std::mem::size_of::()); + let bytes: Vec = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect(); + assert_eq!(bytes, 0x0012_3456isize.to_ne_bytes()); + } + + #[test] + fn a_dialog_keeps_its_owner_disabled() { + let mut recovery = Recovery::default(); + for _ in 0..10 { + assert!(recovery.tick(&[host(1, true, true)]).is_empty()); + } + } + + #[test] + fn an_orphaned_disable_is_undone_on_the_second_tick() { + let mut recovery = Recovery::default(); + assert!(recovery.tick(&[host(1, true, false), host(2, false, false)]).is_empty()); + assert_eq!(recovery.tick(&[host(1, true, false), host(2, false, false)]), vec![1]); + // Counting starts over once it has been re-enabled. + assert!(recovery.tick(&[host(1, true, false)]).is_empty()); + } + + #[test] + fn a_minimized_window_keeps_its_hidden_dialog_modal() { + let mut recovery = Recovery::default(); + let minimized = HostState { minimized: true, ..host(1, true, false) }; + for _ in 0..10 { + assert!(recovery.tick(&[minimized]).is_empty()); + } + } + + #[test] + fn the_gap_before_a_dialog_appears_is_not_a_strike_streak() { + let mut recovery = Recovery::default(); + assert!(recovery.tick(&[host(1, true, false)]).is_empty()); + assert!(recovery.tick(&[host(1, true, true)]).is_empty()); + assert!(recovery.tick(&[host(1, true, false)]).is_empty()); + assert!(recovery.tick(&[host(1, false, false)]).is_empty()); + assert!(recovery.tick(&[host(1, true, false)]).is_empty()); + } +} diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 38fe9da12..585da9dba 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -54,6 +54,10 @@ mod clipboard_win; #[cfg(windows)] mod pe_subsystem; +// Its callers are Windows-only; the pure parts and their tests are not. +#[cfg_attr(not(windows), allow(dead_code))] +mod console_parent; + type SidecarSender = mpsc::Sender; type PendingRequests = Arc>>>; type SharedChild = Arc>>; @@ -1304,14 +1308,72 @@ fn pty_spawn( options: Option, ) { windows.mint(&id, window.label()); - send_to_sidecar(&state, pty_spawn_message(&id, options.as_ref())); + let parent = window_handle_hex(window.app_handle(), window.label()); + send_to_sidecar(&state, pty_spawn_message(&id, options.as_ref(), parent.as_deref())); } /// One `pty:spawn` line. The options ride whole, a persisted `alert` included. -fn pty_spawn_message(id: &str, options: Option<&PtySpawnOptions>) -> String { - sidecar_line("pty:spawn", serde_json::json!({ "id": id, "options": options })) +/// `parentWindow` rides beside them, never in them: only Rust names a window +/// handle (§Console window parenting). +fn pty_spawn_message(id: &str, options: Option<&PtySpawnOptions>, parent_window: Option<&str>) -> String { + let mut data = serde_json::json!({ "id": id, "options": options }); + if let Some(hex) = parent_window { + data["parentWindow"] = JsonValue::from(hex); + } + sidecar_line("pty:spawn", data) +} + +/// `label`'s HWND as the sidecar hands it to node-pty (docs/specs/standalone.md +/// §Console window parenting). +#[cfg(windows)] +fn window_handle_hex(app: &AppHandle, label: &str) -> Option { + let hwnd = app.get_webview_window(label)?.hwnd().ok()?; + Some(console_parent::handle_hex(hwnd.0 as isize)) +} + +#[cfg(not(windows))] +fn window_handle_hex(_app: &AppHandle, _label: &str) -> Option { + None +} + +/// `ids` are now shown in `label`'s window: make it their console windows' +/// owner. Sent once ownership settles, never at a transfer's start, since a +/// tear-out's target has no window yet. +fn send_parent_window(app: &AppHandle, ids: &[String], label: &str) { + if ids.is_empty() { + return; + } + let (Some(hex), Some(sidecar)) = (window_handle_hex(app, label), app.try_state::()) else { + return; + }; + send_to_sidecar(&sidecar, sidecar_line("pty:parentWindow", serde_json::json!({ "ids": ids, "parentWindow": hex }))); +} + +/// Re-enable a window a dead dialog left disabled (`console_parent::Recovery`). +#[cfg(windows)] +fn start_console_parent_recovery(app: &AppHandle) { + let app = app.clone(); + std::thread::spawn(move || { + let mut recovery = console_parent::Recovery::default(); + loop { + std::thread::sleep(console_parent::TICK); + let hosts: Vec = app + .webview_windows() + .values() + .filter_map(|window| window.hwnd().ok()) + .map(|hwnd| hwnd.0 as isize) + .collect(); + for raw in recovery.tick(&console_parent::win::states(&hosts)) { + append_log(format!("[console-parent] window {raw:#x} disabled with no dialog left open; re-enabling it")); + console_parent::win::enable(raw); + } + } + }); } +#[cfg(not(windows))] +fn start_console_parent_recovery(_app: &AppHandle) {} + #[tauri::command] fn pty_write( state: tauri::State<'_, SidecarState>, @@ -3396,6 +3458,7 @@ fn hand_back_arrival( } } let marks = windows.hand_back(&arrival.terminal_ids, &arrival.from); + send_parent_window(app, &arrival.terminal_ids, &arrival.from); let (marked, _) = routing::hand_back_ids(arrival, &marks); // Told before the replay is asked for, so the source is listening for // it (`acceptHandBackReplay` in `standalone/src/workspace-move.ts`). @@ -3626,6 +3689,7 @@ fn adopt_done( ) .ok_or_else(|| format!("no arrival of '{workspace_id}' into {}", window.label()))?; windows.clear_suppression(&arrival.terminal_ids); + send_parent_window(&app, &arrival.terminal_ids, &arrival.to); // Keep the journal until source and target saves both reflect the move. if let Ok(dir) = sessions_dir(&app) { if let Err(e) = mark_arrival_adopted_on_disk(&dir, &workspace_id) { @@ -4817,6 +4881,7 @@ pub fn run() { app.manage(UiWatchdog::default()); start_ui_watchdog(app.handle()); + start_console_parent_recovery(app.handle()); // A crash between a snapshot's temp write and its rename leaves a // file nothing will ever read or overwrite; one written before @@ -5418,11 +5483,23 @@ mod tests { let alert = serde_json::json!({ "status": "ALERT_RINGING", "todo": true, "notification": null }); let options: super::PtySpawnOptions = serde_json::from_value(serde_json::json!({ "cols": 80, "rows": 24, "alert": alert })).unwrap(); - let line = parse_line(&super::pty_spawn_message("t1", Some(&options))); + let line = parse_line(&super::pty_spawn_message("t1", Some(&options), None)); assert_eq!(line["event"], "pty:spawn"); assert_eq!(line["data"]["id"], "t1"); assert_eq!(line["data"]["options"]["cols"], 80); assert_eq!(line["data"]["options"]["alert"], alert); + assert!(line["data"].get("parentWindow").is_none()); + } + + /// The window handle rides beside the options, where a webview cannot put + /// one: `PtySpawnOptions` drops a `parentWindow` it is handed. + #[test] + fn a_spawn_carries_rusts_parent_window_and_never_the_webviews() { + let options: super::PtySpawnOptions = + serde_json::from_value(serde_json::json!({ "cols": 80, "parentWindow": "ff" })).unwrap(); + let line = parse_line(&super::pty_spawn_message("t1", Some(&options), Some("0a0b"))); + assert_eq!(line["data"]["parentWindow"], "0a0b"); + assert!(line["data"]["options"].get("parentWindow").is_none()); } /// A rehydrated Tool's payload rides its spawn whole; dropping the field @@ -5431,7 +5508,7 @@ mod tests { fn a_spawn_carries_a_rehydrated_tools_payload_to_the_sidecar() { let options: super::PtySpawnOptions = serde_json::from_value(serde_json::json!({ "dehydrate": "{\"v\":1,\"state\":[1]}" })).unwrap(); - let line = parse_line(&super::pty_spawn_message("t1", Some(&options))); + let line = parse_line(&super::pty_spawn_message("t1", Some(&options), None)); assert_eq!(line["data"]["options"]["dehydrate"], "{\"v\":1,\"state\":[1]}"); } @@ -5554,6 +5631,20 @@ mod tests { assert!(journal < lock && lock < check && check < refuse && refuse < transfer && transfer < queue); } + /// Console window parenting follows ownership wherever it settles: the + /// spawning window, the adopting target, and the source a hand-back + /// returns to — whatever handed it back (refusal, `Destroyed`, watchdog). + #[test] + fn console_windows_are_parented_wherever_ownership_settles() { + let src = include_str!("lib.rs").split("#[cfg(test)]").next().unwrap(); + let body = |name: &str| src.split(&format!("fn {name}(")).nth(1).unwrap().split("\n}").next().unwrap(); + assert!(body("pty_spawn").contains("window_handle_hex(window.app_handle(), window.label())")); + let adopt = body("adopt_done"); + assert!(adopt.find("routing::take_arrival(").unwrap() < adopt.find("send_parent_window(&app, &arrival.terminal_ids, &arrival.to)").unwrap()); + let hand_back = body("hand_back_arrival"); + assert!(hand_back.find("windows.hand_back(").unwrap() < hand_back.find("send_parent_window(app, &arrival.terminal_ids, &arrival.from)").unwrap()); + } + #[test] fn a_failed_arrival_journal_refuses_the_move_with_nothing_changed() { let dir = TempDir::new("arrival-journal-first"); From 2219c8a872bf0dd78e348cb81fddfe409d8f27bb Mon Sep 17 00:00:00 2001 From: Ned Date: Tue, 27 Oct 2026 09:50:34 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=EF=BB=BFKeep=20console-parenting=20spec=20?= =?UTF-8?q?citations=20on=20one=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- standalone/src-tauri/src/console_parent.rs | 6 +++--- standalone/src-tauri/src/lib.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/standalone/src-tauri/src/console_parent.rs b/standalone/src-tauri/src/console_parent.rs index 9d51458f5..4461b3bf6 100644 --- a/standalone/src-tauri/src/console_parent.rs +++ b/standalone/src-tauri/src/console_parent.rs @@ -1,6 +1,6 @@ -//! Console window parenting (docs/specs/standalone.md -> "Console window -//! parenting"): the window holding a PTY owns its ConPTY console window, so a -//! dialog a console program parents to it opens above Dormouse. +//! docs/specs/standalone.md -> "Console window parenting": the window holding +//! a PTY owns its ConPTY console window, so a dialog a console program parents +//! to it opens above Dormouse. //! //! `Recovery` is pure over its own state, like `routing`; `win` holds the Win32 //! reads and the `EnableWindow` that `lib.rs` drives from its thread. diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 585da9dba..5301d777a 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1323,8 +1323,8 @@ fn pty_spawn_message(id: &str, options: Option<&PtySpawnOptions>, parent_window: sidecar_line("pty:spawn", data) } -/// `label`'s HWND as the sidecar hands it to node-pty (docs/specs/standalone.md -/// §Console window parenting). +/// `label`'s HWND as the sidecar hands it to node-pty +/// (docs/specs/standalone.md §Console window parenting). #[cfg(windows)] fn window_handle_hex(app: &AppHandle, label: &str) -> Option { let hwnd = app.get_webview_window(label)?.hwnd().ok()?;