// app.go wires the application service stack together and exposes // frontend-callable methods through Wails bindings. package main import ( "context" "encoding/json" "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" 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 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"` } // 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 } 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 { cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale) if err != nil { return nil, err } } return cropped, nil } 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 } // 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) } // 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) } // 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") }