feat(app): implement basic function
General - 配置界面支持配置对象存储桶 - 快捷键进入截图 - 一键上传截图 & 复制截图链接 MacOS - 状态栏指示器和应用图标
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
// Package screencapture defines the cross-platform Capturer abstraction.
|
||||
//
|
||||
// Per-platform implementations live in sibling files:
|
||||
// - capturer_darwin.go macOS, uses /usr/sbin/screencapture
|
||||
// - capturer_other.go non-darwin, uses kbinani/screenshot
|
||||
//
|
||||
// The interface is kept here so callers do not need build tags.
|
||||
package screencapture
|
||||
|
||||
import "image"
|
||||
|
||||
// Capturer is the abstraction that the application service depends on.
|
||||
type Capturer interface {
|
||||
// CaptureRegion grabs the pixels within the supplied virtual-screen
|
||||
// rectangle and returns PNG-encoded bytes.
|
||||
CaptureRegion(rect image.Rectangle) ([]byte, error)
|
||||
|
||||
// VirtualBounds returns the union rectangle of every connected display.
|
||||
VirtualBounds() image.Rectangle
|
||||
|
||||
// CaptureInteractive opens a system-provided region picker (e.g. macOS's
|
||||
// `screencapture -i`) and returns the resulting PNG bytes once the user
|
||||
// confirms a selection. If the user cancels (Esc / right-click), it
|
||||
// returns (nil, ErrCancelled) so callers can short-circuit cleanly.
|
||||
CaptureInteractive() ([]byte, error)
|
||||
|
||||
// CaptureFullScreen grabs the entire primary display silently and
|
||||
// returns the PNG bytes. Used as the backdrop for the in-app Snipaste-
|
||||
// style overlay; the per-region crop happens later in CropPNG once the
|
||||
// user finishes dragging.
|
||||
CaptureFullScreen() ([]byte, error)
|
||||
}
|
||||
|
||||
// ErrCancelled signals that the user aborted an interactive capture.
|
||||
// It is a sentinel value; callers should compare via errors.Is.
|
||||
type cancelled struct{}
|
||||
|
||||
func (cancelled) Error() string { return "capture cancelled by user" }
|
||||
|
||||
// ErrCancelled is returned from CaptureInteractive when the user dismisses
|
||||
// the system selection UI without confirming a region.
|
||||
var ErrCancelled = cancelled{}
|
||||
@@ -0,0 +1,145 @@
|
||||
//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)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//go:build !darwin
|
||||
|
||||
package screencapture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
|
||||
"github.com/kbinani/screenshot"
|
||||
)
|
||||
|
||||
// kbinaniCapturer is the default non-darwin Capturer.
|
||||
type kbinaniCapturer struct{}
|
||||
|
||||
// New returns the default cross-platform Capturer for non-macOS systems.
|
||||
func New() Capturer { return &kbinaniCapturer{} }
|
||||
|
||||
func (c *kbinaniCapturer) VirtualBounds() image.Rectangle {
|
||||
n := screenshot.NumActiveDisplays()
|
||||
if n == 0 {
|
||||
return image.Rect(0, 0, 0, 0)
|
||||
}
|
||||
bounds := screenshot.GetDisplayBounds(0)
|
||||
for i := 1; i < n; i++ {
|
||||
bounds = bounds.Union(screenshot.GetDisplayBounds(i))
|
||||
}
|
||||
return bounds
|
||||
}
|
||||
|
||||
func (c *kbinaniCapturer) CaptureRegion(rect image.Rectangle) ([]byte, error) {
|
||||
if rect.Dx() <= 0 || rect.Dy() <= 0 {
|
||||
return nil, fmt.Errorf("invalid capture rectangle: %v", rect)
|
||||
}
|
||||
img, err := screenshot.CaptureRect(rect)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capture: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, fmt.Errorf("encode png: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// CaptureInteractive on non-darwin platforms is not yet implemented. The
|
||||
// frontend overlay still works there. We surface a clear error so callers
|
||||
// can decide whether to fall back to the WebView overlay.
|
||||
func (c *kbinaniCapturer) CaptureInteractive() ([]byte, error) {
|
||||
return nil, fmt.Errorf("interactive capture is not implemented on this platform yet")
|
||||
}
|
||||
|
||||
// CaptureFullScreen grabs the primary display via kbinani/screenshot and
|
||||
// PNG-encodes the result. Multi-display extension is deliberately deferred
|
||||
// to keep parity with the macOS "primary only" UX.
|
||||
func (c *kbinaniCapturer) CaptureFullScreen() ([]byte, error) {
|
||||
if screenshot.NumActiveDisplays() == 0 {
|
||||
return nil, fmt.Errorf("no active display detected")
|
||||
}
|
||||
bounds := screenshot.GetDisplayBounds(0)
|
||||
img, err := screenshot.CaptureRect(bounds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("capture full screen: %w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, fmt.Errorf("encode png: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package screencapture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
// CropPNG decodes the supplied PNG, crops it to rect (in PHYSICAL pixels of
|
||||
// the source image — i.e. the same coordinate system the PNG itself uses),
|
||||
// and re-encodes the result.
|
||||
//
|
||||
// Why an extra abstraction: the overlay flow captures the entire screen as
|
||||
// one big PNG and lets the user pick a region in the WebView. The selection
|
||||
// rectangle the WebView reports is in CSS pixels, so callers must scale by
|
||||
// the display's backing factor before invoking CropPNG. Splitting "scale"
|
||||
// from "crop" keeps this helper focused and unit-testable.
|
||||
func CropPNG(pngBytes []byte, rect image.Rectangle) ([]byte, error) {
|
||||
if rect.Dx() <= 0 || rect.Dy() <= 0 {
|
||||
return nil, fmt.Errorf("invalid crop rectangle: %v", rect)
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode source png: %w", err)
|
||||
}
|
||||
// Clip the requested rectangle to the source bounds — defensive guard
|
||||
// against off-by-one errors from CSS-px → physical-px scaling.
|
||||
src := img.Bounds()
|
||||
clipped := rect.Intersect(src)
|
||||
if clipped.Empty() {
|
||||
return nil, fmt.Errorf("crop rectangle %v has no overlap with source %v", rect, src)
|
||||
}
|
||||
|
||||
// SubImage is documented on most concrete image types; the standard
|
||||
// library decodes PNGs into one of those, so this assertion is safe in
|
||||
// practice. Fall back to a manual copy otherwise.
|
||||
type subImager interface {
|
||||
SubImage(r image.Rectangle) image.Image
|
||||
}
|
||||
var cropped image.Image
|
||||
if si, ok := img.(subImager); ok {
|
||||
cropped = si.SubImage(clipped)
|
||||
} else {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, clipped.Dx(), clipped.Dy()))
|
||||
for y := 0; y < clipped.Dy(); y++ {
|
||||
for x := 0; x < clipped.Dx(); x++ {
|
||||
dst.Set(x, y, img.At(clipped.Min.X+x, clipped.Min.Y+y))
|
||||
}
|
||||
}
|
||||
cropped = dst
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, cropped); err != nil {
|
||||
return nil, fmt.Errorf("encode cropped png: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user