From 41724caddd2156477eb671e36802658ba1da911f Mon Sep 17 00:00:00 2001 From: KuaaMU Date: Thu, 22 Oct 2026 14:10:09 +0800 Subject: [PATCH 1/2] feat(macos): real screen capture via screencapture macOS returned `generate_fallback_capture`, a generated grid, so the snip flow captured nothing on that platform. - `screen::capture_via_screencapture` runs `/usr/sbin/screencapture -x -t png` and reads the result back as a data URL. It needs the Screen Recording permission; a denied permission surfaces as an error, not a silent empty image. - `capture_screen` on macOS reports the image as the main display at (0,0). Multi-display Macs capture the main display only, and the doc comment says so. - The non-Linux snip branch now emits the same `capture`/`monitor` payload as Linux and reports the mapped window rect after `show`, so the frontend's 1:1 crop works when the compositor shrinks the overlay (macOS keeps the menu bar strip free). Also record the executable bit on `scripts/*.sh`. `core.fileMode` is false in this checkout, so the bits were never committed and a fresh clone could not run `./scripts/check.sh`. Co-Authored-By: Claude Code --- crates/glint-core/src/capture.rs | 20 +++++++++++- crates/glint-core/src/lib.rs | 4 ++- crates/glint-core/src/screen.rs | 52 ++++++++++++++++++++++++++++++++ scripts/check.sh | 0 scripts/dev-run.sh | 0 scripts/glint-x11.sh | 0 scripts/install-hooks.sh | 0 scripts/shot.sh | 0 src-tauri/src/capture.rs | 14 ++++++--- src-tauri/src/lib.rs | 44 +++++++++++++++++++++++++-- 10 files changed, 124 insertions(+), 10 deletions(-) mode change 100644 => 100755 scripts/check.sh mode change 100644 => 100755 scripts/dev-run.sh mode change 100644 => 100755 scripts/glint-x11.sh mode change 100644 => 100755 scripts/install-hooks.sh mode change 100644 => 100755 scripts/shot.sh diff --git a/crates/glint-core/src/capture.rs b/crates/glint-core/src/capture.rs index 43139c7..1bcc067 100644 --- a/crates/glint-core/src/capture.rs +++ b/crates/glint-core/src/capture.rs @@ -190,7 +190,25 @@ pub fn generate_fallback_capture(width: u32, height: u32, x: i32, y: i32) -> Scr } } -#[cfg(not(target_os = "windows"))] +/// macOS: one image of the main display, positioned at the global origin. +#[cfg(target_os = "macos")] +pub fn capture_screen() -> Result { + let frame = crate::screen::capture_via_screencapture().map_err(|err| err.to_string())?; + Ok(ScreenCaptureResult { + image_data_url: frame.image_data_url, + geometry: ScreenGeometry { + x: 0, + y: 0, + width: frame.width, + height: frame.height, + }, + window_title: crate::platform::get_active_window_title_opt(), + }) +} + +/// Linux uses the XDG screenshot portal from the shell, so this placeholder is +/// only reachable through `glint-cli screen`, which calls the portal directly. +#[cfg(not(any(target_os = "windows", target_os = "macos")))] pub fn capture_screen() -> Result { Ok(generate_fallback_capture(800, 600, 0, 0)) } diff --git a/crates/glint-core/src/lib.rs b/crates/glint-core/src/lib.rs index 909532b..464ddac 100644 --- a/crates/glint-core/src/lib.rs +++ b/crates/glint-core/src/lib.rs @@ -29,7 +29,9 @@ pub use hotkey::{ pub use llm::{ ChatMessage, QueryRequest, StreamChunk, StreamSink, build_api_payload, stream_explanation, }; -pub use screen::{CapturedFrame, ScreenError, capture_via_portal, png_file_to_data_url}; +pub use screen::{ + CapturedFrame, ScreenError, capture_via_portal, capture_via_screencapture, png_file_to_data_url, +}; pub use selection::{ CaptureError, MAX_CHARS, MAX_RAW_BYTES, READ_TIMEOUT_MS, SelectionCandidate, SelectionSensor, SelectionSource, normalize_selection, platform_sensor, diff --git a/crates/glint-core/src/screen.rs b/crates/glint-core/src/screen.rs index fb1e772..828a656 100644 --- a/crates/glint-core/src/screen.rs +++ b/crates/glint-core/src/screen.rs @@ -118,6 +118,58 @@ pub async fn capture_via_portal() -> Result { )) } +/// Capture the main display with Apple's `screencapture`. +/// +/// `screencapture -x` is the only path that works without linking +/// ScreenCaptureKit or asking for a custom permission prompt. It needs the +/// Screen Recording permission; without it macOS either fails or returns a +/// desktop-only image, and we cannot tell those apart from the exit code. +/// +/// Multi-display Macs capture the main display only. The overlay and the crop +/// use the image's own size, so the result is correct, just limited to one +/// screen. +#[cfg(target_os = "macos")] +pub fn capture_via_screencapture() -> Result { + use std::process::Command; + + let path = std::env::temp_dir().join(format!( + "glint-screen-{}-{}.png", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + )); + + let output = Command::new("/usr/sbin/screencapture") + .args(["-x", "-t", "png"]) + .arg(&path) + .output() + .map_err(|err| ScreenError::Unavailable(err.to_string()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let _ = std::fs::remove_file(&path); + return Err(ScreenError::Failed(if stderr.is_empty() { + "screencapture exited with an error; check the Screen Recording permission".to_string() + } else { + stderr + })); + } + + let frame = png_file_to_data_url(&path); + // The temp file is ours; never leave it behind. + let _ = std::fs::remove_file(&path); + frame +} + +#[cfg(not(target_os = "macos"))] +pub fn capture_via_screencapture() -> Result { + Err(ScreenError::Unavailable( + "screencapture is macOS-only".to_string(), + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/scripts/check.sh b/scripts/check.sh old mode 100644 new mode 100755 diff --git a/scripts/dev-run.sh b/scripts/dev-run.sh old mode 100644 new mode 100755 diff --git a/scripts/glint-x11.sh b/scripts/glint-x11.sh old mode 100644 new mode 100755 diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh old mode 100644 new mode 100755 diff --git a/scripts/shot.sh b/scripts/shot.sh old mode 100644 new mode 100755 diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs index 5c12d84..7a63321 100644 --- a/src-tauri/src/capture.rs +++ b/src-tauri/src/capture.rs @@ -16,8 +16,8 @@ use std::time::Duration; #[cfg(test)] pub use glint_core::encode_rgba_to_png_data_url; pub use glint_core::{ScreenCaptureResult, SelectionResult}; -// Only the non-Windows placeholder screenshot uses this. -#[cfg(not(target_os = "windows"))] +// Only the placeholder screenshot above uses this. +#[cfg(not(any(target_os = "windows", target_os = "macos")))] pub use glint_core::generate_fallback_capture; #[cfg(not(target_os = "linux"))] @@ -28,10 +28,14 @@ pub fn capture_screen() -> Result { glint_core::capture::capture_screen() } -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "macos")] +pub fn capture_screen() -> Result { + glint_core::capture::capture_screen() +} + +// Linux never reaches this: `trigger_snipping_and_show` uses the XDG portal. +#[cfg(not(any(target_os = "windows", target_os = "macos")))] pub fn capture_screen() -> Result { - // TODO(P2): replace with xdg-desktop-portal Screenshot on Linux. Until then - // this is an explicit placeholder, not a screenshot. Ok(generate_fallback_capture(800, 600, 0, 0)) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index feda5f3..016d560 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -512,11 +512,49 @@ pub fn trigger_snipping_and_show(app: &AppHandle) { }; match capture::capture_screen() { Ok(cap) => { - let _ = window.set_position(PhysicalPosition::new(cap.geometry.x, cap.geometry.y)); - let _ = window.set_size(PhysicalSize::new(cap.geometry.width, cap.geometry.height)); - let _ = window.emit("glint-snipping-capture", &cap); + // Same payload shape as the Linux branch: the frontend crops + // 1:1 from the capture using the overlay's real geometry, + // which the compositor may shrink (menu bar on macOS). + let (gx, gy) = (cap.geometry.x, cap.geometry.y); + let (gw, gh) = (cap.geometry.width, cap.geometry.height); + let _ = window.set_position(PhysicalPosition::new(gx, gy)); + let _ = window.set_size(PhysicalSize::new(gw, gh)); + + let payload = serde_json::json!({ + "image_data_url": cap.image_data_url, + "capture": { + "width": gw, + "height": gh, + "origin_x": gx, + "origin_y": gy + }, + "monitor": { + "x": gx, + "y": gy, + "width": gw, + "height": gh + }, + "window_title": cap.window_title + }); + let _ = window.emit("glint-snipping-capture", &payload); let _ = window.show(); let _ = window.set_focus(); + + let verify = window.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + if let (Ok(pos), Ok(size)) = (verify.outer_position(), verify.outer_size()) { + let _ = verify.emit( + "glint-snipping-geometry", + serde_json::json!({ + "x": pos.x, + "y": pos.y, + "width": size.width, + "height": size.height + }), + ); + } + }); } Err(e) => { eprintln!("[Glint] 截屏错误: {}", e); From f67fcdf9b12f9176bdd468cad1c040ee1ac61194 Mon Sep 17 00:00:00 2001 From: KuaaMU Date: Thu, 22 Oct 2026 14:19:51 +0800 Subject: [PATCH 2/2] fix(macos): preflight Screen Recording before trusting a capture `screencapture` exits 0 without Screen Recording permission and returns an image of the desktop wallpaper. The PNG header check cannot tell that apart from a real capture, so the snip UI could submit the wallpaper with no error. Call `CGPreflightScreenCaptureAccess` first and return a distinct `PermissionDenied` error naming the System Settings pane. The framework call is the only FFI here: one function, no pointers. Co-Authored-By: Claude Code --- crates/glint-core/src/screen.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/glint-core/src/screen.rs b/crates/glint-core/src/screen.rs index 828a656..ec15f23 100644 --- a/crates/glint-core/src/screen.rs +++ b/crates/glint-core/src/screen.rs @@ -22,6 +22,10 @@ pub enum ScreenError { Unavailable(String), #[error("screen capture was cancelled or denied")] Denied, + #[error( + "screen recording permission is not granted; enable it in System Settings > Privacy & Security > Screen Recording" + )] + PermissionDenied, #[error("screen capture failed: {0}")] Failed(String), } @@ -132,6 +136,13 @@ pub async fn capture_via_portal() -> Result { pub fn capture_via_screencapture() -> Result { use std::process::Command; + // `screencapture` exits 0 even when the process lacks Screen Recording + // access; it just returns an image of the desktop wallpaper. Preflight the + // permission so the user gets an error instead of a useless capture. + if !macos_screen_capture_access() { + return Err(ScreenError::PermissionDenied); + } + let path = std::env::temp_dir().join(format!( "glint-screen-{}-{}.png", std::process::id(), @@ -163,6 +174,19 @@ pub fn capture_via_screencapture() -> Result { frame } +#[cfg(target_os = "macos")] +fn macos_screen_capture_access() -> bool { + #[link(name = "CoreGraphics", kind = "framework")] + unsafe extern "C" { + /// Available since macOS 10.15. Returns whether this process may read + /// screen contents; it does not prompt. + fn CGPreflightScreenCaptureAccess() -> bool; + } + + // SAFETY: no arguments, no pointers, no lifetime concerns. + unsafe { CGPreflightScreenCaptureAccess() } +} + #[cfg(not(target_os = "macos"))] pub fn capture_via_screencapture() -> Result { Err(ScreenError::Unavailable(