6 Commits

Author SHA1 Message Date
mamamiyear 3aad93e7e8 feat: copy saved file path to clipboard after local save
After writing the screenshot to disk, copy its path to the clipboard so
it can be pasted immediately, matching the upload and summary flows. The
save status now shows the copied path. A clipboard error does not fail
the save itself.
2026-07-07 16:37:47 +08:00
mamamiyear 85d5fcc722 build: silence duplicate -lobjc linker warning on Xcode 15+
Add -Wl,-no_warn_duplicate_libraries to the darwin cgo LDFLAGS and allow
it through the cgo flag allowlist via CGO_LDFLAGS_ALLOW in the build
scripts, so the ld-prime duplicate -lobjc note no longer clutters build
output.
2026-07-07 14:16:24 +08:00
mamamiyear 778055cd1d feat: unify status feedback for copy, save and cancel actions
Route the copy-to-clipboard and save-to-disk pipelines through
emitOperationStatus so they show the same running/success/error stages
as the upload, save-remote and summary flows, and surface a short
"已取消" status when a capture is cancelled. This gives every capture
action a consistent HUD experience.
2026-07-07 14:03:02 +08:00
mamamiyear 87874e64cf feat: prefer base64 inline image for summary, fall back to S3
Send the screenshot to the multimodal model inline as a base64 data URL
so summarisation no longer requires S3 to be configured or publicly
reachable. Only when the PNG exceeds the provider MaxInlineBytes limit
do we upload to S3, verify the public URL is reachable, and delete the
temporary object once done. Oversized images without S3 now surface a
clear hint instead of a hard S3 requirement.
2026-07-07 13:39:23 +08:00
mamamiyear 7c53c0f184 fix(hotkey): split platform files by platform+cgo build matrix
The `!darwin` guard on the modifier helpers was too broad: golang.design/x/
hotkey only exposes its Mod*/Key* constants from the darwin&&cgo, linux&&cgo
and windows backends, while the `!windows && !cgo` complement (e.g. Linux
cross-builds with CGO_ENABLED=0) falls back to a stub with no constants. That
made hk.ModCtrl/ModShift/KeyA... undefined and produced editor/vet warnings.

