diff --git a/.gitignore b/.gitignore index dd153be..36ad0b6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ scripts/secrets.sh # IDE .trae/ .vscode/ + +# OS +*.DS_Store diff --git a/Introduction.md b/Introduction.md new file mode 100644 index 0000000..2d9c625 --- /dev/null +++ b/Introduction.md @@ -0,0 +1,36 @@ +# SnapGo + +SnapGo 是一个以 macOS 为核心体验的截图上传工具,面向“截图后立刻分享链接”的高频场景。它常驻于菜单栏,支持全局快捷键唤起截图覆盖层,在完成区域选择后自动将图片上传到兼容 S3 协议的对象存储,并把最终公开链接写入剪贴板,尽可能减少“截图、保存、上传、复制链接”之间的手动切换成本。 + +项目当前基于 `Wails + Go + Vue 3` 构建,桌面端负责截图、热键、托盘与系统集成,前端负责设置页与截图交互界面。整体设计围绕“轻量常驻、快速触发、上传即复制”展开,适合作为个人图床工作流中的本地入口工具。 + +## 核心功能 + +- 全局快捷键截图:支持通过全局快捷键在任意应用中快速唤起截图流程。 +- 区域选择覆盖层:提供接近原生体验的截图选区交互,优先针对 macOS 做了透明覆盖层与窗口层级优化。 +- 自动上传到 S3 兼容存储:支持 AWS S3、MinIO、Cloudflare R2、Backblaze B2 及其他兼容 S3 API 的对象存储。 +- 自动复制公开链接:上传成功后自动将图片 URL 写入剪贴板,方便直接粘贴到 IM、文档或工单系统。 +- 菜单栏常驻与设置面板:通过托盘菜单进入设置,配置热键、存储参数,并测试连接可用性。 +- 上传失败兜底:当上传失败时,会将截图落盘到本地临时目录,避免截图结果丢失。 + +## 典型使用流程 + +1. 首次启动后,在设置页中填写 S3 兼容对象存储的连接信息。 +2. 保存配置并测试连通性,确认目标 Bucket 可写。 +3. 使用默认快捷键 `cmd+shift+a`,或自定义快捷键,随时发起截图。 +4. 选择需要截取的区域并确认。 +5. 应用自动上传截图,并将生成的公开链接复制到剪贴板。 + +## 项目定位 + +SnapGo 当前更偏向一个面向个人使用的高效工具,而不是“大而全”的截图平台。它的重点不在复杂标注或云端管理能力,而在于把“截图并生成可分享链接”这条链路压缩到尽可能短,尤其适合开发者、产品、测试或需要频繁在聊天工具中分享截图的用户。 + +虽然工程结构中保留了跨平台方向的设计,但现阶段的核心交互与系统集成主要围绕 macOS 打磨,包括菜单栏形态、原生覆盖层、窗口透明化处理以及与系统截图能力的衔接。因此,如果你希望获得完整体验,macOS 是当前最合适的运行平台。 + +## 技术实现概览 + +- 桌面壳与应用编排:Go + Wails +- 设置界面与截图覆盖层:Vue 3 + TypeScript + Vite +- 全局快捷键:`golang.design/x/hotkey` +- 对象存储接入:AWS SDK for Go v2(S3 兼容实现) +- macOS 截图与窗口控制:系统 `screencapture` 命令 + AppKit 原生能力 diff --git a/README.md b/README.md index 02e8d69..7cd0518 100644 --- a/README.md +++ b/README.md @@ -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 是目前最推荐的使用平台。 diff --git a/activation_policy_darwin.go b/activation_policy_darwin.go new file mode 100644 index 0000000..75a9b1b --- /dev/null +++ b/activation_policy_darwin.go @@ -0,0 +1,71 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c -fobjc-arc +#cgo LDFLAGS: -framework AppKit +#include +#import + +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) +} diff --git a/activation_policy_other.go b/activation_policy_other.go new file mode 100644 index 0000000..158169d --- /dev/null +++ b/activation_policy_other.go @@ -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() {} diff --git a/app.go b/app.go new file mode 100644 index 0000000..5d10582 --- /dev/null +++ b/app.go @@ -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") +} diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..1ae2f67 --- /dev/null +++ b/build/README.md @@ -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. \ No newline at end of file diff --git a/build/appicon.png b/build/appicon.png new file mode 100644 index 0000000..dc728e3 Binary files /dev/null and b/build/appicon.png differ diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist new file mode 100644 index 0000000..41f0990 --- /dev/null +++ b/build/darwin/Info.dev.plist @@ -0,0 +1,74 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + io.snapgo.app + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 11.0.0 + NSHighResolutionCapable + true + LSUIElement + + NSAppleEventsUsageDescription + SnapGo needs Apple Events access to capture the active window. + NSScreenCaptureUsageDescription + SnapGo needs screen recording permission to capture screenshots. + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist new file mode 100644 index 0000000..5c7dd30 --- /dev/null +++ b/build/darwin/Info.plist @@ -0,0 +1,75 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + io.snapgo.app + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 11.0.0 + NSHighResolutionCapable + true + + LSUIElement + + NSAppleEventsUsageDescription + SnapGo needs Apple Events access to capture the active window. + NSScreenCaptureUsageDescription + SnapGo needs screen recording permission to capture screenshots. + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/build/darwin/entitlements.plist b/build/darwin/entitlements.plist new file mode 100644 index 0000000..aeb9388 --- /dev/null +++ b/build/darwin/entitlements.plist @@ -0,0 +1,42 @@ + + + + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + + com.apple.security.network.client + + + + com.apple.security.automation.apple-events + + + diff --git a/build/windows/icon.ico b/build/windows/icon.ico new file mode 100644 index 0000000..f334798 Binary files /dev/null and b/build/windows/icon.ico differ diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 0000000..9727946 --- /dev/null +++ b/build/windows/info.json @@ -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}}" + } + } +} \ No newline at end of file diff --git a/build/windows/installer/project.nsi b/build/windows/installer/project.nsi new file mode 100644 index 0000000..654ae2e --- /dev/null +++ b/build/windows/installer/project.nsi @@ -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 diff --git a/build/windows/installer/wails_tools.nsh b/build/windows/installer/wails_tools.nsh new file mode 100644 index 0000000..2f6d321 --- /dev/null +++ b/build/windows/installer/wails_tools.nsh @@ -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 diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 0000000..17e1a23 --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + \ No newline at end of file diff --git a/flush_frame.go b/flush_frame.go new file mode 100644 index 0000000..10727bc --- /dev/null +++ b/flush_frame.go @@ -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) +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..98f4a52 --- /dev/null +++ b/frontend/README.md @@ -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 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..51fcd57 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1071 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "vue": "^3.2.37" + }, + "devDependencies": { + "@babel/types": "^7.18.10", + "@vitejs/plugin-vue": "^3.0.3", + "typescript": "^4.6.4", + "vite": "^3.0.7", + "vue-tsc": "^1.8.27" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-3.2.0.tgz", + "integrity": "sha512-E0tnaL4fr+qkdCNxJ+Xd0yM31UwMkQje76fsDVBBUCoGOUPexu2VDUYHL8P4CwV+zMvWw6nlRw19OnRKmYAJpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmmirror.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/vite": { + "version": "3.2.11", + "resolved": "https://registry.npmmirror.com/vite/-/vite-3.2.11.tgz", + "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e65d0ef --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100755 index 0000000..706e5db --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +bb7ffb87329c9ad4990374471d4ce9a4 \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..648a0c7 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,299 @@ + + + + + diff --git a/frontend/src/assets/fonts/OFL.txt b/frontend/src/assets/fonts/OFL.txt new file mode 100644 index 0000000..9cac04c --- /dev/null +++ b/frontend/src/assets/fonts/OFL.txt @@ -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. diff --git a/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 b/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 new file mode 100644 index 0000000..2f9cc59 Binary files /dev/null and b/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2 differ diff --git a/frontend/src/assets/images/logo-universal.png b/frontend/src/assets/images/logo-universal.png new file mode 100644 index 0000000..d63303b Binary files /dev/null and b/frontend/src/assets/images/logo-universal.png differ diff --git a/frontend/src/components/Toast.vue b/frontend/src/components/Toast.vue new file mode 100644 index 0000000..a35d6f7 --- /dev/null +++ b/frontend/src/components/Toast.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..f9754fe --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,5 @@ +import {createApp} from 'vue' +import App from './App.vue' +import './style.css'; + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..94bb0fb --- /dev/null +++ b/frontend/src/style.css @@ -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; +} diff --git a/frontend/src/views/CaptureOverlay.vue b/frontend/src/views/CaptureOverlay.vue new file mode 100644 index 0000000..851544a --- /dev/null +++ b/frontend/src/views/CaptureOverlay.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..773431d --- /dev/null +++ b/frontend/src/views/SettingsView.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..dcfaef4 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type {DefineComponent} from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..3cc844d --- /dev/null +++ b/frontend/tsconfig.json @@ -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" + } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..b8afcc8 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": [ + "vite.config.ts" + ] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..a30c338 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()] +}) diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts new file mode 100755 index 0000000..dac05f3 --- /dev/null +++ b/frontend/wailsjs/go/main/App.d.ts @@ -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; + +export function CancelRegion():Promise; + +export function CaptureNow():Promise; + +export function ConfirmNativeRegion(arg1:main.RegionRect):Promise; + +export function ConfirmRegion(arg1:main.RegionRect):Promise; + +export function GetConfig():Promise; + +export function QuitApp():Promise; + +export function RetryRegisterHotkey():Promise; + +export function SaveConfig(arg1:domain.AppConfig):Promise; + +export function ShowWindow():Promise; + +export function TestConnection(arg1:domain.S3Config):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js new file mode 100755 index 0000000..3edd6c1 --- /dev/null +++ b/frontend/wailsjs/go/main/App.js @@ -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); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts new file mode 100755 index 0000000..79d2781 --- /dev/null +++ b/frontend/wailsjs/go/models.ts @@ -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"]; + } + } + +} + diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json new file mode 100644 index 0000000..1e7c8a5 --- /dev/null +++ b/frontend/wailsjs/runtime/package.json @@ -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 ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wailsapp/wails/issues" + }, + "homepage": "https://github.com/wailsapp/wails#readme" +} diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts new file mode 100644 index 0000000..3bbea84 --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [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; + +// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext) +// Sets a text on the clipboard +export function ClipboardSetText(text: string): Promise; + +// [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; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [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; + +// [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; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [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; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js new file mode 100644 index 0000000..556621e --- /dev/null +++ b/frontend/wailsjs/runtime/runtime.js @@ -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); +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1f046f4 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6fbce9d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/application/capture_upload.go b/internal/application/capture_upload.go new file mode 100644 index 0000000..30d6a8d --- /dev/null +++ b/internal/application/capture_upload.go @@ -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) +} diff --git a/internal/domain/config.go b/internal/domain/config.go new file mode 100644 index 0000000..1d7445b --- /dev/null +++ b/internal/domain/config.go @@ -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 != "" +} diff --git a/internal/domain/types.go b/internal/domain/types.go new file mode 100644 index 0000000..db7d790 --- /dev/null +++ b/internal/domain/types.go @@ -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 +} diff --git a/internal/infrastructure/clipboard/clipboard.go b/internal/infrastructure/clipboard/clipboard.go new file mode 100644 index 0000000..60cc96b --- /dev/null +++ b/internal/infrastructure/clipboard/clipboard.go @@ -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 +} diff --git a/internal/infrastructure/config/file_store.go b/internal/infrastructure/config/file_store.go new file mode 100644 index 0000000..7994004 --- /dev/null +++ b/internal/infrastructure/config/file_store.go @@ -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 +} diff --git a/internal/infrastructure/cursor/cursor.go b/internal/infrastructure/cursor/cursor.go new file mode 100644 index 0000000..b3368c0 --- /dev/null +++ b/internal/infrastructure/cursor/cursor.go @@ -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 +} diff --git a/internal/infrastructure/cursor/cursor_darwin.go b/internal/infrastructure/cursor/cursor_darwin.go new file mode 100644 index 0000000..7cc14d0 --- /dev/null +++ b/internal/infrastructure/cursor/cursor_darwin.go @@ -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 + +// 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 +} diff --git a/internal/infrastructure/cursor/cursor_other.go b/internal/infrastructure/cursor/cursor_other.go new file mode 100644 index 0000000..e3a1429 --- /dev/null +++ b/internal/infrastructure/cursor/cursor_other.go @@ -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 +} diff --git a/internal/infrastructure/display/display.go b/internal/infrastructure/display/display.go new file mode 100644 index 0000000..81e0e8c --- /dev/null +++ b/internal/infrastructure/display/display.go @@ -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 +} diff --git a/internal/infrastructure/display/display_darwin.go b/internal/infrastructure/display/display_darwin.go new file mode 100644 index 0000000..547d1d5 --- /dev/null +++ b/internal/infrastructure/display/display_darwin.go @@ -0,0 +1,41 @@ +//go:build darwin + +package display + +/* +#cgo LDFLAGS: -framework CoreGraphics -framework Foundation +#include + +// 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, + } +} diff --git a/internal/infrastructure/display/display_other.go b/internal/infrastructure/display/display_other.go new file mode 100644 index 0000000..500257e --- /dev/null +++ b/internal/infrastructure/display/display_other.go @@ -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, + } +} diff --git a/internal/infrastructure/hotkey/hotkey.go b/internal/infrastructure/hotkey/hotkey.go new file mode 100644 index 0000000..1224011 --- /dev/null +++ b/internal/infrastructure/hotkey/hotkey.go @@ -0,0 +1,121 @@ +// Package hotkey wraps golang.design/x/hotkey to provide a process-wide +// global shortcut. The MVP only supports a single hard-coded combination +// (Cmd+Shift+A on macOS) but the abstraction below allows the binding to +// be replaced at runtime once we add per-platform parsing of user input. +package hotkey + +import ( + "fmt" + "strings" + "sync" + + hk "golang.design/x/hotkey" +) + +// Manager owns the lifecycle of one global hotkey. +type Manager struct { + mu sync.Mutex + current *hk.Hotkey + stop chan struct{} +} + +// NewManager returns a fresh manager with no hotkey registered yet. +func NewManager() *Manager { return &Manager{} } + +// Register parses the spec (e.g. "cmd+shift+a") and binds the callback. +// If a previous hotkey is registered, it is unregistered first so callers +// can swap shortcuts at runtime when the user changes the setting. +func (m *Manager) Register(spec string, onTrigger func()) error { + m.mu.Lock() + defer m.mu.Unlock() + + if m.current != nil { + _ = m.current.Unregister() + close(m.stop) + m.current = nil + m.stop = nil + } + + mods, key, err := parseSpec(spec) + if err != nil { + return err + } + + hot := hk.New(mods, key) + if err := hot.Register(); err != nil { + return fmt.Errorf("register hotkey %q: %w", spec, err) + } + + stop := make(chan struct{}) + m.current = hot + m.stop = stop + + // Listen on a dedicated goroutine. We never run the callback inline + // because the keydown channel is unbuffered. + go func() { + for { + select { + case <-stop: + return + case <-hot.Keydown(): + if onTrigger != nil { + go onTrigger() + } + } + } + }() + return nil +} + +// Unregister releases the active hotkey, if any. +func (m *Manager) Unregister() { + m.mu.Lock() + defer m.mu.Unlock() + if m.current != nil { + _ = m.current.Unregister() + close(m.stop) + m.current = nil + m.stop = nil + } +} + +// parseSpec parses a "+"-separated hotkey description into (modifiers, key). +// +// Supported tokens (case-insensitive): cmd, command, ctrl, control, +// option, alt, shift, plus a single letter A–Z or digit 0–9. +// +// We keep the parser intentionally tiny — full key mapping is out of scope +// for the MVP. +func parseSpec(spec string) ([]hk.Modifier, hk.Key, error) { + parts := strings.Split(strings.ToLower(strings.TrimSpace(spec)), "+") + if len(parts) == 0 { + return nil, 0, fmt.Errorf("empty hotkey") + } + var mods []hk.Modifier + var key hk.Key + hasKey := false + for _, p := range parts { + p = strings.TrimSpace(p) + switch p { + case "cmd", "command", "meta", "super": + mods = append(mods, modCmd()) + case "ctrl", "control": + mods = append(mods, modCtrl()) + case "option", "alt": + mods = append(mods, modOption()) + case "shift": + mods = append(mods, hk.ModShift) + default: + k, ok := lookupKey(p) + if !ok { + return nil, 0, fmt.Errorf("unsupported token: %q", p) + } + key = k + hasKey = true + } + } + if !hasKey { + return nil, 0, fmt.Errorf("hotkey %q is missing a key", spec) + } + return mods, key, nil +} diff --git a/internal/infrastructure/hotkey/hotkey_darwin.go b/internal/infrastructure/hotkey/hotkey_darwin.go new file mode 100644 index 0000000..1d21c22 --- /dev/null +++ b/internal/infrastructure/hotkey/hotkey_darwin.go @@ -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 } diff --git a/internal/infrastructure/hotkey/hotkey_other.go b/internal/infrastructure/hotkey/hotkey_other.go new file mode 100644 index 0000000..3fb5b7d --- /dev/null +++ b/internal/infrastructure/hotkey/hotkey_other.go @@ -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 } diff --git a/internal/infrastructure/hotkey/keys.go b/internal/infrastructure/hotkey/keys.go new file mode 100644 index 0000000..e895313 --- /dev/null +++ b/internal/infrastructure/hotkey/keys.go @@ -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 +} diff --git a/internal/infrastructure/oss/s3.go b/internal/infrastructure/oss/s3.go new file mode 100644 index 0000000..0109991 --- /dev/null +++ b/internal/infrastructure/oss/s3.go @@ -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 +} diff --git a/internal/infrastructure/screencapture/capturer.go b/internal/infrastructure/screencapture/capturer.go new file mode 100644 index 0000000..cb462bd --- /dev/null +++ b/internal/infrastructure/screencapture/capturer.go @@ -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{} diff --git a/internal/infrastructure/screencapture/capturer_darwin.go b/internal/infrastructure/screencapture/capturer_darwin.go new file mode 100644 index 0000000..b0f4afa --- /dev/null +++ b/internal/infrastructure/screencapture/capturer_darwin.go @@ -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) +} diff --git a/internal/infrastructure/screencapture/capturer_other.go b/internal/infrastructure/screencapture/capturer_other.go new file mode 100644 index 0000000..0b957ee --- /dev/null +++ b/internal/infrastructure/screencapture/capturer_other.go @@ -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 +} diff --git a/internal/infrastructure/screencapture/cropper.go b/internal/infrastructure/screencapture/cropper.go new file mode 100644 index 0000000..bb71b78 --- /dev/null +++ b/internal/infrastructure/screencapture/cropper.go @@ -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 +} diff --git a/internal/infrastructure/tray/assets/statusbarTemplate.png b/internal/infrastructure/tray/assets/statusbarTemplate.png new file mode 100644 index 0000000..39694e8 Binary files /dev/null and b/internal/infrastructure/tray/assets/statusbarTemplate.png differ diff --git a/internal/infrastructure/tray/assets/statusbarTemplate.tiff b/internal/infrastructure/tray/assets/statusbarTemplate.tiff new file mode 100644 index 0000000..5363835 Binary files /dev/null and b/internal/infrastructure/tray/assets/statusbarTemplate.tiff differ diff --git a/internal/infrastructure/tray/assets/statusbarTemplate@2x.png b/internal/infrastructure/tray/assets/statusbarTemplate@2x.png new file mode 100644 index 0000000..17242d0 Binary files /dev/null and b/internal/infrastructure/tray/assets/statusbarTemplate@2x.png differ diff --git a/internal/infrastructure/tray/dispatch_darwin.go b/internal/infrastructure/tray/dispatch_darwin.go new file mode 100644 index 0000000..7efdd78 --- /dev/null +++ b/internal/infrastructure/tray/dispatch_darwin.go @@ -0,0 +1,62 @@ +//go:build darwin + +package tray + +/* +#cgo darwin LDFLAGS: -framework Foundation +#include + +// 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() + } +} diff --git a/internal/infrastructure/tray/dispatch_other.go b/internal/infrastructure/tray/dispatch_other.go new file mode 100644 index 0000000..4ecf3d5 --- /dev/null +++ b/internal/infrastructure/tray/dispatch_other.go @@ -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() + } +} diff --git a/internal/infrastructure/tray/icon_darwin.go b/internal/infrastructure/tray/icon_darwin.go new file mode 100644 index 0000000..6e47e02 --- /dev/null +++ b/internal/infrastructure/tray/icon_darwin.go @@ -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 diff --git a/internal/infrastructure/tray/icon_other.go b/internal/infrastructure/tray/icon_other.go new file mode 100644 index 0000000..4f4c2fe --- /dev/null +++ b/internal/infrastructure/tray/icon_other.go @@ -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 diff --git a/internal/infrastructure/tray/tray.go b/internal/infrastructure/tray/tray.go new file mode 100644 index 0000000..f10cb03 --- /dev/null +++ b/internal/infrastructure/tray/tray.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c25ff83 --- /dev/null +++ b/main.go @@ -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() +} diff --git a/native_overlay_callbacks_darwin.go b/native_overlay_callbacks_darwin.go new file mode 100644 index 0000000..8185bf8 --- /dev/null +++ b/native_overlay_callbacks_darwin.go @@ -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() +} diff --git a/native_overlay_darwin.go b/native_overlay_darwin.go new file mode 100644 index 0000000..1a7e019 --- /dev/null +++ b/native_overlay_darwin.go @@ -0,0 +1,317 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks +#cgo LDFLAGS: -framework AppKit -framework CoreGraphics +#include +#include +#import + +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 +} diff --git a/native_overlay_other.go b/native_overlay_other.go new file mode 100644 index 0000000..5ba2fe7 --- /dev/null +++ b/native_overlay_other.go @@ -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 +} diff --git a/overlay_window_darwin.go b/overlay_window_darwin.go new file mode 100644 index 0000000..35e84ad --- /dev/null +++ b/overlay_window_darwin.go @@ -0,0 +1,121 @@ +//go:build darwin + +package main + +/* +#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks +#cgo LDFLAGS: -framework AppKit -framework CoreGraphics +#include +#import + +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() +} diff --git a/overlay_window_other.go b/overlay_window_other.go new file mode 100644 index 0000000..9ab2842 --- /dev/null +++ b/overlay_window_other.go @@ -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() {} diff --git a/scripts/create-dev-cert.sh b/scripts/create-dev-cert.sh new file mode 100755 index 0000000..8e71a17 --- /dev/null +++ b/scripts/create-dev-cert.sh @@ -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}'" diff --git a/scripts/dev-build.sh b/scripts/dev-build.sh new file mode 100755 index 0000000..b6fad37 --- /dev/null +++ b/scripts/dev-build.sh @@ -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" diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh new file mode 100755 index 0000000..793e9ee --- /dev/null +++ b/scripts/make-dmg.sh @@ -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--.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}" diff --git a/scripts/notarize.sh b/scripts/notarize.sh new file mode 100755 index 0000000..637a95d --- /dev/null +++ b/scripts/notarize.sh @@ -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 "" \ +# --team-id "" \ +# --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 <" + 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}" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..3408c8d --- /dev/null +++ b/scripts/release.sh @@ -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 diff --git a/scripts/sign.sh b/scripts/sign.sh new file mode 100755 index 0000000..c659a47 --- /dev/null +++ b/scripts/sign.sh @@ -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." diff --git a/wails.json b/wails.json new file mode 100644 index 0000000..980d00a --- /dev/null +++ b/wails.json @@ -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" + } +}