From 83c1e0d185e9e45e561fda1990f012eed6a0f616 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:50:00 +0000 Subject: [PATCH 1/9] test(desktop-gpui): probe how the picker overlays stack against the camera --- .github/workflows/picker-smoke.yml | 134 ++++++++++++++ apps/desktop-gpui/src/main.rs | 4 + apps/desktop-gpui/src/picker_probe.rs | 241 ++++++++++++++++++++++++++ scripts/picker-smoke/run.sh | 40 +++++ 4 files changed, 419 insertions(+) create mode 100644 .github/workflows/picker-smoke.yml create mode 100644 apps/desktop-gpui/src/picker_probe.rs create mode 100755 scripts/picker-smoke/run.sh diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml new file mode 100644 index 0000000000..2cb6d47ced --- /dev/null +++ b/.github/workflows/picker-smoke.yml @@ -0,0 +1,134 @@ +name: Picker smoke (real app) +# Opens the real GPUI app's target picker on macOS, Windows and Linux (X11 +# with a window manager) through `CAP_PICKER_PROBE_OUTPUT` and checks how the +# picker's windows stack against the camera bubble. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "apps/desktop-gpui/src/target_overlay.rs" + - "apps/desktop-gpui/src/app_windows.rs" + - "apps/desktop-gpui/src/camera_window.rs" + - "apps/desktop-gpui/src/picker_probe.rs" + - "apps/desktop-gpui/src/platform.rs" + - "apps/desktop-gpui/src/platform/**" + - "scripts/picker-smoke/**" + - ".github/workflows/picker-smoke.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: picker-smoke-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: Picker smoke (${{ matrix.settings.name }}) + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + settings: + - name: macOS + slug: macos + target: aarch64-apple-darwin + runner: blacksmith-6vcpu-macos-15 + - name: Windows + slug: windows + target: x86_64-pc-windows-msvc + runner: blacksmith-8vcpu-windows-2025 + - name: Linux X11 + slug: linux-x11 + target: x86_64-unknown-linux-gnu + runner: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ matrix.settings.runner }} + env: + CARGO_PROFILE_DEV_DEBUG: "0" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux desktop dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install-desktop-deps + + - name: Install a window manager and a test window + if: ${{ runner.os == 'Linux' }} + run: sudo apt-get install -y openbox xterm + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.settings.target }} + + - uses: ./.github/actions/setup-rust-cache + with: + target: ${{ matrix.settings.target }} + prefix-key: v1-rust-gpui + save-cache: "false" + workspaces: apps/desktop-gpui -> target + + - name: Run setup + shell: bash + run: node scripts/setup.js + env: + RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} + + - name: Prepare GPUI dependency + shell: bash + run: | + node scripts/sync-desktop-versions.mjs + ./scripts/prepare-gpui-dependency.sh + + - name: Build the app + shell: bash + run: | + cargo +1.95.0 build --manifest-path apps/desktop-gpui/Cargo.toml --locked \ + --target ${{ matrix.settings.target }} --bin cap-gpui + if [[ "$RUNNER_OS" == "macOS" ]]; then + frameworks="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/Frameworks" + mkdir -p "$frameworks" + ln -sfn "$GITHUB_WORKSPACE/target/Frameworks/Spacedrive.framework" "$frameworks/Spacedrive.framework" + fi + + - name: Picker probe + shell: bash + run: | + app="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/cap-gpui" + out="$RUNNER_TEMP/picker-smoke" + case "$RUNNER_OS" in + macOS) + caffeinate -u -t 2 + open -a TextEdit + sleep 2 + scripts/picker-smoke/run.sh "$app" "$out" + ;; + Windows) + export PATH="$(cygpath "$GITHUB_WORKSPACE/target/debug"):$PATH" + notepad.exe & + sleep 2 + scripts/picker-smoke/run.sh "$app.exe" "$out" + ;; + Linux) + export LD_LIBRARY_PATH="$GITHUB_WORKSPACE/target/native-deps/lib:$GITHUB_WORKSPACE/target/debug${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + Xvfb :99 -screen 0 1920x1080x24 -noreset & + export DISPLAY=:99 + sleep 1 + openbox & + sleep 1 + xterm -geometry 100x30+300+200 & + sleep 1 + scripts/picker-smoke/run.sh "$app" "$out" + ;; + esac + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: picker-smoke-${{ matrix.settings.slug }} + path: ${{ runner.temp }}/picker-smoke + retention-days: 7 diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index db32a0fa61..448506de17 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -69,6 +69,8 @@ mod permissions_ui; #[cfg(test)] mod picker_benchmark; #[cfg(debug_assertions)] +mod picker_probe; +#[cfg(debug_assertions)] mod picker_ui_benchmark; mod platform; mod presets; @@ -806,6 +808,8 @@ fn main() { // synthetic clicks are dropped. #[cfg(debug_assertions)] picker_ui_benchmark::run(window_handle, cx); + #[cfg(debug_assertions)] + picker_probe::run(window_handle, cx); if let Ok(auto) = std::env::var("CAP_GPUI_AUTO_RECORD") && let Some((mode, secs)) = parse_auto_record(&auto) { diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs new file mode 100644 index 0000000000..108ec8c492 --- /dev/null +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -0,0 +1,241 @@ +use std::time::Duration; + +use gpui::{App, AsyncApp, WindowHandle}; + +use crate::{ + app_windows, + main_window::{MainWindow, TargetType}, + target_overlay::AreaRect, +}; + +/// `CAP_PICKER_PROBE_OUTPUT=`: open the camera bubble, arm each picker +/// mode, activate an overlay the way a click does, and write the native +/// front-to-back order of Cap's windows so CI can check the camera stays on +/// top. +pub fn run(main: WindowHandle, cx: &mut App) { + let Some(output) = std::env::var_os("CAP_PICKER_PROBE_OUTPUT") else { + return; + }; + cx.spawn(async move |cx| { + let delay = std::env::var("CAP_PICKER_PROBE_DELAY_MS") + .ok() + .and_then(|delay| delay.parse().ok()) + .unwrap_or(2000); + sleep(cx, delay).await; + cx.update(app_windows::open_camera_window); + sleep(cx, 800).await; + + let mut modes = Vec::new(); + for (label, kind) in [ + ("display", TargetType::Display), + ("window", TargetType::Window), + ("area", TargetType::Area), + ] { + main.update(cx, |view, _, cx| view.arm_overlay(kind, cx)) + .ok(); + sleep(cx, 800).await; + if kind == TargetType::Area { + cx.update(|cx| { + app_windows::seed_area_selection( + None, + AreaRect { + x: 200., + y: 200., + width: 640., + height: 360., + }, + cx, + ) + }); + } + cx.update(|cx| { + for (_, overlay) in cx.global::().overlays.clone() { + overlay + .update(cx, |_, window, _| window.activate_window()) + .ok(); + } + }); + sleep(cx, 800).await; + modes.push(cx.update(|cx| probe_mode(label, cx))); + cx.update(app_windows::dismiss_target_overlays); + sleep(cx, 400).await; + } + + let passed = modes + .iter() + .all(|mode| mode["cameraAboveOverlays"].as_bool() == Some(true)); + let report = serde_json::json!({ "passed": passed, "modes": modes }); + if let Err(error) = std::fs::write(output, report.to_string()) { + tracing::error!(%error, "Could not write picker probe results"); + } + cx.update(|cx| cx.quit()); + }) + .detach(); +} + +async fn sleep(cx: &mut AsyncApp, millis: u64) { + cx.background_executor() + .timer(Duration::from_millis(millis)) + .await; +} + +fn probe_mode(label: &str, cx: &mut App) -> serde_json::Value { + let windows = cx.global::(); + let camera = windows.camera; + let overlays = windows.overlays.clone(); + let camera_id = camera.and_then(|camera| { + camera + .update(cx, |_, window, _| native_id(window)) + .ok() + .flatten() + }); + let overlay_ids: Vec = overlays + .iter() + .filter_map(|(_, overlay)| { + overlay + .update(cx, |_, window, _| native_id(window)) + .ok() + .flatten() + }) + .collect(); + let stacking = own_window_stacking(); + let position = |id: isize| stacking.iter().position(|current| *current == id); + let camera_position = camera_id.and_then(position); + let overlay_positions: Vec> = + overlay_ids.iter().map(|id| position(*id)).collect(); + let camera_above = camera_position.is_some_and(|camera| { + !overlay_positions.is_empty() + && overlay_positions + .iter() + .all(|overlay| overlay.is_some_and(|overlay| camera < overlay)) + }); + serde_json::json!({ + "mode": label, + "camera": camera_id, + "overlays": overlay_ids, + "stackingFrontToBack": stacking, + "cameraAboveOverlays": camera_above, + }) +} + +#[cfg(target_os = "macos")] +fn native_id(window: &gpui::Window) -> Option { + crate::platform::window_number(window) +} + +#[cfg(target_os = "windows")] +fn native_id(window: &gpui::Window) -> Option { + crate::platform::window_number(window) +} + +#[cfg(target_os = "linux")] +fn native_id(window: &gpui::Window) -> Option { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + match HasWindowHandle::window_handle(window).ok()?.as_raw() { + RawWindowHandle::Xlib(handle) => isize::try_from(handle.window).ok(), + RawWindowHandle::Xcb(handle) => isize::try_from(handle.window.get()).ok(), + _ => None, + } +} + +#[cfg(target_os = "macos")] +fn own_window_stacking() -> Vec { + use core_foundation::array::{CFArray, CFArrayRef}; + use core_foundation::base::{CFType, TCFType}; + use core_foundation::dictionary::CFDictionary; + use core_foundation::number::CFNumber; + use core_foundation::string::{CFString, CFStringRef}; + + #[link(name = "CoreGraphics", kind = "framework")] + unsafe extern "C" { + fn CGWindowListCopyWindowInfo(option: u32, relative_to: u32) -> CFArrayRef; + static kCGWindowOwnerPID: CFStringRef; + static kCGWindowNumber: CFStringRef; + } + const ON_SCREEN_ONLY: u32 = 1 << 0; + + let list = unsafe { + let raw = CGWindowListCopyWindowInfo(ON_SCREEN_ONLY, 0); + if raw.is_null() { + return Vec::new(); + } + CFArray::>::wrap_under_create_rule(raw) + }; + let (owner_key, number_key) = unsafe { + ( + CFString::wrap_under_get_rule(kCGWindowOwnerPID), + CFString::wrap_under_get_rule(kCGWindowNumber), + ) + }; + let pid = i64::from(std::process::id()); + list.iter() + .filter_map(|window| { + let field = |key: &CFString| window.find(key)?.downcast::()?.to_i64(); + (field(&owner_key)? == pid).then_some(())?; + isize::try_from(field(&number_key)?).ok() + }) + .collect() +} + +#[cfg(target_os = "windows")] +fn own_window_stacking() -> Vec { + use windows_sys::Win32::{ + Foundation::{BOOL, HWND, LPARAM, TRUE}, + UI::WindowsAndMessaging::{EnumWindows, GetWindowThreadProcessId, IsWindowVisible}, + }; + + unsafe extern "system" fn collect(window: HWND, data: LPARAM) -> BOOL { + let windows = unsafe { &mut *(data as *mut Vec) }; + let mut owner = 0; + unsafe { GetWindowThreadProcessId(window, &mut owner) }; + if owner == std::process::id() && unsafe { IsWindowVisible(window) } != 0 { + windows.push(window as isize); + } + TRUE + } + + let mut windows = Vec::new(); + unsafe { + EnumWindows(Some(collect), std::ptr::addr_of_mut!(windows) as isize); + } + windows +} + +#[cfg(target_os = "linux")] +fn own_window_stacking() -> Vec { + use x11rb::connection::Connection as _; + use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _}; + + let Ok((connection, screen)) = x11rb::connect(None) else { + return Vec::new(); + }; + let root = connection.setup().roots[screen].root; + let stacking = connection + .intern_atom(false, b"_NET_CLIENT_LIST_STACKING") + .ok() + .and_then(|cookie| cookie.reply().ok()) + .and_then(|atom| { + connection + .get_property(false, root, atom.atom, AtomEnum::WINDOW, 0, u32::MAX) + .ok()? + .reply() + .ok() + }) + .and_then(|reply| reply.value32().map(|values| values.collect::>())) + .filter(|values| !values.is_empty()); + let bottom_to_top = match stacking { + Some(values) => values, + None => connection + .query_tree(root) + .ok() + .and_then(|cookie| cookie.reply().ok()) + .map(|tree| tree.children) + .unwrap_or_default(), + }; + bottom_to_top + .into_iter() + .rev() + .filter_map(|window| isize::try_from(window).ok()) + .collect() +} diff --git a/scripts/picker-smoke/run.sh b/scripts/picker-smoke/run.sh new file mode 100755 index 0000000000..7241a4981f --- /dev/null +++ b/scripts/picker-smoke/run.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Runs cap-gpui's picker probe (src/picker_probe.rs) in a throwaway profile and +# fails unless every check in its report passed. +# +# scripts/picker-smoke/run.sh +set -euo pipefail + +APP=$1 +OUT=$2 +mkdir -p "$OUT/profile/recordings" "$OUT/profile/logs" +cat > "$OUT/profile/store" <<'JSON' +{"general_settings":{"hasCompletedOnboarding":true,"hasCompletedStartup":true,"enableNotifications":false,"enableTelemetry":false,"recordingCountdown":0},"recording_start_safety":{"confirmBeforeRecordingWithoutMicrophone":false}} +JSON +report="$OUT/report.json" +rm -f "$report" + +env CAP_GPUI_APP_DATA_DIR="$OUT/profile" \ + CAP_GPUI_RECORDINGS_DIR="$OUT/profile/recordings" \ + CAP_GPUI_LOG_DIR="$OUT/profile/logs" \ + CAP_GPUI_AUTO_NO_MIC=1 \ + CAP_PICKER_PROBE_OUTPUT="$report" \ + RUST_LOG="${RUST_LOG:-cap_gpui=info}" \ + "$APP" > "$OUT/app.log" 2>&1 & +pid=$! + +for _ in $(seq 1 240); do + [ -s "$report" ] && break + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 +done +kill "$pid" 2>/dev/null || true + +if [ ! -s "$report" ]; then + echo "picker probe wrote no report" >&2 + tail -n 80 "$OUT/app.log" >&2 || true + exit 1 +fi +cat "$report" +echo +grep -q '"passed":true' "$report" From 16ec5a6418f51affe1d229a920170f5413ce76e9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:54:16 +0000 Subject: [PATCH 2/9] ci(desktop-gpui): link Spacedrive.framework where the picker smoke binary looks --- .github/workflows/picker-smoke.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml index 2cb6d47ced..b7d664442e 100644 --- a/.github/workflows/picker-smoke.yml +++ b/.github/workflows/picker-smoke.yml @@ -89,9 +89,11 @@ jobs: cargo +1.95.0 build --manifest-path apps/desktop-gpui/Cargo.toml --locked \ --target ${{ matrix.settings.target }} --bin cap-gpui if [[ "$RUNNER_OS" == "macOS" ]]; then - frameworks="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/Frameworks" - mkdir -p "$frameworks" - ln -sfn "$GITHUB_WORKSPACE/target/Frameworks/Spacedrive.framework" "$frameworks/Spacedrive.framework" + for frameworks in "$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/Frameworks" \ + "$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/Frameworks"; do + mkdir -p "$frameworks" + ln -sfn "$GITHUB_WORKSPACE/target/Frameworks/Spacedrive.framework" "$frameworks/Spacedrive.framework" + done fi - name: Picker probe From 1af0612d51d8c092130dbd85edf404172f62a8bc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:33:05 +0000 Subject: [PATCH 3/9] test(desktop-gpui): read X11 stacking from the window tree and run the Windows probe on an interactive desktop --- .github/workflows/picker-smoke.yml | 2 +- apps/desktop-gpui/src/picker_probe.rs | 91 +++++++++++++++++---------- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml index b7d664442e..765c5cd5e8 100644 --- a/.github/workflows/picker-smoke.yml +++ b/.github/workflows/picker-smoke.yml @@ -39,7 +39,7 @@ jobs: - name: Windows slug: windows target: x86_64-pc-windows-msvc - runner: blacksmith-8vcpu-windows-2025 + runner: windows-2025 - name: Linux X11 slug: linux-x11 target: x86_64-unknown-linux-gnu diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index 108ec8c492..ab2f848193 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -73,6 +73,11 @@ pub fn run(main: WindowHandle, cx: &mut App) { .detach(); } +struct StackEntry { + id: isize, + level: Option, +} + async fn sleep(cx: &mut AsyncApp, millis: u64) { cx.background_executor() .timer(Duration::from_millis(millis)) @@ -98,8 +103,12 @@ fn probe_mode(label: &str, cx: &mut App) -> serde_json::Value { .flatten() }) .collect(); - let stacking = own_window_stacking(); - let position = |id: isize| stacking.iter().position(|current| *current == id); + let ours: Vec = camera_id + .into_iter() + .chain(overlay_ids.iter().copied()) + .collect(); + let stacking = own_window_stacking(&ours); + let position = |id: isize| stacking.iter().position(|entry| entry.id == id); let camera_position = camera_id.and_then(position); let overlay_positions: Vec> = overlay_ids.iter().map(|id| position(*id)).collect(); @@ -113,7 +122,10 @@ fn probe_mode(label: &str, cx: &mut App) -> serde_json::Value { "mode": label, "camera": camera_id, "overlays": overlay_ids, - "stackingFrontToBack": stacking, + "stackingFrontToBack": stacking + .iter() + .map(|entry| serde_json::json!({ "id": entry.id, "level": entry.level })) + .collect::>(), "cameraAboveOverlays": camera_above, }) } @@ -140,7 +152,7 @@ fn native_id(window: &gpui::Window) -> Option { } #[cfg(target_os = "macos")] -fn own_window_stacking() -> Vec { +fn own_window_stacking(_ours: &[isize]) -> Vec { use core_foundation::array::{CFArray, CFArrayRef}; use core_foundation::base::{CFType, TCFType}; use core_foundation::dictionary::CFDictionary; @@ -152,6 +164,7 @@ fn own_window_stacking() -> Vec { fn CGWindowListCopyWindowInfo(option: u32, relative_to: u32) -> CFArrayRef; static kCGWindowOwnerPID: CFStringRef; static kCGWindowNumber: CFStringRef; + static kCGWindowLayer: CFStringRef; } const ON_SCREEN_ONLY: u32 = 1 << 0; @@ -162,10 +175,11 @@ fn own_window_stacking() -> Vec { } CFArray::>::wrap_under_create_rule(raw) }; - let (owner_key, number_key) = unsafe { + let (owner_key, number_key, layer_key) = unsafe { ( CFString::wrap_under_get_rule(kCGWindowOwnerPID), CFString::wrap_under_get_rule(kCGWindowNumber), + CFString::wrap_under_get_rule(kCGWindowLayer), ) }; let pid = i64::from(std::process::id()); @@ -173,13 +187,16 @@ fn own_window_stacking() -> Vec { .filter_map(|window| { let field = |key: &CFString| window.find(key)?.downcast::()?.to_i64(); (field(&owner_key)? == pid).then_some(())?; - isize::try_from(field(&number_key)?).ok() + Some(StackEntry { + id: isize::try_from(field(&number_key)?).ok()?, + level: field(&layer_key), + }) }) .collect() } #[cfg(target_os = "windows")] -fn own_window_stacking() -> Vec { +fn own_window_stacking(_ours: &[isize]) -> Vec { use windows_sys::Win32::{ Foundation::{BOOL, HWND, LPARAM, TRUE}, UI::WindowsAndMessaging::{EnumWindows, GetWindowThreadProcessId, IsWindowVisible}, @@ -195,47 +212,55 @@ fn own_window_stacking() -> Vec { TRUE } - let mut windows = Vec::new(); + let mut windows: Vec = Vec::new(); unsafe { EnumWindows(Some(collect), std::ptr::addr_of_mut!(windows) as isize); } windows + .into_iter() + .map(|id| StackEntry { id, level: None }) + .collect() } +/// Override-redirect windows (the camera and the overlays) never appear in +/// the window manager's client list, so this walks the real stacking order: +/// the root's children bottom to top, each one standing for whichever of our +/// windows it is or frames. #[cfg(target_os = "linux")] -fn own_window_stacking() -> Vec { +fn own_window_stacking(ours: &[isize]) -> Vec { use x11rb::connection::Connection as _; - use x11rb::protocol::xproto::{AtomEnum, ConnectionExt as _}; + use x11rb::protocol::xproto::ConnectionExt as _; let Ok((connection, screen)) = x11rb::connect(None) else { return Vec::new(); }; let root = connection.setup().roots[screen].root; - let stacking = connection - .intern_atom(false, b"_NET_CLIENT_LIST_STACKING") - .ok() - .and_then(|cookie| cookie.reply().ok()) - .and_then(|atom| { - connection - .get_property(false, root, atom.atom, AtomEnum::WINDOW, 0, u32::MAX) - .ok()? - .reply() - .ok() - }) - .and_then(|reply| reply.value32().map(|values| values.collect::>())) - .filter(|values| !values.is_empty()); - let bottom_to_top = match stacking { - Some(values) => values, - None => connection - .query_tree(root) + let children = |window: u32| -> Vec { + connection + .query_tree(window) .ok() .and_then(|cookie| cookie.reply().ok()) .map(|tree| tree.children) - .unwrap_or_default(), + .unwrap_or_default() }; - bottom_to_top - .into_iter() - .rev() - .filter_map(|window| isize::try_from(window).ok()) - .collect() + let is_ours = |window: u32| ours.contains(&(window as isize)); + let mut stacking = Vec::new(); + for top_level in children(root).into_iter().rev() { + let mut found = is_ours(top_level).then_some(top_level); + let mut frontier = vec![top_level]; + for _ in 0..3 { + if found.is_some() { + break; + } + frontier = frontier.into_iter().flat_map(&children).collect(); + found = frontier.iter().copied().find(|window| is_ours(*window)); + } + if let Some(window) = found { + stacking.push(StackEntry { + id: window as isize, + level: None, + }); + } + } + stacking } From 80d526615b6768c03e33561184f69c6d8b6ebe4b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:40:41 +0000 Subject: [PATCH 4/9] fix(desktop-gpui): keep the camera bubble above the picker overlays --- apps/desktop-gpui/src/app_windows.rs | 34 +++++++++++++++-------- apps/desktop-gpui/src/platform.rs | 20 ++++++++++--- apps/desktop-gpui/src/platform/windows.rs | 16 +++++++++++ apps/desktop-gpui/src/target_overlay.rs | 8 ++++++ 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 93b540b0bc..e6a00c55aa 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2606,11 +2606,7 @@ fn open_overlay( window.refresh(); }) .ok(); - if let Some(camera) = cx.update(visible_camera_frame) - && let Err(error) = camera.raise() - { - tracing::warn!(%error, "could not raise the camera above the target picker"); - } + cx.update(raise_camera_above_overlays); }) .detach(); } @@ -4335,15 +4331,27 @@ pub(crate) fn studio_camera_snapshot( .ok() } -fn visible_camera_frame(cx: &mut App) -> Option { +/// Keep the camera bubble above the picker overlays. Not filtered on +/// `window_is_visible` off macOS: Windows' camera is shown asynchronously and +/// X11 has no visibility probe, and neither raise path shows a hidden window. +pub(crate) fn raise_camera_above_overlays(cx: &mut App) { if camera_preview_is_inline(cx) { - return None; + return; } #[cfg(target_os = "linux")] if clean_capture_active(cx) { - return None; + return; + } + let Some(camera) = camera_frame(cx) else { + return; + }; + #[cfg(target_os = "macos")] + if !camera.visible { + return; + } + if let Err(error) = camera.raise() { + tracing::warn!(%error, "could not raise the camera above the target picker"); } - camera_frame(cx).filter(|camera| camera.visible) } fn camera_snapshot(cx: &mut App) -> Option { @@ -4363,9 +4371,13 @@ impl CameraFrame { fn raise(&self) -> anyhow::Result<()> { match &self.native { #[cfg(target_os = "macos")] - CameraNativeFrame::Mac { native, .. } => platform::order_front_native(native), + CameraNativeFrame::Mac { native, .. } => { + platform::raise_panel_native(native, platform::recording_controls_level()) + } #[cfg(target_os = "windows")] - CameraNativeFrame::Windows(native) => platform::order_front_native(native), + CameraNativeFrame::Windows(native) => { + platform::raise_panel_native(native, platform::recording_controls_level()) + } #[cfg(target_os = "linux")] CameraNativeFrame::X11 { window_id, .. } => { use x11rb::connection::Connection as _; diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 6549f0b522..e57f143a56 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1586,8 +1586,8 @@ mod mac { NSRect::new(NSPoint::new(x, appkit_y), NSSize::new(width, height)), true, ); - ns_window.setLevel(level); apply_fullscreen_overlay_behavior(ns_window); + ns_window.setLevel(level); // `.shadow(false)` in the Tauri builder. ns_window.setHasShadow(false); ns_window.setAnimationBehavior(objc2_app_kit::NSWindowAnimationBehavior::None); @@ -1730,6 +1730,15 @@ mod mac { rerun_occlusion_handler_after_show(&native.0); } + /// Lift a floating panel to the front of its level, first restoring the + /// level if anything lowered it below `level`. + pub fn raise_panel_native(native: &NativeWindow, level: isize) { + if native.0.level() < level { + native.0.setLevel(level); + } + native.0.orderFrontRegardless(); + } + /// The `NSWindow` behind a gpui window. pub fn ns_window(window: &Window) -> Option> { // Fully qualified: `Window` also has an inherent `window_handle()` @@ -1753,15 +1762,17 @@ mod mac { tracing::error!("no NSWindow behind gpui window; panel behavior not applied"); return; }; + // Before the level: `setFloatingPanel:` resets it to + // `NSFloatingWindowLevel`. + if behavior.join_all_spaces { + apply_fullscreen_overlay_behavior(&ns_window); + } ns_window.setLevel(behavior.level); tracing::debug!( requested = behavior.level, actual = ns_window.level(), "panel level applied" ); - if behavior.join_all_spaces { - apply_fullscreen_overlay_behavior(&ns_window); - } ns_window.setHasShadow(behavior.shadow); } @@ -3175,6 +3186,7 @@ mod stub { } pub fn set_window_frame(_native: &NativeWindow, _x: f64, _y: f64, _width: f64, _height: f64) {} pub fn order_front_native(_native: &NativeWindow) {} + pub fn raise_panel_native(_native: &NativeWindow, _level: isize) {} pub fn frame_is_on_screen(_x: f64, _y: f64, _width: f64, _height: f64) -> bool { true } diff --git a/apps/desktop-gpui/src/platform/windows.rs b/apps/desktop-gpui/src/platform/windows.rs index a0468465b3..5a340c73f8 100644 --- a/apps/desktop-gpui/src/platform/windows.rs +++ b/apps/desktop-gpui/src/platform/windows.rs @@ -464,6 +464,22 @@ pub fn order_front_native(native: &NativeWindow) { } } +/// Synchronous on purpose: `order_front_native`'s posted `HWND_TOP` lands +/// after an overlay's own activation and loses to it. +pub fn raise_panel_native(native: &NativeWindow, _level: isize) { + unsafe { + SetWindowPos( + native.hwnd(), + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE, + ); + } +} + fn overlap_is_visible(first: Rect, second: Rect) -> bool { const MIN_VISIBLE: f64 = 80.; if !first.is_valid() || !second.is_valid() { diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 430a049bf1..4afc0e5ce8 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -899,6 +899,14 @@ impl OverlayWindow { let theme = Theme::for_window(window, cx, false); cx.observe_in(&select, window, |_, _, window, cx| repaint(window, cx)) .detach(); + // Activating an overlay (a click, or the focus hand-off when it opens) + // lifts it to the top of its band on Windows and X11, over the camera. + cx.observe_window_activation(window, |_, window, cx| { + if window.is_window_active() { + cx.defer(app_windows::raise_camera_above_overlays); + } + }) + .detach(); let session = crate::session::RecordingSession::global(cx); let mut drawn_phase = session.read(cx).phase; cx.observe_in(&session, window, move |_, session, window, cx| { From 9914f721a03770efcb58da6beee13b3e2760759f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:50:57 +0000 Subject: [PATCH 5/9] ci(desktop-gpui): run the Windows picker smoke on windows-2022 --- .github/workflows/picker-smoke.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml index 765c5cd5e8..e7263b6ab5 100644 --- a/.github/workflows/picker-smoke.yml +++ b/.github/workflows/picker-smoke.yml @@ -39,7 +39,7 @@ jobs: - name: Windows slug: windows target: x86_64-pc-windows-msvc - runner: windows-2025 + runner: windows-2022 - name: Linux X11 slug: linux-x11 target: x86_64-unknown-linux-gnu From 77a2f41f6268cb32e67edb43fcc843d2e2c57659 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:36:13 +0000 Subject: [PATCH 6/9] fix(desktop-gpui): raise the camera outside the gpui borrow and re-test overlay activation --- apps/desktop-gpui/src/app_windows.rs | 30 ++++++++++++++++--------- apps/desktop-gpui/src/picker_probe.rs | 20 +++++++++++++++-- apps/desktop-gpui/src/target_overlay.rs | 3 ++- scripts/picker-smoke/run.sh | 4 ---- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index e6a00c55aa..db702d9d9e 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2606,7 +2606,7 @@ fn open_overlay( window.refresh(); }) .ok(); - cx.update(raise_camera_above_overlays); + raise_camera_above_overlays(cx); }) .detach(); } @@ -4334,24 +4334,32 @@ pub(crate) fn studio_camera_snapshot( /// Keep the camera bubble above the picker overlays. Not filtered on /// `window_is_visible` off macOS: Windows' camera is shown asynchronously and /// X11 has no visibility probe, and neither raise path shows a hidden window. -pub(crate) fn raise_camera_above_overlays(cx: &mut App) { - if camera_preview_is_inline(cx) { +/// Lift the camera preview back over the picker overlays. The raise runs +/// outside any gpui update: ordering a window front calls straight back into +/// gpui, which drops those callbacks while the app is borrowed. +pub(crate) fn raise_camera_above_overlays(cx: &mut gpui::AsyncApp) { + let Some(camera) = cx.update(camera_to_raise) else { return; + }; + if let Err(error) = camera.raise() { + tracing::warn!(%error, "could not raise the camera above the target picker"); + } +} + +fn camera_to_raise(cx: &mut App) -> Option { + if camera_preview_is_inline(cx) { + return None; } #[cfg(target_os = "linux")] if clean_capture_active(cx) { - return; + return None; } - let Some(camera) = camera_frame(cx) else { - return; - }; + let camera = camera_frame(cx)?; #[cfg(target_os = "macos")] if !camera.visible { - return; - } - if let Err(error) = camera.raise() { - tracing::warn!(%error, "could not raise the camera above the target picker"); + return None; } + Some(camera) } fn camera_snapshot(cx: &mut App) -> Option { diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index ab2f848193..ce902c2bd1 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -48,15 +48,31 @@ pub fn run(main: WindowHandle, cx: &mut App) { ) }); } + // Opening an overlay already raises the camera, so hand focus to + // the main window first: the activation below then has to raise + // it again by itself. + main.update(cx, |_, window, _| window.activate_window()) + .ok(); + sleep(cx, 600).await; + let overlays = cx.update(|cx| cx.global::().overlays.clone()); + let inactive_before = cx.update(|cx| { + overlays.iter().all(|(_, overlay)| { + overlay + .update(cx, |_, window, _| !window.is_window_active()) + .unwrap_or(true) + }) + }); cx.update(|cx| { - for (_, overlay) in cx.global::().overlays.clone() { + for (_, overlay) in &overlays { overlay .update(cx, |_, window, _| window.activate_window()) .ok(); } }); sleep(cx, 800).await; - modes.push(cx.update(|cx| probe_mode(label, cx))); + let mut mode = cx.update(|cx| probe_mode(label, cx)); + mode["overlayInactiveBeforeActivation"] = inactive_before.into(); + modes.push(mode); cx.update(app_windows::dismiss_target_overlays); sleep(cx, 400).await; } diff --git a/apps/desktop-gpui/src/target_overlay.rs b/apps/desktop-gpui/src/target_overlay.rs index 4afc0e5ce8..12a15e054c 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -903,7 +903,8 @@ impl OverlayWindow { // lifts it to the top of its band on Windows and X11, over the camera. cx.observe_window_activation(window, |_, window, cx| { if window.is_window_active() { - cx.defer(app_windows::raise_camera_above_overlays); + cx.spawn(async move |_, cx| app_windows::raise_camera_above_overlays(cx)) + .detach(); } }) .detach(); diff --git a/scripts/picker-smoke/run.sh b/scripts/picker-smoke/run.sh index 7241a4981f..5b5d13fd27 100755 --- a/scripts/picker-smoke/run.sh +++ b/scripts/picker-smoke/run.sh @@ -1,8 +1,4 @@ #!/usr/bin/env bash -# Runs cap-gpui's picker probe (src/picker_probe.rs) in a throwaway profile and -# fails unless every check in its report passed. -# -# scripts/picker-smoke/run.sh set -euo pipefail APP=$1 From 986e88247806b22390f3c9a747db7bbc139ef38d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:01:05 +0000 Subject: [PATCH 7/9] chore(desktop-gpui): drop comments that restate the picker smoke --- .github/workflows/picker-smoke.yml | 3 --- apps/desktop-gpui/src/picker_probe.rs | 4 ---- 2 files changed, 7 deletions(-) diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml index e7263b6ab5..571d2c3437 100644 --- a/.github/workflows/picker-smoke.yml +++ b/.github/workflows/picker-smoke.yml @@ -1,7 +1,4 @@ name: Picker smoke (real app) -# Opens the real GPUI app's target picker on macOS, Windows and Linux (X11 -# with a window manager) through `CAP_PICKER_PROBE_OUTPUT` and checks how the -# picker's windows stack against the camera bubble. on: pull_request: diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index ce902c2bd1..077b74c695 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -8,10 +8,6 @@ use crate::{ target_overlay::AreaRect, }; -/// `CAP_PICKER_PROBE_OUTPUT=`: open the camera bubble, arm each picker -/// mode, activate an overlay the way a click does, and write the native -/// front-to-back order of Cap's windows so CI can check the camera stays on -/// top. pub fn run(main: WindowHandle, cx: &mut App) { let Some(output) = std::env::var_os("CAP_PICKER_PROBE_OUTPUT") else { return; From c5754539ef587b71d426c3adee89625c52a01c38 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:10:00 +0000 Subject: [PATCH 8/9] fix(desktop-gpui): the picker smoke reads the probe's own verdict, not any nested pass --- apps/desktop-gpui/src/picker_probe.rs | 9 +++++++-- scripts/picker-smoke/run.sh | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/desktop-gpui/src/picker_probe.rs b/apps/desktop-gpui/src/picker_probe.rs index 077b74c695..64dd859068 100644 --- a/apps/desktop-gpui/src/picker_probe.rs +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -76,8 +76,13 @@ pub fn run(main: WindowHandle, cx: &mut App) { let passed = modes .iter() .all(|mode| mode["cameraAboveOverlays"].as_bool() == Some(true)); - let report = serde_json::json!({ "passed": passed, "modes": modes }); - if let Err(error) = std::fs::write(output, report.to_string()) { + let report = serde_json::json!({ "modes": modes }); + // The verdict leads so `run.sh` can read it without parsing JSON: + // nested checks carry `passed` keys of their own. + if let Err(error) = std::fs::write( + output, + format!("{{\"passed\":{passed},{}", &report.to_string()[1..]), + ) { tracing::error!(%error, "Could not write picker probe results"); } cx.update(|cx| cx.quit()); diff --git a/scripts/picker-smoke/run.sh b/scripts/picker-smoke/run.sh index 5b5d13fd27..0a2552c5e7 100755 --- a/scripts/picker-smoke/run.sh +++ b/scripts/picker-smoke/run.sh @@ -33,4 +33,4 @@ if [ ! -s "$report" ]; then fi cat "$report" echo -grep -q '"passed":true' "$report" +grep -q '^{"passed":true,' "$report" From b55f926d366b7c05ec69c08ac733a1e0558283b2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:45:32 +0000 Subject: [PATCH 9/9] ci(picker-smoke): skip the matrix for fork pull requests that change no app code --- .github/workflows/picker-smoke.yml | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml index 571d2c3437..c06b845027 100644 --- a/.github/workflows/picker-smoke.yml +++ b/.github/workflows/picker-smoke.yml @@ -16,14 +16,48 @@ on: permissions: contents: read + pull-requests: read concurrency: group: picker-smoke-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + # A pull request from a fork that changes only this workflow or the smoke's + # scripts does not queue the three-runner matrix; one that also changes the + # app does, and so does any change from this repository. + scope: + name: Scope + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + outputs: + run: ${{ steps.scope.outputs.run }} + steps: + - name: Scope + id: scope + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + PR: ${{ github.event.pull_request.number }} + SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + run: | + set -euo pipefail + if [[ "$EVENT" != pull_request || "$SAME_REPO" == true ]]; then + echo "run=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename' > "$RUNNER_TEMP/changed.txt" + app='^apps/desktop-gpui/src/' + if grep -qE "$app" "$RUNNER_TEMP/changed.txt"; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "A fork pull request that changes no app code does not run the picker smoke." | tee -a "$GITHUB_STEP_SUMMARY" + fi + smoke: name: Picker smoke (${{ matrix.settings.name }}) + needs: scope + if: needs.scope.outputs.run == 'true' timeout-minutes: 60 strategy: fail-fast: false