feat(app): implement basic function
General - 配置界面支持配置对象存储桶 - 快捷键进入截图 - 一键上传截图 & 复制截图链接 MacOS - 状态栏指示器和应用图标
This commit is contained in:
@@ -16,3 +16,6 @@ scripts/secrets.sh
|
||||
# IDE
|
||||
.trae/
|
||||
.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 兼容对象存储,并把图片链接直接复制到剪贴板。你不需要再经历“截图 -> 保存本地 -> 打开图床 -> 上传 -> 复制链接”这串繁琐操作。
|
||||
|
||||
如果你经常在聊天工具、工单、文档、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 兼容对象存储配置
|
||||
@@ -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,471 @@
|
||||
// app.go wires the application service stack together and exposes
|
||||
// frontend-callable methods through Wails bindings.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
if !cfg.IsS3Configured() {
|
||||
a.surfaceWindow()
|
||||
wruntime.EventsEmit(a.ctx, "upload:failure",
|
||||
"S3 is not configured yet — open settings first")
|
||||
return
|
||||
}
|
||||
provider, err := oss.NewS3Provider(cfg.S3)
|
||||
if err != nil {
|
||||
a.surfaceWindow()
|
||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 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 = 900
|
||||
settingsHeight = 640
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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(rect RegionRect) error {
|
||||
a.pendingMu.Lock()
|
||||
pc := a.pending
|
||||
a.pending = nil
|
||||
a.pendingMu.Unlock()
|
||||
|
||||
if pc == nil {
|
||||
return fmt.Errorf("no pending capture")
|
||||
}
|
||||
defer func() {
|
||||
a.capturing.Store(false)
|
||||
a.dismissOverlay()
|
||||
}()
|
||||
|
||||
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
|
||||
a.dismissOverlay()
|
||||
flushFrame()
|
||||
|
||||
cropped, err := a.capturer.CaptureRegion(captureRect)
|
||||
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
|
||||
}
|
||||
|
||||
// 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(rect RegionRect) error {
|
||||
a.pendingMu.Lock()
|
||||
pc := a.pending
|
||||
a.pending = nil
|
||||
a.pendingMu.Unlock()
|
||||
|
||||
if pc == nil {
|
||||
return fmt.Errorf("no pending capture")
|
||||
}
|
||||
defer func() {
|
||||
a.capturing.Store(false)
|
||||
hideDockIcon()
|
||||
}()
|
||||
|
||||
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
|
||||
flushFrame()
|
||||
cropped, err := a.capturer.CaptureRegion(captureRect)
|
||||
if err != nil {
|
||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||
return err
|
||||
}
|
||||
return a.runUploadPipeline(pc.Provider, cropped)
|
||||
}
|
||||
|
||||
// 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.
|
||||
Binary file not shown.
|
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>
|
||||
Binary file not shown.
|
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>
|
||||
Generated
+1071
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
bb7ffb87329c9ad4990374471d4ce9a4
|
||||
@@ -0,0 +1,299 @@
|
||||
<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,
|
||||
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')
|
||||
|
||||
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: {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: 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)
|
||||
} 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"
|
||||
@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 class="sidebar-item active"><span class="dot" /> Settings</div>
|
||||
</aside>
|
||||
<section class="content">
|
||||
<SettingsView />
|
||||
</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: default;
|
||||
}
|
||||
.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.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.
|
||||
Binary file not shown.
Binary file not shown.
|
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,314 @@
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* CaptureOverlay — Snipaste-style region picker.
|
||||
*
|
||||
* Three layers, painted in order:
|
||||
* 1. Dark mask + selection : a single SVG that draws a 0.4 alpha
|
||||
* black rectangle over the whole screen
|
||||
* with the selection cut out via fill-rule
|
||||
* evenodd. The Wails window itself is
|
||||
* transparent, so the cut-out shows the live
|
||||
* desktop rather than a stale screenshot.
|
||||
* 2. Toolbar : floats just outside the bottom-right of
|
||||
* the selection rectangle (Snipaste rule).
|
||||
*
|
||||
* Interaction model (kept minimal per user choice):
|
||||
* • If no selection yet: mousedown drags out a new rectangle.
|
||||
* • If selection exists: mousedown INSIDE moves it; OUTSIDE re-drags.
|
||||
* • Esc / Cancel button → emit('cancel')
|
||||
* • Enter / Upload button → emit('confirm', rect)
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
/** Logical (CSS) width of the primary display. */
|
||||
width: number
|
||||
/** Logical (CSS) height of the primary display. */
|
||||
height: number
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', rect: { x: number; y: number; w: number; h: number }): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
// Selection rect stored in CSS pixels. null = nothing selected yet.
|
||||
interface Rect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
const rect = ref<Rect | null>(null)
|
||||
|
||||
// Drag state machine.
|
||||
type DragMode = 'idle' | 'creating' | 'moving'
|
||||
const dragMode = ref<DragMode>('idle')
|
||||
// For 'creating': the anchor where mousedown started.
|
||||
// For 'moving' : the mouse offset relative to the rect's top-left.
|
||||
const dragAnchor = ref({ x: 0, y: 0 })
|
||||
|
||||
// SVG path for "the entire screen with the selection rect cut out".
|
||||
// We rely on fill-rule:evenodd to subtract the inner rectangle.
|
||||
const maskPath = computed(() => {
|
||||
const outer = `M0 0 H${props.width} V${props.height} H0 Z`
|
||||
if (!rect.value) return outer
|
||||
const r = rect.value
|
||||
// Inner rect drawn in the OPPOSITE winding order so evenodd cuts it.
|
||||
const inner = `M${r.x} ${r.y} H${r.x + r.w} V${r.y + r.h} H${r.x} Z`
|
||||
return outer + ' ' + inner
|
||||
})
|
||||
|
||||
// Toolbar placement: anchor to bottom-right of the selection, but shove
|
||||
// it to the inside top-right if the selection is too close to the screen
|
||||
// edge to keep the buttons visible.
|
||||
const TOOLBAR_W = 220
|
||||
const TOOLBAR_H = 40
|
||||
const GAP = 8
|
||||
const toolbarPos = computed(() => {
|
||||
if (!rect.value) return null
|
||||
const r = rect.value
|
||||
let x = r.x + r.w - TOOLBAR_W
|
||||
let y = r.y + r.h + GAP
|
||||
if (y + TOOLBAR_H > props.height) {
|
||||
// No room below: put it inside the selection's bottom-right.
|
||||
y = r.y + r.h - TOOLBAR_H - GAP
|
||||
}
|
||||
if (x < 0) x = 0
|
||||
if (x + TOOLBAR_W > props.width) x = props.width - TOOLBAR_W
|
||||
return { x, y }
|
||||
})
|
||||
|
||||
const insideRect = (px: number, py: number, r: Rect | null) => {
|
||||
if (!r) return false
|
||||
return px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return
|
||||
const px = e.clientX
|
||||
const py = e.clientY
|
||||
if (rect.value && insideRect(px, py, rect.value)) {
|
||||
// Move existing selection.
|
||||
dragMode.value = 'moving'
|
||||
dragAnchor.value = { x: px - rect.value.x, y: py - rect.value.y }
|
||||
} else {
|
||||
// Start a new selection.
|
||||
dragMode.value = 'creating'
|
||||
dragAnchor.value = { x: px, y: py }
|
||||
rect.value = { x: px, y: py, w: 0, h: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (dragMode.value === 'idle') return
|
||||
const px = e.clientX
|
||||
const py = e.clientY
|
||||
if (dragMode.value === 'creating') {
|
||||
const ax = dragAnchor.value.x
|
||||
const ay = dragAnchor.value.y
|
||||
rect.value = {
|
||||
x: Math.min(ax, px),
|
||||
y: Math.min(ay, py),
|
||||
w: Math.abs(px - ax),
|
||||
h: Math.abs(py - ay),
|
||||
}
|
||||
} else if (dragMode.value === 'moving' && rect.value) {
|
||||
let nx = px - dragAnchor.value.x
|
||||
let ny = py - dragAnchor.value.y
|
||||
// Clamp inside the screen so the selection cannot be dragged off-canvas.
|
||||
nx = Math.max(0, Math.min(nx, props.width - rect.value.w))
|
||||
ny = Math.max(0, Math.min(ny, props.height - rect.value.h))
|
||||
rect.value = { ...rect.value, x: nx, y: ny }
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (dragMode.value === 'creating' && rect.value) {
|
||||
// Discard zero/tiny selections — the user probably clicked accidentally.
|
||||
if (rect.value.w < 4 || rect.value.h < 4) {
|
||||
rect.value = null
|
||||
}
|
||||
}
|
||||
dragMode.value = 'idle'
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (!rect.value) return
|
||||
emit('confirm', { ...rect.value })
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
// Selection size pill (Snipaste shows "WxH" near the top-left of selection).
|
||||
const sizeLabel = computed(() => {
|
||||
if (!rect.value) return ''
|
||||
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="overlay-root"
|
||||
:style="{ width: width + 'px', height: height + 'px' }"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@contextmenu.prevent="onCancel"
|
||||
>
|
||||
<!-- Layer 1: mask with selection cut out over the live desktop -->
|
||||
<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" />
|
||||
<!-- Selection border, drawn separately so it stays crisp. -->
|
||||
<rect
|
||||
v-if="rect"
|
||||
:x="rect.x"
|
||||
:y="rect.y"
|
||||
:width="rect.w"
|
||||
:height="rect.h"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="1.5"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Layer 3a: size readout floating above the selection -->
|
||||
<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>
|
||||
|
||||
<!-- Layer 3b: action toolbar -->
|
||||
<div
|
||||
v-if="toolbarPos && rect && rect.w >= 4 && rect.h >= 4"
|
||||
class="toolbar"
|
||||
:style="{ left: toolbarPos.x + 'px', top: toolbarPos.y + 'px' }"
|
||||
@mousedown.stop
|
||||
>
|
||||
<button class="btn cancel" @click="onCancel" title="Esc">Cancel</button>
|
||||
<button class="btn primary" @click="onConfirm" title="Enter">
|
||||
Upload & copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hint shown before the user has dragged anything. -->
|
||||
<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;
|
||||
}
|
||||
|
||||
.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;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.btn.cancel {
|
||||
background: transparent;
|
||||
color: #d1d5db;
|
||||
}
|
||||
.btn.cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.btn.primary {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.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;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,374 @@
|
||||
<script setup lang="ts">
|
||||
/*
|
||||
* SettingsView — the home screen of SnapGo before the user triggers a
|
||||
* capture. It exposes:
|
||||
* 1. Hotkey configuration
|
||||
* 2. S3-compatible OSS configuration
|
||||
* 3. Manual capture button (useful when developing or when the global
|
||||
* hotkey conflicts with another app)
|
||||
*
|
||||
* State flow: load() pulls config from Go on mount, save() pushes back.
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import {
|
||||
GetConfig,
|
||||
SaveConfig,
|
||||
TestConnection,
|
||||
CaptureNow,
|
||||
} from '../../wailsjs/go/main/App'
|
||||
|
||||
interface S3Config {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
pathPrefix: string
|
||||
publicUrlBase: string
|
||||
usePathStyle: boolean
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
hotkey: string
|
||||
s3: S3Config
|
||||
}
|
||||
|
||||
const config = ref<AppConfig>({
|
||||
hotkey: 'cmd+shift+a',
|
||||
s3: {
|
||||
endpoint: '',
|
||||
region: 'us-east-1',
|
||||
bucket: '',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
pathPrefix: 'snapgo/',
|
||||
publicUrlBase: '',
|
||||
usePathStyle: true,
|
||||
},
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const message = ref<{ kind: 'ok' | 'err'; text: string } | null>(null)
|
||||
|
||||
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) {
|
||||
config.value = cfg as unknown as AppConfig
|
||||
}
|
||||
}
|
||||
|
||||
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 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>
|
||||
|
||||
<section 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>
|
||||
</section>
|
||||
|
||||
<section 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>
|
||||
|
||||
<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: 24px;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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: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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.btn {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #374151;
|
||||
}
|
||||
.subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Vendored
+7
@@ -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()]
|
||||
})
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// 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.RegionRect):Promise<void>;
|
||||
|
||||
export function ConfirmRegion(arg1:main.RegionRect):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 ShowWindow():Promise<void>;
|
||||
|
||||
export function TestConnection(arg1:domain.S3Config):Promise<void>;
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
// @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 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 ShowWindow() {
|
||||
return window['go']['main']['App']['ShowWindow']();
|
||||
}
|
||||
|
||||
export function TestConnection(arg1) {
|
||||
return window['go']['main']['App']['TestConnection'](arg1);
|
||||
}
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
export namespace domain {
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 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"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@wailsapp/runtime",
|
||||
"version": "2.0.0",
|
||||
"description": "Wails Javascript runtime library",
|
||||
"main": "runtime.js",
|
||||
"types": "runtime.d.ts",
|
||||
"scripts": {
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wailsapp/wails.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Wails",
|
||||
"Javascript",
|
||||
"Go"
|
||||
],
|
||||
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
+330
@@ -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
|
||||
)
|
||||
|
||||
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/crypto v0.33.0 // 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,167 @@
|
||||
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/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,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,61 @@
|
||||
// Package domain — configuration types.
|
||||
//
|
||||
// S3Config is intentionally split into its own file 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"`
|
||||
}
|
||||
|
||||
// AppConfig is the top-level on-disk configuration document.
|
||||
//
|
||||
// We keep S3 nested so that adding more providers later (e.g. AliyunOSS, COS)
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 != ""
|
||||
}
|
||||
@@ -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,46 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,62 @@
|
||||
//go:build darwin
|
||||
|
||||
package tray
|
||||
|
||||
/*
|
||||
#cgo darwin LDFLAGS: -framework Foundation
|
||||
#include <dispatch/dispatch.h>
|
||||
|
||||
// runOnMainQueue submits the Go-side callback (identified by handle) to the
|
||||
// main dispatch queue. We use dispatch_async so the caller — typically a
|
||||
// Wails OnStartup goroutine — does not block the cocoa runloop while waiting
|
||||
// for the main thread to drain.
|
||||
extern void trayDispatchMainCallback(unsigned long handle);
|
||||
|
||||
static void runOnMainQueue(unsigned long handle) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
trayDispatchMainCallback(handle);
|
||||
});
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// dispatchRegistry stores Go closures keyed by an integer handle so that the
|
||||
// C side can call back into Go without leaking unsafe.Pointer-cast function
|
||||
// pointers (which CGo disallows).
|
||||
var (
|
||||
dispatchMu sync.Mutex
|
||||
dispatchTable = map[uint64]func(){}
|
||||
dispatchNextID uint64
|
||||
)
|
||||
|
||||
// dispatchOnMain schedules fn to run on the macOS main thread. This is the
|
||||
// Cocoa contract for any API that touches NSWindow / NSStatusBar — calling
|
||||
// them from a goroutine triggers the "should only be instantiated on the
|
||||
// main thread" assertion.
|
||||
func dispatchOnMain(fn func()) {
|
||||
if fn == nil {
|
||||
return
|
||||
}
|
||||
id := atomic.AddUint64(&dispatchNextID, 1)
|
||||
dispatchMu.Lock()
|
||||
dispatchTable[id] = fn
|
||||
dispatchMu.Unlock()
|
||||
C.runOnMainQueue(C.ulong(id))
|
||||
}
|
||||
|
||||
//export trayDispatchMainCallback
|
||||
func trayDispatchMainCallback(handle C.ulong) {
|
||||
id := uint64(handle)
|
||||
dispatchMu.Lock()
|
||||
fn := dispatchTable[id]
|
||||
delete(dispatchTable, id)
|
||||
dispatchMu.Unlock()
|
||||
if fn != nil {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !darwin
|
||||
|
||||
package tray
|
||||
|
||||
// dispatchOnMain is a no-op on non-darwin platforms because:
|
||||
// - On Linux (GTK/AppIndicator), systray.Run / nativeStart can be invoked
|
||||
// from any goroutine; the GTK runloop is started internally.
|
||||
// - On Windows, status icons are tied to a hidden window owned by the
|
||||
// systray library itself, also independent of the Go main goroutine.
|
||||
// Wrapping the call in dispatchOnMain on these platforms would just add a
|
||||
// pointless layer of indirection.
|
||||
func dispatchOnMain(fn func()) {
|
||||
if fn != nil {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build darwin
|
||||
|
||||
package tray
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// templateIconBytes embeds the macOS template icon as a multi-resolution TIFF
|
||||
// generated from the 1x and 2x source PNGs in assets/.
|
||||
//
|
||||
//go:embed assets/statusbarTemplate.tiff
|
||||
var templateIconBytes []byte
|
||||
|
||||
// regularIconBytes keeps a PNG fallback for APIs that expect a regular raster icon.
|
||||
//
|
||||
//go:embed assets/statusbarTemplate.png
|
||||
var regularIconBytes []byte
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !darwin
|
||||
|
||||
package tray
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Keep a regular PNG for non-macOS builds where template icons are unsupported.
|
||||
//
|
||||
//go:embed assets/statusbarTemplate.png
|
||||
var templateIconBytes []byte
|
||||
|
||||
//go:embed assets/statusbarTemplate.png
|
||||
var regularIconBytes []byte
|
||||
@@ -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,82 @@
|
||||
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/tray"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
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: 900,
|
||||
Height: 640,
|
||||
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,36 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import "C"
|
||||
|
||||
//export nativeOverlayConfirm
|
||||
func nativeOverlayConfirm(x, y, w, h C.int) {
|
||||
nativeOverlayState.Lock()
|
||||
app := nativeOverlayState.app
|
||||
nativeOverlayState.app = nil
|
||||
nativeOverlayState.Unlock()
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
_ = app.ConfirmNativeRegion(RegionRect{
|
||||
X: int(x),
|
||||
Y: int(y),
|
||||
W: int(w),
|
||||
H: int(h),
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
//export nativeOverlayCancel
|
||||
func nativeOverlayCancel() {
|
||||
nativeOverlayState.Lock()
|
||||
app := nativeOverlayState.app
|
||||
nativeOverlayState.app = nil
|
||||
nativeOverlayState.Unlock()
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
go app.CancelNativeRegion()
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
|
||||
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
|
||||
#include <math.h>
|
||||
#include <dispatch/dispatch.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
extern void nativeOverlayConfirm(int x, int y, int w, int h);
|
||||
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
|
||||
|
||||
@interface SnipNativeOverlayView : NSView
|
||||
@property BOOL hasSelection;
|
||||
@property BOOL creating;
|
||||
@property BOOL moving;
|
||||
@property NSRect selection;
|
||||
@property NSPoint anchor;
|
||||
@property NSPoint moveOffset;
|
||||
@property(strong) NSButton *cancelButton;
|
||||
@property(strong) NSButton *uploadButton;
|
||||
@property(strong) NSTextField *sizeLabel;
|
||||
@property(strong) NSTextField *hintLabel;
|
||||
- (void)syncControls;
|
||||
- (void)styleControls;
|
||||
- (void)confirmSelection;
|
||||
- (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]];
|
||||
|
||||
_cancelButton = [NSButton buttonWithTitle:@"Cancel" target:self action:@selector(cancelSelection)];
|
||||
_uploadButton = [NSButton buttonWithTitle:@"Upload & copy" target:self action:@selector(confirmSelection)];
|
||||
_sizeLabel = [NSTextField labelWithString:@""];
|
||||
_hintLabel = [NSTextField labelWithString:@"Drag to select an area · Esc to cancel"];
|
||||
[self styleControls];
|
||||
|
||||
for (NSView *view in @[_cancelButton, _uploadButton, _sizeLabel, _hintLabel]) {
|
||||
[self addSubview:view];
|
||||
}
|
||||
[_cancelButton setHidden:YES];
|
||||
[_uploadButton 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;
|
||||
}
|
||||
|
||||
- (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)styleControls {
|
||||
[self styleButton:_cancelButton
|
||||
background:[NSColor colorWithCalibratedWhite:0.12 alpha:0.96]
|
||||
foreground:[NSColor colorWithCalibratedWhite:0.86 alpha:1.0]];
|
||||
[self styleButton:_uploadButton
|
||||
background:[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0]
|
||||
foreground:[NSColor whiteColor]];
|
||||
}
|
||||
|
||||
- (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];
|
||||
} else {
|
||||
NSRectFill(self.bounds);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)mouseDown:(NSEvent *)event {
|
||||
NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil];
|
||||
if (_hasSelection && NSPointInRect(p, _selection)) {
|
||||
_moving = YES;
|
||||
_creating = NO;
|
||||
_moveOffset = NSMakePoint(p.x - _selection.origin.x, p.y - _selection.origin.y);
|
||||
} else {
|
||||
_creating = YES;
|
||||
_moving = NO;
|
||||
_hasSelection = YES;
|
||||
_anchor = p;
|
||||
_selection = NSMakeRect(p.x, p.y, 0, 0);
|
||||
}
|
||||
[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);
|
||||
}
|
||||
[self syncControls];
|
||||
[self setNeedsDisplay:YES];
|
||||
}
|
||||
|
||||
- (void)mouseUp:(NSEvent *)event {
|
||||
_creating = NO;
|
||||
_moving = 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];
|
||||
[_uploadButton 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)];
|
||||
|
||||
CGFloat toolbarW = 220;
|
||||
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));
|
||||
|
||||
[_cancelButton setFrame:NSMakeRect(x, y, 86, 32)];
|
||||
[_uploadButton setFrame:NSMakeRect(x + 92, y, 128, 32)];
|
||||
}
|
||||
|
||||
- (void)confirmSelection {
|
||||
if (!_hasSelection) {
|
||||
return;
|
||||
}
|
||||
NSRect r = _selection;
|
||||
[nativeOverlayWindow orderOut:nil];
|
||||
if (nativeOverlayKeyMonitor != nil) {
|
||||
[NSEvent removeMonitor:nativeOverlayKeyMonitor];
|
||||
nativeOverlayKeyMonitor = nil;
|
||||
}
|
||||
nativeOverlayWindow = nil;
|
||||
nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height));
|
||||
}
|
||||
|
||||
- (void)cancelSelection {
|
||||
[nativeOverlayWindow orderOut:nil];
|
||||
if (nativeOverlayKeyMonitor != nil) {
|
||||
[NSEvent removeMonitor:nativeOverlayKeyMonitor];
|
||||
nativeOverlayKeyMonitor = nil;
|
||||
}
|
||||
nativeOverlayWindow = nil;
|
||||
nativeOverlayCancel();
|
||||
}
|
||||
@end
|
||||
|
||||
static void snipShowNativeOverlay(int width, int height) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSScreen *screen = [NSScreen mainScreen];
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
|
||||
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
|
||||
#include <dispatch/dispatch.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
static NSWindow* snipMainWindow(void) {
|
||||
NSWindow *window = [NSApp mainWindow];
|
||||
if (window == nil && [[NSApp windows] count] > 0) {
|
||||
window = [[NSApp windows] objectAtIndex:0];
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
// snipMakeViewTransparent recursively clears AppKit/WKWebView backing
|
||||
// surfaces. Setting only NSWindow transparent is insufficient for Wails:
|
||||
// WKWebView may still paint an opaque default grey background.
|
||||
static void snipMakeViewTransparent(NSView *view) {
|
||||
if (view == nil) {
|
||||
return;
|
||||
}
|
||||
|
||||
Class wkWebViewClass = NSClassFromString(@"WKWebView");
|
||||
if (wkWebViewClass != nil && [view isKindOfClass:wkWebViewClass]) {
|
||||
@try {
|
||||
[view setValue:@NO forKey:@"drawsBackground"];
|
||||
} @catch (__unused NSException *exception) {
|
||||
}
|
||||
@try {
|
||||
id scrollView = [view valueForKey:@"scrollView"];
|
||||
if ([scrollView isKindOfClass:[NSScrollView class]]) {
|
||||
[(NSScrollView *)scrollView setDrawsBackground:NO];
|
||||
[(NSScrollView *)scrollView setBackgroundColor:[NSColor clearColor]];
|
||||
}
|
||||
} @catch (__unused NSException *exception) {
|
||||
}
|
||||
}
|
||||
|
||||
if ([view isKindOfClass:[NSScrollView class]]) {
|
||||
NSScrollView *scrollView = (NSScrollView *)view;
|
||||
[scrollView setDrawsBackground:NO];
|
||||
[scrollView setBackgroundColor:[NSColor clearColor]];
|
||||
}
|
||||
|
||||
[view setWantsLayer:YES];
|
||||
if ([view layer] != nil) {
|
||||
[[view layer] setOpaque:NO];
|
||||
[[view layer] setBackgroundColor:[[NSColor clearColor] CGColor]];
|
||||
}
|
||||
|
||||
@try {
|
||||
[view setValue:@NO forKey:@"opaque"];
|
||||
} @catch (__unused NSException *exception) {
|
||||
}
|
||||
|
||||
@try {
|
||||
[view setValue:@NO forKey:@"drawsBackground"];
|
||||
} @catch (__unused NSException *exception) {
|
||||
}
|
||||
|
||||
for (NSView *subview in [view subviews]) {
|
||||
snipMakeViewTransparent(subview);
|
||||
}
|
||||
}
|
||||
|
||||
static void snipConfigureOverlayWindow(void) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSWindow *window = snipMainWindow();
|
||||
NSScreen *screen = [NSScreen mainScreen];
|
||||
if (window == nil || screen == nil) {
|
||||
return;
|
||||
}
|
||||
|
||||
[window setOpaque:NO];
|
||||
[window setBackgroundColor:[NSColor clearColor]];
|
||||
snipMakeViewTransparent([window contentView]);
|
||||
[window setLevel:NSScreenSaverWindowLevel];
|
||||
[window setCollectionBehavior:
|
||||
NSWindowCollectionBehaviorCanJoinAllSpaces |
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary |
|
||||
NSWindowCollectionBehaviorStationary];
|
||||
[window setFrame:[screen frame] display:YES];
|
||||
});
|
||||
}
|
||||
|
||||
static void snipRestoreOverlayWindow(void) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSWindow *window = snipMainWindow();
|
||||
if (window == nil) {
|
||||
return;
|
||||
}
|
||||
|
||||
[window setLevel:NSNormalWindowLevel];
|
||||
[window setCollectionBehavior:NSWindowCollectionBehaviorDefault];
|
||||
[window setOpaque:YES];
|
||||
[window setBackgroundColor:
|
||||
[NSColor colorWithCalibratedRed:246.0/255.0
|
||||
green:247.0/255.0
|
||||
blue:250.0/255.0
|
||||
alpha:1.0]];
|
||||
});
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
// configureOverlayWindow promotes the Wails NSWindow to a transparent,
|
||||
// screen-level overlay. Wails can size a frameless window, but AppKit-only
|
||||
// flags are needed to cover the menu bar and keep the WebView transparent.
|
||||
func configureOverlayWindow() {
|
||||
C.snipConfigureOverlayWindow()
|
||||
}
|
||||
|
||||
// restoreOverlayWindow returns the NSWindow to normal app-window behaviour
|
||||
// before the settings UI is shown again.
|
||||
func restoreOverlayWindow() {
|
||||
C.snipRestoreOverlayWindow()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
// configureOverlayWindow is a macOS-only AppKit refinement. Other platforms
|
||||
// rely on Wails' own frameless/always-on-top window behaviour.
|
||||
func configureOverlayWindow() {}
|
||||
|
||||
// restoreOverlayWindow is a macOS-only AppKit refinement.
|
||||
func restoreOverlayWindow() {}
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# create-dev-cert.sh — Create a stable, self-signed code-signing certificate.
|
||||
#
|
||||
# Why this exists:
|
||||
# macOS's privacy database (TCC) tracks app identity primarily by the
|
||||
# code-signing cdhash (for ad-hoc) or the certificate (for properly signed
|
||||
# apps). Wails default ad-hoc signing produces a new cdhash on every build,
|
||||
# so TCC keeps re-prompting for Screen Recording / Accessibility permission.
|
||||
#
|
||||
# By creating ONE self-signed certificate locally and reusing it for every
|
||||
# build, the signing identity never changes, so TCC remembers the grant.
|
||||
#
|
||||
# Idempotent: if the cert already exists with the configured CN, this script
|
||||
# is a no-op and exits 0.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
CERT_CN="${CERT_CN:-SnapGo Dev Cert}"
|
||||
KEYCHAIN="${KEYCHAIN:-login.keychain-db}"
|
||||
|
||||
# 1. Skip ONLY if a usable code-signing identity exists. We deliberately do
|
||||
# not skip on the mere presence of a certificate object, because a partial
|
||||
# import (e.g. wrong PKCS#12 password) leaves the cert behind without a
|
||||
# private key, which makes codesign fail later in confusing ways.
|
||||
if security find-identity -v -p codesigning 2>/dev/null | grep -q "\"${CERT_CN}\""; then
|
||||
echo "[create-dev-cert.sh] usable identity '${CERT_CN}' already present — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1b. Clean any stale certificate-only entry from a previous failed import,
|
||||
# so the new import does not collide on duplicate CN.
|
||||
if security find-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1; then
|
||||
echo "[create-dev-cert.sh] removing stale (key-less) cert entry from previous run..."
|
||||
# Loop because there may be multiple stale copies.
|
||||
while security find-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1; do
|
||||
security delete-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1 || break
|
||||
done
|
||||
fi
|
||||
|
||||
# 2. Generate a self-signed code-signing cert via openssl + import via security.
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
KEY="${WORK}/dev.key"
|
||||
CSR="${WORK}/dev.csr"
|
||||
CRT="${WORK}/dev.crt"
|
||||
P12="${WORK}/dev.p12"
|
||||
EXTFILE="${WORK}/ext.cnf"
|
||||
|
||||
# X509 v3 extension file enabling "Code Signing" extended key usage (EKU).
|
||||
cat > "${EXTFILE}" <<'EOF'
|
||||
[v3_ext]
|
||||
keyUsage = critical,digitalSignature
|
||||
extendedKeyUsage = critical,codeSigning
|
||||
basicConstraints = CA:false
|
||||
EOF
|
||||
|
||||
echo "[create-dev-cert.sh] generating private key..."
|
||||
openssl genrsa -out "${KEY}" 2048 >/dev/null 2>&1
|
||||
|
||||
echo "[create-dev-cert.sh] generating CSR..."
|
||||
openssl req -new -key "${KEY}" -out "${CSR}" -subj "/CN=${CERT_CN}/O=SnapGo Dev/C=US" >/dev/null 2>&1
|
||||
|
||||
echo "[create-dev-cert.sh] self-signing certificate (10 years)..."
|
||||
openssl x509 -req -in "${CSR}" -signkey "${KEY}" -out "${CRT}" \
|
||||
-days 3650 -extfile "${EXTFILE}" -extensions v3_ext >/dev/null 2>&1
|
||||
|
||||
echo "[create-dev-cert.sh] bundling into PKCS#12..."
|
||||
# `-legacy` is REQUIRED on OpenSSL 3+: macOS's `security` tool can only
|
||||
# verify the legacy PKCS#12 MAC (SHA-1 / 3DES). Without this flag the import
|
||||
# fails with "MAC verification failed during PKCS12 import".
|
||||
# Use a non-empty passphrase to avoid edge cases where empty pass triggers
|
||||
# different MAC behaviour on some openssl builds.
|
||||
P12_PASS="snapgo"
|
||||
openssl pkcs12 -export -legacy \
|
||||
-inkey "${KEY}" -in "${CRT}" -out "${P12}" \
|
||||
-name "${CERT_CN}" -passout "pass:${P12_PASS}" >/dev/null 2>&1
|
||||
|
||||
echo "[create-dev-cert.sh] importing into ${KEYCHAIN} (you may be prompted for your login password)..."
|
||||
security import "${P12}" -k "${KEYCHAIN}" -P "${P12_PASS}" -T /usr/bin/codesign
|
||||
|
||||
# 3. Mark the cert as trusted for code signing. This requires admin.
|
||||
# We use `security add-trusted-cert` with policy `codeSign`.
|
||||
echo "[create-dev-cert.sh] adding trust setting (admin password may be required)..."
|
||||
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \
|
||||
-p codeSign "${CRT}" || {
|
||||
echo "[create-dev-cert.sh] WARN: trust setting could not be added. The cert"
|
||||
echo " is still importable for codesign use, but TCC may still re-prompt"
|
||||
echo " unless the cert is trusted. Re-run as admin to fix."
|
||||
}
|
||||
|
||||
echo "[create-dev-cert.sh] done. Verify with:"
|
||||
echo " security find-identity -v -p codesigning"
|
||||
echo "You should see: '${CERT_CN}'"
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dev-build.sh — Local dev rebuild + stable re-sign + open.
|
||||
#
|
||||
# Use this instead of plain `wails build` while iterating, so that every
|
||||
# rebuild uses the same self-signed dev certificate (created by
|
||||
# create-dev-cert.sh). This keeps macOS TCC from re-prompting for Screen
|
||||
# Recording / Accessibility permissions after every code change.
|
||||
#
|
||||
# Workflow:
|
||||
# ./scripts/create-dev-cert.sh # one time
|
||||
# ./scripts/dev-build.sh # every rebuild
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
CERT_CN="${CERT_CN:-SnapGo Dev Cert}"
|
||||
|
||||
# 1. Make sure wails is on PATH (works whether or not the user added it).
|
||||
export PATH="$(go env GOBIN):${PATH}"
|
||||
|
||||
# 2. Make sure the dev cert exists. If not, abort with a clear pointer.
|
||||
if ! security find-identity -v -p codesigning | grep -q "${CERT_CN}"; then
|
||||
echo "[dev-build.sh] ERROR: code-signing identity '${CERT_CN}' not found." >&2
|
||||
echo " Run ./scripts/create-dev-cert.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Stop any running instance so the rebuild is not blocked by file locks
|
||||
# and so the next launch uses the fresh binary.
|
||||
pkill -f SnapGo 2>/dev/null || true
|
||||
sleep 0.4
|
||||
|
||||
# 4. Build via Wails. We deliberately keep the default ad-hoc-style packaging
|
||||
# and re-sign right after, since `wails build` does not expose a
|
||||
# --identity flag in v2.
|
||||
ARCH="${ARCH:-arm64}"
|
||||
echo "[dev-build.sh] building darwin/${ARCH}..."
|
||||
wails build -platform "darwin/${ARCH}" -clean
|
||||
|
||||
# 5. Re-sign with the stable dev cert. We invoke sign.sh through env var
|
||||
# DEVELOPER_ID_APPLICATION which it accepts as the identity.
|
||||
APP_PATH="${ROOT_DIR}/build/bin/SnapGo.app"
|
||||
echo "[dev-build.sh] re-signing ${APP_PATH} with '${CERT_CN}'..."
|
||||
DEVELOPER_ID_APPLICATION="${CERT_CN}" SIGN_MODE=developer-id \
|
||||
"${SCRIPT_DIR}/sign.sh" >/dev/null
|
||||
|
||||
# 6. Sync to /Applications and launch from there.
|
||||
# Why /Applications: macOS TCC tracks an app by its code-signing identity
|
||||
# AND its on-disk path. Running from a stable absolute path makes the
|
||||
# privacy grants stick. We deliberately do NOT launch from build/bin to
|
||||
# avoid creating a second TCC entry every time the build dir moves.
|
||||
INSTALL_PATH="/Applications/SnapGo.app"
|
||||
echo "[dev-build.sh] syncing to ${INSTALL_PATH}..."
|
||||
rm -rf "${INSTALL_PATH}"
|
||||
cp -R "${APP_PATH}" "${INSTALL_PATH}"
|
||||
|
||||
echo "[dev-build.sh] opening app..."
|
||||
open "${INSTALL_PATH}"
|
||||
|
||||
echo "[dev-build.sh] done. If TCC still re-prompts, run:"
|
||||
echo " tccutil reset ScreenCapture io.snapgo.app"
|
||||
echo " tccutil reset Accessibility io.snapgo.app"
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# make-dmg.sh — Build a distributable .dmg for SnapGo.app
|
||||
#
|
||||
# Design rationale:
|
||||
# - Prefer `create-dmg` (https://github.com/create-dmg/create-dmg) when available
|
||||
# because it produces a polished installer with /Applications drag target,
|
||||
# custom background and proper icon layout.
|
||||
# - Fall back to a plain `hdiutil create` UDZO image so the script always works
|
||||
# on a fresh machine even without homebrew. The fallback DMG still shows the
|
||||
# app + a symlink to /Applications so users can drag-install.
|
||||
# - Output is always written to build/bin/SnapGo-<version>-<arch>.dmg so it
|
||||
# does not collide with the .app bundle.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
APP_PATH="${APP_PATH:-${ROOT_DIR}/build/bin/SnapGo.app}"
|
||||
OUT_DIR="${OUT_DIR:-${ROOT_DIR}/build/bin}"
|
||||
|
||||
if [[ ! -d "${APP_PATH}" ]]; then
|
||||
echo "[make-dmg.sh] ERROR: ${APP_PATH} not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------- Version / arch derivation --------
|
||||
VERSION="${VERSION:-$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
|
||||
"${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "0.1.0")}"
|
||||
ARCH="${ARCH:-$(uname -m)}" # arm64 or x86_64
|
||||
DMG_NAME="SnapGo-${VERSION}-${ARCH}.dmg"
|
||||
DMG_PATH="${OUT_DIR}/${DMG_NAME}"
|
||||
VOLNAME="SnapGo ${VERSION}"
|
||||
|
||||
mkdir -p "${OUT_DIR}"
|
||||
rm -f "${DMG_PATH}"
|
||||
|
||||
# -------- Path 1: create-dmg (pretty) --------
|
||||
if command -v create-dmg >/dev/null 2>&1; then
|
||||
echo "[make-dmg.sh] using create-dmg"
|
||||
# NOTE: create-dmg refuses to run when the output exists; we already removed it.
|
||||
create-dmg \
|
||||
--volname "${VOLNAME}" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 600 360 \
|
||||
--icon-size 100 \
|
||||
--icon "SnapGo.app" 160 180 \
|
||||
--hide-extension "SnapGo.app" \
|
||||
--app-drop-link 440 180 \
|
||||
--no-internet-enable \
|
||||
"${DMG_PATH}" \
|
||||
"${APP_PATH}"
|
||||
else
|
||||
# -------- Path 2: hdiutil fallback (always works) --------
|
||||
echo "[make-dmg.sh] create-dmg not found, using hdiutil fallback"
|
||||
STAGING="$(mktemp -d)"
|
||||
trap 'rm -rf "${STAGING}"' EXIT
|
||||
|
||||
# Copy the .app and add an /Applications symlink so users can drag-install.
|
||||
cp -R "${APP_PATH}" "${STAGING}/"
|
||||
ln -s /Applications "${STAGING}/Applications"
|
||||
|
||||
hdiutil create \
|
||||
-volname "${VOLNAME}" \
|
||||
-srcfolder "${STAGING}" \
|
||||
-ov -format UDZO \
|
||||
"${DMG_PATH}"
|
||||
fi
|
||||
|
||||
# -------- Sign the DMG itself if a Developer ID is available --------
|
||||
# Notarization requires the DMG be signed too.
|
||||
if [[ -n "${DEVELOPER_ID_APPLICATION:-}" ]]; then
|
||||
echo "[make-dmg.sh] signing DMG with: ${DEVELOPER_ID_APPLICATION}"
|
||||
codesign --force --sign "${DEVELOPER_ID_APPLICATION}" --timestamp "${DMG_PATH}"
|
||||
elif security find-identity -v -p codesigning 2>/dev/null \
|
||||
| grep -q "Developer ID Application"; then
|
||||
IDENT=$(security find-identity -v -p codesigning \
|
||||
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||
echo "[make-dmg.sh] signing DMG with auto-detected identity: ${IDENT}"
|
||||
codesign --force --sign "${IDENT}" --timestamp "${DMG_PATH}"
|
||||
else
|
||||
echo "[make-dmg.sh] no Developer ID identity, DMG left unsigned (ad-hoc build)."
|
||||
fi
|
||||
|
||||
echo "[make-dmg.sh] OK -> ${DMG_PATH}"
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# notarize.sh — Submit the signed DMG to Apple Notary Service and staple the
|
||||
# returned ticket so users get a green Gatekeeper check offline.
|
||||
#
|
||||
# Design rationale:
|
||||
# - We use `xcrun notarytool` (Xcode 13+). The legacy `altool` is deprecated.
|
||||
# - Two credential modes are supported, in order of preference:
|
||||
# 1. KEYCHAIN_PROFILE — recommended, created once via:
|
||||
# xcrun notarytool store-credentials "snapgo-notary" \
|
||||
# --apple-id "<APPLE_ID>" \
|
||||
# --team-id "<TEAMID>" \
|
||||
# --password "<APP_SPECIFIC_PASSWORD>"
|
||||
# 2. APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD env vars.
|
||||
# - After successful notarization we run `xcrun stapler staple` so the app/dmg
|
||||
# is verifiable without an internet connection on the user's Mac.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
DMG_PATH="${DMG_PATH:-}"
|
||||
if [[ -z "${DMG_PATH}" ]]; then
|
||||
# Auto-detect the most recent DMG produced by make-dmg.sh
|
||||
DMG_PATH="$(ls -1t "${ROOT_DIR}/build/bin/"SnapGo-*.dmg 2>/dev/null | head -1 || true)"
|
||||
fi
|
||||
if [[ -z "${DMG_PATH}" || ! -f "${DMG_PATH}" ]]; then
|
||||
echo "[notarize.sh] ERROR: DMG not found. Set DMG_PATH or run make-dmg.sh first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------- Build the credential argv array --------
|
||||
# Using array form keeps spaces in passwords / paths safe.
|
||||
NOTARY_ARGS=()
|
||||
if [[ -n "${KEYCHAIN_PROFILE:-}" ]]; then
|
||||
NOTARY_ARGS+=(--keychain-profile "${KEYCHAIN_PROFILE}")
|
||||
elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
|
||||
NOTARY_ARGS+=(--apple-id "${APPLE_ID}"
|
||||
--team-id "${APPLE_TEAM_ID}"
|
||||
--password "${APPLE_APP_SPECIFIC_PASSWORD}")
|
||||
else
|
||||
cat >&2 <<EOF
|
||||
[notarize.sh] ERROR: notary credentials not configured.
|
||||
|
||||
Option A (recommended) — store once in keychain, then export KEYCHAIN_PROFILE:
|
||||
xcrun notarytool store-credentials "snapgo-notary" \\
|
||||
--apple-id "you@example.com" \\
|
||||
--team-id "ABCDE12345" \\
|
||||
--password "<app-specific-password>"
|
||||
export KEYCHAIN_PROFILE=snapgo-notary
|
||||
|
||||
Option B — export env vars directly:
|
||||
export APPLE_ID="you@example.com"
|
||||
export APPLE_TEAM_ID="ABCDE12345"
|
||||
export APPLE_APP_SPECIFIC_PASSWORD="abcd-efgh-ijkl-mnop"
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[notarize.sh] submitting: ${DMG_PATH}"
|
||||
xcrun notarytool submit "${DMG_PATH}" "${NOTARY_ARGS[@]}" --wait
|
||||
|
||||
echo "[notarize.sh] stapling ticket..."
|
||||
xcrun stapler staple "${DMG_PATH}"
|
||||
xcrun stapler validate "${DMG_PATH}"
|
||||
|
||||
# Also staple the .app inside, if we still have it on disk — this ensures the
|
||||
# offline Gatekeeper check works even if the user copies the app out of the DMG.
|
||||
APP_PATH="${ROOT_DIR}/build/bin/SnapGo.app"
|
||||
if [[ -d "${APP_PATH}" ]]; then
|
||||
echo "[notarize.sh] stapling app bundle..."
|
||||
xcrun stapler staple "${APP_PATH}" || true
|
||||
fi
|
||||
|
||||
echo "[notarize.sh] OK -> ${DMG_PATH}"
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# release.sh — One-shot build → sign → DMG → notarize → staple pipeline.
|
||||
#
|
||||
# Design rationale:
|
||||
# - Each stage is delegated to a dedicated single-purpose script. This keeps the
|
||||
# pipeline composable: a developer can re-run any individual stage without
|
||||
# redoing the whole release.
|
||||
# - Notarization is OPT-IN. Setting NOTARIZE=1 (or providing notary credentials)
|
||||
# triggers it; otherwise we stop after producing a signed DMG, which is enough
|
||||
# for internal distribution and CI smoke tests.
|
||||
# - ARCH defaults to the host arch so a developer on Apple Silicon gets an
|
||||
# arm64 build. Set ARCH=universal to ship a fat binary.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
ARCH="${ARCH:-$(uname -m)}"
|
||||
case "${ARCH}" in
|
||||
arm64) WAILS_PLATFORM="darwin/arm64" ;;
|
||||
x86_64) WAILS_PLATFORM="darwin/amd64" ;;
|
||||
universal) WAILS_PLATFORM="darwin/universal" ;;
|
||||
*) echo "[release.sh] ERROR: unsupported ARCH=${ARCH}" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "================================================================"
|
||||
echo " SnapGo release pipeline"
|
||||
echo " arch : ${ARCH} (${WAILS_PLATFORM})"
|
||||
echo " sign : ${SIGN_MODE:-auto}"
|
||||
echo " notarize : ${NOTARIZE:-0}"
|
||||
echo "================================================================"
|
||||
|
||||
# ---- 1. Build ----
|
||||
echo ""
|
||||
echo "[release.sh] (1/4) wails build"
|
||||
( cd "${ROOT_DIR}" && wails build -platform "${WAILS_PLATFORM}" -clean )
|
||||
|
||||
# ---- 2. Sign .app ----
|
||||
echo ""
|
||||
echo "[release.sh] (2/4) sign app"
|
||||
"${SCRIPT_DIR}/sign.sh"
|
||||
|
||||
# ---- 3. DMG ----
|
||||
echo ""
|
||||
echo "[release.sh] (3/4) make DMG"
|
||||
ARCH="${ARCH}" "${SCRIPT_DIR}/make-dmg.sh"
|
||||
|
||||
# ---- 4. Notarize (optional) ----
|
||||
echo ""
|
||||
if [[ "${NOTARIZE:-0}" == "1" || -n "${KEYCHAIN_PROFILE:-}" \
|
||||
|| ( -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" \
|
||||
&& -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ) ]]; then
|
||||
echo "[release.sh] (4/4) notarize"
|
||||
"${SCRIPT_DIR}/notarize.sh"
|
||||
else
|
||||
echo "[release.sh] (4/4) notarize SKIPPED (set NOTARIZE=1 with credentials to enable)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[release.sh] DONE."
|
||||
ls -lh "${ROOT_DIR}/build/bin/"SnapGo-*.dmg 2>/dev/null || true
|
||||
Executable
+134
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# sign.sh — Sign SnapGo.app with Hardened Runtime.
|
||||
#
|
||||
# Design rationale:
|
||||
# - Two modes are supported, controlled purely by env vars (no CLI args), so
|
||||
# the script stays idempotent and easy to wire into CI:
|
||||
# 1. "developer-id" : real Developer ID Application signing for distribution
|
||||
# (requires DEVELOPER_ID_APPLICATION env or default
|
||||
# lookup via `security find-identity`).
|
||||
# 2. "ad-hoc" : codesign with `-` identity. The bundle works on the
|
||||
# building machine after `xattr -dr com.apple.quarantine`,
|
||||
# but cannot be notarized.
|
||||
# - We always sign with --options=runtime so the bundle is notarization-ready.
|
||||
# - We sign nested binaries first, then the .app bundle (Apple's required order).
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# -------- Paths --------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
APP_PATH="${APP_PATH:-${ROOT_DIR}/build/bin/SnapGo.app}"
|
||||
ENTITLEMENTS="${ENTITLEMENTS:-${ROOT_DIR}/build/darwin/entitlements.plist}"
|
||||
|
||||
# -------- Mode resolution --------
|
||||
# SIGN_MODE=developer-id | ad-hoc (default: ad-hoc when no identity is found)
|
||||
SIGN_MODE="${SIGN_MODE:-auto}"
|
||||
SIGN_IDENTITY=""
|
||||
|
||||
resolve_identity() {
|
||||
if [[ -n "${DEVELOPER_ID_APPLICATION:-}" ]]; then
|
||||
SIGN_IDENTITY="${DEVELOPER_ID_APPLICATION}"
|
||||
SIGN_MODE="developer-id"
|
||||
return
|
||||
fi
|
||||
# Try to auto-discover the first Developer ID Application identity.
|
||||
local found
|
||||
found=$(security find-identity -v -p codesigning 2>/dev/null \
|
||||
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
|
||||
if [[ -n "${found}" ]]; then
|
||||
SIGN_IDENTITY="${found}"
|
||||
SIGN_MODE="developer-id"
|
||||
else
|
||||
SIGN_IDENTITY="-"
|
||||
SIGN_MODE="ad-hoc"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "${SIGN_MODE}" == "auto" ]]; then
|
||||
resolve_identity
|
||||
elif [[ "${SIGN_MODE}" == "developer-id" ]]; then
|
||||
if [[ -z "${DEVELOPER_ID_APPLICATION:-}" ]]; then
|
||||
resolve_identity
|
||||
if [[ "${SIGN_MODE}" != "developer-id" ]]; then
|
||||
echo "[sign.sh] ERROR: SIGN_MODE=developer-id but no Developer ID Application identity found." >&2
|
||||
echo " Set DEVELOPER_ID_APPLICATION='Developer ID Application: NAME (TEAMID)'" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
SIGN_IDENTITY="${DEVELOPER_ID_APPLICATION}"
|
||||
fi
|
||||
elif [[ "${SIGN_MODE}" == "ad-hoc" ]]; then
|
||||
SIGN_IDENTITY="-"
|
||||
else
|
||||
echo "[sign.sh] ERROR: invalid SIGN_MODE='${SIGN_MODE}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -------- Sanity check --------
|
||||
if [[ ! -d "${APP_PATH}" ]]; then
|
||||
echo "[sign.sh] ERROR: app bundle not found at ${APP_PATH}" >&2
|
||||
echo " Run 'wails build -platform darwin/arm64 -clean' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "${ENTITLEMENTS}" ]]; then
|
||||
echo "[sign.sh] ERROR: entitlements not found at ${ENTITLEMENTS}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[sign.sh] mode = ${SIGN_MODE}"
|
||||
echo "[sign.sh] identity = ${SIGN_IDENTITY}"
|
||||
echo "[sign.sh] app = ${APP_PATH}"
|
||||
echo "[sign.sh] ents = ${ENTITLEMENTS}"
|
||||
|
||||
# -------- Decide whether to request RFC3161 timestamp --------
|
||||
# Apple's timestamp server only accepts signatures from Developer ID-issued
|
||||
# certificates. For ad-hoc or local self-signed certs the timestamp call
|
||||
# either fails or attaches noise that makes re-signing nondeterministic
|
||||
# (which in turn breaks TCC's "is this the same app?" comparison).
|
||||
TIMESTAMP_FLAG="--timestamp=none"
|
||||
if [[ "${SIGN_MODE}" == "developer-id" && "${SIGN_IDENTITY}" == "Developer ID Application:"* ]]; then
|
||||
TIMESTAMP_FLAG="--timestamp"
|
||||
fi
|
||||
echo "[sign.sh] timestamp = ${TIMESTAMP_FLAG}"
|
||||
|
||||
# -------- Strip stale signatures so re-signing is deterministic --------
|
||||
codesign --remove-signature "${APP_PATH}" 2>/dev/null || true
|
||||
|
||||
# -------- Sign nested executables first (Apple required ordering) --------
|
||||
# Find any embedded Mach-O binaries / frameworks / dylibs.
|
||||
NESTED_PATHS=()
|
||||
while IFS= read -r path; do
|
||||
NESTED_PATHS+=("$path")
|
||||
done < <(find "${APP_PATH}/Contents" \
|
||||
\( -name "*.dylib" -o -name "*.framework" -o -path "*/Frameworks/*" \) \
|
||||
-not -path "*/_CodeSignature/*" 2>/dev/null || true)
|
||||
|
||||
for nested in "${NESTED_PATHS[@]:-}"; do
|
||||
[[ -z "${nested}" ]] && continue
|
||||
echo "[sign.sh] signing nested: ${nested}"
|
||||
codesign --force ${TIMESTAMP_FLAG} --options=runtime \
|
||||
--sign "${SIGN_IDENTITY}" \
|
||||
"${nested}"
|
||||
done
|
||||
|
||||
# -------- Sign the main app bundle --------
|
||||
echo "[sign.sh] signing app bundle..."
|
||||
codesign --force ${TIMESTAMP_FLAG} --options=runtime \
|
||||
--entitlements "${ENTITLEMENTS}" \
|
||||
--sign "${SIGN_IDENTITY}" \
|
||||
"${APP_PATH}"
|
||||
|
||||
# -------- Verify --------
|
||||
echo "[sign.sh] verifying signature..."
|
||||
codesign --verify --deep --strict --verbose=2 "${APP_PATH}"
|
||||
|
||||
if [[ "${SIGN_MODE}" == "developer-id" ]]; then
|
||||
# Gatekeeper assessment will pass only after notarization + stapling,
|
||||
# but we can pre-flight the spctl evaluation now.
|
||||
echo "[sign.sh] spctl assessment (will likely say 'rejected' until notarized)..."
|
||||
spctl --assess --type execute --verbose=4 "${APP_PATH}" || true
|
||||
fi
|
||||
|
||||
echo "[sign.sh] OK."
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "SnapGo",
|
||||
"outputfilename": "SnapGo",
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
"author": {
|
||||
"name": "mmmy",
|
||||
"email": "mmmy@example.com"
|
||||
},
|
||||
"info": {
|
||||
"productName": "SnapGo",
|
||||
"productVersion": "0.1.0",
|
||||
"copyright": "Copyright © 2026",
|
||||
"comments": "Cross-platform screenshot tool with one-click upload to S3"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user