Files
SnapGo/internal/infrastructure/screencapture/capturer_darwin.go
T
mamamiyear ceecbb6af4 feat(app): implement basic function
General
- 配置界面支持配置对象存储桶
- 快捷键进入截图
- 一键上传截图 & 复制截图链接

MacOS
- 状态栏指示器和应用图标
2026-06-01 21:17:29 +08:00

146 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build darwin
package screencapture
import (
"fmt"
"image"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// macCapturer shells out to /usr/sbin/screencapture, which is bundled with
// macOS, requires no cgo, and is fully compatible with macOS 15/26 SDKs
// where CoreGraphics's display-capture API has been removed.
//
// Trade-off: a tiny process-spawn cost per capture (~30 ms). Acceptable for
// an interactive screenshot tool — users cannot perceive it.
type macCapturer struct{}
// New returns a macOS-native Capturer.
func New() Capturer { return &macCapturer{} }
// VirtualBounds returns the union of all displays measured in CSS pixels
// (i.e. logical, not Retina physical pixels). We query system_profiler via
// AppleScript-free shell utilities to avoid extra cgo dependencies.
//
// For simplicity we currently return the main display only; this matches
// what the WebView/window can actually paint without a separate transparent
// window per display. Multi-monitor support is a follow-up spec.
func (c *macCapturer) VirtualBounds() image.Rectangle {
out, err := exec.Command(
"system_profiler", "SPDisplaysDataType",
).Output()
if err == nil {
// Look for "Resolution: 3024 x 1964" lines and take the first one.
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Resolution:") {
continue
}
parts := strings.Fields(line)
// Expected pattern: ["Resolution:", "3024", "x", "1964", ...]
if len(parts) >= 4 {
w, errW := strconv.Atoi(parts[1])
h, errH := strconv.Atoi(parts[3])
if errW == nil && errH == nil {
// Resolution reported is physical; convert to CSS px by
// halving for Retina (assume 2× when w >= 2560).
if w >= 2560 {
w /= 2
h /= 2
}
return image.Rect(0, 0, w, h)
}
}
}
}
// Conservative fallback used by ~all modern Macs.
return image.Rect(0, 0, 1440, 900)
}
// CaptureRegion grabs the requested rectangle via the OS tool and returns
// the resulting PNG bytes.
//
// Rect coords here are logical screen coordinates, matching macOS
// screencapture's -R contract and the CSS-point coordinates reported by the
// transparent overlay.
func (c *macCapturer) CaptureRegion(rect image.Rectangle) ([]byte, error) {
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return nil, fmt.Errorf("invalid capture rectangle: %v", rect)
}
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
region := fmt.Sprintf("%d,%d,%d,%d",
rect.Min.X, rect.Min.Y, rect.Dx(), rect.Dy(),
)
// -x: silent (no shutter sound)
// -R: rectangle
// -t png
cmd := exec.Command("/usr/sbin/screencapture", "-x", "-R", region, "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture: %w (%s)", err, strings.TrimSpace(string(out)))
}
data, err := os.ReadFile(tmp)
if err != nil {
return nil, fmt.Errorf("read screencapture output: %w", err)
}
return data, nil
}
// CaptureInteractive launches `screencapture -i` which presents the macOS
// native region selector (the same crosshair that Cmd+Shift+4 produces).
//
// Design rationale:
// - The native picker is pixel-perfect across Retina / external displays
// and supports multi-monitor out of the box.
// - It also handles Esc / right-click to cancel without requiring us to
// run a transparent overlay window — which previously caused the main
// window to flash a grey full-screen and trap the user.
// - When the user cancels, screencapture exits 0 but writes no file; we
// detect that case via os.Stat and return ErrCancelled.
func (c *macCapturer) CaptureInteractive() ([]byte, error) {
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-i-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
// -i: interactive region selection
// -x: silent (suppress the camera shutter sound)
// -t png: png output
cmd := exec.Command("/usr/sbin/screencapture", "-i", "-x", "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture -i: %w (%s)", err, strings.TrimSpace(string(out)))
}
// User cancelled (Esc / right-click): tool exits 0 with no file written.
info, err := os.Stat(tmp)
if err != nil || info.Size() == 0 {
return nil, ErrCancelled
}
return os.ReadFile(tmp)
}
// CaptureFullScreen runs `screencapture -x` to grab the primary display in
// a single shot. We use -m so only the main display is captured even if
// external monitors are attached — matches the "primary screen only" UX
// chosen for the in-app overlay.
func (c *macCapturer) CaptureFullScreen() ([]byte, error) {
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-full-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
// -x: silent
// -m: main display only
// -t png: png output
cmd := exec.Command("/usr/sbin/screencapture", "-x", "-m", "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture -x -m: %w (%s)", err, strings.TrimSpace(string(out)))
}
return os.ReadFile(tmp)
}