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..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), } @@ -118,6 +122,78 @@ 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; + + // `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(), + 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(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( + "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);