diff --git a/README.md b/README.md index 7cd0518..a0b50ab 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ SnapGo 是一个为“截完图马上发链接”而做的轻量截图工具。 - 全局快捷键触发:无论当前在什么应用里,都能快速发起截图 - 菜单栏常驻:随用随取,不打断主流程 - 支持 S3 兼容存储:可接入 AWS S3、MinIO、Cloudflare R2、Backblaze B2 等 +- 支持 FTP / SFTP:可通过独立截图操作上传到文件服务器,并复制远端路径 - 支持自定义公开地址:可配合 CDN 或自定义域名使用 - 上传失败自动兜底:至少保住截图文件,不会白截 @@ -57,11 +58,11 @@ SnapGo 是一个为“截完图马上发链接”而做的轻量截图工具。 ## 使用方式 -1. 打开应用,填写你的 S3 兼容对象存储配置 +1. 打开应用,填写 S3、FTP / SFTP 或 SSH 远端配置 2. 保存并测试连接 3. 按下默认快捷键 `cmd+shift+a` 4. 框选截图区域 -5. 直接粘贴刚刚自动复制好的图片链接 +5. 点击目标上传按钮,直接粘贴自动复制的图片链接或远端路径 ## 当前体验 diff --git a/app.go b/app.go index a9fb1e8..53e67ab 100644 --- a/app.go +++ b/app.go @@ -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 { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9076ea9..2d6679e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -22,6 +22,7 @@ import { CopyRegionImage, SaveRegionImage, SaveRegionToRemote, + UploadRegionToFTP, SummarizeRegion, ExtractTextRegion, CancelRegion, @@ -59,7 +60,7 @@ async function loadThemePreference() { // `SettingsTab` is hoisted to the App shell so the sidebar (which lives // here) and the inner SettingsView (which renders the matching card) can // share a single source of truth without an event bus. -type SettingsTab = 'general' | 's3' | 'ssh' | 'llm' | 'ocr' +type SettingsTab = 'general' | 's3' | 'ftp' | 'ssh' | 'llm' | 'ocr' const activeTab = ref('general') // Sidebar entries are declarative so adding a destination type later is @@ -67,6 +68,7 @@ const activeTab = ref('general') const sidebarItems: Array<{ id: SettingsTab; label: string }> = [ { id: 'general', label: '通用设置' }, { id: 's3', label: '对象存储' }, + { id: 'ftp', label: 'FTP / SFTP' }, { id: 'ssh', label: '远程主机' }, { id: 'llm', label: '智能识图' }, { id: 'ocr', label: '文字提取' }, @@ -184,6 +186,16 @@ async function onOverlaySaveRemote(rect: OverlayResult) { } } +async function onOverlayUploadFTP(rect: OverlayResult) { + mode.value = 'settings' + overlayPayload.value = null + try { + await UploadRegionToFTP(rect as any) + } catch { + /* Surfaced via upload:failure */ + } +} + async function onOverlaySummarize(rect: OverlayResult) { mode.value = 'settings' overlayPayload.value = null @@ -275,6 +287,7 @@ onUnmounted(() => { @copy="onOverlayCopy" @save="onOverlaySave" @save-remote="onOverlaySaveRemote" + @upload-ftp="onOverlayUploadFTP" @summarize="onOverlaySummarize" @ocr="onOverlayOCR" @cancel="onOverlayCancel" diff --git a/frontend/src/assets/icons/ftp.svg b/frontend/src/assets/icons/ftp.svg new file mode 100644 index 0000000..3f41f61 --- /dev/null +++ b/frontend/src/assets/icons/ftp.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/views/CaptureOverlay.vue b/frontend/src/views/CaptureOverlay.vue index 23f147c..0b28a3c 100644 --- a/frontend/src/views/CaptureOverlay.vue +++ b/frontend/src/views/CaptureOverlay.vue @@ -8,6 +8,7 @@ import cancelIcon from '../assets/icons/cancel.svg?raw' import copyIcon from '../assets/icons/copy.svg?raw' import saveIcon from '../assets/icons/save-local.svg?raw' import saveRemoteIcon from '../assets/icons/save-remote.svg?raw' +import ftpIcon from '../assets/icons/ftp.svg?raw' import uploadIcon from '../assets/icons/upload.svg?raw' interface Props { @@ -54,6 +55,10 @@ const emit = defineEmits<{ e: 'save-remote', payload: { rect: Rect; annotations: Annotation[] } ): void + ( + e: 'upload-ftp', + payload: { rect: Rect; annotations: Annotation[] } + ): void ( e: 'summarize', payload: { rect: Rect; annotations: Annotation[] } @@ -142,7 +147,7 @@ const sizeLabel = computed(() => { }) const MARK_TOOLBAR_W = 178 -const ACTION_TOOLBAR_W = 252 +const ACTION_TOOLBAR_W = 288 const TOOLBAR_GROUP_GAP = 8 const toolOrder: Tool[] = ['pen', 'rect', 'ellipse', 'text'] @@ -571,6 +576,10 @@ function onSaveRemote() { emitAction('save-remote') } +function onUploadFTP() { + emitAction('upload-ftp') +} + function onSummarize() { emitAction('summarize') } @@ -579,7 +588,16 @@ function onOCR() { emitAction('ocr') } -function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize' | 'ocr') { +function emitAction( + action: + | 'confirm' + | 'copy' + | 'save' + | 'save-remote' + | 'upload-ftp' + | 'summarize' + | 'ocr' +) { if (!rect.value) return commitTextDraft() const payload = { @@ -590,6 +608,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summa if (action === 'copy') emit('copy', payload) if (action === 'save') emit('save', payload) if (action === 'save-remote') emit('save-remote', payload) + if (action === 'upload-ftp') emit('upload-ftp', payload) if (action === 'summarize') emit('summarize', payload) if (action === 'ocr') emit('ocr', payload) } @@ -995,11 +1014,18 @@ onUnmounted(() => { /> + + + +

SSH / SCP destination

diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index e80d5c7..b4a4066 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -47,4 +47,10 @@ export function SummarizeRegion(arg1:main.CaptureResult):Promise; export function TestConnection(arg1:domain.S3Config):Promise; +export function TestFTPConnection(arg1:domain.FTPConfig):Promise; + export function TestSSHConnection(arg1:domain.SSHConfig):Promise; + +export function UploadNativeRegionToFTP(arg1:main.CaptureResult):Promise; + +export function UploadRegionToFTP(arg1:main.CaptureResult):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 2da5701..f3da96a 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -90,6 +90,18 @@ export function TestConnection(arg1) { return window['go']['main']['App']['TestConnection'](arg1); } +export function TestFTPConnection(arg1) { + return window['go']['main']['App']['TestFTPConnection'](arg1); +} + export function TestSSHConnection(arg1) { return window['go']['main']['App']['TestSSHConnection'](arg1); } + +export function UploadNativeRegionToFTP(arg1) { + return window['go']['main']['App']['UploadNativeRegionToFTP'](arg1); +} + +export function UploadRegionToFTP(arg1) { + return window['go']['main']['App']['UploadRegionToFTP'](arg1); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 3dada1a..b61be30 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -179,6 +179,36 @@ export namespace domain { return a; } } + export class FTPConfig { + protocol: string; + host: string; + port: number; + user: string; + authMethod: string; + password: string; + pathPrefix: string; + strictHostKey: boolean; + knownHostsPath: string; + connectTimeoutSecs: number; + + static createFrom(source: any = {}) { + return new FTPConfig(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.protocol = source["protocol"]; + this.host = source["host"]; + this.port = source["port"]; + this.user = source["user"]; + this.authMethod = source["authMethod"]; + this.password = source["password"]; + this.pathPrefix = source["pathPrefix"]; + this.strictHostKey = source["strictHostKey"]; + this.knownHostsPath = source["knownHostsPath"]; + this.connectTimeoutSecs = source["connectTimeoutSecs"]; + } + } export class SSHConfig { host: string; port: number; @@ -238,6 +268,7 @@ export namespace domain { theme: string; s3: S3Config; ssh: SSHConfig; + ftp: FTPConfig; llm: LLMConfig; ocr: OCRConfig; @@ -251,6 +282,7 @@ export namespace domain { this.theme = source["theme"]; this.s3 = this.convertValues(source["s3"], S3Config); this.ssh = this.convertValues(source["ssh"], SSHConfig); + this.ftp = this.convertValues(source["ftp"], FTPConfig); this.llm = this.convertValues(source["llm"], LLMConfig); this.ocr = this.convertValues(source["ocr"], OCRConfig); } @@ -278,6 +310,7 @@ export namespace domain { + } diff --git a/go.mod b/go.mod index 5816880..095ce2b 100644 --- a/go.mod +++ b/go.mod @@ -7,11 +7,13 @@ require ( github.com/aws/aws-sdk-go-v2 v1.30.3 github.com/aws/aws-sdk-go-v2/credentials v1.17.27 github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2 + github.com/jlaffaye/ftp v0.2.0 github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 + github.com/pkg/sftp v1.13.10 github.com/wailsapp/wails/v2 v2.12.0 golang.design/x/clipboard v0.7.0 golang.design/x/hotkey v0.4.1 - golang.org/x/crypto v0.33.0 + golang.org/x/crypto v0.41.0 golang.org/x/image v0.12.0 ) @@ -32,8 +34,11 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jezek/xgb v1.1.0 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect @@ -54,7 +59,7 @@ require ( github.com/wailsapp/mimetype v1.4.1 // indirect golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index fc5fc64..5451c2e 100644 --- a/go.sum +++ b/go.sum @@ -41,12 +41,20 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk= github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg= +github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI= github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 h1:YOp8St+CM/AQ9Vp4XYm4272E77MptJDHkwypQHIRl9Q= github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237/go.mod h1:e7qQlOY68wOz4b82D7n+DdaptZAi+SHW0+yKiWZzEYE= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -75,6 +83,8 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -106,8 +116,8 @@ golang.design/x/mainthread v0.3.0/go.mod h1:vYX7cF2b3pTJMGM/hc13NmN6kblKnf4/IyvH golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU= golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= @@ -126,8 +136,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -144,21 +154,21 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= diff --git a/internal/application/capture_ftp.go b/internal/application/capture_ftp.go new file mode 100644 index 0000000..da563f4 --- /dev/null +++ b/internal/application/capture_ftp.go @@ -0,0 +1,145 @@ +// capture_ftp.go — application-level FTP/SFTP upload orchestration. +// +// Design rationale: +// - FTP/SFTP returns a remote filesystem path, not necessarily a public URL, +// so this flow stays separate from the OSSProvider-based S3 pipeline. +// - A tiny uploader interface keeps protocol libraries out of the +// application layer and makes the upload/copy/notify sequence unit-testable. +// - The dated filename layout is shared with SSH/SCP while FTP-specific path +// validation prevents an invalid prefix from escaping the login root. +package application + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/mmmy/snapgo/internal/domain" + "github.com/mmmy/snapgo/internal/infrastructure/clipboard" +) + +// ftpSvcLog returns an application-layer FTP logger bound to the current +// default slog handler. +func ftpSvcLog() *slog.Logger { return slog.Default().With("component", "application.ftp") } + +// FTPUploader abstracts both FTP and SFTP adapters. +type FTPUploader interface { + // Upload stores data at remoteRelPath, relative to the authenticated + // account's login root. + Upload(ctx context.Context, remoteRelPath string, data []byte) error +} + +// CaptureAndFTPService wires captured PNG bytes → FTP/SFTP → clipboard → +// notification. +type CaptureAndFTPService struct { + Uploader FTPUploader + Clipboard clipboard.Writer + Notifier Notifier + Cfg domain.FTPConfig +} + +// ExecuteWithBytes uploads previously captured PNG bytes and copies the exact +// remote path semantics shown in Settings: FTP paths are rooted at "/", while +// SFTP paths are relative to the authenticated user's home directory. +func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error { + if s.Uploader == nil { + s.notifyFailure("FTP/SFTP not configured") + return fmt.Errorf("ftp uploader is nil") + } + if len(pngBytes) == 0 { + s.notifyFailure("empty screenshot") + return fmt.Errorf("empty screenshot") + } + + relPath, err := buildFTPRemotePath(s.Cfg.PathPrefix) + if err != nil { + s.notifyFailure(err.Error()) + return err + } + + protocol := normalizedFTPProtocol(s.Cfg.Protocol) + start := time.Now() + ftpSvcLog().Info("file-transfer pipeline start", + "protocol", protocol, + "host", s.Cfg.Host, + "user", s.Cfg.User, + "port", s.Cfg.Port, + "size", len(pngBytes), + "remote_path", relPath, + "strict_host_key", s.Cfg.StrictHostKey) + + if err := s.Uploader.Upload(ctx, relPath, pngBytes); err != nil { + ftpSvcLog().Error("file-transfer upload failed", + "protocol", protocol, + "remote_path", relPath, + "elapsed", time.Since(start), + "err", err) + s.notifyFailure(strings.ToUpper(protocol) + " upload failed: " + err.Error()) + return err + } + + clipText := ftpShareText(protocol, relPath) + if s.Clipboard != nil { + if err := s.Clipboard.WriteText(clipText); err != nil { + s.notifyFailure("clipboard write failed: " + err.Error()) + return err + } + } + if s.Notifier != nil { + s.Notifier.NotifySuccess(clipText) + } + ftpSvcLog().Info("file-transfer pipeline done", + "protocol", protocol, + "remote_path", relPath, + "total_elapsed", time.Since(start)) + return nil +} + +// buildFTPRemotePath validates the user-controlled prefix before reusing the +// existing dated remote-path generator shared with SSH/SCP. +func buildFTPRemotePath(prefix string) (string, error) { + if err := validateFTPPathPrefix(prefix); err != nil { + return "", err + } + prefix = strings.TrimSpace(prefix) + prefix = strings.TrimPrefix(prefix, "~") + prefix = strings.TrimLeft(prefix, "/") + return buildRemoteRelPath(prefix), nil +} + +func validateFTPPathPrefix(prefix string) error { + if strings.ContainsAny(prefix, "\x00\r\n\\") { + return fmt.Errorf("FTP/SFTP path contains an invalid character") + } + prefix = strings.TrimSpace(prefix) + prefix = strings.TrimPrefix(prefix, "~") + prefix = strings.TrimLeft(prefix, "/") + for _, segment := range strings.Split(prefix, "/") { + if segment == ".." { + return fmt.Errorf("FTP/SFTP path must stay inside the login directory") + } + } + return nil +} + +func normalizedFTPProtocol(protocol string) string { + if protocol == domain.FTPProtocolSFTP { + return domain.FTPProtocolSFTP + } + return domain.FTPProtocolFTP +} + +func ftpShareText(protocol, relPath string) string { + if protocol == domain.FTPProtocolSFTP { + return "~/" + relPath + } + return "/" + relPath +} + +func (s *CaptureAndFTPService) notifyFailure(reason string) { + if s.Notifier != nil { + s.Notifier.NotifyFailure(reason) + } +} diff --git a/internal/application/capture_ftp_test.go b/internal/application/capture_ftp_test.go new file mode 100644 index 0000000..92eba5a --- /dev/null +++ b/internal/application/capture_ftp_test.go @@ -0,0 +1,131 @@ +package application + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/mmmy/snapgo/internal/domain" +) + +type fakeFTPUploader struct { + path string + data []byte + err error +} + +func (f *fakeFTPUploader) Upload(_ context.Context, remoteRelPath string, data []byte) error { + f.path = remoteRelPath + f.data = append([]byte(nil), data...) + return f.err +} + +func TestCaptureAndFTPServiceUploadsAndCopiesFTPPath(t *testing.T) { + uploader := &fakeFTPUploader{} + clip := &fakeClipboard{} + notifier := &fakeNotifier{} + svc := &CaptureAndFTPService{ + Uploader: uploader, + Clipboard: clip, + Notifier: notifier, + Cfg: domain.FTPConfig{ + Protocol: domain.FTPProtocolFTP, + PathPrefix: "snapgo/", + }, + } + + if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil { + t.Fatalf("upload FTP screenshot: %v", err) + } + if string(uploader.data) != "png" { + t.Fatalf("expected PNG bytes, got %q", string(uploader.data)) + } + if !strings.HasPrefix(uploader.path, "snapgo/") || !strings.HasSuffix(uploader.path, ".png") { + t.Fatalf("unexpected remote path %q", uploader.path) + } + if clip.text != "/"+uploader.path { + t.Fatalf("expected FTP root path copied, got %q", clip.text) + } + if notifier.success != clip.text { + t.Fatalf("expected success notification %q, got %q", clip.text, notifier.success) + } +} + +func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) { + uploader := &fakeFTPUploader{} + clip := &fakeClipboard{} + svc := &CaptureAndFTPService{ + Uploader: uploader, + Clipboard: clip, + Cfg: domain.FTPConfig{ + Protocol: domain.FTPProtocolSFTP, + PathPrefix: "images", + }, + } + + if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil { + t.Fatalf("upload SFTP screenshot: %v", err) + } + if clip.text != "~/"+uploader.path { + t.Fatalf("expected SFTP home path copied, got %q", clip.text) + } +} + +func TestCaptureAndFTPServiceRejectsTraversalBeforeUpload(t *testing.T) { + uploader := &fakeFTPUploader{} + notifier := &fakeNotifier{} + svc := &CaptureAndFTPService{ + Uploader: uploader, + Notifier: notifier, + Cfg: domain.FTPConfig{ + Protocol: domain.FTPProtocolSFTP, + PathPrefix: "../../outside", + }, + } + + err := svc.ExecuteWithBytes(context.Background(), []byte("png")) + if err == nil { + t.Fatal("expected traversal prefix to fail") + } + if uploader.path != "" { + t.Fatalf("uploader should not run, got path %q", uploader.path) + } + if notifier.failure == "" { + t.Fatal("expected failure notification") + } +} + +func TestCaptureAndFTPServiceSurfacesUploadFailure(t *testing.T) { + want := errors.New("server unavailable") + uploader := &fakeFTPUploader{err: want} + clip := &fakeClipboard{} + notifier := &fakeNotifier{} + svc := &CaptureAndFTPService{ + Uploader: uploader, + Clipboard: clip, + Notifier: notifier, + Cfg: domain.FTPConfig{Protocol: domain.FTPProtocolFTP}, + } + + err := svc.ExecuteWithBytes(context.Background(), []byte("png")) + if !errors.Is(err, want) { + t.Fatalf("expected %v, got %v", want, err) + } + if clip.text != "" { + t.Fatalf("clipboard should remain empty, got %q", clip.text) + } + if !strings.Contains(notifier.failure, "server unavailable") { + t.Fatalf("unexpected failure notification %q", notifier.failure) + } +} + +func TestBuildFTPRemotePathNormalizesLoginRootMarkers(t *testing.T) { + remotePath, err := buildFTPRemotePath(" ~////snapgo/ ") + if err != nil { + t.Fatalf("build remote path: %v", err) + } + if strings.HasPrefix(remotePath, "/") || !strings.HasPrefix(remotePath, "snapgo/") { + t.Fatalf("expected relative snapgo path, got %q", remotePath) + } +} diff --git a/internal/domain/config.go b/internal/domain/config.go index 8fe421e..b862411 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -1,8 +1,8 @@ // Package domain — configuration types. // -// S3Config and SSHConfig are intentionally split into their own structs so -// that future providers can introduce their own configuration types side- -// by-side without polluting the core domain types file. +// S3Config, SSHConfig, and FTPConfig are intentionally split into their own +// structs so future providers can introduce configuration types side-by-side +// without polluting the core domain types file. package domain // S3Config describes the connection parameters for any S3-compatible @@ -72,6 +72,32 @@ type SSHConfig struct { ConnectTimeoutSecs int `json:"connectTimeoutSecs"` } +// FTPConfig describes a file-transfer destination reached through FTP or +// SFTP. It is intentionally separate from SSHConfig: SFTP uses the SSH +// transport, but it is a different file-transfer protocol from the existing +// SCP action and has its own remote-root semantics. +// +// Field design notes: +// - Protocol is either "ftp" or "sftp". Their default ports are 21 and 22. +// - AuthMethod is used only by SFTP. "password" uses Password, while "key" +// reuses the local ssh-agent and ~/.ssh/id_* discovery used by SSH/SCP. +// - PathPrefix is relative to the account's login root. The application +// rejects absolute paths and traversal segments before uploading. +// - StrictHostKey and KnownHostsPath apply only to SFTP. Plain FTP has no +// host-key mechanism and sends credentials and data without encryption. +type FTPConfig struct { + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + AuthMethod string `json:"authMethod"` + Password string `json:"password"` + PathPrefix string `json:"pathPrefix"` + StrictHostKey bool `json:"strictHostKey"` + KnownHostsPath string `json:"knownHostsPath"` + ConnectTimeoutSecs int `json:"connectTimeoutSecs"` +} + // LLMProviderConfig stores one OpenAI-compatible multimodal chat endpoint. // // The three built-in providers (Qwen, Doubao/Ark, OpenAI-compatible) all use @@ -145,6 +171,12 @@ const ( SSHAuthKerberos = "kerberos" ) +// File-transfer protocol identifiers stored in FTPConfig.Protocol. +const ( + FTPProtocolFTP = "ftp" + FTPProtocolSFTP = "sftp" +) + // Built-in LLM provider identifiers. const ( LLMProviderQwen = "qwen" @@ -181,8 +213,8 @@ func (c SSHConfig) IsKerberos() bool { // AppConfig is the top-level on-disk configuration document. // -// We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS, -// COS, FTP) only requires a new sibling field rather than a schema rewrite. +// We keep S3 / SSH / FTP nested so adding another provider later only requires +// a new sibling field rather than a schema rewrite. type AppConfig struct { // Hotkey describes the global shortcut that triggers a capture. // Stored as a human-readable string like "cmd+shift+a"; parsing happens @@ -199,6 +231,9 @@ type AppConfig struct { // destination triggered by the save-remote toolbar button. SSH SSHConfig `json:"ssh"` + // FTP holds the destination used by the dedicated FTP/SFTP toolbar action. + FTP FTPConfig `json:"ftp"` + // LLM holds provider settings for the "copy summary" screenshot action. LLM LLMConfig `json:"llm"` @@ -221,6 +256,14 @@ func DefaultAppConfig() AppConfig { ConnectTimeoutSecs: 10, StrictHostKey: false, }, + FTP: FTPConfig{ + Protocol: FTPProtocolFTP, + Port: 21, + AuthMethod: SSHAuthPassword, + PathPrefix: "snapgo/", + ConnectTimeoutSecs: 10, + StrictHostKey: false, + }, LLM: DefaultLLMConfig(), OCR: DefaultOCRConfig(), } @@ -315,6 +358,25 @@ func (c *AppConfig) Normalize() { if c.SSH.ConnectTimeoutSecs == 0 { c.SSH.ConnectTimeoutSecs = 10 } + if c.FTP.Protocol != FTPProtocolFTP && c.FTP.Protocol != FTPProtocolSFTP { + c.FTP.Protocol = FTPProtocolFTP + } + if c.FTP.Port == 0 { + if c.FTP.Protocol == FTPProtocolSFTP { + c.FTP.Port = 22 + } else { + c.FTP.Port = 21 + } + } + if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey { + c.FTP.AuthMethod = SSHAuthPassword + } + if c.FTP.PathPrefix == "" { + c.FTP.PathPrefix = "snapgo/" + } + if c.FTP.ConnectTimeoutSecs == 0 { + c.FTP.ConnectTimeoutSecs = 10 + } defaultLLM := DefaultLLMConfig() if c.LLM.ActiveProvider == "" { @@ -409,6 +471,14 @@ func (c AppConfig) IsSSHConfigured() bool { return c.SSH.Host != "" && c.SSH.User != "" } +// IsFTPConfigured reports whether the FTP/SFTP destination has the minimum +// fields required to attempt a connection. Password is not required because +// SFTP key authentication and password-less FTP accounts are both valid. +func (c AppConfig) IsFTPConfigured() bool { + return (c.FTP.Protocol == FTPProtocolFTP || c.FTP.Protocol == FTPProtocolSFTP) && + c.FTP.Host != "" && c.FTP.User != "" +} + // ActiveLLMProvider returns the selected provider config plus a boolean // indicating whether the selection exists. func (c AppConfig) ActiveLLMProvider() (string, LLMProviderConfig, bool) { diff --git a/internal/domain/config_ftp_test.go b/internal/domain/config_ftp_test.go new file mode 100644 index 0000000..c3ead50 --- /dev/null +++ b/internal/domain/config_ftp_test.go @@ -0,0 +1,50 @@ +package domain + +import "testing" + +func TestDefaultAppConfigIncludesFTPDefaults(t *testing.T) { + cfg := DefaultAppConfig() + if cfg.FTP.Protocol != FTPProtocolFTP { + t.Fatalf("expected default FTP protocol, got %q", cfg.FTP.Protocol) + } + if cfg.FTP.Port != 21 { + t.Fatalf("expected default FTP port 21, got %d", cfg.FTP.Port) + } + if cfg.FTP.PathPrefix != "snapgo/" { + t.Fatalf("expected default path prefix, got %q", cfg.FTP.PathPrefix) + } + if cfg.FTP.ConnectTimeoutSecs != 10 { + t.Fatalf("expected 10 second timeout, got %d", cfg.FTP.ConnectTimeoutSecs) + } +} + +func TestNormalizeUsesSFTPDefaultPort(t *testing.T) { + cfg := AppConfig{ + FTP: FTPConfig{ + Protocol: FTPProtocolSFTP, + }, + } + cfg.Normalize() + if cfg.FTP.Port != 22 { + t.Fatalf("expected SFTP port 22, got %d", cfg.FTP.Port) + } + if cfg.FTP.AuthMethod != SSHAuthPassword { + t.Fatalf("expected password auth default, got %q", cfg.FTP.AuthMethod) + } +} + +func TestIsFTPConfiguredRequiresProtocolHostAndUser(t *testing.T) { + cfg := DefaultAppConfig() + if cfg.IsFTPConfigured() { + t.Fatal("empty FTP destination should not be configured") + } + cfg.FTP.Host = "files.example.com" + cfg.FTP.User = "snapgo" + if !cfg.IsFTPConfigured() { + t.Fatal("host and user should be enough for a normalized FTP config") + } + cfg.FTP.Protocol = "invalid" + if cfg.IsFTPConfigured() { + t.Fatal("invalid protocol should not be configured") + } +} diff --git a/internal/infrastructure/ftp/uploader.go b/internal/infrastructure/ftp/uploader.go new file mode 100644 index 0000000..a96509a --- /dev/null +++ b/internal/infrastructure/ftp/uploader.go @@ -0,0 +1,288 @@ +// Package ftp provides the FTP side of SnapGo's FTP/SFTP destination. +// +// Design rationale: +// - Plain FTP needs a real protocol client for PASV/EPSV handling; hand- +// rolling those details would be fragile across common servers. +// - SFTP is delegated to the sibling SSH adapter so authentication, agent, +// key discovery, and known_hosts behaviour stay consistent with SCP. +// - Both implementations upload to a temporary name and rename only after +// the payload is complete, preventing a failed transfer from exposing a +// partially written screenshot at the final path. +package ftp + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "net" + "path" + "strings" + "sync" + "time" + + ftpclient "github.com/jlaffaye/ftp" + + "github.com/mmmy/snapgo/internal/domain" + sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh" +) + +// RemoteUploader is the common capability exposed to app.go for FTP and +// SFTP. The application service only needs Upload; TestConnection remains an +// infrastructure concern used by the Settings probe RPC. +type RemoteUploader interface { + Upload(ctx context.Context, remoteRelPath string, data []byte) error + TestConnection(ctx context.Context) error +} + +// NewUploader validates cfg and returns the adapter for its selected protocol. +func NewUploader(cfg domain.FTPConfig) (RemoteUploader, error) { + normalized, err := normalizeConfig(cfg) + if err != nil { + return nil, err + } + if normalized.Protocol == domain.FTPProtocolSFTP { + return sshpkg.NewSFTPUploader(normalized), nil + } + return &plainUploader{cfg: normalized}, nil +} + +type plainUploader struct { + cfg domain.FTPConfig +} + +func ftpLog() *slog.Logger { return slog.Default().With("component", "ftp") } + +func normalizeConfig(cfg domain.FTPConfig) (domain.FTPConfig, error) { + cfg.Protocol = strings.ToLower(strings.TrimSpace(cfg.Protocol)) + if cfg.Protocol == "" { + cfg.Protocol = domain.FTPProtocolFTP + } + if cfg.Protocol != domain.FTPProtocolFTP && cfg.Protocol != domain.FTPProtocolSFTP { + return domain.FTPConfig{}, fmt.Errorf("file transfer: protocol must be ftp or sftp") + } + cfg.Host = strings.TrimSpace(cfg.Host) + cfg.User = strings.TrimSpace(cfg.User) + if cfg.Host == "" || cfg.User == "" { + return domain.FTPConfig{}, fmt.Errorf("file transfer: host and user are required") + } + if cfg.Port <= 0 { + if cfg.Protocol == domain.FTPProtocolSFTP { + cfg.Port = 22 + } else { + cfg.Port = 21 + } + } + if cfg.Port > 65535 { + return domain.FTPConfig{}, fmt.Errorf("file transfer: port must be between 1 and 65535") + } + if cfg.ConnectTimeoutSecs <= 0 { + cfg.ConnectTimeoutSecs = 10 + } + if cfg.AuthMethod == "" { + cfg.AuthMethod = domain.SSHAuthPassword + } + if cfg.Protocol == domain.FTPProtocolSFTP && + cfg.AuthMethod != domain.SSHAuthPassword && cfg.AuthMethod != domain.SSHAuthKey { + return domain.FTPConfig{}, fmt.Errorf("sftp: auth method must be password or key") + } + return cfg, nil +} + +func (u *plainUploader) dial(ctx context.Context) (*ftpclient.ServerConn, error) { + timeout := time.Duration(u.cfg.ConnectTimeoutSecs) * time.Second + addr := net.JoinHostPort(u.cfg.Host, fmt.Sprintf("%d", u.cfg.Port)) + ftpLog().Info("FTP dial start", + "addr", addr, + "user", u.cfg.User, + "timeout", timeout, + "has_password", u.cfg.Password != "") + conn, err := ftpclient.Dial( + addr, + ftpclient.DialWithDialFunc(contextDialFunc(ctx, timeout)), + ftpclient.DialWithShutTimeout(timeout), + ) + if err != nil { + return nil, fmt.Errorf("ftp: dial %s: %w", addr, err) + } + if err := conn.Login(u.cfg.User, u.cfg.Password); err != nil { + _ = conn.Quit() + return nil, fmt.Errorf("ftp: login: %w", err) + } + return conn, nil +} + +// Upload stores one screenshot through plain FTP. +func (u *plainUploader) Upload(ctx context.Context, remoteRelPath string, data []byte) error { + cleaned, err := validateRemoteRelativePath(remoteRelPath) + if err != nil { + return err + } + conn, err := u.dial(ctx) + if err != nil { + return err + } + defer func() { _ = conn.Quit() }() + + if err := enterFTPDirectory(conn, path.Dir(cleaned)); err != nil { + return err + } + base := path.Base(cleaned) + + tempName := temporaryRemoteName(base) + removeTemp := true + defer func() { + if removeTemp { + _ = conn.Delete(tempName) + } + }() + + if err := conn.Stor(tempName, &contextReader{ctx: ctx, reader: bytes.NewReader(data)}); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("ftp: store %s: %w", cleaned, err) + } + if err := conn.Rename(tempName, base); err != nil { + return fmt.Errorf("ftp: commit %s: %w", cleaned, err) + } + removeTemp = false + ftpLog().Info("FTP upload ok", "remote_path", cleaned, "size", len(data)) + return nil +} + +// TestConnection writes and removes a small probe under the configured path, +// proving that credentials and directory permissions are both usable. +func (u *plainUploader) TestConnection(ctx context.Context) error { + probe, err := probeRemotePath(u.cfg.PathPrefix) + if err != nil { + return err + } + conn, err := u.dial(ctx) + if err != nil { + return err + } + defer func() { _ = conn.Quit() }() + + if err := enterFTPDirectory(conn, path.Dir(probe)); err != nil { + return err + } + base := path.Base(probe) + if err := conn.Stor(base, strings.NewReader("snapgo")); err != nil { + return fmt.Errorf("ftp probe: write: %w", err) + } + if err := conn.Delete(base); err != nil { + return fmt.Errorf("ftp probe: delete: %w", err) + } + return nil +} + +// enterFTPDirectory walks to dir relative to the login root, creating missing +// segments. +func enterFTPDirectory(conn *ftpclient.ServerConn, dir string) error { + dir = strings.Trim(dir, "/") + if dir == "" || dir == "." { + return nil + } + for _, segment := range strings.Split(dir, "/") { + if err := conn.ChangeDir(segment); err == nil { + continue + } + if err := conn.MakeDir(segment); err != nil { + // A concurrent uploader may have created the directory after our + // failed CWD. Retrying CWD distinguishes that race from a real error. + if retryErr := conn.ChangeDir(segment); retryErr == nil { + continue + } + return fmt.Errorf("ftp: create directory %q: %w", segment, err) + } + if err := conn.ChangeDir(segment); err != nil { + return fmt.Errorf("ftp: enter directory %q: %w", segment, err) + } + } + return nil +} + +func validateRemoteRelativePath(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, "~") { + return "", fmt.Errorf("file transfer: remote path must be relative to the login directory") + } + if strings.ContainsAny(value, "\x00\r\n\\") { + return "", fmt.Errorf("file transfer: remote path contains an invalid character") + } + for _, segment := range strings.Split(value, "/") { + if segment == ".." { + return "", fmt.Errorf("file transfer: remote path must not contain '..'") + } + } + cleaned := path.Clean(value) + if cleaned == "." || path.Base(cleaned) == "." || path.Base(cleaned) == ".." { + return "", fmt.Errorf("file transfer: remote filename is empty") + } + return cleaned, nil +} + +func probeRemotePath(prefix string) (string, error) { + prefix = strings.TrimSpace(prefix) + prefix = strings.TrimPrefix(prefix, "~") + prefix = strings.TrimLeft(prefix, "/") + probe := fmt.Sprintf(".snapgo-probe-%d", time.Now().UnixNano()) + return validateRemoteRelativePath(path.Join(prefix, probe)) +} + +func temporaryRemoteName(base string) string { + return fmt.Sprintf(".%s.snapgo-upload-%d", base, time.Now().UnixNano()) +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func contextDialFunc(ctx context.Context, timeout time.Duration) func(string, string) (net.Conn, error) { + dialer := &net.Dialer{Timeout: timeout} + return func(network, address string) (net.Conn, error) { + conn, err := dialer.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + wrapped := &cancelableConn{ + Conn: conn, + done: make(chan struct{}), + } + go func() { + select { + case <-ctx.Done(): + _ = wrapped.Close() + case <-wrapped.done: + } + }() + return wrapped, nil + } +} + +type cancelableConn struct { + net.Conn + done chan struct{} + once sync.Once +} + +func (c *cancelableConn) Close() error { + var err error + c.once.Do(func() { + close(c.done) + err = c.Conn.Close() + }) + return err +} + +func (r *contextReader) Read(p []byte) (int, error) { + select { + case <-r.ctx.Done(): + return 0, r.ctx.Err() + default: + return r.reader.Read(p) + } +} diff --git a/internal/infrastructure/ftp/uploader_test.go b/internal/infrastructure/ftp/uploader_test.go new file mode 100644 index 0000000..fd2cb64 --- /dev/null +++ b/internal/infrastructure/ftp/uploader_test.go @@ -0,0 +1,34 @@ +package ftp + +import "testing" + +func TestValidateRemoteRelativePath(t *testing.T) { + valid, err := validateRemoteRelativePath("snapgo/2026/07/image.png") + if err != nil { + t.Fatalf("valid path rejected: %v", err) + } + if valid != "snapgo/2026/07/image.png" { + t.Fatalf("unexpected normalized path %q", valid) + } + + invalid := []string{ + "", + "/absolute/image.png", + "~/image.png", + "../image.png", + "snapgo/../../image.png", + "snapgo\\image.png", + "snapgo/image.png\nnext", + } + for _, value := range invalid { + if _, err := validateRemoteRelativePath(value); err == nil { + t.Errorf("expected %q to be rejected", value) + } + } +} + +func TestProbeRemotePathRejectsTraversalPrefix(t *testing.T) { + if _, err := probeRemotePath("../../outside"); err == nil { + t.Fatal("expected traversal prefix to fail") + } +} diff --git a/internal/infrastructure/ssh/sftp_uploader.go b/internal/infrastructure/ssh/sftp_uploader.go new file mode 100644 index 0000000..445441b --- /dev/null +++ b/internal/infrastructure/ssh/sftp_uploader.go @@ -0,0 +1,213 @@ +package ssh + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path" + "strings" + "time" + + "github.com/pkg/sftp" + + "github.com/mmmy/snapgo/internal/domain" +) + +// SFTPUploader transfers screenshots through the SSH File Transfer Protocol. +// It lives beside the SCP client so both protocols share the same SSH +// authentication and host-key implementation without duplicating credential +// discovery code. +type SFTPUploader struct { + cfg domain.FTPConfig +} + +// NewSFTPUploader returns a short-lived SFTP adapter. A fresh SSH/SFTP +// connection is opened for every upload or connection probe. +func NewSFTPUploader(cfg domain.FTPConfig) *SFTPUploader { + return &SFTPUploader{cfg: cfg} +} + +func (u *SFTPUploader) sshConfig() domain.SSHConfig { + return domain.SSHConfig{ + Host: u.cfg.Host, + Port: u.cfg.Port, + User: u.cfg.User, + AuthMethod: u.cfg.AuthMethod, + Password: u.cfg.Password, + StrictHostKey: u.cfg.StrictHostKey, + KnownHostsPath: u.cfg.KnownHostsPath, + ConnectTimeoutSecs: u.cfg.ConnectTimeoutSecs, + } +} + +func (u *SFTPUploader) dial(ctx context.Context) (*Client, *sftp.Client, error) { + sshClient, err := Dial(ctx, u.sshConfig()) + if err != nil { + return nil, nil, fmt.Errorf("sftp: %w", err) + } + sftpClient, err := sftp.NewClient(sshClient.client) + if err != nil { + _ = sshClient.Close() + return nil, nil, fmt.Errorf("sftp: start subsystem: %w", err) + } + return sshClient, sftpClient, nil +} + +// Upload writes data to a temporary file and atomically exposes it at the +// final relative path after the transfer completes. +func (u *SFTPUploader) Upload(ctx context.Context, remoteRelPath string, data []byte) error { + cleaned, err := validateSFTPRemotePath(remoteRelPath) + if err != nil { + return err + } + sshClient, sftpClient, err := u.dial(ctx) + if err != nil { + return err + } + done := closeSFTPOnCancel(ctx, sshClient, sftpClient) + defer sshClient.Close() + defer sftpClient.Close() + defer close(done) + + dir := path.Dir(cleaned) + if dir != "." { + if err := sftpClient.MkdirAll(dir); err != nil { + return fmt.Errorf("sftp: create directory %s: %w", dir, err) + } + } + + tempPath := path.Join(dir, temporarySFTPName(path.Base(cleaned))) + removeTemp := true + defer func() { + if removeTemp { + _ = sftpClient.Remove(tempPath) + } + }() + + file, err := sftpClient.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) + if err != nil { + return fmt.Errorf("sftp: create temporary file: %w", err) + } + reader := &sftpContextReader{ctx: ctx, reader: bytes.NewReader(data)} + _, copyErr := io.Copy(file, reader) + if copyErr == nil { + copyErr = file.Chmod(0o644) + } + closeErr := file.Close() + if copyErr != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("sftp: write %s: %w", cleaned, copyErr) + } + if closeErr != nil { + return fmt.Errorf("sftp: close %s: %w", cleaned, closeErr) + } + if err := sftpClient.Rename(tempPath, cleaned); err != nil { + return fmt.Errorf("sftp: commit %s: %w", cleaned, err) + } + removeTemp = false + sshLog().Info("SFTP upload ok", "remote_path", cleaned, "size", len(data)) + return nil +} + +// TestConnection verifies SFTP write/delete permissions with a small probe. +func (u *SFTPUploader) TestConnection(ctx context.Context) error { + probe, err := sftpProbePath(u.cfg.PathPrefix) + if err != nil { + return err + } + sshClient, sftpClient, err := u.dial(ctx) + if err != nil { + return err + } + done := closeSFTPOnCancel(ctx, sshClient, sftpClient) + defer sshClient.Close() + defer sftpClient.Close() + defer close(done) + + dir := path.Dir(probe) + if dir != "." { + if err := sftpClient.MkdirAll(dir); err != nil { + return fmt.Errorf("sftp probe: create directory: %w", err) + } + } + file, err := sftpClient.OpenFile(probe, os.O_WRONLY|os.O_CREATE|os.O_TRUNC) + if err != nil { + return fmt.Errorf("sftp probe: create: %w", err) + } + _, writeErr := file.Write([]byte("snapgo")) + closeErr := file.Close() + if writeErr != nil { + _ = sftpClient.Remove(probe) + return fmt.Errorf("sftp probe: write: %w", writeErr) + } + if closeErr != nil { + _ = sftpClient.Remove(probe) + return fmt.Errorf("sftp probe: close: %w", closeErr) + } + if err := sftpClient.Remove(probe); err != nil { + return fmt.Errorf("sftp probe: delete: %w", err) + } + return nil +} + +func closeSFTPOnCancel(ctx context.Context, sshClient *Client, sftpClient *sftp.Client) chan struct{} { + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + _ = sftpClient.Close() + _ = sshClient.Close() + case <-done: + } + }() + return done +} + +func validateSFTPRemotePath(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" || strings.HasPrefix(value, "/") || strings.HasPrefix(value, "~") { + return "", fmt.Errorf("sftp: remote path must be relative to the login directory") + } + if strings.ContainsAny(value, "\x00\r\n\\") { + return "", fmt.Errorf("sftp: remote path contains an invalid character") + } + for _, segment := range strings.Split(value, "/") { + if segment == ".." { + return "", fmt.Errorf("sftp: remote path must not contain '..'") + } + } + cleaned := path.Clean(value) + if cleaned == "." || path.Base(cleaned) == "." || path.Base(cleaned) == ".." { + return "", fmt.Errorf("sftp: remote filename is empty") + } + return cleaned, nil +} + +func sftpProbePath(prefix string) (string, error) { + prefix = strings.TrimSpace(prefix) + prefix = strings.TrimPrefix(prefix, "~") + prefix = strings.TrimLeft(prefix, "/") + return validateSFTPRemotePath(path.Join(prefix, fmt.Sprintf(".snapgo-probe-%d", time.Now().UnixNano()))) +} + +func temporarySFTPName(base string) string { + return fmt.Sprintf(".%s.snapgo-upload-%d", base, time.Now().UnixNano()) +} + +type sftpContextReader struct { + ctx context.Context + reader io.Reader +} + +func (r *sftpContextReader) Read(p []byte) (int, error) { + select { + case <-r.ctx.Done(): + return 0, r.ctx.Err() + default: + return r.reader.Read(p) + } +} diff --git a/internal/infrastructure/ssh/sftp_uploader_test.go b/internal/infrastructure/ssh/sftp_uploader_test.go new file mode 100644 index 0000000..1543f1f --- /dev/null +++ b/internal/infrastructure/ssh/sftp_uploader_test.go @@ -0,0 +1,28 @@ +package ssh + +import "testing" + +func TestValidateSFTPRemotePathRejectsTraversal(t *testing.T) { + if got, err := validateSFTPRemotePath("snapgo/2026/image.png"); err != nil || got != "snapgo/2026/image.png" { + t.Fatalf("valid path: got %q err=%v", got, err) + } + + for _, value := range []string{ + "", + "/absolute/image.png", + "~/image.png", + "../image.png", + "snapgo/../../image.png", + "snapgo\\image.png", + } { + if _, err := validateSFTPRemotePath(value); err == nil { + t.Errorf("expected %q to be rejected", value) + } + } +} + +func TestSFTPProbePathRejectsTraversalPrefix(t *testing.T) { + if _, err := sftpProbePath("../../outside"); err == nil { + t.Fatal("expected traversal prefix to fail") + } +} diff --git a/main.go b/main.go index b5a1c71..b409f85 100644 --- a/main.go +++ b/main.go @@ -74,7 +74,7 @@ func main() { WindowIsTranslucent: true, About: &mac.AboutInfo{ Title: "SnapGo", - Message: "Cross-platform screenshot tool with one-click upload to S3.", + Message: "Cross-platform screenshot tool with one-click upload to S3, FTP, SFTP, or SSH.", }, }, }) diff --git a/native_overlay_callbacks_darwin.go b/native_overlay_callbacks_darwin.go index d3048ab..7f70381 100644 --- a/native_overlay_callbacks_darwin.go +++ b/native_overlay_callbacks_darwin.go @@ -74,6 +74,18 @@ func nativeOverlaySaveRemote(x, y, w, h C.int, annotationsJSON *C.char) { }() } +//export nativeOverlayUploadFTP +func nativeOverlayUploadFTP(x, y, w, h C.int, annotationsJSON *C.char) { + app := consumeNativeOverlayApp() + if app == nil { + return + } + result := nativeCaptureResult(x, y, w, h, annotationsJSON) + go func() { + _ = app.UploadNativeRegionToFTP(result) + }() +} + //export nativeOverlaySummarize func nativeOverlaySummarize(x, y, w, h C.int, annotationsJSON *C.char) { app := consumeNativeOverlayApp() diff --git a/native_overlay_darwin.go b/native_overlay_darwin.go index 2db3411..8aaf032 100644 --- a/native_overlay_darwin.go +++ b/native_overlay_darwin.go @@ -15,6 +15,7 @@ extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotat extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir); extern void nativeOverlaySaveRemote(int x, int y, int w, int h, const char *annotationsJSON); +extern void nativeOverlayUploadFTP(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlaySummarize(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlayOCR(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlayCancel(void); @@ -150,6 +151,7 @@ static id nativeOverlayKeyMonitor = nil; @property(strong) NSButton *clipboardButton; @property(strong) NSButton *saveButton; @property(strong) NSButton *saveRemoteButton; +@property(strong) NSButton *ftpButton; @property(strong) NSButton *ocrButton; @property(strong) NSButton *summaryButton; @property(strong) NSButton *uploadButton; @@ -181,6 +183,7 @@ static id nativeOverlayKeyMonitor = nil; - (void)copySelection; - (void)saveSelection; - (void)saveRemoteSelection; +- (void)uploadFTPSelection; - (void)ocrSelection; - (void)summarizeSelection; - (void)cancelSelection; @@ -222,6 +225,7 @@ static id nativeOverlayKeyMonitor = nil; _clipboardButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(copySelection)]; _saveButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveSelection)]; _saveRemoteButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveRemoteSelection)]; + _ftpButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(uploadFTPSelection)]; _ocrButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(ocrSelection)]; _summaryButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(summarizeSelection)]; _uploadButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(confirmSelection)]; @@ -230,10 +234,11 @@ static id nativeOverlayKeyMonitor = nil; [_cancelButton setToolTip:@"取消截图"]; [_clipboardButton setToolTip:@"复制图片"]; [_saveButton setToolTip:@"保存本地"]; - [_saveRemoteButton setToolTip:@"保存远端"]; + [_saveRemoteButton setToolTip:@"上传 SSH/SCP"]; + [_ftpButton setToolTip:@"上传 FTP/SFTP"]; [_ocrButton setToolTip:@"提取文字"]; [_summaryButton setToolTip:@"复制总结"]; - [_uploadButton setToolTip:@"上传云端"]; + [_uploadButton setToolTip:@"上传 S3"]; _penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)]; _rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)]; _ellipseButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectEllipse)]; @@ -257,10 +262,10 @@ static id nativeOverlayKeyMonitor = nil; // Add the action toolbar background BEFORE the buttons so it sits // behind them in the view hierarchy (AppKit z-order = subview order). [self addSubview:_actionToolbarBg]; - for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _textButton, _undoButton, _toolSettingsView, _sizeLabel, _hintLabel]) { + for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ftpButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _textButton, _undoButton, _toolSettingsView, _sizeLabel, _hintLabel]) { [self addSubview:view]; } - for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _textButton, _undoButton, _toolSettingsView, _actionToolbarBg]) { + for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ftpButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _textButton, _undoButton, _toolSettingsView, _actionToolbarBg]) { [view setHidden:YES]; } [_sizeLabel setHidden:YES]; @@ -323,7 +328,7 @@ static id nativeOverlayKeyMonitor = nil; } - (void)styleControls { - // Cancel / Copy / Save / Save-remote / OCR / Summary / Upload — square icon buttons + // Cancel / Copy / Save / SSH / FTP / OCR / Summary / S3 — square icon buttons // rendered from the user-supplied SVG assets. All actions default to white // and animate to a per-action accent color on hover (cancel = red, // others = blue). The base "white default" replaces the previous blue @@ -334,6 +339,7 @@ static id nativeOverlayKeyMonitor = nil; // save-remote.svg — provided by the user; its content is the same arrow // icon as the previous upload, so we reuse it verbatim here. NSImage *saveRemoteIcon = [self iconFromSVG:@""]; + NSImage *ftpIcon = [self iconFromSVG:@""]; NSImage *ocrIcon = [self iconFromSVG:@""]; NSImage *summaryIcon = [self iconFromSVG:@""]; // upload.svg — newly replaced "send/cloud" icon supplied by the user. @@ -343,6 +349,7 @@ static id nativeOverlayKeyMonitor = nil; [self styleIconButton:_clipboardButton image:copyIcon]; [self styleIconButton:_saveButton image:saveIcon]; [self styleIconButton:_saveRemoteButton image:saveRemoteIcon]; + [self styleIconButton:_ftpButton image:ftpIcon]; [self styleIconButton:_ocrButton image:ocrIcon]; [self styleIconButton:_summaryButton image:summaryIcon]; [self styleIconButton:_uploadButton image:uploadIcon]; @@ -357,6 +364,7 @@ static id nativeOverlayKeyMonitor = nil; SnipHoverButton *clipboardHB = (SnipHoverButton *)_clipboardButton; SnipHoverButton *saveHB = (SnipHoverButton *)_saveButton; SnipHoverButton *saveRemoteHB = (SnipHoverButton *)_saveRemoteButton; + SnipHoverButton *ftpHB = (SnipHoverButton *)_ftpButton; SnipHoverButton *ocrHB = (SnipHoverButton *)_ocrButton; SnipHoverButton *summaryHB = (SnipHoverButton *)_summaryButton; SnipHoverButton *uploadHB = (SnipHoverButton *)_uploadButton; @@ -364,6 +372,7 @@ static id nativeOverlayKeyMonitor = nil; clipboardHB.baseColor = baseWhite; clipboardHB.hoverColor = hoverBlue; saveHB.baseColor = baseWhite; saveHB.hoverColor = hoverBlue; saveRemoteHB.baseColor = baseWhite; saveRemoteHB.hoverColor = hoverBlue; + ftpHB.baseColor = baseWhite; ftpHB.hoverColor = hoverBlue; ocrHB.baseColor = baseWhite; ocrHB.hoverColor = hoverBlue; summaryHB.baseColor = baseWhite; summaryHB.hoverColor = hoverBlue; uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue; @@ -921,6 +930,7 @@ static id nativeOverlayKeyMonitor = nil; [_clipboardButton setHidden:!visible]; [_saveButton setHidden:!visible]; [_saveRemoteButton setHidden:!visible]; + [_ftpButton setHidden:!visible]; [_ocrButton setHidden:!visible]; [_summaryButton setHidden:!visible]; [_uploadButton setHidden:!visible]; @@ -941,12 +951,12 @@ static id nativeOverlayKeyMonitor = nil; [_sizeLabel setStringValue:[NSString stringWithFormat:@"%.0f × %.0f", _selection.size.width, _selection.size.height]]; [_sizeLabel setFrame:NSMakeRect(_selection.origin.x, MAX(0, _selection.origin.y - 26), 110, 22)]; - // Action toolbar layout — 7 icon buttons, each 28x28, separated by 8px, - // with 4px outer padding. Total = 7*28 + 6*8 + 2*4 = 252px wide. + // Action toolbar layout — 8 icon buttons, each 28x28, separated by 8px, + // with 4px outer padding. Total = 8*28 + 7*8 + 2*4 = 288px wide. CGFloat actionBtnSize = 28; CGFloat actionGap = 8; CGFloat actionPad = 4; - NSInteger actionCount = 7; + NSInteger actionCount = 8; CGFloat toolbarW = actionPad * 2 + actionBtnSize * actionCount + actionGap * (actionCount - 1); CGFloat toolbarH = 40; CGFloat x = _selection.origin.x + _selection.size.width - toolbarW; @@ -964,9 +974,10 @@ static id nativeOverlayKeyMonitor = nil; [_clipboardButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 1, btnY, actionBtnSize, actionBtnSize)]; [_saveButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 2, btnY, actionBtnSize, actionBtnSize)]; [_saveRemoteButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 3, btnY, actionBtnSize, actionBtnSize)]; - [_ocrButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)]; - [_summaryButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 5, btnY, actionBtnSize, actionBtnSize)]; - [_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 6, btnY, actionBtnSize, actionBtnSize)]; + [_ftpButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)]; + [_ocrButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 5, btnY, actionBtnSize, actionBtnSize)]; + [_summaryButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 6, btnY, actionBtnSize, actionBtnSize)]; + [_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 7, btnY, actionBtnSize, actionBtnSize)]; // Mark toolbar sits to the LEFT of the action toolbar (same row) with an // 8px gap between the two groups, so they never overlap on tiny selections. @@ -1351,6 +1362,17 @@ static id nativeOverlayKeyMonitor = nil; nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); } +// `uploadFTPSelection` dispatches the dedicated FTP/SFTP upload action. +- (void)uploadFTPSelection { + if (!_hasSelection) { + return; + } + NSRect r = [self globalRectForSelection:_selection]; + NSString *json = [self annotationsJSON]; + [self closeOverlayWindow]; + nativeOverlayUploadFTP((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); +} + // `ocrSelection` sends the screenshot to the configured OCR provider and // copies the extracted text. - (void)ocrSelection {