feat: add FTP and SFTP upload support

This commit is contained in:
2026-07-12 10:04:21 +08:00
parent f1998fb23c
commit dd12521be2
22 changed files with 1466 additions and 44 deletions
+128 -2
View File
@@ -12,6 +12,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
@@ -23,6 +24,7 @@ import (
"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"
@@ -35,8 +37,8 @@ import (
//
// 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.
// real work to application services constructed on demand for the selected
// storage destination.
type App struct {
ctx context.Context
@@ -400,6 +402,56 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
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
@@ -689,6 +741,33 @@ func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
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()
@@ -920,6 +999,30 @@ func (a *App) SaveRegionToRemote(result CaptureResult) error {
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 {
@@ -1008,6 +1111,29 @@ func (a *App) SaveNativeRegionToRemote(result CaptureResult) error {
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 {