diff --git a/.github/workflows/picker-smoke.yml b/.github/workflows/picker-smoke.yml new file mode 100644 index 0000000000..c06b845027 --- /dev/null +++ b/.github/workflows/picker-smoke.yml @@ -0,0 +1,167 @@ +name: Picker smoke (real app) + +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 + 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 + 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: windows-2022 + - 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 + 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 + 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/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index ec7ee97363..593798e39c 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -2638,11 +2638,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"); - } + raise_camera_above_overlays(cx); }) .detach(); } @@ -4368,7 +4364,22 @@ 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. +/// 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; } @@ -4376,7 +4387,12 @@ fn visible_camera_frame(cx: &mut App) -> Option { if clean_capture_active(cx) { return None; } - camera_frame(cx).filter(|camera| camera.visible) + let camera = camera_frame(cx)?; + #[cfg(target_os = "macos")] + if !camera.visible { + return None; + } + Some(camera) } fn camera_snapshot(cx: &mut App) -> Option { @@ -4396,9 +4412,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/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..64dd859068 --- /dev/null +++ b/apps/desktop-gpui/src/picker_probe.rs @@ -0,0 +1,283 @@ +use std::time::Duration; + +use gpui::{App, AsyncApp, WindowHandle}; + +use crate::{ + app_windows, + main_window::{MainWindow, TargetType}, + target_overlay::AreaRect, +}; + +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, + ) + }); + } + // 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 &overlays { + overlay + .update(cx, |_, window, _| window.activate_window()) + .ok(); + } + }); + sleep(cx, 800).await; + 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; + } + + let passed = modes + .iter() + .all(|mode| mode["cameraAboveOverlays"].as_bool() == Some(true)); + 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()); + }) + .detach(); +} + +struct StackEntry { + id: isize, + level: Option, +} + +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 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(); + 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 + .iter() + .map(|entry| serde_json::json!({ "id": entry.id, "level": entry.level })) + .collect::>(), + "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(_ours: &[isize]) -> 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; + static kCGWindowLayer: 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, 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()); + list.iter() + .filter_map(|window| { + let field = |key: &CFString| window.find(key)?.downcast::()?.to_i64(); + (field(&owner_key)? == pid).then_some(())?; + Some(StackEntry { + id: isize::try_from(field(&number_key)?).ok()?, + level: field(&layer_key), + }) + }) + .collect() +} + +#[cfg(target_os = "windows")] +fn own_window_stacking(_ours: &[isize]) -> 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 = 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(ours: &[isize]) -> Vec { + use x11rb::connection::Connection 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 children = |window: u32| -> Vec { + connection + .query_tree(window) + .ok() + .and_then(|cookie| cookie.reply().ok()) + .map(|tree| tree.children) + .unwrap_or_default() + }; + 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 +} 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..12a15e054c 100644 --- a/apps/desktop-gpui/src/target_overlay.rs +++ b/apps/desktop-gpui/src/target_overlay.rs @@ -899,6 +899,15 @@ 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.spawn(async move |_, cx| app_windows::raise_camera_above_overlays(cx)) + .detach(); + } + }) + .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| { diff --git a/scripts/picker-smoke/run.sh b/scripts/picker-smoke/run.sh new file mode 100755 index 0000000000..0a2552c5e7 --- /dev/null +++ b/scripts/picker-smoke/run.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +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"