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
20 changes: 20 additions & 0 deletions docs/panel-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,26 @@ without redesigning the transport.
`promptId` is an integer. There is **no** `arguments` field and never will be: the
selection must not be able to reach this process at all.

`kind` is the sender's, and the wire does not enumerate it — but the panel reads
one value out of it, and that is a rule rather than a detail. A prompt whose
`kind` is `input_suggestion` (or `input_suggestion.` followed by anything)
leaves the resident panel and is asked on a frameless, always-on-top card in the
bottom-right corner of the work area, inset 16 logical pixels. Every other kind
stays on the panel's own confirmation card. The reason is the one thing a
suggestion has that a decision does not: it names text for the field somebody is
typing in *right now*, so it is worthless anywhere they have to go and look.

Two consequences the sender can rely on:

- **The same three answers, from either surface.** `approved`, `declined`, and
setting it aside all use this wire unchanged. Setting a suggestion aside leaves
the request live and puts a standing notice on the panel; it does not answer.
- **When the card cannot be placed, the panel asks.** A desktop with no work
area — off Windows there is none, and a headless session has none either — or a
screen too small for a 360-pixel card gets the prompt on the panel instead. A
suggestion is never silently dropped for want of a corner, and a sender never
has to know which surface answered.

`why` and `denyConsequence` exist because a dialog that states only what will
happen makes approval the cheaper answer: the user can see the command but not
what refusing costs, and "just approve it" is what that asymmetry teaches. Both
Expand Down
121 changes: 102 additions & 19 deletions src/ui-smoke-host-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,61 @@ for ($i = 0; $i -lt $lines.Count; $i++) {
`;
}

/**
* The title of the window that currently has the keyboard.
*
* The measurement `ui/src/keyboard.rs` exists for. Its module comment claimed
* `WS_EX_NOACTIVATE` keeps the card from taking focus; a comment cannot fail, so
* this is what turns the claim into something a run can contradict. Empty output
* means no window has a caption, which the caller reads as "cannot tell" rather
* than "nothing has focus".
*/
function foregroundScript(): string {
return `
Add-Type -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("user32.dll")] public static extern System.IntPtr GetForegroundWindow();
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowTextW(System.IntPtr h, System.Text.StringBuilder s, int n);
'@ -Name Fg -Namespace SquidSmoke
$handle = [SquidSmoke.Fg]::GetForegroundWindow()
if ($handle -eq [System.IntPtr]::Zero) { exit 0 }
$text = New-Object System.Text.StringBuilder 512
[void][SquidSmoke.Fg]::GetWindowTextW($handle, $text, 512)
Write-Output $text.ToString()
`;
}

/**
* The work area of the monitor holding the given window, in physical pixels.
*
* `rcWork` and not `rcMonitor`: the monitor is the screen, and the work area is
* the screen minus the taskbar and anything else the desktop reserved. The
* distinction is the whole point of the assertion this feeds — a card placed
* against the monitor's bottom edge sits under the taskbar, visible in a
* screenshot and unreachable in life.
*
* The window's own monitor rather than the primary one, because the card is
* placed relative to wherever the person is working.
*/
function workAreaScript(handle: string): string {
return `${PREAMBLE}
Add-Type -MemberDefinition @'
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
public struct MONITORINFO { public int cbSize; public RECT rcMonitor; public RECT rcWork; public uint dwFlags; }
[System.Runtime.InteropServices.DllImport("user32.dll")] public static extern System.IntPtr MonitorFromWindow(System.IntPtr h, uint flags);
[System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool GetMonitorInfoW(System.IntPtr monitor, ref MONITORINFO info);
'@ -Name Mon -Namespace SquidSmoke
$info = New-Object SquidSmoke.Mon+MONITORINFO
$info.cbSize = [System.Runtime.InteropServices.Marshal]::SizeOf($info)
$monitor = [SquidSmoke.Mon]::MonitorFromWindow([System.IntPtr]${handle}, 2)
if ($monitor -eq [System.IntPtr]::Zero) { exit 0 }
if (-not [SquidSmoke.Mon]::GetMonitorInfoW($monitor, [ref]$info)) { exit 0 }
Write-Output ('{0} {1} {2} {3}' -f $info.rcWork.Left, $info.rcWork.Top,
($info.rcWork.Right - $info.rcWork.Left), ($info.rcWork.Bottom - $info.rcWork.Top))
`;
}

/**
* Windows PowerShell, by absolute path rather than by name — the one resolver
* every PowerShell spawn in this repository answers to. The reasoning behind it
Expand All @@ -275,6 +330,33 @@ async function powershell(script: string, description: string): Promise<string>
return result.stdout.trim();
}

/**
* The panel's window by title, or `null` when there is none on screen.
*
* Module-level rather than a method so that `awaitWindow` can be the polling
* version of the same lookup instead of a second copy of it: two copies drift,
* and the one that drifts is the one that stops finding the window.
*/
async function findWindow(title: string, processId: number): Promise<SmokeWindow | null> {
const line = await powershell(
windowScript(title, processId),
"could not search for the panel window",
);
if (line.length === 0) return null;
const parts = line.split(/\s+/);
if (parts.length !== 5) return null;
const [id, x, y, width, height] = parts.map((part) => part.trim());
const numbers = [x, y, width, height].map(Number);
if (numbers.some((value) => !Number.isFinite(value))) return null;
return {
id: id ?? "",
x: numbers[0] ?? 0,
y: numbers[1] ?? 0,
width: numbers[2] ?? 0,
height: numbers[3] ?? 0,
};
}

export function createWindowsSmokeHost(): SmokeHost {
return {
kind: "windows",
Expand Down Expand Up @@ -315,26 +397,27 @@ export function createWindowsSmokeHost(): SmokeHost {
*/
panelEnvironment: () => ({}),

findWindow,

awaitWindow: (title: string, processId: number) =>
eventually("a visible Squid window", async () => {
const line = await powershell(
windowScript(title, processId),
"could not search for the panel window",
);
if (line.length === 0) return null;
const parts = line.split(/\s+/);
if (parts.length !== 5) return null;
const [id, x, y, width, height] = parts.map((part) => part.trim());
const numbers = [x, y, width, height].map(Number);
if (numbers.some((value) => !Number.isFinite(value))) return null;
return {
id: id ?? "",
x: numbers[0] ?? 0,
y: numbers[1] ?? 0,
width: numbers[2] ?? 0,
height: numbers[3] ?? 0,
};
}),
eventually("a visible Squid window", () => findWindow(title, processId)),

workArea: async (window: SmokeWindow) => {
const line = await powershell(workAreaScript(window.id), "could not read the work area");
if (line.length === 0) return null;
const numbers = line.split(/\s+/).map(Number);
if (numbers.length !== 4 || numbers.some((value) => !Number.isFinite(value))) return null;
const [x, y, width, height] = numbers as [number, number, number, number];
return { x, y, width, height };
},

foregroundWindowTitle: async () => {
const title = await powershell(foregroundScript(), "could not read the foreground window");
// An empty title is a real state — a window with no caption can hold the
// keyboard — but it is not one this assertion can make a claim about, so
// it reports "cannot tell" rather than "nothing has focus".
return title.length === 0 ? null : title;
},

activate: async (window: SmokeWindow, control: SmokeControl) => {
const name = CONTROL_NAME[control];
Expand Down
65 changes: 45 additions & 20 deletions src/ui-smoke-host-x11.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,35 @@ export function createX11SmokeHost(): SmokeHost {
return { ...process.env, DBUS_SESSION_BUS_ADDRESS: busAddress };
};

/**
* A mapped window of this display by exact title, or `null` when there is none.
*
* `IsViewable` and not merely "found": a window this panel puts away is
* unmapped rather than destroyed, so the suggestion card exists on the root
* window's list of children while being invisible. Visibility is what the
* person can see, and visibility is what gets asserted.
*/
const findByTitle = async (title: string): Promise<SmokeWindow | null> => {
const env = displayEnvironment();
const found = await run("xdotool", ["search", "--name", `^${title}$`], env);
for (const id of found.stdout.trim().split(/\s+/)) {
if (id.length === 0) continue;
const info = await run("xwininfo", ["-id", id], env);
if (info.code !== 0 || !info.stdout.includes("IsViewable")) continue;
const read = (label: string): number | null => {
const match = info.stdout.match(new RegExp(`${label}:\\s+(-?\\d+)`));
return match?.[1] === undefined ? null : Number(match[1]);
};
const x = read("Absolute upper-left X");
const y = read("Absolute upper-left Y");
const width = read("Width");
const height = read("Height");
if (x === null || y === null || width === null || height === null) continue;
return { id, x, y, width, height };
}
return null;
};

// The last line of defence against the run that hung: if the process is
// leaving through any path that skips dispose — an uncaught throw, a signal —
// the two detached groups would otherwise outlive it, holding the pipes and
Expand Down Expand Up @@ -274,27 +303,23 @@ export function createX11SmokeHost(): SmokeHost {
* mean reading `_NET_WM_PID` through another tool on the prerequisite list to
* rule out a window that cannot be there.
*/
findWindow: (title: string, _processId: number) => findByTitle(title),

/**
* `null`, and that is the panel's own answer on this platform rather than a
* gap in the harness.
*
* `ui/src/workarea.rs` returns `None` off Windows, on purpose: the whole
* screen would be a guess that a desktop has no panels — true on a bare Xvfb,
* false on every real Linux desktop, where the card would open under the GNOME
* dash and be unreachable. `None` makes the panel fall back to its own card,
* so what the smoke asserts here is that a suggestion still has somewhere to
* be asked — just not on the floating surface.
*/
workArea: async () => null,

awaitWindow: (title: string, _processId: number) =>
eventually("a mapped Squid window", async () => {
const env = displayEnvironment();
const found = await run("xdotool", ["search", "--name", `^${title}$`], env);
for (const id of found.stdout.trim().split(/\s+/)) {
if (id.length === 0) continue;
const info = await run("xwininfo", ["-id", id], env);
if (info.code !== 0 || !info.stdout.includes("IsViewable")) continue;
const read = (label: string): number | null => {
const match = info.stdout.match(new RegExp(`${label}:\\s+(-?\\d+)`));
return match?.[1] === undefined ? null : Number(match[1]);
};
const x = read("Absolute upper-left X");
const y = read("Absolute upper-left Y");
const width = read("Width");
const height = read("Height");
if (x === null || y === null || width === null || height === null) continue;
return { id, x, y, width, height };
}
return null;
}),
eventually("a mapped Squid window", () => findByTitle(title)),

activate: async (window: SmokeWindow, control: SmokeControl) => {
const at = CONTROL_POSITION[control];
Expand Down
49 changes: 49 additions & 0 deletions src/ui-smoke-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ import { spawn } from "node:child_process";
/** The controls the smoke presses, named for intent rather than position. */
export type SmokeControl = "decline" | "later" | "allow" | "review";

/** A rectangle on screen, in whatever pixels the host measures in. */
export interface SmokeRect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}

/** A window on screen, in whatever pixels the host measures in. */
export interface SmokeWindow {
/** The host's own handle for it, for logging and for follow-up calls. */
Expand Down Expand Up @@ -61,6 +69,47 @@ export interface SmokeHost {
* private Xvfb no such window can exist, so that host may honestly ignore it.
*/
awaitWindow(title: string, processId: number): Promise<SmokeWindow>;
/**
* A window of this panel by title, if it exists and is visible right now.
*
* Distinct from `awaitWindow`, which waits for one that must appear. This one
* answers for a window that must *not* be there — the suggestion card before
* any suggestion, and after an answer — and a wait would turn that assertion
* into a two-minute stall followed by a pass for the wrong reason.
*
* Any other window of the process does not count, and neither does a window
* that exists but is hidden: `hide` is how this panel puts the card away, and
* the card is still a live window afterwards. Visibility is the property the
* person can see, so it is the one asserted.
*/
findWindow(title: string, processId: number): Promise<SmokeWindow | null>;
/**
* The work area of the monitor `window` is on — the screen minus the taskbar,
* the docks, and whatever else the desktop reserves — or `null` when this
* platform cannot be asked.
*
* This is the same question `ui/src/workarea.rs` asks, answered by the same
* platform call, and that is why it belongs here rather than being computed by
* the smoke from a screenshot: a harness that measured the screen itself would
* agree with a card placed against the screen edge, which is the defect. `null`
* is a real answer — X11 has no implementation in the panel either — and the
* smoke asserts the documented consequence of it rather than skipping.
*/
workArea(window: SmokeWindow): Promise<SmokeRect | null>;
/**
* The title of the window that currently has the keyboard, or `null` when this
* host cannot tell.
*
* A capability rather than a requirement, like `readStatus`: the panel's
* floating card is only on some platforms, and only some of those can be asked
* who holds the keyboard. A host that omits it says so, and the smoke skips the
* assertion out loud instead of pretending to make it.
*
* This is what makes "the card does not steal focus" a measurement rather than
* a claim. It is the whole reason `ui/src/keyboard.rs` exists, and a comment in
* that file was the only thing asserting it before.
*/
foregroundWindowTitle?(): Promise<string | null>;
activate(window: SmokeWindow, control: SmokeControl): Promise<void>;
screenshot(window: SmokeWindow, path: string): Promise<void>;
/**
Expand Down
Loading