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
+471
View File
@@ -0,0 +1,471 @@
// app.go wires the application service stack together and exposes
// frontend-callable methods through Wails bindings.
package main
import (
"context"
"fmt"
"image"
"log/slog"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/mmmy/snapgo/internal/application"
"github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
"github.com/mmmy/snapgo/internal/infrastructure/config"
"github.com/mmmy/snapgo/internal/infrastructure/display"
"github.com/mmmy/snapgo/internal/infrastructure/hotkey"
"github.com/mmmy/snapgo/internal/infrastructure/oss"
"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
)
// App is the struct exposed to the Wails frontend through Bind.
//
// We deliberately keep this struct small: it owns long-lived collaborators
// (config store, hotkey manager, capturer, clipboard) but delegates the
// real work to the application service constructed on demand once an OSS
// provider is configured.
type App struct {
ctx context.Context
mu sync.RWMutex
cfg domain.AppConfig
configFile *config.FileStore
hotkeyMgr *hotkey.Manager
capturer screencapture.Capturer
clip clipboard.Writer
// capturing prevents re-entrant capture sessions when the user mashes
// the hotkey while a previous one is still running. It stays true for
// the entire lifecycle of one capture: from overlay start until the
// overlay is either confirmed (ConfirmRegion) or discarded (CancelRegion).
capturing atomic.Bool
// pendingMu guards `pending`. We keep this separate from `mu` so that
// frontend-triggered RPC handlers (which may run concurrently with the
// capture goroutine) can take it cheaply.
pendingMu sync.Mutex
pending *pendingCapture
}
// pendingCapture holds the OSS provider chosen when capture started.
// Keeping the provider here prevents a config edit
// between "snap" and "confirm" from silently retargeting the upload.
type pendingCapture struct {
Provider domain.OSSProvider
Display display.Info
}
// OverlayPayload is the JSON the Go side emits so the WebView can build the
// Snipaste-style overlay. It deliberately does not contain screenshot bytes:
// the window is transparent and the mask sits over the live desktop.
type OverlayPayload struct {
CSSWidth int `json:"cssWidth"`
CSSHeight int `json:"cssHeight"`
Scale float64 `json:"scale"`
}
// RegionRect is the CSS-pixel selection rectangle the WebView reports.
// Origin is the top-left of the primary display.
type RegionRect struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
}
// NewApp creates a new App with collaborators already initialised.
func NewApp() *App {
store, err := config.NewFileStore()
if err != nil {
slog.Error("config store init", "err", err)
}
cfg := domain.DefaultAppConfig()
if store != nil {
if loaded, lerr := store.Load(); lerr == nil {
cfg = loaded
} else {
slog.Warn("config load failed, using defaults", "err", lerr)
}
}
return &App{
cfg: cfg,
configFile: store,
hotkeyMgr: hotkey.NewManager(),
capturer: screencapture.New(),
clip: clipboard.New(),
}
}
// startup is invoked by Wails once the runtime is available.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
if err := a.registerHotkey(a.cfg.Hotkey); err != nil {
slog.Warn("register hotkey failed", "err", err)
wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
} else {
wruntime.EventsEmit(a.ctx, "hotkey:ready", a.cfg.Hotkey)
}
}
// shutdown releases OS resources.
func (a *App) shutdown(_ context.Context) {
a.hotkeyMgr.Unregister()
}
// ---------------------------------------------------------------------------
// Hotkey management
// ---------------------------------------------------------------------------
func (a *App) registerHotkey(spec string) error {
if spec == "" {
spec = "cmd+shift+a"
}
return a.hotkeyMgr.Register(spec, func() {
a.runInteractiveCapture()
})
}
// runInteractiveCapture is the unified entry point for the global hotkey
// and the in-app "Capture now" button. The new flow is:
//
// 1. Hide the main window so it never flashes over the desktop.
// 2. Re-show it as a transparent, high-level overlay that paints only a
// dimming mask plus selection chrome over the live screen.
// 3. Wait — the user finishes selecting in the WebView, which calls
// ConfirmRegion(rect) or CancelRegion() back into us.
//
// We deliberately do NOT block the goroutine on a channel here: the
// "wait" is implicit. capturing stays true until the frontend resolves it.
func (a *App) runInteractiveCapture() {
if a.ctx == nil {
return
}
if !a.capturing.CompareAndSwap(false, true) {
return
}
releaseOnExit := true
defer func() {
if releaseOnExit {
a.capturing.Store(false)
}
}()
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsS3Configured() {
a.surfaceWindow()
wruntime.EventsEmit(a.ctx, "upload:failure",
"S3 is not configured yet — open settings first")
return
}
provider, err := oss.NewS3Provider(cfg.S3)
if err != nil {
a.surfaceWindow()
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return
}
// Hide the settings window before transforming it into an overlay, so
// the user only sees the final transparent mask state.
wruntime.WindowHide(a.ctx)
hideDockIcon()
flushFrame()
wruntime.EventsEmit(a.ctx, "capture:start", nil)
info := display.Primary()
// Park the provider and morph the window into a transparent overlay.
a.pendingMu.Lock()
a.pending = &pendingCapture{
Provider: provider,
Display: info,
}
a.pendingMu.Unlock()
if showNativeCaptureOverlay(a, info) {
releaseOnExit = false // ownership passes to the native overlay callbacks
return
}
payload := OverlayPayload{
CSSWidth: info.CSSWidth,
CSSHeight: info.CSSHeight,
Scale: info.Scale,
}
a.showOverlayWindow(info)
wruntime.EventsEmit(a.ctx, "capture:overlay", payload)
flushFrame()
wruntime.WindowShow(a.ctx)
configureOverlayWindow()
releaseOnExit = false // ownership of `capturing` passes to the overlay lifecycle
}
// ---------------------------------------------------------------------------
// Overlay window helpers
// ---------------------------------------------------------------------------
const (
settingsWidth = 900
settingsHeight = 640
)
// showOverlayWindow prepares the hidden main window as a borderless,
// always-on-top, transparent full-primary-display window. The caller emits
// the overlay event and only then shows it, avoiding an opaque grey flash.
func (a *App) showOverlayWindow(info display.Info) {
if a.ctx == nil {
return
}
wruntime.WindowSetBackgroundColour(a.ctx, 0, 0, 0, 0)
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
wruntime.WindowSetSize(a.ctx, info.CSSWidth, info.CSSHeight)
wruntime.WindowSetPosition(a.ctx, 0, 0)
configureOverlayWindow()
}
// restoreSettingsWindow flips the window back from overlay form to its
// normal Settings size and unpins it. We do NOT call Show here — callers
// decide whether the user actually wanted Settings to appear.
func (a *App) restoreSettingsWindow() {
if a.ctx == nil {
return
}
restoreOverlayWindow()
wruntime.WindowSetBackgroundColour(a.ctx, 246, 247, 250, 255)
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
wruntime.WindowSetSize(a.ctx, settingsWidth, settingsHeight)
wruntime.WindowCenter(a.ctx)
}
// surfaceWindow brings the main window back from a hidden state regardless
// of which mechanism we used to suppress it.
func (a *App) surfaceWindow() {
if a.ctx == nil {
return
}
showDockIcon(true)
a.restoreSettingsWindow()
wruntime.WindowUnminimise(a.ctx)
wruntime.WindowShow(a.ctx)
}
// dismissOverlay hides the overlay window and shrinks/centres it back to
// the Settings dimensions so a future ShowWindow lands in the right place.
func (a *App) dismissOverlay() {
if a.ctx == nil {
return
}
wruntime.WindowHide(a.ctx)
hideDockIcon()
a.restoreSettingsWindow()
}
// runUploadPipeline is the post-capture half of the workflow. Shared by
// the hotkey path and the explicit "Upload now" RPC.
func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
svc := &application.CaptureAndUploadService{
Capturer: a.capturer,
Provider: provider,
Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx},
FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"),
PathPrefix: cfg.S3.PathPrefix,
}
return svc.ExecuteWithBytes(a.ctx, pngBytes)
}
// ---------------------------------------------------------------------------
// Bound methods (called from the frontend via Wails)
// ---------------------------------------------------------------------------
// GetConfig returns the current persisted configuration.
func (a *App) GetConfig() domain.AppConfig {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg
}
// SaveConfig persists the supplied configuration and re-registers the hotkey
// if it changed.
func (a *App) SaveConfig(cfg domain.AppConfig) error {
a.mu.Lock()
prev := a.cfg
a.cfg = cfg
a.mu.Unlock()
if a.configFile != nil {
if err := a.configFile.Save(cfg); err != nil {
return err
}
}
if cfg.Hotkey != prev.Hotkey {
if err := a.registerHotkey(cfg.Hotkey); err != nil {
wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
return fmt.Errorf("register new hotkey: %w", err)
}
wruntime.EventsEmit(a.ctx, "hotkey:ready", cfg.Hotkey)
}
return nil
}
// RetryRegisterHotkey re-registers the configured hotkey on demand.
func (a *App) RetryRegisterHotkey() error {
a.mu.RLock()
spec := a.cfg.Hotkey
a.mu.RUnlock()
if err := a.registerHotkey(spec); err != nil {
wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
return err
}
wruntime.EventsEmit(a.ctx, "hotkey:ready", spec)
return nil
}
// TestConnection performs a put+delete probe against the supplied S3 config.
func (a *App) TestConnection(cfg domain.S3Config) error {
provider, err := oss.NewS3Provider(cfg)
if err != nil {
return err
}
return provider.TestConnection(a.ctx)
}
// CaptureNow is the in-app trigger.
func (a *App) CaptureNow() {
go a.runInteractiveCapture()
}
// ConfirmRegion is invoked by the overlay UI when the user clicks
// "Upload & copy". The overlay is transparent and sits over the live
// desktop, so we hide it first, then ask the OS to capture the selected
// logical screen rectangle directly.
//
// Returning the error to the frontend lets the overlay decide whether to
// keep showing the screenshot (e.g. for a retry) — currently it just
// dismisses regardless and surfaces the error via the upload:failure toast.
func (a *App) ConfirmRegion(rect RegionRect) error {
a.pendingMu.Lock()
pc := a.pending
a.pending = nil
a.pendingMu.Unlock()
if pc == nil {
return fmt.Errorf("no pending capture")
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
a.dismissOverlay()
flushFrame()
cropped, err := a.capturer.CaptureRegion(captureRect)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
if err := a.runUploadPipeline(pc.Provider, cropped); err != nil {
return err
}
return nil
}
// ConfirmNativeRegion mirrors ConfirmRegion for the macOS native overlay.
// The native AppKit panel has already been closed by the time this method is
// called, so we must not dismiss/restore the Wails overlay window here.
func (a *App) ConfirmNativeRegion(rect RegionRect) error {
a.pendingMu.Lock()
pc := a.pending
a.pending = nil
a.pendingMu.Unlock()
if pc == nil {
return fmt.Errorf("no pending capture")
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
flushFrame()
cropped, err := a.capturer.CaptureRegion(captureRect)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runUploadPipeline(pc.Provider, cropped)
}
// CancelRegion is invoked when the user dismisses the overlay (Esc /
// Cancel button / right-click). Frees the pending PNG, releases the
// capture lock, and hides the overlay.
func (a *App) CancelRegion() {
a.pendingMu.Lock()
a.pending = nil
a.pendingMu.Unlock()
a.capturing.Store(false)
a.dismissOverlay()
}
// CancelNativeRegion releases native-overlay state without touching the
// hidden Wails settings window.
func (a *App) CancelNativeRegion() {
a.pendingMu.Lock()
a.pending = nil
a.pendingMu.Unlock()
a.capturing.Store(false)
hideDockIcon()
}
// ShowWindow brings the main window back to the foreground in its normal
// Settings shape. Used by the tray "Settings…" menu item.
func (a *App) ShowWindow() {
a.surfaceWindow()
time.Sleep(20 * time.Millisecond)
}
// QuitApp terminates the entire process. Wired to the tray "Quit" menu.
func (a *App) QuitApp() {
if a.ctx != nil {
wruntime.Quit(a.ctx)
return
}
os.Exit(0)
}
// runtimeNotifier emits success / failure events via the Wails runtime.
type runtimeNotifier struct{ ctx context.Context }
func (n *runtimeNotifier) NotifySuccess(url string) {
wruntime.EventsEmit(n.ctx, "upload:success", url)
}
func (n *runtimeNotifier) NotifyFailure(reason string) {
wruntime.EventsEmit(n.ctx, "upload:failure", reason)
}
// userPicturesDir returns ~/Pictures (or HOME if unavailable).
func userPicturesDir() string {
home, err := os.UserHomeDir()
if err != nil {
return os.TempDir()
}
return filepath.Join(home, "Pictures")
}