feat: add FTP and SFTP upload support
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user