11 Commits

Author SHA1 Message Date
mamamiyear 250ba269f5 feat: [WIP] implement scp by bgo 2026-06-12 14:12:11 +08:00
mamamiyear 3cd92945ac refactor: optimize setting view 2026-06-12 11:10:22 +08:00
mamamiyear 4576653b4e feat: save to remote can be authed by Kerberos 2026-06-12 10:56:21 +08:00
mamamiyear 050f2b74c3 feat: save short cut image to remote by ssh
- add settings for ssh config
- implement save remote function by ssh
2026-06-11 21:14:29 +08:00
mamamiyear 3e4d071b4c chore: building script has better compatibility
- The build script will fallback to using GOPATH instead of
  GOBIN when the GOBIN environment variable is not set
2026-06-11 17:49:51 +08:00
mamamiyear be8dce7135 refactor: resize setting window size 2026-06-11 17:10:08 +08:00
mamamiyear 952f1497c8 fix: can not screen shot in extended display on macos 2026-06-11 17:09:58 +08:00
mamamiyear ab4ba8c417 feat: add icon for buttons
- add icon for after screen shot operation buttons
- add a new button `save-remote` but not implement for now
2026-06-11 17:09:47 +08:00
mamamiyear 1d52d154fd feat(app): support local screenshot actions 2026-06-04 08:56:55 +08:00
mamamiyear c45864e44c feat(app): support to edit screenshot area
- relocate or resize the area
- add anotations on the area
2026-06-02 09:01:53 +08:00
mamamiyear ceecbb6af4 feat(app): implement basic function
General
- 配置界面支持配置对象存储桶
- 快捷键进入截图
- 一键上传截图 & 复制截图链接

