feat(app): implement basic function

General
- 配置界面支持配置对象存储桶
- 快捷键进入截图
- 一键上传截图 & 复制截图链接

MacOS
- 状态栏指示器和应用图标
This commit is contained in:
2026-05-17 15:51:02 +08:00
parent 6c00bf5a6f
commit ceecbb6af4
84 changed files with 7160 additions and 4 deletions
+3
View File
@@ -16,3 +16,6 @@ scripts/secrets.sh
# IDE
.trae/
.vscode/
# OS
*.DS_Store
+36
View File
@@ -0,0 +1,36 @@
# SnapGo
SnapGo 是一个以 macOS 为核心体验的截图上传工具,面向“截图后立刻分享链接”的高频场景。它常驻于菜单栏,支持全局快捷键唤起截图覆盖层,在完成区域选择后自动将图片上传到兼容 S3 协议的对象存储,并把最终公开链接写入剪贴板,尽可能减少“截图、保存、上传、复制链接”之间的手动切换成本。
项目当前基于 `Wails + Go + Vue 3` 构建,桌面端负责截图、热键、托盘与系统集成,前端负责设置页与截图交互界面。整体设计围绕“轻量常驻、快速触发、上传即复制”展开,适合作为个人图床工作流中的本地入口工具。
## 核心功能
- 全局快捷键截图:支持通过全局快捷键在任意应用中快速唤起截图流程。
- 区域选择覆盖层:提供接近原生体验的截图选区交互,优先针对 macOS 做了透明覆盖层与窗口层级优化。
- 自动上传到 S3 兼容存储:支持 AWS S3、MinIO、Cloudflare R2、Backblaze B2 及其他兼容 S3 API 的对象存储。
- 自动复制公开链接:上传成功后自动将图片 URL 写入剪贴板,方便直接粘贴到 IM、文档或工单系统。
- 菜单栏常驻与设置面板:通过托盘菜单进入设置,配置热键、存储参数,并测试连接可用性。
- 上传失败兜底:当上传失败时,会将截图落盘到本地临时目录,避免截图结果丢失。
## 典型使用流程
1. 首次启动后,在设置页中填写 S3 兼容对象存储的连接信息。
2. 保存配置并测试连通性,确认目标 Bucket 可写。
3. 使用默认快捷键 `cmd+shift+a`,或自定义快捷键,随时发起截图。
4. 选择需要截取的区域并确认。
5. 应用自动上传截图,并将生成的公开链接复制到剪贴板。
## 项目定位
SnapGo 当前更偏向一个面向个人使用的高效工具,而不是“大而全”的截图平台。它的重点不在复杂标注或云端管理能力,而在于把“截图并生成可分享链接”这条链路压缩到尽可能短,尤其适合开发者、产品、测试或需要频繁在聊天工具中分享截图的用户。
虽然工程结构中保留了跨平台方向的设计,但现阶段的核心交互与系统集成主要围绕 macOS 打磨,包括菜单栏形态、原生覆盖层、窗口透明化处理以及与系统截图能力的衔接。因此,如果你希望获得完整体验,macOS 是当前最合适的运行平台。
## 技术实现概览
- 桌面壳与应用编排:Go + Wails
- 设置界面与截图覆盖层:Vue 3 + TypeScript + Vite
- 全局快捷键:`golang.design/x/hotkey`
- 对象存储接入:AWS SDK for Go v2S3 兼容实现)
- macOS 截图与窗口控制:系统 `screencapture` 命令 + AppKit 原生能力
+28 -4
View File
@@ -1,10 +1,10 @@
# SnipPicGo
# SnapGo
SnipPicGo 是一个为“截完图马上发链接”而做的轻量截图工具。
SnapGo 是一个为“截完图马上发链接”而做的轻量截图工具。
它常驻菜单栏,通过全局快捷键一键唤起截图,选区确认后自动上传到你自己的 S3 兼容对象存储,并把图片链接直接复制到剪贴板。你不需要再经历“截图 -> 保存本地 -> 打开图床 -> 上传 -> 复制链接”这串繁琐操作。
如果你经常在聊天工具、工单、文档、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 是目前最推荐的使用平台。
+71
View File
@@ -0,0 +1,71 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework AppKit
#include <dispatch/dispatch.h>
#import <AppKit/AppKit.h>
static id snipDidHideObserver = nil;
static void snipSetActivationPolicy(bool regular, bool activate) {
void (^work)(void) = ^{
[NSApp setActivationPolicy: regular
? NSApplicationActivationPolicyRegular
: NSApplicationActivationPolicyAccessory];
if (activate) {
[NSApp unhide:nil];
[NSApp activateIgnoringOtherApps:YES];
}
};
if ([NSThread isMainThread]) {
work();
} else {
dispatch_sync(dispatch_get_main_queue(), work);
}
}
static void snipInstallActivationPolicyHooks(void) {
void (^work)(void) = ^{
if (snipDidHideObserver != nil) {
return;
}
snipDidHideObserver = [[NSNotificationCenter defaultCenter]
addObserverForName:NSApplicationDidHideNotification
object:NSApp
queue:[NSOperationQueue mainQueue]
usingBlock:^(__unused NSNotification *note) {
[NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
}];
};
if ([NSThread isMainThread]) {
work();
} else {
dispatch_sync(dispatch_get_main_queue(), work);
}
}
*/
import "C"
// installActivationPolicyHooks observes the native hide path used by Wails'
// HideWindowOnClose on macOS. Closing Settings calls `[NSApp hide:nil]`,
// which does not reliably pass through Wails' Go OnBeforeClose hook.
func installActivationPolicyHooks() {
C.snipInstallActivationPolicyHooks()
}
// showDockIcon makes the app behave like a normal foreground macOS app while
// Settings is visible.
func showDockIcon(activate bool) {
C.snipSetActivationPolicy(true, C.bool(activate))
}
// hideDockIcon returns the app to menu-bar-agent mode: no persistent Dock
// icon, while the status-bar item remains available.
func hideDockIcon() {
C.snipSetActivationPolicy(false, false)
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !darwin
package main
// installActivationPolicyHooks is only meaningful on macOS.
func installActivationPolicyHooks() {}
// showDockIcon is only meaningful on macOS.
func showDockIcon(_ bool) {}
// hideDockIcon is only meaningful on macOS.
func hideDockIcon() {}
+471
View File
@@ -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")
}
+35
View File
@@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

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

After

Width:  |  Height:  |  Size: 20 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}
+114
View File
@@ -0,0 +1,114 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd
+249
View File
@@ -0,0 +1,249 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
{{range .Info.FileAssociations}}
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
File "..\{{.IconName}}.ico"
{{end}}
!macroend
!macro wails.unassociateFiles
; Delete app associations
{{range .Info.FileAssociations}}
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
Delete "$INSTDIR\{{.IconName}}.ico"
{{end}}
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
{{end}}
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
{{end}}
!macroend
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
+14
View File
@@ -0,0 +1,14 @@
// flush_frame.go provides a tiny helper that yields the current goroutine
// long enough for the OS compositor to redraw after WindowHide / Unfullscreen.
//
// Without this, the screenshot can include the just-hidden overlay window
// because Cocoa hasn't redrawn yet by the time we ask for the pixels.
package main
import "time"
func flushFrame() {
// 80ms is empirically enough on Apple Silicon at 120Hz; on slower devices
// the worst case is a single redraw delay, still imperceptible to the user.
time.Sleep(80 * time.Millisecond)
}
+23
View File
@@ -0,0 +1,23 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue
3 `<script setup>` SFCs, check out
the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type
by default. In most cases this is fine if you don't really care about component prop types outside of templates.
However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using
manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look
for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default,
Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>SnapGo</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.ts" type="module"></script>
</body>
</html>
+1071
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.2.37"
},
"devDependencies": {
"@vitejs/plugin-vue": "^3.0.3",
"typescript": "^4.6.4",
"vite": "^3.0.7",
"vue-tsc": "^1.8.27",
"@babel/types": "^7.18.10"
}
}
+1
View File
@@ -0,0 +1 @@
bb7ffb87329c9ad4990374471d4ce9a4
+299
View File
@@ -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 &amp; Security Accessibility</em>,
enable <strong>SnapGo</strong>, then click "Retry".
</div>
<button class="btn" @click="retryHotkey">Retry</button>
</div>
<main class="main">
<aside class="sidebar">
<div 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>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
/*
* Toast — bottom-right transient notification used for upload success /
* failure messages. Self-managed lifecycle is owned by the parent App.vue,
* which simply hides this component after a delay.
*/
defineProps<{
kind: 'success' | 'error'
text: string
}>()
</script>
<template>
<div class="toast" :class="kind">{{ text }}</div>
</template>
<style scoped>
.toast {
position: fixed;
right: 18px;
bottom: 18px;
max-width: 380px;
padding: 10px 14px;
border-radius: 6px;
font-size: 12.5px;
color: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18);
white-space: pre-wrap;
word-break: break-all;
animation: toast-in 0.18s ease-out;
}
.toast.success {
background: #10b981;
}
.toast.error {
background: #ef4444;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import {createApp} from 'vue'
import App from './App.vue'
import './style.css';
createApp(App).mount('#app')
+42
View File
@@ -0,0 +1,42 @@
/*
* Global styles for SnapGo.
*
* Design rationale:
* - The native window/webview is transparent so the capture overlay can show
* the live desktop through its selection cut-out.
* - SettingsView remains opaque because App.vue paints `.app-root` across
* the whole window.
*/
:root {
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI Variable", "Inter", sans-serif;
font-size: 14px;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
background: transparent;
color: #111827;
-webkit-user-select: none;
user-select: none;
}
@media (prefers-color-scheme: dark) {
html,
body {
background: transparent;
color: #e5e7eb;
}
}
#app {
height: 100%;
}
button {
font-family: inherit;
}
+314
View File
@@ -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 &amp; copy
</button>
</div>
<!-- Hint shown before the user has dragged anything. -->
<div v-if="!rect" class="hint">
Drag to select an area &nbsp;·&nbsp; Esc to cancel
</div>
</div>
</template>
<style scoped>
.overlay-root {
position: fixed;
inset: 0;
user-select: none;
cursor: crosshair;
background: transparent;
overflow: hidden;
}
.mask {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
.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>
+374
View File
@@ -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 +
az or 09. 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>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type {DefineComponent} from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": [
"ESNext",
"DOM"
],
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}
+7
View File
@@ -0,0 +1,7 @@
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})
+26
View File
@@ -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>;
+47
View File
@@ -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);
}
+86
View File
@@ -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"];
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
+330
View File
@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;
+298
View File
@@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}
+60
View File
@@ -0,0 +1,60 @@
module github.com/mmmy/snapgo
go 1.23.0
require (
fyne.io/systray v1.12.1
github.com/aws/aws-sdk-go-v2 v1.30.3
github.com/aws/aws-sdk-go-v2/credentials v1.17.27
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.2
github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237
github.com/wailsapp/wails/v2 v2.12.0
golang.design/x/clipboard v0.7.0
golang.design/x/hotkey v0.4.1
)
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
)
+167
View File
@@ -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=
+147
View File
@@ -0,0 +1,147 @@
// Package application contains use-case orchestrators that the presentation
// layer (Wails bindings, frontend) calls into.
//
// Design rationale:
// - Application code only depends on domain interfaces; concrete adapters
// are injected from main.go. This keeps the layer easy to unit test and
// future-proofs us against swapping providers.
package application
import (
"context"
"fmt"
"image"
"math/rand"
"os"
"path/filepath"
"time"
"github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
"github.com/mmmy/snapgo/internal/infrastructure/screencapture"
)
// Notifier sends user-facing UI notifications. We define it as an interface
// so the application package does not depend on the Wails runtime.
type Notifier interface {
NotifySuccess(url string)
NotifyFailure(reason string)
}
// CaptureAndUploadService wires capture → upload → clipboard → notify.
type CaptureAndUploadService struct {
Capturer screencapture.Capturer
Provider domain.OSSProvider
Clipboard clipboard.Writer
Notifier Notifier
// FallbackDir is the directory where PNGs are saved when upload fails.
FallbackDir string
// PathPrefix is prepended to every generated object key.
PathPrefix string
}
// Execute runs the full pipeline for a single screenshot region.
//
// On any failure after capture, the PNG is written to FallbackDir and the
// local file path is copied to the clipboard so the user can still recover
// the image manually.
func (s *CaptureAndUploadService) Execute(ctx context.Context, rect image.Rectangle) (*domain.UploadResult, error) {
if s.Provider == nil {
s.Notifier.NotifyFailure("OSS not configured")
return nil, fmt.Errorf("oss provider is nil")
}
pngBytes, err := s.Capturer.CaptureRegion(rect)
if err != nil {
s.Notifier.NotifyFailure("capture failed: " + err.Error())
return nil, err
}
return s.uploadAndCopy(ctx, pngBytes)
}
// ExecuteWithBytes runs only the upload-clipboard-notify portion of the
// pipeline using PNG bytes that have already been produced by an upstream
// capture step (e.g. macOS's interactive picker). Splitting this out keeps
// the service free of any knowledge about how the pixels were obtained.
func (s *CaptureAndUploadService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
if s.Provider == nil {
s.Notifier.NotifyFailure("OSS not configured")
return fmt.Errorf("oss provider is nil")
}
if len(pngBytes) == 0 {
s.Notifier.NotifyFailure("empty screenshot")
return fmt.Errorf("empty screenshot")
}
_, err := s.uploadAndCopy(ctx, pngBytes)
return err
}
// uploadAndCopy is the shared tail of Execute / ExecuteWithBytes. Extracting
// this avoids duplicating the failure-handling and clipboard logic.
func (s *CaptureAndUploadService) uploadAndCopy(ctx context.Context, pngBytes []byte) (*domain.UploadResult, error) {
key := buildObjectKey(s.PathPrefix)
start := time.Now()
url, uploadErr := s.Provider.Upload(ctx, key, pngBytes, "image/png")
if uploadErr != nil {
path := s.fallback(pngBytes)
_ = s.Clipboard.WriteText(path)
s.Notifier.NotifyFailure("upload failed: " + uploadErr.Error())
return nil, uploadErr
}
if err := s.Clipboard.WriteText(url); err != nil {
s.Notifier.NotifyFailure("clipboard write failed: " + err.Error())
return nil, err
}
s.Notifier.NotifySuccess(url)
return &domain.UploadResult{
URL: url,
Key: key,
Provider: s.Provider.Name(),
Elapsed: time.Since(start),
}, nil
}
// fallback persists the PNG locally and returns the absolute path.
// Errors are swallowed because the caller has already failed once and we
// must not mask the original failure with a secondary error.
func (s *CaptureAndUploadService) fallback(data []byte) string {
if s.FallbackDir == "" {
s.FallbackDir = filepath.Join(os.TempDir(), "SnapGo")
}
_ = os.MkdirAll(s.FallbackDir, 0o755)
name := fmt.Sprintf("%s.png", time.Now().Format("20060102-150405"))
path := filepath.Join(s.FallbackDir, name)
_ = os.WriteFile(path, data, 0o644)
return path
}
// buildObjectKey produces a date-grouped object key with a 6-char random
// suffix to avoid collisions when many screenshots happen in the same second.
//
// The template is intentionally hard-coded for the MVP; making it user-
// configurable is left to a follow-up spec.
func buildObjectKey(prefix string) string {
now := time.Now()
return fmt.Sprintf("%s%s/%s/%s-%s.png",
prefix,
now.Format("2006"),
now.Format("01"),
now.Format("20060102-150405"),
randomSuffix(6),
)
}
const suffixAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
// randomSuffix produces a short non-cryptographic identifier sufficient for
// avoiding key collisions among consecutive captures.
func randomSuffix(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = suffixAlphabet[rand.Intn(len(suffixAlphabet))]
}
return string(b)
}
+61
View File
@@ -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 != ""
}
+54
View File
@@ -0,0 +1,54 @@
// Package domain defines the core business types of SnapGo.
//
// Design rationale:
// - Keep this layer free of any third-party SDK or OS API.
// - Higher layers (application, infrastructure) depend on these types,
// but this package depends on nothing except the Go standard library.
// - Encapsulating data here makes the upgrade path to multiple OSS providers
// (Aliyun OSS, Qiniu, Tencent COS, etc.) trivial — only a new infra adapter
// is needed.
package domain
import (
"context"
"image"
"time"
)
// Screenshot represents a single captured image in PNG-encoded byte form.
//
// We hold the encoded bytes (not image.Image) because:
// 1. Uploaders need a byte stream;
// 2. Local fallback writes the same bytes to disk;
// 3. Avoids re-encoding cost.
type Screenshot struct {
PNG []byte // PNG-encoded payload
Region image.Rectangle // The captured region in virtual screen coords
Width int // Logical width of the region
Height int // Logical height of the region
CreatedAt time.Time // When the capture happened
}
// UploadResult describes the outcome of a successful upload.
type UploadResult struct {
URL string // Public URL that was copied to the clipboard
Key string // Object key on the remote storage
Provider string // Provider name, e.g. "s3"
Elapsed time.Duration // How long the upload took
}
// OSSProvider is the abstraction every storage adapter MUST implement.
//
// Why an interface:
// - Allows the application layer to remain agnostic of the concrete SDK.
// - Future providers (Aliyun OSS / COS / Qiniu) only need a new adapter
// without touching the service layer.
type OSSProvider interface {
// Upload pushes data with the given key and content-type to remote storage,
// returning the publicly accessible URL on success.
Upload(ctx context.Context, key string, data []byte, contentType string) (publicURL string, err error)
// Name returns a stable identifier of the provider implementation
// (used in logs, telemetry, history records).
Name() string
}
@@ -0,0 +1,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
}
+12
View File
@@ -0,0 +1,12 @@
// Package cursor exposes the current mouse pointer position in a
// platform-agnostic way. We use it to anchor the post-capture toolbar near
// the location where the user released the mouse — on macOS that point is
// effectively the bottom-right corner of the screencapture selection.
package cursor
// Point is a screen-space pointer location, in *logical* pixels (i.e. the
// coordinate system Wails uses for window placement).
type Point struct {
X int
Y int
}
@@ -0,0 +1,30 @@
//go:build darwin
// cursor_darwin.go reads the global cursor position via Quartz Event
// Services. This API is preferable to NSEvent.mouseLocation for our use
// case because it returns the pointer in flipped (top-left origin)
// coordinates, which matches Wails / web pixel coordinates exactly.
package cursor
/*
#cgo LDFLAGS: -framework ApplicationServices
#include <ApplicationServices/ApplicationServices.h>
// readCursor returns the current mouse position with origin at the
// top-left of the primary display, in logical pixels.
static void readCursor(double* x, double* y) {
CGEventRef event = CGEventCreate(NULL);
CGPoint p = CGEventGetLocation(event);
if (event) CFRelease(event);
*x = p.x;
*y = p.y;
}
*/
import "C"
// Get returns the current mouse pointer position.
func Get() (Point, error) {
var x, y C.double
C.readCursor(&x, &y)
return Point{X: int(x), Y: int(y)}, nil
}
@@ -0,0 +1,9 @@
//go:build !darwin
package cursor
// Get is a no-op stub on non-macOS targets. The toolbar will fall back to
// the centre of the screen on these platforms.
func Get() (Point, error) {
return Point{X: 0, Y: 0}, nil
}
@@ -0,0 +1,25 @@
// Package display exposes platform-specific helpers for reading the
// geometry of the primary display, in both CSS (logical) and PHYSICAL
// pixels. The overlay flow needs both: CSS pixels to size the Wails window
// to "fullscreen" (since Wails uses CSS-px-equivalent units), and the
// backing scale factor to translate the user's CSS-px selection rectangle
// into the physical-pixel rectangle of the captured PNG.
package display
// Info describes the primary display.
//
// The struct is deliberately minimal: only the few fields the overlay flow
// actually needs. We can grow it later without affecting callers because
// it is returned by value.
type Info struct {
// CSSWidth / CSSHeight are the logical dimensions of the primary
// display in points (i.e. what CSS / Wails uses).
CSSWidth int
CSSHeight int
// Scale is backing factor (CSS-px → device-px). 2 on Retina, 1 on
// non-Retina. We model it as float64 to leave room for fractional
// scales (e.g. Linux 1.25×) even though macOS effectively only ever
// reports 1 or 2.
Scale float64
}
@@ -0,0 +1,41 @@
//go:build darwin
package display
/*
#cgo LDFLAGS: -framework CoreGraphics -framework Foundation
#include <CoreGraphics/CoreGraphics.h>
// readPrimaryDisplay fills the four out-params with:
// - cssW / cssH : logical (point) dimensions of the main display
// - pxW / pxH : physical (device-pixel) dimensions
// Using CGDisplayBounds for points and CGDisplayPixelsWide/High for pixels
// is the canonical way to derive the backing scale on macOS without
// bringing AppKit into the build (we do not want to link Cocoa here).
static void readPrimaryDisplay(int* cssW, int* cssH, int* pxW, int* pxH) {
CGDirectDisplayID id = CGMainDisplayID();
CGRect bounds = CGDisplayBounds(id);
*cssW = (int)bounds.size.width;
*cssH = (int)bounds.size.height;
*pxW = (int)CGDisplayPixelsWide(id);
*pxH = (int)CGDisplayPixelsHigh(id);
}
*/
import "C"
// Primary returns the geometry of the main display. The CGo call has no
// failure path — the OS always reports a main display whenever there is at
// least one attached, which is implied for any GUI process.
func Primary() Info {
var cssW, cssH, pxW, pxH C.int
C.readPrimaryDisplay(&cssW, &cssH, &pxW, &pxH)
scale := 1.0
if cssW > 0 {
scale = float64(pxW) / float64(cssW)
}
return Info{
CSSWidth: int(cssW),
CSSHeight: int(cssH),
Scale: scale,
}
}
@@ -0,0 +1,21 @@
//go:build !darwin
package display
import "github.com/kbinani/screenshot"
// Primary returns the dimensions of display 0. We currently assume a 1× DPR
// because Wails on Windows / Linux already paints in physical pixels, so
// CSS coordinates and capture coordinates coincide. If a user ever runs at
// non-1× we will add a platform-specific scale lookup.
func Primary() Info {
if screenshot.NumActiveDisplays() == 0 {
return Info{CSSWidth: 1440, CSSHeight: 900, Scale: 1}
}
b := screenshot.GetDisplayBounds(0)
return Info{
CSSWidth: b.Dx(),
CSSHeight: b.Dy(),
Scale: 1,
}
}
+121
View File
@@ -0,0 +1,121 @@
// Package hotkey wraps golang.design/x/hotkey to provide a process-wide
// global shortcut. The MVP only supports a single hard-coded combination
// (Cmd+Shift+A on macOS) but the abstraction below allows the binding to
// be replaced at runtime once we add per-platform parsing of user input.
package hotkey
import (
"fmt"
"strings"
"sync"
hk "golang.design/x/hotkey"
)
// Manager owns the lifecycle of one global hotkey.
type Manager struct {
mu sync.Mutex
current *hk.Hotkey
stop chan struct{}
}
// NewManager returns a fresh manager with no hotkey registered yet.
func NewManager() *Manager { return &Manager{} }
// Register parses the spec (e.g. "cmd+shift+a") and binds the callback.
// If a previous hotkey is registered, it is unregistered first so callers
// can swap shortcuts at runtime when the user changes the setting.
func (m *Manager) Register(spec string, onTrigger func()) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil {
_ = m.current.Unregister()
close(m.stop)
m.current = nil
m.stop = nil
}
mods, key, err := parseSpec(spec)
if err != nil {
return err
}
hot := hk.New(mods, key)
if err := hot.Register(); err != nil {
return fmt.Errorf("register hotkey %q: %w", spec, err)
}
stop := make(chan struct{})
m.current = hot
m.stop = stop
// Listen on a dedicated goroutine. We never run the callback inline
// because the keydown channel is unbuffered.
go func() {
for {
select {
case <-stop:
return
case <-hot.Keydown():
if onTrigger != nil {
go onTrigger()
}
}
}
}()
return nil
}
// Unregister releases the active hotkey, if any.
func (m *Manager) Unregister() {
m.mu.Lock()
defer m.mu.Unlock()
if m.current != nil {
_ = m.current.Unregister()
close(m.stop)
m.current = nil
m.stop = nil
}
}
// parseSpec parses a "+"-separated hotkey description into (modifiers, key).
//
// Supported tokens (case-insensitive): cmd, command, ctrl, control,
// option, alt, shift, plus a single letter AZ or digit 09.
//
// We keep the parser intentionally tiny — full key mapping is out of scope
// for the MVP.
func parseSpec(spec string) ([]hk.Modifier, hk.Key, error) {
parts := strings.Split(strings.ToLower(strings.TrimSpace(spec)), "+")
if len(parts) == 0 {
return nil, 0, fmt.Errorf("empty hotkey")
}
var mods []hk.Modifier
var key hk.Key
hasKey := false
for _, p := range parts {
p = strings.TrimSpace(p)
switch p {
case "cmd", "command", "meta", "super":
mods = append(mods, modCmd())
case "ctrl", "control":
mods = append(mods, modCtrl())
case "option", "alt":
mods = append(mods, modOption())
case "shift":
mods = append(mods, hk.ModShift)
default:
k, ok := lookupKey(p)
if !ok {
return nil, 0, fmt.Errorf("unsupported token: %q", p)
}
key = k
hasKey = true
}
}
if !hasKey {
return nil, 0, fmt.Errorf("hotkey %q is missing a key", spec)
}
return mods, key, nil
}
@@ -0,0 +1,10 @@
//go:build darwin
package hotkey
import hk "golang.design/x/hotkey"
// macOS uses ⌘ as the primary modifier; Option corresponds to Alt.
func modCmd() hk.Modifier { return hk.ModCmd }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.ModOption }
@@ -0,0 +1,11 @@
//go:build !darwin
package hotkey
import hk "golang.design/x/hotkey"
// On Windows / Linux we map "cmd" to Ctrl so the same config string works
// across platforms; "option" is treated as Alt.
func modCmd() hk.Modifier { return hk.ModCtrl }
func modCtrl() hk.Modifier { return hk.ModCtrl }
func modOption() hk.Modifier { return hk.ModAlt }
+86
View File
@@ -0,0 +1,86 @@
package hotkey
import hk "golang.design/x/hotkey"
// lookupKey maps a token from the user-supplied hotkey spec to a hk.Key.
//
// The map is intentionally exhaustive over the alphanumeric set so we never
// rely on an assumption that hk.KeyA..hk.KeyZ are contiguous values — the
// upstream package gives no such guarantee.
func lookupKey(token string) (hk.Key, bool) {
switch token {
case "a":
return hk.KeyA, true
case "b":
return hk.KeyB, true
case "c":
return hk.KeyC, true
case "d":
return hk.KeyD, true
case "e":
return hk.KeyE, true
case "f":
return hk.KeyF, true
case "g":
return hk.KeyG, true
case "h":
return hk.KeyH, true
case "i":
return hk.KeyI, true
case "j":
return hk.KeyJ, true
case "k":
return hk.KeyK, true
case "l":
return hk.KeyL, true
case "m":
return hk.KeyM, true
case "n":
return hk.KeyN, true
case "o":
return hk.KeyO, true
case "p":
return hk.KeyP, true
case "q":
return hk.KeyQ, true
case "r":
return hk.KeyR, true
case "s":
return hk.KeyS, true
case "t":
return hk.KeyT, true
case "u":
return hk.KeyU, true
case "v":
return hk.KeyV, true
case "w":
return hk.KeyW, true
case "x":
return hk.KeyX, true
case "y":
return hk.KeyY, true
case "z":
return hk.KeyZ, true
case "0":
return hk.Key0, true
case "1":
return hk.Key1, true
case "2":
return hk.Key2, true
case "3":
return hk.Key3, true
case "4":
return hk.Key4, true
case "5":
return hk.Key5, true
case "6":
return hk.Key6, true
case "7":
return hk.Key7, true
case "8":
return hk.Key8, true
case "9":
return hk.Key9, true
}
return 0, false
}
+124
View File
@@ -0,0 +1,124 @@
// Package oss contains storage adapters that satisfy domain.OSSProvider.
//
// The S3 adapter targets any S3-compatible endpoint via aws-sdk-go-v2.
package oss
import (
"bytes"
"context"
"fmt"
"net/url"
"strings"
awsv2 "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/mmmy/snapgo/internal/domain"
)
// S3Provider implements domain.OSSProvider using aws-sdk-go-v2.
//
// We construct one client per Provider instance because the S3 client is
// goroutine-safe and the configuration is immutable for the lifetime of the
// provider.
type S3Provider struct {
cfg domain.S3Config
client *s3.Client
}
// NewS3Provider builds an *S3Provider from the supplied user configuration.
//
// The endpoint is plumbed through BaseEndpoint + UsePathStyle so any
// S3-compatible service (MinIO, R2, B2, Aliyun OSS s3 endpoint) works
// without us writing per-vendor branches.
func NewS3Provider(cfg domain.S3Config) (*S3Provider, error) {
if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
return nil, fmt.Errorf("s3 config: endpoint/bucket/accessKeyId/secretAccessKey are required")
}
region := cfg.Region
if region == "" {
region = "us-east-1"
}
awsCfg := awsv2.Config{
Region: region,
Credentials: credentials.NewStaticCredentialsProvider(
cfg.AccessKeyID, cfg.SecretAccessKey, "",
),
}
endpoint := cfg.Endpoint
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = awsv2.String(endpoint)
o.UsePathStyle = cfg.UsePathStyle
})
return &S3Provider{cfg: cfg, client: client}, nil
}
// Name identifies this provider in logs and history records.
func (p *S3Provider) Name() string { return "s3" }
// Upload pushes the bytes to the configured bucket and returns a public URL.
//
// We pass the body via bytes.Reader so the SDK can compute Content-Length
// and signed checksum without buffering the data twice.
func (p *S3Provider) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) {
if contentType == "" {
contentType = "image/png"
}
_, err := p.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(key),
Body: bytes.NewReader(data),
ContentType: awsv2.String(contentType),
})
if err != nil {
return "", fmt.Errorf("s3 put object: %w", err)
}
return p.BuildPublicURL(key), nil
}
// BuildPublicURL constructs the URL that the user pastes into Markdown.
//
// Priority:
// 1. PublicURLBase if user filled it (best-fit for CDN-fronted buckets);
// 2. {Endpoint}/{Bucket}/{Key} for path-style;
// 3. virtual-hosted-style fallback ({bucket}.host).
func (p *S3Provider) BuildPublicURL(key string) string {
if p.cfg.PublicURLBase != "" {
base := strings.TrimRight(p.cfg.PublicURLBase, "/")
return base + "/" + key
}
endpoint := strings.TrimRight(p.cfg.Endpoint, "/")
if p.cfg.UsePathStyle {
return endpoint + "/" + p.cfg.Bucket + "/" + key
}
// Virtual-hosted-style: replace host with {bucket}.{host}
if u, err := url.Parse(endpoint); err == nil && u.Host != "" {
u.Host = p.cfg.Bucket + "." + u.Host
u.Path = "/" + key
return u.String()
}
return endpoint + "/" + p.cfg.Bucket + "/" + key
}
// TestConnection performs a small put + delete to verify credentials and
// bucket reachability. Used by the "Test connection" button in settings.
func (p *S3Provider) TestConnection(ctx context.Context) error {
probeKey := strings.TrimRight(p.cfg.PathPrefix, "/") + "/.snapgo-probe"
probeKey = strings.TrimLeft(probeKey, "/")
if _, err := p.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(probeKey),
Body: bytes.NewReader([]byte("snapgo")),
ContentType: awsv2.String("text/plain"),
}); err != nil {
return fmt.Errorf("probe put: %w", err)
}
if _, err := p.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: awsv2.String(p.cfg.Bucket),
Key: awsv2.String(probeKey),
}); err != nil {
return fmt.Errorf("probe delete: %w", err)
}
return nil
}
@@ -0,0 +1,42 @@
// Package screencapture defines the cross-platform Capturer abstraction.
//
// Per-platform implementations live in sibling files:
// - capturer_darwin.go macOS, uses /usr/sbin/screencapture
// - capturer_other.go non-darwin, uses kbinani/screenshot
//
// The interface is kept here so callers do not need build tags.
package screencapture
import "image"
// Capturer is the abstraction that the application service depends on.
type Capturer interface {
// CaptureRegion grabs the pixels within the supplied virtual-screen
// rectangle and returns PNG-encoded bytes.
CaptureRegion(rect image.Rectangle) ([]byte, error)
// VirtualBounds returns the union rectangle of every connected display.
VirtualBounds() image.Rectangle
// CaptureInteractive opens a system-provided region picker (e.g. macOS's
// `screencapture -i`) and returns the resulting PNG bytes once the user
// confirms a selection. If the user cancels (Esc / right-click), it
// returns (nil, ErrCancelled) so callers can short-circuit cleanly.
CaptureInteractive() ([]byte, error)
// CaptureFullScreen grabs the entire primary display silently and
// returns the PNG bytes. Used as the backdrop for the in-app Snipaste-
// style overlay; the per-region crop happens later in CropPNG once the
// user finishes dragging.
CaptureFullScreen() ([]byte, error)
}
// ErrCancelled signals that the user aborted an interactive capture.
// It is a sentinel value; callers should compare via errors.Is.
type cancelled struct{}
func (cancelled) Error() string { return "capture cancelled by user" }
// ErrCancelled is returned from CaptureInteractive when the user dismisses
// the system selection UI without confirming a region.
var ErrCancelled = cancelled{}
@@ -0,0 +1,145 @@
//go:build darwin
package screencapture
import (
"fmt"
"image"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// macCapturer shells out to /usr/sbin/screencapture, which is bundled with
// macOS, requires no cgo, and is fully compatible with macOS 15/26 SDKs
// where CoreGraphics's display-capture API has been removed.
//
// Trade-off: a tiny process-spawn cost per capture (~30 ms). Acceptable for
// an interactive screenshot tool — users cannot perceive it.
type macCapturer struct{}
// New returns a macOS-native Capturer.
func New() Capturer { return &macCapturer{} }
// VirtualBounds returns the union of all displays measured in CSS pixels
// (i.e. logical, not Retina physical pixels). We query system_profiler via
// AppleScript-free shell utilities to avoid extra cgo dependencies.
//
// For simplicity we currently return the main display only; this matches
// what the WebView/window can actually paint without a separate transparent
// window per display. Multi-monitor support is a follow-up spec.
func (c *macCapturer) VirtualBounds() image.Rectangle {
out, err := exec.Command(
"system_profiler", "SPDisplaysDataType",
).Output()
if err == nil {
// Look for "Resolution: 3024 x 1964" lines and take the first one.
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Resolution:") {
continue
}
parts := strings.Fields(line)
// Expected pattern: ["Resolution:", "3024", "x", "1964", ...]
if len(parts) >= 4 {
w, errW := strconv.Atoi(parts[1])
h, errH := strconv.Atoi(parts[3])
if errW == nil && errH == nil {
// Resolution reported is physical; convert to CSS px by
// halving for Retina (assume 2× when w >= 2560).
if w >= 2560 {
w /= 2
h /= 2
}
return image.Rect(0, 0, w, h)
}
}
}
}
// Conservative fallback used by ~all modern Macs.
return image.Rect(0, 0, 1440, 900)
}
// CaptureRegion grabs the requested rectangle via the OS tool and returns
// the resulting PNG bytes.
//
// Rect coords here are logical screen coordinates, matching macOS
// screencapture's -R contract and the CSS-point coordinates reported by the
// transparent overlay.
func (c *macCapturer) CaptureRegion(rect image.Rectangle) ([]byte, error) {
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return nil, fmt.Errorf("invalid capture rectangle: %v", rect)
}
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
region := fmt.Sprintf("%d,%d,%d,%d",
rect.Min.X, rect.Min.Y, rect.Dx(), rect.Dy(),
)
// -x: silent (no shutter sound)
// -R: rectangle
// -t png
cmd := exec.Command("/usr/sbin/screencapture", "-x", "-R", region, "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture: %w (%s)", err, strings.TrimSpace(string(out)))
}
data, err := os.ReadFile(tmp)
if err != nil {
return nil, fmt.Errorf("read screencapture output: %w", err)
}
return data, nil
}
// CaptureInteractive launches `screencapture -i` which presents the macOS
// native region selector (the same crosshair that Cmd+Shift+4 produces).
//
// Design rationale:
// - The native picker is pixel-perfect across Retina / external displays
// and supports multi-monitor out of the box.
// - It also handles Esc / right-click to cancel without requiring us to
// run a transparent overlay window — which previously caused the main
// window to flash a grey full-screen and trap the user.
// - When the user cancels, screencapture exits 0 but writes no file; we
// detect that case via os.Stat and return ErrCancelled.
func (c *macCapturer) CaptureInteractive() ([]byte, error) {
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-i-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
// -i: interactive region selection
// -x: silent (suppress the camera shutter sound)
// -t png: png output
cmd := exec.Command("/usr/sbin/screencapture", "-i", "-x", "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture -i: %w (%s)", err, strings.TrimSpace(string(out)))
}
// User cancelled (Esc / right-click): tool exits 0 with no file written.
info, err := os.Stat(tmp)
if err != nil || info.Size() == 0 {
return nil, ErrCancelled
}
return os.ReadFile(tmp)
}
// CaptureFullScreen runs `screencapture -x` to grab the primary display in
// a single shot. We use -m so only the main display is captured even if
// external monitors are attached — matches the "primary screen only" UX
// chosen for the in-app overlay.
func (c *macCapturer) CaptureFullScreen() ([]byte, error) {
tmp := filepath.Join(os.TempDir(), fmt.Sprintf("snapgo-full-%d.png", time.Now().UnixNano()))
defer os.Remove(tmp)
// -x: silent
// -m: main display only
// -t png: png output
cmd := exec.Command("/usr/sbin/screencapture", "-x", "-m", "-t", "png", tmp)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("screencapture -x -m: %w (%s)", err, strings.TrimSpace(string(out)))
}
return os.ReadFile(tmp)
}
@@ -0,0 +1,71 @@
//go:build !darwin
package screencapture
import (
"bytes"
"fmt"
"image"
"image/png"
"github.com/kbinani/screenshot"
)
// kbinaniCapturer is the default non-darwin Capturer.
type kbinaniCapturer struct{}
// New returns the default cross-platform Capturer for non-macOS systems.
func New() Capturer { return &kbinaniCapturer{} }
func (c *kbinaniCapturer) VirtualBounds() image.Rectangle {
n := screenshot.NumActiveDisplays()
if n == 0 {
return image.Rect(0, 0, 0, 0)
}
bounds := screenshot.GetDisplayBounds(0)
for i := 1; i < n; i++ {
bounds = bounds.Union(screenshot.GetDisplayBounds(i))
}
return bounds
}
func (c *kbinaniCapturer) CaptureRegion(rect image.Rectangle) ([]byte, error) {
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return nil, fmt.Errorf("invalid capture rectangle: %v", rect)
}
img, err := screenshot.CaptureRect(rect)
if err != nil {
return nil, fmt.Errorf("capture: %w", err)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
return buf.Bytes(), nil
}
// CaptureInteractive on non-darwin platforms is not yet implemented. The
// frontend overlay still works there. We surface a clear error so callers
// can decide whether to fall back to the WebView overlay.
func (c *kbinaniCapturer) CaptureInteractive() ([]byte, error) {
return nil, fmt.Errorf("interactive capture is not implemented on this platform yet")
}
// CaptureFullScreen grabs the primary display via kbinani/screenshot and
// PNG-encodes the result. Multi-display extension is deliberately deferred
// to keep parity with the macOS "primary only" UX.
func (c *kbinaniCapturer) CaptureFullScreen() ([]byte, error) {
if screenshot.NumActiveDisplays() == 0 {
return nil, fmt.Errorf("no active display detected")
}
bounds := screenshot.GetDisplayBounds(0)
img, err := screenshot.CaptureRect(bounds)
if err != nil {
return nil, fmt.Errorf("capture full screen: %w", err)
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
return buf.Bytes(), nil
}
@@ -0,0 +1,59 @@
package screencapture
import (
"bytes"
"fmt"
"image"
"image/png"
)
// CropPNG decodes the supplied PNG, crops it to rect (in PHYSICAL pixels of
// the source image — i.e. the same coordinate system the PNG itself uses),
// and re-encodes the result.
//
// Why an extra abstraction: the overlay flow captures the entire screen as
// one big PNG and lets the user pick a region in the WebView. The selection
// rectangle the WebView reports is in CSS pixels, so callers must scale by
// the display's backing factor before invoking CropPNG. Splitting "scale"
// from "crop" keeps this helper focused and unit-testable.
func CropPNG(pngBytes []byte, rect image.Rectangle) ([]byte, error) {
if rect.Dx() <= 0 || rect.Dy() <= 0 {
return nil, fmt.Errorf("invalid crop rectangle: %v", rect)
}
img, err := png.Decode(bytes.NewReader(pngBytes))
if err != nil {
return nil, fmt.Errorf("decode source png: %w", err)
}
// Clip the requested rectangle to the source bounds — defensive guard
// against off-by-one errors from CSS-px → physical-px scaling.
src := img.Bounds()
clipped := rect.Intersect(src)
if clipped.Empty() {
return nil, fmt.Errorf("crop rectangle %v has no overlap with source %v", rect, src)
}
// SubImage is documented on most concrete image types; the standard
// library decodes PNGs into one of those, so this assertion is safe in
// practice. Fall back to a manual copy otherwise.
type subImager interface {
SubImage(r image.Rectangle) image.Image
}
var cropped image.Image
if si, ok := img.(subImager); ok {
cropped = si.SubImage(clipped)
} else {
dst := image.NewRGBA(image.Rect(0, 0, clipped.Dx(), clipped.Dy()))
for y := 0; y < clipped.Dy(); y++ {
for x := 0; x < clipped.Dx(); x++ {
dst.Set(x, y, img.At(clipped.Min.X+x, clipped.Min.Y+y))
}
}
cropped = dst
}
var buf bytes.Buffer
if err := png.Encode(&buf, cropped); err != nil {
return nil, fmt.Errorf("encode cropped png: %w", err)
}
return buf.Bytes(), nil
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,62 @@
//go:build darwin
package tray
/*
#cgo darwin LDFLAGS: -framework Foundation
#include <dispatch/dispatch.h>
// runOnMainQueue submits the Go-side callback (identified by handle) to the
// main dispatch queue. We use dispatch_async so the caller — typically a
// Wails OnStartup goroutine — does not block the cocoa runloop while waiting
// for the main thread to drain.
extern void trayDispatchMainCallback(unsigned long handle);
static void runOnMainQueue(unsigned long handle) {
dispatch_async(dispatch_get_main_queue(), ^{
trayDispatchMainCallback(handle);
});
}
*/
import "C"
import (
"sync"
"sync/atomic"
)
// dispatchRegistry stores Go closures keyed by an integer handle so that the
// C side can call back into Go without leaking unsafe.Pointer-cast function
// pointers (which CGo disallows).
var (
dispatchMu sync.Mutex
dispatchTable = map[uint64]func(){}
dispatchNextID uint64
)
// dispatchOnMain schedules fn to run on the macOS main thread. This is the
// Cocoa contract for any API that touches NSWindow / NSStatusBar — calling
// them from a goroutine triggers the "should only be instantiated on the
// main thread" assertion.
func dispatchOnMain(fn func()) {
if fn == nil {
return
}
id := atomic.AddUint64(&dispatchNextID, 1)
dispatchMu.Lock()
dispatchTable[id] = fn
dispatchMu.Unlock()
C.runOnMainQueue(C.ulong(id))
}
//export trayDispatchMainCallback
func trayDispatchMainCallback(handle C.ulong) {
id := uint64(handle)
dispatchMu.Lock()
fn := dispatchTable[id]
delete(dispatchTable, id)
dispatchMu.Unlock()
if fn != nil {
fn()
}
}
@@ -0,0 +1,16 @@
//go:build !darwin
package tray
// dispatchOnMain is a no-op on non-darwin platforms because:
// - On Linux (GTK/AppIndicator), systray.Run / nativeStart can be invoked
// from any goroutine; the GTK runloop is started internally.
// - On Windows, status icons are tied to a hidden window owned by the
// systray library itself, also independent of the Go main goroutine.
// Wrapping the call in dispatchOnMain on these platforms would just add a
// pointless layer of indirection.
func dispatchOnMain(fn func()) {
if fn != nil {
fn()
}
}
@@ -0,0 +1,16 @@
//go:build darwin
package tray
import _ "embed"
// templateIconBytes embeds the macOS template icon as a multi-resolution TIFF
// generated from the 1x and 2x source PNGs in assets/.
//
//go:embed assets/statusbarTemplate.tiff
var templateIconBytes []byte
// regularIconBytes keeps a PNG fallback for APIs that expect a regular raster icon.
//
//go:embed assets/statusbarTemplate.png
var regularIconBytes []byte
@@ -0,0 +1,13 @@
//go:build !darwin
package tray
import _ "embed"
// Keep a regular PNG for non-macOS builds where template icons are unsupported.
//
//go:embed assets/statusbarTemplate.png
var templateIconBytes []byte
//go:embed assets/statusbarTemplate.png
var regularIconBytes []byte
+84
View File
@@ -0,0 +1,84 @@
// Package tray runs the menu-bar agent UI for SnapGo on macOS / Linux /
// Windows. It is intentionally decoupled from the main App struct: the App
// supplies a small set of callbacks (Capture / Settings / Quit) so that this
// package never imports the application service stack.
//
// We use fyne.io/systray (a fork of getlantern/systray) because it is
// designed to coexist with other GUI runloops — important when the host
// process is also driving a Wails / Cocoa main loop.
package tray
import (
"fyne.io/systray"
)
// Callbacks bundles the menu actions the host application wants to expose.
// Splitting these into a struct (rather than 3 positional arguments) keeps
// future extension cheap — we can add e.g. OnCheckForUpdates without
// touching the call sites.
type Callbacks struct {
OnCapture func()
OnSettings func()
OnQuit func()
}
// Start installs the menu-bar status item and returns immediately. The
// fyne.io/systray fork exposes RunWithExternalLoop precisely for cases like
// ours where the host process already owns the main thread (Wails).
//
// The returned `stop` function tears down the status item; call it from the
// host's shutdown path.
func Start(cbs Callbacks) (start, stop func()) {
onReady := func() {
systray.SetTemplateIcon(templateIconBytes, regularIconBytes)
systray.SetTooltip("SnapGo")
mCapture := systray.AddMenuItem("Capture screenshot", "Take a region screenshot")
mSettings := systray.AddMenuItem("Settings…", "Open settings window")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit SnapGo", "Quit the application")
// Pump menu clicks on a dedicated goroutine. Channel sends from
// systray are non-blocking, so a slow user callback does not back
// up the UI thread.
go func() {
for {
select {
case <-mCapture.ClickedCh:
if cbs.OnCapture != nil {
cbs.OnCapture()
}
case <-mSettings.ClickedCh:
if cbs.OnSettings != nil {
cbs.OnSettings()
}
case <-mQuit.ClickedCh:
if cbs.OnQuit != nil {
cbs.OnQuit()
}
systray.Quit()
return
}
}
}()
}
onExit := func() { /* nothing to clean up */ }
// RunWithExternalLoop registers the status item without blocking the
// caller — the returned `start` should be invoked once the host's
// runloop is up so the icon appears. fyne.io/systray returns a
// (start, end) pair; we delegate `end` to systray.Quit for symmetry.
rawStart, _ := systray.RunWithExternalLoop(onReady, onExit)
// IMPORTANT: on macOS, `nativeStart` ends up calling
// [NSStatusBar systemStatusBar] -> [-NSStatusBar _statusItemWithLength:...]
// which constructs an NSWindow. Cocoa hard-asserts that NSWindow can
// only be instantiated on the main thread. Wails v2 invokes OnStartup
// from a worker goroutine, so calling rawStart() directly from there
// crashes with NSInternalInconsistencyException. We wrap it in
// dispatchOnMain so the call is forwarded to the main dispatch queue.
start = func() { dispatchOnMain(rawStart) }
stop = systray.Quit
return start, stop
}
+82
View File
@@ -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()
}
+36
View File
@@ -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()
}
+317
View File
@@ -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
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !darwin
package main
import "github.com/mmmy/snapgo/internal/infrastructure/display"
// showNativeCaptureOverlay is only implemented on macOS. Other platforms
// keep using the Wails WebView overlay fallback.
func showNativeCaptureOverlay(_ *App, _ display.Info) bool {
return false
}
+121
View File
@@ -0,0 +1,121 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
#cgo LDFLAGS: -framework AppKit -framework CoreGraphics
#include <dispatch/dispatch.h>
#import <AppKit/AppKit.h>
static NSWindow* snipMainWindow(void) {
NSWindow *window = [NSApp mainWindow];
if (window == nil && [[NSApp windows] count] > 0) {
window = [[NSApp windows] objectAtIndex:0];
}
return window;
}
// snipMakeViewTransparent recursively clears AppKit/WKWebView backing
// surfaces. Setting only NSWindow transparent is insufficient for Wails:
// WKWebView may still paint an opaque default grey background.
static void snipMakeViewTransparent(NSView *view) {
if (view == nil) {
return;
}
Class wkWebViewClass = NSClassFromString(@"WKWebView");
if (wkWebViewClass != nil && [view isKindOfClass:wkWebViewClass]) {
@try {
[view setValue:@NO forKey:@"drawsBackground"];
} @catch (__unused NSException *exception) {
}
@try {
id scrollView = [view valueForKey:@"scrollView"];
if ([scrollView isKindOfClass:[NSScrollView class]]) {
[(NSScrollView *)scrollView setDrawsBackground:NO];
[(NSScrollView *)scrollView setBackgroundColor:[NSColor clearColor]];
}
} @catch (__unused NSException *exception) {
}
}
if ([view isKindOfClass:[NSScrollView class]]) {
NSScrollView *scrollView = (NSScrollView *)view;
[scrollView setDrawsBackground:NO];
[scrollView setBackgroundColor:[NSColor clearColor]];
}
[view setWantsLayer:YES];
if ([view layer] != nil) {
[[view layer] setOpaque:NO];
[[view layer] setBackgroundColor:[[NSColor clearColor] CGColor]];
}
@try {
[view setValue:@NO forKey:@"opaque"];
} @catch (__unused NSException *exception) {
}
@try {
[view setValue:@NO forKey:@"drawsBackground"];
} @catch (__unused NSException *exception) {
}
for (NSView *subview in [view subviews]) {
snipMakeViewTransparent(subview);
}
}
static void snipConfigureOverlayWindow(void) {
dispatch_async(dispatch_get_main_queue(), ^{
NSWindow *window = snipMainWindow();
NSScreen *screen = [NSScreen mainScreen];
if (window == nil || screen == nil) {
return;
}
[window setOpaque:NO];
[window setBackgroundColor:[NSColor clearColor]];
snipMakeViewTransparent([window contentView]);
[window setLevel:NSScreenSaverWindowLevel];
[window setCollectionBehavior:
NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorStationary];
[window setFrame:[screen frame] display:YES];
});
}
static void snipRestoreOverlayWindow(void) {
dispatch_async(dispatch_get_main_queue(), ^{
NSWindow *window = snipMainWindow();
if (window == nil) {
return;
}
[window setLevel:NSNormalWindowLevel];
[window setCollectionBehavior:NSWindowCollectionBehaviorDefault];
[window setOpaque:YES];
[window setBackgroundColor:
[NSColor colorWithCalibratedRed:246.0/255.0
green:247.0/255.0
blue:250.0/255.0
alpha:1.0]];
});
}
*/
import "C"
// configureOverlayWindow promotes the Wails NSWindow to a transparent,
// screen-level overlay. Wails can size a frameless window, but AppKit-only
// flags are needed to cover the menu bar and keep the WebView transparent.
func configureOverlayWindow() {
C.snipConfigureOverlayWindow()
}
// restoreOverlayWindow returns the NSWindow to normal app-window behaviour
// before the settings UI is shown again.
func restoreOverlayWindow() {
C.snipRestoreOverlayWindow()
}
+10
View File
@@ -0,0 +1,10 @@
//go:build !darwin
package main
// configureOverlayWindow is a macOS-only AppKit refinement. Other platforms
// rely on Wails' own frameless/always-on-top window behaviour.
func configureOverlayWindow() {}
// restoreOverlayWindow is a macOS-only AppKit refinement.
func restoreOverlayWindow() {}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# =============================================================================
# create-dev-cert.sh — Create a stable, self-signed code-signing certificate.
#
# Why this exists:
# macOS's privacy database (TCC) tracks app identity primarily by the
# code-signing cdhash (for ad-hoc) or the certificate (for properly signed
# apps). Wails default ad-hoc signing produces a new cdhash on every build,
# so TCC keeps re-prompting for Screen Recording / Accessibility permission.
#
# By creating ONE self-signed certificate locally and reusing it for every
# build, the signing identity never changes, so TCC remembers the grant.
#
# Idempotent: if the cert already exists with the configured CN, this script
# is a no-op and exits 0.
# =============================================================================
set -euo pipefail
CERT_CN="${CERT_CN:-SnapGo Dev Cert}"
KEYCHAIN="${KEYCHAIN:-login.keychain-db}"
# 1. Skip ONLY if a usable code-signing identity exists. We deliberately do
# not skip on the mere presence of a certificate object, because a partial
# import (e.g. wrong PKCS#12 password) leaves the cert behind without a
# private key, which makes codesign fail later in confusing ways.
if security find-identity -v -p codesigning 2>/dev/null | grep -q "\"${CERT_CN}\""; then
echo "[create-dev-cert.sh] usable identity '${CERT_CN}' already present — nothing to do."
exit 0
fi
# 1b. Clean any stale certificate-only entry from a previous failed import,
# so the new import does not collide on duplicate CN.
if security find-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1; then
echo "[create-dev-cert.sh] removing stale (key-less) cert entry from previous run..."
# Loop because there may be multiple stale copies.
while security find-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1; do
security delete-certificate -c "${CERT_CN}" "${KEYCHAIN}" >/dev/null 2>&1 || break
done
fi
# 2. Generate a self-signed code-signing cert via openssl + import via security.
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT
KEY="${WORK}/dev.key"
CSR="${WORK}/dev.csr"
CRT="${WORK}/dev.crt"
P12="${WORK}/dev.p12"
EXTFILE="${WORK}/ext.cnf"
# X509 v3 extension file enabling "Code Signing" extended key usage (EKU).
cat > "${EXTFILE}" <<'EOF'
[v3_ext]
keyUsage = critical,digitalSignature
extendedKeyUsage = critical,codeSigning
basicConstraints = CA:false
EOF
echo "[create-dev-cert.sh] generating private key..."
openssl genrsa -out "${KEY}" 2048 >/dev/null 2>&1
echo "[create-dev-cert.sh] generating CSR..."
openssl req -new -key "${KEY}" -out "${CSR}" -subj "/CN=${CERT_CN}/O=SnapGo Dev/C=US" >/dev/null 2>&1
echo "[create-dev-cert.sh] self-signing certificate (10 years)..."
openssl x509 -req -in "${CSR}" -signkey "${KEY}" -out "${CRT}" \
-days 3650 -extfile "${EXTFILE}" -extensions v3_ext >/dev/null 2>&1
echo "[create-dev-cert.sh] bundling into PKCS#12..."
# `-legacy` is REQUIRED on OpenSSL 3+: macOS's `security` tool can only
# verify the legacy PKCS#12 MAC (SHA-1 / 3DES). Without this flag the import
# fails with "MAC verification failed during PKCS12 import".
# Use a non-empty passphrase to avoid edge cases where empty pass triggers
# different MAC behaviour on some openssl builds.
P12_PASS="snapgo"
openssl pkcs12 -export -legacy \
-inkey "${KEY}" -in "${CRT}" -out "${P12}" \
-name "${CERT_CN}" -passout "pass:${P12_PASS}" >/dev/null 2>&1
echo "[create-dev-cert.sh] importing into ${KEYCHAIN} (you may be prompted for your login password)..."
security import "${P12}" -k "${KEYCHAIN}" -P "${P12_PASS}" -T /usr/bin/codesign
# 3. Mark the cert as trusted for code signing. This requires admin.
# We use `security add-trusted-cert` with policy `codeSign`.
echo "[create-dev-cert.sh] adding trust setting (admin password may be required)..."
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \
-p codeSign "${CRT}" || {
echo "[create-dev-cert.sh] WARN: trust setting could not be added. The cert"
echo " is still importable for codesign use, but TCC may still re-prompt"
echo " unless the cert is trusted. Re-run as admin to fix."
}
echo "[create-dev-cert.sh] done. Verify with:"
echo " security find-identity -v -p codesigning"
echo "You should see: '${CERT_CN}'"
+66
View File
@@ -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"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# =============================================================================
# make-dmg.sh — Build a distributable .dmg for SnapGo.app
#
# Design rationale:
# - Prefer `create-dmg` (https://github.com/create-dmg/create-dmg) when available
# because it produces a polished installer with /Applications drag target,
# custom background and proper icon layout.
# - Fall back to a plain `hdiutil create` UDZO image so the script always works
# on a fresh machine even without homebrew. The fallback DMG still shows the
# app + a symlink to /Applications so users can drag-install.
# - Output is always written to build/bin/SnapGo-<version>-<arch>.dmg so it
# does not collide with the .app bundle.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_PATH="${APP_PATH:-${ROOT_DIR}/build/bin/SnapGo.app}"
OUT_DIR="${OUT_DIR:-${ROOT_DIR}/build/bin}"
if [[ ! -d "${APP_PATH}" ]]; then
echo "[make-dmg.sh] ERROR: ${APP_PATH} not found." >&2
exit 1
fi
# -------- Version / arch derivation --------
VERSION="${VERSION:-$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"${APP_PATH}/Contents/Info.plist" 2>/dev/null || echo "0.1.0")}"
ARCH="${ARCH:-$(uname -m)}" # arm64 or x86_64
DMG_NAME="SnapGo-${VERSION}-${ARCH}.dmg"
DMG_PATH="${OUT_DIR}/${DMG_NAME}"
VOLNAME="SnapGo ${VERSION}"
mkdir -p "${OUT_DIR}"
rm -f "${DMG_PATH}"
# -------- Path 1: create-dmg (pretty) --------
if command -v create-dmg >/dev/null 2>&1; then
echo "[make-dmg.sh] using create-dmg"
# NOTE: create-dmg refuses to run when the output exists; we already removed it.
create-dmg \
--volname "${VOLNAME}" \
--window-pos 200 120 \
--window-size 600 360 \
--icon-size 100 \
--icon "SnapGo.app" 160 180 \
--hide-extension "SnapGo.app" \
--app-drop-link 440 180 \
--no-internet-enable \
"${DMG_PATH}" \
"${APP_PATH}"
else
# -------- Path 2: hdiutil fallback (always works) --------
echo "[make-dmg.sh] create-dmg not found, using hdiutil fallback"
STAGING="$(mktemp -d)"
trap 'rm -rf "${STAGING}"' EXIT
# Copy the .app and add an /Applications symlink so users can drag-install.
cp -R "${APP_PATH}" "${STAGING}/"
ln -s /Applications "${STAGING}/Applications"
hdiutil create \
-volname "${VOLNAME}" \
-srcfolder "${STAGING}" \
-ov -format UDZO \
"${DMG_PATH}"
fi
# -------- Sign the DMG itself if a Developer ID is available --------
# Notarization requires the DMG be signed too.
if [[ -n "${DEVELOPER_ID_APPLICATION:-}" ]]; then
echo "[make-dmg.sh] signing DMG with: ${DEVELOPER_ID_APPLICATION}"
codesign --force --sign "${DEVELOPER_ID_APPLICATION}" --timestamp "${DMG_PATH}"
elif security find-identity -v -p codesigning 2>/dev/null \
| grep -q "Developer ID Application"; then
IDENT=$(security find-identity -v -p codesigning \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
echo "[make-dmg.sh] signing DMG with auto-detected identity: ${IDENT}"
codesign --force --sign "${IDENT}" --timestamp "${DMG_PATH}"
else
echo "[make-dmg.sh] no Developer ID identity, DMG left unsigned (ad-hoc build)."
fi
echo "[make-dmg.sh] OK -> ${DMG_PATH}"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# =============================================================================
# notarize.sh — Submit the signed DMG to Apple Notary Service and staple the
# returned ticket so users get a green Gatekeeper check offline.
#
# Design rationale:
# - We use `xcrun notarytool` (Xcode 13+). The legacy `altool` is deprecated.
# - Two credential modes are supported, in order of preference:
# 1. KEYCHAIN_PROFILE — recommended, created once via:
# xcrun notarytool store-credentials "snapgo-notary" \
# --apple-id "<APPLE_ID>" \
# --team-id "<TEAMID>" \
# --password "<APP_SPECIFIC_PASSWORD>"
# 2. APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD env vars.
# - After successful notarization we run `xcrun stapler staple` so the app/dmg
# is verifiable without an internet connection on the user's Mac.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
DMG_PATH="${DMG_PATH:-}"
if [[ -z "${DMG_PATH}" ]]; then
# Auto-detect the most recent DMG produced by make-dmg.sh
DMG_PATH="$(ls -1t "${ROOT_DIR}/build/bin/"SnapGo-*.dmg 2>/dev/null | head -1 || true)"
fi
if [[ -z "${DMG_PATH}" || ! -f "${DMG_PATH}" ]]; then
echo "[notarize.sh] ERROR: DMG not found. Set DMG_PATH or run make-dmg.sh first." >&2
exit 1
fi
# -------- Build the credential argv array --------
# Using array form keeps spaces in passwords / paths safe.
NOTARY_ARGS=()
if [[ -n "${KEYCHAIN_PROFILE:-}" ]]; then
NOTARY_ARGS+=(--keychain-profile "${KEYCHAIN_PROFILE}")
elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
NOTARY_ARGS+=(--apple-id "${APPLE_ID}"
--team-id "${APPLE_TEAM_ID}"
--password "${APPLE_APP_SPECIFIC_PASSWORD}")
else
cat >&2 <<EOF
[notarize.sh] ERROR: notary credentials not configured.
Option A (recommended) — store once in keychain, then export KEYCHAIN_PROFILE:
xcrun notarytool store-credentials "snapgo-notary" \\
--apple-id "you@example.com" \\
--team-id "ABCDE12345" \\
--password "<app-specific-password>"
export KEYCHAIN_PROFILE=snapgo-notary
Option B — export env vars directly:
export APPLE_ID="you@example.com"
export APPLE_TEAM_ID="ABCDE12345"
export APPLE_APP_SPECIFIC_PASSWORD="abcd-efgh-ijkl-mnop"
EOF
exit 1
fi
echo "[notarize.sh] submitting: ${DMG_PATH}"
xcrun notarytool submit "${DMG_PATH}" "${NOTARY_ARGS[@]}" --wait
echo "[notarize.sh] stapling ticket..."
xcrun stapler staple "${DMG_PATH}"
xcrun stapler validate "${DMG_PATH}"
# Also staple the .app inside, if we still have it on disk — this ensures the
# offline Gatekeeper check works even if the user copies the app out of the DMG.
APP_PATH="${ROOT_DIR}/build/bin/SnapGo.app"
if [[ -d "${APP_PATH}" ]]; then
echo "[notarize.sh] stapling app bundle..."
xcrun stapler staple "${APP_PATH}" || true
fi
echo "[notarize.sh] OK -> ${DMG_PATH}"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# =============================================================================
# release.sh — One-shot build → sign → DMG → notarize → staple pipeline.
#
# Design rationale:
# - Each stage is delegated to a dedicated single-purpose script. This keeps the
# pipeline composable: a developer can re-run any individual stage without
# redoing the whole release.
# - Notarization is OPT-IN. Setting NOTARIZE=1 (or providing notary credentials)
# triggers it; otherwise we stop after producing a signed DMG, which is enough
# for internal distribution and CI smoke tests.
# - ARCH defaults to the host arch so a developer on Apple Silicon gets an
# arm64 build. Set ARCH=universal to ship a fat binary.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
ARCH="${ARCH:-$(uname -m)}"
case "${ARCH}" in
arm64) WAILS_PLATFORM="darwin/arm64" ;;
x86_64) WAILS_PLATFORM="darwin/amd64" ;;
universal) WAILS_PLATFORM="darwin/universal" ;;
*) echo "[release.sh] ERROR: unsupported ARCH=${ARCH}" >&2; exit 1 ;;
esac
echo "================================================================"
echo " SnapGo release pipeline"
echo " arch : ${ARCH} (${WAILS_PLATFORM})"
echo " sign : ${SIGN_MODE:-auto}"
echo " notarize : ${NOTARIZE:-0}"
echo "================================================================"
# ---- 1. Build ----
echo ""
echo "[release.sh] (1/4) wails build"
( cd "${ROOT_DIR}" && wails build -platform "${WAILS_PLATFORM}" -clean )
# ---- 2. Sign .app ----
echo ""
echo "[release.sh] (2/4) sign app"
"${SCRIPT_DIR}/sign.sh"
# ---- 3. DMG ----
echo ""
echo "[release.sh] (3/4) make DMG"
ARCH="${ARCH}" "${SCRIPT_DIR}/make-dmg.sh"
# ---- 4. Notarize (optional) ----
echo ""
if [[ "${NOTARIZE:-0}" == "1" || -n "${KEYCHAIN_PROFILE:-}" \
|| ( -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" \
&& -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ) ]]; then
echo "[release.sh] (4/4) notarize"
"${SCRIPT_DIR}/notarize.sh"
else
echo "[release.sh] (4/4) notarize SKIPPED (set NOTARIZE=1 with credentials to enable)"
fi
echo ""
echo "[release.sh] DONE."
ls -lh "${ROOT_DIR}/build/bin/"SnapGo-*.dmg 2>/dev/null || true
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# =============================================================================
# sign.sh — Sign SnapGo.app with Hardened Runtime.
#
# Design rationale:
# - Two modes are supported, controlled purely by env vars (no CLI args), so
# the script stays idempotent and easy to wire into CI:
# 1. "developer-id" : real Developer ID Application signing for distribution
# (requires DEVELOPER_ID_APPLICATION env or default
# lookup via `security find-identity`).
# 2. "ad-hoc" : codesign with `-` identity. The bundle works on the
# building machine after `xattr -dr com.apple.quarantine`,
# but cannot be notarized.
# - We always sign with --options=runtime so the bundle is notarization-ready.
# - We sign nested binaries first, then the .app bundle (Apple's required order).
# =============================================================================
set -euo pipefail
# -------- Paths --------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
APP_PATH="${APP_PATH:-${ROOT_DIR}/build/bin/SnapGo.app}"
ENTITLEMENTS="${ENTITLEMENTS:-${ROOT_DIR}/build/darwin/entitlements.plist}"
# -------- Mode resolution --------
# SIGN_MODE=developer-id | ad-hoc (default: ad-hoc when no identity is found)
SIGN_MODE="${SIGN_MODE:-auto}"
SIGN_IDENTITY=""
resolve_identity() {
if [[ -n "${DEVELOPER_ID_APPLICATION:-}" ]]; then
SIGN_IDENTITY="${DEVELOPER_ID_APPLICATION}"
SIGN_MODE="developer-id"
return
fi
# Try to auto-discover the first Developer ID Application identity.
local found
found=$(security find-identity -v -p codesigning 2>/dev/null \
| awk -F'"' '/Developer ID Application/ {print $2; exit}')
if [[ -n "${found}" ]]; then
SIGN_IDENTITY="${found}"
SIGN_MODE="developer-id"
else
SIGN_IDENTITY="-"
SIGN_MODE="ad-hoc"
fi
}
if [[ "${SIGN_MODE}" == "auto" ]]; then
resolve_identity
elif [[ "${SIGN_MODE}" == "developer-id" ]]; then
if [[ -z "${DEVELOPER_ID_APPLICATION:-}" ]]; then
resolve_identity
if [[ "${SIGN_MODE}" != "developer-id" ]]; then
echo "[sign.sh] ERROR: SIGN_MODE=developer-id but no Developer ID Application identity found." >&2
echo " Set DEVELOPER_ID_APPLICATION='Developer ID Application: NAME (TEAMID)'" >&2
exit 1
fi
else
SIGN_IDENTITY="${DEVELOPER_ID_APPLICATION}"
fi
elif [[ "${SIGN_MODE}" == "ad-hoc" ]]; then
SIGN_IDENTITY="-"
else
echo "[sign.sh] ERROR: invalid SIGN_MODE='${SIGN_MODE}'" >&2
exit 1
fi
# -------- Sanity check --------
if [[ ! -d "${APP_PATH}" ]]; then
echo "[sign.sh] ERROR: app bundle not found at ${APP_PATH}" >&2
echo " Run 'wails build -platform darwin/arm64 -clean' first." >&2
exit 1
fi
if [[ ! -f "${ENTITLEMENTS}" ]]; then
echo "[sign.sh] ERROR: entitlements not found at ${ENTITLEMENTS}" >&2
exit 1
fi
echo "[sign.sh] mode = ${SIGN_MODE}"
echo "[sign.sh] identity = ${SIGN_IDENTITY}"
echo "[sign.sh] app = ${APP_PATH}"
echo "[sign.sh] ents = ${ENTITLEMENTS}"
# -------- Decide whether to request RFC3161 timestamp --------
# Apple's timestamp server only accepts signatures from Developer ID-issued
# certificates. For ad-hoc or local self-signed certs the timestamp call
# either fails or attaches noise that makes re-signing nondeterministic
# (which in turn breaks TCC's "is this the same app?" comparison).
TIMESTAMP_FLAG="--timestamp=none"
if [[ "${SIGN_MODE}" == "developer-id" && "${SIGN_IDENTITY}" == "Developer ID Application:"* ]]; then
TIMESTAMP_FLAG="--timestamp"
fi
echo "[sign.sh] timestamp = ${TIMESTAMP_FLAG}"
# -------- Strip stale signatures so re-signing is deterministic --------
codesign --remove-signature "${APP_PATH}" 2>/dev/null || true
# -------- Sign nested executables first (Apple required ordering) --------
# Find any embedded Mach-O binaries / frameworks / dylibs.
NESTED_PATHS=()
while IFS= read -r path; do
NESTED_PATHS+=("$path")
done < <(find "${APP_PATH}/Contents" \
\( -name "*.dylib" -o -name "*.framework" -o -path "*/Frameworks/*" \) \
-not -path "*/_CodeSignature/*" 2>/dev/null || true)
for nested in "${NESTED_PATHS[@]:-}"; do
[[ -z "${nested}" ]] && continue
echo "[sign.sh] signing nested: ${nested}"
codesign --force ${TIMESTAMP_FLAG} --options=runtime \
--sign "${SIGN_IDENTITY}" \
"${nested}"
done
# -------- Sign the main app bundle --------
echo "[sign.sh] signing app bundle..."
codesign --force ${TIMESTAMP_FLAG} --options=runtime \
--entitlements "${ENTITLEMENTS}" \
--sign "${SIGN_IDENTITY}" \
"${APP_PATH}"
# -------- Verify --------
echo "[sign.sh] verifying signature..."
codesign --verify --deep --strict --verbose=2 "${APP_PATH}"
if [[ "${SIGN_MODE}" == "developer-id" ]]; then
# Gatekeeper assessment will pass only after notarization + stapling,
# but we can pre-flight the spctl evaluation now.
echo "[sign.sh] spctl assessment (will likely say 'rejected' until notarized)..."
spctl --assess --type execute --verbose=4 "${APP_PATH}" || true
fi
echo "[sign.sh] OK."
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "SnapGo",
"outputfilename": "SnapGo",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "mmmy",
"email": "mmmy@example.com"
},
"info": {
"productName": "SnapGo",
"productVersion": "0.1.0",
"copyright": "Copyright © 2026",
"comments": "Cross-platform screenshot tool with one-click upload to S3"
}
}