Files

1282 lines
39 KiB
Go

// app.go wires the application service stack together and exposes
// frontend-callable methods through Wails bindings.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/png"
"log/slog"
"os"
"path/filepath"
"strings"
"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"
ftppkg "github.com/mmmy/snapgo/internal/infrastructure/ftp"
"github.com/mmmy/snapgo/internal/infrastructure/hotkey"
llmpkg "github.com/mmmy/snapgo/internal/infrastructure/llm"
ocrpkg "github.com/mmmy/snapgo/internal/infrastructure/ocr"
"github.com/mmmy/snapgo/internal/infrastructure/oss"
"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh"
)
// 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 application services constructed on demand for the selected
// storage destination.
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"`
}
// CaptureResult is returned by the overlay with the final selection and any
// annotations drawn inside the selected area.
type CaptureResult struct {
Rect RegionRect `json:"rect"`
Annotations []application.Annotation `json:"annotations"`
}
// CaptureActionResult tells the frontend whether an action was completed or
// the user cancelled a secondary choice such as the save destination dialog.
type CaptureActionResult struct {
Completed bool `json:"completed"`
Path string `json:"path,omitempty"`
}
// OperationStatusPayload drives the frontend status HUD and mirrors the
// native macOS HUD state.
type OperationStatusPayload struct {
Operation string `json:"operation"`
Phase string `json:"phase"`
Message string `json:"message"`
State string `json:"state"`
}
// 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)
}
}
cfg.Normalize()
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()
var provider domain.OSSProvider
if cfg.IsS3Configured() {
var err error
provider, err = oss.NewS3Provider(cfg.S3)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
}
}
// 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 = 1000
settingsHeight = 820
)
// 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,
}
a.emitOperationStatus("upload", "上传中", "正在上传截图到 S3", "running")
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("upload", "上传失败", err.Error(), "error")
return err
}
a.emitOperationStatus("upload", "上传完成", "链接已复制到剪贴板", "success")
return nil
}
func (a *App) runCopyImagePipeline(pngBytes []byte) error {
svc := &application.CaptureActionsService{
Clipboard: a.clip,
}
a.emitOperationStatus("copy", "复制中", "正在把截图复制到剪贴板", "running")
if err := svc.CopyImage(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("copy", "复制失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("copy", "复制完成", "截图已复制到剪贴板", "success")
wruntime.EventsEmit(a.ctx, "upload:success", "image copied to clipboard")
return nil
}
func (a *App) runSaveImagePipeline(pngBytes []byte, dir string) (string, error) {
svc := &application.CaptureActionsService{
Clipboard: a.clip,
}
a.emitOperationStatus("save", "保存中", "正在保存截图到本地", "running")
path, err := svc.SaveImage(a.ctx, pngBytes, dir)
if err != nil {
a.emitOperationStatus("save", "保存失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return "", err
}
a.emitOperationStatus("save", "保存完成", "文件路径已复制到剪贴板:"+path, "success")
wruntime.EventsEmit(a.ctx, "upload:success", path)
return path, nil
}
// runSaveRemotePipeline uploads the captured PNG to the configured SSH host
// via SCP and copies a sharable reference (URL or "user@host:~/path") to
// the clipboard.
//
// Why a dedicated method (vs. extending CaptureAndUploadService): SSH and
// S3 have different config + clipboard semantics, so keeping them separate
// avoids leaking provider-specific branches into the OSS pipeline.
func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsSSHConfigured() {
err := fmt.Errorf("SSH host/user is not configured")
slog.Warn("save-remote rejected: ssh not configured",
"host", cfg.SSH.Host, "user", cfg.SSH.User)
a.emitOperationStatus("save-remote", "需要配置 SSH", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
slog.Info("save-remote dispatch",
"host", cfg.SSH.Host, "user", cfg.SSH.User, "port", cfg.SSH.Port,
"auth_method", cfg.SSH.AuthMethod, "png_size", len(pngBytes))
// Select the uploader by auth method: Kerberos delegates to the system
// ssh/scp binaries (reusing the kinit credential cache), while builtin
// uses the in-process Go SSH client.
var uploader application.SSHUploader
if cfg.SSH.IsKerberos() {
uploader = sshpkg.NewKerberosUploader(cfg.SSH)
} else {
uploader = sshpkg.NewUploader(cfg.SSH)
}
svc := &application.CaptureAndSSHService{
Uploader: uploader,
Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx},
Cfg: cfg.SSH,
}
a.emitOperationStatus("save-remote", "保存中", "正在保存截图到远端 SSH", "running")
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("save-remote", "保存失败", err.Error(), "error")
return err
}
a.emitOperationStatus("save-remote", "保存完成", "远端路径已复制到剪贴板", "success")
return nil
}
// runFTPUploadPipeline uploads through the protocol selected in FTPConfig and
// copies the resulting remote path. FTP/SFTP remains separate from both S3
// (public URL semantics) and SSH/SCP (a distinct toolbar destination).
func (a *App) runFTPUploadPipeline(pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsFTPConfigured() {
err := fmt.Errorf("FTP/SFTP host/user is not configured")
a.emitOperationStatus("ftp-upload", "需要配置 FTP/SFTP", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
uploader, err := ftppkg.NewUploader(cfg.FTP)
if err != nil {
a.emitOperationStatus("ftp-upload", "FTP/SFTP 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
slog.Info("FTP/SFTP upload dispatch",
"protocol", cfg.FTP.Protocol,
"host", cfg.FTP.Host,
"user", cfg.FTP.User,
"port", cfg.FTP.Port,
"auth_method", cfg.FTP.AuthMethod,
"strict_host_key", cfg.FTP.StrictHostKey,
"has_password", cfg.FTP.Password != "",
"png_size", len(pngBytes))
svc := &application.CaptureAndFTPService{
Uploader: uploader,
Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx},
Cfg: cfg.FTP,
}
protocolLabel := strings.ToUpper(cfg.FTP.Protocol)
if protocolLabel == "" {
protocolLabel = "FTP"
}
a.emitOperationStatus("ftp-upload", "上传中", "正在上传截图到 "+protocolLabel, "running")
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("ftp-upload", "上传失败", err.Error(), "error")
return err
}
a.emitOperationStatus("ftp-upload", "上传完成", "远端路径已复制到剪贴板", "success")
return nil
}
func (a *App) runSummaryPipeline(pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsLLMConfigured() {
err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
providerID, llmCfg, _ := cfg.ActiveLLMProvider()
visionClient, err := llmpkg.NewVisionClient(providerID, llmCfg)
if err != nil {
a.emitOperationStatus("summary", "LLM 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
svc := &application.CaptureSummaryService{
Summarizer: visionClient,
Clipboard: a.clip,
Prompt: cfg.LLM.Prompt,
PathPrefix: cfg.S3.PathPrefix,
MaxInlineBytes: llmCfg.MaxInlineBytes,
}
// Preferred path: send the screenshot inline as a base64 data URL so the
// summary never depends on S3 being configured or publicly reachable.
if svc.CanInline(pngBytes) {
a.emitOperationStatus("summary", "识别中", "正在请求多模态模型(本地内联图片)", "running")
summary, err := svc.Summarize(a.ctx, svc.InlineDataURL(pngBytes))
if err != nil {
a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.finishSummary(svc, summary)
}
// Oversized image: fall back to uploading to S3 and passing a public URL.
if !cfg.IsS3Configured() {
err := fmt.Errorf("截图尺寸超过内联上限(%d 字节),请缩小截图区域,或在 S3 配置页填写公网可达的对象存储后重试", llmCfg.MaxInlineBytes)
a.emitOperationStatus("summary", "尺寸超限", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
s3Provider, err := oss.NewS3Provider(cfg.S3)
if err != nil {
a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
svc.Provider = s3Provider
a.emitOperationStatus("summary", "上传中", "截图较大,正在上传供模型读取", "running")
uploaded, err := svc.UploadImage(a.ctx, pngBytes)
if err != nil {
a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
// The remote model fetches the URL itself, so verify it is reachable and
// clean up the temporary object once we are done with it either way.
defer func() {
if derr := svc.DeleteUploaded(a.ctx, uploaded.Key); derr != nil {
slog.Warn("summary: failed to delete temporary object", "key", uploaded.Key, "err", derr)
}
}()
if err := svc.EnsureReachable(a.ctx, uploaded.URL); err != nil {
a.emitOperationStatus("summary", "链接不可达", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("summary", "识别中", "图片已上传,正在请求多模态模型", "running")
summary, err := svc.Summarize(a.ctx, uploaded.URL)
if err != nil {
a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.finishSummary(svc, summary)
}
// finishSummary copies the summary to the clipboard and emits the terminal
// success/failure UI state shared by the inline and S3 paths.
func (a *App) finishSummary(svc *application.CaptureSummaryService, summary string) error {
if err := svc.CopySummary(a.ctx, summary); err != nil {
a.emitOperationStatus("summary", "复制失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success")
wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard")
return nil
}
func (a *App) runOCRPipeline(pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsOCRConfigured() {
err := fmt.Errorf("请先在文字提取配置页填写 OCR Provider 的 AccessKey ID 和 AccessKey Secret")
a.emitOperationStatus("ocr", "需要配置 OCR", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
providerID, ocrCfg, _ := cfg.ActiveOCRProvider()
recognizer, err := ocrpkg.NewClient(providerID, ocrCfg)
if err != nil {
a.emitOperationStatus("ocr", "OCR 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
svc := &application.CaptureOCRService{
Recognizer: recognizer,
Clipboard: a.clip,
}
a.emitOperationStatus("ocr", "识别中", "正在提取截图文字", "running")
text, err := svc.Recognize(a.ctx, pngBytes)
if err != nil {
a.emitOperationStatus("ocr", "识别失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
if err := svc.CopyText(a.ctx, text); err != nil {
a.emitOperationStatus("ocr", "复制失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("ocr", "提取完成", "文字已复制到剪贴板", "success")
wruntime.EventsEmit(a.ctx, "upload:success", "ocr text copied to clipboard")
return nil
}
func (a *App) consumePendingCapture() (*pendingCapture, error) {
a.pendingMu.Lock()
pc := a.pending
a.pending = nil
a.pendingMu.Unlock()
if pc == nil {
return nil, fmt.Errorf("no pending capture")
}
return pc, nil
}
func (a *App) captureSelectedPNG(result CaptureResult, pc *pendingCapture) ([]byte, error) {
rect := result.Rect
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
cropped, err := a.capturer.CaptureRegion(captureRect)
if err != nil {
return nil, err
}
if len(result.Annotations) > 0 {
scaleX, scaleY := annotationScalesForCapture(cropped, rect, pc.Display.Scale)
cropped, err = application.ApplyAnnotationsWithScale(cropped, result.Annotations, scaleX, scaleY)
if err != nil {
return nil, err
}
}
return cropped, nil
}
func annotationScalesForCapture(pngBytes []byte, rect RegionRect, fallback float64) (float64, float64) {
if fallback <= 0 {
fallback = 1
}
scaleX := fallback
scaleY := fallback
cfg, err := png.DecodeConfig(bytes.NewReader(pngBytes))
if err != nil {
return scaleX, scaleY
}
if rect.W > 0 && cfg.Width > 0 {
scaleX = saneAnnotationScale(float64(cfg.Width)/float64(rect.W), fallback)
}
if rect.H > 0 && cfg.Height > 0 {
scaleY = saneAnnotationScale(float64(cfg.Height)/float64(rect.H), fallback)
}
return scaleX, scaleY
}
func saneAnnotationScale(scale, fallback float64) float64 {
if scale >= 0.25 && scale <= 8 {
return scale
}
return fallback
}
func (a *App) chooseSaveDirectory() (string, error) {
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Save screenshot to folder",
DefaultDirectory: userPicturesDir(),
CanCreateDirectories: true,
})
}
// ---------------------------------------------------------------------------
// 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 {
cfg.Normalize()
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)
}
// TestSSHConnection performs a minimal SSH handshake probe against the
// supplied configuration so the SettingsView's "Test connection" button
// can give the user immediate feedback.
func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
slog.Info("RPC TestSSHConnection",
"host", cfg.Host, "user", cfg.User, "port", cfg.Port,
"auth_method", cfg.AuthMethod,
"strict_host_key", cfg.StrictHostKey, "has_password", cfg.Password != "")
// Kerberos auth delegates to the system ssh binary (see KerberosUploader)
// so its probe path differs from the in-process client.
if cfg.IsKerberos() {
if err := sshpkg.TestKerberosConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection (kerberos) failed", "err", err)
return err
}
return nil
}
if err := sshpkg.TestConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection failed", "err", err)
return err
}
return nil
}
// TestFTPConnection performs a write/delete probe with the supplied FTP or
// SFTP configuration so Settings can verify both authentication and remote
// directory permissions before saving.
func (a *App) TestFTPConnection(cfg domain.FTPConfig) error {
slog.Info("RPC TestFTPConnection",
"protocol", cfg.Protocol,
"host", cfg.Host,
"user", cfg.User,
"port", cfg.Port,
"auth_method", cfg.AuthMethod,
"strict_host_key", cfg.StrictHostKey,
"has_password", cfg.Password != "")
uploader, err := ftppkg.NewUploader(cfg)
if err != nil {
return err
}
if err := uploader.TestConnection(a.ctx); err != nil {
slog.Error("RPC TestFTPConnection failed",
"protocol", cfg.Protocol,
"host", cfg.Host,
"user", cfg.User,
"err", err)
return err
}
return nil
}
// 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(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
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
}
// CopyRegionImage is invoked by the fallback Wails overlay when the user
// double-clicks the selected area.
func (a *App) CopyRegionImage(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runCopyImagePipeline(cropped)
}
// SaveRegionImage lets the fallback Wails overlay pick a native destination
// folder and writes the selected screenshot PNG there.
func (a *App) SaveRegionImage(result CaptureResult) (CaptureActionResult, error) {
pc, err := a.consumePendingCapture()
if err != nil {
return CaptureActionResult{}, err
}
a.dismissOverlay()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
a.capturing.Store(false)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
dir, err := a.chooseSaveDirectory()
if err != nil {
a.capturing.Store(false)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
if dir == "" {
a.capturing.Store(false)
a.dismissOverlay()
return CaptureActionResult{Completed: false}, nil
}
path, err := a.runSaveImagePipeline(cropped, dir)
a.capturing.Store(false)
a.dismissOverlay()
if err != nil {
return CaptureActionResult{}, err
}
return CaptureActionResult{Completed: true, Path: path}, 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(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runUploadPipeline(pc.Provider, cropped)
}
func (a *App) CopyNativeRegionImage(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runCopyImagePipeline(cropped)
}
func (a *App) SaveNativeRegionImage(result CaptureResult) (CaptureActionResult, error) {
pc, err := a.consumePendingCapture()
if err != nil {
return CaptureActionResult{}, err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
dir, err := a.chooseSaveDirectory()
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
if dir == "" {
return CaptureActionResult{Completed: false}, nil
}
path, err := a.runSaveImagePipeline(cropped, dir)
if err != nil {
return CaptureActionResult{}, err
}
return CaptureActionResult{Completed: true, Path: path}, nil
}
func (a *App) SaveNativeRegionImageToDir(result CaptureResult, dir string) (CaptureActionResult, error) {
pc, err := a.consumePendingCapture()
if err != nil {
return CaptureActionResult{}, err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
if dir == "" {
return CaptureActionResult{Completed: false}, nil
}
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
path, err := a.runSaveImagePipeline(cropped, dir)
if err != nil {
return CaptureActionResult{}, err
}
return CaptureActionResult{Completed: true, Path: path}, nil
}
// SaveRegionToRemote uploads the user-selected region via SCP to the
// configured SSH host. Driven by the Wails overlay's "save remote" button.
//
// The flow mirrors ConfirmRegion (S3 upload) but routes through
// runSaveRemotePipeline instead. We dismiss the overlay first so the
// upload progress (and any error toast) shows over the regular settings UI.
func (a *App) SaveRegionToRemote(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SaveRegionToRemote: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
slog.Info("SaveRegionToRemote: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SaveRegionToRemote: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
slog.Debug("SaveRegionToRemote: capture ok", "png_bytes", len(cropped))
return a.runSaveRemotePipeline(cropped)
}
// UploadRegionToFTP uploads the selected region through the configured FTP or
// SFTP destination. This is the non-macOS Wails overlay action.
func (a *App) UploadRegionToFTP(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("UploadRegionToFTP: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("UploadRegionToFTP: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runFTPUploadPipeline(cropped)
}
// SummarizeRegion uploads the selected screenshot to S3, sends the public URL
// to the configured multimodal LLM, and copies the resulting summary.
func (a *App) SummarizeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SummarizeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
slog.Info("SummarizeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SummarizeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runSummaryPipeline(cropped)
}
// ExtractTextRegion sends the selected screenshot to the configured OCR
// provider and copies the extracted text to the clipboard.
func (a *App) ExtractTextRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("ExtractTextRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
slog.Info("ExtractTextRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("ExtractTextRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runOCRPipeline(cropped)
}
// SaveNativeRegionToRemote is the macOS-native overlay equivalent of
// SaveRegionToRemote. The AppKit panel is already closed by the time this
// runs (see saveRemoteSelection in native_overlay_darwin.go), so we only
// have to release the dock icon afterwards.
func (a *App) SaveNativeRegionToRemote(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SaveNativeRegionToRemote: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
slog.Info("SaveNativeRegionToRemote: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SaveNativeRegionToRemote: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
slog.Debug("SaveNativeRegionToRemote: capture ok", "png_bytes", len(cropped))
return a.runSaveRemotePipeline(cropped)
}
// UploadNativeRegionToFTP is the macOS AppKit overlay equivalent of
// UploadRegionToFTP. The native panel has already closed when this runs.
func (a *App) UploadNativeRegionToFTP(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("UploadNativeRegionToFTP: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("UploadNativeRegionToFTP: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runFTPUploadPipeline(cropped)
}
// SummarizeNativeRegion is the macOS-native overlay equivalent of
// SummarizeRegion. The AppKit panel is already closed before this runs.
func (a *App) SummarizeNativeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SummarizeNativeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
slog.Info("SummarizeNativeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SummarizeNativeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runSummaryPipeline(cropped)
}
// ExtractTextNativeRegion is the macOS-native overlay equivalent of
// ExtractTextRegion. The AppKit panel is already closed before this runs.
func (a *App) ExtractTextNativeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("ExtractTextNativeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
slog.Info("ExtractTextNativeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("ExtractTextNativeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runOCRPipeline(cropped)
}
func parseNativeAnnotations(raw string) []application.Annotation {
if raw == "" {
return nil
}
var annotations []application.Annotation
if err := json.Unmarshal([]byte(raw), &annotations); err != nil {
slog.Warn("parse native annotations failed", "err", err)
return nil
}
return annotations
}
// 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()
a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
}
// 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()
a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
}
// 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)
}
func (a *App) emitOperationStatus(operation, phase, message, state string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "operation:status", OperationStatusPayload{
Operation: operation,
Phase: phase,
Message: message,
State: state,
})
}
switch state {
case "success":
showOperationStatus(phase, message, operationStatusSuccess)
hideOperationStatusAfter(1400 * time.Millisecond)
case "error":
showOperationStatus(phase, message, operationStatusError)
hideOperationStatusAfter(2600 * time.Millisecond)
default:
showOperationStatus(phase, message, operationStatusRunning)
}
}
// 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")
}