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