Realign the build tags with the upstream matrix and confine every hk.* constant
to a tagged file so the shared hotkey.go compiles everywhere. Also fix the
Linux mapping (option -> hk.Mod1; hk.ModAlt does not exist on X11).
2026-07-07 10:29:59 +08:00
mamamiyear 5ce3eba455 docs: add AGENTS.md architecture overview 2026-07-07 10:29:59 +08:00
18 changed files with 435 additions and 65 deletions
+67
View File
@@ -0,0 +1,67 @@
# AGENTS.md
## 项目概述
SnapGo 是一个常驻菜单栏的轻量截图工具:全局快捷键唤起选区截图,确认后自动上传到 S3 兼容对象存储(或通过 SSH/SCP 保存到远端),并把可分享链接复制到剪贴板。目前主要打磨 macOS 体验。
技术栈:**Wails v2**Go 后端 + WebView 前端)+ **Vue 3 / TypeScript / Vite** 前端。Go 后端遵循分层架构(domain → application → infrastructure),macOS 上截图选区使用原生 AppKit overlay,其它平台回退到 Wails 透明窗口 overlay。
### 分层架构(`internal/`
- `domain/`:核心业务类型与接口,**不依赖任何第三方 SDK 或 OS API**(仅标准库)。如 `OSSProvider` 接口、`AppConfig`/`S3Config`/`SSHConfig``Screenshot`/`UploadResult`
- `application/`:用例编排(capture → upload/save → clipboard → notify)。**只依赖 domain 接口**,具体适配器从 `main.go`/`app.go` 注入,便于单测。如 `CaptureAndUploadService``CaptureActionsService``CaptureAndSSHService``ApplyAnnotations`
- `infrastructure/`:对接外部世界的适配器实现 —— `oss/`(S3)、`ssh/`(内置 Go client + Kerberos 委托系统 ssh)、`clipboard/``config/`(JSON 文件存储)、`screencapture/``display/``cursor/``hotkey/``tray/`(菜单栏)、`logging/`
### 顶层文件(`main` 包,根目录)
- `main.go`Wails 启动入口,embed `frontend/dist`,装配 tray 与 `App`
- `app.go``App` 结构体,通过 Wails `Bind` 暴露给前端的方法(RPC),编排截图/上传/保存流程与 overlay 窗口生命周期。
- `*_darwin.go` / `*_other.go`:平台分离的 overlay、dock 图标、激活策略、帧刷新等实现。`native_overlay_darwin.go` 使用 cgo + AppKit。
### 关键约定
- 前端通过 Wails 自动生成的 `frontend/wailsjs/go/main/App.js` 调用后端 `App` 的导出方法;后端通过 `wruntime.EventsEmit` 向前端推事件(如 `upload:success``upload:failure``hotkey:ready``capture:overlay`)。
- 新增 OSS provider 只需在 `infrastructure/oss/` 加适配器并实现 `domain.OSSProvider`,无需改 application 层。
- S3 与 SSH 走独立 pipeline`runUploadPipeline` vs `runSaveRemotePipeline`),避免 provider 分支泄漏进彼此。
## 构建与命令
依赖前置:Go 1.23+、Node/npm、`wails` CLI`go install github.com/wailsapp/wails/v2/cmd/wails@latest`)。
- `go mod tidy`:同步 Go 依赖。
- `wails dev`:开发模式(热重载前端 + 后端),日志可直接在终端看到。
- `wails build -platform darwin/arm64 -clean`:构建 `.app`(产物在 `build/bin/`)。
- `./scripts/create-dev-cert.sh`:首次创建本地自签开发证书(保证 macOS TCC 权限不被反复重置)。
- `./scripts/dev-build.sh`:本地迭代用 —— 构建 + 稳定证书重签名 + 同步到 `/Applications` + 打开。优先用它而非裸 `wails build`
- `./scripts/release.sh`:一键 build → sign → DMG → notarizenotarize 需 `NOTARIZE=1` 与凭证,默认跳过)。`ARCH=universal` 可出 fat binary。
- 前端单独命令(`frontend/`):`npm run dev` / `npm run build``vue-tsc --noEmit && vite build`/ `npm run preview`
## 代码风格
- **Go**:标准 `gofmt`(tab 缩进)。包级 doc 注释说明“设计动机/rationale”是本仓库的强约定,注释解释“为什么”而非“是什么”。导出标识符必须有注释。错误用 `fmt.Errorf("...: %w", err)` 包装。日志统一用 `log/slog`(结构化 kv,如 `slog.Info("...", "host", h)`)。
- **平台分离**:用 `_darwin.go` / `_other.go` 文件名后缀或 `//go:build` 标签隔离平台代码,保持跨平台编译通过。
- **TypeScript/Vue**Vue 3 `<script setup>` + Composition API2 空格缩进。视图在 `frontend/src/views/`,组件在 `components/`
- 注释中可使用中文(部分 infra 包如 logging 已用中文 rationale),与现有文件保持一致即可。
- 不要手改 `frontend/wailsjs/` 下文件,它们由 Wails 生成。
## 测试
- 框架:Go 标准 `testing` 包(无第三方断言库)。运行:`go test ./...`
- 现有测试集中在 `internal/application/`(如 `capture_actions_test.go``annotation_test.go`)。
- 约定:application 层依赖 domain 接口,测试用手写 fake 实现(如 `fakeClipboard``fakeNotifier`)注入,不触碰真实 OS/网络。文件 IO 测试使用 `t.TempDir()`
- infrastructure 适配器(S3/SSH/screencapture 等)依赖真实环境,通常不写单测;SSH 连通性可借助 `cmd/sshdiag/` 诊断工具与设置页的 “Test connection” 按钮验证。
## 安全
- **凭证落盘**:配置(含 S3 access key、SSH 密码)以 JSON 存于 `os.UserConfigDir()/SnapGo/config.json`,文件权限 `0600`、目录 `0700`。尚未接入 OS keychain(后续计划),改动配置存储时务必保持严格权限并避免把密钥写入日志。
- **日志**:写入 `~/Library/Logs/SnapGo/snapgo.log`(大小滚动,5MiB×2 备份)。记录连接信息时只记 host/user/port 等非敏感字段,**切勿记录密码/密钥**(参考 `app.go` 中的 `slog` 用法)。
- **SSH**`StrictHostKey=false` 时使用 `InsecureIgnoreHostKey()`(牺牲安全换首启可用性,仅限个人 LAN);Kerberos 模式委托系统 `/usr/bin/ssh` 复用 `kinit` 凭证缓存。`PathPrefix` 相对远端 `$HOME`,会剥离前导 `/``~` 防止逃逸。
- **上传兜底**:上传失败时把 PNG 落地到 `~/Pictures/SnapGo` 并复制本地路径,保证不丢截图。
- macOS 需 Screen Recording / AccessibilityTCC)权限;稳定的代码签名身份与安装路径是权限保持的关键(见 `dev-build.sh` 注释)。
## 配置
- Wails 项目配置:`wails.json`(产物名、frontend install/build/dev 命令、应用元信息)。
- 运行时配置:首启写入默认值(`domain.DefaultAppConfig()`:快捷键 `cmd+shift+a`、S3 `pathPrefix=snapgo/`、SSH port 22 / timeout 10s)。原子写入(temp + rename)。
- 环境变量:`SNAPGO_LOG_LEVEL=debug|info|warn|error`(默认 debug)。Release 相关:`ARCH``NOTARIZE``SIGN_MODE``DEVELOPER_ID_APPLICATION``KEYCHAIN_PROFILE``APPLE_ID`/`APPLE_TEAM_ID`/`APPLE_APP_SPECIFIC_PASSWORD`
- S3 兼容性:`UsePathStyle` 默认 true(适配 MinIO/R2);`PublicURLBase` 可选用于 CDN/自定义域名,否则回退 `{Endpoint}/{Bucket}/{Key}`
+6
View File
@@ -5,6 +5,12 @@ package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework AppKit
// Silence the "ignoring duplicate libraries: '-lobjc'" note emitted by the
// Xcode 15+ linker (ld-prime): cgo links -lobjc for our Objective-C code and
// the Go darwin runtime pulls it in as well, which the new linker flags as a
// duplicate. Suppressing it here keeps the build output clean; the flag is a
// no-op on the legacy ld64 path.
#cgo LDFLAGS: -Wl,-no_warn_duplicate_libraries
#include <dispatch/dispatch.h>
#import <AppKit/AppKit.h>
+80 -26
View File
@@ -320,17 +320,32 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er
func (a *App) runCopyImagePipeline(pngBytes []byte) error {
svc := &application.CaptureActionsService{
Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx},
}
return svc.CopyImage(a.ctx, pngBytes)
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,
Notifier: &runtimeNotifier{ctx: a.ctx},
}
return svc.SaveImage(a.ctx, pngBytes, dir)
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
@@ -387,12 +402,6 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsS3Configured() {
err := fmt.Errorf("请先在 S3 配置页填写可用的对象存储配置")
a.emitOperationStatus("summary", "需要配置 S3", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
if !cfg.IsLLMConfigured() {
err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
@@ -400,28 +409,52 @@ func (a *App) runSummaryPipeline(pngBytes []byte) 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
}
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{
Provider: s3Provider,
Summarizer: visionClient,
Clipboard: a.clip,
Prompt: cfg.LLM.Prompt,
PathPrefix: cfg.S3.PathPrefix,
}
svc.Provider = s3Provider
a.emitOperationStatus("summary", "上传中", "正在上传截图供模型读取", "running")
a.emitOperationStatus("summary", "上传中", "截图较大,正在上传供模型读取", "running")
uploaded, err := svc.UploadImage(a.ctx, pngBytes)
if err != nil {
a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
@@ -429,6 +462,20 @@ func (a *App) runSummaryPipeline(pngBytes []byte) 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 {
@@ -436,12 +483,17 @@ func (a *App) runSummaryPipeline(pngBytes []byte) 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
@@ -902,6 +954,7 @@ func (a *App) CancelRegion() {
a.pendingMu.Unlock()
a.capturing.Store(false)
a.dismissOverlay()
a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
}
// CancelNativeRegion releases native-overlay state without touching the
@@ -912,6 +965,7 @@ func (a *App) CancelNativeRegion() {
a.pendingMu.Unlock()
a.capturing.Store(false)
hideDockIcon()
a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
}
// ShowWindow brings the main window back to the foreground in its normal
+2
View File
@@ -61,6 +61,7 @@ export namespace domain {
maxTokens: number;
temperature: number;
timeoutSecs: number;
maxInlineBytes: number;
static createFrom(source: any = {}) {
return new LLMProviderConfig(source);
@@ -75,6 +76,7 @@ export namespace domain {
this.maxTokens = source["maxTokens"];
this.temperature = source["temperature"];
this.timeoutSecs = source["timeoutSecs"];
this.maxInlineBytes = source["maxInlineBytes"];
}
}
export class LLMConfig {
+8
View File
@@ -61,6 +61,14 @@ func (s *CaptureActionsService) SaveImage(_ context.Context, pngBytes []byte, di
s.notifyFailure("save failed: " + err.Error())
return "", err
}
// Copy the saved path to the clipboard so the user can paste it right
// away, mirroring how the upload/summary flows copy their result. A
// clipboard failure must not fail the save itself.
if s.Clipboard != nil {
if err := s.Clipboard.WriteText(path); err != nil {
s.notifyFailure("clipboard write failed: " + err.Error())
}
}
if s.Notifier != nil {
s.Notifier.NotifySuccess(path)
}
+93 -7
View File
@@ -2,32 +2,65 @@ package application
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"time"
"github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
)
// VisionSummarizer describes a multimodal model capable of reading a public
// image URL and returning a textual summary.
// VisionSummarizer describes a multimodal model capable of reading an image
// reference (either a public https URL or an inline base64 data URL) and
// returning a textual summary.
type VisionSummarizer interface {
SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error)
}
// CaptureSummaryService wires screenshot bytes -> S3 public URL -> LLM
// summary -> clipboard. It intentionally does not notify UI state itself:
// the caller emits fine-grained "uploading / recognizing / done" progress.
// ObjectDeleter is an optional capability an OSSProvider may implement so the
// summary pipeline can delete the temporary screenshot it uploaded purely so a
// remote model could fetch it. It is kept separate from domain.OSSProvider to
// avoid forcing every provider (or test fake) to implement deletion.
type ObjectDeleter interface {
Delete(ctx context.Context, key string) error
}
// CaptureSummaryService wires screenshot bytes -> multimodal LLM summary ->
// clipboard. It prefers sending the image inline as a base64 data URL and only
// falls back to an S3 upload (public URL) when the payload exceeds the
// provider's inline size limit. It intentionally does not notify UI state
// itself: the caller emits fine-grained "uploading / recognizing / done"
// progress.
type CaptureSummaryService struct {
Provider domain.OSSProvider
Summarizer VisionSummarizer
Clipboard clipboard.Writer
Prompt string
PathPrefix string
// MaxInlineBytes caps the PNG size eligible for inline base64. Payloads
// larger than this must go through S3. A value <= 0 disables the inline
// path entirely (always upload).
MaxInlineBytes int
// HTTPClient is used for the public-URL reachability probe. Defaults to a
// short-timeout client when nil.
HTTPClient *http.Client
}
// CanInline reports whether the screenshot is small enough to be embedded
// directly in the request as a base64 data URL.
func (s *CaptureSummaryService) CanInline(pngBytes []byte) bool {
return s.MaxInlineBytes > 0 && len(pngBytes) <= s.MaxInlineBytes
}
// InlineDataURL encodes the PNG as an RFC 2397 data URL suitable for the
// image_url field of an OpenAI-compatible request.
func (s *CaptureSummaryService) InlineDataURL(pngBytes []byte) string {
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes)
}
// UploadImage uploads the screenshot and returns the public URL visible to
// the multimodal provider.
// the multimodal provider. Used only when the image is too large to inline.
func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte) (*domain.UploadResult, error) {
if s.Provider == nil {
return nil, fmt.Errorf("s3 is not configured")
@@ -49,7 +82,60 @@ func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte
}, nil
}
// Summarize asks the configured model to summarize the uploaded image URL.
// EnsureReachable verifies the public URL can actually be fetched, so we can
// surface a clear "link not reachable" message instead of an opaque provider
// error when the bucket is private or fronted by an inaccessible CDN.
func (s *CaptureSummaryService) EnsureReachable(ctx context.Context, imageURL string) error {
client := s.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
probe := func(method string) (int, error) {
req, err := http.NewRequestWithContext(ctx, method, imageURL, nil)
if err != nil {
return 0, err
}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
resp.Body.Close()
return resp.StatusCode, nil
}
status, err := probe(http.MethodHead)
// Some S3-compatible endpoints reject HEAD; retry with GET before failing.
if err != nil || status == http.StatusMethodNotAllowed || status == http.StatusForbidden {
if s, gerr := probe(http.MethodGet); gerr == nil {
status, err = s, nil
} else if err == nil {
err = gerr
}
}
if err != nil {
return fmt.Errorf("图片链接不可达(模型无法读取上传的截图):%w", err)
}
if status < 200 || status >= 300 {
return fmt.Errorf("图片链接不可达:HTTP %d,请确认对象存储 bucket 公网可读或已正确配置 PublicURLBase", status)
}
return nil
}
// DeleteUploaded removes the temporary object from S3 if the provider supports
// deletion. It is best-effort: a nil return means either success or that the
// provider cannot delete.
func (s *CaptureSummaryService) DeleteUploaded(ctx context.Context, key string) error {
if key == "" {
return nil
}
deleter, ok := s.Provider.(ObjectDeleter)
if !ok {
return nil
}
return deleter.Delete(ctx, key)
}
// Summarize asks the configured model to summarize the image reference, which
// may be a public URL or an inline base64 data URL.
func (s *CaptureSummaryService) Summarize(ctx context.Context, imageURL string) (string, error) {
if s.Summarizer == nil {
return "", fmt.Errorf("llm is not configured")
@@ -2,6 +2,7 @@ package application
import (
"context"
"strings"
"testing"
)
@@ -10,6 +11,7 @@ type fakeOSSProvider struct {
data []byte
contentType string
url string
deletedKey string
}
func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, contentType string) (string, error) {
@@ -24,6 +26,11 @@ func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, con
func (f *fakeOSSProvider) Name() string { return "fake" }
func (f *fakeOSSProvider) Delete(_ context.Context, key string) error {
f.deletedKey = key
return nil
}
type fakeSummarizer struct {
prompt string
url string
@@ -84,3 +91,50 @@ func TestCaptureSummaryServiceUploadsSummarizesAndCopies(t *testing.T) {
t.Fatalf("expected summary copied, got %q", clip.text)
}
}
func TestCaptureSummaryServiceInlineDataURL(t *testing.T) {
svc := &CaptureSummaryService{MaxInlineBytes: 10}
if !svc.CanInline([]byte("small")) {
t.Fatalf("expected 5-byte payload to be inlinable under 10-byte cap")
}
if svc.CanInline([]byte("this is definitely too big")) {
t.Fatalf("expected oversized payload to be rejected for inlining")
}
url := svc.InlineDataURL([]byte("png"))
if !strings.HasPrefix(url, "data:image/png;base64,") {
t.Fatalf("expected base64 data URL prefix, got %q", url)
}
if strings.Contains(url, "https://") {
t.Fatalf("inline URL must not be a remote URL, got %q", url)
}
}
func TestCaptureSummaryServiceDeleteUploadedUsesDeleter(t *testing.T) {
provider := &fakeOSSProvider{}
svc := &CaptureSummaryService{Provider: provider}
if err := svc.DeleteUploaded(context.Background(), "snapgo/a.png"); err != nil {
t.Fatalf("delete uploaded: %v", err)
}
if provider.deletedKey != "snapgo/a.png" {
t.Fatalf("expected object deleted, got %q", provider.deletedKey)
}
}
func TestCaptureSummaryServiceDeleteUploadedNoDeleterIsNoop(t *testing.T) {
// A provider without Delete must not error when cleanup is requested.
svc := &CaptureSummaryService{Provider: nonDeletingProvider{}}
if err := svc.DeleteUploaded(context.Background(), "snapgo/a.png"); err != nil {
t.Fatalf("expected no-op delete, got %v", err)
}
}
type nonDeletingProvider struct{}
func (nonDeletingProvider) Upload(_ context.Context, _ string, _ []byte, _ string) (string, error) {
return "https://cdn.example.com/x.png", nil
}
func (nonDeletingProvider) Name() string { return "no-delete" }
+18
View File
@@ -86,8 +86,20 @@ type LLMProviderConfig struct {
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`
TimeoutSecs int `json:"timeoutSecs"`
// MaxInlineBytes caps the screenshot size (in bytes of the PNG payload)
// that may be sent inline as a base64 data URL. Above this threshold the
// summary pipeline falls back to uploading the image to S3 and passing a
// public URL instead, because most providers reject oversized inline
// images. A value <= 0 means "use the built-in default".
MaxInlineBytes int `json:"maxInlineBytes"`
}
// DefaultMaxInlineBytes is the fallback inline-image cap (~4 MiB) used when a
// provider config does not specify MaxInlineBytes. It is a conservative bound
// that keeps a single base64 data URL within the request-size limits accepted
// by the common OpenAI-compatible multimodal endpoints.
const DefaultMaxInlineBytes = 4 << 20
// LLMConfig controls screenshot summarisation.
type LLMConfig struct {
ActiveProvider string `json:"activeProvider"`
@@ -198,6 +210,7 @@ func DefaultLLMConfig() LLMConfig {
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
},
LLMProviderDoubao: {
Label: "火山方舟豆包多模态",
@@ -206,6 +219,7 @@ func DefaultLLMConfig() LLMConfig {
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
},
LLMProviderOpenAI: {
Label: "ChatGPT / OpenAI-compatible",
@@ -214,6 +228,7 @@ func DefaultLLMConfig() LLMConfig {
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
},
},
}
@@ -275,6 +290,9 @@ func (c *AppConfig) Normalize() {
if current.TimeoutSecs == 0 {
current.TimeoutSecs = def.TimeoutSecs
}
if current.MaxInlineBytes <= 0 {
current.MaxInlineBytes = DefaultMaxInlineBytes
}
c.LLM.Providers[id] = current
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ func parseSpec(spec string) ([]hk.Modifier, hk.Key, error) {
case "option", "alt":
mods = append(mods, modOption())
case "shift":
mods = append(mods, hk.ModShift)
mods = append(mods, modShift())
default:
k, ok := lookupKey(p)
if !ok {
@@ -1,10 +1,15 @@
//go:build darwin
//go:build darwin && cgo
package hotkey
import hk "golang.design/x/hotkey"
// macOS uses ⌘ as the primary modifier; Option corresponds to Alt.
//
// This file is gated on cgo because golang.design/x/hotkey only exposes the
// Mod* constants from its cgo-backed darwin implementation; with CGO_ENABLED=0
// the package falls back to a stub that defines no constants.
func modCmd() hk.Modifier { return hk.ModCmd }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.ModOption }
func modShift() hk.Modifier { return hk.ModShift }
@@ -0,0 +1,18 @@
//go:build linux && cgo
package hotkey
import hk "golang.design/x/hotkey"
// On Linux we map "cmd" to Ctrl so the same config string ("cmd+shift+a")
// stays meaningful across platforms.
//
// The X11 backend exposes modifiers as Mod1..Mod5 rather than named Alt/Option
// constants; Mod1 is the conventional Alt mask under X11, so "option"/"alt"
// resolve to hk.Mod1. This file is gated on cgo because the constants only
// exist in the cgo-backed linux implementation (CGO_ENABLED=0 falls back to a
// stub with no constants).
func modCmd() hk.Modifier { return hk.ModCtrl }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.Mod1 }
func modShift() hk.Modifier { return hk.ModShift }
@@ -1,11 +0,0 @@
//go:build !darwin
package hotkey
import hk "golang.design/x/hotkey"
// On Windows / Linux we map "cmd" to Ctrl so the same config string works
// across platforms; "option" is treated as Alt.
func modCmd() hk.Modifier { return hk.ModCtrl }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.ModAlt }
@@ -0,0 +1,25 @@
//go:build !windows && !cgo
package hotkey
import hk "golang.design/x/hotkey"
// This file covers the configuration where golang.design/x/hotkey compiles its
// cgo-less stub (any non-Windows platform built with CGO_ENABLED=0). In that
// stub the package defines the Modifier/Key types but no Mod*/Key* constants,
// and Register panics at runtime. We still provide the package-internal
// helpers so that snapgo keeps cross-compiling (e.g. `go vet`, editor analysis,
// or CI cross-builds) without pulling in a C toolchain.
//
// The returned values are inert zero modifiers and lookupKey always reports
// "unsupported": callers on this build cannot register a real global hotkey,
// so surfacing an "unsupported token" parse error is preferable to reaching
// the upstream panic.
func modCmd() hk.Modifier { return 0 }
func modCtrl() hk.Modifier { return 0 }
func modOption() hk.Modifier { return 0 }
func modShift() hk.Modifier { return 0 }
// lookupKey has no key constants to map against in the cgo-less stub, so it
// always reports the token as unsupported.
func lookupKey(string) (hk.Key, bool) { return 0, false }
@@ -0,0 +1,13 @@
//go:build windows
package hotkey
import hk "golang.design/x/hotkey"
// On Windows we map "cmd" to Ctrl so the same config string ("cmd+shift+a")
// stays meaningful across platforms; "option" is treated as Alt. The Windows
// backend does not require cgo, hence the single-tag guard.
func modCmd() hk.Modifier { return hk.ModCtrl }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.ModAlt }
func modShift() hk.Modifier { return hk.ModShift }
+6
View File
@@ -1,9 +1,15 @@
//go:build windows || cgo
package hotkey
import hk "golang.design/x/hotkey"
// lookupKey maps a token from the user-supplied hotkey spec to a hk.Key.
//
// The Key* constants only exist in the platform backends that ship them
// (windows, or the cgo-backed darwin/linux implementations); the cgo-less stub
// in hotkey_unsupported.go provides a fallback lookupKey for the complement.
//
// The map is intentionally exhaustive over the alphanumeric set so we never
// rely on an assumption that hk.KeyA..hk.KeyZ are contiguous values — the
// upstream package gives no such guarantee.
+14
View File
@@ -101,6 +101,20 @@ func (p *S3Provider) BuildPublicURL(key string) string {
return endpoint + "/" + p.cfg.Bucket + "/" + key
}
// Delete removes a previously uploaded object by key.
//
// The summary pipeline uses this to clean up the temporary screenshot it had
// to upload only so a remote multimodal model could fetch it via public URL.
func (p *S3Provider) Delete(ctx context.Context, key string) error {
if _, err := p.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(key),
}); err != nil {
return fmt.Errorf("s3 delete object: %w", err)
}
return nil
}
// TestConnection performs a small put + delete to verify credentials and
// bucket reachability. Used by the "Test connection" button in settings.
func (p *S3Provider) TestConnection(ctx context.Context) error {
+3
View File
@@ -43,6 +43,9 @@ sleep 0.4
# --identity flag in v2.
ARCH="${ARCH:-arm64}"
echo "[dev-build.sh] building darwin/${ARCH}..."
# Allow the -Wl,-no_warn_duplicate_libraries flag declared in the darwin cgo
# files past Go's cgo LDFLAGS allowlist (see activation_policy_darwin.go).
export CGO_LDFLAGS_ALLOW='-Wl,-no_warn_duplicate_libraries'
wails build -platform "darwin/${ARCH}" -clean
# 5. Re-sign with the stable dev cert. We invoke sign.sh through env var
+3 -1
View File
@@ -35,7 +35,9 @@ echo "================================================================"
# ---- 1. Build ----
echo ""
echo "[release.sh] (1/4) wails build"
( cd "${ROOT_DIR}" && wails build -platform "${WAILS_PLATFORM}" -clean )
# Allow the -Wl,-no_warn_duplicate_libraries flag declared in the darwin cgo
# files past Go's cgo LDFLAGS allowlist (see activation_policy_darwin.go).
( cd "${ROOT_DIR}" && CGO_LDFLAGS_ALLOW='-Wl,-no_warn_duplicate_libraries' wails build -platform "${WAILS_PLATFORM}" -clean )
# ---- 2. Sign .app ----
echo ""