1282 lines
39 KiB
Go
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")
|
|
}
|