12 Commits

Author SHA1 Message Date
mamamiyear a15f5b2b78 refactor: localize tray menu items to Chinese
Rename the menu bar items to 截图 / 设置 / 退出 for the primary Chinese
audience.
2026-07-07 16:56:48 +08:00
mamamiyear cd3d045ef5 refactor: localize settings sidebar tab names to Chinese
Rename the settings tabs to 通用设置 / 对象存储 / 远程主机 / 智能识图 so
the sidebar reads naturally for the primary Chinese audience.
2026-07-07 16:51:39 +08:00
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
mamamiyear 71ca67c0ee refactor: change default size of settings panel 2026-07-07 01:12:18 +08:00
mamamiyear 777d5cb6c3 refactor: change dark theme color 2026-07-07 01:09:22 +08:00
mamamiyear 712a2cbb6a feat: you can summary screen shot directly 2026-07-07 00:59:26 +08:00
mamamiyear 39f0aaae02 refactor: merge mark and action toolbars to the right side
Place the mark toolbar to the left of the action toolbar on the same row
with an 8px gap, so the two button groups never overlap on tiny selections.
2026-06-14 14:30:43 +08:00
34 changed files with 2047 additions and 360 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>
+233 -25
View File
@@ -22,6 +22,7 @@ import (
"github.com/mmmy/snapgo/internal/infrastructure/config"
"github.com/mmmy/snapgo/internal/infrastructure/display"
"github.com/mmmy/snapgo/internal/infrastructure/hotkey"
llmpkg "github.com/mmmy/snapgo/internal/infrastructure/llm"
"github.com/mmmy/snapgo/internal/infrastructure/oss"
"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh"
@@ -96,6 +97,15 @@ type CaptureActionResult struct {
Path string `json:"path,omitempty"`
}
// OperationStatusPayload drives the frontend status HUD and mirrors the
// native macOS HUD state.
type OperationStatusPayload struct {
Operation string `json:"operation"`
Phase string `json:"phase"`
Message string `json:"message"`
State string `json:"state"`
}
// NewApp creates a new App with collaborators already initialised.
func NewApp() *App {
store, err := config.NewFileStore()
@@ -110,6 +120,7 @@ func NewApp() *App {
slog.Warn("config load failed, using defaults", "err", lerr)
}
}
cfg.Normalize()
return &App{
cfg: cfg,
configFile: store,
@@ -227,8 +238,8 @@ func (a *App) runInteractiveCapture() {
// ---------------------------------------------------------------------------
const (
settingsWidth = 1080
settingsHeight = 720
settingsWidth = 1000
settingsHeight = 820
)
// showOverlayWindow prepares the hidden main window as a borderless,
@@ -297,23 +308,44 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er
FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"),
PathPrefix: cfg.S3.PathPrefix,
}
return svc.ExecuteWithBytes(a.ctx, pngBytes)
a.emitOperationStatus("upload", "上传中", "正在上传截图到 S3", "running")
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("upload", "上传失败", err.Error(), "error")
return err
}
a.emitOperationStatus("upload", "上传完成", "链接已复制到剪贴板", "success")
return nil
}
func (a *App) runCopyImagePipeline(pngBytes []byte) error {
svc := &application.CaptureActionsService{
Clipboard: a.clip,
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
@@ -332,6 +364,7 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
err := fmt.Errorf("SSH host/user is not configured")
slog.Warn("save-remote rejected: ssh not configured",
"host", cfg.SSH.Host, "user", cfg.SSH.User)
a.emitOperationStatus("save-remote", "需要配置 SSH", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
@@ -340,16 +373,12 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
"auth_method", cfg.SSH.AuthMethod, "png_size", len(pngBytes))
// Select the uploader by auth method: Kerberos delegates to the system
// ssh/scp binaries (reusing the kinit credential cache), bgo delegates to
// the internal `bgo scp` client (under a PTY), while builtin uses the
// in-process Go SSH client.
// ssh/scp binaries (reusing the kinit credential cache), while builtin
// uses the in-process Go SSH client.
var uploader application.SSHUploader
switch {
case cfg.SSH.IsKerberos():
if cfg.SSH.IsKerberos() {
uploader = sshpkg.NewKerberosUploader(cfg.SSH)
case cfg.SSH.IsBgo():
uploader = sshpkg.NewBgoUploader(cfg.SSH)
default:
} else {
uploader = sshpkg.NewUploader(cfg.SSH)
}
@@ -359,7 +388,115 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
Notifier: &runtimeNotifier{ctx: a.ctx},
Cfg: cfg.SSH,
}
return svc.ExecuteWithBytes(a.ctx, pngBytes)
a.emitOperationStatus("save-remote", "保存中", "正在保存截图到远端 SSH", "running")
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("save-remote", "保存失败", err.Error(), "error")
return err
}
a.emitOperationStatus("save-remote", "保存完成", "远端路径已复制到剪贴板", "success")
return nil
}
func (a *App) runSummaryPipeline(pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsLLMConfigured() {
err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
providerID, llmCfg, _ := cfg.ActiveLLMProvider()
visionClient, err := llmpkg.NewVisionClient(providerID, llmCfg)
if err != nil {
a.emitOperationStatus("summary", "LLM 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
svc := &application.CaptureSummaryService{
Summarizer: visionClient,
Clipboard: a.clip,
Prompt: cfg.LLM.Prompt,
PathPrefix: cfg.S3.PathPrefix,
MaxInlineBytes: llmCfg.MaxInlineBytes,
}
// Preferred path: send the screenshot inline as a base64 data URL so the
// summary never depends on S3 being configured or publicly reachable.
if svc.CanInline(pngBytes) {
a.emitOperationStatus("summary", "识别中", "正在请求多模态模型(本地内联图片)", "running")
summary, err := svc.Summarize(a.ctx, svc.InlineDataURL(pngBytes))
if err != nil {
a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.finishSummary(svc, summary)
}
// Oversized image: fall back to uploading to S3 and passing a public URL.
if !cfg.IsS3Configured() {
err := fmt.Errorf("截图尺寸超过内联上限(%d 字节),请缩小截图区域,或在 S3 配置页填写公网可达的对象存储后重试", llmCfg.MaxInlineBytes)
a.emitOperationStatus("summary", "尺寸超限", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
s3Provider, err := oss.NewS3Provider(cfg.S3)
if err != nil {
a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
svc.Provider = s3Provider
a.emitOperationStatus("summary", "上传中", "截图较大,正在上传供模型读取", "running")
uploaded, err := svc.UploadImage(a.ctx, pngBytes)
if err != nil {
a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
// The remote model fetches the URL itself, so verify it is reachable and
// clean up the temporary object once we are done with it either way.
defer func() {
if derr := svc.DeleteUploaded(a.ctx, uploaded.Key); derr != nil {
slog.Warn("summary: failed to delete temporary object", "key", uploaded.Key, "err", derr)
}
}()
if err := svc.EnsureReachable(a.ctx, uploaded.URL); err != nil {
a.emitOperationStatus("summary", "链接不可达", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("summary", "识别中", "图片已上传,正在请求多模态模型", "running")
summary, err := svc.Summarize(a.ctx, uploaded.URL)
if err != nil {
a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.finishSummary(svc, summary)
}
// finishSummary copies the summary to the clipboard and emits the terminal
// success/failure UI state shared by the inline and S3 paths.
func (a *App) finishSummary(svc *application.CaptureSummaryService, summary string) error {
if err := svc.CopySummary(a.ctx, summary); err != nil {
a.emitOperationStatus("summary", "复制失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success")
wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard")
return nil
}
func (a *App) consumePendingCapture() (*pendingCapture, error) {
@@ -412,6 +549,7 @@ func (a *App) GetConfig() domain.AppConfig {
// SaveConfig persists the supplied configuration and re-registers the hotkey
// if it changed.
func (a *App) SaveConfig(cfg domain.AppConfig) error {
cfg.Normalize()
a.mu.Lock()
prev := a.cfg
a.cfg = cfg
@@ -471,15 +609,6 @@ func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
}
return nil
}
// bgo manages its own auth; we can only confirm the binary is installed
// and resolvable (a real transfer would need a live remote + PTY).
if cfg.IsBgo() {
if err := sshpkg.TestBgoConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection (bgo) failed", "err", err)
return err
}
return nil
}
if err := sshpkg.TestConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection failed", "err", err)
return err
@@ -718,6 +847,35 @@ func (a *App) SaveRegionToRemote(result CaptureResult) error {
return a.runSaveRemotePipeline(cropped)
}
// SummarizeRegion uploads the selected screenshot to S3, sends the public URL
// to the configured multimodal LLM, and copies the resulting summary.
func (a *App) SummarizeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SummarizeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
slog.Info("SummarizeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SummarizeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runSummaryPipeline(cropped)
}
// SaveNativeRegionToRemote is the macOS-native overlay equivalent of
// SaveRegionToRemote. The AppKit panel is already closed by the time this
// runs (see saveRemoteSelection in native_overlay_darwin.go), so we only
@@ -748,6 +906,33 @@ func (a *App) SaveNativeRegionToRemote(result CaptureResult) error {
return a.runSaveRemotePipeline(cropped)
}
// SummarizeNativeRegion is the macOS-native overlay equivalent of
// SummarizeRegion. The AppKit panel is already closed before this runs.
func (a *App) SummarizeNativeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SummarizeNativeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
slog.Info("SummarizeNativeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SummarizeNativeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runSummaryPipeline(cropped)
}
func parseNativeAnnotations(raw string) []application.Annotation {
if raw == "" {
return nil
@@ -769,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
@@ -779,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
@@ -797,6 +984,27 @@ func (a *App) QuitApp() {
os.Exit(0)
}
func (a *App) emitOperationStatus(operation, phase, message, state string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "operation:status", OperationStatusPayload{
Operation: operation,
Phase: phase,
Message: message,
State: state,
})
}
switch state {
case "success":
showOperationStatus(phase, message, operationStatusSuccess)
hideOperationStatusAfter(1400 * time.Millisecond)
case "error":
showOperationStatus(phase, message, operationStatusError)
hideOperationStatusAfter(2600 * time.Millisecond)
default:
showOperationStatus(phase, message, operationStatusRunning)
}
}
// runtimeNotifier emits success / failure events via the Wails runtime.
type runtimeNotifier struct{ ctx context.Context }
+211 -19
View File
@@ -3,9 +3,7 @@
* App shell — switches between two distinct UI modes that share the same
* Wails window:
*
* • "settings" : full settings UI (self-drawn title bar + sidebar + form).
* Self-drawn because the window is now Frameless to make
* the overlay paint edge-to-edge.
* • "settings" : full settings UI (native title bar + sidebar + form).
* • "overlay" : Snipaste-style region picker that fills the whole
* primary display.
*
@@ -13,7 +11,7 @@
* emits a `capture:overlay` event with the screenshot payload so the
* frontend knows when (and what) to render.
*/
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import SettingsView from './views/SettingsView.vue'
import Toast from './components/Toast.vue'
import CaptureOverlay from './views/CaptureOverlay.vue'
@@ -24,7 +22,9 @@ import {
CopyRegionImage,
SaveRegionImage,
SaveRegionToRemote,
SummarizeRegion,
CancelRegion,
GetConfig,
} from '../wailsjs/go/main/App'
type HotkeyStatus =
@@ -36,18 +36,38 @@ const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
type Mode = 'settings' | 'overlay'
const mode = ref<Mode>('settings')
type ThemeMode = 'auto' | 'light' | 'dark'
const themeMode = ref<ThemeMode>('auto')
const appThemeClass = computed(() => `theme-${themeMode.value}`)
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
async function loadThemePreference() {
try {
const cfg = await GetConfig()
themeMode.value = normalizeTheme((cfg as any)?.theme)
} catch {
themeMode.value = 'auto'
}
}
// `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'
type SettingsTab = 'general' | 's3' | 'ssh' | 'llm'
const activeTab = ref<SettingsTab>('general')
// Sidebar entries are declarative so adding a destination type later is
// a one-line change. The label values match the user-facing tab names.
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
{ id: 'general', label: 'General' },
{ id: 's3', label: 'S3' },
{ id: 'ssh', label: 'SSH' },
{ id: 'general', label: '通用设置' },
{ id: 's3', label: '对象存储' },
{ id: 'ssh', label: '远程主机' },
{ id: 'llm', label: '智能识图' },
]
interface OverlayPayload {
@@ -62,6 +82,14 @@ const capturing = ref(false)
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
let toastTimer: number | undefined
const operationStatus = ref<{
operation: string
phase: string
message: string
state: 'running' | 'success' | 'error'
} | null>(null)
let operationTimer: number | undefined
function showToast(kind: 'success' | 'error', text: string) {
toast.value = { kind, text }
window.clearTimeout(toastTimer)
@@ -70,6 +98,21 @@ function showToast(kind: 'success' | 'error', text: string) {
}, 3000)
}
function updateOperationStatus(payload: {
operation: string
phase: string
message: string
state: 'running' | 'success' | 'error'
}) {
operationStatus.value = payload
window.clearTimeout(operationTimer)
if (payload.state !== 'running') {
operationTimer = window.setTimeout(() => {
operationStatus.value = null
}, payload.state === 'success' ? 1600 : 3200)
}
}
async function retryHotkey() {
try {
await RetryRegisterHotkey()
@@ -168,6 +211,28 @@ async function onOverlaySaveRemote(rect: {
}
}
async function onOverlaySummarize(rect: {
rect: {
x: number
y: number
w: number
h: number
}
annotations: Array<{
tool: string
color: string
points: Array<{ x: number; y: number }>
}>
}) {
mode.value = 'settings'
overlayPayload.value = null
try {
await SummarizeRegion(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCancel() {
mode.value = 'settings'
overlayPayload.value = null
@@ -179,6 +244,7 @@ async function onOverlayCancel() {
}
onMounted(() => {
void loadThemePreference()
EventsOn('capture:start', () => {
capturing.value = true
})
@@ -194,7 +260,12 @@ onMounted(() => {
capturing.value = false
})
EventsOn('upload:success', (url: string) => {
showToast('success', `Copied: ${url}`)
showToast(
'success',
url === 'summary copied to clipboard'
? 'Summary copied to clipboard'
: `Copied: ${url}`
)
})
EventsOn('upload:failure', (reason: string) => {
showToast('error', reason)
@@ -205,6 +276,7 @@ onMounted(() => {
EventsOn('hotkey:error', (reason: string) => {
hotkeyStatus.value = { state: 'error', reason }
})
EventsOn('operation:status', updateOperationStatus)
})
onUnmounted(() => {
@@ -216,6 +288,7 @@ onUnmounted(() => {
EventsOff('upload:failure')
EventsOff('hotkey:ready')
EventsOff('hotkey:error')
EventsOff('operation:status')
})
</script>
@@ -229,11 +302,12 @@ onUnmounted(() => {
@copy="onOverlayCopy"
@save="onOverlaySave"
@save-remote="onOverlaySaveRemote"
@summarize="onOverlaySummarize"
@cancel="onOverlayCancel"
/>
<!-- Settings mode: standard desktop window; macOS title bar is native. -->
<div v-else class="app-root">
<div v-else class="app-root" :class="appThemeClass">
<div v-if="hotkeyStatus.state === 'error'" class="permission-banner">
<div>
<strong>Global hotkey is not active.</strong>
@@ -259,11 +333,29 @@ onUnmounted(() => {
</div>
</aside>
<section class="content">
<SettingsView :tab="activeTab" />
<SettingsView
:tab="activeTab"
:theme="themeMode"
@theme-change="themeMode = normalizeTheme($event)"
/>
</section>
</main>
<Toast v-if="toast" :kind="toast.kind" :text="toast.text" />
<div
v-if="operationStatus"
class="operation-hud"
:class="operationStatus.state"
>
<span v-if="operationStatus.state === 'running'" class="spinner" />
<span v-else class="status-mark">
{{ operationStatus.state === 'success' ? 'OK' : '!' }}
</span>
<div>
<strong>{{ operationStatus.phase }}</strong>
<p>{{ operationStatus.message }}</p>
</div>
</div>
</div>
</template>
@@ -277,6 +369,15 @@ onUnmounted(() => {
color: #111827;
font-size: 13px;
}
.app-root.theme-light {
color-scheme: light;
}
.app-root.theme-dark {
color-scheme: dark;
}
.app-root.theme-auto {
color-scheme: light dark;
}
.status-pill {
font-size: 11px;
@@ -371,33 +472,124 @@ onUnmounted(() => {
min-width: 0;
}
.operation-hud {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 20;
display: flex;
align-items: center;
gap: 12px;
min-width: 260px;
max-width: 360px;
padding: 12px 14px;
color: #f9fafb;
background: rgba(17, 24, 39, 0.94);
border-radius: 8px;
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.28);
}
.operation-hud strong {
display: block;
margin-bottom: 2px;
font-size: 13px;
}
.operation-hud p {
margin: 0;
color: #d1d5db;
font-size: 12px;
line-height: 1.35;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 0.75s linear infinite;
flex: 0 0 auto;
}
.status-mark {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.operation-hud.success .status-mark {
color: #052e16;
background: #86efac;
}
.operation-hud.error .status-mark {
color: #450a0a;
background: #fca5a5;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.app-root.theme-dark {
background: #1c1d22;
color: #e5e7eb;
}
.app-root.theme-dark .titlebar {
background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36;
}
.app-root.theme-dark .titlebar-title {
color: #f3f4f6;
}
.app-root.theme-dark .sidebar {
background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36;
}
.app-root.theme-dark .sidebar-item {
color: #d1d5db;
}
.app-root.theme-dark .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.app-root.theme-dark .sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.app-root.theme-dark .permission-banner {
background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa;
}
@media (prefers-color-scheme: dark) {
.app-root {
.app-root.theme-auto {
background: #1c1d22;
color: #e5e7eb;
}
.titlebar {
.app-root.theme-auto .titlebar {
background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36;
}
.titlebar-title {
.app-root.theme-auto .titlebar-title {
color: #f3f4f6;
}
.sidebar {
.app-root.theme-auto .sidebar {
background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36;
}
.sidebar-item {
.app-root.theme-auto .sidebar-item {
color: #d1d5db;
}
.sidebar-item:hover:not(.active) {
.app-root.theme-auto .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.sidebar-item.active {
.app-root.theme-auto .sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.permission-banner {
.app-root.theme-auto .permission-banner {
background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa;
+49 -7
View File
@@ -51,6 +51,10 @@ const emit = defineEmits<{
e: 'save-remote',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'summarize',
payload: { rect: Rect; annotations: Annotation[] }
): void
(e: 'cancel'): void
}>()
@@ -111,14 +115,24 @@ const sizeLabel = computed(() => {
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
})
const MARK_TOOLBAR_W = 190
const ACTION_TOOLBAR_W = 216
const TOOLBAR_GROUP_GAP = 8
const rightToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 180, 40, 'right')
return placeToolbar(rect.value, ACTION_TOOLBAR_W, 40, 'right')
})
const leftToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 190, 40, 'left')
if (!rect.value || !rightToolbarPos.value) return null
// 标记组与操作组合并到右侧,紧贴在操作组左侧,中间保留间隔
const x = clamp(
rightToolbarPos.value.x - TOOLBAR_GROUP_GAP - MARK_TOOLBAR_W,
0,
props.width - MARK_TOOLBAR_W
)
return { x, y: rightToolbarPos.value.y }
})
const handles = computed(() => {
@@ -353,7 +367,11 @@ function onSaveRemote() {
emitAction('save-remote')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
function onSummarize() {
emitAction('summarize')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize') {
if (!rect.value) return
const payload = {
rect: { ...rect.value },
@@ -363,6 +381,7 @@ function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
if (action === 'copy') emit('copy', payload)
if (action === 'save') emit('save', payload)
if (action === 'save-remote') emit('save-remote', payload)
if (action === 'summarize') emit('summarize', payload)
}
function onCancel() {
@@ -583,8 +602,8 @@ onUnmounted(() => {
/>
<button
class="action-btn"
data-tip="复制图"
aria-label="复制图"
data-tip="复制图"
aria-label="复制图"
@click="onCopy"
v-html="copyIcon"
/>
@@ -602,6 +621,19 @@ onUnmounted(() => {
@click="onSaveRemote"
v-html="saveRemoteIcon"
/>
<button
class="action-btn"
data-tip="复制总结"
aria-label="复制总结"
@click="onSummarize"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 3h8l4 4v14H6z" />
<path d="M14 3v5h4" />
<path d="M9 12h6" />
<path d="M9 16h5" />
</svg>
</button>
<button
class="action-btn primary"
data-tip="上传云端"
@@ -696,7 +728,7 @@ onUnmounted(() => {
width: 190px;
}
.action-toolbar {
width: 180px;
width: 216px;
}
.icon-btn,
@@ -752,6 +784,16 @@ onUnmounted(() => {
fill: currentColor;
pointer-events: none;
}
.action-btn > svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.action-btn::after {
content: attr(data-tip);
position: absolute;
+323 -43
View File
@@ -8,10 +8,11 @@
* • "general" — the global hotkey / capture parameters.
* • "s3" — S3-compatible object-storage credentials.
* • "ssh" — SSH/SCP destination for the "save remote" button.
* • "llm" — multimodal screenshot summary provider settings.
*
* State flow: load() pulls config from Go on mount, save() pushes back.
*/
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import {
GetConfig,
SaveConfig,
@@ -22,10 +23,16 @@ import {
// Tab discriminator shared with the App shell. Kept as a string union so
// the parent can pass the value without importing a type from this view.
type TabId = 'general' | 's3' | 'ssh'
type TabId = 'general' | 's3' | 'ssh' | 'llm'
type ThemeMode = 'auto' | 'light' | 'dark'
const props = defineProps<{
tab: TabId
theme: ThemeMode
}>()
const emit = defineEmits<{
(event: 'theme-change', theme: ThemeMode): void
}>()
interface S3Config {
@@ -51,12 +58,51 @@ interface SSHConfig {
connectTimeoutSecs: number
}
interface LLMProviderConfig {
label: string
baseUrl: string
apiKey: string
model: string
maxTokens: number
temperature: number
timeoutSecs: number
}
interface LLMConfig {
activeProvider: string
prompt: string
providers: Record<string, LLMProviderConfig>
}
interface AppConfig {
hotkey: string
theme: ThemeMode
s3: S3Config
ssh: SSHConfig
llm: LLMConfig
}
const llmProviderOptions = [
{ id: 'qwen', label: '阿里千问多模态' },
{ id: 'doubao', label: '火山方舟豆包多模态' },
{ id: 'openai', label: 'ChatGPT / OpenAI-compatible' },
]
const themeOptions: Array<{ id: ThemeMode; label: string }> = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
{ id: 'auto', label: '自动' },
]
const DEFAULT_SUMMARY_PROMPT = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。
要求:
- 先说明截图中最重要的信息和用户可能想表达的意图。
- 如果截图包含报错、异常状态、表格、代码、界面控件或关键数字,请准确提取。
- 如果截图内容不足以判断,不要编造;直接说明不确定点。
- 输出尽量简洁,默认 3 到 6 条要点。
- 不要输出 Markdown 标题,不要寒暄。`
// `defaultConfig` keeps the initial render in sync with the Go-side
// DefaultAppConfig so the UI never flashes empty fields before load()
// completes. Centralising the defaults also avoids subtle drift between
@@ -64,6 +110,7 @@ interface AppConfig {
function defaultConfig(): AppConfig {
return {
hotkey: 'cmd+shift+a',
theme: 'auto',
s3: {
endpoint: '',
region: 'us-east-1',
@@ -85,6 +132,39 @@ function defaultConfig(): AppConfig {
knownHostsPath: '',
connectTimeoutSecs: 10,
},
llm: {
activeProvider: 'openai',
prompt: DEFAULT_SUMMARY_PROMPT,
providers: {
qwen: {
label: '阿里千问多模态',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: '',
model: 'qwen-vl-plus',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
doubao: {
label: '火山方舟豆包多模态',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey: '',
model: '',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
openai: {
label: 'ChatGPT / OpenAI-compatible',
baseUrl: 'https://api.openai.com/v1',
apiKey: '',
model: 'gpt-5.5',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
},
},
}
}
@@ -109,6 +189,21 @@ const sshPathDisplay = computed({
},
})
const activeLLMProvider = computed(() => {
const id = config.value.llm.activeProvider || 'openai'
if (!config.value.llm.providers[id]) {
config.value.llm.activeProvider = 'openai'
return config.value.llm.providers.openai
}
return config.value.llm.providers[id]
})
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
function flash(kind: 'ok' | 'err', text: string) {
message.value = { kind, text }
window.setTimeout(() => {
@@ -125,7 +220,14 @@ async function load() {
Object.assign(merged, cfg)
merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
merged.llm = { ...merged.llm, ...(cfg as any).llm }
merged.llm.providers = {
...defaultConfig().llm.providers,
...((cfg as any).llm?.providers ?? {}),
}
merged.theme = normalizeTheme((cfg as any).theme)
config.value = merged
emit('theme-change', config.value.theme)
}
}
@@ -169,11 +271,16 @@ async function manualCapture() {
await CaptureNow()
}
watch(
() => config.value.theme,
(theme) => emit('theme-change', normalizeTheme(theme))
)
onMounted(load)
</script>
<template>
<div class="settings">
<div class="settings" :class="`theme-${props.theme}`">
<header class="hero">
<div>
<h1>SnapGo</h1>
@@ -185,17 +292,31 @@ onMounted(load)
<button class="btn primary" @click="manualCapture">Capture now</button>
</header>
<!-- General tab only the shortcut configuration lives here. -->
<!-- General tab shortcut and appearance configuration. -->
<section v-if="props.tab === 'general'" class="card">
<h2>Shortcut</h2>
<label class="field">
<span>Global hotkey</span>
<input
v-model="config.hotkey"
placeholder="cmd+shift+a"
spellcheck="false"
/>
</label>
<h2>General</h2>
<div class="grid">
<label class="field">
<span>Global hotkey</span>
<input
v-model="config.hotkey"
placeholder="cmd+shift+a"
spellcheck="false"
/>
</label>
<label class="field">
<span>主题选择</span>
<select v-model="config.theme">
<option
v-for="themeOption in themeOptions"
:key="themeOption.id"
:value="themeOption.id"
>
{{ themeOption.label }}
</option>
</select>
</label>
</div>
<p class="hint">
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
az or 09. Example: <code>cmd+shift+a</code>.
@@ -289,7 +410,6 @@ onMounted(load)
<option value="password">Password</option>
<option value="key">SSH-Key</option>
<option value="kerberos">Kerberos</option>
<option value="bgo">bgo</option>
</select>
</label>
<label class="field">
@@ -351,18 +471,6 @@ onMounted(load)
keys. Make sure password-less login already works (e.g. via
<code>ssh-copy-id</code>).
</p>
<p v-else-if="config.ssh.authMethod === 'bgo'" class="hint">
bgo mode delegates uploads to the internal <code>bgo scp</code> client,
which handles authentication itself. Install bgo and add it to your
<code>PATH</code> first see the
<a
href="https://bytedance.larkoffice.com/wiki/HpUCw7qRDiW66YkzKdwcXYrTnLf"
target="_blank"
rel="noopener"
>setup guide</a>.
Note: bgo cannot create remote directories, so screenshots are saved
flat into the remote home directory (the Remote path setting is ignored).
</p>
<p
v-else-if="config.ssh.authMethod === 'password' && !config.ssh.password"
class="hint warn"
@@ -381,6 +489,103 @@ onMounted(load)
</div>
</section>
<!-- LLM tab multimodal screenshot summary provider settings. -->
<section v-if="props.tab === 'llm'" class="card">
<h2>LLM screenshot summary</h2>
<div class="grid">
<label class="field full">
<span>Provider</span>
<select v-model="config.llm.activeProvider">
<option
v-for="provider in llmProviderOptions"
:key="provider.id"
:value="provider.id"
>
{{ provider.label }}
</option>
</select>
</label>
<label class="field full">
<span>Base URL *</span>
<input
v-model="activeLLMProvider.baseUrl"
placeholder="https://api.openai.com/v1"
spellcheck="false"
/>
</label>
<label class="field">
<span>API Key *</span>
<input v-model="activeLLMProvider.apiKey" type="password" />
</label>
<label class="field">
<span>Model *</span>
<input
v-model="activeLLMProvider.model"
placeholder="gpt-5.5 / qwen-vl-plus / Ark endpoint ID"
spellcheck="false"
/>
</label>
<label class="field">
<span>Max tokens</span>
<input
v-model.number="activeLLMProvider.maxTokens"
type="number"
min="64"
max="4096"
placeholder="600"
/>
</label>
<label class="field">
<span>Temperature</span>
<input
v-model.number="activeLLMProvider.temperature"
type="number"
min="0"
max="2"
step="0.1"
placeholder="0.2"
/>
</label>
<label class="field">
<span>Timeout (sec)</span>
<input
v-model.number="activeLLMProvider.timeoutSecs"
type="number"
min="10"
max="300"
placeholder="60"
/>
</label>
<label class="field full">
<span>Prompt</span>
<textarea
v-model="config.llm.prompt"
class="prompt-textarea"
spellcheck="false"
/>
</label>
</div>
<p class="hint">
The "copy summary" screenshot action first uploads the image through the
S3 configuration, then sends the public image URL to this multimodal
provider. Keep S3 configured before using it.
</p>
<p v-if="config.llm.activeProvider === 'doubao'" class="hint">
Volcengine Ark usually uses the inference endpoint ID as the model
value; paste the endpoint/model identifier from your Ark console.
</p>
<p v-else-if="config.llm.activeProvider === 'qwen'" class="hint">
DashScope OpenAI-compatible mode is used here. If your workspace uses a
regional endpoint, replace Base URL with that compatible-mode URL.
</p>
<div class="actions">
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving' : 'Save' }}
</button>
</div>
</section>
<transition name="fade">
<div
v-if="message"
@@ -400,6 +605,15 @@ onMounted(load)
padding: 28px 32px 60px;
color: #1f2937;
}
.settings.theme-light {
color-scheme: light;
}
.settings.theme-dark {
color-scheme: dark;
}
.settings.theme-auto {
color-scheme: light dark;
}
.hero {
display: flex;
align-items: center;
@@ -473,13 +687,13 @@ kbd {
.field input[type='text'],
.field input[type='number'],
.field input:not([type='checkbox']),
.field select {
.field select,
.field textarea {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 7px 10px;
font-size: 13px;
line-height: 1.4;
height: 36px;
background: #fff;
color: inherit;
outline: none;
@@ -487,8 +701,20 @@ kbd {
width: 100%;
box-sizing: border-box;
}
.field input[type='text'],
.field input[type='number'],
.field input:not([type='checkbox']),
.field select {
height: 36px;
}
.field textarea {
min-height: 168px;
resize: vertical;
font-family: inherit;
}
.field input:focus,
.field select:focus {
.field select:focus,
.field textarea:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
}
@@ -603,46 +829,100 @@ kbd {
.fade-leave-to {
opacity: 0;
}
.settings.theme-dark {
color: #e5e7eb;
}
.settings.theme-dark .card {
background: #1f2937;
border-color: #374151;
}
.settings.theme-dark .field span {
color: #d1d5db;
}
.settings.theme-dark .field input[type='text'],
.settings.theme-dark .field input[type='number'],
.settings.theme-dark .field input:not([type='checkbox']),
.settings.theme-dark .field select,
.settings.theme-dark .field textarea {
background: #111827;
border-color: #374151;
color: #fff;
}
.settings.theme-dark .field input::placeholder,
.settings.theme-dark .field textarea::placeholder {
color: #9ca3af;
}
.settings.theme-dark .field input:disabled {
background: #1f2937;
color: #6b7280;
}
.settings.theme-dark .prefixed-input {
background: #111827;
border-color: #374151;
}
.settings.theme-dark .input-prefix {
background: #1f2937;
border-right-color: #374151;
color: #d1d5db;
}
.settings.theme-dark .btn:not(.primary) {
background: #1f2937;
border-color: #374151;
color: #e5e7eb;
}
.settings.theme-dark .btn:not(.primary):hover:not(:disabled) {
background: #374151;
}
.settings.theme-dark .subtitle {
color: #9ca3af;
}
@media (prefers-color-scheme: dark) {
.settings {
.settings.theme-auto {
color: #e5e7eb;
}
.card {
.settings.theme-auto .card {
background: #1f2937;
border-color: #374151;
}
.field input {
.settings.theme-auto .field span {
color: #d1d5db;
}
.settings.theme-auto .field input[type='text'],
.settings.theme-auto .field input[type='number'],
.settings.theme-auto .field input:not([type='checkbox']),
.settings.theme-auto .field select,
.settings.theme-auto .field textarea {
background: #111827;
border-color: #374151;
color: #e5e7eb;
color: #fff;
}
.field select {
background: #111827;
border-color: #374151;
color: #e5e7eb;
.settings.theme-auto .field input::placeholder,
.settings.theme-auto .field textarea::placeholder {
color: #9ca3af;
}
.field input:disabled {
.settings.theme-auto .field input:disabled {
background: #1f2937;
color: #6b7280;
}
.prefixed-input {
.settings.theme-auto .prefixed-input {
background: #111827;
border-color: #374151;
}
.input-prefix {
.settings.theme-auto .input-prefix {
background: #1f2937;
border-right-color: #374151;
color: #d1d5db;
}
.btn {
.settings.theme-auto .btn:not(.primary) {
background: #1f2937;
border-color: #374151;
color: #e5e7eb;
}
.btn:hover:not(:disabled) {
.settings.theme-auto .btn:not(.primary):hover:not(:disabled) {
background: #374151;
}
.subtitle {
.settings.theme-auto .subtitle {
color: #9ca3af;
}
}
+4
View File
@@ -37,6 +37,10 @@ export function SaveRegionToRemote(arg1:main.CaptureResult):Promise<void>;
export function ShowWindow():Promise<void>;
export function SummarizeNativeRegion(arg1:main.CaptureResult):Promise<void>;
export function SummarizeRegion(arg1:main.CaptureResult):Promise<void>;
export function TestConnection(arg1:domain.S3Config):Promise<void>;
export function TestSSHConnection(arg1:domain.SSHConfig):Promise<void>;
+8
View File
@@ -70,6 +70,14 @@ export function ShowWindow() {
return window['go']['main']['App']['ShowWindow']();
}
export function SummarizeNativeRegion(arg1) {
return window['go']['main']['App']['SummarizeNativeRegion'](arg1);
}
export function SummarizeRegion(arg1) {
return window['go']['main']['App']['SummarizeRegion'](arg1);
}
export function TestConnection(arg1) {
return window['go']['main']['App']['TestConnection'](arg1);
}
+66
View File
@@ -53,6 +53,66 @@ export namespace application {
export namespace domain {
export class LLMProviderConfig {
label: string;
baseUrl: string;
apiKey: string;
model: string;
maxTokens: number;
temperature: number;
timeoutSecs: number;
maxInlineBytes: number;
static createFrom(source: any = {}) {
return new LLMProviderConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.baseUrl = source["baseUrl"];
this.apiKey = source["apiKey"];
this.model = source["model"];
this.maxTokens = source["maxTokens"];
this.temperature = source["temperature"];
this.timeoutSecs = source["timeoutSecs"];
this.maxInlineBytes = source["maxInlineBytes"];
}
}
export class LLMConfig {
activeProvider: string;
prompt: string;
providers: Record<string, LLMProviderConfig>;
static createFrom(source: any = {}) {
return new LLMConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.activeProvider = source["activeProvider"];
this.prompt = source["prompt"];
this.providers = this.convertValues(source["providers"], LLMProviderConfig, true);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SSHConfig {
host: string;
port: number;
@@ -109,8 +169,10 @@ export namespace domain {
}
export class AppConfig {
hotkey: string;
theme: string;
s3: S3Config;
ssh: SSHConfig;
llm: LLMConfig;
static createFrom(source: any = {}) {
return new AppConfig(source);
@@ -119,8 +181,10 @@ export namespace domain {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hotkey = source["hotkey"];
this.theme = source["theme"];
this.s3 = this.convertValues(source["s3"], S3Config);
this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.llm = this.convertValues(source["llm"], LLMConfig);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -143,6 +207,8 @@ export namespace domain {
}
}
export namespace main {
+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)
}
-17
View File
@@ -69,12 +69,6 @@ func (s *CaptureAndSSHService) ExecuteWithBytes(ctx context.Context, pngBytes []
return fmt.Errorf("empty screenshot")
}
relPath := buildRemoteRelPath(s.Cfg.PathPrefix)
// bgo cannot create remote directories, so it uploads flat into the
// remote $HOME with a timestamped filename rather than a date-grouped
// subdirectory tree (see BgoUploader for the rationale).
if s.Cfg.IsBgo() {
relPath = buildRemoteFlatName()
}
sshSvcLog().Info("save-remote pipeline start",
"host", s.Cfg.Host, "user", s.Cfg.User, "port", s.Cfg.Port,
"size", len(pngBytes), "remote_path", relPath,
@@ -134,17 +128,6 @@ func buildRemoteRelPath(prefix string) string {
)
}
// buildRemoteFlatName produces a flat, timestamped filename (no directory
// components) for destinations that cannot create remote directories, such
// as the bgo client. The file lands directly in the remote $HOME.
func buildRemoteFlatName() string {
now := time.Now()
return fmt.Sprintf("%s-%s.png",
now.Format("20060102-150405"),
randomSuffix(6),
)
}
func (s *CaptureAndSSHService) notifyFailure(reason string) {
if s.Notifier != nil {
s.Notifier.NotifyFailure(reason)
+158
View File
@@ -0,0 +1,158 @@
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 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)
}
// 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. 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")
}
if len(pngBytes) == 0 {
return nil, fmt.Errorf("empty screenshot")
}
key := buildObjectKey(s.PathPrefix)
start := time.Now()
url, err := s.Provider.Upload(ctx, key, pngBytes, "image/png")
if err != nil {
return nil, fmt.Errorf("s3 put object: %w", err)
}
return &domain.UploadResult{
URL: url,
Key: key,
Provider: s.Provider.Name(),
Elapsed: time.Since(start),
}, nil
}
// 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")
}
return s.Summarizer.SummarizeImage(ctx, s.Prompt, imageURL)
}
// CopySummary writes the final summary text to the clipboard.
func (s *CaptureSummaryService) CopySummary(_ context.Context, summary string) error {
if summary == "" {
return fmt.Errorf("empty summary")
}
if s.Clipboard == nil {
return fmt.Errorf("clipboard is not configured")
}
if err := s.Clipboard.WriteText(summary); err != nil {
return fmt.Errorf("clipboard write failed: %w", err)
}
return nil
}
@@ -0,0 +1,140 @@
package application
import (
"context"
"strings"
"testing"
)
type fakeOSSProvider struct {
key string
data []byte
contentType string
url string
deletedKey string
}
func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, contentType string) (string, error) {
f.key = key
f.data = append([]byte(nil), data...)
f.contentType = contentType
if f.url == "" {
return "https://cdn.example.com/" + key, nil
}
return f.url, nil
}
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
text string
}
func (f *fakeSummarizer) SummarizeImage(_ context.Context, prompt string, imageURL string) (string, error) {
f.prompt = prompt
f.url = imageURL
if f.text == "" {
return "summary text", nil
}
return f.text, nil
}
func TestCaptureSummaryServiceUploadsSummarizesAndCopies(t *testing.T) {
ctx := context.Background()
provider := &fakeOSSProvider{}
summarizer := &fakeSummarizer{}
clip := &fakeClipboard{}
svc := &CaptureSummaryService{
Provider: provider,
Summarizer: summarizer,
Clipboard: clip,
Prompt: "describe screenshot",
PathPrefix: "snapgo/",
}
uploaded, err := svc.UploadImage(ctx, []byte("png"))
if err != nil {
t.Fatalf("upload image: %v", err)
}
if provider.contentType != "image/png" {
t.Fatalf("expected image/png content type, got %q", provider.contentType)
}
if string(provider.data) != "png" {
t.Fatalf("expected uploaded png bytes, got %q", string(provider.data))
}
summary, err := svc.Summarize(ctx, uploaded.URL)
if err != nil {
t.Fatalf("summarize: %v", err)
}
if summarizer.prompt != "describe screenshot" {
t.Fatalf("expected prompt forwarded, got %q", summarizer.prompt)
}
if summarizer.url != uploaded.URL {
t.Fatalf("expected uploaded url forwarded, got %q", summarizer.url)
}
if summary != "summary text" {
t.Fatalf("expected summary text, got %q", summary)
}
if err := svc.CopySummary(ctx, summary); err != nil {
t.Fatalf("copy summary: %v", err)
}
if clip.text != "summary text" {
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" }
+189 -15
View File
@@ -42,11 +42,6 @@ type S3Config struct {
// `kinit` is reused. Native GSSAPI is required here
// because macOS stores tickets in an API: ccache
// that pure-Go SSH libraries cannot read.
// "bgo" → delegate to the internal `bgo scp` client which
// handles its own auth/credential management. bgo
// must run under a PTY and cannot create remote
// directories, so this mode uploads to the user's
// remote $HOME with a flat, timestamped filename.
// "" / "builtin" → legacy combined behaviour (password → agent → key
// files) kept only for backward compatibility with
// configs written before the method was split.
@@ -77,6 +72,41 @@ type SSHConfig struct {
ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
}
// LLMProviderConfig stores one OpenAI-compatible multimodal chat endpoint.
//
// The three built-in providers (Qwen, Doubao/Ark, OpenAI-compatible) all use
// the same request shape: chat/completions with a user content array that
// contains text plus an image_url item. Keeping them as provider records lets
// users switch between credentials/models without retyping every field.
type LLMProviderConfig struct {
Label string `json:"label"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
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"`
Prompt string `json:"prompt"`
Providers map[string]LLMProviderConfig `json:"providers"`
}
// SSH authentication method identifiers stored in SSHConfig.AuthMethod.
const (
// SSHAuthBuiltin is the legacy combined method (password → agent → key
@@ -90,22 +120,36 @@ const (
// SSHAuthKerberos delegates to the system ssh binary so an existing
// Kerberos credential cache (populated by `kinit`) is reused via GSSAPI.
SSHAuthKerberos = "kerberos"
// SSHAuthBgo delegates to the internal `bgo scp` client, which manages
// its own authentication. bgo requires a PTY and cannot create remote
// directories, so uploads land flat in the remote $HOME.
SSHAuthBgo = "bgo"
)
// Built-in LLM provider identifiers.
const (
LLMProviderQwen = "qwen"
LLMProviderDoubao = "doubao"
LLMProviderOpenAI = "openai"
)
// Theme preference identifiers stored in AppConfig.Theme.
const (
ThemeAuto = "auto"
ThemeLight = "light"
ThemeDark = "dark"
)
const DefaultSummaryPrompt = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。
要求:
- 先说明截图中最重要的信息和用户可能想表达的意图。
- 如果截图包含报错、异常状态、表格、代码、界面控件或关键数字,请准确提取。
- 如果截图内容不足以判断,不要编造;直接说明不确定点。
- 输出尽量简洁,默认 3 到 6 条要点。
- 不要输出 Markdown 标题,不要寒暄。`
// IsKerberos reports whether this config requests Kerberos/GSSAPI auth.
func (c SSHConfig) IsKerberos() bool {
return c.AuthMethod == SSHAuthKerberos
}
// IsBgo reports whether this config requests the internal bgo client.
func (c SSHConfig) IsBgo() bool {
return c.AuthMethod == SSHAuthBgo
}
// AppConfig is the top-level on-disk configuration document.
//
// We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS,
@@ -116,18 +160,25 @@ type AppConfig struct {
// in the infrastructure hotkey adapter.
Hotkey string `json:"hotkey"`
// Theme controls the settings UI appearance: "auto", "light", or "dark".
Theme string `json:"theme"`
// S3 holds the active S3-compatible storage configuration.
S3 S3Config `json:"s3"`
// SSH holds the configuration for the optional "save to remote via scp"
// destination triggered by the save-remote toolbar button.
SSH SSHConfig `json:"ssh"`
// LLM holds provider settings for the "copy summary" screenshot action.
LLM LLMConfig `json:"llm"`
}
// DefaultAppConfig returns sane zero-value defaults used on first launch.
func DefaultAppConfig() AppConfig {
return AppConfig{
cfg := AppConfig{
Hotkey: "cmd+shift+a",
Theme: ThemeAuto,
S3: S3Config{
PathPrefix: "snapgo/",
UsePathStyle: true,
@@ -138,6 +189,111 @@ func DefaultAppConfig() AppConfig {
ConnectTimeoutSecs: 10,
StrictHostKey: false,
},
LLM: DefaultLLMConfig(),
}
cfg.Normalize()
return cfg
}
// DefaultLLMConfig returns the built-in provider presets. API keys and exact
// models remain user-editable because each provider account may expose
// different model names / endpoint IDs.
func DefaultLLMConfig() LLMConfig {
return LLMConfig{
ActiveProvider: LLMProviderOpenAI,
Prompt: DefaultSummaryPrompt,
Providers: map[string]LLMProviderConfig{
LLMProviderQwen: {
Label: "阿里千问多模态",
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
Model: "qwen-vl-plus",
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
},
LLMProviderDoubao: {
Label: "火山方舟豆包多模态",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Model: "",
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
},
LLMProviderOpenAI: {
Label: "ChatGPT / OpenAI-compatible",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-5.5",
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
MaxInlineBytes: DefaultMaxInlineBytes,
},
},
}
}
// Normalize fills defaults into configs written by older app versions while
// preserving any user-provided provider fields.
func (c *AppConfig) Normalize() {
if c.Hotkey == "" {
c.Hotkey = "cmd+shift+a"
}
if c.Theme != ThemeLight && c.Theme != ThemeDark && c.Theme != ThemeAuto {
c.Theme = ThemeAuto
}
if c.S3.PathPrefix == "" {
c.S3.PathPrefix = "snapgo/"
}
if c.SSH.Port == 0 {
c.SSH.Port = 22
}
if c.SSH.PathPrefix == "" {
c.SSH.PathPrefix = "snapgo/"
}
if c.SSH.ConnectTimeoutSecs == 0 {
c.SSH.ConnectTimeoutSecs = 10
}
defaultLLM := DefaultLLMConfig()
if c.LLM.ActiveProvider == "" {
c.LLM.ActiveProvider = defaultLLM.ActiveProvider
}
if c.LLM.Prompt == "" {
c.LLM.Prompt = defaultLLM.Prompt
}
if c.LLM.Providers == nil {
c.LLM.Providers = map[string]LLMProviderConfig{}
}
for id, def := range defaultLLM.Providers {
current, ok := c.LLM.Providers[id]
if !ok {
c.LLM.Providers[id] = def
continue
}
if current.Label == "" {
current.Label = def.Label
}
if current.BaseURL == "" {
current.BaseURL = def.BaseURL
}
if current.Model == "" && id != LLMProviderDoubao {
current.Model = def.Model
}
if current.MaxTokens == 0 {
current.MaxTokens = def.MaxTokens
}
if current.Temperature < 0 {
current.Temperature = def.Temperature
}
if current.TimeoutSecs == 0 {
current.TimeoutSecs = def.TimeoutSecs
}
if current.MaxInlineBytes <= 0 {
current.MaxInlineBytes = DefaultMaxInlineBytes
}
c.LLM.Providers[id] = current
}
}
@@ -155,3 +311,21 @@ func (c AppConfig) IsS3Configured() bool {
func (c AppConfig) IsSSHConfigured() bool {
return c.SSH.Host != "" && c.SSH.User != ""
}
// ActiveLLMProvider returns the selected provider config plus a boolean
// indicating whether the selection exists.
func (c AppConfig) ActiveLLMProvider() (string, LLMProviderConfig, bool) {
id := c.LLM.ActiveProvider
if id == "" {
id = LLMProviderOpenAI
}
provider, ok := c.LLM.Providers[id]
return id, provider, ok
}
// IsLLMConfigured reports whether the selected multimodal provider has the
// fields required to make a request.
func (c AppConfig) IsLLMConfigured() bool {
_, provider, ok := c.ActiveLLMProvider()
return ok && provider.BaseURL != "" && provider.APIKey != "" && provider.Model != ""
}
+8 -6
View File
@@ -1,12 +1,12 @@
// Package config provides JSON-file backed persistence for AppConfig.
//
// Design rationale:
// - JSON keeps the file human-readable, which is useful while the MVP has
// no settings UI for every option.
// - Storage location follows os.UserConfigDir() so each platform places it
// in the canonical spot (macOS: ~/Library/Application Support/SnapGo).
// - File mode 0600 / dir mode 0700 mitigate accidental leakage of access
// keys before we wire up the OS keychain (deferred to a later spec).
// - JSON keeps the file human-readable, which is useful while the MVP has
// no settings UI for every option.
// - Storage location follows os.UserConfigDir() so each platform places it
// in the canonical spot (macOS: ~/Library/Application Support/SnapGo).
// - File mode 0600 / dir mode 0700 mitigate accidental leakage of access
// keys before we wire up the OS keychain (deferred to a later spec).
package config
import (
@@ -65,6 +65,7 @@ func (s *FileStore) Load() (domain.AppConfig, error) {
if err := json.Unmarshal(data, &cfg); err != nil {
return domain.DefaultAppConfig(), fmt.Errorf("parse config: %w", err)
}
cfg.Normalize()
return cfg, nil
}
@@ -74,6 +75,7 @@ func (s *FileStore) Save(cfg domain.AppConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
cfg.Normalize()
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
+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.
@@ -0,0 +1,174 @@
// Package llm contains multimodal LLM adapters.
//
// The built-in providers are intentionally implemented through the
// OpenAI-compatible chat/completions shape. Qwen DashScope, Volcengine Ark,
// and OpenAI-compatible gateways all accept the same "text + image_url"
// message content pattern, so one small HTTP adapter is enough here.
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// VisionClient calls one configured multimodal chat endpoint.
type VisionClient struct {
providerID string
cfg domain.LLMProviderConfig
httpClient *http.Client
}
// NewVisionClient validates cfg and returns a reusable client.
func NewVisionClient(providerID string, cfg domain.LLMProviderConfig) (*VisionClient, error) {
if strings.TrimSpace(cfg.BaseURL) == "" {
return nil, fmt.Errorf("llm base url is required")
}
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, fmt.Errorf("llm api key is required")
}
if strings.TrimSpace(cfg.Model) == "" {
return nil, fmt.Errorf("llm model is required")
}
timeout := cfg.TimeoutSecs
if timeout <= 0 {
timeout = 60
}
return &VisionClient{
providerID: providerID,
cfg: cfg,
httpClient: &http.Client{Timeout: time.Duration(timeout) * time.Second},
}, nil
}
// SummarizeImage asks the model to summarise an image available at imageURL.
func (c *VisionClient) SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error) {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return "", fmt.Errorf("llm prompt is empty")
}
imageURL = strings.TrimSpace(imageURL)
if imageURL == "" {
return "", fmt.Errorf("image url is empty")
}
body := map[string]any{
"model": c.cfg.Model,
"messages": []map[string]any{
{
"role": "user",
"content": []map[string]any{
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": map[string]any{"url": imageURL}},
},
},
},
}
if c.cfg.MaxTokens > 0 {
if c.providerID == domain.LLMProviderOpenAI {
body["max_completion_tokens"] = c.cfg.MaxTokens
} else {
body["max_tokens"] = c.cfg.MaxTokens
}
}
if c.cfg.Temperature >= 0 {
body["temperature"] = c.cfg.Temperature
}
payload, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal llm request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatCompletionsURL(c.cfg.BaseURL), bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("build llm request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("llm status %d: %s", resp.StatusCode, compactBody(data))
}
var decoded chatCompletionResponse
if err := json.Unmarshal(data, &decoded); err != nil {
return "", fmt.Errorf("decode llm response: %w", err)
}
if decoded.Error != nil && decoded.Error.Message != "" {
return "", fmt.Errorf("llm error: %s", decoded.Error.Message)
}
if len(decoded.Choices) == 0 {
return "", fmt.Errorf("llm response has no choices")
}
text := strings.TrimSpace(contentText(decoded.Choices[0].Message.Content))
if text == "" {
return "", fmt.Errorf("llm response is empty")
}
return text, nil
}
func chatCompletionsURL(baseURL string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(base, "/chat/completions") {
return base
}
return base + "/chat/completions"
}
type chatCompletionResponse struct {
Choices []struct {
Message struct {
Content any `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Code any `json:"code"`
} `json:"error"`
}
func contentText(content any) string {
switch v := content.(type) {
case string:
return v
case []any:
parts := make([]string, 0, len(v))
for _, item := range v {
obj, ok := item.(map[string]any)
if !ok {
continue
}
if text, ok := obj["text"].(string); ok {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
default:
return ""
}
}
func compactBody(data []byte) string {
text := strings.TrimSpace(string(data))
if text == "" {
return "<empty body>"
}
if len(text) > 800 {
return text[:800] + "..."
}
return text
}
@@ -0,0 +1,86 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/mmmy/snapgo/internal/domain"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestVisionClientSummarizeImageUsesChatCompletionImageURL(t *testing.T) {
var requestBody map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("expected /v1/chat/completions, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("unexpected auth header %q", got)
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatalf("decode request: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(`{"choices":[{"message":{"content":"summary ok"}}]}`)),
}, nil
})
client, err := NewVisionClient(domain.LLMProviderQwen, domain.LLMProviderConfig{
BaseURL: "https://example.test/v1",
APIKey: "test-key",
Model: "qwen-vl-plus",
MaxTokens: 321,
Temperature: 0.2,
TimeoutSecs: 5,
})
if err != nil {
t.Fatalf("new client: %v", err)
}
client.httpClient.Transport = transport
text, err := client.SummarizeImage(context.Background(), "describe", "https://cdn.example.com/a.png")
if err != nil {
t.Fatalf("summarize image: %v", err)
}
if text != "summary ok" {
t.Fatalf("expected response text, got %q", text)
}
if requestBody["model"] != "qwen-vl-plus" {
t.Fatalf("expected model forwarded, got %#v", requestBody["model"])
}
if requestBody["max_tokens"] != float64(321) {
t.Fatalf("expected max_tokens forwarded, got %#v", requestBody["max_tokens"])
}
messages := requestBody["messages"].([]any)
content := messages[0].(map[string]any)["content"].([]any)
if content[0].(map[string]any)["text"] != "describe" {
t.Fatalf("expected prompt content, got %#v", content[0])
}
image := content[1].(map[string]any)
if image["type"] != "image_url" {
t.Fatalf("expected image_url item, got %#v", image)
}
url := image["image_url"].(map[string]any)["url"]
if url != "https://cdn.example.com/a.png" {
t.Fatalf("expected image url forwarded, got %#v", url)
}
}
func TestChatCompletionsURLAcceptsFullEndpoint(t *testing.T) {
full := "https://example.com/v1/chat/completions"
if got := chatCompletionsURL(full); got != full {
t.Fatalf("expected full endpoint unchanged, got %q", got)
}
}
+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 {
-200
View File
@@ -1,200 +0,0 @@
// bgo_uploader.go — an SSHUploader implementation that delegates to the
// internal `bgo scp` client. bgo is ByteDance's internal credential/auth
// management tool; it wraps ssh/scp and handles login transparently once the
// user has configured it (see the Lark guide linked in the Settings UI).
//
// 设计理由 (为什么不能直接 exec bgo, 也不能复用 KerberosUploader):
// - bgo 强制要求在 PTY (伪终端) 下运行: 它会对 stdin/stdout 做终端 ioctl
// (resize pty). 没有 PTY 时直接 panic ("operation not supported by
// device"). 因此必须用系统 /usr/bin/script 为其分配一个伪终端:
// script -q /dev/null <bgo> scp <src> <user@host:dst>
// 这样既不引入额外的 PTY 依赖, 又满足 bgo 的运行约束.
// - bgo scp 不会自动创建远程目录, 且 bgo ssh 无法执行远程命令 (会 panic),
// 所以无法像 Kerberos 那样先 `mkdir -p`. 故 bgo 模式只能把文件平铺上传
// 到远端用户的 $HOME 根目录 (文件名内嵌时间戳保证唯一), 上层 service
// 负责生成扁平文件名.
// - bgo 自带鉴权, 不需要也不应传 GSSAPI / 密码 / 公钥相关的 ssh 选项.
package ssh
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// systemScriptPath is the absolute path to the macOS BSD `script` utility,
// which we use purely to allocate a PTY for bgo. Absolute path avoids PATH
// hijacking and the minimal PATH GUI apps inherit on macOS.
const systemScriptPath = "/usr/bin/script"
// bgoSearchPaths lists the locations we probe for the bgo binary when it is
// not already resolvable via $PATH. GUI apps on macOS launch with a minimal
// PATH that usually omits ~/.local/bin and Homebrew dirs, so we look there
// explicitly rather than failing with a confusing "command not found".
func bgoSearchPaths() []string {
home, _ := os.UserHomeDir()
return []string{
filepath.Join(home, ".local", "bin", "bgo"),
"/usr/local/bin/bgo",
"/opt/homebrew/bin/bgo",
"/opt/bin/bgo",
}
}
// BgoUploader satisfies application.SSHUploader by shelling out to `bgo scp`
// under a PTY allocated via the system `script` utility.
type BgoUploader struct {
cfg domain.SSHConfig
}
// NewBgoUploader returns an uploader that delegates to the bgo client.
func NewBgoUploader(cfg domain.SSHConfig) *BgoUploader {
return &BgoUploader{cfg: cfg}
}
// resolveBgoBinary locates the bgo executable, first via $PATH and then via
// the well-known install locations. Returns a clear error pointing the user
// to the setup guide when bgo cannot be found.
func resolveBgoBinary() (string, error) {
if p, err := exec.LookPath("bgo"); err == nil {
return p, nil
}
for _, candidate := range bgoSearchPaths() {
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("bgo executable not found — install bgo and add it to PATH (see the setup guide)")
}
// Upload writes data to a flat remote filename in the user's remote $HOME by
// staging it locally and invoking `bgo scp` once under a PTY.
//
// remoteRelPath is expected to be a flat filename (no directory components),
// because bgo cannot create remote directories. Any directory components are
// collapsed to the base name defensively.
func (u *BgoUploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
start := time.Now()
// Collapse to a flat filename: bgo scp cannot create remote directories.
name := path.Base(normaliseRemotePath(remoteRelPath))
if name == "" || name == "." || name == "/" {
return fmt.Errorf("ssh(bgo): remote filename is empty")
}
bgoBin, err := resolveBgoBinary()
if err != nil {
sshLog().Error("bgo uploader: binary not found", "err", err)
return err
}
sshLog().Info("bgo uploader start",
"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
"remote_file", name, "size", len(data), "bgo", bgoBin)
// 1. Stage the payload to a local temp file for scp to read.
tmp, err := os.CreateTemp("", "snapgo-bgo-*.png")
if err != nil {
return fmt.Errorf("ssh(bgo): create temp: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("ssh(bgo): write temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("ssh(bgo): close temp: %w", err)
}
if err := os.Chmod(tmpPath, mode.Perm()); err != nil {
sshLog().Debug("bgo uploader chmod temp failed", "err", err)
}
// 2. bgo scp <tmp> <user@host:name> under a PTY (via `script`).
remoteTarget := fmt.Sprintf("%s@%s:%s", u.cfg.User, bracketHost(u.cfg.Host), name)
bgoArgs := u.bgoScpArgs(bgoBin, tmpPath, remoteTarget)
if err := u.runUnderPTY(ctx, bgoArgs); err != nil {
sshLog().Error("bgo uploader scp failed",
"remote_file", name, "elapsed", time.Since(start), "err", err)
return err
}
sshLog().Info("bgo uploader done",
"remote_file", name, "size", len(data), "total_elapsed", time.Since(start))
return nil
}
// bgoScpArgs builds the argument vector "bgo scp [-P port] <src> <dst>".
// bgo manages its own auth, so we pass no ssh -o options here.
func (u *BgoUploader) bgoScpArgs(bgoBin, src, dst string) []string {
args := []string{bgoBin, "scp"}
if u.cfg.Port > 0 && u.cfg.Port != 22 {
args = append(args, "-P", strconv.Itoa(u.cfg.Port))
}
args = append(args, src, dst)
return args
}
// runUnderPTY runs the given command vector under a PTY allocated by the
// system `script` utility, capturing combined output for diagnostics.
//
// macOS BSD script signature: `script -q <typescript-file> command [args...]`.
// We discard the typescript by writing to /dev/null.
func (u *BgoUploader) runUnderPTY(ctx context.Context, cmdVec []string) error {
args := append([]string{"-q", "/dev/null"}, cmdVec...)
cmd := exec.CommandContext(ctx, systemScriptPath, args...)
var combined bytes.Buffer
cmd.Stdout = &combined
cmd.Stderr = &combined
sshLog().Debug("bgo exec", "args", strings.Join(args, " "))
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(combined.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("bgo scp failed: %s", msg)
}
return nil
}
// bracketHost wraps a bare IPv6 literal in [] so the "user@host:path" target
// parses correctly (e.g. fdbd:dc03:8:379::130 → [fdbd:dc03:8:379::130]).
// IPv4 addresses and hostnames are returned unchanged. Already-bracketed
// inputs are left as-is.
func bracketHost(host string) string {
host = strings.TrimSpace(host)
if host == "" {
return host
}
if strings.HasPrefix(host, "[") {
return host
}
// An IPv6 literal contains multiple ':' separators; a hostname:port style
// is not expected here because the port lives in cfg.Port.
if strings.Count(host, ":") >= 2 {
return "[" + host + "]"
}
return host
}
// TestBgoConnection verifies that the bgo binary is resolvable. A real
// transfer test is intentionally avoided: bgo requires a PTY and a live
// remote, and bgo ssh cannot run a harmless remote probe command (it panics).
// Confirming bgo is installed and on PATH is the most useful signal we can
// give without performing an actual upload.
func TestBgoConnection(ctx context.Context, cfg domain.SSHConfig) error {
sshLog().Info("bgo test connection start", "host", cfg.Host, "user", cfg.User)
bin, err := resolveBgoBinary()
if err != nil {
return err
}
sshLog().Info("bgo test connection ok", "bgo", bin)
return nil
}
+3 -3
View File
@@ -33,10 +33,10 @@ func Start(cbs Callbacks) (start, stop func()) {
systray.SetTemplateIcon(templateIconBytes, regularIconBytes)
systray.SetTooltip("SnapGo")
mCapture := systray.AddMenuItem("Capture screenshot", "Take a region screenshot")
mSettings := systray.AddMenuItem("Settings…", "Open settings window")
mCapture := systray.AddMenuItem("截图", "Take a region screenshot")
mSettings := systray.AddMenuItem("设置", "Open settings window")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit SnapGo", "Quit the application")
mQuit := systray.AddMenuItem("退出", "Quit the application")
// Pump menu clicks on a dedicated goroutine. Channel sends from
// systray are non-blocking, so a slow user callback does not back
+2 -2
View File
@@ -36,8 +36,8 @@ func main() {
err := wails.Run(&options.App{
Title: "SnapGo",
Width: 1080,
Height: 720,
Width: 1000,
Height: 820,
MinWidth: 720,
MinHeight: 520,
AssetServer: &assetserver.Options{
+12
View File
@@ -74,6 +74,18 @@ func nativeOverlaySaveRemote(x, y, w, h C.int, annotationsJSON *C.char) {
}()
}
//export nativeOverlaySummarize
func nativeOverlaySummarize(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.SummarizeNativeRegion(result)
}()
}
//export nativeOverlayCancel
func nativeOverlayCancel() {
app := consumeNativeOverlayApp()
+33 -9
View File
@@ -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 nativeOverlaySummarize(int x, int y, int w, int h, const char *annotationsJSON);
extern void nativeOverlayCancel(void);
static NSWindow *nativeOverlayWindow = nil;
@@ -101,9 +102,8 @@ static id nativeOverlayKeyMonitor = nil;
// as returning a +1 retained object.
@property(strong) NSButton *clipboardButton;
@property(strong) NSButton *saveButton;
// `saveRemoteButton` triggers a placeholder action ("not yet supported")
// per product spec; the icon comes from save-remote.svg.
@property(strong) NSButton *saveRemoteButton;
@property(strong) NSButton *summaryButton;
@property(strong) NSButton *uploadButton;
@property(strong) NSButton *penButton;
@property(strong) NSButton *rectButton;
@@ -120,6 +120,7 @@ static id nativeOverlayKeyMonitor = nil;
- (void)copySelection;
- (void)saveSelection;
- (void)saveRemoteSelection;
- (void)summarizeSelection;
- (void)cancelSelection;
@end
@@ -144,6 +145,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)];
_summaryButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(summarizeSelection)];
_uploadButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(confirmSelection)];
// Tooltip strings shown on hover for each action button.
// Localized in Chinese to match the rest of the action UI surface.
@@ -151,6 +153,7 @@ static id nativeOverlayKeyMonitor = nil;
[_clipboardButton setToolTip:@"复制图片"];
[_saveButton setToolTip:@"保存本地"];
[_saveRemoteButton setToolTip:@"保存远端"];
[_summaryButton setToolTip:@"复制总结"];
[_uploadButton setToolTip:@"上传云端"];
_penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)];
_rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)];
@@ -171,10 +174,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, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) {
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) {
[self addSubview:view];
}
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _actionToolbarBg]) {
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _actionToolbarBg]) {
[view setHidden:YES];
}
[_sizeLabel setHidden:YES];
@@ -248,6 +251,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:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M481.749333 353.792c16.682667-16.64 43.690667-16.64 60.373334 0l120.917333 120.832 3.541333 4.010667a42.666667 42.666667 0 0 1-63.872 56.32L554.666667 486.954667V896a42.666667 42.666667 0 0 1-85.333334 0v-409.173333l-48.170666 48.128a42.666667 42.666667 0 0 1-60.330667 0l-3.541333-4.010667a42.666667 42.666667 0 0 1 3.541333-56.32zM512 85.333333c122.538667 0 227.84 73.813333 273.92 179.370667A213.333333 213.333333 0 0 1 725.333333 682.666667a42.666667 42.666667 0 0 1-4.992-85.034667L725.333333 597.333333a128 128 0 0 0 36.394667-250.794666l-38.144-11.264-15.914667-36.437334a213.376 213.376 0 0 0-407.296 58.026667l-7.381333 58.368-57.173333 13.824A85.418667 85.418667 0 0 0 256 597.333333h42.666667a42.666667 42.666667 0 0 1 0 85.333334H256a170.666667 170.666667 0 0 1-40.277333-336.554667A298.709333 298.709333 0 0 1 512 85.333333z' fill='white'/></svg>"];
NSImage *summaryIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M256 128h448l192 192v576H256V128z m64 64v640h512V352H672V192H320z m416 45.248V288h50.752L736 237.248zM384 448h384v64H384v-64z m0 128h384v64H384v-64z m0 128h256v64H384v-64zM192 256v704h512v64H128V256h64z' fill='white'/></svg>"];
// upload.svg — newly replaced "send/cloud" icon supplied by the user.
NSImage *uploadIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M550.528 544.128L640 539.776V678.4l178.005333-178.005333L640 322.389333v129.024l-72.618667 10.922667c-155.52 23.424-251.733333 73.557333-312.874666 164.437333 84.906667-50.602667 178.261333-76.928 296.021333-82.645333zM554.666667 629.376c-27.392 1.322667-53.034667 3.968-77.141334 7.808l-8.192 1.365333c-136.704 23.637333-224.810667 87.168-310.954666 176.128-26.154667 27.008-51.882667 59.648-51.882667 59.648-14.848 18.176-24.021333 14.037333-20.352-9.173333 0 0 4.394667-31.402667 11.690667-65.792C144.938667 577.066667 250.581333 423.68 554.666667 377.941333V209.066667a38.4 38.4 0 0 1 65.536-27.136l291.328 291.285333a38.4 38.4 0 0 1 0 54.314667l-291.328 291.285333A38.4 38.4 0 0 1 554.666667 791.637333v-162.261333z' fill='white'/></svg>"];
@@ -255,6 +259,7 @@ static id nativeOverlayKeyMonitor = nil;
[self styleIconButton:_clipboardButton image:copyIcon];
[self styleIconButton:_saveButton image:saveIcon];
[self styleIconButton:_saveRemoteButton image:saveRemoteIcon];
[self styleIconButton:_summaryButton image:summaryIcon];
[self styleIconButton:_uploadButton image:uploadIcon];
// Configure hover colors for the 5 action buttons. White is the shared
@@ -267,11 +272,13 @@ static id nativeOverlayKeyMonitor = nil;
SnipHoverButton *clipboardHB = (SnipHoverButton *)_clipboardButton;
SnipHoverButton *saveHB = (SnipHoverButton *)_saveButton;
SnipHoverButton *saveRemoteHB = (SnipHoverButton *)_saveRemoteButton;
SnipHoverButton *summaryHB = (SnipHoverButton *)_summaryButton;
SnipHoverButton *uploadHB = (SnipHoverButton *)_uploadButton;
cancelHB.baseColor = baseWhite; cancelHB.hoverColor = hoverRed;
clipboardHB.baseColor = baseWhite; clipboardHB.hoverColor = hoverBlue;
saveHB.baseColor = baseWhite; saveHB.hoverColor = hoverBlue;
saveRemoteHB.baseColor = baseWhite; saveRemoteHB.hoverColor = hoverBlue;
summaryHB.baseColor = baseWhite; summaryHB.hoverColor = hoverBlue;
uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue;
[self styleIconButton:_penButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M742.72 752.064c-29.696 28.576-42.976 48.96-42.976 77.12 0 98.4 143.776 142.464 229.728 65.536l-21.344-23.84c-66.976 59.936-176.384 26.4-176.384-41.696 0-16.832 9.28-31.104 33.184-54.08l13.44-12.736c61.024-58.016 75.328-102.72 29.312-179.84-43.84-73.44-96.8-88.288-162.784-50.56-47.232 27.04-68.64 47.456-208.32 190.4-70.624 72.288-153.92 77.088-217.344 26.784-59.712-47.328-79.872-127.36-42.176-188.64 24.512-39.84 62.656-72.896 136.64-124.48 5.952-4.192 27.04-18.816 31.104-21.664 12.16-8.448 21.536-15.104 30.4-21.536 107.552-78.272 140.8-139.136 86.048-219.904-76.992-113.472-202.304-90.016-367.744 61.472l21.6 23.616c153.088-140.224 256.896-159.616 319.68-67.104 41.632 61.408 16.896 106.688-78.4 176.032-8.64 6.304-17.92 12.832-29.888 21.184l-31.136 21.632c-77.504 54.08-117.984 89.184-145.536 133.984-46.784 76.032-22.176 173.664 49.536 230.496 76.224 60.416 177.952 54.56 260.096-29.504 136.16-139.36 158.08-160.224 201.312-184.96 50.656-28.96 84.416-19.52 119.424 39.168 36.896 61.824 27.52 91.392-23.808 140.16 0.096-0.064-10.976 10.368-13.632 12.96z' fill='white'/></svg>"]];
@@ -591,6 +598,7 @@ static id nativeOverlayKeyMonitor = nil;
[_clipboardButton setHidden:!visible];
[_saveButton setHidden:!visible];
[_saveRemoteButton setHidden:!visible];
[_summaryButton setHidden:!visible];
[_uploadButton setHidden:!visible];
[_actionToolbarBg setHidden:!visible];
[_penButton setHidden:!visible];
@@ -608,12 +616,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 — 5 icon buttons, each 28x28, separated by 8px,
// with 4px outer padding. Total = 5*28 + 4*8 + 2*4 = 180px wide.
// Action toolbar layout — 6 icon buttons, each 28x28, separated by 8px,
// with 4px outer padding. Total = 6*28 + 5*8 + 2*4 = 216px wide.
CGFloat actionBtnSize = 28;
CGFloat actionGap = 8;
CGFloat actionPad = 4;
NSInteger actionCount = 5;
NSInteger actionCount = 6;
CGFloat toolbarW = actionPad * 2 + actionBtnSize * actionCount + actionGap * (actionCount - 1);
CGFloat toolbarH = 40;
CGFloat x = _selection.origin.x + _selection.size.width - toolbarW;
@@ -631,10 +639,14 @@ 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)];
[_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)];
[_summaryButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)];
[_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 5, 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.
CGFloat markW = 170;
CGFloat markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - markW));
CGFloat markGap = 8;
CGFloat markX = MAX(0, x - markGap - markW);
[_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)];
[_rectButton setFrame:NSMakeRect(markX + 36, y + 4, 28, 28)];
[_ellipseButton setFrame:NSMakeRect(markX + 68, y + 4, 28, 28)];
@@ -796,6 +808,18 @@ static id nativeOverlayKeyMonitor = nil;
NSString *json = [self annotationsJSON];
nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
}
// `summarizeSelection` uploads the screenshot to S3, sends it to the
// configured multimodal model, and copies the generated summary.
- (void)summarizeSelection {
if (!_hasSelection) {
return;
}
NSRect r = [self globalRectForSelection:_selection];
[self closeOverlayWindow];
NSString *json = [self annotationsJSON];
nativeOverlaySummarize((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
}
@end
// screenContainingCursor returns the NSScreen the cursor is currently on,
+163
View File
@@ -0,0 +1,163 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
#cgo LDFLAGS: -framework AppKit -framework QuartzCore
#include <stdlib.h>
#include <dispatch/dispatch.h>
#import <AppKit/AppKit.h>
#import <QuartzCore/QuartzCore.h>
static NSPanel *operationPanel = nil;
static NSTextField *operationTitleLabel = nil;
static NSTextField *operationDetailLabel = nil;
static NSTextField *operationIconLabel = nil;
static NSProgressIndicator *operationSpinner = nil;
static NSString *statusString(const char *value) {
if (value == NULL) {
return @"";
}
NSString *text = [NSString stringWithUTF8String:value];
return text ?: @"";
}
static void ensureOperationPanel(void) {
if (operationPanel != nil) {
return;
}
NSRect frame = NSMakeRect(0, 0, 360, 118);
operationPanel = [[NSPanel alloc]
initWithContentRect:frame
styleMask:NSWindowStyleMaskBorderless
backing:NSBackingStoreBuffered
defer:NO];
[operationPanel setOpaque:NO];
[operationPanel setBackgroundColor:[NSColor clearColor]];
[operationPanel setLevel:NSFloatingWindowLevel];
[operationPanel setHidesOnDeactivate:NO];
[operationPanel setCollectionBehavior:
NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorStationary];
NSView *root = [[NSView alloc] initWithFrame:frame];
[root setWantsLayer:YES];
[[root layer] setCornerRadius:14];
[[root layer] setBackgroundColor:[[NSColor colorWithCalibratedWhite:0.08 alpha:0.92] CGColor]];
[[root layer] setShadowColor:[[NSColor blackColor] CGColor]];
[[root layer] setShadowOpacity:0.28];
[[root layer] setShadowRadius:18];
[[root layer] setShadowOffset:CGSizeMake(0, -8)];
operationSpinner = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(24, 42, 34, 34)];
[operationSpinner setStyle:NSProgressIndicatorStyleSpinning];
[operationSpinner setIndeterminate:YES];
operationIconLabel = [NSTextField labelWithString:@"OK"];
[operationIconLabel setFrame:NSMakeRect(21, 43, 42, 30)];
[operationIconLabel setAlignment:NSTextAlignmentCenter];
[operationIconLabel setFont:[NSFont systemFontOfSize:17 weight:NSFontWeightSemibold]];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:74.0/255.0 green:222.0/255.0 blue:128.0/255.0 alpha:1.0]];
[operationIconLabel setHidden:YES];
operationTitleLabel = [NSTextField labelWithString:@""];
[operationTitleLabel setFrame:NSMakeRect(76, 61, 260, 24)];
[operationTitleLabel setFont:[NSFont systemFontOfSize:15 weight:NSFontWeightSemibold]];
[operationTitleLabel setTextColor:[NSColor whiteColor]];
operationDetailLabel = [NSTextField labelWithString:@""];
[operationDetailLabel setFrame:NSMakeRect(76, 34, 260, 22)];
[operationDetailLabel setFont:[NSFont systemFontOfSize:12 weight:NSFontWeightRegular]];
[operationDetailLabel setTextColor:[NSColor colorWithCalibratedWhite:0.82 alpha:1.0]];
[operationDetailLabel setLineBreakMode:NSLineBreakByTruncatingTail];
[root addSubview:operationSpinner];
[root addSubview:operationIconLabel];
[root addSubview:operationTitleLabel];
[root addSubview:operationDetailLabel];
[operationPanel setContentView:root];
}
static void snipShowOperationStatus(const char *titleC, const char *detailC, int state) {
NSString *title = statusString(titleC);
NSString *detail = statusString(detailC);
dispatch_async(dispatch_get_main_queue(), ^{
ensureOperationPanel();
[operationTitleLabel setStringValue:title];
[operationDetailLabel setStringValue:detail];
if (state == 0) {
[operationIconLabel setHidden:YES];
[operationSpinner setHidden:NO];
[operationSpinner startAnimation:nil];
} else {
[operationSpinner stopAnimation:nil];
[operationSpinner setHidden:YES];
[operationIconLabel setHidden:NO];
if (state == 2) {
[operationIconLabel setStringValue:@"!"];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:248.0/255.0 green:113.0/255.0 blue:113.0/255.0 alpha:1.0]];
} else {
[operationIconLabel setStringValue:@"OK"];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:74.0/255.0 green:222.0/255.0 blue:128.0/255.0 alpha:1.0]];
}
}
NSScreen *screen = [NSScreen mainScreen];
if (screen != nil) {
NSRect visible = [screen visibleFrame];
NSRect panelFrame = [operationPanel frame];
panelFrame.origin.x = NSMidX(visible) - panelFrame.size.width / 2;
panelFrame.origin.y = NSMaxY(visible) - panelFrame.size.height - 48;
[operationPanel setFrame:panelFrame display:YES];
}
[operationPanel orderFrontRegardless];
});
}
static void snipHideOperationStatus(void) {
dispatch_async(dispatch_get_main_queue(), ^{
if (operationPanel != nil) {
[operationSpinner stopAnimation:nil];
[operationPanel orderOut:nil];
}
});
}
*/
import "C"
import (
"sync/atomic"
"time"
"unsafe"
)
const (
operationStatusRunning = iota
operationStatusSuccess
operationStatusError
)
var operationStatusSeq atomic.Uint64
func showOperationStatus(title, detail string, state int) {
operationStatusSeq.Add(1)
ctitle := C.CString(title)
cdetail := C.CString(detail)
defer C.free(unsafe.Pointer(ctitle))
defer C.free(unsafe.Pointer(cdetail))
C.snipShowOperationStatus(ctitle, cdetail, C.int(state))
}
func hideOperationStatusAfter(delay time.Duration) {
seq := operationStatusSeq.Load()
time.AfterFunc(delay, func() {
if operationStatusSeq.Load() == seq {
C.snipHideOperationStatus()
}
})
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !darwin
package main
import "time"
const (
operationStatusRunning = iota
operationStatusSuccess
operationStatusError
)
func showOperationStatus(_ string, _ string, _ int) {}
func hideOperationStatusAfter(_ time.Duration) {}
+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 ""