Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fe3a0fc93 | |||
| 050f2b74c3 | |||
| 3e4d071b4c | |||
| be8dce7135 | |||
| 952f1497c8 | |||
| ab4ba8c417 | |||
| 1d52d154fd | |||
| c45864e44c | |||
| ceecbb6af4 |
@@ -1,6 +1,7 @@
|
|||||||
build/bin
|
build/bin
|
||||||
node_modules
|
node_modules
|
||||||
frontend/dist
|
frontend/dist
|
||||||
|
landing/dist
|
||||||
|
|
||||||
# macOS release artifacts
|
# macOS release artifacts
|
||||||
*.dmg
|
*.dmg
|
||||||
@@ -16,3 +17,6 @@ scripts/secrets.sh
|
|||||||
# IDE
|
# IDE
|
||||||
.trae/
|
.trae/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
*.DS_Store
|
||||||
|
|||||||
@@ -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 v2(S3 兼容实现)
|
||||||
|
- macOS 截图与窗口控制:系统 `screencapture` 命令 + AppKit 原生能力
|
||||||
@@ -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 是目前最推荐的使用平台。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
@@ -0,0 +1,785 @@
|
|||||||
|
// 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,
|
||||||
|
"png_size", len(pngBytes))
|
||||||
|
|
||||||
|
svc := &application.CaptureAndSSHService{
|
||||||
|
Uploader: sshpkg.NewUploader(cfg.SSH),
|
||||||
|
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,
|
||||||
|
"strict_host_key", cfg.StrictHostKey, "has_password", cfg.Password != "")
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 20 KiB |
@@ -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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
bb7ffb87329c9ad4990374471d4ce9a4
|
||||||
@@ -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-Conf' },
|
||||||
|
{ id: 'ssh', label: 'SSH-Conf' },
|
||||||
|
]
|
||||||
|
|
||||||
|
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 & 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>
|
||||||
@@ -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.
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 136 KiB |
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createApp} from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 · 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>
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
<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
|
||||||
|
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: '',
|
||||||
|
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 +
|
||||||
|
a–z or 0–9. 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>
|
||||||
|
<label class="field">
|
||||||
|
<span>User *</span>
|
||||||
|
<input v-model="config.ssh.user" placeholder="ubuntu" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Password (optional)</span>
|
||||||
|
<input v-model="config.ssh.password" type="password" />
|
||||||
|
</label>
|
||||||
|
<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.password" class="hint warn">
|
||||||
|
Password is empty — please make sure password-less SSH is configured on
|
||||||
|
this machine (e.g. via <code>ssh-copy-id</code> or your <code>ssh-agent</code>).
|
||||||
|
</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 span {
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.field input[type='text'],
|
||||||
|
.field input[type='number'],
|
||||||
|
.field input:not([type='checkbox']) {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.field input: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 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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type {DefineComponent} from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import {defineConfig} from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()]
|
||||||
|
})
|
||||||
@@ -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>;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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;
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// 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)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaptureAndSSHService) notifyFailure(reason string) {
|
||||||
|
if s.Notifier != nil {
|
||||||
|
s.Notifier.NotifyFailure(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// 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.
|
||||||
|
// - 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).
|
||||||
|
// - 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"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
PathPrefix string `json:"pathPrefix"`
|
||||||
|
StrictHostKey bool `json:"strictHostKey"`
|
||||||
|
KnownHostsPath string `json:"knownHostsPath"`
|
||||||
|
ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 != ""
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 A–Z or digit 0–9.
|
||||||
|
//
|
||||||
|
// 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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
// 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. See Dial's docstring for the priority order.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
if cfg.Password != "" {
|
||||||
|
methods = append(methods, ssh.Password(cfg.Password))
|
||||||
|
sources = append(sources, "password")
|
||||||
|
}
|
||||||
|
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,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
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
|
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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_BASE_PATH=/
|
||||||
|
VITE_DOWNLOAD_URL=https://github.com/mamamiyear/SnapGo/releases/latest/download/SnapGo-0.1.0-arm64.dmg
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# SnapGo Landing
|
||||||
|
|
||||||
|
Vite + React landing page for SnapGo.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `VITE_BASE_PATH` when deploying under a sub-path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_BASE_PATH=/SnapGo/ npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional download URL override:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_DOWNLOAD_URL=https://example.com/SnapGo-arm64.dmg npm run build
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="SnapGo 是一款 macOS 截图工具,截图后自动上传到对象存储或远端服务器,并复制链接或路径。"
|
||||||
|
/>
|
||||||
|
<title>SnapGo - 截完图,链接已经在剪贴板</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "snapgo-landing",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import {
|
||||||
|
Apple,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardCheck,
|
||||||
|
Cloud,
|
||||||
|
Command,
|
||||||
|
Download,
|
||||||
|
FolderInput,
|
||||||
|
Gauge,
|
||||||
|
Globe2,
|
||||||
|
HardDriveUpload,
|
||||||
|
LockKeyhole,
|
||||||
|
MousePointer2,
|
||||||
|
Network,
|
||||||
|
Server,
|
||||||
|
Sparkles,
|
||||||
|
TerminalSquare,
|
||||||
|
UploadCloud,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import appIcon from '../../build/appicon.png';
|
||||||
|
|
||||||
|
const defaultDownloadUrl =
|
||||||
|
'https://github.com/mamamiyear/SnapGo/releases/latest/download/SnapGo-0.1.0-arm64.dmg';
|
||||||
|
|
||||||
|
const downloadUrl = import.meta.env.VITE_DOWNLOAD_URL || defaultDownloadUrl;
|
||||||
|
|
||||||
|
const providers = ['AWS S3', 'MinIO', 'Cloudflare R2', 'Backblaze B2'];
|
||||||
|
|
||||||
|
const workflow = [
|
||||||
|
{
|
||||||
|
title: '全局快捷键截图',
|
||||||
|
detail: '菜单栏常驻,按下 cmd+shift+a 后直接框选当前屏幕内容。',
|
||||||
|
icon: Command,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '选择上传目标',
|
||||||
|
detail: '对象存储得到公开 URL,远端服务器得到 SSH 家目录下的路径。',
|
||||||
|
icon: Network,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '自动复制结果',
|
||||||
|
detail: '上传完成后,URL 或 ~/snapgo/...png 已经在剪贴板里。',
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const featureCards = [
|
||||||
|
{
|
||||||
|
title: '截图直传对象存储',
|
||||||
|
copy: '接入 S3 兼容端点,支持路径前缀和自定义公开地址,适合自有图床和 CDN。',
|
||||||
|
icon: Cloud,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '远端服务器保存',
|
||||||
|
copy: '通过 SSH/SCP 写入远端主机,复制相对家目录路径,方便登录服务器后立刻定位。',
|
||||||
|
icon: Server,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '失败也有兜底',
|
||||||
|
copy: '上传失败时保住截图文件,避免截了一次图却丢了上下文。',
|
||||||
|
icon: FolderInput,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '为高频沟通而轻',
|
||||||
|
copy: '截图、上传、复制三步合一,发给聊天、工单、Issue、PR 都不打断手上的事。',
|
||||||
|
icon: Gauge,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const comparison = [
|
||||||
|
'不用打开图床网页',
|
||||||
|
'不用手动保存再拖拽',
|
||||||
|
'不用把图片交给陌生服务',
|
||||||
|
'不用给 CLI Agent 拼 base64',
|
||||||
|
];
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="site-shell">
|
||||||
|
<header className="hero">
|
||||||
|
<nav className="top-nav" aria-label="主导航">
|
||||||
|
<a className="brand" href="#top" aria-label="SnapGo 首页">
|
||||||
|
<img src={appIcon} alt="" />
|
||||||
|
<span>SnapGo</span>
|
||||||
|
</a>
|
||||||
|
<div className="nav-actions">
|
||||||
|
<a href="#workflow">工作流</a>
|
||||||
|
<a href="#destinations">远端目标</a>
|
||||||
|
<a className="nav-download" href={downloadUrl}>
|
||||||
|
<Download size={16} aria-hidden="true" />
|
||||||
|
下载
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="hero-grid" id="top">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<div className="eyebrow">
|
||||||
|
<Sparkles size={16} aria-hidden="true" />
|
||||||
|
macOS 截图直传工具
|
||||||
|
</div>
|
||||||
|
<h1>截完图,链接已经在剪贴板。</h1>
|
||||||
|
<p className="hero-lede">
|
||||||
|
SnapGo 把「框选截图 → 上传远端 → 复制链接或路径」压缩成一次动作。
|
||||||
|
图片进你的对象存储或远端服务器,分享结果立刻可粘贴。
|
||||||
|
</p>
|
||||||
|
<div className="hero-actions">
|
||||||
|
<a className="primary-cta" href={downloadUrl}>
|
||||||
|
<Apple size={19} aria-hidden="true" />
|
||||||
|
下载 macOS Apple silicon
|
||||||
|
</a>
|
||||||
|
<a className="secondary-cta" href="#destinations">
|
||||||
|
看远端目标
|
||||||
|
<ArrowRight size={17} aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="hero-proof" aria-label="核心能力">
|
||||||
|
<span>S3 兼容对象存储</span>
|
||||||
|
<span>SSH/SCP 远端服务器</span>
|
||||||
|
<span>自动复制 URL/路径</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hero-visual" aria-label="SnapGo 截图上传工作流示意">
|
||||||
|
<img className="hero-app-icon" src={appIcon} alt="SnapGo 应用图标" />
|
||||||
|
<div className="capture-scene">
|
||||||
|
<div className="desktop-bar">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<div className="selection-box">
|
||||||
|
<div className="selection-label">1280 x 720</div>
|
||||||
|
<div className="toolbar" aria-hidden="true">
|
||||||
|
<MousePointer2 size={16} />
|
||||||
|
<UploadCloud size={16} />
|
||||||
|
<HardDriveUpload size={16} />
|
||||||
|
<ClipboardCheck size={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="result-stack">
|
||||||
|
<div className="result-line">
|
||||||
|
<Globe2 size={15} aria-hidden="true" />
|
||||||
|
<span>https://cdn.example.com/snapgo/2026/06/shot.png</span>
|
||||||
|
</div>
|
||||||
|
<div className="result-line">
|
||||||
|
<TerminalSquare size={15} aria-hidden="true" />
|
||||||
|
<span>~/snapgo/2026/06/shot.png</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className="quick-strip" aria-label="上传目标">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<span key={provider}>{provider}</span>
|
||||||
|
))}
|
||||||
|
<span>Custom CDN</span>
|
||||||
|
<span>SSH Host</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section workflow-section" id="workflow">
|
||||||
|
<div className="section-heading">
|
||||||
|
<p>一次动作</p>
|
||||||
|
<h2>给截图分享装上直达通道</h2>
|
||||||
|
</div>
|
||||||
|
<div className="workflow-grid">
|
||||||
|
{workflow.map((item, index) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<article className="workflow-card" key={item.title}>
|
||||||
|
<div className="step-index">0{index + 1}</div>
|
||||||
|
<Icon size={25} aria-hidden="true" />
|
||||||
|
<h3>{item.title}</h3>
|
||||||
|
<p>{item.detail}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section split-section" id="destinations">
|
||||||
|
<div className="destination-copy">
|
||||||
|
<p className="section-kicker">远端优先</p>
|
||||||
|
<h2>对象存储给 URL,服务器给路径。</h2>
|
||||||
|
<p>
|
||||||
|
SnapGo 的吸引力不只是「能截图」,而是截图完成后直接抵达你真正要用的地方:
|
||||||
|
公开链接可以贴进文档和 PR,远端路径可以给脚本、服务器会话或 Agent 继续处理。
|
||||||
|
</p>
|
||||||
|
<ul className="check-list">
|
||||||
|
{comparison.map((item) => (
|
||||||
|
<li key={item}>
|
||||||
|
<CheckCircle2 size={18} aria-hidden="true" />
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="destination-panel" aria-label="远端配置示意">
|
||||||
|
<div className="panel-head">
|
||||||
|
<span>Destination</span>
|
||||||
|
<strong>Ready</strong>
|
||||||
|
</div>
|
||||||
|
<div className="route-row active">
|
||||||
|
<Cloud size={20} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>S3 compatible</strong>
|
||||||
|
<span>snapgo/2026/06/*.png → public URL</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="route-row">
|
||||||
|
<Server size={20} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>SSH remote</strong>
|
||||||
|
<span>~/snapgo/2026/06/*.png → clipboard path</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="clipboard-preview">
|
||||||
|
<ClipboardCheck size={19} aria-hidden="true" />
|
||||||
|
<code>Copied: https://cdn.example.com/snapgo/shot.png</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="section feature-section">
|
||||||
|
<div className="section-heading">
|
||||||
|
<p>为什么值得用</p>
|
||||||
|
<h2>为每天反复发截图的人打磨</h2>
|
||||||
|
</div>
|
||||||
|
<div className="feature-grid">
|
||||||
|
{featureCards.map((feature) => {
|
||||||
|
const Icon = feature.icon;
|
||||||
|
return (
|
||||||
|
<article className="feature-card" key={feature.title}>
|
||||||
|
<Icon size={24} aria-hidden="true" />
|
||||||
|
<h3>{feature.title}</h3>
|
||||||
|
<p>{feature.copy}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="final-cta" aria-labelledby="download-title">
|
||||||
|
<div>
|
||||||
|
<p className="section-kicker">SnapGo for macOS</p>
|
||||||
|
<h2 id="download-title">把截图分享流程缩短到一次粘贴。</h2>
|
||||||
|
<p>
|
||||||
|
当前主要围绕 macOS 体验打磨,推荐 Apple silicon 设备下载使用。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a className="primary-cta final-button" href={downloadUrl}>
|
||||||
|
<Download size={19} aria-hidden="true" />
|
||||||
|
下载 macOS Apple silicon
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800&family=Work+Sans:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family:
|
||||||
|
'Work Sans',
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
sans-serif;
|
||||||
|
background: #070b14;
|
||||||
|
color: #f6fbff;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-synthesis: none;
|
||||||
|
--bg: #070b14;
|
||||||
|
--panel: rgba(11, 20, 36, 0.82);
|
||||||
|
--panel-strong: #101b30;
|
||||||
|
--line: rgba(139, 223, 255, 0.18);
|
||||||
|
--line-strong: rgba(121, 239, 231, 0.38);
|
||||||
|
--text: #f6fbff;
|
||||||
|
--muted: #a7bad1;
|
||||||
|
--cyan: #42edf2;
|
||||||
|
--blue: #3a9bff;
|
||||||
|
--purple: #8d63ff;
|
||||||
|
--mint: #5df0bd;
|
||||||
|
--orange: #ff7a2f;
|
||||||
|
--orange-deep: #e85f18;
|
||||||
|
--shadow-cyan: rgba(66, 237, 242, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg, rgba(66, 237, 242, 0.08), transparent 34%),
|
||||||
|
linear-gradient(220deg, rgba(141, 99, 255, 0.12), transparent 42%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
min-height: 88svh;
|
||||||
|
padding: 18px clamp(18px, 4vw, 64px) 56px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(66, 237, 242, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(0deg, rgba(66, 237, 242, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(135deg, #08111f 0%, #0c1223 42%, #070b14 100%);
|
||||||
|
background-size:
|
||||||
|
72px 72px,
|
||||||
|
72px 72px,
|
||||||
|
auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(110deg, rgba(66, 237, 242, 0.16), transparent 28%),
|
||||||
|
linear-gradient(290deg, rgba(141, 99, 255, 0.18), transparent 30%);
|
||||||
|
mask-image: linear-gradient(to bottom, #000 0%, transparent 88%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav,
|
||||||
|
.hero-grid,
|
||||||
|
.quick-strip,
|
||||||
|
.section,
|
||||||
|
.final-cta {
|
||||||
|
width: min(1180px, calc(100vw - 36px));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand,
|
||||||
|
.nav-actions,
|
||||||
|
.nav-download,
|
||||||
|
.hero-actions,
|
||||||
|
.hero-proof,
|
||||||
|
.eyebrow,
|
||||||
|
.quick-strip,
|
||||||
|
.check-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
gap: 10px;
|
||||||
|
font-family: Outfit, sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 24px rgba(66, 237, 242, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions {
|
||||||
|
gap: 18px;
|
||||||
|
color: #c2d2e6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions a {
|
||||||
|
transition: color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-download {
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 13px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.13);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-download:hover {
|
||||||
|
border-color: rgba(66, 237, 242, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.04fr) minmax(360px, 0.96fr);
|
||||||
|
gap: clamp(26px, 6vw, 72px);
|
||||||
|
align-items: center;
|
||||||
|
min-height: calc(88svh - 88px);
|
||||||
|
padding: 42px 0 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
max-width: 690px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
width: fit-content;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid rgba(93, 240, 189, 0.24);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #c6fff0;
|
||||||
|
background: rgba(93, 240, 189, 0.08);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: Outfit, sans-serif;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 780px;
|
||||||
|
font-size: clamp(46px, 7vw, 88px);
|
||||||
|
line-height: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-lede {
|
||||||
|
margin: 24px 0 0;
|
||||||
|
max-width: 650px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: clamp(17px, 2vw, 21px);
|
||||||
|
line-height: 1.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 13px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta,
|
||||||
|
.secondary-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 50px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition:
|
||||||
|
transform 180ms ease,
|
||||||
|
border-color 180ms ease,
|
||||||
|
background 180ms ease,
|
||||||
|
color 180ms ease,
|
||||||
|
box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta {
|
||||||
|
padding: 0 20px;
|
||||||
|
color: #190b04;
|
||||||
|
background: linear-gradient(180deg, #ff9c54 0%, var(--orange) 100%);
|
||||||
|
box-shadow: 0 16px 40px rgba(255, 122, 47, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
background: linear-gradient(180deg, #ffac6e 0%, #ff812f 100%);
|
||||||
|
box-shadow: 0 18px 44px rgba(255, 122, 47, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta:focus-visible,
|
||||||
|
.secondary-cta:focus-visible,
|
||||||
|
.nav-actions a:focus-visible {
|
||||||
|
outline: 3px solid rgba(66, 237, 242, 0.72);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-cta {
|
||||||
|
padding: 0 18px;
|
||||||
|
border: 1px solid rgba(151, 190, 232, 0.22);
|
||||||
|
color: #dbeeff;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-cta:hover {
|
||||||
|
border-color: rgba(66, 237, 242, 0.45);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-proof {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-proof span {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid rgba(139, 223, 255, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #c5d9ef;
|
||||||
|
background: rgba(255, 255, 255, 0.045);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
position: relative;
|
||||||
|
min-height: 530px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-app-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: -4%;
|
||||||
|
right: -8%;
|
||||||
|
width: min(420px, 88%);
|
||||||
|
border-radius: 30px;
|
||||||
|
opacity: 0.92;
|
||||||
|
filter: drop-shadow(0 28px 60px rgba(66, 237, 242, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-scene {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 4%;
|
||||||
|
width: min(510px, 100%);
|
||||||
|
min-height: 360px;
|
||||||
|
border: 1px solid rgba(139, 223, 255, 0.24);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg, rgba(13, 30, 54, 0.88), rgba(9, 15, 30, 0.94)),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0 46px,
|
||||||
|
rgba(66, 237, 242, 0.06) 47px 48px
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 80px rgba(0, 0, 0, 0.44),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid rgba(139, 223, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-bar span {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #3b4860;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-bar span:nth-child(1) {
|
||||||
|
background: #ff7a2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-bar span:nth-child(2) {
|
||||||
|
background: #f8c75d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-bar span:nth-child(3) {
|
||||||
|
background: #5df0bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-box {
|
||||||
|
position: absolute;
|
||||||
|
left: 54px;
|
||||||
|
top: 76px;
|
||||||
|
width: 68%;
|
||||||
|
height: 150px;
|
||||||
|
border: 2px solid var(--cyan);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(66, 237, 242, 0.1), rgba(141, 99, 255, 0.08)),
|
||||||
|
rgba(255, 255, 255, 0.03);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 999px rgba(0, 0, 0, 0.26),
|
||||||
|
0 0 34px var(--shadow-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-label {
|
||||||
|
position: absolute;
|
||||||
|
top: -34px;
|
||||||
|
left: -2px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #07121f;
|
||||||
|
background: var(--cyan);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: absolute;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -48px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(139, 223, 255, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(6, 11, 21, 0.92);
|
||||||
|
color: #dff9ff;
|
||||||
|
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar svg {
|
||||||
|
padding: 7px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar svg:nth-child(2) {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar svg:nth-child(3) {
|
||||||
|
color: var(--mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar svg:nth-child(4) {
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stack {
|
||||||
|
position: absolute;
|
||||||
|
left: 28px;
|
||||||
|
right: 28px;
|
||||||
|
bottom: 28px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid rgba(139, 223, 255, 0.16);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #cfe4f7;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-line span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-strip {
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: -26px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(139, 223, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(9, 17, 31, 0.92);
|
||||||
|
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-strip span {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #bfd2e9;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 100px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
max-width: 700px;
|
||||||
|
margin-bottom: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading p,
|
||||||
|
.section-kicker {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: var(--mint);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h2,
|
||||||
|
.split-section h2,
|
||||||
|
.final-cta h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: clamp(32px, 4.2vw, 54px);
|
||||||
|
line-height: 1.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-grid,
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card,
|
||||||
|
.feature-card {
|
||||||
|
min-height: 230px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, rgba(16, 30, 53, 0.88), rgba(8, 15, 28, 0.88));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--cyan), var(--purple));
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-index {
|
||||||
|
margin-bottom: 26px;
|
||||||
|
color: rgba(255, 255, 255, 0.28);
|
||||||
|
font-family: Outfit, sans-serif;
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card svg,
|
||||||
|
.feature-card svg {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card h3,
|
||||||
|
.feature-card h3 {
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card p,
|
||||||
|
.feature-card p,
|
||||||
|
.destination-copy p,
|
||||||
|
.final-cta p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.9fr 1.1fr;
|
||||||
|
gap: clamp(28px, 6vw, 76px);
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-copy p:not(.section-kicker) {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 28px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-list li {
|
||||||
|
gap: 10px;
|
||||||
|
color: #dcecff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-list svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-panel {
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(19, 36, 65, 0.86), rgba(7, 13, 24, 0.92)),
|
||||||
|
var(--panel-strong);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 72px rgba(0, 0, 0, 0.36),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head,
|
||||||
|
.route-row,
|
||||||
|
.clipboard-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
color: #8fa8c5;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head strong {
|
||||||
|
color: var(--mint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-row {
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 86px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid rgba(139, 223, 255, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-row + .route-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-row.active {
|
||||||
|
border-color: rgba(66, 237, 242, 0.5);
|
||||||
|
background: rgba(66, 237, 242, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-row svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-row strong,
|
||||||
|
.route-row span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-row strong {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-row span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-preview {
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #07121f;
|
||||||
|
background: linear-gradient(90deg, var(--cyan), var(--mint));
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-preview code {
|
||||||
|
color: inherit;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card p {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-cta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 28px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 110px;
|
||||||
|
margin-bottom: 60px;
|
||||||
|
padding: 34px;
|
||||||
|
border: 1px solid rgba(255, 122, 47, 0.32);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
linear-gradient(120deg, rgba(255, 122, 47, 0.12), transparent 40%),
|
||||||
|
linear-gradient(300deg, rgba(66, 237, 242, 0.13), transparent 42%),
|
||||||
|
#0b1424;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-cta p:not(.section-kicker) {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-button {
|
||||||
|
min-width: 252px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.hero {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid,
|
||||||
|
.split-section,
|
||||||
|
.final-cta {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
min-height: auto;
|
||||||
|
padding-top: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-app-icon {
|
||||||
|
right: 3%;
|
||||||
|
width: min(360px, 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-scene {
|
||||||
|
right: 4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-grid,
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-button {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.hero {
|
||||||
|
padding: 14px 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav,
|
||||||
|
.hero-grid,
|
||||||
|
.quick-strip,
|
||||||
|
.section,
|
||||||
|
.final-cta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
padding-top: 30px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-actions a:not(.nav-download) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(39px, 13.5vw, 52px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-lede {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta,
|
||||||
|
.secondary-cta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-proof {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-visual {
|
||||||
|
position: absolute;
|
||||||
|
top: 72px;
|
||||||
|
right: -18px;
|
||||||
|
z-index: -1;
|
||||||
|
width: 180px;
|
||||||
|
min-height: 180px;
|
||||||
|
opacity: 0.24;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-app-icon {
|
||||||
|
position: static;
|
||||||
|
width: 180px;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capture-scene {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-strip {
|
||||||
|
margin-top: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding-top: 70px;
|
||||||
|
padding-right: 18px;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-section > *,
|
||||||
|
.final-cta > *,
|
||||||
|
.workflow-card,
|
||||||
|
.feature-card,
|
||||||
|
.destination-copy,
|
||||||
|
.destination-panel,
|
||||||
|
.route-row > div {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-grid,
|
||||||
|
.feature-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-card,
|
||||||
|
.feature-card {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.destination-panel {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-preview code {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-cta {
|
||||||
|
margin-top: 70px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_DOWNLOAD_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
function normalizeBasePath(value: string | undefined) {
|
||||||
|
const raw = value?.trim();
|
||||||
|
if (!raw || raw === '/') return '/';
|
||||||
|
if (raw === './') return './';
|
||||||
|
if (/^https?:\/\//.test(raw)) {
|
||||||
|
return raw.endsWith('/') ? raw : `${raw}/`;
|
||||||
|
}
|
||||||
|
return `/${raw.replace(/^\/+|\/+$/g, '')}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: normalizeBasePath(env.VITE_BASE_PATH),
|
||||||
|
plugins: [react()],
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||