feat(app): implement basic function

General
- 配置界面支持配置对象存储桶
- 快捷键进入截图
- 一键上传截图 & 复制截图链接

MacOS
- 状态栏指示器和应用图标
This commit is contained in:
2026-05-17 15:51:02 +08:00
parent 6c00bf5a6f
commit ceecbb6af4
84 changed files with 7160 additions and 4 deletions
@@ -0,0 +1,46 @@
// Package clipboard wraps golang.design/x/clipboard with lazy initialization.
//
// Why a wrapper:
// - The upstream library requires a one-time clipboard.Init() call. Hiding it
// here keeps the application service free of init concerns.
// - Allows future swap to a different backend (e.g. atotto/clipboard) without
// changing callers.
package clipboard
import (
"fmt"
"sync"
"golang.design/x/clipboard"
)
// Writer abstracts clipboard writes for testability.
type Writer interface {
WriteText(s string) error
}
type clipWriter struct {
once sync.Once
initErr error
}
// New returns a process-wide clipboard writer. Initialization is lazy.
func New() Writer {
return &clipWriter{}
}
func (w *clipWriter) ensureInit() error {
w.once.Do(func() {
w.initErr = clipboard.Init()
})
return w.initErr
}
// WriteText replaces the clipboard contents with the supplied UTF-8 string.
func (w *clipWriter) WriteText(s string) error {
if err := w.ensureInit(); err != nil {
return fmt.Errorf("clipboard init: %w", err)
}
clipboard.Write(clipboard.FmtText, []byte(s))
return nil
}
@@ -0,0 +1,89 @@
// Package config provides JSON-file backed persistence for AppConfig.
//
// Design rationale:
// - JSON keeps the file human-readable, which is useful while the MVP has
// no settings UI for every option.
// - Storage location follows os.UserConfigDir() so each platform places it
// in the canonical spot (macOS: ~/Library/Application Support/SnapGo).
// - File mode 0600 / dir mode 0700 mitigate accidental leakage of access
// keys before we wire up the OS keychain (deferred to a later spec).
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/mmmy/snapgo/internal/domain"
)
// FileStore implements load/save of AppConfig on the local filesystem.
//
// All methods are safe for concurrent use.
type FileStore struct {
mu sync.RWMutex
path string
}
// NewFileStore resolves the on-disk path and creates the parent directory
// with permissions 0700 if missing.
func NewFileStore() (*FileStore, error) {
dir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("resolve user config dir: %w", err)
}
appDir := filepath.Join(dir, "SnapGo")
if err := os.MkdirAll(appDir, 0o700); err != nil {
return nil, fmt.Errorf("ensure config dir: %w", err)
}
return &FileStore{path: filepath.Join(appDir, "config.json")}, nil
}
// Path exposes the absolute config file path (mainly for diagnostics / UI).
func (s *FileStore) Path() string {
return s.path
}
// Load reads the config file. If the file does not exist OR cannot be parsed,
// the default config is returned with a non-nil error so the caller can decide
// whether to surface the parse failure to the user.
func (s *FileStore) Load() (domain.AppConfig, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cfg := domain.DefaultAppConfig()
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
return cfg, fmt.Errorf("read config: %w", err)
}
if err := json.Unmarshal(data, &cfg); err != nil {
return domain.DefaultAppConfig(), fmt.Errorf("parse config: %w", err)
}
return cfg, nil
}
// Save persists the supplied config atomically (write-temp + rename) so
// crashes mid-write cannot leave a half-written file.
func (s *FileStore) Save(cfg domain.AppConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write tmp config: %w", err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("commit config: %w", err)
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
// Package cursor exposes the current mouse pointer position in a
// platform-agnostic way. We use it to anchor the post-capture toolbar near
// the location where the user released the mouse — on macOS that point is
// effectively the bottom-right corner of the screencapture selection.
package cursor
// Point is a screen-space pointer location, in *logical* pixels (i.e. the
// coordinate system Wails uses for window placement).
type Point struct {
X int
Y int
}
@@ -0,0 +1,30 @@
//go:build darwin
// cursor_darwin.go reads the global cursor position via Quartz Event
// Services. This API is preferable to NSEvent.mouseLocation for our use
// case because it returns the pointer in flipped (top-left origin)
// coordinates, which matches Wails / web pixel coordinates exactly.
package cursor
/*
#cgo LDFLAGS: -framework ApplicationServices
#include <ApplicationServices/ApplicationServices.h>
// readCursor returns the current mouse position with origin at the
// top-left of the primary display, in logical pixels.
static void readCursor(double* x, double* y) {
CGEventRef event = CGEventCreate(NULL);
CGPoint p = CGEventGetLocation(event);
if (event) CFRelease(event);
*x = p.x;
*y = p.y;
}
*/
import "C"
// Get returns the current mouse pointer position.
func Get() (Point, error) {
var x, y C.double
C.readCursor(&x, &y)
return Point{X: int(x), Y: int(y)}, nil
}
@@ -0,0 +1,9 @@
//go:build !darwin
package cursor
// Get is a no-op stub on non-macOS targets. The toolbar will fall back to
// the centre of the screen on these platforms.
func Get() (Point, error) {
return Point{X: 0, Y: 0}, nil
}
@@ -0,0 +1,25 @@
// Package display exposes platform-specific helpers for reading the
// geometry of the primary display, in both CSS (logical) and PHYSICAL
// pixels. The overlay flow needs both: CSS pixels to size the Wails window
// to "fullscreen" (since Wails uses CSS-px-equivalent units), and the
// backing scale factor to translate the user's CSS-px selection rectangle
// into the physical-pixel rectangle of the captured PNG.
package display
// Info describes the primary display.
//
// The struct is deliberately minimal: only the few fields the overlay flow
// actually needs. We can grow it later without affecting callers because
// it is returned by value.
type Info struct {
// CSSWidth / CSSHeight are the logical dimensions of the primary
// display in points (i.e. what CSS / Wails uses).
CSSWidth int
CSSHeight int
// Scale is backing factor (CSS-px → device-px). 2 on Retina, 1 on
// non-Retina. We model it as float64 to leave room for fractional
// scales (e.g. Linux 1.25×) even though macOS effectively only ever
// reports 1 or 2.
Scale float64
}
@@ -0,0 +1,41 @@
//go:build darwin
package display
/*
#cgo LDFLAGS: -framework CoreGraphics -framework Foundation
#include <CoreGraphics/CoreGraphics.h>
// readPrimaryDisplay fills the four out-params with:
// - cssW / cssH : logical (point) dimensions of the main display
// - pxW / pxH : physical (device-pixel) dimensions
// Using CGDisplayBounds for points and CGDisplayPixelsWide/High for pixels
// is the canonical way to derive the backing scale on macOS without
// bringing AppKit into the build (we do not want to link Cocoa here).
static void readPrimaryDisplay(int* cssW, int* cssH, int* pxW, int* pxH) {
CGDirectDisplayID id = CGMainDisplayID();
CGRect bounds = CGDisplayBounds(id);
*cssW = (int)bounds.size.width;
*cssH = (int)bounds.size.height;
*pxW = (int)CGDisplayPixelsWide(id);
*pxH = (int)CGDisplayPixelsHigh(id);
}
*/
import "C"
// Primary returns the geometry of the main display. The CGo call has no
// failure path — the OS always reports a main display whenever there is at
// least one attached, which is implied for any GUI process.
func Primary() Info {
var cssW, cssH, pxW, pxH C.int
C.readPrimaryDisplay(&cssW, &cssH, &pxW, &pxH)
scale := 1.0
if cssW > 0 {
scale = float64(pxW) / float64(cssW)
}
return Info{
CSSWidth: int(cssW),
CSSHeight: int(cssH),
Scale: scale,
}
}
@@ -0,0 +1,21 @@
//go:build !darwin
package display
import "github.com/kbinani/screenshot"
// Primary returns the dimensions of display 0. We currently assume a 1× DPR
// because Wails on Windows / Linux already paints in physical pixels, so
// CSS coordinates and capture coordinates coincide. If a user ever runs at
// non-1× we will add a platform-specific scale lookup.
func Primary() Info {
if screenshot.NumActiveDisplays() == 0 {
return Info{CSSWidth: 1440, CSSHeight: 900, Scale: 1}
}
b := screenshot.GetDisplayBounds(0)
return Info{
CSSWidth: b.Dx(),
CSSHeight: b.Dy(),
Scale: 1,
}
}
+121
View File
@@ -0,0 +1,121 @@
// Package hotkey wraps golang.design/x/hotkey to provide a process-wide
// global shortcut. The MVP only supports a single hard-coded combination
// (Cmd+Shift+A on macOS) but the abstraction below allows the binding to
// be replaced at runtime once we add per-platform parsing of user input.
package hotkey
import (
"fmt"
"strings"
"sync"
hk "golang.design/x/hotkey"
)
// Manager owns the lifecycle of one global hotkey.
type Manager struct {
mu sync.Mutex
current *hk.Hotkey
stop chan struct{}
}
// NewManager returns a fresh manager with no hotkey registered yet.
func NewManager() *Manager { return &Manager{} }
// Register parses the spec (e.g. "cmd+shift+a") and binds the callback.
// If a previous hotkey is registered, it is unregistered first so callers
// can swap shortcuts at runtime when the user changes the setting.
func (m *Manager) Register(spec string, onTrigger func()) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil {
_ = m.current.Unregister()
close(m.stop)
m.current = nil
m.stop = nil
}
mods, key, err := parseSpec(spec)
if err != nil {
return err
}
hot := hk.New(mods, key)
if err := hot.Register(); err != nil {
return fmt.Errorf("register hotkey %q: %w", spec, err)
}
stop := make(chan struct{})
m.current = hot
m.stop = stop
// Listen on a dedicated goroutine. We never run the callback inline
// because the keydown channel is unbuffered.
go func() {
for {
select {
case <-stop:
return
case <-hot.Keydown():
if onTrigger != nil {
go onTrigger()
}
}
}
}()
return nil
}
// Unregister releases the active hotkey, if any.
func (m *Manager) Unregister() {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil {
_ = m.current.Unregister()
close(m.stop)
m.current = nil
m.stop = nil
}
}
// parseSpec parses a "+"-separated hotkey description into (modifiers, key).
//
// Supported tokens (case-insensitive): cmd, command, ctrl, control,
// option, alt, shift, plus a single letter AZ or digit 09.
//
// We keep the parser intentionally tiny — full key mapping is out of scope
// for the MVP.
func parseSpec(spec string) ([]hk.Modifier, hk.Key, error) {
parts := strings.Split(strings.ToLower(strings.TrimSpace(spec)), "+")
if len(parts) == 0 {
return nil, 0, fmt.Errorf("empty hotkey")
}
var mods []hk.Modifier
var key hk.Key
hasKey := false
for _, p := range parts {
p = strings.TrimSpace(p)
switch p {
case "cmd", "command", "meta", "super":
mods = append(mods, modCmd())
case "ctrl", "control":
mods = append(mods, modCtrl())
case "option", "alt":
mods = append(mods, modOption())
case "shift":
mods = append(mods, hk.ModShift)
default:
k, ok := lookupKey(p)
if !ok {
return nil, 0, fmt.Errorf("unsupported token: %q", p)
}
key = k
hasKey = true
}
}
if !hasKey {
return nil, 0, fmt.Errorf("hotkey %q is missing a key", spec)
}
return mods, key, nil
}
@@ -0,0 +1,10 @@
//go:build darwin
package hotkey
import hk "golang.design/x/hotkey"
// macOS uses ⌘ as the primary modifier; Option corresponds to Alt.
func modCmd() hk.Modifier { return hk.ModCmd }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.ModOption }
@@ -0,0 +1,11 @@
//go:build !darwin
package hotkey
import hk "golang.design/x/hotkey"
// On Windows / Linux we map "cmd" to Ctrl so the same config string works
// across platforms; "option" is treated as Alt.
func modCmd() hk.Modifier { return hk.ModCtrl }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.ModAlt }
+86
View File
@@ -0,0 +1,86 @@
package hotkey
import hk "golang.design/x/hotkey"
// lookupKey maps a token from the user-supplied hotkey spec to a hk.Key.
//
// The map is intentionally exhaustive over the alphanumeric set so we never
// rely on an assumption that hk.KeyA..hk.KeyZ are contiguous values — the
// upstream package gives no such guarantee.
func lookupKey(token string) (hk.Key, bool) {
switch token {
case "a":
return hk.KeyA, true
case "b":
return hk.KeyB, true
case "c":
return hk.KeyC, true
case "d":
return hk.KeyD, true
case "e":
return hk.KeyE, true
case "f":
return hk.KeyF, true
case "g":
return hk.KeyG, true
case "h":
return hk.KeyH, true
case "i":
return hk.KeyI, true
case "j":
return hk.KeyJ, true
case "k":
return hk.KeyK, true
case "l":
return hk.KeyL, true
case "m":
return hk.KeyM, true
case "n":
return hk.KeyN, true
case "o":
return hk.KeyO, true
case "p":
return hk.KeyP, true
case "q":
return hk.KeyQ, true
case "r":
return hk.KeyR, true
case "s":
return hk.KeyS, true
case "t":
return hk.KeyT, true
case "u":
return hk.KeyU, true
case "v":
return hk.KeyV, true
case "w":
return hk.KeyW, true
case "x":
return hk.KeyX, true
case "y":
return hk.KeyY, true
case "z":
return hk.KeyZ, true
case "0":
return hk.Key0, true
case "1":
return hk.Key1, true
case "2":
return hk.Key2, true
case "3":
return hk.Key3, true
case "4":
return hk.Key4, true
case "5":
return hk.Key5, true
case "6":
return hk.Key6, true
case "7":
return hk.Key7, true
case "8":
return hk.Key8, true
case "9":
return hk.Key9, true
}
return 0, false
}
+124
View File
@@ -0,0 +1,124 @@
// Package oss contains storage adapters that satisfy domain.OSSProvider.
//
// The S3 adapter targets any S3-compatible endpoint via aws-sdk-go-v2.
package oss
import (
"bytes"
"context"
"fmt"
"net/url"
"strings"
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/mmmy/snapgo/internal/domain"
)
// S3Provider implements domain.OSSProvider using aws-sdk-go-v2.
//
// We construct one client per Provider instance because the S3 client is
// goroutine-safe and the configuration is immutable for the lifetime of the
// provider.
type S3Provider struct {
cfg domain.S3Config
client *s3.Client
}
// NewS3Provider builds an *S3Provider from the supplied user configuration.
//
// The endpoint is plumbed through BaseEndpoint + UsePathStyle so any
// S3-compatible service (MinIO, R2, B2, Aliyun OSS s3 endpoint) works
// without us writing per-vendor branches.
func NewS3Provider(cfg domain.S3Config) (*S3Provider, error) {
if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
return nil, fmt.Errorf("s3 config: endpoint/bucket/accessKeyId/secretAccessKey are required")
}
region := cfg.Region
if region == "" {
region = "us-east-1"
}
awsCfg := awsv2.Config{
Region: region,
Credentials: credentials.NewStaticCredentialsProvider(
cfg.AccessKeyID, cfg.SecretAccessKey, "",
),
}
endpoint := cfg.Endpoint
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = awsv2.String(endpoint)
o.UsePathStyle = cfg.UsePathStyle
})
return &S3Provider{cfg: cfg, client: client}, nil
}
// Name identifies this provider in logs and history records.
func (p *S3Provider) Name() string { return "s3" }
// Upload pushes the bytes to the configured bucket and returns a public URL.
//
// We pass the body via bytes.Reader so the SDK can compute Content-Length
// and signed checksum without buffering the data twice.
func (p *S3Provider) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
if contentType == "" {
contentType = "image/png"
}
_, err := p.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(key),
Body: bytes.NewReader(data),
ContentType: awsv2.String(contentType),
})
if err != nil {
return "", fmt.Errorf("s3 put object: %w", err)
}
return p.BuildPublicURL(key), nil
}
// BuildPublicURL constructs the URL that the user pastes into Markdown.
//
// Priority:
// 1. PublicURLBase if user filled it (best-fit for CDN-fronted buckets);
// 2. {Endpoint}/{Bucket}/{Key} for path-style;
// 3. virtual-hosted-style fallback ({bucket}.host).
func (p *S3Provider) BuildPublicURL(key string) string {
if p.cfg.PublicURLBase != "" {
base := strings.TrimRight(p.cfg.PublicURLBase, "/")
return base + "/" + key
}
endpoint := strings.TrimRight(p.cfg.Endpoint, "/")
if p.cfg.UsePathStyle {
return endpoint + "/" + p.cfg.Bucket + "/" + key
}
// Virtual-hosted-style: replace host with {bucket}.{host}
if u, err := url.Parse(endpoint); err == nil && u.Host != "" {
u.Host = p.cfg.Bucket + "." + u.Host
u.Path = "/" + key
return u.String()
}
return endpoint + "/" + p.cfg.Bucket + "/" + key
}
// TestConnection performs a small put + delete to verify credentials and
// bucket reachability. Used by the "Test connection" button in settings.
func (p *S3Provider) TestConnection(ctx context.Context) error {
probeKey := strings.TrimRight(p.cfg.PathPrefix, "/") + "/.snapgo-probe"
probeKey = strings.TrimLeft(probeKey, "/")
if _, err := p.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(probeKey),
Body: bytes.NewReader([]byte("snapgo")),
ContentType: awsv2.String("text/plain"),
}); err != nil {
return fmt.Errorf("probe put: %w", err)
}
if _, err := p.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(probeKey),
}); err != nil {
return fmt.Errorf("probe delete: %w", err)
}
return nil
}
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,62 @@
//go:build darwin
package tray
/*
#cgo darwin LDFLAGS: -framework Foundation
#include <dispatch/dispatch.h>
// runOnMainQueue submits the Go-side callback (identified by handle) to the
// main dispatch queue. We use dispatch_async so the caller — typically a
// Wails OnStartup goroutine — does not block the cocoa runloop while waiting
// for the main thread to drain.
extern void trayDispatchMainCallback(unsigned long handle);
static void runOnMainQueue(unsigned long handle) {
dispatch_async(dispatch_get_main_queue(), ^{
trayDispatchMainCallback(handle);
});
}
*/
import "C"
import (
"sync"
"sync/atomic"
)
// dispatchRegistry stores Go closures keyed by an integer handle so that the
// C side can call back into Go without leaking unsafe.Pointer-cast function
// pointers (which CGo disallows).
var (
dispatchMu sync.Mutex
dispatchTable = map[uint64]func(){}
dispatchNextID uint64
)
// dispatchOnMain schedules fn to run on the macOS main thread. This is the
// Cocoa contract for any API that touches NSWindow / NSStatusBar — calling
// them from a goroutine triggers the "should only be instantiated on the
// main thread" assertion.
func dispatchOnMain(fn func()) {
if fn == nil {
return
}
id := atomic.AddUint64(&dispatchNextID, 1)
dispatchMu.Lock()
dispatchTable[id] = fn
dispatchMu.Unlock()
C.runOnMainQueue(C.ulong(id))
}
//export trayDispatchMainCallback
func trayDispatchMainCallback(handle C.ulong) {
id := uint64(handle)
dispatchMu.Lock()
fn := dispatchTable[id]
delete(dispatchTable, id)
dispatchMu.Unlock()
if fn != nil {
fn()
}
}
@@ -0,0 +1,16 @@
//go:build !darwin
package tray
// dispatchOnMain is a no-op on non-darwin platforms because:
// - On Linux (GTK/AppIndicator), systray.Run / nativeStart can be invoked
// from any goroutine; the GTK runloop is started internally.
// - On Windows, status icons are tied to a hidden window owned by the
// systray library itself, also independent of the Go main goroutine.
// Wrapping the call in dispatchOnMain on these platforms would just add a
// pointless layer of indirection.
func dispatchOnMain(fn func()) {
if fn != nil {
fn()
}
}
@@ -0,0 +1,16 @@
//go:build darwin
package tray
import _ "embed"
// templateIconBytes embeds the macOS template icon as a multi-resolution TIFF
// generated from the 1x and 2x source PNGs in assets/.
//
//go:embed assets/statusbarTemplate.tiff
var templateIconBytes []byte
// regularIconBytes keeps a PNG fallback for APIs that expect a regular raster icon.
//
//go:embed assets/statusbarTemplate.png
var regularIconBytes []byte
@@ -0,0 +1,13 @@
//go:build !darwin
package tray
import _ "embed"
// Keep a regular PNG for non-macOS builds where template icons are unsupported.
//
//go:embed assets/statusbarTemplate.png
var templateIconBytes []byte
//go:embed assets/statusbarTemplate.png
var regularIconBytes []byte
+84
View File
@@ -0,0 +1,84 @@
// Package tray runs the menu-bar agent UI for SnapGo on macOS / Linux /
// Windows. It is intentionally decoupled from the main App struct: the App
// supplies a small set of callbacks (Capture / Settings / Quit) so that this
// package never imports the application service stack.
//
// We use fyne.io/systray (a fork of getlantern/systray) because it is
// designed to coexist with other GUI runloops — important when the host
// process is also driving a Wails / Cocoa main loop.
package tray
import (
"fyne.io/systray"
)
// Callbacks bundles the menu actions the host application wants to expose.
// Splitting these into a struct (rather than 3 positional arguments) keeps
// future extension cheap — we can add e.g. OnCheckForUpdates without
// touching the call sites.
type Callbacks struct {
OnCapture func()
OnSettings func()
OnQuit func()
}
// Start installs the menu-bar status item and returns immediately. The
// fyne.io/systray fork exposes RunWithExternalLoop precisely for cases like
// ours where the host process already owns the main thread (Wails).
//
// The returned `stop` function tears down the status item; call it from the
// host's shutdown path.
func Start(cbs Callbacks) (start, stop func()) {
onReady := func() {
systray.SetTemplateIcon(templateIconBytes, regularIconBytes)
systray.SetTooltip("SnapGo")
mCapture := systray.AddMenuItem("Capture screenshot", "Take a region screenshot")
mSettings := systray.AddMenuItem("Settings…", "Open settings window")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit SnapGo", "Quit the application")
// Pump menu clicks on a dedicated goroutine. Channel sends from
// systray are non-blocking, so a slow user callback does not back
// up the UI thread.
go func() {
for {
select {
case <-mCapture.ClickedCh:
if cbs.OnCapture != nil {
cbs.OnCapture()
}
case <-mSettings.ClickedCh:
if cbs.OnSettings != nil {
cbs.OnSettings()
}
case <-mQuit.ClickedCh:
if cbs.OnQuit != nil {
cbs.OnQuit()
}
systray.Quit()
return
}
}
}()
}
onExit := func() { /* nothing to clean up */ }
// RunWithExternalLoop registers the status item without blocking the
// caller — the returned `start` should be invoked once the host's
// runloop is up so the icon appears. fyne.io/systray returns a
// (start, end) pair; we delegate `end` to systray.Quit for symmetry.
rawStart, _ := systray.RunWithExternalLoop(onReady, onExit)
// IMPORTANT: on macOS, `nativeStart` ends up calling
// [NSStatusBar systemStatusBar] -> [-NSStatusBar _statusItemWithLength:...]
// which constructs an NSWindow. Cocoa hard-asserts that NSWindow can
// only be instantiated on the main thread. Wails v2 invokes OnStartup
// from a worker goroutine, so calling rawStart() directly from there
// crashes with NSInternalInconsistencyException. We wrap it in
// dispatchOnMain so the call is forwarded to the main dispatch queue.
start = func() { dispatchOnMain(rawStart) }
stop = systray.Quit
return start, stop
}