MacOS
- 状态栏指示器和应用图标
2026-06-01 21:17:29 +08:00
99 changed files with 11117 additions and 4 deletions
+3
View File
@@ -16,3 +16,6 @@ scripts/secrets.sh
# IDE # IDE
.trae/ .trae/
.vscode/ .vscode/
# OS
*.DS_Store
+36
View File
@@ -0,0 +1,36 @@
# SnapGo
SnapGo 是一个以 macOS 为核心体验的截图上传工具,面向“截图后立刻分享链接”的高频场景。它常驻于菜单栏,支持全局快捷键唤起截图覆盖层,在完成区域选择后自动将图片上传到兼容 S3 协议的对象存储,并把最终公开链接写入剪贴板,尽可能减少“截图、保存、上传、复制链接”之间的手动切换成本。
项目当前基于 `Wails + Go + Vue 3` 构建,桌面端负责截图、热键、托盘与系统集成,前端负责设置页与截图交互界面。整体设计围绕“轻量常驻、快速触发、上传即复制”展开,适合作为个人图床工作流中的本地入口工具。
## 核心功能
- 全局快捷键截图:支持通过全局快捷键在任意应用中快速唤起截图流程。
- 区域选择覆盖层:提供接近原生体验的截图选区交互,优先针对 macOS 做了透明覆盖层与窗口层级优化。
- 自动上传到 S3 兼容存储:支持 AWS S3、MinIO、Cloudflare R2、Backblaze B2 及其他兼容 S3 API 的对象存储。
- 自动复制公开链接:上传成功后自动将图片 URL 写入剪贴板,方便直接粘贴到 IM、文档或工单系统。
- 菜单栏常驻与设置面板:通过托盘菜单进入设置,配置热键、存储参数,并测试连接可用性。
- 上传失败兜底:当上传失败时,会将截图落盘到本地临时目录,避免截图结果丢失。
## 典型使用流程
1. 首次启动后,在设置页中填写 S3 兼容对象存储的连接信息。
2. 保存配置并测试连通性,确认目标 Bucket 可写。
3. 使用默认快捷键 `cmd+shift+a`,或自定义快捷键,随时发起截图。
4. 选择需要截取的区域并确认。
5. 应用自动上传截图,并将生成的公开链接复制到剪贴板。
## 项目定位
SnapGo 当前更偏向一个面向个人使用的高效工具,而不是“大而全”的截图平台。它的重点不在复杂标注或云端管理能力,而在于把“截图并生成可分享链接”这条链路压缩到尽可能短,尤其适合开发者、产品、测试或需要频繁在聊天工具中分享截图的用户。
虽然工程结构中保留了跨平台方向的设计,但现阶段的核心交互与系统集成主要围绕 macOS 打磨,包括菜单栏形态、原生覆盖层、窗口透明化处理以及与系统截图能力的衔接。因此,如果你希望获得完整体验,macOS 是当前最合适的运行平台。
## 技术实现概览
- 桌面壳与应用编排:Go + Wails
- 设置界面与截图覆盖层:Vue 3 + TypeScript + Vite
- 全局快捷键:`golang.design/x/hotkey`
- 对象存储接入:AWS SDK for Go v2S3 兼容实现)
- macOS 截图与窗口控制:系统 `screencapture` 命令 + AppKit 原生能力
+28 -4
View File
@@ -1,10 +1,10 @@
# SnipPicGo # SnapGo
SnipPicGo 是一个为“截完图马上发链接”而做的轻量截图工具。 SnapGo 是一个为“截完图马上发链接”而做的轻量截图工具。
它常驻菜单栏,通过全局快捷键一键唤起截图,选区确认后自动上传到你自己的 S3 兼容对象存储,并把图片链接直接复制到剪贴板。你不需要再经历“截图 -> 保存本地 -> 打开图床 -> 上传 -> 复制链接”这串繁琐操作。 它常驻菜单栏,通过全局快捷键一键唤起截图,选区确认后自动上传到你自己的 S3 兼容对象存储,并把图片链接直接复制到剪贴板。你不需要再经历“截图 -> 保存本地 -> 打开图床 -> 上传 -> 复制链接”这串繁琐操作。
如果你经常在聊天工具、工单、文档、Issue 或 PR 里发截图,SnipPicGo 的目标就是把这件事变成一次动作。 如果你经常在聊天工具、工单、文档、Issue 或 PR 里发截图,SnapGo 的目标就是把这件事变成一次动作。
## 它适合谁 ## 它适合谁
@@ -31,6 +31,30 @@ SnipPicGo 是一个为“截完图马上发链接”而做的轻量截图工具
- 更可控:图片上传到你自己的对象存储,而不是托管在陌生服务上 - 更可控:图片上传到你自己的对象存储,而不是托管在陌生服务上
- 更适合高频工作:特别适合要反复发截图沟通的人 - 更适合高频工作:特别适合要反复发截图沟通的人
# 测试版安装
## MacOS
1. 进入项目目录
- `cd /path/of/project`
2. 安装 go 项目依赖
- `go mod tidy & go install github.com/wailsapp/wails/v2/cmd/wails@latest`
3. 生成 macos 应用开发证书(首次安装前执行)
- `./scripts/create-dev-cert.sh`
4. 检查证书(首次安装前执行)
- `security find-identity -v -p codesigning`
你应当能看到类似下面的显示(一条或两条)
```bash
2) XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX "SnapGo Dev Cert"
3) XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX "SnapGo Dev Cert"
```
5. 安装 & 打开应用
- `./scripts/dev-build.sh`
然后你就可以在应用列表里看到它了
## 使用方式 ## 使用方式
1. 打开应用,填写你的 S3 兼容对象存储配置 1. 打开应用,填写你的 S3 兼容对象存储配置
@@ -41,4 +65,4 @@ SnipPicGo 是一个为“截完图马上发链接”而做的轻量截图工具
## 当前体验 ## 当前体验
SnipPicGo 当前主要围绕 macOS 打磨体验。如果你希望获得完整、顺滑的截图上传工作流,macOS 是目前最推荐的使用平台。 SnapGo 当前主要围绕 macOS 打磨体验。如果你希望获得完整、顺滑的截图上传工作流,macOS 是目前最推荐的使用平台。
+71
View File
@@ -0,0 +1,71 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework AppKit
#include <dispatch/dispatch.h>
#import <AppKit/AppKit.h>
static id snipDidHideObserver = nil;
static void snipSetActivationPolicy(bool regular, bool activate) {
void (^work)(void) = ^{
[NSApp setActivationPolicy: regular
? NSApplicationActivationPolicyRegular
: NSApplicationActivationPolicyAccessory];
if (activate) {
[NSApp unhide:nil];
[NSApp activateIgnoringOtherApps:YES];
}
};
if ([NSThread isMainThread]) {
work();
} else {
dispatch_sync(dispatch_get_main_queue(), work);
}
}
static void snipInstallActivationPolicyHooks(void) {
void (^work)(void) = ^{
if (snipDidHideObserver != nil) {
return;
}
snipDidHideObserver = [[NSNotificationCenter defaultCenter]
addObserverForName:NSApplicationDidHideNotification
object:NSApp
queue:[NSOperationQueue mainQueue]
usingBlock:^(__unused NSNotification *note) {
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
}];
};
if ([NSThread isMainThread]) {
work();
} else {
dispatch_sync(dispatch_get_main_queue(), work);
}
}
*/
import "C"
// installActivationPolicyHooks observes the native hide path used by Wails'
// HideWindowOnClose on macOS. Closing Settings calls `[NSApp hide:nil]`,
// which does not reliably pass through Wails' Go OnBeforeClose hook.
func installActivationPolicyHooks() {
C.snipInstallActivationPolicyHooks()
}
// showDockIcon makes the app behave like a normal foreground macOS app while
// Settings is visible.
func showDockIcon(activate bool) {
C.snipSetActivationPolicy(true, C.bool(activate))
}
// hideDockIcon returns the app to menu-bar-agent mode: no persistent Dock
// icon, while the status-bar item remains available.
func hideDockIcon() {
C.snipSetActivationPolicy(false, false)
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !darwin
package main
// installActivationPolicyHooks is only meaningful on macOS.
func installActivationPolicyHooks() {}
// showDockIcon is only meaningful on macOS.
func showDockIcon(_ bool) {}
// hideDockIcon is only meaningful on macOS.
func hideDockIcon() {}
+818
View File
@@ -0,0 +1,818 @@
// app.go wires the application service stack together and exposes
// frontend-callable methods through Wails bindings.
package main
import (
"context"
"encoding/json"
"fmt"
"image"
"log/slog"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/mmmy/snapgo/internal/application"
"github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
"github.com/mmmy/snapgo/internal/infrastructure/config"
"github.com/mmmy/snapgo/internal/infrastructure/display"
"github.com/mmmy/snapgo/internal/infrastructure/hotkey"
"github.com/mmmy/snapgo/internal/infrastructure/oss"
"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh"
)
// App is the struct exposed to the Wails frontend through Bind.
//
// We deliberately keep this struct small: it owns long-lived collaborators
// (config store, hotkey manager, capturer, clipboard) but delegates the
// real work to the application service constructed on demand once an OSS
// provider is configured.
type App struct {
ctx context.Context
mu sync.RWMutex
cfg domain.AppConfig
configFile *config.FileStore
hotkeyMgr *hotkey.Manager
capturer screencapture.Capturer
clip clipboard.Writer
// capturing prevents re-entrant capture sessions when the user mashes
// the hotkey while a previous one is still running. It stays true for
// the entire lifecycle of one capture: from overlay start until the
// overlay is either confirmed (ConfirmRegion) or discarded (CancelRegion).
capturing atomic.Bool
// pendingMu guards `pending`. We keep this separate from `mu` so that
// frontend-triggered RPC handlers (which may run concurrently with the
// capture goroutine) can take it cheaply.
pendingMu sync.Mutex
pending *pendingCapture
}
// pendingCapture holds the OSS provider chosen when capture started.
// Keeping the provider here prevents a config edit
// between "snap" and "confirm" from silently retargeting the upload.
type pendingCapture struct {
Provider domain.OSSProvider
Display display.Info
}
// OverlayPayload is the JSON the Go side emits so the WebView can build the
// Snipaste-style overlay. It deliberately does not contain screenshot bytes:
// the window is transparent and the mask sits over the live desktop.
type OverlayPayload struct {
CSSWidth int `json:"cssWidth"`
CSSHeight int `json:"cssHeight"`
Scale float64 `json:"scale"`
}
// RegionRect is the CSS-pixel selection rectangle the WebView reports.
// Origin is the top-left of the primary display.
type RegionRect struct {
X int `json:"x"`
Y int `json:"y"`
W int `json:"w"`
H int `json:"h"`
}
// CaptureResult is returned by the overlay with the final selection and any
// annotations drawn inside the selected area.
type CaptureResult struct {
Rect RegionRect `json:"rect"`
Annotations []application.Annotation `json:"annotations"`
}
// CaptureActionResult tells the frontend whether an action was completed or
// the user cancelled a secondary choice such as the save destination dialog.
type CaptureActionResult struct {
Completed bool `json:"completed"`
Path string `json:"path,omitempty"`
}
// NewApp creates a new App with collaborators already initialised.
func NewApp() *App {
store, err := config.NewFileStore()
if err != nil {
slog.Error("config store init", "err", err)
}
cfg := domain.DefaultAppConfig()
if store != nil {
if loaded, lerr := store.Load(); lerr == nil {
cfg = loaded
} else {
slog.Warn("config load failed, using defaults", "err", lerr)
}
}
return &App{
cfg: cfg,
configFile: store,
hotkeyMgr: hotkey.NewManager(),
capturer: screencapture.New(),
clip: clipboard.New(),
}
}
// startup is invoked by Wails once the runtime is available.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
if err := a.registerHotkey(a.cfg.Hotkey); err != nil {
slog.Warn("register hotkey failed", "err", err)
wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
} else {
wruntime.EventsEmit(a.ctx, "hotkey:ready", a.cfg.Hotkey)
}
}
// shutdown releases OS resources.
func (a *App) shutdown(_ context.Context) {
a.hotkeyMgr.Unregister()
}
// ---------------------------------------------------------------------------
// Hotkey management
// ---------------------------------------------------------------------------
func (a *App) registerHotkey(spec string) error {
if spec == "" {
spec = "cmd+shift+a"
}
return a.hotkeyMgr.Register(spec, func() {
a.runInteractiveCapture()
})
}
// runInteractiveCapture is the unified entry point for the global hotkey
// and the in-app "Capture now" button. The new flow is:
//
// 1. Hide the main window so it never flashes over the desktop.
// 2. Re-show it as a transparent, high-level overlay that paints only a
// dimming mask plus selection chrome over the live screen.
// 3. Wait — the user finishes selecting in the WebView, which calls
// ConfirmRegion(rect) or CancelRegion() back into us.
//
// We deliberately do NOT block the goroutine on a channel here: the
// "wait" is implicit. capturing stays true until the frontend resolves it.
func (a *App) runInteractiveCapture() {
if a.ctx == nil {
return
}
if !a.capturing.CompareAndSwap(false, true) {
return
}
releaseOnExit := true
defer func() {
if releaseOnExit {
a.capturing.Store(false)
}
}()
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
var provider domain.OSSProvider
if cfg.IsS3Configured() {
var err error
provider, err = oss.NewS3Provider(cfg.S3)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
}
}
// Hide the settings window before transforming it into an overlay, so
// the user only sees the final transparent mask state.
wruntime.WindowHide(a.ctx)
hideDockIcon()
flushFrame()
wruntime.EventsEmit(a.ctx, "capture:start", nil)
info := display.Primary()
// Park the provider and morph the window into a transparent overlay.
a.pendingMu.Lock()
a.pending = &pendingCapture{
Provider: provider,
Display: info,
}
a.pendingMu.Unlock()
if showNativeCaptureOverlay(a, info) {
releaseOnExit = false // ownership passes to the native overlay callbacks
return
}
payload := OverlayPayload{
CSSWidth: info.CSSWidth,
CSSHeight: info.CSSHeight,
Scale: info.Scale,
}
a.showOverlayWindow(info)
wruntime.EventsEmit(a.ctx, "capture:overlay", payload)
flushFrame()
wruntime.WindowShow(a.ctx)
configureOverlayWindow()
releaseOnExit = false // ownership of `capturing` passes to the overlay lifecycle
}
// ---------------------------------------------------------------------------
// Overlay window helpers
// ---------------------------------------------------------------------------
const (
settingsWidth = 1080
settingsHeight = 720
)
// showOverlayWindow prepares the hidden main window as a borderless,
// always-on-top, transparent full-primary-display window. The caller emits
// the overlay event and only then shows it, avoiding an opaque grey flash.
func (a *App) showOverlayWindow(info display.Info) {
if a.ctx == nil {
return
}
wruntime.WindowSetBackgroundColour(a.ctx, 0, 0, 0, 0)
wruntime.WindowSetAlwaysOnTop(a.ctx, true)
wruntime.WindowSetSize(a.ctx, info.CSSWidth, info.CSSHeight)
wruntime.WindowSetPosition(a.ctx, 0, 0)
configureOverlayWindow()
}
// restoreSettingsWindow flips the window back from overlay form to its
// normal Settings size and unpins it. We do NOT call Show here — callers
// decide whether the user actually wanted Settings to appear.
func (a *App) restoreSettingsWindow() {
if a.ctx == nil {
return
}
restoreOverlayWindow()
wruntime.WindowSetBackgroundColour(a.ctx, 246, 247, 250, 255)
wruntime.WindowSetAlwaysOnTop(a.ctx, false)
wruntime.WindowSetSize(a.ctx, settingsWidth, settingsHeight)
wruntime.WindowCenter(a.ctx)
}
// surfaceWindow brings the main window back from a hidden state regardless
// of which mechanism we used to suppress it.
func (a *App) surfaceWindow() {
if a.ctx == nil {
return
}
showDockIcon(true)
a.restoreSettingsWindow()
wruntime.WindowUnminimise(a.ctx)
wruntime.WindowShow(a.ctx)
}
// dismissOverlay hides the overlay window and shrinks/centres it back to
// the Settings dimensions so a future ShowWindow lands in the right place.
func (a *App) dismissOverlay() {
if a.ctx == nil {
return
}
wruntime.WindowHide(a.ctx)
hideDockIcon()
a.restoreSettingsWindow()
}
// runUploadPipeline is the post-capture half of the workflow. Shared by
// the hotkey path and the explicit "Upload now" RPC.
func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
svc := &application.CaptureAndUploadService{
Capturer: a.capturer,
Provider: provider,
Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx},
FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"),
PathPrefix: cfg.S3.PathPrefix,
}
return svc.ExecuteWithBytes(a.ctx, pngBytes)
}
func (a *App) runCopyImagePipeline(pngBytes []byte) error {
svc := &application.CaptureActionsService{
Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx},
}
return svc.CopyImage(a.ctx, pngBytes)
}
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)
}
// runSaveRemotePipeline uploads the captured PNG to the configured SSH host
// via SCP and copies a sharable reference (URL or "user@host:~/path") to
// the clipboard.
//
// Why a dedicated method (vs. extending CaptureAndUploadService): SSH and
// S3 have different config + clipboard semantics, so keeping them separate
// avoids leaking provider-specific branches into the OSS pipeline.
func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsSSHConfigured() {
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)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
slog.Info("save-remote dispatch",
"host", cfg.SSH.Host, "user", cfg.SSH.User, "port", cfg.SSH.Port,
"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.
var uploader application.SSHUploader
switch {
case cfg.SSH.IsKerberos():
uploader = sshpkg.NewKerberosUploader(cfg.SSH)
case cfg.SSH.IsBgo():
uploader = sshpkg.NewBgoUploader(cfg.SSH)
default:
uploader = sshpkg.NewUploader(cfg.SSH)
}
svc := &application.CaptureAndSSHService{
Uploader: uploader,
Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx},
Cfg: cfg.SSH,
}
return svc.ExecuteWithBytes(a.ctx, pngBytes)
}
func (a *App) consumePendingCapture() (*pendingCapture, error) {
a.pendingMu.Lock()
pc := a.pending
a.pending = nil
a.pendingMu.Unlock()
if pc == nil {
return nil, fmt.Errorf("no pending capture")
}
return pc, nil
}
func (a *App) captureSelectedPNG(result CaptureResult, pc *pendingCapture) ([]byte, error) {
rect := result.Rect
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
cropped, err := a.capturer.CaptureRegion(captureRect)
if err != nil {
return nil, err
}
if len(result.Annotations) > 0 {
cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale)
if err != nil {
return nil, err
}
}
return cropped, nil
}
func (a *App) chooseSaveDirectory() (string, error) {
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
Title: "Save screenshot to folder",
DefaultDirectory: userPicturesDir(),
CanCreateDirectories: true,
})
}
// ---------------------------------------------------------------------------
// Bound methods (called from the frontend via Wails)
// ---------------------------------------------------------------------------
// GetConfig returns the current persisted configuration.
func (a *App) GetConfig() domain.AppConfig {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg
}
// SaveConfig persists the supplied configuration and re-registers the hotkey
// if it changed.
func (a *App) SaveConfig(cfg domain.AppConfig) error {
a.mu.Lock()
prev := a.cfg
a.cfg = cfg
a.mu.Unlock()
if a.configFile != nil {
if err := a.configFile.Save(cfg); err != nil {
return err
}
}
if cfg.Hotkey != prev.Hotkey {
if err := a.registerHotkey(cfg.Hotkey); err != nil {
wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
return fmt.Errorf("register new hotkey: %w", err)
}
wruntime.EventsEmit(a.ctx, "hotkey:ready", cfg.Hotkey)
}
return nil
}
// RetryRegisterHotkey re-registers the configured hotkey on demand.
func (a *App) RetryRegisterHotkey() error {
a.mu.RLock()
spec := a.cfg.Hotkey
a.mu.RUnlock()
if err := a.registerHotkey(spec); err != nil {
wruntime.EventsEmit(a.ctx, "hotkey:error", err.Error())
return err
}
wruntime.EventsEmit(a.ctx, "hotkey:ready", spec)
return nil
}
// TestConnection performs a put+delete probe against the supplied S3 config.
func (a *App) TestConnection(cfg domain.S3Config) error {
provider, err := oss.NewS3Provider(cfg)
if err != nil {
return err
}
return provider.TestConnection(a.ctx)
}
// TestSSHConnection performs a minimal SSH handshake probe against the
// supplied configuration so the SettingsView's "Test connection" button
// can give the user immediate feedback.
func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
slog.Info("RPC TestSSHConnection",
"host", cfg.Host, "user", cfg.User, "port", cfg.Port,
"auth_method", cfg.AuthMethod,
"strict_host_key", cfg.StrictHostKey, "has_password", cfg.Password != "")
// Kerberos auth delegates to the system ssh binary (see KerberosUploader)
// so its probe path differs from the in-process client.
if cfg.IsKerberos() {
if err := sshpkg.TestKerberosConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection (kerberos) failed", "err", err)
return err
}
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
}
return nil
}
// CaptureNow is the in-app trigger.
func (a *App) CaptureNow() {
go a.runInteractiveCapture()
}
// ConfirmRegion is invoked by the overlay UI when the user clicks
// "Upload & copy". The overlay is transparent and sits over the live
// desktop, so we hide it first, then ask the OS to capture the selected
// logical screen rectangle directly.
//
// Returning the error to the frontend lets the overlay decide whether to
// keep showing the screenshot (e.g. for a retry) — currently it just
// dismisses regardless and surfaces the error via the upload:failure toast.
func (a *App) ConfirmRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
if err := a.runUploadPipeline(pc.Provider, cropped); err != nil {
return err
}
return nil
}
// CopyRegionImage is invoked by the fallback Wails overlay when the user
// double-clicks the selected area.
func (a *App) CopyRegionImage(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runCopyImagePipeline(cropped)
}
// SaveRegionImage lets the fallback Wails overlay pick a native destination
// folder and writes the selected screenshot PNG there.
func (a *App) SaveRegionImage(result CaptureResult) (CaptureActionResult, error) {
pc, err := a.consumePendingCapture()
if err != nil {
return CaptureActionResult{}, err
}
a.dismissOverlay()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
a.capturing.Store(false)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
dir, err := a.chooseSaveDirectory()
if err != nil {
a.capturing.Store(false)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
if dir == "" {
a.capturing.Store(false)
a.dismissOverlay()
return CaptureActionResult{Completed: false}, nil
}
path, err := a.runSaveImagePipeline(cropped, dir)
a.capturing.Store(false)
a.dismissOverlay()
if err != nil {
return CaptureActionResult{}, err
}
return CaptureActionResult{Completed: true, Path: path}, nil
}
// ConfirmNativeRegion mirrors ConfirmRegion for the macOS native overlay.
// The native AppKit panel has already been closed by the time this method is
// called, so we must not dismiss/restore the Wails overlay window here.
func (a *App) ConfirmNativeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runUploadPipeline(pc.Provider, cropped)
}
func (a *App) CopyNativeRegionImage(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runCopyImagePipeline(cropped)
}
func (a *App) SaveNativeRegionImage(result CaptureResult) (CaptureActionResult, error) {
pc, err := a.consumePendingCapture()
if err != nil {
return CaptureActionResult{}, err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
dir, err := a.chooseSaveDirectory()
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
if dir == "" {
return CaptureActionResult{Completed: false}, nil
}
path, err := a.runSaveImagePipeline(cropped, dir)
if err != nil {
return CaptureActionResult{}, err
}
return CaptureActionResult{Completed: true, Path: path}, nil
}
func (a *App) SaveNativeRegionImageToDir(result CaptureResult, dir string) (CaptureActionResult, error) {
pc, err := a.consumePendingCapture()
if err != nil {
return CaptureActionResult{}, err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
if dir == "" {
return CaptureActionResult{Completed: false}, nil
}
flushFrame()
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return CaptureActionResult{}, err
}
path, err := a.runSaveImagePipeline(cropped, dir)
if err != nil {
return CaptureActionResult{}, err
}
return CaptureActionResult{Completed: true, Path: path}, nil
}
// SaveRegionToRemote uploads the user-selected region via SCP to the
// configured SSH host. Driven by the Wails overlay's "save remote" button.
//
// The flow mirrors ConfirmRegion (S3 upload) but routes through
// runSaveRemotePipeline instead. We dismiss the overlay first so the
// upload progress (and any error toast) shows over the regular settings UI.
func (a *App) SaveRegionToRemote(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SaveRegionToRemote: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
slog.Info("SaveRegionToRemote: 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("SaveRegionToRemote: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
slog.Debug("SaveRegionToRemote: capture ok", "png_bytes", len(cropped))
return a.runSaveRemotePipeline(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
// have to release the dock icon afterwards.
func (a *App) SaveNativeRegionToRemote(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SaveNativeRegionToRemote: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
slog.Info("SaveNativeRegionToRemote: 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("SaveNativeRegionToRemote: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
slog.Debug("SaveNativeRegionToRemote: capture ok", "png_bytes", len(cropped))
return a.runSaveRemotePipeline(cropped)
}
func parseNativeAnnotations(raw string) []application.Annotation {
if raw == "" {
return nil
}
var annotations []application.Annotation
if err := json.Unmarshal([]byte(raw), &annotations); err != nil {
slog.Warn("parse native annotations failed", "err", err)
return nil
}
return annotations
}
// CancelRegion is invoked when the user dismisses the overlay (Esc /
// Cancel button / right-click). Frees the pending PNG, releases the
// capture lock, and hides the overlay.
func (a *App) CancelRegion() {
a.pendingMu.Lock()
a.pending = nil
a.pendingMu.Unlock()
a.capturing.Store(false)
a.dismissOverlay()
}
// CancelNativeRegion releases native-overlay state without touching the
// hidden Wails settings window.
func (a *App) CancelNativeRegion() {
a.pendingMu.Lock()
a.pending = nil
a.pendingMu.Unlock()
a.capturing.Store(false)
hideDockIcon()
}
// ShowWindow brings the main window back to the foreground in its normal
// Settings shape. Used by the tray "Settings…" menu item.
func (a *App) ShowWindow() {
a.surfaceWindow()
time.Sleep(20 * time.Millisecond)
}
// QuitApp terminates the entire process. Wired to the tray "Quit" menu.
func (a *App) QuitApp() {
if a.ctx != nil {
wruntime.Quit(a.ctx)
return
}
os.Exit(0)
}
// runtimeNotifier emits success / failure events via the Wails runtime.
type runtimeNotifier struct{ ctx context.Context }
func (n *runtimeNotifier) NotifySuccess(url string) {
wruntime.EventsEmit(n.ctx, "upload:success", url)
}
func (n *runtimeNotifier) NotifyFailure(reason string) {
wruntime.EventsEmit(n.ctx, "upload:failure", reason)
}
// userPicturesDir returns ~/Pictures (or HOME if unavailable).
func userPicturesDir() string {
home, err := os.UserHomeDir()
if err != nil {
return os.TempDir()
}
return filepath.Join(home, "Pictures")
}
+35
View File
@@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>io.snapgo.app</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>11.0.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>LSUIElement</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>SnapGo needs Apple Events access to capture the active window.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>SnapGo needs screen recording permission to capture screenshots.</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>io.snapgo.app</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>11.0.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<!--
LSUIElement=true turns the app into a "menu-bar agent": it does
NOT show up in the Dock, has no application menu, and is driven
entirely from a menu-bar icon. Mirrors apps like Snipaste,
Bartender, Itsycal, etc.
-->
<key>LSUIElement</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>SnapGo needs Apple Events access to capture the active window.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>SnapGo needs screen recording permission to capture screenshots.</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Hardened Runtime entitlements for SnapGo on macOS.
Design rationale:
- Hardened Runtime is REQUIRED for notarization (Apple Notary Service).
- We deliberately keep entitlements MINIMAL: only what the screenshot tool
actually needs. Granting more increases attack surface and slows review.
- JIT / unsigned-executable-memory is enabled because the embedded WebView
(WKWebView via wails) needs JS JIT.
- We do NOT enable App Sandbox: the app uses /usr/sbin/screencapture and a
global hotkey that requires non-sandboxed access. (App Sandbox + screen
recording requires extra plumbing not in MVP scope.)
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow JIT for WKWebView JavaScript engine -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow unsigned executable memory pages used by JIT -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Allow loading dylibs not signed by the same team (Wails runtime needs this on some setups) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Outbound network for S3 / OSS uploads -->
<key>com.apple.security.network.client</key>
<true/>
<!--
Apple Events: required to invoke /usr/sbin/screencapture and to query
display info via system_profiler. Combined with NSAppleEventsUsageDescription
in Info.plist, this triggers the standard TCC prompt.
-->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}
+114
View File
@@ -0,0 +1,114 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd
+249
View File
@@ -0,0 +1,249 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
{{range .Info.FileAssociations}}
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
File "..\{{.IconName}}.ico"
{{end}}
!macroend
!macro wails.unassociateFiles
; Delete app associations
{{range .Info.FileAssociations}}
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
Delete "$INSTDIR\{{.IconName}}.ico"
{{end}}
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
{{end}}
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
{{end}}
!macroend
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
+14
View File
@@ -0,0 +1,14 @@
// flush_frame.go provides a tiny helper that yields the current goroutine
// long enough for the OS compositor to redraw after WindowHide / Unfullscreen.
//
// Without this, the screenshot can include the just-hidden overlay window
// because Cocoa hasn't redrawn yet by the time we ask for the pixels.
package main
import "time"
func flushFrame() {
// 80ms is empirically enough on Apple Silicon at 120Hz; on slower devices
// the worst case is a single redraw delay, still imperceptible to the user.
time.Sleep(80 * time.Millisecond)
}
+23
View File
@@ -0,0 +1,23 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue
3 `<script setup>` SFCs, check out
the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type
by default. In most cases this is fine if you don't really care about component prop types outside of templates.
However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using
manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look
for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default,
Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>SnapGo</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.ts" type="module"></script>
</body>
</html>
+1071
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.2.37"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
"typescript": "^4.6.4",
"vite": "^3.0.7",
"vue-tsc": "^1.8.27",
"@babel/types": "^7.18.10"
}
}
+1
View File
@@ -0,0 +1 @@
bb7ffb87329c9ad4990374471d4ce9a4
+406
View File
@@ -0,0 +1,406 @@
<script setup lang="ts">
/*
* 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.
* • "overlay" : Snipaste-style region picker that fills the whole
* primary display.
*
* The Go side resizes/positions/flag-flips the window for each mode and
* 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 SettingsView from './views/SettingsView.vue'
import Toast from './components/Toast.vue'
import CaptureOverlay from './views/CaptureOverlay.vue'
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'
import {
RetryRegisterHotkey,
ConfirmRegion,
CopyRegionImage,
SaveRegionImage,
SaveRegionToRemote,
CancelRegion,
} from '../wailsjs/go/main/App'
type HotkeyStatus =
| { state: 'unknown' }
| { state: 'ok'; spec: string }
| { state: 'error'; reason: string }
const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
type Mode = 'settings' | 'overlay'
const mode = ref<Mode>('settings')
// `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'
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' },
]
interface OverlayPayload {
cssWidth: number
cssHeight: number
scale: number
}
const overlayPayload = ref<OverlayPayload | null>(null)
const capturing = ref(false)
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
let toastTimer: number | undefined
function showToast(kind: 'success' | 'error', text: string) {
toast.value = { kind, text }
window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => {
toast.value = null
}, 3000)
}
async function retryHotkey() {
try {
await RetryRegisterHotkey()
} catch {
/* Surfaced via hotkey:error */
}
}
async function onOverlayConfirm(rect: {
rect: {
x: number
y: number
w: number
h: number
}
annotations: Array<{
tool: string
color: string
points: Array<{ x: number; y: number }>
}>
}) {
// Optimistically swap back so the window does not visually lag the
// Go-side hide. If upload fails, the toast surfaces the reason.
mode.value = 'settings'
overlayPayload.value = null
try {
await ConfirmRegion(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCopy(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 CopyRegionImage(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlaySave(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 SaveRegionImage(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlaySaveRemote(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 SaveRegionToRemote(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCancel() {
mode.value = 'settings'
overlayPayload.value = null
try {
await CancelRegion()
} catch {
/* Best-effort */
}
}
onMounted(() => {
EventsOn('capture:start', () => {
capturing.value = true
})
EventsOn('capture:end', () => {
capturing.value = false
})
EventsOn('capture:cancelled', () => {
capturing.value = false
})
EventsOn('capture:overlay', (payload: OverlayPayload) => {
overlayPayload.value = payload
mode.value = 'overlay'
capturing.value = false
})
EventsOn('upload:success', (url: string) => {
showToast('success', `Copied: ${url}`)
})
EventsOn('upload:failure', (reason: string) => {
showToast('error', reason)
})
EventsOn('hotkey:ready', (spec: string) => {
hotkeyStatus.value = { state: 'ok', spec }
})
EventsOn('hotkey:error', (reason: string) => {
hotkeyStatus.value = { state: 'error', reason }
})
})
onUnmounted(() => {
EventsOff('capture:start')
EventsOff('capture:end')
EventsOff('capture:cancelled')
EventsOff('capture:overlay')
EventsOff('upload:success')
EventsOff('upload:failure')
EventsOff('hotkey:ready')
EventsOff('hotkey:error')
})
</script>
<template>
<!-- Overlay mode: full-screen Snipaste-style picker. -->
<CaptureOverlay
v-if="mode === 'overlay' && overlayPayload"
:width="overlayPayload.cssWidth"
:height="overlayPayload.cssHeight"
@confirm="onOverlayConfirm"
@copy="onOverlayCopy"
@save="onOverlaySave"
@save-remote="onOverlaySaveRemote"
@cancel="onOverlayCancel"
/>
<!-- Settings mode: standard desktop window; macOS title bar is native. -->
<div v-else class="app-root">
<div v-if="hotkeyStatus.state === 'error'" class="permission-banner">
<div>
<strong>Global hotkey is not active.</strong>
Reason: {{ hotkeyStatus.reason }}
<br />
On macOS open
<em>System Settings Privacy &amp; Security Accessibility</em>,
enable <strong>SnapGo</strong>, then click "Retry".
</div>
<button class="btn" @click="retryHotkey">Retry</button>
</div>
<main class="main">
<aside class="sidebar">
<div
v-for="item in sidebarItems"
:key="item.id"
class="sidebar-item"
:class="{ active: activeTab === item.id }"
@click="activeTab = item.id"
>
<span class="dot" /> {{ item.label }}
</div>
</aside>
<section class="content">
<SettingsView :tab="activeTab" />
</section>
</main>
<Toast v-if="toast" :kind="toast.kind" :text="toast.text" />
</div>
</template>
<style scoped>
.app-root {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: #f6f7fa;
color: #111827;
font-size: 13px;
}
.status-pill {
font-size: 11px;
padding: 3px 9px;
border-radius: 999px;
font-weight: 500;
}
.status-pill.ok {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.status-pill.err {
color: #b91c1c;
background: rgba(239, 68, 68, 0.14);
cursor: help;
}
.status-pill.muted {
color: #6b7280;
background: rgba(107, 114, 128, 0.14);
}
.permission-banner {
display: flex;
gap: 12px;
align-items: flex-start;
margin: 12px 16px 0;
padding: 10px 14px;
background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 8px;
color: #7c2d12;
font-size: 12px;
line-height: 1.55;
}
.permission-banner em {
font-style: normal;
background: rgba(124, 45, 18, 0.08);
padding: 0 4px;
border-radius: 3px;
}
.permission-banner .btn {
flex: 0 0 auto;
border: 1px solid #fdba74;
background: #fff;
color: #7c2d12;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
}
.permission-banner .btn:hover {
background: #fed7aa;
}
.main {
flex: 1;
display: grid;
grid-template-columns: 180px 1fr;
min-height: 0;
}
.sidebar {
border-right: 1px solid #e5e7eb;
padding: 14px 8px;
background: rgba(255, 255, 255, 0.4);
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 6px;
font-size: 13px;
color: #374151;
cursor: pointer;
}
.sidebar-item:hover:not(.active) {
background: rgba(0, 0, 0, 0.04);
}
.sidebar-item.active {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
font-weight: 500;
}
.sidebar-item .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.content {
overflow-y: auto;
min-width: 0;
}
@media (prefers-color-scheme: dark) {
.app-root {
background: #1c1d22;
color: #e5e7eb;
}
.titlebar {
background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36;
}
.titlebar-title {
color: #f3f4f6;
}
.sidebar {
background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36;
}
.sidebar-item {
color: #d1d5db;
}
.sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.permission-banner {
background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa;
}
}
</style>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M512 85.333333c235.648 0 426.666667 191.018667 426.666667 426.666667s-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512 276.352 85.333333 512 85.333333zM170.666667 512a341.333333 341.333333 0 0 0 550.613333 269.653333L242.304 302.762667A339.84 339.84 0 0 0 170.666667 512z m341.333333-341.333333c-78.848 0-151.466667 26.752-209.28 71.68l478.976 478.933333A341.333333 341.333333 0 0 0 512 170.666667z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 534 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M768 256a85.333333 85.333333 0 0 1 85.333333 85.333333v512a85.333333 85.333333 0 0 1-85.333333 85.333334h-341.333333a85.333333 85.333333 0 0 1-85.333334-85.333334V341.333333a85.333333 85.333333 0 0 1 85.333334-85.333333h341.333333z m0 85.333333h-341.333333v512h341.333333V341.333333z m-128-256a42.666667 42.666667 0 0 1 42.666667 42.666667l-0.042667 42.666667H256l-0.042667 597.333333H213.333333a42.666667 42.666667 0 0 1-42.666666-42.666667V170.666667a85.333333 85.333333 0 0 1 85.333333-85.333334h384z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 612 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M896 810.666667a85.333333 85.333333 0 0 1-85.333333 85.333333H213.333333a85.333333 85.333333 0 0 1-85.333333-85.333333V213.333333a85.333333 85.333333 0 0 1 85.333333-85.333333h250.368a85.333333 85.333333 0 0 1 73.173334 41.429333L563.2 213.333333H810.666667a85.333333 85.333333 0 0 1 85.333333 85.333334v512z m-85.333333-341.333334H213.333333v341.333334h597.333334v-341.333334z m-346.965334-256H213.333333v170.666667h597.333334V298.666667h-271.616a42.666667 42.666667 0 0 1-36.608-20.736L463.701333 213.333333z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 619 B

@@ -0,0 +1 @@
<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="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 946 B

+1
View File
@@ -0,0 +1 @@
<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="currentColor" p-id="2487"></path></svg>

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
/*
* Toast bottom-right transient notification used for upload success /
* failure messages. Self-managed lifecycle is owned by the parent App.vue,
* which simply hides this component after a delay.
*/
defineProps<{
kind: 'success' | 'error'
text: string
}>()
</script>
<template>
<div class="toast" :class="kind">{{ text }}</div>
</template>
<style scoped>
.toast {
position: fixed;
right: 18px;
bottom: 18px;
max-width: 380px;
padding: 10px 14px;
border-radius: 6px;
font-size: 12.5px;
color: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
white-space: pre-wrap;
word-break: break-all;
animation: toast-in 0.18s ease-out;
}
.toast.success {
background: #10b981;
}
.toast.error {
background: #ef4444;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import {createApp} from 'vue'
import App from './App.vue'
import './style.css';
createApp(App).mount('#app')
+42
View File
@@ -0,0 +1,42 @@
/*
* Global styles for SnapGo.
*
* Design rationale:
* - The native window/webview is transparent so the capture overlay can show
* the live desktop through its selection cut-out.
* - SettingsView remains opaque because App.vue paints `.app-root` across
* the whole window.
*/
:root {
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI Variable", "Inter", sans-serif;
font-size: 14px;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: transparent;
color: #111827;
-webkit-user-select: none;
user-select: none;
}
@media (prefers-color-scheme: dark) {
html,
body {
background: transparent;
color: #e5e7eb;
}
}
#app {
height: 100%;
}
button {
font-family: inherit;
}
+851
View File
@@ -0,0 +1,851 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
// Inline SVG markup imported as raw strings via Vite's `?raw` suffix.
// Rationale: rendering through v-html lets the icon inherit `currentColor`
// from the toolbar button, so styling stays in CSS without bundling extra
// SVG-as-component plugins.
import cancelIcon from '../assets/icons/cancel.svg?raw'
import copyIcon from '../assets/icons/copy.svg?raw'
import saveIcon from '../assets/icons/save-local.svg?raw'
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
import uploadIcon from '../assets/icons/upload.svg?raw'
interface Props {
width: number
height: number
}
const props = defineProps<Props>()
interface Rect {
x: number
y: number
w: number
h: number
}
type Tool = 'pen' | 'rect' | 'ellipse'
interface Point {
x: number
y: number
}
interface Annotation {
tool: Tool
color: string
points: Point[]
}
const emit = defineEmits<{
(
e: 'confirm',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'copy',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'save',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'save-remote',
payload: { rect: Rect; annotations: Annotation[] }
): void
(e: 'cancel'): void
}>()
const rect = ref<Rect | null>(null)
const annotations = ref<Annotation[]>([])
const draftAnnotation = ref<Annotation | null>(null)
const activeTool = ref<Tool>('pen')
const activeColor = ref('#ef4444')
const paletteOpen = ref(false)
type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se'
type DragMode = 'idle' | 'creating' | 'moving' | 'resizing' | 'annotating'
const dragMode = ref<DragMode>('idle')
const resizeHandle = ref<ResizeHandle | null>(null)
const dragAnchor = ref({ x: 0, y: 0 })
const startRect = ref<Rect | null>(null)
const colors = [
'#ef4444',
'#f97316',
'#facc15',
'#22c55e',
'#06b6d4',
'#3b82f6',
'#8b5cf6',
'#ec4899',
'#ffffff',
'#111827',
]
const maskPath = computed(() => {
const outer = `M0 0 H${props.width} V${props.height} H0 Z`
if (!rect.value) return outer
const r = rect.value
const inner = `M${r.x} ${r.y} H${r.x + r.w} V${r.y + r.h} H${r.x} Z`
return outer + ' ' + inner
})
const allAnnotations = computed(() => {
return draftAnnotation.value
? [...annotations.value, draftAnnotation.value]
: annotations.value
})
const selectionAnnotations = computed(() => {
if (!rect.value) return []
return allAnnotations.value.map((annotation) => ({
...annotation,
points: annotation.points.map((point) => ({
x: rect.value!.x + point.x,
y: rect.value!.y + point.y,
})),
}))
})
const sizeLabel = computed(() => {
if (!rect.value) return ''
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
})
const rightToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 180, 40, 'right')
})
const leftToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 190, 40, 'left')
})
const handles = computed(() => {
if (!rect.value) return []
const r = rect.value
const cx = r.x + r.w / 2
const cy = r.y + r.h / 2
return [
{ name: 'nw', x: r.x, y: r.y },
{ name: 'n', x: cx, y: r.y },
{ name: 'ne', x: r.x + r.w, y: r.y },
{ name: 'e', x: r.x + r.w, y: cy },
{ name: 'se', x: r.x + r.w, y: r.y + r.h },
{ name: 's', x: cx, y: r.y + r.h },
{ name: 'sw', x: r.x, y: r.y + r.h },
{ name: 'w', x: r.x, y: cy },
] as Array<{ name: ResizeHandle; x: number; y: number }>
})
function placeToolbar(
r: Rect,
toolbarW: number,
toolbarH: number,
side: 'left' | 'right'
) {
const gap = 8
let x =
side === 'right'
? r.x + r.w - toolbarW
: r.x
let y = r.y + r.h + gap
if (y + toolbarH > props.height) {
y = r.y + r.h - toolbarH - gap
}
x = clamp(x, 0, props.width - toolbarW)
return { x, y }
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
function normalizeRect(a: Point, b: Point): Rect {
return {
x: clamp(Math.min(a.x, b.x), 0, props.width),
y: clamp(Math.min(a.y, b.y), 0, props.height),
w: Math.abs(b.x - a.x),
h: Math.abs(b.y - a.y),
}
}
function pointFromEvent(e: MouseEvent): Point {
return {
x: clamp(e.clientX, 0, props.width),
y: clamp(e.clientY, 0, props.height),
}
}
function localPoint(p: Point) {
if (!rect.value) return p
return {
x: clamp(p.x - rect.value.x, 0, rect.value.w),
y: clamp(p.y - rect.value.y, 0, rect.value.h),
}
}
function insideRect(p: Point, r: Rect | null) {
if (!r) return false
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h
}
function hitHandle(p: Point): ResizeHandle | null {
if (!rect.value) return null
const tolerance = 9
for (const handle of handles.value) {
if (
Math.abs(p.x - handle.x) <= tolerance &&
Math.abs(p.y - handle.y) <= tolerance
) {
return handle.name
}
}
const r = rect.value
const nearX = p.x >= r.x - tolerance && p.x <= r.x + r.w + tolerance
const nearY = p.y >= r.y - tolerance && p.y <= r.y + r.h + tolerance
if (nearX && Math.abs(p.y - r.y) <= tolerance) return 'n'
if (nearX && Math.abs(p.y - (r.y + r.h)) <= tolerance) return 's'
if (nearY && Math.abs(p.x - r.x) <= tolerance) return 'w'
if (nearY && Math.abs(p.x - (r.x + r.w)) <= tolerance) return 'e'
return null
}
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return
paletteOpen.value = false
const p = pointFromEvent(e)
const handle = hitHandle(p)
if (handle && rect.value) {
dragMode.value = 'resizing'
resizeHandle.value = handle
dragAnchor.value = p
startRect.value = { ...rect.value }
return
}
if (rect.value && insideRect(p, rect.value)) {
dragMode.value = 'moving'
dragAnchor.value = { x: p.x - rect.value.x, y: p.y - rect.value.y }
return
}
dragMode.value = 'creating'
dragAnchor.value = p
rect.value = { x: p.x, y: p.y, w: 0, h: 0 }
annotations.value = []
draftAnnotation.value = null
}
function onSelectionMouseDown(e: MouseEvent) {
if (e.button !== 0 || !rect.value) return
e.stopPropagation()
paletteOpen.value = false
if (e.detail >= 2) {
removeSinglePointAnnotation()
onCopy()
return
}
const p = pointFromEvent(e)
const handle = hitHandle(p)
if (handle) {
dragMode.value = 'resizing'
resizeHandle.value = handle
dragAnchor.value = p
startRect.value = { ...rect.value }
return
}
dragMode.value = 'annotating'
const local = localPoint(p)
draftAnnotation.value = {
tool: activeTool.value,
color: activeColor.value,
points: [local],
}
}
function onMouseMove(e: MouseEvent) {
if (dragMode.value === 'idle') return
const p = pointFromEvent(e)
if (dragMode.value === 'creating') {
rect.value = normalizeRect(dragAnchor.value, p)
} else if (dragMode.value === 'moving' && rect.value) {
rect.value = {
...rect.value,
x: clamp(p.x - dragAnchor.value.x, 0, props.width - rect.value.w),
y: clamp(p.y - dragAnchor.value.y, 0, props.height - rect.value.h),
}
} else if (dragMode.value === 'resizing') {
resizeSelection(p)
} else if (dragMode.value === 'annotating' && draftAnnotation.value) {
if (activeTool.value === 'pen') {
draftAnnotation.value.points.push(localPoint(p))
} else {
draftAnnotation.value.points = [
draftAnnotation.value.points[0],
localPoint(p),
]
}
}
}
function onMouseUp() {
if (dragMode.value === 'creating' && rect.value) {
if (rect.value.w < 4 || rect.value.h < 4) {
rect.value = null
}
}
if (dragMode.value === 'annotating' && draftAnnotation.value) {
if (draftAnnotation.value.points.length > 0) {
annotations.value.push(draftAnnotation.value)
}
draftAnnotation.value = null
}
dragMode.value = 'idle'
resizeHandle.value = null
startRect.value = null
}
function resizeSelection(p: Point) {
if (!startRect.value || !resizeHandle.value) return
const r = startRect.value
let left = r.x
let top = r.y
let right = r.x + r.w
let bottom = r.y + r.h
if (resizeHandle.value.includes('w')) left = p.x
if (resizeHandle.value.includes('e')) right = p.x
if (resizeHandle.value.includes('n')) top = p.y
if (resizeHandle.value.includes('s')) bottom = p.y
left = clamp(left, 0, props.width)
right = clamp(right, 0, props.width)
top = clamp(top, 0, props.height)
bottom = clamp(bottom, 0, props.height)
rect.value = {
x: Math.min(left, right),
y: Math.min(top, bottom),
w: Math.abs(right - left),
h: Math.abs(bottom - top),
}
}
function selectTool(tool: Tool) {
activeTool.value = tool
}
function chooseColor(color: string) {
activeColor.value = color
paletteOpen.value = false
}
function onConfirm() {
emitAction('confirm')
}
function onCopy() {
emitAction('copy')
}
function onSave() {
emitAction('save')
}
function onSaveRemote() {
emitAction('save-remote')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
if (!rect.value) return
const payload = {
rect: { ...rect.value },
annotations: annotations.value,
}
if (action === 'confirm') emit('confirm', payload)
if (action === 'copy') emit('copy', payload)
if (action === 'save') emit('save', payload)
if (action === 'save-remote') emit('save-remote', payload)
}
function onCancel() {
emit('cancel')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
} else if (e.key === 'Enter' && rect.value) {
e.preventDefault()
onConfirm()
}
}
function undoAnnotation() {
annotations.value.pop()
}
function removeSinglePointAnnotation() {
const last = annotations.value[annotations.value.length - 1]
if (last && last.tool === activeTool.value && last.points.length <= 1) {
annotations.value.pop()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div
class="overlay-root"
:style="{ width: width + 'px', height: height + 'px' }"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@contextmenu.prevent="onCancel"
>
<svg
class="mask"
:width="width"
:height="height"
:viewBox="`0 0 ${width} ${height}`"
preserveAspectRatio="none"
>
<path :d="maskPath" fill="rgba(0,0,0,0.45)" fill-rule="evenodd" />
<rect
v-if="rect"
:x="rect.x"
:y="rect.y"
:width="rect.w"
:height="rect.h"
fill="transparent"
stroke="#3b82f6"
stroke-width="1.5"
vector-effect="non-scaling-stroke"
/>
<g v-for="(annotation, index) in selectionAnnotations" :key="index">
<polyline
v-if="annotation.tool === 'pen'"
:points="annotation.points.map((p) => `${p.x},${p.y}`).join(' ')"
:stroke="annotation.color"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<rect
v-else-if="annotation.tool === 'rect' && annotation.points.length >= 2"
:x="Math.min(annotation.points[0].x, annotation.points[1].x)"
:y="Math.min(annotation.points[0].y, annotation.points[1].y)"
:width="Math.abs(annotation.points[1].x - annotation.points[0].x)"
:height="Math.abs(annotation.points[1].y - annotation.points[0].y)"
:stroke="annotation.color"
stroke-width="3"
fill="none"
/>
<ellipse
v-else-if="annotation.tool === 'ellipse' && annotation.points.length >= 2"
:cx="(annotation.points[0].x + annotation.points[1].x) / 2"
:cy="(annotation.points[0].y + annotation.points[1].y) / 2"
:rx="Math.abs(annotation.points[1].x - annotation.points[0].x) / 2"
:ry="Math.abs(annotation.points[1].y - annotation.points[0].y) / 2"
:stroke="annotation.color"
stroke-width="3"
fill="none"
/>
</g>
</svg>
<div
v-if="rect"
class="selection-hit-area"
:style="{
left: rect.x + 'px',
top: rect.y + 'px',
width: rect.w + 'px',
height: rect.h + 'px',
}"
@mousedown="onSelectionMouseDown"
/>
<div
v-for="handle in handles"
:key="handle.name"
class="resize-handle"
:class="`handle-${handle.name}`"
:style="{ left: handle.x + 'px', top: handle.y + 'px' }"
@mousedown.stop="
(event) => {
dragMode = 'resizing'
resizeHandle = handle.name
dragAnchor = pointFromEvent(event)
startRect = rect ? { ...rect } : null
}
"
/>
<div
v-if="rect && rect.w > 0 && rect.h > 0"
class="size-pill"
:style="{
left: rect.x + 'px',
top: Math.max(0, rect.y - 26) + 'px',
}"
>
{{ sizeLabel }}
</div>
<div
v-if="leftToolbarPos && rect && rect.w >= 4 && rect.h >= 4"
class="toolbar mark-toolbar"
:style="{ left: leftToolbarPos.x + 'px', top: leftToolbarPos.y + 'px' }"
@mousedown.stop
>
<button
class="icon-btn"
:class="{ active: activeTool === 'pen' }"
title="划线标记"
@click="selectTool('pen')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 20c4-1 6-3 8-7l5-9 3 2-5 9c-2 4-5 6-9 7z" />
<path d="M14 5l5 3" />
</svg>
</button>
<button
class="icon-btn"
:class="{ active: activeTool === 'rect' }"
title="矩形标记"
@click="selectTool('rect')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="5" y="6" width="14" height="12" rx="1.5" />
</svg>
</button>
<button
class="icon-btn"
:class="{ active: activeTool === 'ellipse' }"
title="圆形标记"
@click="selectTool('ellipse')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="7" />
</svg>
</button>
<div class="color-wrap">
<button
class="icon-btn color-btn"
title="选择标记颜色"
@click="paletteOpen = !paletteOpen"
>
<span :style="{ background: activeColor }" />
</button>
<div v-if="paletteOpen" class="palette">
<button
v-for="color in colors"
:key="color"
class="swatch"
:class="{ selected: color === activeColor }"
:style="{ background: color }"
:title="color"
@click="chooseColor(color)"
/>
</div>
</div>
<button
class="icon-btn"
:disabled="annotations.length === 0"
title="撤销上一处标记"
@click="undoAnnotation"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 7H4v5" />
<path d="M4 7c3-3 8-4 12-1 4 3 4 9 0 12-2 1-4 2-7 1" />
</svg>
</button>
</div>
<div
v-if="rightToolbarPos && rect && rect.w >= 4 && rect.h >= 4"
class="toolbar action-toolbar"
:style="{ left: rightToolbarPos.x + 'px', top: rightToolbarPos.y + 'px' }"
@mousedown.stop
>
<button
class="action-btn cancel"
data-tip="取消截图"
aria-label="取消截图"
@click="onCancel"
v-html="cancelIcon"
/>
<button
class="action-btn"
data-tip="复制图标"
aria-label="复制图标"
@click="onCopy"
v-html="copyIcon"
/>
<button
class="action-btn"
data-tip="保存本地"
aria-label="保存本地"
@click="onSave"
v-html="saveIcon"
/>
<button
class="action-btn"
data-tip="保存远端"
aria-label="保存远端"
@click="onSaveRemote"
v-html="saveRemoteIcon"
/>
<button
class="action-btn primary"
data-tip="上传云端"
aria-label="上传云端"
@click="onConfirm"
v-html="uploadIcon"
/>
</div>
<div v-if="!rect" class="hint">
Drag to select an area &nbsp;·&nbsp; Esc to cancel
</div>
</div>
</template>
<style scoped>
.overlay-root {
position: fixed;
inset: 0;
user-select: none;
cursor: crosshair;
background: transparent;
overflow: hidden;
}
.mask {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
.selection-hit-area {
position: absolute;
cursor: crosshair;
}
.resize-handle {
position: absolute;
width: 10px;
height: 10px;
transform: translate(-50%, -50%);
border: 1px solid #fff;
background: #3b82f6;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
z-index: 3;
}
.handle-n,
.handle-s {
cursor: ns-resize;
}
.handle-e,
.handle-w {
cursor: ew-resize;
}
.handle-nw,
.handle-se {
cursor: nwse-resize;
}
.handle-ne,
.handle-sw {
cursor: nesw-resize;
}
.size-pill {
position: absolute;
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: rgba(15, 23, 42, 0.78);
border-radius: 4px;
pointer-events: none;
font-variant-numeric: tabular-nums;
}
.toolbar {
position: absolute;
display: flex;
gap: 6px;
align-items: center;
height: 32px;
padding: 4px;
background: rgba(28, 28, 32, 0.94);
border-radius: 8px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4);
cursor: default;
z-index: 4;
}
.mark-toolbar {
width: 190px;
}
.action-toolbar {
width: 180px;
}
.icon-btn,
.action-btn,
.swatch {
font-family: inherit;
}
/*
* .action-btn Square SVG icon button used in the bottom-right action toolbar.
*
* Design rationale:
* - Mirrors .icon-btn sizing (32px square) so the action toolbar is visually
* balanced with the annotation toolbar.
* - Uses `currentColor` so each instance can override its own hue (e.g. the
* primary upload button is blue) while sharing one base set of rules.
* - The native CSS tooltip is rendered through `::after` driven by the
* `data-tip` attribute. This avoids the system `title` tooltip's slow show
* delay and lets us style/position the bubble consistently.
*/
.action-btn {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 5px;
color: #d1d5db;
background: transparent;
cursor: pointer;
padding: 0;
}
.action-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.action-btn.cancel:hover {
color: #fca5a5;
background: rgba(239, 68, 68, 0.18);
}
.action-btn.primary {
color: #60a5fa;
}
.action-btn.primary:hover {
color: #fff;
background: #2563eb;
}
.action-btn :deep(svg) {
width: 18px;
height: 18px;
display: block;
fill: currentColor;
pointer-events: none;
}
.action-btn::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 3px 8px;
font-size: 12px;
color: #fff;
background: rgba(15, 23, 42, 0.92);
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.12s ease 0.05s;
z-index: 5;
}
.action-btn:hover::after {
opacity: 1;
}
.icon-btn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 5px;
color: #d1d5db;
background: transparent;
cursor: pointer;
}
.icon-btn:hover:not(:disabled),
.icon-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.icon-btn svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.color-wrap {
position: relative;
}
.color-btn span {
width: 16px;
height: 16px;
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18);
}
.palette {
position: absolute;
left: 0;
bottom: 36px;
display: grid;
grid-template-columns: repeat(5, 22px);
gap: 6px;
padding: 8px;
background: rgba(28, 28, 32, 0.96);
border-radius: 8px;
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.45);
}
.swatch {
width: 22px;
height: 22px;
border: 1px solid rgba(255, 255, 255, 0.55);
border-radius: 4px;
cursor: pointer;
}
.swatch.selected {
outline: 2px solid #fff;
outline-offset: 2px;
}
.hint {
position: absolute;
left: 50%;
top: 24px;
transform: translateX(-50%);
padding: 6px 14px;
font-size: 12px;
color: #f3f4f6;
background: rgba(0, 0, 0, 0.5);
border-radius: 999px;
pointer-events: none;
}
</style>
+649
View File
@@ -0,0 +1,649 @@
<script setup lang="ts">
/*
* SettingsView the home screen of SnapGo before the user triggers a
* capture. The active configuration section is selected by the App-shell
* sidebar and passed in via the `tab` prop, so this view only owns the
* card content for the currently selected destination type:
*
* "general" the global hotkey / capture parameters.
* "s3" S3-compatible object-storage credentials.
* "ssh" SSH/SCP destination for the "save remote" button.
*
* State flow: load() pulls config from Go on mount, save() pushes back.
*/
import { computed, onMounted, ref } from 'vue'
import {
GetConfig,
SaveConfig,
TestConnection,
TestSSHConnection,
CaptureNow,
} from '../../wailsjs/go/main/App'
// 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'
const props = defineProps<{
tab: TabId
}>()
interface S3Config {
endpoint: string
region: string
bucket: string
accessKeyId: string
secretAccessKey: string
pathPrefix: string
publicUrlBase: string
usePathStyle: boolean
}
interface SSHConfig {
host: string
port: number
user: string
authMethod: string
password: string
pathPrefix: string
strictHostKey: boolean
knownHostsPath: string
connectTimeoutSecs: number
}
interface AppConfig {
hotkey: string
s3: S3Config
ssh: SSHConfig
}
// `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
// frontend and backend.
function defaultConfig(): AppConfig {
return {
hotkey: 'cmd+shift+a',
s3: {
endpoint: '',
region: 'us-east-1',
bucket: '',
accessKeyId: '',
secretAccessKey: '',
pathPrefix: 'snapgo/',
publicUrlBase: '',
usePathStyle: true,
},
ssh: {
host: '',
port: 22,
user: '',
authMethod: 'password',
password: '',
pathPrefix: 'snapgo/',
strictHostKey: false,
knownHostsPath: '',
connectTimeoutSecs: 10,
},
}
}
const config = ref<AppConfig>(defaultConfig())
const saving = ref(false)
const testing = ref(false)
const testingSSH = ref(false)
const message = ref<{ kind: 'ok' | 'err'; text: string } | null>(null)
// The "remote path" in the UI is shown as "~/<pathPrefix>" so the user
// understands that everything is rooted at the remote home directory.
// We strip leading slashes / tilde here defensively in case someone pastes
// a full path.
const sshPathDisplay = computed({
get: () => config.value.ssh.pathPrefix,
set: (raw: string) => {
let cleaned = raw.trim()
cleaned = cleaned.replace(/^~/, '')
cleaned = cleaned.replace(/^\/+/, '')
config.value.ssh.pathPrefix = cleaned
},
})
function flash(kind: 'ok' | 'err', text: string) {
message.value = { kind, text }
window.setTimeout(() => {
if (message.value?.text === text) message.value = null
}, 4000)
}
async function load() {
const cfg = await GetConfig()
if (cfg) {
// Merge with defaults so a config file written before SSH support
// existed does not leave ssh fields undefined.
const merged = defaultConfig()
Object.assign(merged, cfg)
merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
config.value = merged
}
}
async function save() {
saving.value = true
try {
await SaveConfig(config.value as any)
flash('ok', 'Settings saved')
} catch (err: any) {
flash('err', `Save failed: ${err?.message ?? err}`)
} finally {
saving.value = false
}
}
async function test() {
testing.value = true
try {
await TestConnection(config.value.s3 as any)
flash('ok', 'S3 connection OK — bucket is writable')
} catch (err: any) {
flash('err', `Test failed: ${err?.message ?? err}`)
} finally {
testing.value = false
}
}
async function testSSH() {
testingSSH.value = true
try {
await TestSSHConnection(config.value.ssh as any)
flash('ok', 'SSH connection OK')
} catch (err: any) {
flash('err', `SSH test failed: ${err?.message ?? err}`)
} finally {
testingSSH.value = false
}
}
async function manualCapture() {
await CaptureNow()
}
onMounted(load)
</script>
<template>
<div class="settings">
<header class="hero">
<div>
<h1>SnapGo</h1>
<p class="subtitle">
Press <kbd>{{ config.hotkey }}</kbd> anywhere to capture, upload, and
copy the URL to your clipboard.
</p>
</div>
<button class="btn primary" @click="manualCapture">Capture now</button>
</header>
<!-- General tab only the shortcut configuration lives here. -->
<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>
<p class="hint">
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
az or 09. Example: <code>cmd+shift+a</code>.
</p>
<div class="actions">
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</section>
<!-- S3-Conf tab unchanged content, moved out of General. -->
<section v-if="props.tab === 's3'" class="card">
<h2>S3-compatible storage</h2>
<div class="grid">
<label class="field">
<span>Endpoint *</span>
<input
v-model="config.s3.endpoint"
placeholder="https://s3.us-east-005.backblazeb2.com"
/>
</label>
<label class="field">
<span>Region</span>
<input v-model="config.s3.region" placeholder="us-east-1 / auto" />
</label>
<label class="field">
<span>Bucket *</span>
<input v-model="config.s3.bucket" placeholder="my-screenshots" />
</label>
<label class="field">
<span>Access Key ID *</span>
<input v-model="config.s3.accessKeyId" />
</label>
<label class="field">
<span>Secret Access Key *</span>
<input v-model="config.s3.secretAccessKey" type="password" />
</label>
<label class="field">
<span>Path prefix</span>
<input v-model="config.s3.pathPrefix" placeholder="snapgo/" />
</label>
<label class="field full">
<span>Public URL base (optional, e.g. CDN)</span>
<input
v-model="config.s3.publicUrlBase"
placeholder="https://cdn.example.com/screenshots"
/>
</label>
<label class="field checkbox">
<input type="checkbox" v-model="config.s3.usePathStyle" />
<span
>Use path-style addressing (recommended for MinIO, R2, custom
domains)</span
>
</label>
</div>
<div class="actions">
<button class="btn" :disabled="testing" @click="test">
{{ testing ? 'Testing…' : 'Test connection' }}
</button>
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</section>
<!-- SSH-Conf tab destination for the save-remote action. -->
<section v-if="props.tab === 'ssh'" class="card">
<h2>SSH / SCP destination</h2>
<div class="grid">
<label class="field">
<span>Host (IP or domain) *</span>
<input v-model="config.ssh.host" placeholder="192.168.1.10" />
</label>
<label class="field">
<span>Port</span>
<input
v-model.number="config.ssh.port"
type="number"
min="1"
max="65535"
placeholder="22"
/>
</label>
<div class="field-row auth-row">
<label class="field">
<span>Auth method</span>
<select v-model="config.ssh.authMethod">
<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">
<span>User *</span>
<input v-model="config.ssh.user" placeholder="ubuntu" />
</label>
<label
v-if="config.ssh.authMethod === 'password'"
class="field"
>
<span>Password</span>
<input v-model="config.ssh.password" type="password" />
</label>
</div>
<label class="field full">
<span>Remote path (under home)</span>
<div class="prefixed-input">
<span class="input-prefix">~/</span>
<input
v-model="sshPathDisplay"
placeholder="snapgo/"
spellcheck="false"
/>
</div>
</label>
<label class="field">
<span>Connect timeout (sec)</span>
<input
v-model.number="config.ssh.connectTimeoutSecs"
type="number"
min="1"
max="120"
placeholder="10"
/>
</label>
<label class="field">
<span>Known hosts path</span>
<input
v-model="config.ssh.knownHostsPath"
placeholder="~/.ssh/known_hosts"
:disabled="!config.ssh.strictHostKey"
/>
</label>
<label class="field checkbox">
<input type="checkbox" v-model="config.ssh.strictHostKey" />
<span>
Strict host key checking (recommended for production hosts)
</span>
</label>
</div>
<p v-if="config.ssh.authMethod === 'kerberos'" class="hint">
Kerberos mode delegates to the system <code>ssh</code>/<code>scp</code>.
Run <code>kinit your.name@BYTEDANCE.COM</code> in a terminal first; tickets
expire (typically every 1024h), so re-run <code>kinit</code> if uploads
start failing. Verify with <code>klist</code>.
</p>
<p v-else-if="config.ssh.authMethod === 'key'" class="hint">
SSH-Key mode uses your <code>ssh-agent</code> and <code>~/.ssh/id_*</code>
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"
>
Password is empty enter the remote login password, or switch the auth
method to SSH-Key.
</p>
<div class="actions">
<button class="btn" :disabled="testingSSH" @click="testSSH">
{{ testingSSH ? 'Testing…' : 'Test connection' }}
</button>
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</section>
<transition name="fade">
<div
v-if="message"
class="banner"
:class="message.kind === 'ok' ? 'ok' : 'err'"
>
{{ message.text }}
</div>
</transition>
</div>
</template>
<style scoped>
.settings {
max-width: 760px;
margin: 0 auto;
padding: 28px 32px 60px;
color: #1f2937;
}
.hero {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.hero h1 {
margin: 0 0 6px;
font-size: 24px;
letter-spacing: -0.01em;
}
.subtitle {
margin: 0;
color: #6b7280;
font-size: 13px;
}
kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 4px;
padding: 1px 6px;
font-size: 12px;
}
/* Tab strip removed — sidebar in App.vue now drives section selection. */
.card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 18px 20px;
margin-bottom: 18px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.card h2 {
margin: 0 0 12px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.field.full {
grid-column: 1 / -1;
}
/* field-row groups several fields onto a single full-width row. auth-row
splits Auth method / User / Password roughly 1:2:2. */
.field-row {
grid-column: 1 / -1;
display: grid;
gap: 12px 16px;
}
.auth-row {
grid-template-columns: 1fr 2fr 2fr;
}
.field span {
color: #374151;
font-weight: 500;
}
.field input[type='text'],
.field input[type='number'],
.field input:not([type='checkbox']),
.field select {
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;
transition: border-color 0.15s, box-shadow 0.15s;
width: 100%;
box-sizing: border-box;
}
.field input:focus,
.field select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
}
.field.checkbox {
flex-direction: row;
align-items: center;
gap: 8px;
grid-column: 1 / -1;
}
.field input:disabled {
background: #f3f4f6;
color: #9ca3af;
cursor: not-allowed;
}
.prefixed-input {
display: flex;
align-items: stretch;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #fff;
overflow: hidden;
}
.prefixed-input:focus-within {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
}
.input-prefix {
padding: 7px 10px;
background: #f3f4f6;
color: #4b5563;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
border-right: 1px solid #d1d5db;
}
.prefixed-input input {
border: 0 !important;
border-radius: 0 !important;
flex: 1;
padding: 7px 10px;
}
.prefixed-input input:focus {
box-shadow: none !important;
}
.hint {
margin: 8px 0 0;
font-size: 12px;
color: #6b7280;
}
.hint.warn {
color: #b45309;
}
.hint code {
background: #f3f4f6;
padding: 1px 6px;
border-radius: 4px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.btn {
border: 1px solid #d1d5db;
background: #fff;
border-radius: 6px;
padding: 7px 14px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btn:hover:not(:disabled) {
border-color: #9ca3af;
background: #f9fafb;
}
.btn.primary {
background: #3b82f6;
border-color: #3b82f6;
color: #fff;
}
.btn.primary:hover:not(:disabled) {
background: #2563eb;
border-color: #2563eb;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.banner {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
box-shadow: 0 6px 24px rgba(15, 23, 42, 0.18);
}
.banner.ok {
background: #10b981;
color: #fff;
}
.banner.err {
background: #ef4444;
color: #fff;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (prefers-color-scheme: dark) {
.settings {
color: #e5e7eb;
}
.card {
background: #1f2937;
border-color: #374151;
}
.field input {
background: #111827;
border-color: #374151;
color: #e5e7eb;
}
.field select {
background: #111827;
border-color: #374151;
color: #e5e7eb;
}
.field input:disabled {
background: #1f2937;
color: #6b7280;
}
.prefixed-input {
background: #111827;
border-color: #374151;
}
.input-prefix {
background: #1f2937;
border-right-color: #374151;
color: #d1d5db;
}
.btn {
background: #1f2937;
border-color: #374151;
color: #e5e7eb;
}
.btn:hover:not(:disabled) {
background: #374151;
}
.subtitle {
color: #9ca3af;
}
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type {DefineComponent} from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": [
"ESNext",
"DOM"
],
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}
+7
View File
@@ -0,0 +1,7 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})
+42
View File
@@ -0,0 +1,42 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {main} from '../models';
import {domain} from '../models';
export function CancelNativeRegion():Promise<void>;
export function CancelRegion():Promise<void>;
export function CaptureNow():Promise<void>;
export function ConfirmNativeRegion(arg1:main.CaptureResult):Promise<void>;
export function ConfirmRegion(arg1:main.CaptureResult):Promise<void>;
export function CopyNativeRegionImage(arg1:main.CaptureResult):Promise<void>;
export function CopyRegionImage(arg1:main.CaptureResult):Promise<void>;
export function GetConfig():Promise<domain.AppConfig>;
export function QuitApp():Promise<void>;
export function RetryRegisterHotkey():Promise<void>;
export function SaveConfig(arg1:domain.AppConfig):Promise<void>;
export function SaveNativeRegionImage(arg1:main.CaptureResult):Promise<main.CaptureActionResult>;
export function SaveNativeRegionImageToDir(arg1:main.CaptureResult,arg2:string):Promise<main.CaptureActionResult>;
export function SaveNativeRegionToRemote(arg1:main.CaptureResult):Promise<void>;
export function SaveRegionImage(arg1:main.CaptureResult):Promise<main.CaptureActionResult>;
export function SaveRegionToRemote(arg1:main.CaptureResult):Promise<void>;
export function ShowWindow():Promise<void>;
export function TestConnection(arg1:domain.S3Config):Promise<void>;
export function TestSSHConnection(arg1:domain.SSHConfig):Promise<void>;
+79
View File
@@ -0,0 +1,79 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CancelNativeRegion() {
return window['go']['main']['App']['CancelNativeRegion']();
}
export function CancelRegion() {
return window['go']['main']['App']['CancelRegion']();
}
export function CaptureNow() {
return window['go']['main']['App']['CaptureNow']();
}
export function ConfirmNativeRegion(arg1) {
return window['go']['main']['App']['ConfirmNativeRegion'](arg1);
}
export function ConfirmRegion(arg1) {
return window['go']['main']['App']['ConfirmRegion'](arg1);
}
export function CopyNativeRegionImage(arg1) {
return window['go']['main']['App']['CopyNativeRegionImage'](arg1);
}
export function CopyRegionImage(arg1) {
return window['go']['main']['App']['CopyRegionImage'](arg1);
}
export function GetConfig() {
return window['go']['main']['App']['GetConfig']();
}
export function QuitApp() {
return window['go']['main']['App']['QuitApp']();
}
export function RetryRegisterHotkey() {
return window['go']['main']['App']['RetryRegisterHotkey']();
}
export function SaveConfig(arg1) {
return window['go']['main']['App']['SaveConfig'](arg1);
}
export function SaveNativeRegionImage(arg1) {
return window['go']['main']['App']['SaveNativeRegionImage'](arg1);
}
export function SaveNativeRegionImageToDir(arg1, arg2) {
return window['go']['main']['App']['SaveNativeRegionImageToDir'](arg1, arg2);
}
export function SaveNativeRegionToRemote(arg1) {
return window['go']['main']['App']['SaveNativeRegionToRemote'](arg1);
}
export function SaveRegionImage(arg1) {
return window['go']['main']['App']['SaveRegionImage'](arg1);
}
export function SaveRegionToRemote(arg1) {
return window['go']['main']['App']['SaveRegionToRemote'](arg1);
}
export function ShowWindow() {
return window['go']['main']['App']['ShowWindow']();
}
export function TestConnection(arg1) {
return window['go']['main']['App']['TestConnection'](arg1);
}
export function TestSSHConnection(arg1) {
return window['go']['main']['App']['TestSSHConnection'](arg1);
}
+216
View File
@@ -0,0 +1,216 @@
export namespace application {
export class Point {
x: number;
y: number;
static createFrom(source: any = {}) {
return new Point(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.x = source["x"];
this.y = source["y"];
}
}
export class Annotation {
tool: string;
color: string;
points: Point[];
static createFrom(source: any = {}) {
return new Annotation(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.tool = source["tool"];
this.color = source["color"];
this.points = this.convertValues(source["points"], Point);
}
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 namespace domain {
export class SSHConfig {
host: string;
port: number;
user: string;
authMethod: string;
password: string;
pathPrefix: string;
strictHostKey: boolean;
knownHostsPath: string;
connectTimeoutSecs: number;
static createFrom(source: any = {}) {
return new SSHConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.authMethod = source["authMethod"];
this.password = source["password"];
this.pathPrefix = source["pathPrefix"];
this.strictHostKey = source["strictHostKey"];
this.knownHostsPath = source["knownHostsPath"];
this.connectTimeoutSecs = source["connectTimeoutSecs"];
}
}
export class S3Config {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
pathPrefix: string;
publicUrlBase: string;
usePathStyle: boolean;
static createFrom(source: any = {}) {
return new S3Config(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.endpoint = source["endpoint"];
this.region = source["region"];
this.bucket = source["bucket"];
this.accessKeyId = source["accessKeyId"];
this.secretAccessKey = source["secretAccessKey"];
this.pathPrefix = source["pathPrefix"];
this.publicUrlBase = source["publicUrlBase"];
this.usePathStyle = source["usePathStyle"];
}
}
export class AppConfig {
hotkey: string;
s3: S3Config;
ssh: SSHConfig;
static createFrom(source: any = {}) {
return new AppConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.hotkey = source["hotkey"];
this.s3 = this.convertValues(source["s3"], S3Config);
this.ssh = this.convertValues(source["ssh"], SSHConfig);
}
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 namespace main {
export class CaptureActionResult {
completed: boolean;
path?: string;
static createFrom(source: any = {}) {
return new CaptureActionResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.completed = source["completed"];
this.path = source["path"];
}
}
export class RegionRect {
x: number;
y: number;
w: number;
h: number;
static createFrom(source: any = {}) {
return new RegionRect(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.x = source["x"];
this.y = source["y"];
this.w = source["w"];
this.h = source["h"];
}
}
export class CaptureResult {
rect: RegionRect;
annotations: application.Annotation[];
static createFrom(source: any = {}) {
return new CaptureResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.rect = this.convertValues(source["rect"], RegionRect);
this.annotations = this.convertValues(source["annotations"], application.Annotation);
}
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;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
+330
View File
@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;
+298
View File
@@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}
+60
View File
@@ -0,0 +1,60 @@
module github.com/mmmy/snapgo
go 1.23.0
require (
fyne.io/systray v1.12.1
github.com/aws/aws-sdk-go-v2 v1.30.3
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237
github.com/wailsapp/wails/v2 v2.12.0
golang.design/x/clipboard v0.7.0
golang.design/x/hotkey v0.4.1
golang.org/x/crypto v0.33.0
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 // indirect
github.com/aws/smithy-go v1.20.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jezek/xgb v1.1.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect
golang.org/x/image v0.12.0 // indirect
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
+169
View File
@@ -0,0 +1,169 @@
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15 h1:Z5r7SycxmSllHYmaAZPpmN8GviDrSGhMS6bldqtXZPw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.15/go.mod h1:CetW7bDE00QoGEmPUoZuRog07SGVAUVW6LFpNP0YfIg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17 h1:YPYe6ZmvUfDDDELqEKtAd6bo8zxhkm+XEFEzQisqUIE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.17/go.mod h1:oBtcnYua/CgzCWYN7NZ5j7PotFDaFSUjCYVTtfyn7vw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15 h1:246A4lSTXWJw/rmlQI+TT2OcqeDMKBdyjEQrafMaQdA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.15/go.mod h1:haVfg3761/WF7YPuJOER2MP0k4UAXyHaLclKXB6usDg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2 h1:sZXIzO38GZOU+O0C+INqbH7C2yALwfMWpd64tONS/NE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2/go.mod h1:Lcxzg5rojyVPU/0eFwLtcyTaek/6Mtic5B1gJo7e/zE=
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7 h1:VLEKvjGJYAMCXw0/32r9io61tEXnMWDRxMk+peyRVFc=
github.com/gen2brain/shm v0.0.0-20230802011745-f2460f5984f7/go.mod h1:uF6rMu/1nvu+5DpiRLwusA6xB8zlkNoGzKn8lmYONUo=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk=
github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 h1:YOp8St+CM/AQ9Vp4XYm4272E77MptJDHkwypQHIRl9Q=
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237/go.mod h1:e7qQlOY68wOz4b82D7n+DdaptZAi+SHW0+yKiWZzEYE=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
golang.design/x/hotkey v0.4.1 h1:zLP/2Pztl4WjyxURdW84GoZ5LUrr6hr69CzJFJ5U1go=
golang.design/x/hotkey v0.4.1/go.mod h1:M8SGcwFYHnKRa83FpTFQoZvPO5vVT+kWPztFqTQKmXA=
golang.design/x/mainthread v0.3.0 h1:UwFus0lcPodNpMOGoQMe87jSFwbSsEY//CA7yVmu4j8=
golang.design/x/mainthread v0.3.0/go.mod h1:vYX7cF2b3pTJMGM/hc13NmN6kblKnf4/IyvHeu259L0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c h1:Gk61ECugwEHL6IiyyNLXNzmu8XslmRP2dS0xjIYhbb4=
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+182
View File
@@ -0,0 +1,182 @@
package application
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"strconv"
"strings"
)
// Annotation describes a user-drawn mark relative to the selected screenshot.
// Coordinates are logical pixels in the same coordinate system as the overlay.
type Annotation struct {
Tool string `json:"tool"`
Color string `json:"color"`
Points []Point `json:"points"`
}
type Point struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
// ApplyAnnotations decodes a PNG, draws all annotations, and re-encodes it.
func ApplyAnnotations(pngBytes []byte, annotations []Annotation, scale float64) ([]byte, error) {
if len(annotations) == 0 {
return pngBytes, nil
}
if scale <= 0 {
scale = 1
}
src, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return nil, fmt.Errorf("decode annotated png: %w", err)
}
bounds := src.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
draw.Draw(dst, dst.Bounds(), src, bounds.Min, draw.Src)
for _, ann := range annotations {
c, err := parseHexColor(ann.Color)
if err != nil {
c = color.RGBA{R: 59, G: 130, B: 246, A: 255}
}
width := int(math.Max(2, math.Round(3*scale)))
switch ann.Tool {
case "pen":
drawPolyline(dst, ann.Points, scale, width, c)
case "rect":
drawRectOutline(dst, ann.Points, scale, width, c)
case "ellipse":
drawEllipseOutline(dst, ann.Points, scale, width, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, dst); err != nil {
return nil, fmt.Errorf("encode annotated png: %w", err)
}
return buf.Bytes(), nil
}
func parseHexColor(hex string) (color.RGBA, error) {
value := strings.TrimPrefix(strings.TrimSpace(hex), "#")
if len(value) != 6 {
return color.RGBA{}, fmt.Errorf("invalid color %q", hex)
}
n, err := strconv.ParseUint(value, 16, 32)
if err != nil {
return color.RGBA{}, err
}
return color.RGBA{
R: uint8(n >> 16),
G: uint8(n >> 8),
B: uint8(n),
A: 255,
}, nil
}
func drawPolyline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
if len(points) == 1 {
drawDot(img, scalePoint(points[0], scale), width, c)
return
}
for i := 1; i < len(points); i++ {
drawLine(img, scalePoint(points[i-1], scale), scalePoint(points[i], scale), width, c)
}
}
func drawRectOutline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
if len(points) < 2 {
return
}
a := scalePoint(points[0], scale)
b := scalePoint(points[len(points)-1], scale)
x1, x2 := ordered(a.X, b.X)
y1, y2 := ordered(a.Y, b.Y)
drawLine(img, Point{X: x1, Y: y1}, Point{X: x2, Y: y1}, width, c)
drawLine(img, Point{X: x2, Y: y1}, Point{X: x2, Y: y2}, width, c)
drawLine(img, Point{X: x2, Y: y2}, Point{X: x1, Y: y2}, width, c)
drawLine(img, Point{X: x1, Y: y2}, Point{X: x1, Y: y1}, width, c)
}
func drawEllipseOutline(img *image.RGBA, points []Point, scale float64, width int, c color.RGBA) {
if len(points) < 2 {
return
}
a := scalePoint(points[0], scale)
b := scalePoint(points[len(points)-1], scale)
x1, x2 := ordered(a.X, b.X)
y1, y2 := ordered(a.Y, b.Y)
rx := (x2 - x1) / 2
ry := (y2 - y1) / 2
if rx <= 0 || ry <= 0 {
return
}
cx := x1 + rx
cy := y1 + ry
steps := int(math.Max(48, math.Ceil(2*math.Pi*math.Max(rx, ry)/4)))
prev := Point{
X: cx + rx,
Y: cy,
}
for i := 1; i <= steps; i++ {
theta := 2 * math.Pi * float64(i) / float64(steps)
next := Point{
X: cx + rx*math.Cos(theta),
Y: cy + ry*math.Sin(theta),
}
drawLine(img, prev, next, width, c)
prev = next
}
}
func drawLine(img *image.RGBA, a, b Point, width int, c color.RGBA) {
dx := b.X - a.X
dy := b.Y - a.Y
steps := int(math.Max(math.Abs(dx), math.Abs(dy)))
if steps == 0 {
drawDot(img, a, width, c)
return
}
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
drawDot(img, Point{X: a.X + dx*t, Y: a.Y + dy*t}, width, c)
}
}
func drawDot(img *image.RGBA, p Point, width int, c color.RGBA) {
radius := float64(width) / 2
minX := int(math.Floor(p.X - radius))
maxX := int(math.Ceil(p.X + radius))
minY := int(math.Floor(p.Y - radius))
maxY := int(math.Ceil(p.Y + radius))
bounds := img.Bounds()
for y := minY; y <= maxY; y++ {
for x := minX; x <= maxX; x++ {
if !image.Pt(x, y).In(bounds) {
continue
}
if math.Hypot(float64(x)-p.X, float64(y)-p.Y) <= radius {
img.SetRGBA(x, y, c)
}
}
}
}
func scalePoint(p Point, scale float64) Point {
return Point{X: p.X * scale, Y: p.Y * scale}
}
func ordered(a, b float64) (float64, float64) {
if a < b {
return a, b
}
return b, a
}
+53
View File
@@ -0,0 +1,53 @@
package application
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"testing"
)
func TestApplyAnnotationsDrawsIntoPNG(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 40, 40))
draw.Draw(src, src.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, src); err != nil {
t.Fatalf("encode source: %v", err)
}
out, err := ApplyAnnotations(buf.Bytes(), []Annotation{
{
Tool: "rect",
Color: "#ef4444",
Points: []Point{
{X: 5, Y: 5},
{X: 30, Y: 30},
},
},
}, 1)
if err != nil {
t.Fatalf("apply annotations: %v", err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode output: %v", err)
}
if got := color.RGBAModel.Convert(img.At(5, 5)).(color.RGBA); got.R != 0xef || got.G != 0x44 || got.B != 0x44 {
t.Fatalf("expected annotated pixel at rectangle edge, got %#v", got)
}
}
func TestApplyAnnotationsNoAnnotationsReturnsOriginalBytes(t *testing.T) {
original := []byte("not decoded when no annotations")
out, err := ApplyAnnotations(original, nil, 1)
if err != nil {
t.Fatalf("apply annotations: %v", err)
}
if !bytes.Equal(out, original) {
t.Fatalf("expected original bytes")
}
}
+88
View File
@@ -0,0 +1,88 @@
package application
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
)
// CaptureActionsService handles non-upload actions on already-produced PNG
// screenshots. Keeping these actions here makes save/copy available across
// overlay implementations without binding them to Wails or a specific OS.
type CaptureActionsService struct {
Clipboard clipboard.Writer
Notifier Notifier
}
func (s *CaptureActionsService) CopyImage(_ context.Context, pngBytes []byte) error {
if len(pngBytes) == 0 {
err := fmt.Errorf("empty screenshot")
s.notifyFailure(err.Error())
return err
}
if s.Clipboard == nil {
err := fmt.Errorf("clipboard is not configured")
s.notifyFailure(err.Error())
return err
}
if err := s.Clipboard.WriteImage(pngBytes); err != nil {
s.notifyFailure("clipboard write failed: " + err.Error())
return err
}
if s.Notifier != nil {
s.Notifier.NotifySuccess("image copied to clipboard")
}
return nil
}
func (s *CaptureActionsService) SaveImage(_ context.Context, pngBytes []byte, dir string) (string, error) {
if len(pngBytes) == 0 {
err := fmt.Errorf("empty screenshot")
s.notifyFailure(err.Error())
return "", err
}
dir = strings.TrimSpace(dir)
if dir == "" {
err := fmt.Errorf("save directory is empty")
s.notifyFailure(err.Error())
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
s.notifyFailure("create save directory failed: " + err.Error())
return "", err
}
path := uniquePNGPath(dir, time.Now())
if err := os.WriteFile(path, pngBytes, 0o644); err != nil {
s.notifyFailure("save failed: " + err.Error())
return "", err
}
if s.Notifier != nil {
s.Notifier.NotifySuccess(path)
}
return path, nil
}
func (s *CaptureActionsService) notifyFailure(reason string) {
if s.Notifier != nil {
s.Notifier.NotifyFailure(reason)
}
}
func uniquePNGPath(dir string, now time.Time) string {
base := now.Format("20060102-150405")
path := filepath.Join(dir, base+".png")
for i := 1; fileExists(path); i++ {
path = filepath.Join(dir, fmt.Sprintf("%s-%02d.png", base, i))
}
return path
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
@@ -0,0 +1,72 @@
package application
import (
"context"
"os"
"path/filepath"
"testing"
)
type fakeClipboard struct {
text string
image []byte
}
func (f *fakeClipboard) WriteText(s string) error {
f.text = s
return nil
}
func (f *fakeClipboard) WriteImage(pngBytes []byte) error {
f.image = append([]byte(nil), pngBytes...)
return nil
}
type fakeNotifier struct {
success string
failure string
}
func (f *fakeNotifier) NotifySuccess(value string) { f.success = value }
func (f *fakeNotifier) NotifyFailure(reason string) { f.failure = reason }
func TestCaptureActionsCopyImageWritesPNGToClipboard(t *testing.T) {
clip := &fakeClipboard{}
notifier := &fakeNotifier{}
svc := &CaptureActionsService{Clipboard: clip, Notifier: notifier}
err := svc.CopyImage(context.Background(), []byte("png"))
if err != nil {
t.Fatalf("copy image: %v", err)
}
if string(clip.image) != "png" {
t.Fatalf("expected image bytes copied, got %q", string(clip.image))
}
if notifier.success != "image copied to clipboard" {
t.Fatalf("expected copy success notification, got %q", notifier.success)
}
}
func TestCaptureActionsSaveImageWritesUniquePNG(t *testing.T) {
dir := t.TempDir()
svc := &CaptureActionsService{Notifier: &fakeNotifier{}}
path, err := svc.SaveImage(context.Background(), []byte("png"), dir)
if err != nil {
t.Fatalf("save image: %v", err)
}
if filepath.Dir(path) != dir {
t.Fatalf("expected save in %q, got %q", dir, path)
}
if data, err := os.ReadFile(path); err != nil || string(data) != "png" {
t.Fatalf("expected saved bytes, data=%q err=%v", string(data), err)
}
next, err := svc.SaveImage(context.Background(), []byte("png2"), dir)
if err != nil {
t.Fatalf("save second image: %v", err)
}
if next == path {
t.Fatalf("expected unique path for second save")
}
}
+152
View File
@@ -0,0 +1,152 @@
// capture_ssh.go — application-level service that uploads a screenshot to
// a remote host via SSH/SCP.
//
// Design rationale:
// - Mirrors CaptureAndUploadService so the UI layer can call a single
// well-defined entrypoint per destination kind.
// - Depends on a small Uploader interface rather than the concrete SSH
// adapter so the service is unit-testable without a real SSH server.
// - The remote object key is generated through the same date-based
// scheme used for S3 uploads (see buildObjectKey in capture_upload.go)
// so users get a consistent layout regardless of destination.
package application
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
)
// sshSvcLog returns an application-layer SSH logger bound to the CURRENT
// default handler.
//
// 为什么是函数而非包级 slog.With 变量: 包级变量会在 main() 调用
// logging.Init() 之前被初始化, 捕获到 bootstrap handler, 之后 SetDefault
// 切换 handler 时会产生双前缀日志. 惰性读取 slog.Default() 可避免该问题.
func sshSvcLog() *slog.Logger { return slog.Default().With("component", "application.ssh") }
// SSHUploader abstracts the SSH/SCP adapter so this layer does not depend
// on golang.org/x/crypto/ssh directly. The infrastructure package wires a
// concrete implementation through a small adapter below.
type SSHUploader interface {
// Upload writes `data` to remoteRelPath (relative to the user's $HOME)
// with the given file mode and returns once the remote has acknowledged
// the transfer.
Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error
}
// CaptureAndSSHService wires capture-bytes → SCP → clipboard → notify.
type CaptureAndSSHService struct {
Uploader SSHUploader
Clipboard clipboard.Writer
Notifier Notifier
// Cfg is the active SSH configuration; we keep a copy on the service
// so PathPrefix and PublicURLBase are explicitly part of the contract
// rather than reaching into a shared global.
Cfg domain.SSHConfig
}
// ExecuteWithBytes uploads previously-captured PNG bytes via SCP and copies
// either a public URL (if configured) or the remote-relative path to the
// clipboard. Either is useful: a URL for sharing, a path so the user can
// inspect / scp it manually.
func (s *CaptureAndSSHService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
if s.Uploader == nil {
sshSvcLog().Warn("ExecuteWithBytes aborted: uploader nil")
s.notifyFailure("SSH not configured")
return fmt.Errorf("ssh uploader is nil")
}
if len(pngBytes) == 0 {
sshSvcLog().Warn("ExecuteWithBytes aborted: empty screenshot")
s.notifyFailure("empty screenshot")
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,
"strict_host_key", s.Cfg.StrictHostKey)
start := time.Now()
if err := s.Uploader.Upload(ctx, relPath, pngBytes, 0o644); err != nil {
sshSvcLog().Error("save-remote upload failed",
"remote_path", relPath, "elapsed", time.Since(start), "err", err)
s.notifyFailure("ssh upload failed: " + err.Error())
return err
}
sshSvcLog().Info("save-remote upload ok",
"remote_path", relPath, "elapsed", time.Since(start))
clipText := remoteShareText(relPath)
if s.Clipboard != nil {
if err := s.Clipboard.WriteText(clipText); err != nil {
sshSvcLog().Error("save-remote clipboard write failed", "err", err)
s.notifyFailure("clipboard write failed: " + err.Error())
return err
}
sshSvcLog().Debug("save-remote clipboard write ok", "text", clipText)
}
if s.Notifier != nil {
s.Notifier.NotifySuccess(clipText)
}
sshSvcLog().Info("save-remote pipeline done",
"remote_path", relPath, "total_elapsed", time.Since(start))
return nil
}
// remoteShareText returns the clipboard string for an SSH upload.
//
// 设计理由: 用户反馈只需要远端机器上的路径 (方便登录后直接定位文件),
// 不需要 user@host 前缀, 也不再支持 Public URL Base. 这里统一返回
// "~/<relPath>", 即相对远端 $HOME 的可读路径.
func remoteShareText(relPath string) string {
return "~/" + relPath
}
// buildRemoteRelPath produces the remote-relative target path with the
// same date-grouped layout used by the S3 pipeline.
func buildRemoteRelPath(prefix string) string {
prefix = strings.TrimSpace(prefix)
prefix = strings.TrimPrefix(prefix, "~")
prefix = strings.TrimPrefix(prefix, "/")
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
now := time.Now()
return fmt.Sprintf("%s%s/%s/%s-%s.png",
prefix,
now.Format("2006"),
now.Format("01"),
now.Format("20060102-150405"),
randomSuffix(6),
)
}
// 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)
}
}
+147
View File
@@ -0,0 +1,147 @@
// Package application contains use-case orchestrators that the presentation
// layer (Wails bindings, frontend) calls into.
//
// Design rationale:
// - Application code only depends on domain interfaces; concrete adapters
// are injected from main.go. This keeps the layer easy to unit test and
// future-proofs us against swapping providers.
package application
import (
"context"
"fmt"
"image"
"math/rand"
"os"
"path/filepath"
"time"
"github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
)
// Notifier sends user-facing UI notifications. We define it as an interface
// so the application package does not depend on the Wails runtime.
type Notifier interface {
NotifySuccess(url string)
NotifyFailure(reason string)
}
// CaptureAndUploadService wires capture → upload → clipboard → notify.
type CaptureAndUploadService struct {
Capturer screencapture.Capturer
Provider domain.OSSProvider
Clipboard clipboard.Writer
Notifier Notifier
// FallbackDir is the directory where PNGs are saved when upload fails.
FallbackDir string
// PathPrefix is prepended to every generated object key.
PathPrefix string
}
// Execute runs the full pipeline for a single screenshot region.
//
// On any failure after capture, the PNG is written to FallbackDir and the
// local file path is copied to the clipboard so the user can still recover
// the image manually.
func (s *CaptureAndUploadService) Execute(ctx context.Context, rect image.Rectangle) (*domain.UploadResult, error) {
if s.Provider == nil {
s.Notifier.NotifyFailure("OSS not configured")
return nil, fmt.Errorf("oss provider is nil")
}
pngBytes, err := s.Capturer.CaptureRegion(rect)
if err != nil {
s.Notifier.NotifyFailure("capture failed: " + err.Error())
return nil, err
}
return s.uploadAndCopy(ctx, pngBytes)
}
// ExecuteWithBytes runs only the upload-clipboard-notify portion of the
// pipeline using PNG bytes that have already been produced by an upstream
// capture step (e.g. macOS's interactive picker). Splitting this out keeps
// the service free of any knowledge about how the pixels were obtained.
func (s *CaptureAndUploadService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
if s.Provider == nil {
s.Notifier.NotifyFailure("OSS not configured")
return fmt.Errorf("oss provider is nil")
}
if len(pngBytes) == 0 {
s.Notifier.NotifyFailure("empty screenshot")
return fmt.Errorf("empty screenshot")
}
_, err := s.uploadAndCopy(ctx, pngBytes)
return err
}
// uploadAndCopy is the shared tail of Execute / ExecuteWithBytes. Extracting
// this avoids duplicating the failure-handling and clipboard logic.
func (s *CaptureAndUploadService) uploadAndCopy(ctx context.Context, pngBytes []byte) (*domain.UploadResult, error) {
key := buildObjectKey(s.PathPrefix)
start := time.Now()
url, uploadErr := s.Provider.Upload(ctx, key, pngBytes, "image/png")
if uploadErr != nil {
path := s.fallback(pngBytes)
_ = s.Clipboard.WriteText(path)
s.Notifier.NotifyFailure("upload failed: " + uploadErr.Error())
return nil, uploadErr
}
if err := s.Clipboard.WriteText(url); err != nil {
s.Notifier.NotifyFailure("clipboard write failed: " + err.Error())
return nil, err
}
s.Notifier.NotifySuccess(url)
return &domain.UploadResult{
URL: url,
Key: key,
Provider: s.Provider.Name(),
Elapsed: time.Since(start),
}, nil
}
// fallback persists the PNG locally and returns the absolute path.
// Errors are swallowed because the caller has already failed once and we
// must not mask the original failure with a secondary error.
func (s *CaptureAndUploadService) fallback(data []byte) string {
if s.FallbackDir == "" {
s.FallbackDir = filepath.Join(os.TempDir(), "SnapGo")
}
_ = os.MkdirAll(s.FallbackDir, 0o755)
name := fmt.Sprintf("%s.png", time.Now().Format("20060102-150405"))
path := filepath.Join(s.FallbackDir, name)
_ = os.WriteFile(path, data, 0o644)
return path
}
// buildObjectKey produces a date-grouped object key with a 6-char random
// suffix to avoid collisions when many screenshots happen in the same second.
//
// The template is intentionally hard-coded for the MVP; making it user-
// configurable is left to a follow-up spec.
func buildObjectKey(prefix string) string {
now := time.Now()
return fmt.Sprintf("%s%s/%s/%s-%s.png",
prefix,
now.Format("2006"),
now.Format("01"),
now.Format("20060102-150405"),
randomSuffix(6),
)
}
const suffixAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
// randomSuffix produces a short non-cryptographic identifier sufficient for
// avoiding key collisions among consecutive captures.
func randomSuffix(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = suffixAlphabet[rand.Intn(len(suffixAlphabet))]
}
return string(b)
}
+157
View File
@@ -0,0 +1,157 @@
// Package domain — configuration types.
//
// S3Config and SSHConfig are intentionally split into their own structs so
// that future providers can introduce their own configuration types side-
// by-side without polluting the core domain types file.
package domain
// S3Config describes the connection parameters for any S3-compatible
// endpoint (AWS S3, MinIO, Cloudflare R2, Backblaze B2, Aliyun OSS S3
// endpoint, etc.).
//
// Field design notes:
// - PathPrefix supports object name templating so users can group screenshots
// by date.
// - PublicURLBase is optional to support CDN-fronted buckets; otherwise the
// adapter falls back to "{Endpoint}/{Bucket}/{Key}" (path-style).
// - UsePathStyle defaults to true because many self-hosted MinIO / R2
// deployments do not support virtual-hosted-style addressing.
type S3Config struct {
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
PathPrefix string `json:"pathPrefix"`
PublicURLBase string `json:"publicUrlBase"`
UsePathStyle bool `json:"usePathStyle"`
}
// SSHConfig describes the parameters required to upload a screenshot via
// SSH/SCP to a remote host.
//
// Field design notes:
// - Host / Port form the destination endpoint. Port defaults to 22 when 0.
// - User is the SSH login name.
// - AuthMethod selects how the connection authenticates:
// "password" → password only, via the in-process Go SSH client.
// "key" → ssh-agent + ~/.ssh/id_* key files (no password),
// via the in-process Go SSH client.
// "kerberos" → delegate to the system /usr/bin/ssh binary so the
// existing Kerberos (GSSAPI) credential cache from
// `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.
// - Password is optional; when empty the implementation falls back to the
// local SSH agent or ~/.ssh/id_* keys (i.e. the user is responsible for
// having password-less access already configured on this machine).
// It is ignored entirely when AuthMethod is "kerberos".
// - PathPrefix is interpreted *relative to the remote user's $HOME*. The
// UI presents it as "~/<PathPrefix>" so the user cannot escape the home
// directory by writing absolute paths. Leading "/" or "~" markers are
// stripped before the path is used.
// - StrictHostKey toggles host key verification. When false the client
// uses ssh.InsecureIgnoreHostKey() to mimic `scp -o StrictHostKeyChecking=no`,
// trading some security for first-launch usability on personal LANs.
// - KnownHostsPath defaults to ~/.ssh/known_hosts when empty and is only
// used while StrictHostKey is true.
// - ConnectTimeoutSecs caps the network handshake duration so a wrong
// endpoint does not hang the capture flow indefinitely.
type SSHConfig struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
AuthMethod string `json:"authMethod"`
Password string `json:"password"`
PathPrefix string `json:"pathPrefix"`
StrictHostKey bool `json:"strictHostKey"`
KnownHostsPath string `json:"knownHostsPath"`
ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
}
// SSH authentication method identifiers stored in SSHConfig.AuthMethod.
const (
// SSHAuthBuiltin is the legacy combined method (password → agent → key
// files). Retained for backward compatibility with older config files;
// new configs use the explicit methods below.
SSHAuthBuiltin = "builtin"
// SSHAuthPassword authenticates with the password field only.
SSHAuthPassword = "password"
// SSHAuthKey authenticates with ssh-agent / ~/.ssh/id_* key files only.
SSHAuthKey = "key"
// 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"
)
// 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,
// COS, FTP) only requires a new sibling field rather than a schema rewrite.
type AppConfig struct {
// Hotkey describes the global shortcut that triggers a capture.
// Stored as a human-readable string like "cmd+shift+a"; parsing happens
// in the infrastructure hotkey adapter.
Hotkey string `json:"hotkey"`
// 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"`
}
// DefaultAppConfig returns sane zero-value defaults used on first launch.
func DefaultAppConfig() AppConfig {
return AppConfig{
Hotkey: "cmd+shift+a",
S3: S3Config{
PathPrefix: "snapgo/",
UsePathStyle: true,
},
SSH: SSHConfig{
Port: 22,
PathPrefix: "snapgo/",
ConnectTimeoutSecs: 10,
StrictHostKey: false,
},
}
}
// IsS3Configured reports whether the user has filled the mandatory S3 fields.
func (c AppConfig) IsS3Configured() bool {
return c.S3.Endpoint != "" &&
c.S3.Bucket != "" &&
c.S3.AccessKeyID != "" &&
c.S3.SecretAccessKey != ""
}
// IsSSHConfigured reports whether the SSH destination has the minimum
// fields required to attempt a connection. Password is intentionally NOT
// checked because empty password means "use agent / key auth".
func (c AppConfig) IsSSHConfigured() bool {
return c.SSH.Host != "" && c.SSH.User != ""
}
+54
View File
@@ -0,0 +1,54 @@
// Package domain defines the core business types of SnapGo.
//
// Design rationale:
// - Keep this layer free of any third-party SDK or OS API.
// - Higher layers (application, infrastructure) depend on these types,
// but this package depends on nothing except the Go standard library.
// - Encapsulating data here makes the upgrade path to multiple OSS providers
// (Aliyun OSS, Qiniu, Tencent COS, etc.) trivial — only a new infra adapter
// is needed.
package domain
import (
"context"
"image"
"time"
)
// Screenshot represents a single captured image in PNG-encoded byte form.
//
// We hold the encoded bytes (not image.Image) because:
// 1. Uploaders need a byte stream;
// 2. Local fallback writes the same bytes to disk;
// 3. Avoids re-encoding cost.
type Screenshot struct {
PNG []byte // PNG-encoded payload
Region image.Rectangle // The captured region in virtual screen coords
Width int // Logical width of the region
Height int // Logical height of the region
CreatedAt time.Time // When the capture happened
}
// UploadResult describes the outcome of a successful upload.
type UploadResult struct {
URL string // Public URL that was copied to the clipboard
Key string // Object key on the remote storage
Provider string // Provider name, e.g. "s3"
Elapsed time.Duration // How long the upload took
}
// OSSProvider is the abstraction every storage adapter MUST implement.
//
// Why an interface:
// - Allows the application layer to remain agnostic of the concrete SDK.
// - Future providers (Aliyun OSS / COS / Qiniu) only need a new adapter
// without touching the service layer.
type OSSProvider interface {
// Upload pushes data with the given key and content-type to remote storage,
// returning the publicly accessible URL on success.
Upload(ctx context.Context, key string, data []byte, contentType string) (publicURL string, err error)
// Name returns a stable identifier of the provider implementation
// (used in logs, telemetry, history records).
Name() string
}
@@ -0,0 +1,56 @@
// Package clipboard wraps golang.design/x/clipboard with lazy initialization.
//
// Why a wrapper:
// - The upstream library requires a one-time clipboard.Init() call. Hiding it
// here keeps the application service free of init concerns.
// - Allows future swap to a different backend (e.g. atotto/clipboard) without
// changing callers.
package clipboard
import (
"fmt"
"sync"
"golang.design/x/clipboard"
)
// Writer abstracts clipboard writes for testability.
type Writer interface {
WriteText(s string) error
WriteImage(pngBytes []byte) error
}
type clipWriter struct {
once sync.Once
initErr error
}
// New returns a process-wide clipboard writer. Initialization is lazy.
func New() Writer {
return &clipWriter{}
}
func (w *clipWriter) ensureInit() error {
w.once.Do(func() {
w.initErr = clipboard.Init()
})
return w.initErr
}
// WriteText replaces the clipboard contents with the supplied UTF-8 string.
func (w *clipWriter) WriteText(s string) error {
if err := w.ensureInit(); err != nil {
return fmt.Errorf("clipboard init: %w", err)
}
clipboard.Write(clipboard.FmtText, []byte(s))
return nil
}
// WriteImage replaces the clipboard contents with PNG-encoded image data.
func (w *clipWriter) WriteImage(pngBytes []byte) error {
if err := w.ensureInit(); err != nil {
return fmt.Errorf("clipboard init: %w", err)
}
clipboard.Write(clipboard.FmtImage, pngBytes)
return nil
}
@@ -0,0 +1,89 @@
// 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).
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/mmmy/snapgo/internal/domain"
)
// FileStore implements load/save of AppConfig on the local filesystem.
//
// All methods are safe for concurrent use.
type FileStore struct {
mu sync.RWMutex
path string
}
// NewFileStore resolves the on-disk path and creates the parent directory
// with permissions 0700 if missing.
func NewFileStore() (*FileStore, error) {
dir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("resolve user config dir: %w", err)
}
appDir := filepath.Join(dir, "SnapGo")
if err := os.MkdirAll(appDir, 0o700); err != nil {
return nil, fmt.Errorf("ensure config dir: %w", err)
}
return &FileStore{path: filepath.Join(appDir, "config.json")}, nil
}
// Path exposes the absolute config file path (mainly for diagnostics / UI).
func (s *FileStore) Path() string {
return s.path
}
// Load reads the config file. If the file does not exist OR cannot be parsed,
// the default config is returned with a non-nil error so the caller can decide
// whether to surface the parse failure to the user.
func (s *FileStore) Load() (domain.AppConfig, error) {
s.mu.RLock()
defer s.mu.RUnlock()
cfg := domain.DefaultAppConfig()
data, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
return cfg, fmt.Errorf("read config: %w", err)
}
if err := json.Unmarshal(data, &cfg); err != nil {
return domain.DefaultAppConfig(), fmt.Errorf("parse config: %w", err)
}
return cfg, nil
}
// Save persists the supplied config atomically (write-temp + rename) so
// crashes mid-write cannot leave a half-written file.
func (s *FileStore) Save(cfg domain.AppConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write tmp config: %w", err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("commit config: %w", err)
}
return nil
}
+12
View File
@@ -0,0 +1,12 @@
// Package cursor exposes the current mouse pointer position in a
// platform-agnostic way. We use it to anchor the post-capture toolbar near
// the location where the user released the mouse — on macOS that point is
// effectively the bottom-right corner of the screencapture selection.
package cursor
// Point is a screen-space pointer location, in *logical* pixels (i.e. the
// coordinate system Wails uses for window placement).
type Point struct {
X int
Y int
}
@@ -0,0 +1,30 @@
//go:build darwin
// cursor_darwin.go reads the global cursor position via Quartz Event
// Services. This API is preferable to NSEvent.mouseLocation for our use
// case because it returns the pointer in flipped (top-left origin)
// coordinates, which matches Wails / web pixel coordinates exactly.
package cursor
/*
#cgo LDFLAGS: -framework ApplicationServices
#include <ApplicationServices/ApplicationServices.h>
// readCursor returns the current mouse position with origin at the
// top-left of the primary display, in logical pixels.
static void readCursor(double* x, double* y) {
CGEventRef event = CGEventCreate(NULL);
CGPoint p = CGEventGetLocation(event);
if (event) CFRelease(event);
*x = p.x;
*y = p.y;
}
*/
import "C"
// Get returns the current mouse pointer position.
func Get() (Point, error) {
var x, y C.double
C.readCursor(&x, &y)
return Point{X: int(x), Y: int(y)}, nil
}
@@ -0,0 +1,9 @@
//go:build !darwin
package cursor
// Get is a no-op stub on non-macOS targets. The toolbar will fall back to
// the centre of the screen on these platforms.
func Get() (Point, error) {
return Point{X: 0, Y: 0}, nil
}
@@ -0,0 +1,25 @@
// Package display exposes platform-specific helpers for reading the
// geometry of the primary display, in both CSS (logical) and PHYSICAL
// pixels. The overlay flow needs both: CSS pixels to size the Wails window
// to "fullscreen" (since Wails uses CSS-px-equivalent units), and the
// backing scale factor to translate the user's CSS-px selection rectangle
// into the physical-pixel rectangle of the captured PNG.
package display
// Info describes the primary display.
//
// The struct is deliberately minimal: only the few fields the overlay flow
// actually needs. We can grow it later without affecting callers because
// it is returned by value.
type Info struct {
// CSSWidth / CSSHeight are the logical dimensions of the primary
// display in points (i.e. what CSS / Wails uses).
CSSWidth int
CSSHeight int
// Scale is backing factor (CSS-px → device-px). 2 on Retina, 1 on
// non-Retina. We model it as float64 to leave room for fractional
// scales (e.g. Linux 1.25×) even though macOS effectively only ever
// reports 1 or 2.
Scale float64
}
@@ -0,0 +1,41 @@
//go:build darwin
package display
/*
#cgo LDFLAGS: -framework CoreGraphics -framework Foundation
#include <CoreGraphics/CoreGraphics.h>
// readPrimaryDisplay fills the four out-params with:
// - cssW / cssH : logical (point) dimensions of the main display
// - pxW / pxH : physical (device-pixel) dimensions
// Using CGDisplayBounds for points and CGDisplayPixelsWide/High for pixels
// is the canonical way to derive the backing scale on macOS without
// bringing AppKit into the build (we do not want to link Cocoa here).
static void readPrimaryDisplay(int* cssW, int* cssH, int* pxW, int* pxH) {
CGDirectDisplayID id = CGMainDisplayID();
CGRect bounds = CGDisplayBounds(id);
*cssW = (int)bounds.size.width;
*cssH = (int)bounds.size.height;
*pxW = (int)CGDisplayPixelsWide(id);
*pxH = (int)CGDisplayPixelsHigh(id);
}
*/
import "C"
// Primary returns the geometry of the main display. The CGo call has no
// failure path — the OS always reports a main display whenever there is at
// least one attached, which is implied for any GUI process.
func Primary() Info {
var cssW, cssH, pxW, pxH C.int
C.readPrimaryDisplay(&cssW, &cssH, &pxW, &pxH)
scale := 1.0
if cssW > 0 {
scale = float64(pxW) / float64(cssW)
}
return Info{
CSSWidth: int(cssW),
CSSHeight: int(cssH),
Scale: scale,
}
}
@@ -0,0 +1,21 @@
//go:build !darwin
package display
import "github.com/kbinani/screenshot"
// Primary returns the dimensions of display 0. We currently assume a 1× DPR
// because Wails on Windows / Linux already paints in physical pixels, so
// CSS coordinates and capture coordinates coincide. If a user ever runs at
// non-1× we will add a platform-specific scale lookup.
func Primary() Info {
if screenshot.NumActiveDisplays() == 0 {
return Info{CSSWidth: 1440, CSSHeight: 900, Scale: 1}
}
b := screenshot.GetDisplayBounds(0)
return Info{
CSSWidth: b.Dx(),
CSSHeight: b.Dy(),
Scale: 1,
}
}
+121
View File
@@ -0,0 +1,121 @@
// Package hotkey wraps golang.design/x/hotkey to provide a process-wide
// global shortcut. The MVP only supports a single hard-coded combination
// (Cmd+Shift+A on macOS) but the abstraction below allows the binding to
// be replaced at runtime once we add per-platform parsing of user input.
package hotkey
import (
"fmt"
"strings"
"sync"
hk "golang.design/x/hotkey"
)
// Manager owns the lifecycle of one global hotkey.
type Manager struct {
mu sync.Mutex
current *hk.Hotkey
stop chan struct{}
}
// NewManager returns a fresh manager with no hotkey registered yet.
func NewManager() *Manager { return &Manager{} }
// Register parses the spec (e.g. "cmd+shift+a") and binds the callback.
// If a previous hotkey is registered, it is unregistered first so callers
// can swap shortcuts at runtime when the user changes the setting.
func (m *Manager) Register(spec string, onTrigger func()) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil {
_ = m.current.Unregister()
close(m.stop)
m.current = nil
m.stop = nil
}
mods, key, err := parseSpec(spec)
if err != nil {
return err
}
hot := hk.New(mods, key)
if err := hot.Register(); err != nil {
return fmt.Errorf("register hotkey %q: %w", spec, err)
}
stop := make(chan struct{})
m.current = hot
m.stop = stop
// Listen on a dedicated goroutine. We never run the callback inline
// because the keydown channel is unbuffered.
go func() {
for {
select {
case <-stop:
return
case <-hot.Keydown():
if onTrigger != nil {
go onTrigger()
}
}
}
}()
return nil
}
// Unregister releases the active hotkey, if any.
func (m *Manager) Unregister() {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil {
_ = m.current.Unregister()
close(m.stop)
m.current = nil
m.stop = nil
}
}
// parseSpec parses a "+"-separated hotkey description into (modifiers, key).
//
// Supported tokens (case-insensitive): cmd, command, ctrl, control,
// option, alt, shift, plus a single letter AZ or digit 09.
//
// We keep the parser intentionally tiny — full key mapping is out of scope
// for the MVP.
func parseSpec(spec string) ([]hk.Modifier, hk.Key, error) {
parts := strings.Split(strings.ToLower(strings.TrimSpace(spec)), "+")
if len(parts) == 0 {
return nil, 0, fmt.Errorf("empty hotkey")
}
var mods []hk.Modifier
var key hk.Key
hasKey := false
for _, p := range parts {
p = strings.TrimSpace(p)
switch p {
case "cmd", "command", "meta", "super":
mods = append(mods, modCmd())
case "ctrl", "control":
mods = append(mods, modCtrl())
case "option", "alt":
mods = append(mods, modOption())
case "shift":
mods = append(mods, hk.ModShift)
default:
k, ok := lookupKey(p)
if !ok {
return nil, 0, fmt.Errorf("unsupported token: %q", p)
}
key = k
hasKey = true
}
}
if !hasKey {
return nil, 0, fmt.Errorf("hotkey %q is missing a key", spec)
}
return mods, key, nil
}
@@ -0,0 +1,10 @@
//go:build darwin
package hotkey
import hk "golang.design/x/hotkey"
// macOS uses ⌘ as the primary modifier; Option corresponds to Alt.
func modCmd() hk.Modifier { return hk.ModCmd }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.ModOption }
@@ -0,0 +1,11 @@
//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 }
+86
View File
@@ -0,0 +1,86 @@
package hotkey
import hk "golang.design/x/hotkey"
// lookupKey maps a token from the user-supplied hotkey spec to a hk.Key.
//
// 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.
func lookupKey(token string) (hk.Key, bool) {
switch token {
case "a":
return hk.KeyA, true
case "b":
return hk.KeyB, true
case "c":
return hk.KeyC, true
case "d":
return hk.KeyD, true
case "e":
return hk.KeyE, true
case "f":
return hk.KeyF, true
case "g":
return hk.KeyG, true
case "h":
return hk.KeyH, true
case "i":
return hk.KeyI, true
case "j":
return hk.KeyJ, true
case "k":
return hk.KeyK, true
case "l":
return hk.KeyL, true
case "m":
return hk.KeyM, true
case "n":
return hk.KeyN, true
case "o":
return hk.KeyO, true
case "p":
return hk.KeyP, true
case "q":
return hk.KeyQ, true
case "r":
return hk.KeyR, true
case "s":
return hk.KeyS, true
case "t":
return hk.KeyT, true
case "u":
return hk.KeyU, true
case "v":
return hk.KeyV, true
case "w":
return hk.KeyW, true
case "x":
return hk.KeyX, true
case "y":
return hk.KeyY, true
case "z":
return hk.KeyZ, true
case "0":
return hk.Key0, true
case "1":
return hk.Key1, true
case "2":
return hk.Key2, true
case "3":
return hk.Key3, true
case "4":
return hk.Key4, true
case "5":
return hk.Key5, true
case "6":
return hk.Key6, true
case "7":
return hk.Key7, true
case "8":
return hk.Key8, true
case "9":
return hk.Key9, true
}
return 0, false
}
+226
View File
@@ -0,0 +1,226 @@
// Package logging centralises slog configuration.
//
// 设计动机:
// - SnapGo 是 macOS GUI 应用, 由 Finder/open 启动时 stderr 会被重定向
// 到 /dev/null, slog 默认 handler 写到 stderr 因此对用户不可见.
// - 为方便排查网络 / 权限 / SCP 协议层问题, 我们将日志同时写入磁盘文件
// 和 stderr, 让 `tail -f` 与开发期 `wails dev` 都能直接看到输出.
// - 单独抽出包可避免 main / app 直接依赖文件 IO 细节, 也方便后续替换为
// os_log 或集中式日志方案.
package logging
import (
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
)
const (
// maxLogSize is the byte threshold that triggers a rotation. 5 MiB keeps
// a useful amount of recent history while staying small enough to open
// in any editor.
maxLogSize = 5 * 1024 * 1024
// maxBackups is how many rotated files to retain (snapgo.log.1,
// snapgo.log.2). Older ones are deleted on each rotation.
maxBackups = 2
)
// teeWriter is a thin io.Writer that fans out a single slog write to many
// underlying writers. We avoid log.MultiWriter from the std library because
// io.MultiWriter already covers the use case without an extra dependency.
type teeWriter struct {
writers []io.Writer
}
// Write copies p to every underlying writer. We deliberately keep going on
// errors so a single broken writer (e.g. closed file handle) does not lose
// output to the surviving writers; the last error wins which is sufficient
// for a logger.
func (t *teeWriter) Write(p []byte) (int, error) {
var lastErr error
for _, w := range t.writers {
if w == nil {
continue
}
if _, err := w.Write(p); err != nil {
lastErr = err
}
}
return len(p), lastErr
}
// Init wires slog.Default to write to both ~/Library/Logs/SnapGo/snapgo.log
// and stderr. Returns a closer the caller (main) should defer so the file
// handle is released on shutdown.
//
// 日志级别可通过环境变量 SNAPGO_LOG_LEVEL 调整: debug|info|warn|error.
// 默认 debug 以便排查 SCP 阶段性问题, 待功能稳定后可调整为 info.
func Init() (closer func()) {
level := parseLevel(os.Getenv("SNAPGO_LOG_LEVEL"))
var fileW io.Writer
logPath, err := defaultLogPath()
if err == nil {
// Size-based rotation keeps the log directory bounded; append mode is
// preserved inside rotatingFile so prior-launch context survives.
rf, ferr := newRotatingFile(logPath, maxLogSize, maxBackups)
if ferr == nil {
fileW = rf
closer = func() { _ = rf.Close() }
}
}
if closer == nil {
closer = func() {}
}
writer := &teeWriter{writers: []io.Writer{os.Stderr}}
if fileW != nil {
writer.writers = append(writer.writers, fileW)
}
handler := slog.NewTextHandler(writer, &slog.HandlerOptions{Level: level})
slog.SetDefault(slog.New(handler))
slog.Info("logging initialised",
"level", level.String(),
"file", logPath)
return closer
}
// rotatingFile is a size-based rotating log writer.
//
// 设计理由:
// - GUI 应用长期 append 写日志, 不加限制会无限增长, 占满磁盘.
// - 标准库不提供滚动能力, 引入 lumberjack 等第三方库又会增加依赖,
// 而我们的需求很简单 (单文件 / 按大小 / 固定份数), 自实现成本更低.
// - 实现策略: 每次 Write 前检查写入后是否超过 maxLogSize, 超过则先
// rotate (snapgo.log -> snapgo.log.1 -> snapgo.log.2, 丢弃最老的),
// 再写入新建的 snapgo.log. 用互斥锁保证并发安全 (slog handler 自身
// 不保证对 io.Writer 的串行化).
type rotatingFile struct {
mu sync.Mutex
path string
maxSize int64
backups int
file *os.File
size int64
}
// newRotatingFile opens (or creates, append mode) the target log file and
// initialises the current size from the existing file so a restart does not
// reset the rotation threshold.
func newRotatingFile(path string, maxSize int64, backups int) (*rotatingFile, error) {
r := &rotatingFile{path: path, maxSize: maxSize, backups: backups}
if err := r.openExisting(); err != nil {
return nil, err
}
return r, nil
}
// openExisting opens the active log file in append mode and records its
// current size.
func (r *rotatingFile) openExisting() error {
f, err := os.OpenFile(r.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return err
}
r.file = f
r.size = info.Size()
return nil
}
// Write appends p, rotating first when the write would exceed maxSize.
func (r *rotatingFile) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.size+int64(len(p)) > r.maxSize {
if err := r.rotate(); err != nil {
// On rotation failure we keep writing to the current file rather
// than dropping logs; oversized-by-a-bit beats data loss.
r.size += int64(len(p))
n, werr := r.file.Write(p)
if werr != nil {
return n, werr
}
return n, err
}
}
n, err := r.file.Write(p)
r.size += int64(n)
return n, err
}
// rotate closes the current file, shifts backups (snapgo.log.1 ->
// snapgo.log.2, dropping the oldest), renames the active file to
// snapgo.log.1, then opens a fresh active file.
func (r *rotatingFile) rotate() error {
if err := r.file.Close(); err != nil {
return err
}
// Drop the oldest backup, then shift the rest up by one index.
oldest := fmt.Sprintf("%s.%d", r.path, r.backups)
_ = os.Remove(oldest)
for i := r.backups - 1; i >= 1; i-- {
src := fmt.Sprintf("%s.%d", r.path, i)
dst := fmt.Sprintf("%s.%d", r.path, i+1)
_ = os.Rename(src, dst)
}
if r.backups >= 1 {
_ = os.Rename(r.path, fmt.Sprintf("%s.1", r.path))
}
return r.openExisting()
}
// Close releases the underlying file handle.
func (r *rotatingFile) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.file == nil {
return nil
}
return r.file.Close()
}
// defaultLogPath returns ~/Library/Logs/SnapGo/snapgo.log, creating the
// parent directory if needed. macOS users expect app logs to live there
// (Console.app surfaces this directory under "Reports" and `open ~/Library/Logs`
// is muscle-memory).
func defaultLogPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, "Library", "Logs", "SnapGo")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return filepath.Join(dir, "snapgo.log"), nil
}
// parseLevel translates a free-form string into an slog.Level. We default
// to debug because the app currently has plenty of diagnostic logs and the
// user-facing impact (slightly more disk IO on uploads) is negligible.
func parseLevel(s string) slog.Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "error":
return slog.LevelError
case "warn", "warning":
return slog.LevelWarn
case "info":
return slog.LevelInfo
case "debug", "":
return slog.LevelDebug
default:
return slog.LevelDebug
}
}
+124
View File
@@ -0,0 +1,124 @@
// Package oss contains storage adapters that satisfy domain.OSSProvider.
//
// The S3 adapter targets any S3-compatible endpoint via aws-sdk-go-v2.
package oss
import (
"bytes"
"context"
"fmt"
"net/url"
"strings"
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/mmmy/snapgo/internal/domain"
)
// S3Provider implements domain.OSSProvider using aws-sdk-go-v2.
//
// We construct one client per Provider instance because the S3 client is
// goroutine-safe and the configuration is immutable for the lifetime of the
// provider.
type S3Provider struct {
cfg domain.S3Config
client *s3.Client
}
// NewS3Provider builds an *S3Provider from the supplied user configuration.
//
// The endpoint is plumbed through BaseEndpoint + UsePathStyle so any
// S3-compatible service (MinIO, R2, B2, Aliyun OSS s3 endpoint) works
// without us writing per-vendor branches.
func NewS3Provider(cfg domain.S3Config) (*S3Provider, error) {
if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
return nil, fmt.Errorf("s3 config: endpoint/bucket/accessKeyId/secretAccessKey are required")
}
region := cfg.Region
if region == "" {
region = "us-east-1"
}
awsCfg := awsv2.Config{
Region: region,
Credentials: credentials.NewStaticCredentialsProvider(
cfg.AccessKeyID, cfg.SecretAccessKey, "",
),
}
endpoint := cfg.Endpoint
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = awsv2.String(endpoint)
o.UsePathStyle = cfg.UsePathStyle
})
return &S3Provider{cfg: cfg, client: client}, nil
}
// Name identifies this provider in logs and history records.
func (p *S3Provider) Name() string { return "s3" }
// Upload pushes the bytes to the configured bucket and returns a public URL.
//
// We pass the body via bytes.Reader so the SDK can compute Content-Length
// and signed checksum without buffering the data twice.
func (p *S3Provider) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
if contentType == "" {
contentType = "image/png"
}
_, err := p.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(key),
Body: bytes.NewReader(data),
ContentType: awsv2.String(contentType),
})
if err != nil {
return "", fmt.Errorf("s3 put object: %w", err)
}
return p.BuildPublicURL(key), nil
}
// BuildPublicURL constructs the URL that the user pastes into Markdown.
//
// Priority:
// 1. PublicURLBase if user filled it (best-fit for CDN-fronted buckets);
// 2. {Endpoint}/{Bucket}/{Key} for path-style;
// 3. virtual-hosted-style fallback ({bucket}.host).
func (p *S3Provider) BuildPublicURL(key string) string {
if p.cfg.PublicURLBase != "" {
base := strings.TrimRight(p.cfg.PublicURLBase, "/")
return base + "/" + key
}
endpoint := strings.TrimRight(p.cfg.Endpoint, "/")
if p.cfg.UsePathStyle {
return endpoint + "/" + p.cfg.Bucket + "/" + key
}
// Virtual-hosted-style: replace host with {bucket}.{host}
if u, err := url.Parse(endpoint); err == nil && u.Host != "" {
u.Host = p.cfg.Bucket + "." + u.Host
u.Path = "/" + key
return u.String()
}
return endpoint + "/" + p.cfg.Bucket + "/" + key
}
// 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 {
probeKey := strings.TrimRight(p.cfg.PathPrefix, "/") + "/.snapgo-probe"
probeKey = strings.TrimLeft(probeKey, "/")
if _, err := p.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(probeKey),
Body: bytes.NewReader([]byte("snapgo")),
ContentType: awsv2.String("text/plain"),
}); err != nil {
return fmt.Errorf("probe put: %w", err)
}
if _, err := p.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(probeKey),
}); err != nil {
return fmt.Errorf("probe delete: %w", err)
}
return nil
}
@@ -0,0 +1,42 @@
// Package screencapture defines the cross-platform Capturer abstraction.
//
// Per-platform implementations live in sibling files:
// - capturer_darwin.go macOS, uses /usr/sbin/screencapture
// - capturer_other.go non-darwin, uses kbinani/screenshot
//
// The interface is kept here so callers do not need build tags.
package screencapture
import "image"
// Capturer is the abstraction that the application service depends on.
type Capturer interface {
// CaptureRegion grabs the pixels within the supplied virtual-screen
// rectangle and returns PNG-encoded bytes.
CaptureRegion(rect image.Rectangle) ([]byte, error)
// VirtualBounds returns the union rectangle of every connected display.
VirtualBounds() image.Rectangle
// CaptureInteractive opens a system-provided region picker (e.g. macOS's
// `screencapture -i`) and returns the resulting PNG bytes once the user
// confirms a selection. If the user cancels (Esc / right-click), it
// returns (nil, ErrCancelled) so callers can short-circuit cleanly.
CaptureInteractive() ([]byte, error)
// CaptureFullScreen grabs the entire primary display silently and
// returns the PNG bytes. Used as the backdrop for the in-app Snipaste-
// style overlay; the per-region crop happens later in CropPNG once the
// user finishes dragging.
CaptureFullScreen() ([]byte, error)
}
// ErrCancelled signals that the user aborted an interactive capture.
// It is a sentinel value; callers should compare via errors.Is.
type cancelled struct{}
func (cancelled) Error() string { return "capture cancelled by user" }
// ErrCancelled is returned from CaptureInteractive when the user dismisses
// the system selection UI without confirming a region.
var ErrCancelled = cancelled{}
@@ -0,0 +1,145 @@
//go:build darwin
package screencapture
import (
"fmt"
"image"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// macCapturer shells out to /usr/sbin/screencapture, which is bundled with
// macOS, requires no cgo, and is fully compatible with macOS 15/26 SDKs
// where CoreGraphics's display-capture API has been removed.
//
// Trade-off: a tiny process-spawn cost per capture (~30 ms). Acceptable for
// an interactive screenshot tool — users cannot perceive it.
type macCapturer struct{}
// New returns a macOS-native Capturer.
func New() Capturer { return &macCapturer{} }
// VirtualBounds returns the union of all displays measured in CSS pixels
// (i.e. logical, not Retina physical pixels). We query system_profiler via
// AppleScript-free shell utilities to avoid extra cgo dependencies.
//
// For simplicity we currently return the main display only; this matches
// what the WebView/window can actually paint without a separate transparent
// window per display. Multi-monitor support is a follow-up spec.
func (c *macCapturer) VirtualBounds() image.Rectangle {
out, err := exec.Command(
"system_profiler", "SPDisplaysDataType",
).Output()
if err == nil {
// Look for "Resolution: 3024 x 1964" lines and take the first one.
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Resolution:") {
continue
}
parts := strings.Fields(line)
// Expected pattern: ["Resolution:", "3024", "x", "1964", ...]
if len(parts) >= 4 {
w, errW := strconv.Atoi(parts[1])
h, errH := strconv.Atoi(parts[3])
if errW == nil && errH == nil {
// Resolution reported is physical; convert to CSS px by
// halving for Retina (assume 2× when w >= 2560).
if w >= 2560 {
w /= 2
h /= 2
}
return image.Rect(0, 0, w, h)
}
}
}
}
// Conservative fallback used by ~all modern Macs.
return image.Rect(0, 0, 1440, 900)
}
// CaptureRegion grabs the requested rectangle via the OS tool and returns
// the resulting PNG bytes.
//
// Rect coords here are logical screen coordinates, matching macOS
// screencapture's -R contract and the CSS-point coordinates reported by the
// transparent overlay.
func (c *macCapturer) CaptureRegion(rect image.Rectangle) ([]byte, error) {
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return nil, fmt.Errorf("invalid capture rectangle: %v", rect)
}
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
region := fmt.Sprintf("%d,%d,%d,%d",
rect.Min.X, rect.Min.Y, rect.Dx(), rect.Dy(),
)
// -x: silent (no shutter sound)
// -R: rectangle
// -t png
cmd := exec.Command("/usr/sbin/screencapture", "-x", "-R", region, "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture: %w (%s)", err, strings.TrimSpace(string(out)))
}
data, err := os.ReadFile(tmp)
if err != nil {
return nil, fmt.Errorf("read screencapture output: %w", err)
}
return data, nil
}
// CaptureInteractive launches `screencapture -i` which presents the macOS
// native region selector (the same crosshair that Cmd+Shift+4 produces).
//
// Design rationale:
// - The native picker is pixel-perfect across Retina / external displays
// and supports multi-monitor out of the box.
// - It also handles Esc / right-click to cancel without requiring us to
// run a transparent overlay window — which previously caused the main
// window to flash a grey full-screen and trap the user.
// - When the user cancels, screencapture exits 0 but writes no file; we
// detect that case via os.Stat and return ErrCancelled.
func (c *macCapturer) CaptureInteractive() ([]byte, error) {
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-i-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
// -i: interactive region selection
// -x: silent (suppress the camera shutter sound)
// -t png: png output
cmd := exec.Command("/usr/sbin/screencapture", "-i", "-x", "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture -i: %w (%s)", err, strings.TrimSpace(string(out)))
}
// User cancelled (Esc / right-click): tool exits 0 with no file written.
info, err := os.Stat(tmp)
if err != nil || info.Size() == 0 {
return nil, ErrCancelled
}
return os.ReadFile(tmp)
}
// CaptureFullScreen runs `screencapture -x` to grab the primary display in
// a single shot. We use -m so only the main display is captured even if
// external monitors are attached — matches the "primary screen only" UX
// chosen for the in-app overlay.
func (c *macCapturer) CaptureFullScreen() ([]byte, error) {
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-full-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
// -x: silent
// -m: main display only
// -t png: png output
cmd := exec.Command("/usr/sbin/screencapture", "-x", "-m", "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture -x -m: %w (%s)", err, strings.TrimSpace(string(out)))
}
return os.ReadFile(tmp)
}
@@ -0,0 +1,71 @@
//go:build !darwin
package screencapture
import (
"bytes"
"fmt"
"image"
"image/png"
"github.com/kbinani/screenshot"
)
// kbinaniCapturer is the default non-darwin Capturer.
type kbinaniCapturer struct{}
// New returns the default cross-platform Capturer for non-macOS systems.
func New() Capturer { return &kbinaniCapturer{} }
func (c *kbinaniCapturer) VirtualBounds() image.Rectangle {
n := screenshot.NumActiveDisplays()
if n == 0 {
return image.Rect(0, 0, 0, 0)
}
bounds := screenshot.GetDisplayBounds(0)
for i := 1; i < n; i++ {
bounds = bounds.Union(screenshot.GetDisplayBounds(i))
}
return bounds
}
func (c *kbinaniCapturer) CaptureRegion(rect image.Rectangle) ([]byte, error) {
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return nil, fmt.Errorf("invalid capture rectangle: %v", rect)
}
img, err := screenshot.CaptureRect(rect)
if err != nil {
return nil, fmt.Errorf("capture: %w", err)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
return buf.Bytes(), nil
}
// CaptureInteractive on non-darwin platforms is not yet implemented. The
// frontend overlay still works there. We surface a clear error so callers
// can decide whether to fall back to the WebView overlay.
func (c *kbinaniCapturer) CaptureInteractive() ([]byte, error) {
return nil, fmt.Errorf("interactive capture is not implemented on this platform yet")
}
// CaptureFullScreen grabs the primary display via kbinani/screenshot and
// PNG-encodes the result. Multi-display extension is deliberately deferred
// to keep parity with the macOS "primary only" UX.
func (c *kbinaniCapturer) CaptureFullScreen() ([]byte, error) {
if screenshot.NumActiveDisplays() == 0 {
return nil, fmt.Errorf("no active display detected")
}
bounds := screenshot.GetDisplayBounds(0)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return nil, fmt.Errorf("capture full screen: %w", err)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
return buf.Bytes(), nil
}
@@ -0,0 +1,59 @@
package screencapture
import (
"bytes"
"fmt"
"image"
"image/png"
)
// CropPNG decodes the supplied PNG, crops it to rect (in PHYSICAL pixels of
// the source image — i.e. the same coordinate system the PNG itself uses),
// and re-encodes the result.
//
// Why an extra abstraction: the overlay flow captures the entire screen as
// one big PNG and lets the user pick a region in the WebView. The selection
// rectangle the WebView reports is in CSS pixels, so callers must scale by
// the display's backing factor before invoking CropPNG. Splitting "scale"
// from "crop" keeps this helper focused and unit-testable.
func CropPNG(pngBytes []byte, rect image.Rectangle) ([]byte, error) {
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return nil, fmt.Errorf("invalid crop rectangle: %v", rect)
}
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return nil, fmt.Errorf("decode source png: %w", err)
}
// Clip the requested rectangle to the source bounds — defensive guard
// against off-by-one errors from CSS-px → physical-px scaling.
src := img.Bounds()
clipped := rect.Intersect(src)
if clipped.Empty() {
return nil, fmt.Errorf("crop rectangle %v has no overlap with source %v", rect, src)
}
// SubImage is documented on most concrete image types; the standard
// library decodes PNGs into one of those, so this assertion is safe in
// practice. Fall back to a manual copy otherwise.
type subImager interface {
SubImage(r image.Rectangle) image.Image
}
var cropped image.Image
if si, ok := img.(subImager); ok {
cropped = si.SubImage(clipped)
} else {
dst := image.NewRGBA(image.Rect(0, 0, clipped.Dx(), clipped.Dy()))
for y := 0; y < clipped.Dy(); y++ {
for x := 0; x < clipped.Dx(); x++ {
dst.Set(x, y, img.At(clipped.Min.X+x, clipped.Min.Y+y))
}
}
cropped = dst
}
var buf bytes.Buffer
if err := png.Encode(&buf, cropped); err != nil {
return nil, fmt.Errorf("encode cropped png: %w", err)
}
return buf.Bytes(), nil
}
+200
View File
@@ -0,0 +1,200 @@
// 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
}
+505
View File
@@ -0,0 +1,505 @@
// Package ssh provides an SCP-based remote upload adapter for SnapGo.
//
// Design rationale:
// - We deliberately implement SCP "sink mode" by hand on top of an SSH
// session instead of pulling in an extra dependency. The protocol is
// trivial (one control line + payload + null byte) and we only need
// write support, so a custom implementation keeps the dependency
// surface small and auditable.
// - The adapter is split into a Client (connection lifetime) and a
// CopyFile method (single transfer) so future use-cases such as listing
// or deleting remote files can extend the same SSH session.
// - All public methods accept a context so the application layer can
// enforce a timeout that matches the user-perceived capture latency.
package ssh
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"os"
"path"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
"github.com/mmmy/snapgo/internal/domain"
)
// logger returns a component-scoped slog logger bound to the CURRENT
// default handler.
//
// 为什么是函数而非包级 slog.With 变量:
// - 包级变量在 main() 调用 logging.Init() 之前就初始化, 那一刻
// slog.Default() 还是 bootstrap handler (它转发给标准 log 包).
// - logging.Init() 里的 slog.SetDefault() 会把标准 log 包重定向回新的
// TextHandler, 于是旧 handler 先把整行格式化成 "INFO msg component=ssh..."
// 再被新 handler 当成一个 msg 二次包裹, 产生双前缀日志.
// - 每次惰性读取 slog.Default() 即可始终拿到正确的目标 handler.
func sshLog() *slog.Logger { return slog.Default().With("component", "ssh") }
// Client is a thin wrapper around *ssh.Client whose lifetime maps 1:1
// to a single upload operation. We do not pool connections because the
// expected cadence (a few uploads per minute at most) does not justify
// the additional complexity of managing keep-alives.
type Client struct {
client *ssh.Client
cfg domain.SSHConfig
}
// Dial establishes an SSH connection using the supplied configuration.
//
// Authentication priority:
// 1. Password, if non-empty.
// 2. Local ssh-agent ($SSH_AUTH_SOCK), if present.
// 3. ~/.ssh/id_ed25519 → id_rsa fallback files.
//
// Host-key verification follows cfg.StrictHostKey. When strict, the user-
// supplied known_hosts file is honoured (defaulting to ~/.ssh/known_hosts).
// When false the call uses ssh.InsecureIgnoreHostKey, which is a deliberate
// trade-off that surfaces in the UI — the settings page warns the user.
func Dial(ctx context.Context, cfg domain.SSHConfig) (*Client, error) {
if cfg.Host == "" || cfg.User == "" {
return nil, fmt.Errorf("ssh: host and user are required")
}
port := cfg.Port
if port <= 0 {
port = 22
}
timeout := time.Duration(cfg.ConnectTimeoutSecs) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
authMethods, authSummary, err := buildAuthMethods(cfg)
if err != nil {
return nil, err
}
if len(authMethods) == 0 {
sshLog().Warn("dial aborted: no auth methods",
"host", cfg.Host, "user", cfg.User, "auth_summary", authSummary)
return nil, fmt.Errorf("ssh: no authentication methods available (set password or ensure ssh-agent / ~/.ssh/id_* exists)")
}
hostKeyCallback, hostKeyMode, err := buildHostKeyCallback(cfg)
if err != nil {
sshLog().Error("host key callback failed",
"host", cfg.Host, "strict", cfg.StrictHostKey,
"known_hosts", cfg.KnownHostsPath, "err", err)
return nil, err
}
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Auth: authMethods,
HostKeyCallback: hostKeyCallback,
Timeout: timeout,
}
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", port))
sshLog().Info("dial start",
"addr", addr, "user", cfg.User,
"timeout", timeout, "auth_summary", authSummary, "host_key", hostKeyMode)
dialStart := time.Now()
// Honour ctx by dialing through net.Dialer so an early ctx cancel
// surfaces here instead of after the (long) ssh handshake.
dialer := net.Dialer{Timeout: timeout}
tcpConn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
sshLog().Error("tcp dial failed",
"addr", addr, "elapsed", time.Since(dialStart), "err", err)
return nil, fmt.Errorf("ssh: dial %s: %w", addr, err)
}
sshLog().Debug("tcp connected", "addr", addr, "elapsed", time.Since(dialStart))
hsStart := time.Now()
sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, addr, clientCfg)
if err != nil {
_ = tcpConn.Close()
sshLog().Error("ssh handshake failed",
"addr", addr, "user", cfg.User,
"elapsed", time.Since(hsStart), "err", err)
return nil, fmt.Errorf("ssh: handshake: %w", err)
}
sshLog().Info("ssh handshake ok",
"addr", addr, "user", cfg.User,
"server_version", string(sshConn.ServerVersion()),
"elapsed", time.Since(hsStart))
return &Client{
client: ssh.NewClient(sshConn, chans, reqs),
cfg: cfg,
}, nil
}
// Close shuts down the underlying SSH connection.
func (c *Client) Close() error {
if c == nil || c.client == nil {
return nil
}
return c.client.Close()
}
// CopyFile uploads `data` to remoteRelativePath under the user's home
// directory. The caller passes a path *relative to $HOME*; the adapter
// strips any leading "/" or "~" so users cannot escape their home
// directory through misconfigured PathPrefix values.
//
// The upload uses the SCP "sink mode" protocol:
//
// $ scp -t <remote-dir>
// ← we send: D0755 0 <subdir>\n (mkdir -p analogue, repeated)
// ← we send: C0644 <size> <basename>\n
// ← we send: <bytes...>\0
// ← we send: E\n (close each directory)
//
// Each line we write is acknowledged by a single 0x00 byte from the remote
// `scp -t` process; a non-zero ack indicates an error.
func (c *Client) CopyFile(ctx context.Context, remoteRelativePath string, data []byte, mode os.FileMode) error {
cleaned := normaliseRemotePath(remoteRelativePath)
if cleaned == "" {
return fmt.Errorf("ssh: remote path is empty")
}
dir, base := path.Split(cleaned)
dir = strings.Trim(dir, "/")
sshLog().Info("scp copy start",
"remote_path", cleaned, "dir", dir, "file", base,
"size", len(data), "mode", fmt.Sprintf("%#o", mode.Perm()))
session, err := c.client.NewSession()
if err != nil {
sshLog().Error("scp new session failed", "err", err)
return fmt.Errorf("ssh: new session: %w", err)
}
defer session.Close()
stdin, err := session.StdinPipe()
if err != nil {
return fmt.Errorf("ssh: stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
return fmt.Errorf("ssh: stdout pipe: %w", err)
}
// `-t` puts the remote scp into "sink mode" rooted at $HOME. `-r`
// allows us to feed `D` directives so we can create directory trees
// in one round-trip rather than running a separate `mkdir -p`.
cmd := fmt.Sprintf("scp -tr %s", shellQuote("./"))
sshLog().Debug("scp remote command", "cmd", cmd)
if err := session.Start(cmd); err != nil {
sshLog().Error("scp session start failed", "cmd", cmd, "err", err)
return fmt.Errorf("ssh: start scp: %w", err)
}
transferStart := time.Now()
errCh := make(chan error, 1)
go func() {
errCh <- writeSCPStream(stdin, stdout, dir, base, data, mode)
}()
// Wait for either ctx, the writer goroutine, or the remote command.
select {
case <-ctx.Done():
sshLog().Warn("scp cancelled by context",
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", ctx.Err())
_ = session.Signal(ssh.SIGTERM)
_ = session.Close()
return ctx.Err()
case writeErr := <-errCh:
if writeErr != nil {
sshLog().Error("scp protocol failed",
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", writeErr)
_ = session.Close()
return writeErr
}
}
if err := session.Wait(); err != nil {
sshLog().Error("scp session wait failed",
"remote_path", cleaned, "elapsed", time.Since(transferStart), "err", err)
return fmt.Errorf("ssh: scp wait: %w", err)
}
sshLog().Info("scp copy ok",
"remote_path", cleaned, "size", len(data), "elapsed", time.Since(transferStart))
return nil
}
// writeSCPStream drives the SCP sink-mode dialogue described above. It is
// extracted from CopyFile for two reasons:
// 1. It contains the only blocking IO so we can run it in a goroutine
// and select on ctx.Done().
// 2. It makes the protocol-level steps testable in isolation (the unit
// tests pipe an in-memory bytes.Buffer into expectAck).
func writeSCPStream(stdin io.WriteCloser, stdout io.Reader, dir, base string, data []byte, mode os.FileMode) error {
defer stdin.Close()
// First ack: the remote scp -t emits an initial 0x00 once it is ready.
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: initial ack: %w", err)
}
sshLog().Debug("scp initial ack received")
// Walk `dir` segment by segment, opening each level with `D0755 0 <name>`.
dirs := splitNonEmpty(dir, "/")
for _, segment := range dirs {
line := fmt.Sprintf("D0755 0 %s\n", segment)
if _, err := io.WriteString(stdin, line); err != nil {
return fmt.Errorf("scp: write D-line: %w", err)
}
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: ack D-line %q: %w", segment, err)
}
sshLog().Debug("scp dir opened", "segment", segment)
}
// File header: `C<perm> <size> <name>\n`
header := fmt.Sprintf("C%04o %d %s\n", mode.Perm(), len(data), base)
if _, err := io.WriteString(stdin, header); err != nil {
return fmt.Errorf("scp: write C-line: %w", err)
}
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: ack C-line: %w", err)
}
sshLog().Debug("scp header acked", "header", strings.TrimSpace(header))
if _, err := stdin.Write(data); err != nil {
return fmt.Errorf("scp: write payload: %w", err)
}
if _, err := stdin.Write([]byte{0}); err != nil {
return fmt.Errorf("scp: write trailing null: %w", err)
}
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: ack payload: %w", err)
}
sshLog().Debug("scp payload acked", "bytes", len(data))
// Pop each directory we opened earlier with an `E` line so the remote
// scp finishes cleanly.
for range dirs {
if _, err := io.WriteString(stdin, "E\n"); err != nil {
return fmt.Errorf("scp: write E-line: %w", err)
}
if err := expectAck(stdout); err != nil {
return fmt.Errorf("scp: ack E-line: %w", err)
}
}
return nil
}
// expectAck reads exactly one byte from the remote scp and treats anything
// other than 0x00 as a protocol-level error. When the remote signals an
// error (0x01 = warning, 0x02 = fatal) it is followed by a textual reason
// terminated with '\n', which we surface to the caller.
func expectAck(r io.Reader) error {
buf := make([]byte, 1)
if _, err := io.ReadFull(r, buf); err != nil {
return err
}
if buf[0] == 0 {
return nil
}
// Read the rest of the message until newline so the user sees a
// meaningful "permission denied"-style reason.
msg := readUntilNewline(r)
return fmt.Errorf("scp remote error (%d): %s", buf[0], strings.TrimSpace(msg))
}
func readUntilNewline(r io.Reader) string {
var sb strings.Builder
buf := make([]byte, 1)
for i := 0; i < 1024; i++ {
if _, err := io.ReadFull(r, buf); err != nil {
break
}
if buf[0] == '\n' {
break
}
sb.WriteByte(buf[0])
}
return sb.String()
}
// TestConnection performs a minimal handshake + `pwd` round trip so the
// settings UI can confirm the credentials work without writing a file.
func TestConnection(ctx context.Context, cfg domain.SSHConfig) error {
sshLog().Info("test connection start", "host", cfg.Host, "user", cfg.User, "port", cfg.Port)
start := time.Now()
client, err := Dial(ctx, cfg)
if err != nil {
sshLog().Error("test connection: dial failed", "err", err, "elapsed", time.Since(start))
return err
}
defer client.Close()
session, err := client.client.NewSession()
if err != nil {
sshLog().Error("test connection: new session failed", "err", err)
return fmt.Errorf("ssh: new session: %w", err)
}
defer session.Close()
if err := session.Run("true"); err != nil {
sshLog().Error("test connection: probe failed", "err", err)
return fmt.Errorf("ssh: probe command failed: %w", err)
}
sshLog().Info("test connection ok", "elapsed", time.Since(start))
return nil
}
// normaliseRemotePath strips any leading "/" or "~" so the path is always
// interpreted relative to the remote $HOME, matching the UI promise.
func normaliseRemotePath(p string) string {
p = strings.TrimSpace(p)
p = strings.TrimPrefix(p, "~")
p = strings.TrimPrefix(p, "/")
p = strings.TrimPrefix(p, "./")
return path.Clean(p)
}
// splitNonEmpty splits `s` on `sep` and discards empty segments so the
// caller does not have to defend against double slashes etc.
func splitNonEmpty(s, sep string) []string {
parts := strings.Split(s, sep)
out := parts[:0]
for _, part := range parts {
if part == "" || part == "." {
continue
}
out = append(out, part)
}
return out
}
// shellQuote is a defensive single-quote escaper used when interpolating
// user-controlled strings (currently only the relative root ".") into the
// remote `scp` command line. We do NOT quote arbitrary user paths because
// SCP itself receives them through the protocol channel above.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// buildAuthMethods chooses authentication methods given the supplied
// configuration.
//
// The method set is gated by cfg.AuthMethod:
// - SSHAuthPassword → password only.
// - SSHAuthKey → ssh-agent + ~/.ssh/id_* key files only.
// - "" / SSHAuthBuiltin (legacy) → password (if set) + agent + key files.
//
// (Kerberos never reaches here; it uses the system ssh binary instead.)
//
// Returns a human-readable summary alongside the method slice so the
// caller can include "password+agent+ed25519" (and similar) in its dial
// log without leaking secret material.
func buildAuthMethods(cfg domain.SSHConfig) ([]ssh.AuthMethod, string, error) {
var methods []ssh.AuthMethod
var sources []string
// Decide which families are allowed for the selected method. An empty /
// "builtin" value preserves the original combined behaviour so configs
// written before the split keep working.
allowPassword := cfg.AuthMethod == domain.SSHAuthPassword ||
cfg.AuthMethod == domain.SSHAuthBuiltin || cfg.AuthMethod == ""
allowKey := cfg.AuthMethod == domain.SSHAuthKey ||
cfg.AuthMethod == domain.SSHAuthBuiltin || cfg.AuthMethod == ""
if allowPassword && cfg.Password != "" {
methods = append(methods, ssh.Password(cfg.Password))
sources = append(sources, "password")
}
if allowKey {
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
if conn, err := net.Dial("unix", sock); err == nil {
// 关键: 仅在 agent 真正持有 key 时才把它加入 auth methods.
//
// macOS 的 launchd ssh-agent 即便没有任何 identity 也会响应,
// 此时若仍注册 PublicKeysCallback, x/crypto 会把它当作一次空的
// publickey 尝试. 配合服务器的 MaxAuthTries 计数, 这次"空尝试"
// 会挤占后续基于磁盘私钥的认证机会, 最终导致
// "[none publickey] no supported methods remain" —— 即便磁盘上
// 的 key 本身完全可用. 因此空 agent 必须跳过.
ag := agent.NewClient(conn)
if keys, lerr := ag.List(); lerr == nil && len(keys) > 0 {
methods = append(methods, ssh.PublicKeysCallback(ag.Signers))
sources = append(sources, fmt.Sprintf("agent(%d)", len(keys)))
} else {
sshLog().Debug("ssh-agent has no identities; skipping",
"sock", sock, "list_err", lerr)
}
} else {
sshLog().Debug("ssh-agent dial failed", "sock", sock, "err", err)
}
}
home, err := os.UserHomeDir()
if err == nil {
// Probe the common default keys; any unreadable / missing file is
// silently skipped so the user never sees noise about keys they did
// not set up.
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
signer, err := loadPrivateKey(path.Join(home, ".ssh", name))
if err == nil && signer != nil {
methods = append(methods, ssh.PublicKeys(signer))
sources = append(sources, name)
}
}
} else {
sshLog().Debug("home dir unavailable for ssh keys", "err", err)
}
}
if len(sources) == 0 {
return methods, "none", nil
}
return methods, strings.Join(sources, "+"), nil
}
// loadPrivateKey reads and parses a single OpenSSH private key file.
// Returns (nil, nil) when the file does not exist so the caller can keep
// scanning the standard key list.
func loadPrivateKey(p string) (ssh.Signer, error) {
data, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
signer, err := ssh.ParsePrivateKey(data)
if err != nil {
// Encrypted keys without a passphrase are skipped silently for now;
// passphrase support is left to a follow-up spec.
return nil, nil
}
return signer, nil
}
// buildHostKeyCallback returns either a strict known_hosts-backed callback
// or an InsecureIgnoreHostKey fallback, depending on cfg.StrictHostKey.
//
// The returned mode string ("insecure" or "known_hosts:<path>") is logged
// at dial time so a "rejected by host key" failure is easy to diagnose.
func buildHostKeyCallback(cfg domain.SSHConfig) (ssh.HostKeyCallback, string, error) {
if !cfg.StrictHostKey {
// Deliberate trade-off: matches `scp -o StrictHostKeyChecking=no` and
// keeps the first-launch experience friction-free for personal LANs.
return ssh.InsecureIgnoreHostKey(), "insecure", nil
}
knownHosts := cfg.KnownHostsPath
if knownHosts == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, "", fmt.Errorf("ssh: resolve home for known_hosts: %w", err)
}
knownHosts = path.Join(home, ".ssh", "known_hosts")
}
cb, err := knownhosts.New(knownHosts)
if err != nil {
return nil, "", fmt.Errorf("ssh: load known_hosts %q: %w", knownHosts, err)
}
return cb, "known_hosts:" + knownHosts, nil
}
@@ -0,0 +1,230 @@
// kerberos_uploader.go — an SSHUploader implementation that delegates to
// the system /usr/bin/ssh + /usr/bin/scp binaries so an existing Kerberos
// (GSSAPI) credential cache populated by `kinit` is reused transparently.
//
// 设计理由 (为什么不用 golang.org/x/crypto/ssh 做 Kerberos):
// - macOS 的 Kerberos 票据默认存放在 API: 类型的 credential cache 中,
// 由系统 GSSD (Heimdal) 守护进程托管. Go 生态的 gokrb5 只能读取
// FILE: 类型 ccache 与 keytab, 无法访问 API: ccache, 因此在 macOS 上
// 用纯 Go 实现 GSSAPI 认证基本不可行.
// - 系统自带的 /usr/bin/ssh 原生集成 Heimdal GSSAPI, 用户 `kinit` 之后
// 的票据可被直接复用. 把上传委托给系统 ssh/scp 是最稳妥的方案, 也与
// 用户"命令行能免密登录即可"的预期完全一致.
// - 仅在 AuthMethod == kerberos 时启用此实现; builtin 路径不受影响.
package ssh
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path"
"strconv"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// systemSSHPath / systemSCPPath are the absolute paths to the macOS system
// binaries. We use absolute paths rather than relying on $PATH so a hijacked
// PATH cannot redirect us to an arbitrary binary, and because GUI apps on
// macOS often launch with a minimal PATH that omits /usr/bin entirely.
const (
systemSSHPath = "/usr/bin/ssh"
systemSCPPath = "/usr/bin/scp"
)
// KerberosUploader satisfies application.SSHUploader by shelling out to the
// system scp binary with GSSAPI authentication enabled.
type KerberosUploader struct {
cfg domain.SSHConfig
}
// NewKerberosUploader returns an uploader that delegates to system scp.
func NewKerberosUploader(cfg domain.SSHConfig) *KerberosUploader {
return &KerberosUploader{cfg: cfg}
}
// Upload writes data to remoteRelPath (relative to the remote $HOME) by
// staging it in a local temp file and invoking system scp once.
//
// We stage to a temp file because scp transfers files, not stdin; piping a
// stream would require the more fragile `scp -t` sink protocol that we only
// use for the in-process client. A temp file is simpler and robust.
func (u *KerberosUploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
start := time.Now()
cleaned := normaliseRemotePath(remoteRelPath)
if cleaned == "" {
return fmt.Errorf("ssh(kerberos): remote path is empty")
}
sshLog().Info("kerberos uploader start",
"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
"remote_path", cleaned, "size", len(data))
// 1. Ensure the remote directory tree exists. scp itself will not create
// intermediate directories, so we run a remote `mkdir -p` first.
dir, _ := path.Split(cleaned)
dir = strings.Trim(dir, "/")
if dir != "" {
if err := u.runRemoteMkdir(ctx, dir); err != nil {
sshLog().Error("kerberos uploader mkdir failed",
"dir", dir, "elapsed", time.Since(start), "err", err)
return err
}
}
// 2. Stage the payload to a local temp file for scp to read.
tmp, err := os.CreateTemp("", "snapgo-scp-*.png")
if err != nil {
return fmt.Errorf("ssh(kerberos): create temp: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("ssh(kerberos): write temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("ssh(kerberos): close temp: %w", err)
}
if err := os.Chmod(tmpPath, mode.Perm()); err != nil {
sshLog().Debug("kerberos uploader chmod temp failed", "err", err)
}
// 3. scp the temp file to "user@host:cleaned" (cleaned is relative to
// $HOME, which scp interprets correctly for a remote login shell).
remoteTarget := fmt.Sprintf("%s@%s:%s", u.cfg.User, u.cfg.Host, cleaned)
args := append(u.commonSCPArgs(), tmpPath, remoteTarget)
if err := u.runCommand(ctx, systemSCPPath, args); err != nil {
sshLog().Error("kerberos uploader scp failed",
"remote_path", cleaned, "elapsed", time.Since(start), "err", err)
return err
}
sshLog().Info("kerberos uploader done",
"remote_path", cleaned, "size", len(data), "total_elapsed", time.Since(start))
return nil
}
// runRemoteMkdir runs `mkdir -p ~/<dir>` on the remote host via system ssh.
func (u *KerberosUploader) runRemoteMkdir(ctx context.Context, dir string) error {
remote := fmt.Sprintf("%s@%s", u.cfg.User, u.cfg.Host)
// Quote the directory so spaces / metacharacters cannot break the shell
// command. dir is already normalised (no leading ~ or /).
cmd := "mkdir -p " + shellQuote("./"+dir)
args := append(u.commonSSHArgs(), remote, cmd)
return u.runCommand(ctx, systemSSHPath, args)
}
// commonSSHArgs builds the shared option set that enables GSSAPI auth and
// disables interactive prompts (we want a hard failure, never a hang waiting
// for a password the GUI cannot answer).
func (u *KerberosUploader) commonSSHArgs() []string {
port := u.cfg.Port
if port <= 0 {
port = 22
}
timeout := u.cfg.ConnectTimeoutSecs
if timeout <= 0 {
timeout = 10
}
args := []string{
"-p", strconv.Itoa(port),
"-o", "GSSAPIAuthentication=yes",
"-o", "GSSAPIDelegateCredentials=no",
"-o", "PreferredAuthentications=gssapi-with-mic,gssapi-keyex",
"-o", "PubkeyAuthentication=no",
"-o", "PasswordAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=" + strconv.Itoa(timeout),
}
args = append(args, u.hostKeyArgs()...)
return args
}
// commonSCPArgs mirrors commonSSHArgs but uses scp's uppercase -P port flag.
func (u *KerberosUploader) commonSCPArgs() []string {
port := u.cfg.Port
if port <= 0 {
port = 22
}
timeout := u.cfg.ConnectTimeoutSecs
if timeout <= 0 {
timeout = 10
}
args := []string{
"-P", strconv.Itoa(port),
"-o", "GSSAPIAuthentication=yes",
"-o", "GSSAPIDelegateCredentials=no",
"-o", "PreferredAuthentications=gssapi-with-mic,gssapi-keyex",
"-o", "PubkeyAuthentication=no",
"-o", "PasswordAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=" + strconv.Itoa(timeout),
}
args = append(args, u.hostKeyArgs()...)
return args
}
// hostKeyArgs maps StrictHostKey onto OpenSSH's StrictHostKeyChecking option,
// keeping behaviour consistent with the in-process client.
func (u *KerberosUploader) hostKeyArgs() []string {
if u.cfg.StrictHostKey {
if u.cfg.KnownHostsPath != "" {
return []string{
"-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=" + u.cfg.KnownHostsPath,
}
}
return []string{"-o", "StrictHostKeyChecking=yes"}
}
// Non-strict: accept new keys automatically but still record them. This
// mirrors the in-process InsecureIgnoreHostKey trade-off the UI warns of.
return []string{"-o", "StrictHostKeyChecking=accept-new"}
}
// runCommand executes the given binary, capturing combined stderr so a
// failure surfaces the remote/OpenSSH diagnostic instead of a bare exit code.
func (u *KerberosUploader) runCommand(ctx context.Context, bin string, args []string) error {
cmd := exec.CommandContext(ctx, bin, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
sshLog().Debug("kerberos exec", "bin", bin, "args", strings.Join(args, " "))
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("%s failed: %s", path.Base(bin), msg)
}
return nil
}
// TestKerberosConnection probes the remote host with system ssh, surfacing a
// clear hint when no valid ticket is present.
func TestKerberosConnection(ctx context.Context, cfg domain.SSHConfig) error {
sshLog().Info("kerberos test connection start",
"host", cfg.Host, "user", cfg.User, "port", cfg.Port)
u := &KerberosUploader{cfg: cfg}
remote := fmt.Sprintf("%s@%s", cfg.User, cfg.Host)
args := append(u.commonSSHArgs(), remote, "true")
if err := u.runCommand(ctx, systemSSHPath, args); err != nil {
// Detect the most common cause: no/expired Kerberos ticket.
if !hasKerberosTicket(ctx) {
return fmt.Errorf("no valid Kerberos ticket found — run `kinit %s@BYTEDANCE.COM` first (%w)", cfg.User, err)
}
return err
}
sshLog().Info("kerberos test connection ok")
return nil
}
// hasKerberosTicket reports whether `klist -s` indicates a valid ticket.
// `klist -s` exits 0 when a valid TGT exists, non-zero otherwise.
func hasKerberosTicket(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "/usr/bin/klist", "-s")
return cmd.Run() == nil
}
+59
View File
@@ -0,0 +1,59 @@
// uploader.go — small adapter that satisfies application.SSHUploader by
// dialing a one-shot connection per upload. This keeps the application
// layer free of any direct dependency on golang.org/x/crypto/ssh while
// still exposing a clean, unit-testable contract.
package ssh
import (
"context"
"os"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// Uploader is the production implementation of application.SSHUploader.
//
// We dial fresh per-upload because:
// - Captures happen sparsely (a few per minute at peak), so the cost of
// a TCP+SSH handshake is dwarfed by user think-time.
// - Holding a long-lived connection would force us to add keepalives,
// reconnection logic, and config-change invalidation — not worth it
// for the current usage profile.
type Uploader struct {
cfg domain.SSHConfig
}
// NewUploader returns a ready-to-use uploader. The configuration is
// captured by value so a simultaneous in-flight upload cannot be re-
// targeted by a settings save.
func NewUploader(cfg domain.SSHConfig) *Uploader {
return &Uploader{cfg: cfg}
}
// Upload satisfies application.SSHUploader. We log entry/exit at INFO so
// the operator sees a single line per upload in the normal happy path,
// and ERROR with elapsed time when something fails.
func (u *Uploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
start := time.Now()
sshLog().Info("uploader.Upload start",
"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
"remote_path", remoteRelPath, "size", len(data))
client, err := Dial(ctx, u.cfg)
if err != nil {
sshLog().Error("uploader.Upload dial failed",
"host", u.cfg.Host, "elapsed", time.Since(start), "err", err)
return err
}
defer client.Close()
if err := client.CopyFile(ctx, remoteRelPath, data, mode); err != nil {
sshLog().Error("uploader.Upload copy failed",
"remote_path", remoteRelPath, "elapsed", time.Since(start), "err", err)
return err
}
sshLog().Info("uploader.Upload done",
"remote_path", remoteRelPath, "size", len(data),
"total_elapsed", time.Since(start))
return nil
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,62 @@
//go:build darwin
package tray
/*
#cgo darwin LDFLAGS: -framework Foundation
#include <dispatch/dispatch.h>
// runOnMainQueue submits the Go-side callback (identified by handle) to the
// main dispatch queue. We use dispatch_async so the caller — typically a
// Wails OnStartup goroutine — does not block the cocoa runloop while waiting
// for the main thread to drain.
extern void trayDispatchMainCallback(unsigned long handle);
static void runOnMainQueue(unsigned long handle) {
dispatch_async(dispatch_get_main_queue(), ^{
trayDispatchMainCallback(handle);
});
}
*/
import "C"
import (
"sync"
"sync/atomic"
)
// dispatchRegistry stores Go closures keyed by an integer handle so that the
// C side can call back into Go without leaking unsafe.Pointer-cast function
// pointers (which CGo disallows).
var (
dispatchMu sync.Mutex
dispatchTable = map[uint64]func(){}
dispatchNextID uint64
)
// dispatchOnMain schedules fn to run on the macOS main thread. This is the
// Cocoa contract for any API that touches NSWindow / NSStatusBar — calling
// them from a goroutine triggers the "should only be instantiated on the
// main thread" assertion.
func dispatchOnMain(fn func()) {
if fn == nil {
return
}
id := atomic.AddUint64(&dispatchNextID, 1)
dispatchMu.Lock()
dispatchTable[id] = fn
dispatchMu.Unlock()
C.runOnMainQueue(C.ulong(id))
}
//export trayDispatchMainCallback
func trayDispatchMainCallback(handle C.ulong) {
id := uint64(handle)
dispatchMu.Lock()
fn := dispatchTable[id]
delete(dispatchTable, id)
dispatchMu.Unlock()
if fn != nil {
fn()
}
}
@@ -0,0 +1,16 @@
//go:build !darwin
package tray
// dispatchOnMain is a no-op on non-darwin platforms because:
// - On Linux (GTK/AppIndicator), systray.Run / nativeStart can be invoked
// from any goroutine; the GTK runloop is started internally.
// - On Windows, status icons are tied to a hidden window owned by the
// systray library itself, also independent of the Go main goroutine.
// Wrapping the call in dispatchOnMain on these platforms would just add a
// pointless layer of indirection.
func dispatchOnMain(fn func()) {
if fn != nil {
fn()
}
}
@@ -0,0 +1,16 @@
//go:build darwin
package tray
import _ "embed"
// templateIconBytes embeds the macOS template icon as a multi-resolution TIFF
// generated from the 1x and 2x source PNGs in assets/.
//
//go:embed assets/statusbarTemplate.tiff
var templateIconBytes []byte
// regularIconBytes keeps a PNG fallback for APIs that expect a regular raster icon.
//
//go:embed assets/statusbarTemplate.png
var regularIconBytes []byte
@@ -0,0 +1,13 @@
//go:build !darwin
package tray
import _ "embed"
// Keep a regular PNG for non-macOS builds where template icons are unsupported.
//
//go:embed assets/statusbarTemplate.png
var templateIconBytes []byte
//go:embed assets/statusbarTemplate.png
var regularIconBytes []byte
+84
View File
@@ -0,0 +1,84 @@
// Package tray runs the menu-bar agent UI for SnapGo on macOS / Linux /
// Windows. It is intentionally decoupled from the main App struct: the App
// supplies a small set of callbacks (Capture / Settings / Quit) so that this
// package never imports the application service stack.
//
// We use fyne.io/systray (a fork of getlantern/systray) because it is
// designed to coexist with other GUI runloops — important when the host
// process is also driving a Wails / Cocoa main loop.
package tray
import (
"fyne.io/systray"
)
// Callbacks bundles the menu actions the host application wants to expose.
// Splitting these into a struct (rather than 3 positional arguments) keeps
// future extension cheap — we can add e.g. OnCheckForUpdates without
// touching the call sites.
type Callbacks struct {
OnCapture func()
OnSettings func()
OnQuit func()
}
// Start installs the menu-bar status item and returns immediately. The
// fyne.io/systray fork exposes RunWithExternalLoop precisely for cases like
// ours where the host process already owns the main thread (Wails).
//
// The returned `stop` function tears down the status item; call it from the
// host's shutdown path.
func Start(cbs Callbacks) (start, stop func()) {
onReady := func() {
systray.SetTemplateIcon(templateIconBytes, regularIconBytes)
systray.SetTooltip("SnapGo")
mCapture := systray.AddMenuItem("Capture screenshot", "Take a region screenshot")
mSettings := systray.AddMenuItem("Settings…", "Open settings window")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit SnapGo", "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
// up the UI thread.
go func() {
for {
select {
case <-mCapture.ClickedCh:
if cbs.OnCapture != nil {
cbs.OnCapture()
}
case <-mSettings.ClickedCh:
if cbs.OnSettings != nil {
cbs.OnSettings()
}
case <-mQuit.ClickedCh:
if cbs.OnQuit != nil {
cbs.OnQuit()
}
systray.Quit()
return
}
}
}()
}
onExit := func() { /* nothing to clean up */ }
// RunWithExternalLoop registers the status item without blocking the
// caller — the returned `start` should be invoked once the host's
// runloop is up so the icon appears. fyne.io/systray returns a
// (start, end) pair; we delegate `end` to systray.Quit for symmetry.
rawStart, _ := systray.RunWithExternalLoop(onReady, onExit)
// IMPORTANT: on macOS, `nativeStart` ends up calling
// [NSStatusBar systemStatusBar] -> [-NSStatusBar _statusItemWithLength:...]
// which constructs an NSWindow. Cocoa hard-asserts that NSWindow can
// only be instantiated on the main thread. Wails v2 invokes OnStartup
// from a worker goroutine, so calling rawStart() directly from there
// crashes with NSInternalInconsistencyException. We wrap it in
// dispatchOnMain so the call is forwarded to the main dispatch queue.
start = func() { dispatchOnMain(rawStart) }
stop = systray.Quit
return start, stop
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"context"
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
"github.com/mmmy/snapgo/internal/infrastructure/logging"
"github.com/mmmy/snapgo/internal/infrastructure/tray"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// Init logging FIRST so the rest of startup (config load, hotkey
// registration, tray bootstrap) is captured to ~/Library/Logs/SnapGo
// even when the binary was launched via `open` and stderr is /dev/null.
closeLog := logging.Init()
defer closeLog()
app := NewApp()
// Wire up the menu-bar agent. Start() returns a `start` thunk that we
// invoke from OnStartup so the icon appears AFTER Wails has spun up the
// Cocoa runloop, and a `stop` thunk we call on shutdown.
startTray, stopTray := tray.Start(tray.Callbacks{
OnCapture: func() { app.CaptureNow() },
OnSettings: func() { app.ShowWindow() },
OnQuit: func() { app.QuitApp() },
})
err := wails.Run(&options.App{
Title: "SnapGo",
Width: 1080,
Height: 720,
MinWidth: 720,
MinHeight: 520,
AssetServer: &assetserver.Options{
Assets: assets,
},
// Keep the standard macOS title bar for the Settings window. The
// capture UI uses a separate native NSPanel on macOS, so the main
// Wails window no longer needs to be frameless.
Frameless: false,
StartHidden: true,
HideWindowOnClose: true,
BackgroundColour: &options.RGBA{R: 0, G: 0, B: 0, A: 0},
OnStartup: func(ctx context.Context) {
app.startup(ctx)
installActivationPolicyHooks()
hideDockIcon()
startTray()
},
OnBeforeClose: func(ctx context.Context) (prevent bool) {
hideDockIcon()
return false
},
OnShutdown: func(ctx context.Context) {
stopTray()
app.shutdown(ctx)
},
Bind: []interface{}{
app,
},
Mac: &mac.Options{
// The capture overlay is native AppKit on macOS; the Wails
// Settings window should behave like a normal macOS window.
WebviewIsTransparent: true,
WindowIsTranslucent: true,
About: &mac.AboutInfo{
Title: "SnapGo",
Message: "Cross-platform screenshot tool with one-click upload to S3.",
},
},
})
if err != nil {
println("Error:", err.Error())
}
// Defensive: ensure the tray is torn down even if Wails exited via an
// error path that bypassed OnShutdown.
stopTray()
}
+84
View File
@@ -0,0 +1,84 @@
//go:build darwin
package main
import "C"
func nativeCaptureResult(x, y, w, h C.int, annotationsJSON *C.char) CaptureResult {
rawAnnotations := C.GoString(annotationsJSON)
return CaptureResult{
Rect: RegionRect{
X: int(x),
Y: int(y),
W: int(w),
H: int(h),
},
Annotations: parseNativeAnnotations(rawAnnotations),
}
}
func consumeNativeOverlayApp() *App {
nativeOverlayState.Lock()
app := nativeOverlayState.app
nativeOverlayState.app = nil
nativeOverlayState.Unlock()
return app
}
//export nativeOverlayConfirm
func nativeOverlayConfirm(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.ConfirmNativeRegion(result)
}()
}
//export nativeOverlayCopy
func nativeOverlayCopy(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.CopyNativeRegionImage(result)
}()
}
//export nativeOverlaySave
func nativeOverlaySave(x, y, w, h C.int, annotationsJSON *C.char, dir *C.char) {
app := consumeNativeOverlayApp()
if app == nil {
return
}
result := nativeCaptureResult(x, y, w, h, annotationsJSON)
saveDir := C.GoString(dir)
go func() {
_, _ = app.SaveNativeRegionImageToDir(result, saveDir)
}()
}
//export nativeOverlaySaveRemote
func nativeOverlaySaveRemote(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.SaveNativeRegionToRemote(result)
}()
}
//export nativeOverlayCancel
func nativeOverlayCancel() {
app := consumeNativeOverlayApp()
if app == nil {
return
}
go app.CancelNativeRegion()
}
+891
View File
@@ -0,0 +1,891 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics -framework QuartzCore
#include <math.h>
#include <dispatch/dispatch.h>
#import <objc/runtime.h>
#import <AppKit/AppKit.h>
#import <QuartzCore/QuartzCore.h>
extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotationsJSON);
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 nativeOverlayCancel(void);
static NSWindow *nativeOverlayWindow = nil;
static id nativeOverlayKeyMonitor = nil;
@interface SnipNativeOverlayPanel : NSPanel
@end
@implementation SnipNativeOverlayPanel
- (BOOL)canBecomeKeyWindow { return YES; }
- (BOOL)canBecomeMainWindow { return YES; }
@end
// SnipHoverButton — NSButton subclass that animates its tint color on hover.
//
// Design rationale:
// - NSButton has no built-in hover state; we install an NSTrackingArea that
// tracks mouseEntered/mouseExited regardless of focus state.
// - When the cursor enters, the content tint color animates from `baseColor`
// to `hoverColor` over 0.2s using NSAnimationContext, matching the spec.
// When the cursor leaves, the color reverts immediately (no animation).
// - `baseColor`/`hoverColor` are configurable per-instance so each action
// button can keep a shared white default but expose its own hover hue
// (cancel = red, others = blue).
@interface SnipHoverButton : NSButton
@property(strong) NSColor *baseColor;
@property(strong) NSColor *hoverColor;
@property(strong) NSTrackingArea *hoverTrackingArea;
@end
@implementation SnipHoverButton
- (void)updateTrackingAreas {
[super updateTrackingAreas];
if (_hoverTrackingArea) {
[self removeTrackingArea:_hoverTrackingArea];
}
NSTrackingAreaOptions opts = NSTrackingMouseEnteredAndExited |
NSTrackingActiveAlways |
NSTrackingInVisibleRect;
_hoverTrackingArea = [[NSTrackingArea alloc] initWithRect:NSZeroRect
options:opts
owner:self
userInfo:nil];
[self addTrackingArea:_hoverTrackingArea];
}
- (void)mouseEntered:(NSEvent *)event {
if (@available(macOS 10.14, *)) {
NSColor *target = _hoverColor ?: [NSColor whiteColor];
// Animate the tint color transition over 0.2s for a soft fade-in.
[NSAnimationContext runAnimationGroup:^(NSAnimationContext *ctx) {
[ctx setDuration:0.2];
[ctx setTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]];
[[self animator] setContentTintColor:target];
} completionHandler:nil];
}
}
- (void)mouseExited:(NSEvent *)event {
if (@available(macOS 10.14, *)) {
NSColor *base = _baseColor ?: [NSColor whiteColor];
// Snap back to the base color instantly (no animation) per spec.
[self setContentTintColor:base];
}
}
@end
@interface SnipNativeOverlayView : NSView
@property BOOL hasSelection;
@property BOOL creating;
@property BOOL moving;
@property BOOL resizing;
@property BOOL annotating;
@property NSRect selection;
@property NSPoint anchor;
@property NSPoint moveOffset;
@property NSRect resizeStart;
@property NSString *resizeHandle;
@property NSString *activeTool;
@property(strong) NSColor *activeColor;
@property(strong) NSMutableArray<NSDictionary *> *annotations;
@property(strong) NSMutableDictionary *draftAnnotation;
@property(strong) NSButton *cancelButton;
// Use `clipboardButton` (not `copyButton`) to avoid ARC's "copy" method
// family ownership rule which would otherwise treat the synthesized getter
// 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 *uploadButton;
@property(strong) NSButton *penButton;
@property(strong) NSButton *rectButton;
@property(strong) NSButton *ellipseButton;
@property(strong) NSButton *colorButton;
@property(strong) NSButton *undoButton;
@property(strong) NSView *paletteView;
@property(strong) NSView *actionToolbarBg;
@property(strong) NSTextField *sizeLabel;
@property(strong) NSTextField *hintLabel;
- (void)syncControls;
- (void)styleControls;
- (void)confirmSelection;
- (void)copySelection;
- (void)saveSelection;
- (void)saveRemoteSelection;
- (void)cancelSelection;
@end
@implementation SnipNativeOverlayView
- (BOOL)isFlipped { return YES; }
- (BOOL)acceptsFirstResponder { return YES; }
- (instancetype)initWithFrame:(NSRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self setWantsLayer:YES];
[[self layer] setBackgroundColor:[[NSColor clearColor] CGColor]];
_activeTool = nil;
_activeColor = [NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0];
_annotations = [NSMutableArray array];
// Use SnipHoverButton for the 5 action buttons so we get hover-color
// animation. Annotation buttons stay as plain NSButton because they
// already have explicit on/off "active" styling.
_cancelButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(cancelSelection)];
_clipboardButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(copySelection)];
_saveButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveSelection)];
_saveRemoteButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveRemoteSelection)];
_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.
[_cancelButton setToolTip:@"取消截图"];
[_clipboardButton setToolTip:@"复制图片"];
[_saveButton setToolTip:@"保存本地"];
[_saveRemoteButton setToolTip:@"保存远端"];
[_uploadButton setToolTip:@"上传云端"];
_penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)];
_rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)];
_ellipseButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectEllipse)];
_colorButton = [NSButton buttonWithTitle:@"" target:self action:@selector(togglePalette)];
_undoButton = [NSButton buttonWithTitle:@"" target:self action:@selector(undoAnnotation)];
_paletteView = [[NSView alloc] initWithFrame:NSZeroRect];
// Dark rounded background container behind the action icon buttons,
// mirroring the look of `.action-toolbar` in the Vue overlay.
_actionToolbarBg = [[NSView alloc] initWithFrame:NSZeroRect];
[_actionToolbarBg setWantsLayer:YES];
[[_actionToolbarBg layer] setCornerRadius:8];
[[_actionToolbarBg layer] setBackgroundColor:[[NSColor colorWithCalibratedWhite:0.11 alpha:0.94] CGColor]];
_sizeLabel = [NSTextField labelWithString:@""];
_hintLabel = [NSTextField labelWithString:@"Drag to select an area · Esc to cancel"];
[self styleControls];
// 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]) {
[self addSubview:view];
}
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _actionToolbarBg]) {
[view setHidden:YES];
}
[_sizeLabel setHidden:YES];
[_sizeLabel setTextColor:[NSColor whiteColor]];
[_sizeLabel setBackgroundColor:[NSColor colorWithCalibratedWhite:0.08 alpha:0.78]];
[_sizeLabel setDrawsBackground:YES];
[_sizeLabel setBezeled:NO];
[_sizeLabel setAlignment:NSTextAlignmentCenter];
[_hintLabel setTextColor:[NSColor whiteColor]];
[_hintLabel setBackgroundColor:[NSColor colorWithCalibratedWhite:0.0 alpha:0.5]];
[_hintLabel setDrawsBackground:YES];
[_hintLabel setBezeled:NO];
[_hintLabel setAlignment:NSTextAlignmentCenter];
[_hintLabel setFrame:NSMakeRect((frame.size.width - 320) / 2, 24, 320, 24)];
}
return self;
}
- (NSString *)hexForColor:(NSColor *)color {
NSColor *rgb = [color colorUsingColorSpace:[NSColorSpace sRGBColorSpace]];
NSInteger r = (NSInteger)llround([rgb redComponent] * 255.0);
NSInteger g = (NSInteger)llround([rgb greenComponent] * 255.0);
NSInteger b = (NSInteger)llround([rgb blueComponent] * 255.0);
return [NSString stringWithFormat:@"#%02lx%02lx%02lx", (long)r, (long)g, (long)b];
}
- (NSImage *)iconFromSVG:(NSString *)svg {
NSData *data = [svg dataUsingEncoding:NSUTF8StringEncoding];
NSImage *image = [[NSImage alloc] initWithData:data];
[image setTemplate:YES];
return image;
}
- (void)styleButton:(NSButton *)button background:(NSColor *)background foreground:(NSColor *)foreground {
[button setBordered:NO];
[button setBezelStyle:NSBezelStyleRegularSquare];
[button setWantsLayer:YES];
[[button layer] setCornerRadius:5];
[[button layer] setBackgroundColor:[background CGColor]];
NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:[button title]];
[title addAttribute:NSForegroundColorAttributeName value:foreground range:NSMakeRange(0, [title length])];
[title addAttribute:NSFontAttributeName value:[NSFont systemFontOfSize:12 weight:NSFontWeightMedium] range:NSMakeRange(0, [title length])];
[button setAttributedTitle:title];
}
- (void)styleIconButton:(NSButton *)button image:(NSImage *)image {
[button setBordered:NO];
[button setBezelStyle:NSBezelStyleRegularSquare];
[button setImagePosition:NSImageOnly];
[button setImageScaling:NSImageScaleProportionallyDown];
if (@available(macOS 10.14, *)) {
[button setContentTintColor:[NSColor whiteColor]];
}
[button setImage:image];
[button setWantsLayer:YES];
[[button layer] setCornerRadius:5];
[[button layer] setBackgroundColor:[[NSColor clearColor] CGColor]];
}
- (void)styleControls {
// Cancel / Copy / Save / Save-remote / Upload — square icon buttons
// rendered from the user-supplied SVG assets. All five default to white
// and animate to a per-action accent color on hover (cancel = red,
// others = blue). The base "white default" replaces the previous blue
// upload tint so the toolbar reads as a uniform icon row.
NSImage *cancelIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M512 85.333333c235.648 0 426.666667 191.018667 426.666667 426.666667s-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512 276.352 85.333333 512 85.333333zM170.666667 512a341.333333 341.333333 0 0 0 550.613333 269.653333L242.304 302.762667A339.84 339.84 0 0 0 170.666667 512z m341.333333-341.333333c-78.848 0-151.466667 26.752-209.28 71.68l478.976 478.933333A341.333333 341.333333 0 0 0 512 170.666667z' fill='white'/></svg>"];
NSImage *copyIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M768 256a85.333333 85.333333 0 0 1 85.333333 85.333333v512a85.333333 85.333333 0 0 1-85.333333 85.333334h-341.333333a85.333333 85.333333 0 0 1-85.333334-85.333334V341.333333a85.333333 85.333333 0 0 1 85.333334-85.333333h341.333333z m0 85.333333h-341.333333v512h341.333333V341.333333z m-128-256a42.666667 42.666667 0 0 1 42.666667 42.666667l-0.042667 42.666667H256l-0.042667 597.333333H213.333333a42.666667 42.666667 0 0 1-42.666666-42.666667V170.666667a85.333333 85.333333 0 0 1 85.333333-85.333334h384z' fill='white'/></svg>"];
NSImage *saveIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M896 810.666667a85.333333 85.333333 0 0 1-85.333333 85.333333H213.333333a85.333333 85.333333 0 0 1-85.333333-85.333333V213.333333a85.333333 85.333333 0 0 1 85.333333-85.333333h250.368a85.333333 85.333333 0 0 1 73.173334 41.429333L563.2 213.333333H810.666667a85.333333 85.333333 0 0 1 85.333333 85.333334v512z m-85.333333-341.333334H213.333333v341.333334h597.333334v-341.333334z m-346.965334-256H213.333333v170.666667h597.333334V298.666667h-271.616a42.666667 42.666667 0 0 1-36.608-20.736L463.701333 213.333333z' fill='white'/></svg>"];
// 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>"];
// 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>"];
[self styleIconButton:_cancelButton image:cancelIcon];
[self styleIconButton:_clipboardButton image:copyIcon];
[self styleIconButton:_saveButton image:saveIcon];
[self styleIconButton:_saveRemoteButton image:saveRemoteIcon];
[self styleIconButton:_uploadButton image:uploadIcon];
// Configure hover colors for the 5 action buttons. White is the shared
// base; cancel turns red on hover and the rest turn blue, providing a
// glance-able cue for destructive vs. constructive actions.
NSColor *baseWhite = [NSColor whiteColor];
NSColor *hoverRed = [NSColor colorWithCalibratedRed:248.0/255.0 green:113.0/255.0 blue:113.0/255.0 alpha:1.0];
NSColor *hoverBlue = [NSColor colorWithCalibratedRed:96.0/255.0 green:165.0/255.0 blue:250.0/255.0 alpha:1.0];
SnipHoverButton *cancelHB = (SnipHoverButton *)_cancelButton;
SnipHoverButton *clipboardHB = (SnipHoverButton *)_clipboardButton;
SnipHoverButton *saveHB = (SnipHoverButton *)_saveButton;
SnipHoverButton *saveRemoteHB = (SnipHoverButton *)_saveRemoteButton;
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;
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>"]];
[self styleIconButton:_rectButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M96 96h832v832H96V96z m32 32v768h768V128H128z' fill='white'/></svg>"]];
[self styleIconButton:_ellipseButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M512 928c229.76 0 416-186.24 416-416S741.76 96 512 96 96 282.24 96 512s186.24 416 416 416z m0-32C299.936 896 128 724.064 128 512S299.936 128 512 128s384 171.936 384 384-171.936 384-384 384z' fill='white'/></svg>"]];
[self styleIconButton:_colorButton image:nil];
[self styleIconButton:_undoButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><g transform='translate(1024 0) scale(-1 1)'><path d='M838.976 288l-169.344-162.56a16 16 0 1 1 22.144-23.04l213.632 204.992-213.312 215.84a16 16 0 0 1-22.784-22.464L847.936 320H454.56c-164.192 0-297.28 128.96-297.28 288s133.088 288 297.28 288H704v32h-242.784C266.88 928 128 784.736 128 608S266.88 288 461.248 288h377.728z' fill='white'/></g></svg>"]];
[_paletteView setWantsLayer:YES];
[[_paletteView layer] setCornerRadius:8];
[[_paletteView layer] setBackgroundColor:[[NSColor colorWithCalibratedWhite:0.11 alpha:0.96] CGColor]];
}
- (void)drawRect:(NSRect)dirtyRect {
[[NSColor colorWithCalibratedWhite:0.0 alpha:0.48] setFill];
if (_hasSelection) {
NSRect top = NSMakeRect(0, 0, self.bounds.size.width, _selection.origin.y);
NSRect left = NSMakeRect(0, _selection.origin.y, _selection.origin.x, _selection.size.height);
NSRect right = NSMakeRect(NSMaxX(_selection), _selection.origin.y, self.bounds.size.width - NSMaxX(_selection), _selection.size.height);
NSRect bottom = NSMakeRect(0, NSMaxY(_selection), self.bounds.size.width, self.bounds.size.height - NSMaxY(_selection));
NSRectFill(top);
NSRectFill(left);
NSRectFill(right);
NSRectFill(bottom);
NSBezierPath *border = [NSBezierPath bezierPathWithRect:NSInsetRect(_selection, 0.75, 0.75)];
[border setLineWidth:1.5];
[[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0] setStroke];
[border stroke];
CGFloat corner = 8;
NSBezierPath *corners = [NSBezierPath bezierPath];
[corners moveToPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection) + corner)];
[corners lineToPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection))];
[corners lineToPoint:NSMakePoint(NSMinX(_selection) + corner, NSMinY(_selection))];
[corners moveToPoint:NSMakePoint(NSMaxX(_selection) - corner, NSMinY(_selection))];
[corners lineToPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection))];
[corners lineToPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection) + corner)];
[corners moveToPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection) - corner)];
[corners lineToPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection))];
[corners lineToPoint:NSMakePoint(NSMaxX(_selection) - corner, NSMaxY(_selection))];
[corners moveToPoint:NSMakePoint(NSMinX(_selection) + corner, NSMaxY(_selection))];
[corners lineToPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection))];
[corners lineToPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection) - corner)];
[corners setLineWidth:3];
[corners stroke];
[self drawAnnotations];
[self drawResizeHandles];
} else {
NSRectFill(self.bounds);
}
}
- (void)drawResizeHandles {
NSArray *points = @[
[NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection))],
[NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMinY(_selection))],
[NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection))],
[NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMidY(_selection))],
[NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection))],
[NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMaxY(_selection))],
[NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection))],
[NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMidY(_selection))]
];
[[NSColor whiteColor] setStroke];
[[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0] setFill];
for (NSValue *value in points) {
NSPoint p = [value pointValue];
NSRect handleRect = NSMakeRect(p.x - 5, p.y - 5, 10, 10);
NSBezierPath *path = [NSBezierPath bezierPathWithRect:handleRect];
[path fill];
[path stroke];
}
}
- (void)drawAnnotations {
NSMutableArray *items = [NSMutableArray arrayWithArray:_annotations];
if (_draftAnnotation != nil) {
[items addObject:_draftAnnotation];
}
for (NSDictionary *item in items) {
NSString *tool = item[@"tool"];
NSColor *color = item[@"nsColor"];
NSArray *points = item[@"points"];
if (points.count == 0) {
continue;
}
[color setStroke];
NSBezierPath *path = [NSBezierPath bezierPath];
[path setLineWidth:3];
[path setLineCapStyle:NSLineCapStyleRound];
[path setLineJoinStyle:NSLineJoinStyleRound];
if ([tool isEqualToString:@"pen"]) {
NSPoint first = [points[0] pointValue];
[path moveToPoint:NSMakePoint(_selection.origin.x + first.x, _selection.origin.y + first.y)];
for (NSUInteger i = 1; i < points.count; i++) {
NSPoint point = [points[i] pointValue];
[path lineToPoint:NSMakePoint(_selection.origin.x + point.x, _selection.origin.y + point.y)];
}
[path stroke];
} else if (points.count >= 2) {
NSPoint a = [points[0] pointValue];
NSPoint b = [[points lastObject] pointValue];
NSRect r = NSMakeRect(
_selection.origin.x + MIN(a.x, b.x),
_selection.origin.y + MIN(a.y, b.y),
fabs(b.x - a.x),
fabs(b.y - a.y));
if ([tool isEqualToString:@"rect"]) {
NSBezierPath *rectPath = [NSBezierPath bezierPathWithRect:r];
[rectPath setLineWidth:3];
[rectPath stroke];
} else if ([tool isEqualToString:@"ellipse"]) {
NSBezierPath *ellipsePath = [NSBezierPath bezierPathWithOvalInRect:r];
[ellipsePath setLineWidth:3];
[ellipsePath stroke];
}
}
}
}
- (NSString *)resizeHandleAtPoint:(NSPoint)p {
if (!_hasSelection) {
return nil;
}
CGFloat t = 9;
NSDictionary *handles = @{
@"nw": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMinY(_selection))],
@"n": [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMinY(_selection))],
@"ne": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMinY(_selection))],
@"e": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMidY(_selection))],
@"se": [NSValue valueWithPoint:NSMakePoint(NSMaxX(_selection), NSMaxY(_selection))],
@"s": [NSValue valueWithPoint:NSMakePoint(NSMidX(_selection), NSMaxY(_selection))],
@"sw": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMaxY(_selection))],
@"w": [NSValue valueWithPoint:NSMakePoint(NSMinX(_selection), NSMidY(_selection))]
};
for (NSString *key in handles) {
NSPoint hp = [handles[key] pointValue];
if (fabs(p.x - hp.x) <= t && fabs(p.y - hp.y) <= t) {
return key;
}
}
BOOL nearX = p.x >= NSMinX(_selection) - t && p.x <= NSMaxX(_selection) + t;
BOOL nearY = p.y >= NSMinY(_selection) - t && p.y <= NSMaxY(_selection) + t;
if (nearX && fabs(p.y - NSMinY(_selection)) <= t) return @"n";
if (nearX && fabs(p.y - NSMaxY(_selection)) <= t) return @"s";
if (nearY && fabs(p.x - NSMinX(_selection)) <= t) return @"w";
if (nearY && fabs(p.x - NSMaxX(_selection)) <= t) return @"e";
return nil;
}
- (NSPoint)localPoint:(NSPoint)p {
return NSMakePoint(
MAX(0, MIN(p.x - _selection.origin.x, _selection.size.width)),
MAX(0, MIN(p.y - _selection.origin.y, _selection.size.height)));
}
// Translate a selection rectangle expressed in view-local coordinates into
// the global top-left screen coordinate system used by
// `/usr/sbin/screencapture -R`.
//
// Why this is needed:
// - The overlay window is created with the host screen's AppKit frame and
// its flipped content view yields top-left local coordinates relative
// to that screen only.
// - macOS combines all attached displays into a single virtual coordinate
// space whose origin is the top-left corner of the primary display
// (Y grows downward). `screencapture -R` interprets its arguments in
// that space.
// - Without this translation, a selection on a secondary display is sent
// as if it were on the primary, so screencapture crops the wrong area.
//
// Translation steps:
// 1. globalX = screenFrame.origin.x + sel.origin.x
// (NSScreen frame origin.x is already in primary-anchored global X.)
// 2. globalY = (primaryHeight - (screenFrame.origin.y + screenFrame.size.height)) + sel.origin.y
// Converts the screen's AppKit bottom-left origin to a top-left global
// origin, then adds the already-top-left local Y inside the view.
- (NSRect)globalRectForSelection:(NSRect)sel {
NSScreen *screen = [nativeOverlayWindow screen];
if (screen == nil) {
screen = [NSScreen mainScreen];
}
if (screen == nil) {
return sel;
}
NSRect screenFrame = [screen frame];
NSScreen *primary = [[NSScreen screens] firstObject];
CGFloat primaryHeight = primary != nil ? [primary frame].size.height : screenFrame.size.height;
CGFloat globalX = screenFrame.origin.x + sel.origin.x;
CGFloat globalY = (primaryHeight - (screenFrame.origin.y + screenFrame.size.height)) + sel.origin.y;
return NSMakeRect(globalX, globalY, sel.size.width, sel.size.height);
}
- (void)mouseDown:(NSEvent *)event {
NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil];
if ([event clickCount] == 2 && _hasSelection && NSPointInRect(p, _selection)) {
[self copySelection];
return;
}
NSString *handle = [self resizeHandleAtPoint:p];
if (handle != nil) {
_resizing = YES;
_creating = NO;
_moving = NO;
_annotating = NO;
_resizeHandle = handle;
_resizeStart = _selection;
} else if (_hasSelection && NSPointInRect(p, _selection) && _activeTool != nil) {
_annotating = YES;
_creating = NO;
_moving = NO;
_resizing = NO;
NSPoint local = [self localPoint:p];
_draftAnnotation = [@{
@"tool": _activeTool,
@"color": [self hexForColor:_activeColor],
@"nsColor": _activeColor,
@"points": [NSMutableArray arrayWithObject:[NSValue valueWithPoint:local]]
} mutableCopy];
} else if (_hasSelection && NSPointInRect(p, _selection)) {
_moving = YES;
_creating = NO;
_resizing = NO;
_annotating = NO;
_moveOffset = NSMakePoint(p.x - _selection.origin.x, p.y - _selection.origin.y);
} else {
_creating = YES;
_moving = NO;
_resizing = NO;
_annotating = NO;
_hasSelection = YES;
_anchor = p;
_selection = NSMakeRect(p.x, p.y, 0, 0);
[_annotations removeAllObjects];
_draftAnnotation = nil;
}
[self syncControls];
}
- (void)mouseDragged:(NSEvent *)event {
NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil];
if (_creating) {
CGFloat x = MIN(_anchor.x, p.x);
CGFloat y = MIN(_anchor.y, p.y);
_selection = NSMakeRect(x, y, fabs(p.x - _anchor.x), fabs(p.y - _anchor.y));
} else if (_moving) {
CGFloat x = p.x - _moveOffset.x;
CGFloat y = p.y - _moveOffset.y;
x = MAX(0, MIN(x, self.bounds.size.width - _selection.size.width));
y = MAX(0, MIN(y, self.bounds.size.height - _selection.size.height));
_selection.origin = NSMakePoint(x, y);
} else if (_resizing) {
CGFloat left = NSMinX(_resizeStart);
CGFloat top = NSMinY(_resizeStart);
CGFloat right = NSMaxX(_resizeStart);
CGFloat bottom = NSMaxY(_resizeStart);
if ([_resizeHandle containsString:@"w"]) left = p.x;
if ([_resizeHandle containsString:@"e"]) right = p.x;
if ([_resizeHandle containsString:@"n"]) top = p.y;
if ([_resizeHandle containsString:@"s"]) bottom = p.y;
left = MAX(0, MIN(left, self.bounds.size.width));
right = MAX(0, MIN(right, self.bounds.size.width));
top = MAX(0, MIN(top, self.bounds.size.height));
bottom = MAX(0, MIN(bottom, self.bounds.size.height));
_selection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top));
} else if (_annotating && _draftAnnotation != nil) {
NSMutableArray *points = _draftAnnotation[@"points"];
NSPoint local = [self localPoint:p];
if ([_activeTool isEqualToString:@"pen"]) {
[points addObject:[NSValue valueWithPoint:local]];
} else {
if (points.count == 1) {
[points addObject:[NSValue valueWithPoint:local]];
} else {
points[1] = [NSValue valueWithPoint:local];
}
}
}
[self syncControls];
[self setNeedsDisplay:YES];
}
- (void)mouseUp:(NSEvent *)event {
_creating = NO;
_moving = NO;
_resizing = NO;
if (_annotating && _draftAnnotation != nil) {
[_annotations addObject:_draftAnnotation];
_draftAnnotation = nil;
}
_annotating = NO;
if (_hasSelection && (_selection.size.width < 4 || _selection.size.height < 4)) {
_hasSelection = NO;
}
[self syncControls];
[self setNeedsDisplay:YES];
}
- (void)keyDown:(NSEvent *)event {
if ([event keyCode] == 53 || [[event charactersIgnoringModifiers] isEqualToString:@"\033"]) {
[self cancelSelection];
} else if (([event keyCode] == 36 || [[event charactersIgnoringModifiers] isEqualToString:@"\r"]) && _hasSelection) {
[self confirmSelection];
} else {
[super keyDown:event];
}
}
- (void)rightMouseDown:(NSEvent *)event {
[self cancelSelection];
}
- (void)syncControls {
BOOL visible = _hasSelection && _selection.size.width >= 4 && _selection.size.height >= 4;
[_cancelButton setHidden:!visible];
[_clipboardButton setHidden:!visible];
[_saveButton setHidden:!visible];
[_saveRemoteButton setHidden:!visible];
[_uploadButton setHidden:!visible];
[_actionToolbarBg setHidden:!visible];
[_penButton setHidden:!visible];
[_rectButton setHidden:!visible];
[_ellipseButton setHidden:!visible];
[_colorButton setHidden:!visible];
[_undoButton setHidden:!visible];
[_sizeLabel setHidden:!visible];
[_hintLabel setHidden:_hasSelection];
if (!visible) {
return;
}
[_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.
CGFloat actionBtnSize = 28;
CGFloat actionGap = 8;
CGFloat actionPad = 4;
NSInteger actionCount = 5;
CGFloat toolbarW = actionPad * 2 + actionBtnSize * actionCount + actionGap * (actionCount - 1);
CGFloat toolbarH = 40;
CGFloat x = _selection.origin.x + _selection.size.width - toolbarW;
CGFloat y = _selection.origin.y + _selection.size.height + 8;
if (y + toolbarH > self.bounds.size.height) {
y = _selection.origin.y + _selection.size.height - toolbarH - 8;
}
x = MAX(0, MIN(x, self.bounds.size.width - toolbarW));
[_actionToolbarBg setFrame:NSMakeRect(x, y, toolbarW, toolbarH)];
CGFloat btnY = y + (toolbarH - actionBtnSize) / 2;
CGFloat baseX = x + actionPad;
[_cancelButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 0, btnY, actionBtnSize, actionBtnSize)];
[_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)];
CGFloat markW = 170;
CGFloat markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - 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)];
[_colorButton setFrame:NSMakeRect(markX + 104, y + 7, 22, 22)];
[_undoButton setFrame:NSMakeRect(markX + 134, y + 4, 28, 28)];
[[_colorButton layer] setBackgroundColor:[_activeColor CGColor]];
[_undoButton setEnabled:_annotations.count > 0];
[_paletteView setFrame:NSMakeRect(markX, MAX(0, y - 78), 150, 70)];
[self updateToolButtonStates];
}
- (void)updateToolButtonStates {
NSDictionary *buttons = @{@"pen": _penButton, @"rect": _rectButton, @"ellipse": _ellipseButton};
for (NSString *tool in buttons) {
NSButton *button = buttons[tool];
NSColor *bg = [tool isEqualToString:_activeTool]
? [NSColor colorWithCalibratedWhite:1.0 alpha:0.14]
: [NSColor clearColor];
[[button layer] setBackgroundColor:[bg CGColor]];
}
}
- (void)toggleTool:(NSString *)tool {
_activeTool = [_activeTool isEqualToString:tool] ? nil : tool;
[self syncControls];
}
- (void)selectPen { [self toggleTool:@"pen"]; }
- (void)selectRect { [self toggleTool:@"rect"]; }
- (void)selectEllipse { [self toggleTool:@"ellipse"]; }
- (void)undoAnnotation {
if (_annotations.count > 0) {
[_annotations removeLastObject];
[self syncControls];
[self setNeedsDisplay:YES];
}
}
- (void)togglePalette {
[_paletteView setHidden:![_paletteView isHidden]];
if (![_paletteView isHidden] && _paletteView.subviews.count == 0) {
NSArray *colors = @[
[NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0],
[NSColor colorWithCalibratedRed:249.0/255.0 green:115.0/255.0 blue:22.0/255.0 alpha:1.0],
[NSColor colorWithCalibratedRed:250.0/255.0 green:204.0/255.0 blue:21.0/255.0 alpha:1.0],
[NSColor colorWithCalibratedRed:34.0/255.0 green:197.0/255.0 blue:94.0/255.0 alpha:1.0],
[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0],
[NSColor colorWithCalibratedRed:139.0/255.0 green:92.0/255.0 blue:246.0/255.0 alpha:1.0],
[NSColor colorWithCalibratedRed:236.0/255.0 green:72.0/255.0 blue:153.0/255.0 alpha:1.0],
[NSColor whiteColor],
[NSColor colorWithCalibratedWhite:0.07 alpha:1.0]
];
for (NSUInteger i = 0; i < colors.count; i++) {
NSButton *button = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPaletteColor:)];
[button setBordered:NO];
[button setWantsLayer:YES];
[[button layer] setCornerRadius:4];
[[button layer] setBackgroundColor:[colors[i] CGColor]];
[button setTag:(NSInteger)i];
[button setFrame:NSMakeRect(8 + (i % 5) * 28, 38 - (i / 5) * 28, 22, 22)];
[_paletteView addSubview:button];
}
objc_setAssociatedObject(_paletteView, "snapgoColors", colors, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
}
- (void)selectPaletteColor:(NSButton *)sender {
NSArray *colors = objc_getAssociatedObject(_paletteView, "snapgoColors");
if (sender.tag >= 0 && sender.tag < (NSInteger)colors.count) {
_activeColor = colors[(NSUInteger)sender.tag];
[_paletteView setHidden:YES];
[self syncControls];
}
}
- (NSString *)annotationsJSON {
NSMutableArray *payload = [NSMutableArray array];
for (NSDictionary *item in _annotations) {
NSMutableArray *points = [NSMutableArray array];
for (NSValue *value in item[@"points"]) {
NSPoint p = [value pointValue];
[points addObject:@{@"x": @(p.x), @"y": @(p.y)}];
}
[payload addObject:@{@"tool": item[@"tool"], @"color": item[@"color"], @"points": points}];
}
NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:nil];
if (data == nil) {
return @"[]";
}
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
}
- (void)closeOverlayWindow {
[nativeOverlayWindow orderOut:nil];
if (nativeOverlayKeyMonitor != nil) {
[NSEvent removeMonitor:nativeOverlayKeyMonitor];
nativeOverlayKeyMonitor = nil;
}
nativeOverlayWindow = nil;
}
- (void)confirmSelection {
if (!_hasSelection) {
return;
}
NSRect r = [self globalRectForSelection:_selection];
[self closeOverlayWindow];
NSString *json = [self annotationsJSON];
nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
}
- (void)copySelection {
if (!_hasSelection) {
return;
}
NSRect r = [self globalRectForSelection:_selection];
[self closeOverlayWindow];
NSString *json = [self annotationsJSON];
nativeOverlayCopy((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
}
- (void)saveSelection {
if (!_hasSelection) {
return;
}
NSRect r = [self globalRectForSelection:_selection];
NSString *json = [self annotationsJSON];
[self closeOverlayWindow];
NSOpenPanel *panel = [NSOpenPanel openPanel];
[panel setTitle:@"Save screenshot to folder"];
[panel setCanChooseFiles:NO];
[panel setCanChooseDirectories:YES];
[panel setAllowsMultipleSelection:NO];
[panel setCanCreateDirectories:YES];
[panel beginWithCompletionHandler:^(NSModalResponse result) {
if (result != NSModalResponseOK || [panel URL] == nil) {
nativeOverlayCancel();
return;
}
NSString *dir = [[panel URL] path];
nativeOverlaySave((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String], [dir UTF8String]);
}];
}
- (void)cancelSelection {
[self closeOverlayWindow];
nativeOverlayCancel();
}
// `saveRemoteSelection` triggers the SCP upload flow. The overlay closes
// immediately so the user can keep working while the transfer runs in the
// background; success / failure surfaces through the regular Toast events.
- (void)saveRemoteSelection {
if (!_hasSelection) {
return;
}
NSRect r = [self globalRectForSelection:_selection];
[self closeOverlayWindow];
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]);
}
@end
// screenContainingCursor returns the NSScreen the cursor is currently on,
// falling back to the main screen and then the first attached screen.
//
// Rationale: when an external display is attached, users expect the capture
// overlay to appear on whichever display they were just working on.
// `[NSEvent mouseLocation]` reports the cursor in the same global Cocoa
// (bottom-left-origin) screen space used by `NSScreen.frame`, so a simple
// `NSPointInRect` test against each screen's frame yields the right one.
// Iteration over `[NSScreen screens]` is cheap (typically <=4 displays).
static NSScreen *snipScreenContainingCursor(void) {
NSPoint cursor = [NSEvent mouseLocation];
for (NSScreen *candidate in [NSScreen screens]) {
if (NSPointInRect(cursor, [candidate frame])) {
return candidate;
}
}
NSScreen *fallback = [NSScreen mainScreen];
if (fallback != nil) {
return fallback;
}
return [[NSScreen screens] firstObject];
}
static void snipShowNativeOverlay(int width, int height) {
dispatch_async(dispatch_get_main_queue(), ^{
NSScreen *screen = snipScreenContainingCursor();
if (screen == nil) {
nativeOverlayCancel();
return;
}
NSRect frame = [screen frame];
[NSApp activateIgnoringOtherApps:YES];
nativeOverlayWindow = [[SnipNativeOverlayPanel alloc]
initWithContentRect:frame
styleMask:NSWindowStyleMaskBorderless
backing:NSBackingStoreBuffered
defer:NO];
[nativeOverlayWindow setOpaque:NO];
[nativeOverlayWindow setBackgroundColor:[NSColor clearColor]];
[nativeOverlayWindow setLevel:NSScreenSaverWindowLevel];
[nativeOverlayWindow setHidesOnDeactivate:NO];
[(NSPanel *)nativeOverlayWindow setFloatingPanel:YES];
[nativeOverlayWindow setCollectionBehavior:
NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorStationary];
[nativeOverlayWindow setIgnoresMouseEvents:NO];
SnipNativeOverlayView *view = [[SnipNativeOverlayView alloc]
initWithFrame:NSMakeRect(0, 0, frame.size.width, frame.size.height)];
[nativeOverlayWindow setContentView:view];
[nativeOverlayWindow makeKeyAndOrderFront:nil];
[nativeOverlayWindow makeFirstResponder:view];
nativeOverlayKeyMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyDown handler:^NSEvent *(NSEvent *event) {
if ([event keyCode] == 53) {
[view cancelSelection];
return nil;
}
if ([event keyCode] == 36 && [view hasSelection]) {
[view confirmSelection];
return nil;
}
return event;
}];
});
}
*/
import "C"
import (
"sync"
"github.com/mmmy/snapgo/internal/infrastructure/display"
)
var nativeOverlayState struct {
sync.Mutex
app *App
}
// showNativeCaptureOverlay uses a macOS-native transparent panel instead of
// the Wails WebView overlay, avoiding WKWebView's opaque grey backing layer.
func showNativeCaptureOverlay(app *App, info display.Info) bool {
nativeOverlayState.Lock()
nativeOverlayState.app = app
nativeOverlayState.Unlock()
C.snipShowNativeOverlay(C.int(info.CSSWidth), C.int(info.CSSHeight))
return true
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !darwin
package main
import "github.com/mmmy/snapgo/internal/infrastructure/display"
// showNativeCaptureOverlay is only implemented on macOS. Other platforms
// keep using the Wails WebView overlay fallback.
func showNativeCaptureOverlay(_ *App, _ display.Info) bool {
return false
}
+121
View File
@@ -0,0 +1,121 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
#include <dispatch/dispatch.h>
#import <AppKit/AppKit.h>
static NSWindow* snipMainWindow(void) {
NSWindow *window = [NSApp mainWindow];
if (window == nil && [[NSApp windows] count] > 0) {
window = [[NSApp windows] objectAtIndex:0];
}
return window;
}
// snipMakeViewTransparent recursively clears AppKit/WKWebView backing
// surfaces. Setting only NSWindow transparent is insufficient for Wails:
// WKWebView may still paint an opaque default grey background.
static void snipMakeViewTransparent(NSView *view) {
if (view == nil) {
return;
}
Class wkWebViewClass = NSClassFromString(@"WKWebView");
if (wkWebViewClass != nil && [view isKindOfClass:wkWebViewClass]) {
@try {
[view setValue:@NO forKey:@"drawsBackground"];
} @catch (__unused NSException *exception) {
}
@try {
id scrollView = [view valueForKey:@"scrollView"];
if ([scrollView isKindOfClass:[NSScrollView class]]) {
[(NSScrollView *)scrollView setDrawsBackground:NO];
[(NSScrollView *)scrollView setBackgroundColor:[NSColor clearColor]];
}
} @catch (__unused NSException *exception) {
}
}
if ([view isKindOfClass:[NSScrollView class]]) {
NSScrollView *scrollView = (NSScrollView *)view;
[scrollView setDrawsBackground:NO];
[scrollView setBackgroundColor:[NSColor clearColor]];
}
[view setWantsLayer:YES];
if ([view layer] != nil) {
[[view layer] setOpaque:NO];
[[view layer] setBackgroundColor:[[NSColor clearColor] CGColor]];
}
@try {
[view setValue:@NO forKey:@"opaque"];
} @catch (__unused NSException *exception) {
}
@try {
[view setValue:@NO forKey:@"drawsBackground"];
} @catch (__unused NSException *exception) {
}
for (NSView *subview in [view subviews]) {
snipMakeViewTransparent(subview);
}
}
static void snipConfigureOverlayWindow(void) {
dispatch_async(dispatch_get_main_queue(), ^{
NSWindow *window = snipMainWindow();
NSScreen *screen = [NSScreen mainScreen];
if (window == nil || screen == nil) {
return;
}
[window setOpaque:NO];
[window setBackgroundColor:[NSColor clearColor]];
snipMakeViewTransparent([window contentView]);
[window setLevel:NSScreenSaverWindowLevel];
[window setCollectionBehavior:
NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorStationary];
[window setFrame:[screen frame] display:YES];
});
}
static void snipRestoreOverlayWindow(void) {
dispatch_async(dispatch_get_main_queue(), ^{
NSWindow *window = snipMainWindow();
if (window == nil) {
return;
}
[window setLevel:NSNormalWindowLevel];
[window setCollectionBehavior:NSWindowCollectionBehaviorDefault];
[window setOpaque:YES];
[window setBackgroundColor:
[NSColor colorWithCalibratedRed:246.0/255.0
green:247.0/255.0
blue:250.0/255.0
alpha:1.0]];
});
}
*/
import "C"
// configureOverlayWindow promotes the Wails NSWindow to a transparent,
// screen-level overlay. Wails can size a frameless window, but AppKit-only
// flags are needed to cover the menu bar and keep the WebView transparent.
func configureOverlayWindow() {
C.snipConfigureOverlayWindow()
}
// restoreOverlayWindow returns the NSWindow to normal app-window behaviour
// before the settings UI is shown again.
func restoreOverlayWindow() {
C.snipRestoreOverlayWindow()
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !darwin
package main
// configureOverlayWindow is a macOS-only AppKit refinement. Other platforms
// rely on Wails' own frameless/always-on-top window behaviour.
func configureOverlayWindow() {}
// restoreOverlayWindow is a macOS-only AppKit refinement.
func restoreOverlayWindow() {}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# =============================================================================
# create-dev-cert.sh — Create a stable, self-signed code-signing certificate.
#
# Why this exists:
# macOS's privacy database (TCC) tracks app identity primarily by the
# code-signing cdhash (for ad-hoc) or the certificate (for properly signed
# apps). Wails default ad-hoc signing produces a new cdhash on every build,
# so TCC keeps re-prompting for Screen Recording / Accessibility permission.
#
# By creating ONE self-signed certificate locally and reusing it for every
# build, the signing identity never changes, so TCC remembers the grant.
#
# Idempotent: if the cert already exists with the configured CN, this script
# is a no-op and exits 0.
# =============================================================================
set -euo pipefail
CERT_CN="${CERT_CN:-SnapGo Dev Cert}"
KEYCHAIN="${KEYCHAIN:-login.keychain-db}"
# 1. Skip ONLY if a usable code-signing identity exists. We deliberately do
# not skip on the mere presence of a certificate object, because a partial
# import (e.g. wrong PKCS#12 password) leaves the cert behind without a
# private key, which makes codesign fail later in confusing ways.
if security find-identity -v -p codesigning 2>/dev/null | grep -q "\"${CERT_CN}\""; then
echo "[create-dev-cert.sh] usable identity '${CERT_CN}' already present — nothing to do."
exit 0
fi
# 1b. Clean any stale certificate-only entry from a previous failed import,
# so the new import does not collide on duplicate CN.
if security find-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1; then
echo "[create-dev-cert.sh] removing stale (key-less) cert entry from previous run..."
# Loop because there may be multiple stale copies.
while security find-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1; do
security delete-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1 || break
done
fi
# 2. Generate a self-signed code-signing cert via openssl + import via security.
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
KEY="${WORK}/dev.key"
CSR="${WORK}/dev.csr"
CRT="${WORK}/dev.crt"
P12="${WORK}/dev.p12"
EXTFILE="${WORK}/ext.cnf"
# X509 v3 extension file enabling "Code Signing" extended key usage (EKU).
cat > "${EXTFILE}" <<'EOF'
[v3_ext]
keyUsage = critical,digitalSignature
extendedKeyUsage = critical,codeSigning
basicConstraints = CA:false
EOF
echo "[create-dev-cert.sh] generating private key..."
openssl genrsa -out "${KEY}" 2048 >/dev/null 2>&1
echo "[create-dev-cert.sh] generating CSR..."
openssl req -new -key "${KEY}" -out "${CSR}" -subj "/CN=${CERT_CN}/O=SnapGo Dev/C=US" >/dev/null 2>&1
echo "[create-dev-cert.sh] self-signing certificate (10 years)..."
openssl x509 -req -in "${CSR}" -signkey "${KEY}" -out "${CRT}" \
-days 3650 -extfile "${EXTFILE}" -extensions v3_ext >/dev/null 2>&1
echo "[create-dev-cert.sh] bundling into PKCS#12..."
# `-legacy` is REQUIRED on OpenSSL 3+: macOS's `security` tool can only
# verify the legacy PKCS#12 MAC (SHA-1 / 3DES). Without this flag the import
# fails with "MAC verification failed during PKCS12 import".
# Use a non-empty passphrase to avoid edge cases where empty pass triggers
# different MAC behaviour on some openssl builds.
P12_PASS="snapgo"
openssl pkcs12 -export -legacy \
-inkey "${KEY}" -in "${CRT}" -out "${P12}" \
-name "${CERT_CN}" -passout "pass:${P12_PASS}" >/dev/null 2>&1
echo "[create-dev-cert.sh] importing into ${KEYCHAIN} (you may be prompted for your login password)..."
security import "${P12}" -k "${KEYCHAIN}" -P "${P12_PASS}" -T /usr/bin/codesign
# 3. Mark the cert as trusted for code signing. This requires admin.
# We use `security add-trusted-cert` with policy `codeSign`.
echo "[create-dev-cert.sh] adding trust setting (admin password may be required)..."
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \
-p codeSign "${CRT}" || {
echo "[create-dev-cert.sh] WARN: trust setting could not be added. The cert"
echo " is still importable for codesign use, but TCC may still re-prompt"
echo " unless the cert is trusted. Re-run as admin to fix."
}
echo "[create-dev-cert.sh] done. Verify with:"
echo " security find-identity -v -p codesigning"
echo "You should see: '${CERT_CN}'"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# =============================================================================
# dev-build.sh — Local dev rebuild + stable re-sign + open.
#
# Use this instead of plain `wails build` while iterating, so that every
# rebuild uses the same self-signed dev certificate (created by
# create-dev-cert.sh). This keeps macOS TCC from re-prompting for Screen
# Recording / Accessibility permissions after every code change.
#
# Workflow:
# ./scripts/create-dev-cert.sh # one time
# ./scripts/dev-build.sh # every rebuild
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT_DIR}"
CERT_CN="${CERT_CN:-SnapGo Dev Cert}"
# 1. Make sure wails is on PATH (works whether or not the user added it).
_GOBINPATH=$(go env GOBIN)
if [ "${_GOBINPATH}X" == "X" ]; then
_GOBINPATH=$(go env GOPATH)/bin
fi
export PATH="${_GOBINPATH}:${PATH}"
# 2. Make sure the dev cert exists. If not, abort with a clear pointer.
if ! security find-identity -v -p codesigning | grep -q "${CERT_CN}"; then
echo "[dev-build.sh] ERROR: code-signing identity '${CERT_CN}' not found." >&2
echo " Run ./scripts/create-dev-cert.sh first." >&2
exit 1
fi
# 3. Stop any running instance so the rebuild is not blocked by file locks
# and so the next launch uses the fresh binary.
pkill -f SnapGo 2>/dev/null || true
sleep 0.4
# 4. Build via Wails. We deliberately keep the default ad-hoc-style packaging
# and re-sign right after, since `wails build` does not expose a
# --identity flag in v2.
ARCH="${ARCH:-arm64}"
echo "[dev-build.sh] building darwin/${ARCH}..."
wails build -platform "darwin/${ARCH}" -clean
# 5. Re-sign with the stable dev cert. We invoke sign.sh through env var
# DEVELOPER_ID_APPLICATION which it accepts as the identity.
APP_PATH="${ROOT_DIR}/build/bin/SnapGo.app"
echo "[dev-build.sh] re-signing ${APP_PATH} with '${CERT_CN}'..."
DEVELOPER_ID_APPLICATION="${CERT_CN}" SIGN_MODE=developer-id \
"${SCRIPT_DIR}/sign.sh" >/dev/null
# 6. Sync to /Applications and launch from there.
# Why /Applications: macOS TCC tracks an app by its code-signing identity
# AND its on-disk path. Running from a stable absolute path makes the
# privacy grants stick. We deliberately do NOT launch from build/bin to
# avoid creating a second TCC entry every time the build dir moves.
INSTALL_PATH="/Applications/SnapGo.app"
echo "[dev-build.sh] syncing to ${INSTALL_PATH}..."
rm -rf "${INSTALL_PATH}"
cp -R "${APP_PATH}" "${INSTALL_PATH}"
echo "[dev-build.sh] opening app..."
open "${INSTALL_PATH}"
echo "[dev-build.sh] done. If TCC still re-prompts, run:"
echo " tccutil reset ScreenCapture io.snapgo.app"
echo " tccutil reset Accessibility io.snapgo.app"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# =============================================================================
# make-dmg.sh — Build a distributable .dmg for SnapGo.app
#
# Design rationale:
# - Prefer `create-dmg` (https://github.com/create-dmg/create-dmg) when available
# because it produces a polished installer with /Applications drag target,
# custom background and proper icon layout.
# - Fall back to a plain `hdiutil create` UDZO image so the script always works
# on a fresh machine even without homebrew. The fallback DMG still shows the
# app + a symlink to /Applications so users can drag-install.
# - Output is always written to build/bin/SnapGo-<version>-<arch>.dmg so it
# does not collide with the .app bundle.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_PATH="${APP_PATH:-${ROOT_DIR}/build/bin/SnapGo.app}"
OUT_DIR="${OUT_DIR:-${ROOT_DIR}/build/bin}"
if [[ ! -d "${APP_PATH}" ]]; then
echo "[make-dmg.sh] ERROR: ${APP_PATH} not found." >&2
exit 1
fi
# -------- Version / arch derivation --------
VERSION="${VERSION:-$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "0.1.0")}"
ARCH="${ARCH:-$(uname -m)}" # arm64 or x86_64
DMG_NAME="SnapGo-${VERSION}-${ARCH}.dmg"
DMG_PATH="${OUT_DIR}/${DMG_NAME}"
VOLNAME="SnapGo ${VERSION}"
mkdir -p "${OUT_DIR}"
rm -f "${DMG_PATH}"
# -------- Path 1: create-dmg (pretty) --------
if command -v create-dmg >/dev/null 2>&1; then
echo "[make-dmg.sh] using create-dmg"
# NOTE: create-dmg refuses to run when the output exists; we already removed it.
create-dmg \
--volname "${VOLNAME}" \
--window-pos 200 120 \
--window-size 600 360 \
--icon-size 100 \
--icon "SnapGo.app" 160 180 \
--hide-extension "SnapGo.app" \
--app-drop-link 440 180 \
--no-internet-enable \
"${DMG_PATH}" \
"${APP_PATH}"
else
# -------- Path 2: hdiutil fallback (always works) --------
echo "[make-dmg.sh] create-dmg not found, using hdiutil fallback"
STAGING="$(mktemp -d)"
trap 'rm -rf "${STAGING}"' EXIT
# Copy the .app and add an /Applications symlink so users can drag-install.
cp -R "${APP_PATH}" "${STAGING}/"
ln -s /Applications "${STAGING}/Applications"
hdiutil create \
-volname "${VOLNAME}" \
-srcfolder "${STAGING}" \
-ov -format UDZO \
"${DMG_PATH}"
fi
# -------- Sign the DMG itself if a Developer ID is available --------
# Notarization requires the DMG be signed too.
if [[ -n "${DEVELOPER_ID_APPLICATION:-}" ]]; then
echo "[make-dmg.sh] signing DMG with: ${DEVELOPER_ID_APPLICATION}"
codesign --force --sign "${DEVELOPER_ID_APPLICATION}" --timestamp "${DMG_PATH}"
elif security find-identity -v -p codesigning 2>/dev/null \
| grep -q "Developer ID Application"; then
IDENT=$(security find-identity -v -p codesigning \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
echo "[make-dmg.sh] signing DMG with auto-detected identity: ${IDENT}"
codesign --force --sign "${IDENT}" --timestamp "${DMG_PATH}"
else
echo "[make-dmg.sh] no Developer ID identity, DMG left unsigned (ad-hoc build)."
fi
echo "[make-dmg.sh] OK -> ${DMG_PATH}"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# =============================================================================
# notarize.sh — Submit the signed DMG to Apple Notary Service and staple the
# returned ticket so users get a green Gatekeeper check offline.
#
# Design rationale:
# - We use `xcrun notarytool` (Xcode 13+). The legacy `altool` is deprecated.
# - Two credential modes are supported, in order of preference:
# 1. KEYCHAIN_PROFILE — recommended, created once via:
# xcrun notarytool store-credentials "snapgo-notary" \
# --apple-id "<APPLE_ID>" \
# --team-id "<TEAMID>" \
# --password "<APP_SPECIFIC_PASSWORD>"
# 2. APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD env vars.
# - After successful notarization we run `xcrun stapler staple` so the app/dmg
# is verifiable without an internet connection on the user's Mac.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
DMG_PATH="${DMG_PATH:-}"
if [[ -z "${DMG_PATH}" ]]; then
# Auto-detect the most recent DMG produced by make-dmg.sh
DMG_PATH="$(ls -1t "${ROOT_DIR}/build/bin/"SnapGo-*.dmg 2>/dev/null | head -1 || true)"
fi
if [[ -z "${DMG_PATH}" || ! -f "${DMG_PATH}" ]]; then
echo "[notarize.sh] ERROR: DMG not found. Set DMG_PATH or run make-dmg.sh first." >&2
exit 1
fi
# -------- Build the credential argv array --------
# Using array form keeps spaces in passwords / paths safe.
NOTARY_ARGS=()
if [[ -n "${KEYCHAIN_PROFILE:-}" ]]; then
NOTARY_ARGS+=(--keychain-profile "${KEYCHAIN_PROFILE}")
elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
NOTARY_ARGS+=(--apple-id "${APPLE_ID}"
--team-id "${APPLE_TEAM_ID}"
--password "${APPLE_APP_SPECIFIC_PASSWORD}")
else
cat >&2 <<EOF
[notarize.sh] ERROR: notary credentials not configured.
Option A (recommended) — store once in keychain, then export KEYCHAIN_PROFILE:
xcrun notarytool store-credentials "snapgo-notary" \\
--apple-id "you@example.com" \\
--team-id "ABCDE12345" \\
--password "<app-specific-password>"
export KEYCHAIN_PROFILE=snapgo-notary
Option B — export env vars directly:
export APPLE_ID="you@example.com"
export APPLE_TEAM_ID="ABCDE12345"
export APPLE_APP_SPECIFIC_PASSWORD="abcd-efgh-ijkl-mnop"
EOF
exit 1
fi
echo "[notarize.sh] submitting: ${DMG_PATH}"
xcrun notarytool submit "${DMG_PATH}" "${NOTARY_ARGS[@]}" --wait
echo "[notarize.sh] stapling ticket..."
xcrun stapler staple "${DMG_PATH}"
xcrun stapler validate "${DMG_PATH}"
# Also staple the .app inside, if we still have it on disk — this ensures the
# offline Gatekeeper check works even if the user copies the app out of the DMG.
APP_PATH="${ROOT_DIR}/build/bin/SnapGo.app"
if [[ -d "${APP_PATH}" ]]; then
echo "[notarize.sh] stapling app bundle..."
xcrun stapler staple "${APP_PATH}" || true
fi
echo "[notarize.sh] OK -> ${DMG_PATH}"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# =============================================================================
# release.sh — One-shot build → sign → DMG → notarize → staple pipeline.
#
# Design rationale:
# - Each stage is delegated to a dedicated single-purpose script. This keeps the
# pipeline composable: a developer can re-run any individual stage without
# redoing the whole release.
# - Notarization is OPT-IN. Setting NOTARIZE=1 (or providing notary credentials)
# triggers it; otherwise we stop after producing a signed DMG, which is enough
# for internal distribution and CI smoke tests.
# - ARCH defaults to the host arch so a developer on Apple Silicon gets an
# arm64 build. Set ARCH=universal to ship a fat binary.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
ARCH="${ARCH:-$(uname -m)}"
case "${ARCH}" in
arm64) WAILS_PLATFORM="darwin/arm64" ;;
x86_64) WAILS_PLATFORM="darwin/amd64" ;;
universal) WAILS_PLATFORM="darwin/universal" ;;
*) echo "[release.sh] ERROR: unsupported ARCH=${ARCH}" >&2; exit 1 ;;
esac
echo "================================================================"
echo " SnapGo release pipeline"
echo " arch : ${ARCH} (${WAILS_PLATFORM})"
echo " sign : ${SIGN_MODE:-auto}"
echo " notarize : ${NOTARIZE:-0}"
echo "================================================================"
# ---- 1. Build ----
echo ""
echo "[release.sh] (1/4) wails build"
( cd "${ROOT_DIR}" && wails build -platform "${WAILS_PLATFORM}" -clean )
# ---- 2. Sign .app ----
echo ""
echo "[release.sh] (2/4) sign app"
"${SCRIPT_DIR}/sign.sh"
# ---- 3. DMG ----
echo ""
echo "[release.sh] (3/4) make DMG"
ARCH="${ARCH}" "${SCRIPT_DIR}/make-dmg.sh"
# ---- 4. Notarize (optional) ----
echo ""
if [[ "${NOTARIZE:-0}" == "1" || -n "${KEYCHAIN_PROFILE:-}" \
|| ( -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" \
&& -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ) ]]; then
echo "[release.sh] (4/4) notarize"
"${SCRIPT_DIR}/notarize.sh"
else
echo "[release.sh] (4/4) notarize SKIPPED (set NOTARIZE=1 with credentials to enable)"
fi
echo ""
echo "[release.sh] DONE."
ls -lh "${ROOT_DIR}/build/bin/"SnapGo-*.dmg 2>/dev/null || true
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# =============================================================================
# sign.sh — Sign SnapGo.app with Hardened Runtime.
#
# Design rationale:
# - Two modes are supported, controlled purely by env vars (no CLI args), so
# the script stays idempotent and easy to wire into CI:
# 1. "developer-id" : real Developer ID Application signing for distribution
# (requires DEVELOPER_ID_APPLICATION env or default
# lookup via `security find-identity`).
# 2. "ad-hoc" : codesign with `-` identity. The bundle works on the
# building machine after `xattr -dr com.apple.quarantine`,
# but cannot be notarized.
# - We always sign with --options=runtime so the bundle is notarization-ready.
# - We sign nested binaries first, then the .app bundle (Apple's required order).
# =============================================================================
set -euo pipefail
# -------- Paths --------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_PATH="${APP_PATH:-${ROOT_DIR}/build/bin/SnapGo.app}"
ENTITLEMENTS="${ENTITLEMENTS:-${ROOT_DIR}/build/darwin/entitlements.plist}"
# -------- Mode resolution --------
# SIGN_MODE=developer-id | ad-hoc (default: ad-hoc when no identity is found)
SIGN_MODE="${SIGN_MODE:-auto}"
SIGN_IDENTITY=""
resolve_identity() {
if [[ -n "${DEVELOPER_ID_APPLICATION:-}" ]]; then
SIGN_IDENTITY="${DEVELOPER_ID_APPLICATION}"
SIGN_MODE="developer-id"
return
fi
# Try to auto-discover the first Developer ID Application identity.
local found
found=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
if [[ -n "${found}" ]]; then
SIGN_IDENTITY="${found}"
SIGN_MODE="developer-id"
else
SIGN_IDENTITY="-"
SIGN_MODE="ad-hoc"
fi
}
if [[ "${SIGN_MODE}" == "auto" ]]; then
resolve_identity
elif [[ "${SIGN_MODE}" == "developer-id" ]]; then
if [[ -z "${DEVELOPER_ID_APPLICATION:-}" ]]; then
resolve_identity
if [[ "${SIGN_MODE}" != "developer-id" ]]; then
echo "[sign.sh] ERROR: SIGN_MODE=developer-id but no Developer ID Application identity found." >&2
echo " Set DEVELOPER_ID_APPLICATION='Developer ID Application: NAME (TEAMID)'" >&2
exit 1
fi
else
SIGN_IDENTITY="${DEVELOPER_ID_APPLICATION}"
fi
elif [[ "${SIGN_MODE}" == "ad-hoc" ]]; then
SIGN_IDENTITY="-"
else
echo "[sign.sh] ERROR: invalid SIGN_MODE='${SIGN_MODE}'" >&2
exit 1
fi
# -------- Sanity check --------
if [[ ! -d "${APP_PATH}" ]]; then
echo "[sign.sh] ERROR: app bundle not found at ${APP_PATH}" >&2
echo " Run 'wails build -platform darwin/arm64 -clean' first." >&2
exit 1
fi
if [[ ! -f "${ENTITLEMENTS}" ]]; then
echo "[sign.sh] ERROR: entitlements not found at ${ENTITLEMENTS}" >&2
exit 1
fi
echo "[sign.sh] mode = ${SIGN_MODE}"
echo "[sign.sh] identity = ${SIGN_IDENTITY}"
echo "[sign.sh] app = ${APP_PATH}"
echo "[sign.sh] ents = ${ENTITLEMENTS}"
# -------- Decide whether to request RFC3161 timestamp --------
# Apple's timestamp server only accepts signatures from Developer ID-issued
# certificates. For ad-hoc or local self-signed certs the timestamp call
# either fails or attaches noise that makes re-signing nondeterministic
# (which in turn breaks TCC's "is this the same app?" comparison).
TIMESTAMP_FLAG="--timestamp=none"
if [[ "${SIGN_MODE}" == "developer-id" && "${SIGN_IDENTITY}" == "Developer ID Application:"* ]]; then
TIMESTAMP_FLAG="--timestamp"
fi
echo "[sign.sh] timestamp = ${TIMESTAMP_FLAG}"
# -------- Strip stale signatures so re-signing is deterministic --------
codesign --remove-signature "${APP_PATH}" 2>/dev/null || true
# -------- Sign nested executables first (Apple required ordering) --------
# Find any embedded Mach-O binaries / frameworks / dylibs.
NESTED_PATHS=()
while IFS= read -r path; do
NESTED_PATHS+=("$path")
done < <(find "${APP_PATH}/Contents" \
\( -name "*.dylib" -o -name "*.framework" -o -path "*/Frameworks/*" \) \
-not -path "*/_CodeSignature/*" 2>/dev/null || true)
for nested in "${NESTED_PATHS[@]:-}"; do
[[ -z "${nested}" ]] && continue
echo "[sign.sh] signing nested: ${nested}"
codesign --force ${TIMESTAMP_FLAG} --options=runtime \
--sign "${SIGN_IDENTITY}" \
"${nested}"
done
# -------- Sign the main app bundle --------
echo "[sign.sh] signing app bundle..."
codesign --force ${TIMESTAMP_FLAG} --options=runtime \
--entitlements "${ENTITLEMENTS}" \
--sign "${SIGN_IDENTITY}" \
"${APP_PATH}"
# -------- Verify --------
echo "[sign.sh] verifying signature..."
codesign --verify --deep --strict --verbose=2 "${APP_PATH}"
if [[ "${SIGN_MODE}" == "developer-id" ]]; then
# Gatekeeper assessment will pass only after notarization + stapling,
# but we can pre-flight the spctl evaluation now.
echo "[sign.sh] spctl assessment (will likely say 'rejected' until notarized)..."
spctl --assess --type execute --verbose=4 "${APP_PATH}" || true
fi
echo "[sign.sh] OK."
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "SnapGo",
"outputfilename": "SnapGo",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "mmmy",
"email": "mmmy@example.com"
},
"info": {
"productName": "SnapGo",
"productVersion": "0.1.0",
"copyright": "Copyright © 2026",
"comments": "Cross-platform screenshot tool with one-click upload to S3"
}
}