feat(app): implement basic function
General - 配置界面支持配置对象存储桶 - 快捷键进入截图 - 一键上传截图 & 复制截图链接 MacOS - 状态栏指示器和应用图标
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 A–Z or digit 0–9.
|
||||
//
|
||||
// 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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user