Compare commits
6 Commits
71ca67c0ee
...
3aad93e7e8
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aad93e7e8 | |||
| 85d5fcc722 | |||
| 778055cd1d | |||
| 87874e64cf | |||
| 7c53c0f184 | |||
| 5ce3eba455 |
@@ -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 → notarize(notarize 需 `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 API;2 空格缩进。视图在 `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 / Accessibility(TCC)权限;稳定的代码签名身份与安装路径是权限保持的关键(见 `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}`。
|
||||||
@@ -5,6 +5,12 @@ package main
|
|||||||
/*
|
/*
|
||||||
#cgo CFLAGS: -x objective-c -fobjc-arc
|
#cgo CFLAGS: -x objective-c -fobjc-arc
|
||||||
#cgo LDFLAGS: -framework AppKit
|
#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>
|
#include <dispatch/dispatch.h>
|
||||||
#import <AppKit/AppKit.h>
|
#import <AppKit/AppKit.h>
|
||||||
|
|
||||||
|
|||||||
@@ -320,17 +320,32 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er
|
|||||||
func (a *App) runCopyImagePipeline(pngBytes []byte) error {
|
func (a *App) runCopyImagePipeline(pngBytes []byte) error {
|
||||||
svc := &application.CaptureActionsService{
|
svc := &application.CaptureActionsService{
|
||||||
Clipboard: a.clip,
|
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) {
|
func (a *App) runSaveImagePipeline(pngBytes []byte, dir string) (string, error) {
|
||||||
svc := &application.CaptureActionsService{
|
svc := &application.CaptureActionsService{
|
||||||
Clipboard: a.clip,
|
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
|
// runSaveRemotePipeline uploads the captured PNG to the configured SSH host
|
||||||
@@ -387,12 +402,6 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
|
|||||||
cfg := a.cfg
|
cfg := a.cfg
|
||||||
a.mu.RUnlock()
|
a.mu.RUnlock()
|
||||||
|
|
||||||
if !cfg.IsS3Configured() {
|
|
||||||
err := fmt.Errorf("请先在 S3 配置页填写可用的对象存储配置")
|
|
||||||
a.emitOperationStatus("summary", "需要配置 S3", err.Error(), "error")
|
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !cfg.IsLLMConfigured() {
|
if !cfg.IsLLMConfigured() {
|
||||||
err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
|
err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
|
||||||
a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
|
a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
|
||||||
@@ -400,28 +409,52 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
|
|||||||
return err
|
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)
|
s3Provider, err := oss.NewS3Provider(cfg.S3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error")
|
a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error")
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
providerID, llmCfg, _ := cfg.ActiveLLMProvider()
|
svc.Provider = s3Provider
|
||||||
visionClient, err := llmpkg.NewVisionClient(providerID, llmCfg)
|
|
||||||
if err != nil {
|
|
||||||
a.emitOperationStatus("summary", "LLM 配置错误", err.Error(), "error")
|
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
svc := &application.CaptureSummaryService{
|
|
||||||
Provider: s3Provider,
|
|
||||||
Summarizer: visionClient,
|
|
||||||
Clipboard: a.clip,
|
|
||||||
Prompt: cfg.LLM.Prompt,
|
|
||||||
PathPrefix: cfg.S3.PathPrefix,
|
|
||||||
}
|
|
||||||
|
|
||||||
a.emitOperationStatus("summary", "上传中", "正在上传截图供模型读取", "running")
|
a.emitOperationStatus("summary", "上传中", "截图较大,正在上传供模型读取", "running")
|
||||||
uploaded, err := svc.UploadImage(a.ctx, pngBytes)
|
uploaded, err := svc.UploadImage(a.ctx, pngBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
|
a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
|
||||||
@@ -429,6 +462,20 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
|
|||||||
return err
|
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")
|
a.emitOperationStatus("summary", "识别中", "图片已上传,正在请求多模态模型", "running")
|
||||||
summary, err := svc.Summarize(a.ctx, uploaded.URL)
|
summary, err := svc.Summarize(a.ctx, uploaded.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -436,12 +483,17 @@ func (a *App) runSummaryPipeline(pngBytes []byte) error {
|
|||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
return err
|
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 {
|
if err := svc.CopySummary(a.ctx, summary); err != nil {
|
||||||
a.emitOperationStatus("summary", "复制失败", err.Error(), "error")
|
a.emitOperationStatus("summary", "复制失败", err.Error(), "error")
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success")
|
a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success")
|
||||||
wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard")
|
wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard")
|
||||||
return nil
|
return nil
|
||||||
@@ -902,6 +954,7 @@ func (a *App) CancelRegion() {
|
|||||||
a.pendingMu.Unlock()
|
a.pendingMu.Unlock()
|
||||||
a.capturing.Store(false)
|
a.capturing.Store(false)
|
||||||
a.dismissOverlay()
|
a.dismissOverlay()
|
||||||
|
a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
|
||||||
}
|
}
|
||||||
|
|
||||||
// CancelNativeRegion releases native-overlay state without touching the
|
// CancelNativeRegion releases native-overlay state without touching the
|
||||||
@@ -912,6 +965,7 @@ func (a *App) CancelNativeRegion() {
|
|||||||
a.pendingMu.Unlock()
|
a.pendingMu.Unlock()
|
||||||
a.capturing.Store(false)
|
a.capturing.Store(false)
|
||||||
hideDockIcon()
|
hideDockIcon()
|
||||||
|
a.emitOperationStatus("capture", "已取消", "本次截图已取消", "success")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowWindow brings the main window back to the foreground in its normal
|
// ShowWindow brings the main window back to the foreground in its normal
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export namespace domain {
|
|||||||
maxTokens: number;
|
maxTokens: number;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
timeoutSecs: number;
|
timeoutSecs: number;
|
||||||
|
maxInlineBytes: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new LLMProviderConfig(source);
|
return new LLMProviderConfig(source);
|
||||||
@@ -75,6 +76,7 @@ export namespace domain {
|
|||||||
this.maxTokens = source["maxTokens"];
|
this.maxTokens = source["maxTokens"];
|
||||||
this.temperature = source["temperature"];
|
this.temperature = source["temperature"];
|
||||||
this.timeoutSecs = source["timeoutSecs"];
|
this.timeoutSecs = source["timeoutSecs"];
|
||||||
|
this.maxInlineBytes = source["maxInlineBytes"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class LLMConfig {
|
export class LLMConfig {
|
||||||
|
|||||||
@@ -61,6 +61,14 @@ func (s *CaptureActionsService) SaveImage(_ context.Context, pngBytes []byte, di
|
|||||||
s.notifyFailure("save failed: " + err.Error())
|
s.notifyFailure("save failed: " + err.Error())
|
||||||
return "", err
|
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 {
|
if s.Notifier != nil {
|
||||||
s.Notifier.NotifySuccess(path)
|
s.Notifier.NotifySuccess(path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,65 @@ package application
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mmmy/snapgo/internal/domain"
|
"github.com/mmmy/snapgo/internal/domain"
|
||||||
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
|
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VisionSummarizer describes a multimodal model capable of reading a public
|
// VisionSummarizer describes a multimodal model capable of reading an image
|
||||||
// image URL and returning a textual summary.
|
// reference (either a public https URL or an inline base64 data URL) and
|
||||||
|
// returning a textual summary.
|
||||||
type VisionSummarizer interface {
|
type VisionSummarizer interface {
|
||||||
SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error)
|
SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaptureSummaryService wires screenshot bytes -> S3 public URL -> LLM
|
// ObjectDeleter is an optional capability an OSSProvider may implement so the
|
||||||
// summary -> clipboard. It intentionally does not notify UI state itself:
|
// summary pipeline can delete the temporary screenshot it uploaded purely so a
|
||||||
// the caller emits fine-grained "uploading / recognizing / done" progress.
|
// 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 {
|
type CaptureSummaryService struct {
|
||||||
Provider domain.OSSProvider
|
Provider domain.OSSProvider
|
||||||
Summarizer VisionSummarizer
|
Summarizer VisionSummarizer
|
||||||
Clipboard clipboard.Writer
|
Clipboard clipboard.Writer
|
||||||
Prompt string
|
Prompt string
|
||||||
PathPrefix 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
|
// UploadImage uploads the screenshot and returns the public URL visible to
|
||||||
// the multimodal provider.
|
// the multimodal provider. Used only when the image is too large to inline.
|
||||||
func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte) (*domain.UploadResult, error) {
|
func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte) (*domain.UploadResult, error) {
|
||||||
if s.Provider == nil {
|
if s.Provider == nil {
|
||||||
return nil, fmt.Errorf("s3 is not configured")
|
return nil, fmt.Errorf("s3 is not configured")
|
||||||
@@ -49,7 +82,60 @@ func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summarize asks the configured model to summarize the uploaded image URL.
|
// EnsureReachable verifies the public URL can actually be fetched, so we can
|
||||||
|
// surface a clear "link not reachable" message instead of an opaque provider
|
||||||
|
// error when the bucket is private or fronted by an inaccessible CDN.
|
||||||
|
func (s *CaptureSummaryService) EnsureReachable(ctx context.Context, imageURL string) error {
|
||||||
|
client := s.HTTPClient
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
probe := func(method string) (int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, imageURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
status, err := probe(http.MethodHead)
|
||||||
|
// Some S3-compatible endpoints reject HEAD; retry with GET before failing.
|
||||||
|
if err != nil || status == http.StatusMethodNotAllowed || status == http.StatusForbidden {
|
||||||
|
if s, gerr := probe(http.MethodGet); gerr == nil {
|
||||||
|
status, err = s, nil
|
||||||
|
} else if err == nil {
|
||||||
|
err = gerr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("图片链接不可达(模型无法读取上传的截图):%w", err)
|
||||||
|
}
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return fmt.Errorf("图片链接不可达:HTTP %d,请确认对象存储 bucket 公网可读或已正确配置 PublicURLBase", status)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUploaded removes the temporary object from S3 if the provider supports
|
||||||
|
// deletion. It is best-effort: a nil return means either success or that the
|
||||||
|
// provider cannot delete.
|
||||||
|
func (s *CaptureSummaryService) DeleteUploaded(ctx context.Context, key string) error {
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
deleter, ok := s.Provider.(ObjectDeleter)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return deleter.Delete(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summarize asks the configured model to summarize the image reference, which
|
||||||
|
// may be a public URL or an inline base64 data URL.
|
||||||
func (s *CaptureSummaryService) Summarize(ctx context.Context, imageURL string) (string, error) {
|
func (s *CaptureSummaryService) Summarize(ctx context.Context, imageURL string) (string, error) {
|
||||||
if s.Summarizer == nil {
|
if s.Summarizer == nil {
|
||||||
return "", fmt.Errorf("llm is not configured")
|
return "", fmt.Errorf("llm is not configured")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package application
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ type fakeOSSProvider struct {
|
|||||||
data []byte
|
data []byte
|
||||||
contentType string
|
contentType string
|
||||||
url string
|
url string
|
||||||
|
deletedKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, contentType string) (string, error) {
|
func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, contentType string) (string, error) {
|
||||||
@@ -24,6 +26,11 @@ func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, con
|
|||||||
|
|
||||||
func (f *fakeOSSProvider) Name() string { return "fake" }
|
func (f *fakeOSSProvider) Name() string { return "fake" }
|
||||||
|
|
||||||
|
func (f *fakeOSSProvider) Delete(_ context.Context, key string) error {
|
||||||
|
f.deletedKey = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type fakeSummarizer struct {
|
type fakeSummarizer struct {
|
||||||
prompt string
|
prompt string
|
||||||
url string
|
url string
|
||||||
@@ -84,3 +91,50 @@ func TestCaptureSummaryServiceUploadsSummarizesAndCopies(t *testing.T) {
|
|||||||
t.Fatalf("expected summary copied, got %q", clip.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" }
|
||||||
|
|||||||
@@ -86,8 +86,20 @@ type LLMProviderConfig struct {
|
|||||||
MaxTokens int `json:"maxTokens"`
|
MaxTokens int `json:"maxTokens"`
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
TimeoutSecs int `json:"timeoutSecs"`
|
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.
|
// LLMConfig controls screenshot summarisation.
|
||||||
type LLMConfig struct {
|
type LLMConfig struct {
|
||||||
ActiveProvider string `json:"activeProvider"`
|
ActiveProvider string `json:"activeProvider"`
|
||||||
@@ -198,6 +210,7 @@ func DefaultLLMConfig() LLMConfig {
|
|||||||
MaxTokens: 600,
|
MaxTokens: 600,
|
||||||
Temperature: 0.2,
|
Temperature: 0.2,
|
||||||
TimeoutSecs: 60,
|
TimeoutSecs: 60,
|
||||||
|
MaxInlineBytes: DefaultMaxInlineBytes,
|
||||||
},
|
},
|
||||||
LLMProviderDoubao: {
|
LLMProviderDoubao: {
|
||||||
Label: "火山方舟豆包多模态",
|
Label: "火山方舟豆包多模态",
|
||||||
@@ -206,6 +219,7 @@ func DefaultLLMConfig() LLMConfig {
|
|||||||
MaxTokens: 600,
|
MaxTokens: 600,
|
||||||
Temperature: 0.2,
|
Temperature: 0.2,
|
||||||
TimeoutSecs: 60,
|
TimeoutSecs: 60,
|
||||||
|
MaxInlineBytes: DefaultMaxInlineBytes,
|
||||||
},
|
},
|
||||||
LLMProviderOpenAI: {
|
LLMProviderOpenAI: {
|
||||||
Label: "ChatGPT / OpenAI-compatible",
|
Label: "ChatGPT / OpenAI-compatible",
|
||||||
@@ -214,6 +228,7 @@ func DefaultLLMConfig() LLMConfig {
|
|||||||
MaxTokens: 600,
|
MaxTokens: 600,
|
||||||
Temperature: 0.2,
|
Temperature: 0.2,
|
||||||
TimeoutSecs: 60,
|
TimeoutSecs: 60,
|
||||||
|
MaxInlineBytes: DefaultMaxInlineBytes,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -275,6 +290,9 @@ func (c *AppConfig) Normalize() {
|
|||||||
if current.TimeoutSecs == 0 {
|
if current.TimeoutSecs == 0 {
|
||||||
current.TimeoutSecs = def.TimeoutSecs
|
current.TimeoutSecs = def.TimeoutSecs
|
||||||
}
|
}
|
||||||
|
if current.MaxInlineBytes <= 0 {
|
||||||
|
current.MaxInlineBytes = DefaultMaxInlineBytes
|
||||||
|
}
|
||||||
c.LLM.Providers[id] = current
|
c.LLM.Providers[id] = current
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func parseSpec(spec string) ([]hk.Modifier, hk.Key, error) {
|
|||||||
case "option", "alt":
|
case "option", "alt":
|
||||||
mods = append(mods, modOption())
|
mods = append(mods, modOption())
|
||||||
case "shift":
|
case "shift":
|
||||||
mods = append(mods, hk.ModShift)
|
mods = append(mods, modShift())
|
||||||
default:
|
default:
|
||||||
k, ok := lookupKey(p)
|
k, ok := lookupKey(p)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
//go:build darwin
|
//go:build darwin && cgo
|
||||||
|
|
||||||
package hotkey
|
package hotkey
|
||||||
|
|
||||||
import hk "golang.design/x/hotkey"
|
import hk "golang.design/x/hotkey"
|
||||||
|
|
||||||
// macOS uses ⌘ as the primary modifier; Option corresponds to Alt.
|
// 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 modCmd() hk.Modifier { return hk.ModCmd }
|
||||||
func modCtrl() hk.Modifier { return hk.ModCtrl }
|
func modCtrl() hk.Modifier { return hk.ModCtrl }
|
||||||
func modOption() hk.Modifier { return hk.ModOption }
|
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 }
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
//go:build windows || cgo
|
||||||
|
|
||||||
package hotkey
|
package hotkey
|
||||||
|
|
||||||
import hk "golang.design/x/hotkey"
|
import hk "golang.design/x/hotkey"
|
||||||
|
|
||||||
// lookupKey maps a token from the user-supplied hotkey spec to a hk.Key.
|
// 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
|
// 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
|
// rely on an assumption that hk.KeyA..hk.KeyZ are contiguous values — the
|
||||||
// upstream package gives no such guarantee.
|
// upstream package gives no such guarantee.
|
||||||
|
|||||||
@@ -101,6 +101,20 @@ func (p *S3Provider) BuildPublicURL(key string) string {
|
|||||||
return endpoint + "/" + p.cfg.Bucket + "/" + key
|
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
|
// TestConnection performs a small put + delete to verify credentials and
|
||||||
// bucket reachability. Used by the "Test connection" button in settings.
|
// bucket reachability. Used by the "Test connection" button in settings.
|
||||||
func (p *S3Provider) TestConnection(ctx context.Context) error {
|
func (p *S3Provider) TestConnection(ctx context.Context) error {
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ sleep 0.4
|
|||||||
# --identity flag in v2.
|
# --identity flag in v2.
|
||||||
ARCH="${ARCH:-arm64}"
|
ARCH="${ARCH:-arm64}"
|
||||||
echo "[dev-build.sh] building darwin/${ARCH}..."
|
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
|
wails build -platform "darwin/${ARCH}" -clean
|
||||||
|
|
||||||
# 5. Re-sign with the stable dev cert. We invoke sign.sh through env var
|
# 5. Re-sign with the stable dev cert. We invoke sign.sh through env var
|
||||||
|
|||||||
+3
-1
@@ -35,7 +35,9 @@ echo "================================================================"
|
|||||||
# ---- 1. Build ----
|
# ---- 1. Build ----
|
||||||
echo ""
|
echo ""
|
||||||
echo "[release.sh] (1/4) wails build"
|
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 ----
|
# ---- 2. Sign .app ----
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
Reference in New Issue
Block a user