1 Commits

Author SHA1 Message Date
mamamiyear 7fe3a0fc93 feat: add landing page 2026-06-12 08:02:55 +08:00
33 changed files with 3192 additions and 2051 deletions
+1
View File
@@ -1,6 +1,7 @@
build/bin build/bin
node_modules node_modules
frontend/dist frontend/dist
landing/dist
# macOS release artifacts # macOS release artifacts
*.dmg *.dmg
+6 -193
View File
@@ -22,7 +22,6 @@ import (
"github.com/mmmy/snapgo/internal/infrastructure/config" "github.com/mmmy/snapgo/internal/infrastructure/config"
"github.com/mmmy/snapgo/internal/infrastructure/display" "github.com/mmmy/snapgo/internal/infrastructure/display"
"github.com/mmmy/snapgo/internal/infrastructure/hotkey" "github.com/mmmy/snapgo/internal/infrastructure/hotkey"
llmpkg "github.com/mmmy/snapgo/internal/infrastructure/llm"
"github.com/mmmy/snapgo/internal/infrastructure/oss" "github.com/mmmy/snapgo/internal/infrastructure/oss"
"github.com/mmmy/snapgo/internal/infrastructure/screencapture" "github.com/mmmy/snapgo/internal/infrastructure/screencapture"
sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh" sshpkg "github.com/mmmy/snapgo/internal/infrastructure/ssh"
@@ -97,15 +96,6 @@ type CaptureActionResult struct {
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
} }
// OperationStatusPayload drives the frontend status HUD and mirrors the
// native macOS HUD state.
type OperationStatusPayload struct {
Operation string `json:"operation"`
Phase string `json:"phase"`
Message string `json:"message"`
State string `json:"state"`
}
// NewApp creates a new App with collaborators already initialised. // NewApp creates a new App with collaborators already initialised.
func NewApp() *App { func NewApp() *App {
store, err := config.NewFileStore() store, err := config.NewFileStore()
@@ -120,7 +110,6 @@ func NewApp() *App {
slog.Warn("config load failed, using defaults", "err", lerr) slog.Warn("config load failed, using defaults", "err", lerr)
} }
} }
cfg.Normalize()
return &App{ return &App{
cfg: cfg, cfg: cfg,
configFile: store, configFile: store,
@@ -238,8 +227,8 @@ func (a *App) runInteractiveCapture() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ( const (
settingsWidth = 1000 settingsWidth = 1080
settingsHeight = 820 settingsHeight = 720
) )
// showOverlayWindow prepares the hidden main window as a borderless, // showOverlayWindow prepares the hidden main window as a borderless,
@@ -308,13 +297,7 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er
FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"), FallbackDir: filepath.Join(userPicturesDir(), "SnapGo"),
PathPrefix: cfg.S3.PathPrefix, PathPrefix: cfg.S3.PathPrefix,
} }
a.emitOperationStatus("upload", "上传中", "正在上传截图到 S3", "running") return svc.ExecuteWithBytes(a.ctx, pngBytes)
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("upload", "上传失败", err.Error(), "error")
return err
}
a.emitOperationStatus("upload", "上传完成", "链接已复制到剪贴板", "success")
return nil
} }
func (a *App) runCopyImagePipeline(pngBytes []byte) error { func (a *App) runCopyImagePipeline(pngBytes []byte) error {
@@ -349,102 +332,20 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
err := fmt.Errorf("SSH host/user is not configured") err := fmt.Errorf("SSH host/user is not configured")
slog.Warn("save-remote rejected: ssh not configured", slog.Warn("save-remote rejected: ssh not configured",
"host", cfg.SSH.Host, "user", cfg.SSH.User) "host", cfg.SSH.Host, "user", cfg.SSH.User)
a.emitOperationStatus("save-remote", "需要配置 SSH", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error()) wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err return err
} }
slog.Info("save-remote dispatch", slog.Info("save-remote dispatch",
"host", cfg.SSH.Host, "user", cfg.SSH.User, "port", cfg.SSH.Port, "host", cfg.SSH.Host, "user", cfg.SSH.User, "port", cfg.SSH.Port,
"auth_method", cfg.SSH.AuthMethod, "png_size", len(pngBytes)) "png_size", len(pngBytes))
// Select the uploader by auth method: Kerberos delegates to the system
// ssh/scp binaries (reusing the kinit credential cache), while builtin
// uses the in-process Go SSH client.
var uploader application.SSHUploader
if cfg.SSH.IsKerberos() {
uploader = sshpkg.NewKerberosUploader(cfg.SSH)
} else {
uploader = sshpkg.NewUploader(cfg.SSH)
}
svc := &application.CaptureAndSSHService{ svc := &application.CaptureAndSSHService{
Uploader: uploader, Uploader: sshpkg.NewUploader(cfg.SSH),
Clipboard: a.clip, Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx}, Notifier: &runtimeNotifier{ctx: a.ctx},
Cfg: cfg.SSH, Cfg: cfg.SSH,
} }
a.emitOperationStatus("save-remote", "保存中", "正在保存截图到远端 SSH", "running") return svc.ExecuteWithBytes(a.ctx, pngBytes)
if err := svc.ExecuteWithBytes(a.ctx, pngBytes); err != nil {
a.emitOperationStatus("save-remote", "保存失败", err.Error(), "error")
return err
}
a.emitOperationStatus("save-remote", "保存完成", "远端路径已复制到剪贴板", "success")
return nil
}
func (a *App) runSummaryPipeline(pngBytes []byte) error {
a.mu.RLock()
cfg := a.cfg
a.mu.RUnlock()
if !cfg.IsS3Configured() {
err := fmt.Errorf("请先在 S3 配置页填写可用的对象存储配置")
a.emitOperationStatus("summary", "需要配置 S3", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
if !cfg.IsLLMConfigured() {
err := fmt.Errorf("请先在 LLM 配置页填写 API Key 和模型")
a.emitOperationStatus("summary", "需要配置 LLM", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
s3Provider, err := oss.NewS3Provider(cfg.S3)
if err != nil {
a.emitOperationStatus("summary", "S3 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
providerID, llmCfg, _ := cfg.ActiveLLMProvider()
visionClient, err := llmpkg.NewVisionClient(providerID, llmCfg)
if err != nil {
a.emitOperationStatus("summary", "LLM 配置错误", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
svc := &application.CaptureSummaryService{
Provider: s3Provider,
Summarizer: visionClient,
Clipboard: a.clip,
Prompt: cfg.LLM.Prompt,
PathPrefix: cfg.S3.PathPrefix,
}
a.emitOperationStatus("summary", "上传中", "正在上传截图供模型读取", "running")
uploaded, err := svc.UploadImage(a.ctx, pngBytes)
if err != nil {
a.emitOperationStatus("summary", "上传失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("summary", "识别中", "图片已上传,正在请求多模态模型", "running")
summary, err := svc.Summarize(a.ctx, uploaded.URL)
if err != nil {
a.emitOperationStatus("summary", "识别失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
if err := svc.CopySummary(a.ctx, summary); err != nil {
a.emitOperationStatus("summary", "复制失败", err.Error(), "error")
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
a.emitOperationStatus("summary", "识别完成", "总结已复制到剪贴板", "success")
wruntime.EventsEmit(a.ctx, "upload:success", "summary copied to clipboard")
return nil
} }
func (a *App) consumePendingCapture() (*pendingCapture, error) { func (a *App) consumePendingCapture() (*pendingCapture, error) {
@@ -497,7 +398,6 @@ func (a *App) GetConfig() domain.AppConfig {
// SaveConfig persists the supplied configuration and re-registers the hotkey // SaveConfig persists the supplied configuration and re-registers the hotkey
// if it changed. // if it changed.
func (a *App) SaveConfig(cfg domain.AppConfig) error { func (a *App) SaveConfig(cfg domain.AppConfig) error {
cfg.Normalize()
a.mu.Lock() a.mu.Lock()
prev := a.cfg prev := a.cfg
a.cfg = cfg a.cfg = cfg
@@ -546,17 +446,7 @@ func (a *App) TestConnection(cfg domain.S3Config) error {
func (a *App) TestSSHConnection(cfg domain.SSHConfig) error { func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
slog.Info("RPC TestSSHConnection", slog.Info("RPC TestSSHConnection",
"host", cfg.Host, "user", cfg.User, "port", cfg.Port, "host", cfg.Host, "user", cfg.User, "port", cfg.Port,
"auth_method", cfg.AuthMethod,
"strict_host_key", cfg.StrictHostKey, "has_password", cfg.Password != "") "strict_host_key", cfg.StrictHostKey, "has_password", cfg.Password != "")
// Kerberos auth delegates to the system ssh binary (see KerberosUploader)
// so its probe path differs from the in-process client.
if cfg.IsKerberos() {
if err := sshpkg.TestKerberosConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection (kerberos) failed", "err", err)
return err
}
return nil
}
if err := sshpkg.TestConnection(a.ctx, cfg); err != nil { if err := sshpkg.TestConnection(a.ctx, cfg); err != nil {
slog.Error("RPC TestSSHConnection failed", "err", err) slog.Error("RPC TestSSHConnection failed", "err", err)
return err return err
@@ -795,35 +685,6 @@ func (a *App) SaveRegionToRemote(result CaptureResult) error {
return a.runSaveRemotePipeline(cropped) return a.runSaveRemotePipeline(cropped)
} }
// SummarizeRegion uploads the selected screenshot to S3, sends the public URL
// to the configured multimodal LLM, and copies the resulting summary.
func (a *App) SummarizeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SummarizeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
a.dismissOverlay()
}()
a.dismissOverlay()
flushFrame()
slog.Info("SummarizeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SummarizeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runSummaryPipeline(cropped)
}
// SaveNativeRegionToRemote is the macOS-native overlay equivalent of // SaveNativeRegionToRemote is the macOS-native overlay equivalent of
// SaveRegionToRemote. The AppKit panel is already closed by the time this // SaveRegionToRemote. The AppKit panel is already closed by the time this
// runs (see saveRemoteSelection in native_overlay_darwin.go), so we only // runs (see saveRemoteSelection in native_overlay_darwin.go), so we only
@@ -854,33 +715,6 @@ func (a *App) SaveNativeRegionToRemote(result CaptureResult) error {
return a.runSaveRemotePipeline(cropped) return a.runSaveRemotePipeline(cropped)
} }
// SummarizeNativeRegion is the macOS-native overlay equivalent of
// SummarizeRegion. The AppKit panel is already closed before this runs.
func (a *App) SummarizeNativeRegion(result CaptureResult) error {
pc, err := a.consumePendingCapture()
if err != nil {
slog.Warn("SummarizeNativeRegion: no pending capture", "err", err)
return err
}
defer func() {
a.capturing.Store(false)
hideDockIcon()
}()
flushFrame()
slog.Info("SummarizeNativeRegion: capturing region",
"x", result.Rect.X, "y", result.Rect.Y,
"w", result.Rect.W, "h", result.Rect.H,
"annotations", len(result.Annotations))
cropped, err := a.captureSelectedPNG(result, pc)
if err != nil {
slog.Error("SummarizeNativeRegion: capture failed", "err", err)
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
return err
}
return a.runSummaryPipeline(cropped)
}
func parseNativeAnnotations(raw string) []application.Annotation { func parseNativeAnnotations(raw string) []application.Annotation {
if raw == "" { if raw == "" {
return nil return nil
@@ -930,27 +764,6 @@ func (a *App) QuitApp() {
os.Exit(0) os.Exit(0)
} }
func (a *App) emitOperationStatus(operation, phase, message, state string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "operation:status", OperationStatusPayload{
Operation: operation,
Phase: phase,
Message: message,
State: state,
})
}
switch state {
case "success":
showOperationStatus(phase, message, operationStatusSuccess)
hideOperationStatusAfter(1400 * time.Millisecond)
case "error":
showOperationStatus(phase, message, operationStatusError)
hideOperationStatusAfter(2600 * time.Millisecond)
default:
showOperationStatus(phase, message, operationStatusRunning)
}
}
// runtimeNotifier emits success / failure events via the Wails runtime. // runtimeNotifier emits success / failure events via the Wails runtime.
type runtimeNotifier struct{ ctx context.Context } type runtimeNotifier struct{ ctx context.Context }
+18 -210
View File
@@ -3,7 +3,9 @@
* App shell — switches between two distinct UI modes that share the same * App shell — switches between two distinct UI modes that share the same
* Wails window: * Wails window:
* *
* • "settings" : full settings UI (native title bar + sidebar + form). * • "settings" : full settings UI (self-drawn title bar + sidebar + form).
* Self-drawn because the window is now Frameless to make
* the overlay paint edge-to-edge.
* • "overlay" : Snipaste-style region picker that fills the whole * • "overlay" : Snipaste-style region picker that fills the whole
* primary display. * primary display.
* *
@@ -11,7 +13,7 @@
* emits a `capture:overlay` event with the screenshot payload so the * emits a `capture:overlay` event with the screenshot payload so the
* frontend knows when (and what) to render. * frontend knows when (and what) to render.
*/ */
import { computed, onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import SettingsView from './views/SettingsView.vue' import SettingsView from './views/SettingsView.vue'
import Toast from './components/Toast.vue' import Toast from './components/Toast.vue'
import CaptureOverlay from './views/CaptureOverlay.vue' import CaptureOverlay from './views/CaptureOverlay.vue'
@@ -22,9 +24,7 @@ import {
CopyRegionImage, CopyRegionImage,
SaveRegionImage, SaveRegionImage,
SaveRegionToRemote, SaveRegionToRemote,
SummarizeRegion,
CancelRegion, CancelRegion,
GetConfig,
} from '../wailsjs/go/main/App' } from '../wailsjs/go/main/App'
type HotkeyStatus = type HotkeyStatus =
@@ -36,38 +36,18 @@ const hotkeyStatus = ref<HotkeyStatus>({ state: 'unknown' })
type Mode = 'settings' | 'overlay' type Mode = 'settings' | 'overlay'
const mode = ref<Mode>('settings') const mode = ref<Mode>('settings')
type ThemeMode = 'auto' | 'light' | 'dark'
const themeMode = ref<ThemeMode>('auto')
const appThemeClass = computed(() => `theme-${themeMode.value}`)
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
async function loadThemePreference() {
try {
const cfg = await GetConfig()
themeMode.value = normalizeTheme((cfg as any)?.theme)
} catch {
themeMode.value = 'auto'
}
}
// `SettingsTab` is hoisted to the App shell so the sidebar (which lives // `SettingsTab` is hoisted to the App shell so the sidebar (which lives
// here) and the inner SettingsView (which renders the matching card) can // here) and the inner SettingsView (which renders the matching card) can
// share a single source of truth without an event bus. // share a single source of truth without an event bus.
type SettingsTab = 'general' | 's3' | 'ssh' | 'llm' type SettingsTab = 'general' | 's3' | 'ssh'
const activeTab = ref<SettingsTab>('general') const activeTab = ref<SettingsTab>('general')
// Sidebar entries are declarative so adding a destination type later is // Sidebar entries are declarative so adding a destination type later is
// a one-line change. The label values match the user-facing tab names. // a one-line change. The label values match the user-facing tab names.
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [ const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
{ id: 'general', label: 'General' }, { id: 'general', label: 'General' },
{ id: 's3', label: 'S3' }, { id: 's3', label: 'S3-Conf' },
{ id: 'ssh', label: 'SSH' }, { id: 'ssh', label: 'SSH-Conf' },
{ id: 'llm', label: 'LLM' },
] ]
interface OverlayPayload { interface OverlayPayload {
@@ -82,14 +62,6 @@ const capturing = ref(false)
const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null) const toast = ref<{ kind: 'success' | 'error'; text: string } | null>(null)
let toastTimer: number | undefined let toastTimer: number | undefined
const operationStatus = ref<{
operation: string
phase: string
message: string
state: 'running' | 'success' | 'error'
} | null>(null)
let operationTimer: number | undefined
function showToast(kind: 'success' | 'error', text: string) { function showToast(kind: 'success' | 'error', text: string) {
toast.value = { kind, text } toast.value = { kind, text }
window.clearTimeout(toastTimer) window.clearTimeout(toastTimer)
@@ -98,21 +70,6 @@ function showToast(kind: 'success' | 'error', text: string) {
}, 3000) }, 3000)
} }
function updateOperationStatus(payload: {
operation: string
phase: string
message: string
state: 'running' | 'success' | 'error'
}) {
operationStatus.value = payload
window.clearTimeout(operationTimer)
if (payload.state !== 'running') {
operationTimer = window.setTimeout(() => {
operationStatus.value = null
}, payload.state === 'success' ? 1600 : 3200)
}
}
async function retryHotkey() { async function retryHotkey() {
try { try {
await RetryRegisterHotkey() await RetryRegisterHotkey()
@@ -211,28 +168,6 @@ async function onOverlaySaveRemote(rect: {
} }
} }
async function onOverlaySummarize(rect: {
rect: {
x: number
y: number
w: number
h: number
}
annotations: Array<{
tool: string
color: string
points: Array<{ x: number; y: number }>
}>
}) {
mode.value = 'settings'
overlayPayload.value = null
try {
await SummarizeRegion(rect as any)
} catch {
/* Surfaced via upload:failure */
}
}
async function onOverlayCancel() { async function onOverlayCancel() {
mode.value = 'settings' mode.value = 'settings'
overlayPayload.value = null overlayPayload.value = null
@@ -244,7 +179,6 @@ async function onOverlayCancel() {
} }
onMounted(() => { onMounted(() => {
void loadThemePreference()
EventsOn('capture:start', () => { EventsOn('capture:start', () => {
capturing.value = true capturing.value = true
}) })
@@ -260,12 +194,7 @@ onMounted(() => {
capturing.value = false capturing.value = false
}) })
EventsOn('upload:success', (url: string) => { EventsOn('upload:success', (url: string) => {
showToast( showToast('success', `Copied: ${url}`)
'success',
url === 'summary copied to clipboard'
? 'Summary copied to clipboard'
: `Copied: ${url}`
)
}) })
EventsOn('upload:failure', (reason: string) => { EventsOn('upload:failure', (reason: string) => {
showToast('error', reason) showToast('error', reason)
@@ -276,7 +205,6 @@ onMounted(() => {
EventsOn('hotkey:error', (reason: string) => { EventsOn('hotkey:error', (reason: string) => {
hotkeyStatus.value = { state: 'error', reason } hotkeyStatus.value = { state: 'error', reason }
}) })
EventsOn('operation:status', updateOperationStatus)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -288,7 +216,6 @@ onUnmounted(() => {
EventsOff('upload:failure') EventsOff('upload:failure')
EventsOff('hotkey:ready') EventsOff('hotkey:ready')
EventsOff('hotkey:error') EventsOff('hotkey:error')
EventsOff('operation:status')
}) })
</script> </script>
@@ -302,12 +229,11 @@ onUnmounted(() => {
@copy="onOverlayCopy" @copy="onOverlayCopy"
@save="onOverlaySave" @save="onOverlaySave"
@save-remote="onOverlaySaveRemote" @save-remote="onOverlaySaveRemote"
@summarize="onOverlaySummarize"
@cancel="onOverlayCancel" @cancel="onOverlayCancel"
/> />
<!-- Settings mode: standard desktop window; macOS title bar is native. --> <!-- Settings mode: standard desktop window; macOS title bar is native. -->
<div v-else class="app-root" :class="appThemeClass"> <div v-else class="app-root">
<div v-if="hotkeyStatus.state === 'error'" class="permission-banner"> <div v-if="hotkeyStatus.state === 'error'" class="permission-banner">
<div> <div>
<strong>Global hotkey is not active.</strong> <strong>Global hotkey is not active.</strong>
@@ -333,29 +259,11 @@ onUnmounted(() => {
</div> </div>
</aside> </aside>
<section class="content"> <section class="content">
<SettingsView <SettingsView :tab="activeTab" />
:tab="activeTab"
:theme="themeMode"
@theme-change="themeMode = normalizeTheme($event)"
/>
</section> </section>
</main> </main>
<Toast v-if="toast" :kind="toast.kind" :text="toast.text" /> <Toast v-if="toast" :kind="toast.kind" :text="toast.text" />
<div
v-if="operationStatus"
class="operation-hud"
:class="operationStatus.state"
>
<span v-if="operationStatus.state === 'running'" class="spinner" />
<span v-else class="status-mark">
{{ operationStatus.state === 'success' ? 'OK' : '!' }}
</span>
<div>
<strong>{{ operationStatus.phase }}</strong>
<p>{{ operationStatus.message }}</p>
</div>
</div>
</div> </div>
</template> </template>
@@ -369,15 +277,6 @@ onUnmounted(() => {
color: #111827; color: #111827;
font-size: 13px; font-size: 13px;
} }
.app-root.theme-light {
color-scheme: light;
}
.app-root.theme-dark {
color-scheme: dark;
}
.app-root.theme-auto {
color-scheme: light dark;
}
.status-pill { .status-pill {
font-size: 11px; font-size: 11px;
@@ -472,124 +371,33 @@ onUnmounted(() => {
min-width: 0; min-width: 0;
} }
.operation-hud {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 20;
display: flex;
align-items: center;
gap: 12px;
min-width: 260px;
max-width: 360px;
padding: 12px 14px;
color: #f9fafb;
background: rgba(17, 24, 39, 0.94);
border-radius: 8px;
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.28);
}
.operation-hud strong {
display: block;
margin-bottom: 2px;
font-size: 13px;
}
.operation-hud p {
margin: 0;
color: #d1d5db;
font-size: 12px;
line-height: 1.35;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.25);
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 0.75s linear infinite;
flex: 0 0 auto;
}
.status-mark {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
flex: 0 0 auto;
}
.operation-hud.success .status-mark {
color: #052e16;
background: #86efac;
}
.operation-hud.error .status-mark {
color: #450a0a;
background: #fca5a5;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.app-root.theme-dark {
background: #1c1d22;
color: #e5e7eb;
}
.app-root.theme-dark .titlebar {
background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36;
}
.app-root.theme-dark .titlebar-title {
color: #f3f4f6;
}
.app-root.theme-dark .sidebar {
background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36;
}
.app-root.theme-dark .sidebar-item {
color: #d1d5db;
}
.app-root.theme-dark .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05);
}
.app-root.theme-dark .sidebar-item.active {
background: rgba(59, 130, 246, 0.18);
color: #93c5fd;
}
.app-root.theme-dark .permission-banner {
background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.app-root.theme-auto { .app-root {
background: #1c1d22; background: #1c1d22;
color: #e5e7eb; color: #e5e7eb;
} }
.app-root.theme-auto .titlebar { .titlebar {
background: rgba(28, 29, 34, 0.7); background: rgba(28, 29, 34, 0.7);
border-bottom-color: #2c2f36; border-bottom-color: #2c2f36;
} }
.app-root.theme-auto .titlebar-title { .titlebar-title {
color: #f3f4f6; color: #f3f4f6;
} }
.app-root.theme-auto .sidebar { .sidebar {
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
border-right-color: #2c2f36; border-right-color: #2c2f36;
} }
.app-root.theme-auto .sidebar-item { .sidebar-item {
color: #d1d5db; color: #d1d5db;
} }
.app-root.theme-auto .sidebar-item:hover:not(.active) { .sidebar-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
.app-root.theme-auto .sidebar-item.active { .sidebar-item.active {
background: rgba(59, 130, 246, 0.18); background: rgba(59, 130, 246, 0.18);
color: #93c5fd; color: #93c5fd;
} }
.app-root.theme-auto .permission-banner { .permission-banner {
background: rgba(120, 53, 15, 0.3); background: rgba(120, 53, 15, 0.3);
border-color: rgba(253, 186, 116, 0.4); border-color: rgba(253, 186, 116, 0.4);
color: #fed7aa; color: #fed7aa;
+7 -49
View File
@@ -51,10 +51,6 @@ const emit = defineEmits<{
e: 'save-remote', e: 'save-remote',
payload: { rect: Rect; annotations: Annotation[] } payload: { rect: Rect; annotations: Annotation[] }
): void ): void
(
e: 'summarize',
payload: { rect: Rect; annotations: Annotation[] }
): void
(e: 'cancel'): void (e: 'cancel'): void
}>() }>()
@@ -115,24 +111,14 @@ const sizeLabel = computed(() => {
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}` return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
}) })
const MARK_TOOLBAR_W = 190
const ACTION_TOOLBAR_W = 216
const TOOLBAR_GROUP_GAP = 8
const rightToolbarPos = computed(() => { const rightToolbarPos = computed(() => {
if (!rect.value) return null if (!rect.value) return null
return placeToolbar(rect.value, ACTION_TOOLBAR_W, 40, 'right') return placeToolbar(rect.value, 180, 40, 'right')
}) })
const leftToolbarPos = computed(() => { const leftToolbarPos = computed(() => {
if (!rect.value || !rightToolbarPos.value) return null if (!rect.value) return null
// 标记组与操作组合并到右侧,紧贴在操作组左侧,中间保留间隔 return placeToolbar(rect.value, 190, 40, 'left')
const x = clamp(
rightToolbarPos.value.x - TOOLBAR_GROUP_GAP - MARK_TOOLBAR_W,
0,
props.width - MARK_TOOLBAR_W
)
return { x, y: rightToolbarPos.value.y }
}) })
const handles = computed(() => { const handles = computed(() => {
@@ -367,11 +353,7 @@ function onSaveRemote() {
emitAction('save-remote') emitAction('save-remote')
} }
function onSummarize() { function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
emitAction('summarize')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summarize') {
if (!rect.value) return if (!rect.value) return
const payload = { const payload = {
rect: { ...rect.value }, rect: { ...rect.value },
@@ -381,7 +363,6 @@ function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote' | 'summa
if (action === 'copy') emit('copy', payload) if (action === 'copy') emit('copy', payload)
if (action === 'save') emit('save', payload) if (action === 'save') emit('save', payload)
if (action === 'save-remote') emit('save-remote', payload) if (action === 'save-remote') emit('save-remote', payload)
if (action === 'summarize') emit('summarize', payload)
} }
function onCancel() { function onCancel() {
@@ -602,8 +583,8 @@ onUnmounted(() => {
/> />
<button <button
class="action-btn" class="action-btn"
data-tip="复制图" data-tip="复制图"
aria-label="复制图" aria-label="复制图"
@click="onCopy" @click="onCopy"
v-html="copyIcon" v-html="copyIcon"
/> />
@@ -621,19 +602,6 @@ onUnmounted(() => {
@click="onSaveRemote" @click="onSaveRemote"
v-html="saveRemoteIcon" v-html="saveRemoteIcon"
/> />
<button
class="action-btn"
data-tip="复制总结"
aria-label="复制总结"
@click="onSummarize"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 3h8l4 4v14H6z" />
<path d="M14 3v5h4" />
<path d="M9 12h6" />
<path d="M9 16h5" />
</svg>
</button>
<button <button
class="action-btn primary" class="action-btn primary"
data-tip="上传云端" data-tip="上传云端"
@@ -728,7 +696,7 @@ onUnmounted(() => {
width: 190px; width: 190px;
} }
.action-toolbar { .action-toolbar {
width: 216px; width: 180px;
} }
.icon-btn, .icon-btn,
@@ -784,16 +752,6 @@ onUnmounted(() => {
fill: currentColor; fill: currentColor;
pointer-events: none; pointer-events: none;
} }
.action-btn > svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.action-btn::after { .action-btn::after {
content: attr(data-tip); content: attr(data-tip);
position: absolute; position: absolute;
+22 -363
View File
@@ -8,11 +8,10 @@
* • "general" — the global hotkey / capture parameters. * • "general" — the global hotkey / capture parameters.
* • "s3" — S3-compatible object-storage credentials. * • "s3" — S3-compatible object-storage credentials.
* • "ssh" — SSH/SCP destination for the "save remote" button. * • "ssh" — SSH/SCP destination for the "save remote" button.
* • "llm" — multimodal screenshot summary provider settings.
* *
* State flow: load() pulls config from Go on mount, save() pushes back. * State flow: load() pulls config from Go on mount, save() pushes back.
*/ */
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref } from 'vue'
import { import {
GetConfig, GetConfig,
SaveConfig, SaveConfig,
@@ -23,16 +22,10 @@ import {
// Tab discriminator shared with the App shell. Kept as a string union so // Tab discriminator shared with the App shell. Kept as a string union so
// the parent can pass the value without importing a type from this view. // the parent can pass the value without importing a type from this view.
type TabId = 'general' | 's3' | 'ssh' | 'llm' type TabId = 'general' | 's3' | 'ssh'
type ThemeMode = 'auto' | 'light' | 'dark'
const props = defineProps<{ const props = defineProps<{
tab: TabId tab: TabId
theme: ThemeMode
}>()
const emit = defineEmits<{
(event: 'theme-change', theme: ThemeMode): void
}>() }>()
interface S3Config { interface S3Config {
@@ -50,7 +43,6 @@ interface SSHConfig {
host: string host: string
port: number port: number
user: string user: string
authMethod: string
password: string password: string
pathPrefix: string pathPrefix: string
strictHostKey: boolean strictHostKey: boolean
@@ -58,51 +50,12 @@ interface SSHConfig {
connectTimeoutSecs: number connectTimeoutSecs: number
} }
interface LLMProviderConfig {
label: string
baseUrl: string
apiKey: string
model: string
maxTokens: number
temperature: number
timeoutSecs: number
}
interface LLMConfig {
activeProvider: string
prompt: string
providers: Record<string, LLMProviderConfig>
}
interface AppConfig { interface AppConfig {
hotkey: string hotkey: string
theme: ThemeMode
s3: S3Config s3: S3Config
ssh: SSHConfig ssh: SSHConfig
llm: LLMConfig
} }
const llmProviderOptions = [
{ id: 'qwen', label: '阿里千问多模态' },
{ id: 'doubao', label: '火山方舟豆包多模态' },
{ id: 'openai', label: 'ChatGPT / OpenAI-compatible' },
]
const themeOptions: Array<{ id: ThemeMode; label: string }> = [
{ id: 'light', label: '浅色' },
{ id: 'dark', label: '深色' },
{ id: 'auto', label: '自动' },
]
const DEFAULT_SUMMARY_PROMPT = `你是一个截图内容总结助手。请阅读截图,并用中文输出一段适合直接粘贴到聊天、工单、Issue 或 PR 中的说明。
要求:
- 先说明截图中最重要的信息和用户可能想表达的意图。
- 如果截图包含报错、异常状态、表格、代码、界面控件或关键数字,请准确提取。
- 如果截图内容不足以判断,不要编造;直接说明不确定点。
- 输出尽量简洁,默认 3 到 6 条要点。
- 不要输出 Markdown 标题,不要寒暄。`
// `defaultConfig` keeps the initial render in sync with the Go-side // `defaultConfig` keeps the initial render in sync with the Go-side
// DefaultAppConfig so the UI never flashes empty fields before load() // DefaultAppConfig so the UI never flashes empty fields before load()
// completes. Centralising the defaults also avoids subtle drift between // completes. Centralising the defaults also avoids subtle drift between
@@ -110,7 +63,6 @@ const DEFAULT_SUMMARY_PROMPT = `你是一个截图内容总结助手。请阅读
function defaultConfig(): AppConfig { function defaultConfig(): AppConfig {
return { return {
hotkey: 'cmd+shift+a', hotkey: 'cmd+shift+a',
theme: 'auto',
s3: { s3: {
endpoint: '', endpoint: '',
region: 'us-east-1', region: 'us-east-1',
@@ -125,46 +77,12 @@ function defaultConfig(): AppConfig {
host: '', host: '',
port: 22, port: 22,
user: '', user: '',
authMethod: 'password',
password: '', password: '',
pathPrefix: 'snapgo/', pathPrefix: 'snapgo/',
strictHostKey: false, strictHostKey: false,
knownHostsPath: '', knownHostsPath: '',
connectTimeoutSecs: 10, connectTimeoutSecs: 10,
}, },
llm: {
activeProvider: 'openai',
prompt: DEFAULT_SUMMARY_PROMPT,
providers: {
qwen: {
label: '阿里千问多模态',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: '',
model: 'qwen-vl-plus',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
doubao: {
label: '火山方舟豆包多模态',
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey: '',
model: '',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
openai: {
label: 'ChatGPT / OpenAI-compatible',
baseUrl: 'https://api.openai.com/v1',
apiKey: '',
model: 'gpt-5.5',
maxTokens: 600,
temperature: 0.2,
timeoutSecs: 60,
},
},
},
} }
} }
@@ -189,21 +107,6 @@ const sshPathDisplay = computed({
}, },
}) })
const activeLLMProvider = computed(() => {
const id = config.value.llm.activeProvider || 'openai'
if (!config.value.llm.providers[id]) {
config.value.llm.activeProvider = 'openai'
return config.value.llm.providers.openai
}
return config.value.llm.providers[id]
})
function normalizeTheme(theme: unknown): ThemeMode {
return theme === 'light' || theme === 'dark' || theme === 'auto'
? theme
: 'auto'
}
function flash(kind: 'ok' | 'err', text: string) { function flash(kind: 'ok' | 'err', text: string) {
message.value = { kind, text } message.value = { kind, text }
window.setTimeout(() => { window.setTimeout(() => {
@@ -220,14 +123,7 @@ async function load() {
Object.assign(merged, cfg) Object.assign(merged, cfg)
merged.s3 = { ...merged.s3, ...(cfg as any).s3 } merged.s3 = { ...merged.s3, ...(cfg as any).s3 }
merged.ssh = { ...merged.ssh, ...(cfg as any).ssh } merged.ssh = { ...merged.ssh, ...(cfg as any).ssh }
merged.llm = { ...merged.llm, ...(cfg as any).llm }
merged.llm.providers = {
...defaultConfig().llm.providers,
...((cfg as any).llm?.providers ?? {}),
}
merged.theme = normalizeTheme((cfg as any).theme)
config.value = merged config.value = merged
emit('theme-change', config.value.theme)
} }
} }
@@ -271,16 +167,11 @@ async function manualCapture() {
await CaptureNow() await CaptureNow()
} }
watch(
() => config.value.theme,
(theme) => emit('theme-change', normalizeTheme(theme))
)
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="settings" :class="`theme-${props.theme}`"> <div class="settings">
<header class="hero"> <header class="hero">
<div> <div>
<h1>SnapGo</h1> <h1>SnapGo</h1>
@@ -292,10 +183,9 @@ onMounted(load)
<button class="btn primary" @click="manualCapture">Capture now</button> <button class="btn primary" @click="manualCapture">Capture now</button>
</header> </header>
<!-- General tab shortcut and appearance configuration. --> <!-- General tab only the shortcut configuration lives here. -->
<section v-if="props.tab === 'general'" class="card"> <section v-if="props.tab === 'general'" class="card">
<h2>General</h2> <h2>Shortcut</h2>
<div class="grid">
<label class="field"> <label class="field">
<span>Global hotkey</span> <span>Global hotkey</span>
<input <input
@@ -304,19 +194,6 @@ onMounted(load)
spellcheck="false" spellcheck="false"
/> />
</label> </label>
<label class="field">
<span>主题选择</span>
<select v-model="config.theme">
<option
v-for="themeOption in themeOptions"
:key="themeOption.id"
:value="themeOption.id"
>
{{ themeOption.label }}
</option>
</select>
</label>
</div>
<p class="hint"> <p class="hint">
Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift + Tokens (case-insensitive, "+"-separated): cmd / ctrl / option / shift +
az or 09. Example: <code>cmd+shift+a</code>. az or 09. Example: <code>cmd+shift+a</code>.
@@ -403,27 +280,14 @@ onMounted(load)
placeholder="22" placeholder="22"
/> />
</label> </label>
<div class="field-row auth-row">
<label class="field">
<span>Auth method</span>
<select v-model="config.ssh.authMethod">
<option value="password">Password</option>
<option value="key">SSH-Key</option>
<option value="kerberos">Kerberos</option>
</select>
</label>
<label class="field"> <label class="field">
<span>User *</span> <span>User *</span>
<input v-model="config.ssh.user" placeholder="ubuntu" /> <input v-model="config.ssh.user" placeholder="ubuntu" />
</label> </label>
<label <label class="field">
v-if="config.ssh.authMethod === 'password'" <span>Password (optional)</span>
class="field"
>
<span>Password</span>
<input v-model="config.ssh.password" type="password" /> <input v-model="config.ssh.password" type="password" />
</label> </label>
</div>
<label class="field full"> <label class="field full">
<span>Remote path (under home)</span> <span>Remote path (under home)</span>
<div class="prefixed-input"> <div class="prefixed-input">
@@ -460,23 +324,9 @@ onMounted(load)
</span> </span>
</label> </label>
</div> </div>
<p v-if="config.ssh.authMethod === 'kerberos'" class="hint"> <p v-if="!config.ssh.password" class="hint warn">
Kerberos mode delegates to the system <code>ssh</code>/<code>scp</code>. Password is empty please make sure password-less SSH is configured on
Run <code>kinit your.name@BYTEDANCE.COM</code> in a terminal first; tickets this machine (e.g. via <code>ssh-copy-id</code> or your <code>ssh-agent</code>).
expire (typically every 1024h), so re-run <code>kinit</code> if uploads
start failing. Verify with <code>klist</code>.
</p>
<p v-else-if="config.ssh.authMethod === 'key'" class="hint">
SSH-Key mode uses your <code>ssh-agent</code> and <code>~/.ssh/id_*</code>
keys. Make sure password-less login already works (e.g. via
<code>ssh-copy-id</code>).
</p>
<p
v-else-if="config.ssh.authMethod === 'password' && !config.ssh.password"
class="hint warn"
>
Password is empty enter the remote login password, or switch the auth
method to SSH-Key.
</p> </p>
<div class="actions"> <div class="actions">
@@ -489,103 +339,6 @@ onMounted(load)
</div> </div>
</section> </section>
<!-- LLM tab multimodal screenshot summary provider settings. -->
<section v-if="props.tab === 'llm'" class="card">
<h2>LLM screenshot summary</h2>
<div class="grid">
<label class="field full">
<span>Provider</span>
<select v-model="config.llm.activeProvider">
<option
v-for="provider in llmProviderOptions"
:key="provider.id"
:value="provider.id"
>
{{ provider.label }}
</option>
</select>
</label>
<label class="field full">
<span>Base URL *</span>
<input
v-model="activeLLMProvider.baseUrl"
placeholder="https://api.openai.com/v1"
spellcheck="false"
/>
</label>
<label class="field">
<span>API Key *</span>
<input v-model="activeLLMProvider.apiKey" type="password" />
</label>
<label class="field">
<span>Model *</span>
<input
v-model="activeLLMProvider.model"
placeholder="gpt-5.5 / qwen-vl-plus / Ark endpoint ID"
spellcheck="false"
/>
</label>
<label class="field">
<span>Max tokens</span>
<input
v-model.number="activeLLMProvider.maxTokens"
type="number"
min="64"
max="4096"
placeholder="600"
/>
</label>
<label class="field">
<span>Temperature</span>
<input
v-model.number="activeLLMProvider.temperature"
type="number"
min="0"
max="2"
step="0.1"
placeholder="0.2"
/>
</label>
<label class="field">
<span>Timeout (sec)</span>
<input
v-model.number="activeLLMProvider.timeoutSecs"
type="number"
min="10"
max="300"
placeholder="60"
/>
</label>
<label class="field full">
<span>Prompt</span>
<textarea
v-model="config.llm.prompt"
class="prompt-textarea"
spellcheck="false"
/>
</label>
</div>
<p class="hint">
The "copy summary" screenshot action first uploads the image through the
S3 configuration, then sends the public image URL to this multimodal
provider. Keep S3 configured before using it.
</p>
<p v-if="config.llm.activeProvider === 'doubao'" class="hint">
Volcengine Ark usually uses the inference endpoint ID as the model
value; paste the endpoint/model identifier from your Ark console.
</p>
<p v-else-if="config.llm.activeProvider === 'qwen'" class="hint">
DashScope OpenAI-compatible mode is used here. If your workspace uses a
regional endpoint, replace Base URL with that compatible-mode URL.
</p>
<div class="actions">
<button class="btn primary" :disabled="saving" @click="save">
{{ saving ? 'Saving' : 'Save' }}
</button>
</div>
</section>
<transition name="fade"> <transition name="fade">
<div <div
v-if="message" v-if="message"
@@ -605,15 +358,6 @@ onMounted(load)
padding: 28px 32px 60px; padding: 28px 32px 60px;
color: #1f2937; color: #1f2937;
} }
.settings.theme-light {
color-scheme: light;
}
.settings.theme-dark {
color-scheme: dark;
}
.settings.theme-auto {
color-scheme: light dark;
}
.hero { .hero {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -670,30 +414,17 @@ kbd {
.field.full { .field.full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* field-row groups several fields onto a single full-width row. auth-row
splits Auth method / User / Password roughly 1:2:2. */
.field-row {
grid-column: 1 / -1;
display: grid;
gap: 12px 16px;
}
.auth-row {
grid-template-columns: 1fr 2fr 2fr;
}
.field span { .field span {
color: #374151; color: #374151;
font-weight: 500; font-weight: 500;
} }
.field input[type='text'], .field input[type='text'],
.field input[type='number'], .field input[type='number'],
.field input:not([type='checkbox']), .field input:not([type='checkbox']) {
.field select,
.field textarea {
border: 1px solid #d1d5db; border: 1px solid #d1d5db;
border-radius: 6px; border-radius: 6px;
padding: 7px 10px; padding: 7px 10px;
font-size: 13px; font-size: 13px;
line-height: 1.4;
background: #fff; background: #fff;
color: inherit; color: inherit;
outline: none; outline: none;
@@ -701,20 +432,7 @@ kbd {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.field input[type='text'], .field input:focus {
.field input[type='number'],
.field input:not([type='checkbox']),
.field select {
height: 36px;
}
.field textarea {
min-height: 168px;
resize: vertical;
font-family: inherit;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: #3b82f6; border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
} }
@@ -829,100 +547,41 @@ kbd {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
.settings.theme-dark {
color: #e5e7eb;
}
.settings.theme-dark .card {
background: #1f2937;
border-color: #374151;
}
.settings.theme-dark .field span {
color: #d1d5db;
}
.settings.theme-dark .field input[type='text'],
.settings.theme-dark .field input[type='number'],
.settings.theme-dark .field input:not([type='checkbox']),
.settings.theme-dark .field select,
.settings.theme-dark .field textarea {
background: #111827;
border-color: #374151;
color: #fff;
}
.settings.theme-dark .field input::placeholder,
.settings.theme-dark .field textarea::placeholder {
color: #9ca3af;
}
.settings.theme-dark .field input:disabled {
background: #1f2937;
color: #6b7280;
}
.settings.theme-dark .prefixed-input {
background: #111827;
border-color: #374151;
}
.settings.theme-dark .input-prefix {
background: #1f2937;
border-right-color: #374151;
color: #d1d5db;
}
.settings.theme-dark .btn:not(.primary) {
background: #1f2937;
border-color: #374151;
color: #e5e7eb;
}
.settings.theme-dark .btn:not(.primary):hover:not(:disabled) {
background: #374151;
}
.settings.theme-dark .subtitle {
color: #9ca3af;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.settings.theme-auto { .settings {
color: #e5e7eb; color: #e5e7eb;
} }
.settings.theme-auto .card { .card {
background: #1f2937; background: #1f2937;
border-color: #374151; border-color: #374151;
} }
.settings.theme-auto .field span { .field input {
color: #d1d5db;
}
.settings.theme-auto .field input[type='text'],
.settings.theme-auto .field input[type='number'],
.settings.theme-auto .field input:not([type='checkbox']),
.settings.theme-auto .field select,
.settings.theme-auto .field textarea {
background: #111827; background: #111827;
border-color: #374151; border-color: #374151;
color: #fff; color: #e5e7eb;
} }
.settings.theme-auto .field input::placeholder, .field input:disabled {
.settings.theme-auto .field textarea::placeholder {
color: #9ca3af;
}
.settings.theme-auto .field input:disabled {
background: #1f2937; background: #1f2937;
color: #6b7280; color: #6b7280;
} }
.settings.theme-auto .prefixed-input { .prefixed-input {
background: #111827; background: #111827;
border-color: #374151; border-color: #374151;
} }
.settings.theme-auto .input-prefix { .input-prefix {
background: #1f2937; background: #1f2937;
border-right-color: #374151; border-right-color: #374151;
color: #d1d5db; color: #d1d5db;
} }
.settings.theme-auto .btn:not(.primary) { .btn {
background: #1f2937; background: #1f2937;
border-color: #374151; border-color: #374151;
color: #e5e7eb; color: #e5e7eb;
} }
.settings.theme-auto .btn:not(.primary):hover:not(:disabled) { .btn:hover:not(:disabled) {
background: #374151; background: #374151;
} }
.settings.theme-auto .subtitle { .subtitle {
color: #9ca3af; color: #9ca3af;
} }
} }
-4
View File
@@ -37,10 +37,6 @@ export function SaveRegionToRemote(arg1:main.CaptureResult):Promise<void>;
export function ShowWindow():Promise<void>; export function ShowWindow():Promise<void>;
export function SummarizeNativeRegion(arg1:main.CaptureResult):Promise<void>;
export function SummarizeRegion(arg1:main.CaptureResult):Promise<void>;
export function TestConnection(arg1:domain.S3Config):Promise<void>; export function TestConnection(arg1:domain.S3Config):Promise<void>;
export function TestSSHConnection(arg1:domain.SSHConfig):Promise<void>; export function TestSSHConnection(arg1:domain.SSHConfig):Promise<void>;
-8
View File
@@ -70,14 +70,6 @@ export function ShowWindow() {
return window['go']['main']['App']['ShowWindow'](); return window['go']['main']['App']['ShowWindow']();
} }
export function SummarizeNativeRegion(arg1) {
return window['go']['main']['App']['SummarizeNativeRegion'](arg1);
}
export function SummarizeRegion(arg1) {
return window['go']['main']['App']['SummarizeRegion'](arg1);
}
export function TestConnection(arg1) { export function TestConnection(arg1) {
return window['go']['main']['App']['TestConnection'](arg1); return window['go']['main']['App']['TestConnection'](arg1);
} }
-66
View File
@@ -53,69 +53,10 @@ export namespace application {
export namespace domain { export namespace domain {
export class LLMProviderConfig {
label: string;
baseUrl: string;
apiKey: string;
model: string;
maxTokens: number;
temperature: number;
timeoutSecs: number;
static createFrom(source: any = {}) {
return new LLMProviderConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.label = source["label"];
this.baseUrl = source["baseUrl"];
this.apiKey = source["apiKey"];
this.model = source["model"];
this.maxTokens = source["maxTokens"];
this.temperature = source["temperature"];
this.timeoutSecs = source["timeoutSecs"];
}
}
export class LLMConfig {
activeProvider: string;
prompt: string;
providers: Record<string, LLMProviderConfig>;
static createFrom(source: any = {}) {
return new LLMConfig(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.activeProvider = source["activeProvider"];
this.prompt = source["prompt"];
this.providers = this.convertValues(source["providers"], LLMProviderConfig, true);
}
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 class SSHConfig { export class SSHConfig {
host: string; host: string;
port: number; port: number;
user: string; user: string;
authMethod: string;
password: string; password: string;
pathPrefix: string; pathPrefix: string;
strictHostKey: boolean; strictHostKey: boolean;
@@ -131,7 +72,6 @@ export namespace domain {
this.host = source["host"]; this.host = source["host"];
this.port = source["port"]; this.port = source["port"];
this.user = source["user"]; this.user = source["user"];
this.authMethod = source["authMethod"];
this.password = source["password"]; this.password = source["password"];
this.pathPrefix = source["pathPrefix"]; this.pathPrefix = source["pathPrefix"];
this.strictHostKey = source["strictHostKey"]; this.strictHostKey = source["strictHostKey"];
@@ -167,10 +107,8 @@ export namespace domain {
} }
export class AppConfig { export class AppConfig {
hotkey: string; hotkey: string;
theme: string;
s3: S3Config; s3: S3Config;
ssh: SSHConfig; ssh: SSHConfig;
llm: LLMConfig;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new AppConfig(source); return new AppConfig(source);
@@ -179,10 +117,8 @@ export namespace domain {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.hotkey = source["hotkey"]; this.hotkey = source["hotkey"];
this.theme = source["theme"];
this.s3 = this.convertValues(source["s3"], S3Config); this.s3 = this.convertValues(source["s3"], S3Config);
this.ssh = this.convertValues(source["ssh"], SSHConfig); this.ssh = this.convertValues(source["ssh"], SSHConfig);
this.llm = this.convertValues(source["llm"], LLMConfig);
} }
convertValues(a: any, classs: any, asMap: boolean = false): any { convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -205,8 +141,6 @@ export namespace domain {
} }
} }
export namespace main { export namespace main {
-72
View File
@@ -1,72 +0,0 @@
package application
import (
"context"
"fmt"
"time"
"github.com/mmmy/snapgo/internal/domain"
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
)
// VisionSummarizer describes a multimodal model capable of reading a public
// image URL and returning a textual summary.
type VisionSummarizer interface {
SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error)
}
// CaptureSummaryService wires screenshot bytes -> S3 public URL -> LLM
// summary -> clipboard. It intentionally does not notify UI state itself:
// the caller emits fine-grained "uploading / recognizing / done" progress.
type CaptureSummaryService struct {
Provider domain.OSSProvider
Summarizer VisionSummarizer
Clipboard clipboard.Writer
Prompt string
PathPrefix string
}
// UploadImage uploads the screenshot and returns the public URL visible to
// the multimodal provider.
func (s *CaptureSummaryService) UploadImage(ctx context.Context, pngBytes []byte) (*domain.UploadResult, error) {
if s.Provider == nil {
return nil, fmt.Errorf("s3 is not configured")
}
if len(pngBytes) == 0 {
return nil, fmt.Errorf("empty screenshot")
}
key := buildObjectKey(s.PathPrefix)
start := time.Now()
url, err := s.Provider.Upload(ctx, key, pngBytes, "image/png")
if err != nil {
return nil, fmt.Errorf("s3 put object: %w", err)
}
return &domain.UploadResult{
URL: url,
Key: key,
Provider: s.Provider.Name(),
Elapsed: time.Since(start),
}, nil
}
// Summarize asks the configured model to summarize the uploaded image URL.
func (s *CaptureSummaryService) Summarize(ctx context.Context, imageURL string) (string, error) {
if s.Summarizer == nil {
return "", fmt.Errorf("llm is not configured")
}
return s.Summarizer.SummarizeImage(ctx, s.Prompt, imageURL)
}
// CopySummary writes the final summary text to the clipboard.
func (s *CaptureSummaryService) CopySummary(_ context.Context, summary string) error {
if summary == "" {
return fmt.Errorf("empty summary")
}
if s.Clipboard == nil {
return fmt.Errorf("clipboard is not configured")
}
if err := s.Clipboard.WriteText(summary); err != nil {
return fmt.Errorf("clipboard write failed: %w", err)
}
return nil
}
@@ -1,86 +0,0 @@
package application
import (
"context"
"testing"
)
type fakeOSSProvider struct {
key string
data []byte
contentType string
url string
}
func (f *fakeOSSProvider) Upload(_ context.Context, key string, data []byte, contentType string) (string, error) {
f.key = key
f.data = append([]byte(nil), data...)
f.contentType = contentType
if f.url == "" {
return "https://cdn.example.com/" + key, nil
}
return f.url, nil
}
func (f *fakeOSSProvider) Name() string { return "fake" }
type fakeSummarizer struct {
prompt string
url string
text string
}
func (f *fakeSummarizer) SummarizeImage(_ context.Context, prompt string, imageURL string) (string, error) {
f.prompt = prompt
f.url = imageURL
if f.text == "" {
return "summary text", nil
}
return f.text, nil
}
func TestCaptureSummaryServiceUploadsSummarizesAndCopies(t *testing.T) {
ctx := context.Background()
provider := &fakeOSSProvider{}
summarizer := &fakeSummarizer{}
clip := &fakeClipboard{}
svc := &CaptureSummaryService{
Provider: provider,
Summarizer: summarizer,
Clipboard: clip,
Prompt: "describe screenshot",
PathPrefix: "snapgo/",
}
uploaded, err := svc.UploadImage(ctx, []byte("png"))
if err != nil {
t.Fatalf("upload image: %v", err)
}
if provider.contentType != "image/png" {
t.Fatalf("expected image/png content type, got %q", provider.contentType)
}
if string(provider.data) != "png" {
t.Fatalf("expected uploaded png bytes, got %q", string(provider.data))
}
summary, err := svc.Summarize(ctx, uploaded.URL)
if err != nil {
t.Fatalf("summarize: %v", err)
}
if summarizer.prompt != "describe screenshot" {
t.Fatalf("expected prompt forwarded, got %q", summarizer.prompt)
}
if summarizer.url != uploaded.URL {
t.Fatalf("expected uploaded url forwarded, got %q", summarizer.url)
}
if summary != "summary text" {
t.Fatalf("expected summary text, got %q", summary)
}
if err := svc.CopySummary(ctx, summary); err != nil {
t.Fatalf("copy summary: %v", err)
}
if clip.text != "summary text" {
t.Fatalf("expected summary copied, got %q", clip.text)
}
}
+1 -205
View File
@@ -33,22 +33,9 @@ type S3Config struct {
// Field design notes: // Field design notes:
// - Host / Port form the destination endpoint. Port defaults to 22 when 0. // - Host / Port form the destination endpoint. Port defaults to 22 when 0.
// - User is the SSH login name. // - User is the SSH login name.
// - AuthMethod selects how the connection authenticates:
// "password" → password only, via the in-process Go SSH client.
// "key" → ssh-agent + ~/.ssh/id_* key files (no password),
// via the in-process Go SSH client.
// "kerberos" → delegate to the system /usr/bin/ssh binary so the
// existing Kerberos (GSSAPI) credential cache from
// `kinit` is reused. Native GSSAPI is required here
// because macOS stores tickets in an API: ccache
// that pure-Go SSH libraries cannot read.
// "" / "builtin" → legacy combined behaviour (password → agent → key
// files) kept only for backward compatibility with
// configs written before the method was split.
// - Password is optional; when empty the implementation falls back to the // - Password is optional; when empty the implementation falls back to the
// local SSH agent or ~/.ssh/id_* keys (i.e. the user is responsible for // local SSH agent or ~/.ssh/id_* keys (i.e. the user is responsible for
// having password-less access already configured on this machine). // having password-less access already configured on this machine).
// It is ignored entirely when AuthMethod is "kerberos".
// - PathPrefix is interpreted *relative to the remote user's $HOME*. The // - PathPrefix is interpreted *relative to the remote user's $HOME*. The
// UI presents it as "~/<PathPrefix>" so the user cannot escape the home // UI presents it as "~/<PathPrefix>" so the user cannot escape the home
// directory by writing absolute paths. Leading "/" or "~" markers are // directory by writing absolute paths. Leading "/" or "~" markers are
@@ -64,7 +51,6 @@ type SSHConfig struct {
Host string `json:"host"` Host string `json:"host"`
Port int `json:"port"` Port int `json:"port"`
User string `json:"user"` User string `json:"user"`
AuthMethod string `json:"authMethod"`
Password string `json:"password"` Password string `json:"password"`
PathPrefix string `json:"pathPrefix"` PathPrefix string `json:"pathPrefix"`
StrictHostKey bool `json:"strictHostKey"` StrictHostKey bool `json:"strictHostKey"`
@@ -72,72 +58,6 @@ type SSHConfig struct {
ConnectTimeoutSecs int `json:"connectTimeoutSecs"` ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
} }
// LLMProviderConfig stores one OpenAI-compatible multimodal chat endpoint.
//
// The three built-in providers (Qwen, Doubao/Ark, OpenAI-compatible) all use
// the same request shape: chat/completions with a user content array that
// contains text plus an image_url item. Keeping them as provider records lets
// users switch between credentials/models without retyping every field.
type LLMProviderConfig struct {
Label string `json:"label"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
MaxTokens int `json:"maxTokens"`
Temperature float64 `json:"temperature"`
TimeoutSecs int `json:"timeoutSecs"`
}
// LLMConfig controls screenshot summarisation.
type LLMConfig struct {
ActiveProvider string `json:"activeProvider"`
Prompt string `json:"prompt"`
Providers map[string]LLMProviderConfig `json:"providers"`
}
// SSH authentication method identifiers stored in SSHConfig.AuthMethod.
const (
// SSHAuthBuiltin is the legacy combined method (password → agent → key
// files). Retained for backward compatibility with older config files;
// new configs use the explicit methods below.
SSHAuthBuiltin = "builtin"
// SSHAuthPassword authenticates with the password field only.
SSHAuthPassword = "password"
// SSHAuthKey authenticates with ssh-agent / ~/.ssh/id_* key files only.
SSHAuthKey = "key"
// SSHAuthKerberos delegates to the system ssh binary so an existing
// Kerberos credential cache (populated by `kinit`) is reused via GSSAPI.
SSHAuthKerberos = "kerberos"
)
// Built-in LLM provider identifiers.
const (
LLMProviderQwen = "qwen"
LLMProviderDoubao = "doubao"
LLMProviderOpenAI = "openai"
)
// Theme preference identifiers stored in AppConfig.Theme.
const (
ThemeAuto = "auto"
ThemeLight = "light"
ThemeDark = "dark"
)
const DefaultSummaryPrompt = `你是一个截图内容总结助手请阅读截图并用中文输出一段适合直接粘贴到聊天工单Issue PR 中的说明
要求
- 先说明截图中最重要的信息和用户可能想表达的意图
- 如果截图包含报错异常状态表格代码界面控件或关键数字请准确提取
- 如果截图内容不足以判断不要编造直接说明不确定点
- 输出尽量简洁默认 3 6 条要点
- 不要输出 Markdown 标题不要寒暄`
// IsKerberos reports whether this config requests Kerberos/GSSAPI auth.
func (c SSHConfig) IsKerberos() bool {
return c.AuthMethod == SSHAuthKerberos
}
// AppConfig is the top-level on-disk configuration document. // AppConfig is the top-level on-disk configuration document.
// //
// We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS, // We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS,
@@ -148,25 +68,18 @@ type AppConfig struct {
// in the infrastructure hotkey adapter. // in the infrastructure hotkey adapter.
Hotkey string `json:"hotkey"` Hotkey string `json:"hotkey"`
// Theme controls the settings UI appearance: "auto", "light", or "dark".
Theme string `json:"theme"`
// S3 holds the active S3-compatible storage configuration. // S3 holds the active S3-compatible storage configuration.
S3 S3Config `json:"s3"` S3 S3Config `json:"s3"`
// SSH holds the configuration for the optional "save to remote via scp" // SSH holds the configuration for the optional "save to remote via scp"
// destination triggered by the save-remote toolbar button. // destination triggered by the save-remote toolbar button.
SSH SSHConfig `json:"ssh"` SSH SSHConfig `json:"ssh"`
// LLM holds provider settings for the "copy summary" screenshot action.
LLM LLMConfig `json:"llm"`
} }
// DefaultAppConfig returns sane zero-value defaults used on first launch. // DefaultAppConfig returns sane zero-value defaults used on first launch.
func DefaultAppConfig() AppConfig { func DefaultAppConfig() AppConfig {
cfg := AppConfig{ return AppConfig{
Hotkey: "cmd+shift+a", Hotkey: "cmd+shift+a",
Theme: ThemeAuto,
S3: S3Config{ S3: S3Config{
PathPrefix: "snapgo/", PathPrefix: "snapgo/",
UsePathStyle: true, UsePathStyle: true,
@@ -177,105 +90,6 @@ func DefaultAppConfig() AppConfig {
ConnectTimeoutSecs: 10, ConnectTimeoutSecs: 10,
StrictHostKey: false, StrictHostKey: false,
}, },
LLM: DefaultLLMConfig(),
}
cfg.Normalize()
return cfg
}
// DefaultLLMConfig returns the built-in provider presets. API keys and exact
// models remain user-editable because each provider account may expose
// different model names / endpoint IDs.
func DefaultLLMConfig() LLMConfig {
return LLMConfig{
ActiveProvider: LLMProviderOpenAI,
Prompt: DefaultSummaryPrompt,
Providers: map[string]LLMProviderConfig{
LLMProviderQwen: {
Label: "阿里千问多模态",
BaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
Model: "qwen-vl-plus",
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
},
LLMProviderDoubao: {
Label: "火山方舟豆包多模态",
BaseURL: "https://ark.cn-beijing.volces.com/api/v3",
Model: "",
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
},
LLMProviderOpenAI: {
Label: "ChatGPT / OpenAI-compatible",
BaseURL: "https://api.openai.com/v1",
Model: "gpt-5.5",
MaxTokens: 600,
Temperature: 0.2,
TimeoutSecs: 60,
},
},
}
}
// Normalize fills defaults into configs written by older app versions while
// preserving any user-provided provider fields.
func (c *AppConfig) Normalize() {
if c.Hotkey == "" {
c.Hotkey = "cmd+shift+a"
}
if c.Theme != ThemeLight && c.Theme != ThemeDark && c.Theme != ThemeAuto {
c.Theme = ThemeAuto
}
if c.S3.PathPrefix == "" {
c.S3.PathPrefix = "snapgo/"
}
if c.SSH.Port == 0 {
c.SSH.Port = 22
}
if c.SSH.PathPrefix == "" {
c.SSH.PathPrefix = "snapgo/"
}
if c.SSH.ConnectTimeoutSecs == 0 {
c.SSH.ConnectTimeoutSecs = 10
}
defaultLLM := DefaultLLMConfig()
if c.LLM.ActiveProvider == "" {
c.LLM.ActiveProvider = defaultLLM.ActiveProvider
}
if c.LLM.Prompt == "" {
c.LLM.Prompt = defaultLLM.Prompt
}
if c.LLM.Providers == nil {
c.LLM.Providers = map[string]LLMProviderConfig{}
}
for id, def := range defaultLLM.Providers {
current, ok := c.LLM.Providers[id]
if !ok {
c.LLM.Providers[id] = def
continue
}
if current.Label == "" {
current.Label = def.Label
}
if current.BaseURL == "" {
current.BaseURL = def.BaseURL
}
if current.Model == "" && id != LLMProviderDoubao {
current.Model = def.Model
}
if current.MaxTokens == 0 {
current.MaxTokens = def.MaxTokens
}
if current.Temperature < 0 {
current.Temperature = def.Temperature
}
if current.TimeoutSecs == 0 {
current.TimeoutSecs = def.TimeoutSecs
}
c.LLM.Providers[id] = current
} }
} }
@@ -293,21 +107,3 @@ func (c AppConfig) IsS3Configured() bool {
func (c AppConfig) IsSSHConfigured() bool { func (c AppConfig) IsSSHConfigured() bool {
return c.SSH.Host != "" && c.SSH.User != "" return c.SSH.Host != "" && c.SSH.User != ""
} }
// ActiveLLMProvider returns the selected provider config plus a boolean
// indicating whether the selection exists.
func (c AppConfig) ActiveLLMProvider() (string, LLMProviderConfig, bool) {
id := c.LLM.ActiveProvider
if id == "" {
id = LLMProviderOpenAI
}
provider, ok := c.LLM.Providers[id]
return id, provider, ok
}
// IsLLMConfigured reports whether the selected multimodal provider has the
// fields required to make a request.
func (c AppConfig) IsLLMConfigured() bool {
_, provider, ok := c.ActiveLLMProvider()
return ok && provider.BaseURL != "" && provider.APIKey != "" && provider.Model != ""
}
@@ -65,7 +65,6 @@ func (s *FileStore) Load() (domain.AppConfig, error) {
if err := json.Unmarshal(data, &cfg); err != nil { if err := json.Unmarshal(data, &cfg); err != nil {
return domain.DefaultAppConfig(), fmt.Errorf("parse config: %w", err) return domain.DefaultAppConfig(), fmt.Errorf("parse config: %w", err)
} }
cfg.Normalize()
return cfg, nil return cfg, nil
} }
@@ -75,7 +74,6 @@ func (s *FileStore) Save(cfg domain.AppConfig) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
cfg.Normalize()
data, err := json.MarshalIndent(cfg, "", " ") data, err := json.MarshalIndent(cfg, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("marshal config: %w", err) return fmt.Errorf("marshal config: %w", err)
@@ -1,174 +0,0 @@
// Package llm contains multimodal LLM adapters.
//
// The built-in providers are intentionally implemented through the
// OpenAI-compatible chat/completions shape. Qwen DashScope, Volcengine Ark,
// and OpenAI-compatible gateways all accept the same "text + image_url"
// message content pattern, so one small HTTP adapter is enough here.
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// VisionClient calls one configured multimodal chat endpoint.
type VisionClient struct {
providerID string
cfg domain.LLMProviderConfig
httpClient *http.Client
}
// NewVisionClient validates cfg and returns a reusable client.
func NewVisionClient(providerID string, cfg domain.LLMProviderConfig) (*VisionClient, error) {
if strings.TrimSpace(cfg.BaseURL) == "" {
return nil, fmt.Errorf("llm base url is required")
}
if strings.TrimSpace(cfg.APIKey) == "" {
return nil, fmt.Errorf("llm api key is required")
}
if strings.TrimSpace(cfg.Model) == "" {
return nil, fmt.Errorf("llm model is required")
}
timeout := cfg.TimeoutSecs
if timeout <= 0 {
timeout = 60
}
return &VisionClient{
providerID: providerID,
cfg: cfg,
httpClient: &http.Client{Timeout: time.Duration(timeout) * time.Second},
}, nil
}
// SummarizeImage asks the model to summarise an image available at imageURL.
func (c *VisionClient) SummarizeImage(ctx context.Context, prompt string, imageURL string) (string, error) {
prompt = strings.TrimSpace(prompt)
if prompt == "" {
return "", fmt.Errorf("llm prompt is empty")
}
imageURL = strings.TrimSpace(imageURL)
if imageURL == "" {
return "", fmt.Errorf("image url is empty")
}
body := map[string]any{
"model": c.cfg.Model,
"messages": []map[string]any{
{
"role": "user",
"content": []map[string]any{
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": map[string]any{"url": imageURL}},
},
},
},
}
if c.cfg.MaxTokens > 0 {
if c.providerID == domain.LLMProviderOpenAI {
body["max_completion_tokens"] = c.cfg.MaxTokens
} else {
body["max_tokens"] = c.cfg.MaxTokens
}
}
if c.cfg.Temperature >= 0 {
body["temperature"] = c.cfg.Temperature
}
payload, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("marshal llm request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatCompletionsURL(c.cfg.BaseURL), bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("build llm request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("llm request: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("llm status %d: %s", resp.StatusCode, compactBody(data))
}
var decoded chatCompletionResponse
if err := json.Unmarshal(data, &decoded); err != nil {
return "", fmt.Errorf("decode llm response: %w", err)
}
if decoded.Error != nil && decoded.Error.Message != "" {
return "", fmt.Errorf("llm error: %s", decoded.Error.Message)
}
if len(decoded.Choices) == 0 {
return "", fmt.Errorf("llm response has no choices")
}
text := strings.TrimSpace(contentText(decoded.Choices[0].Message.Content))
if text == "" {
return "", fmt.Errorf("llm response is empty")
}
return text, nil
}
func chatCompletionsURL(baseURL string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(base, "/chat/completions") {
return base
}
return base + "/chat/completions"
}
type chatCompletionResponse struct {
Choices []struct {
Message struct {
Content any `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
Code any `json:"code"`
} `json:"error"`
}
func contentText(content any) string {
switch v := content.(type) {
case string:
return v
case []any:
parts := make([]string, 0, len(v))
for _, item := range v {
obj, ok := item.(map[string]any)
if !ok {
continue
}
if text, ok := obj["text"].(string); ok {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n")
default:
return ""
}
}
func compactBody(data []byte) string {
text := strings.TrimSpace(string(data))
if text == "" {
return "<empty body>"
}
if len(text) > 800 {
return text[:800] + "..."
}
return text
}
@@ -1,86 +0,0 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/mmmy/snapgo/internal/domain"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestVisionClientSummarizeImageUsesChatCompletionImageURL(t *testing.T) {
var requestBody map[string]any
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("expected /v1/chat/completions, got %s", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("unexpected auth header %q", got)
}
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
t.Fatalf("decode request: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(`{"choices":[{"message":{"content":"summary ok"}}]}`)),
}, nil
})
client, err := NewVisionClient(domain.LLMProviderQwen, domain.LLMProviderConfig{
BaseURL: "https://example.test/v1",
APIKey: "test-key",
Model: "qwen-vl-plus",
MaxTokens: 321,
Temperature: 0.2,
TimeoutSecs: 5,
})
if err != nil {
t.Fatalf("new client: %v", err)
}
client.httpClient.Transport = transport
text, err := client.SummarizeImage(context.Background(), "describe", "https://cdn.example.com/a.png")
if err != nil {
t.Fatalf("summarize image: %v", err)
}
if text != "summary ok" {
t.Fatalf("expected response text, got %q", text)
}
if requestBody["model"] != "qwen-vl-plus" {
t.Fatalf("expected model forwarded, got %#v", requestBody["model"])
}
if requestBody["max_tokens"] != float64(321) {
t.Fatalf("expected max_tokens forwarded, got %#v", requestBody["max_tokens"])
}
messages := requestBody["messages"].([]any)
content := messages[0].(map[string]any)["content"].([]any)
if content[0].(map[string]any)["text"] != "describe" {
t.Fatalf("expected prompt content, got %#v", content[0])
}
image := content[1].(map[string]any)
if image["type"] != "image_url" {
t.Fatalf("expected image_url item, got %#v", image)
}
url := image["image_url"].(map[string]any)["url"]
if url != "https://cdn.example.com/a.png" {
t.Fatalf("expected image url forwarded, got %#v", url)
}
}
func TestChatCompletionsURLAcceptsFullEndpoint(t *testing.T) {
full := "https://example.com/v1/chat/completions"
if got := chatCompletionsURL(full); got != full {
t.Fatalf("expected full endpoint unchanged, got %q", got)
}
}
+2 -20
View File
@@ -385,14 +385,7 @@ func shellQuote(s string) string {
} }
// buildAuthMethods chooses authentication methods given the supplied // buildAuthMethods chooses authentication methods given the supplied
// configuration. // configuration. See Dial's docstring for the priority order.
//
// The method set is gated by cfg.AuthMethod:
// - SSHAuthPassword → password only.
// - SSHAuthKey → ssh-agent + ~/.ssh/id_* key files only.
// - "" / SSHAuthBuiltin (legacy) → password (if set) + agent + key files.
//
// (Kerberos never reaches here; it uses the system ssh binary instead.)
// //
// Returns a human-readable summary alongside the method slice so the // Returns a human-readable summary alongside the method slice so the
// caller can include "password+agent+ed25519" (and similar) in its dial // caller can include "password+agent+ed25519" (and similar) in its dial
@@ -400,20 +393,10 @@ func shellQuote(s string) string {
func buildAuthMethods(cfg domain.SSHConfig) ([]ssh.AuthMethod, string, error) { func buildAuthMethods(cfg domain.SSHConfig) ([]ssh.AuthMethod, string, error) {
var methods []ssh.AuthMethod var methods []ssh.AuthMethod
var sources []string var sources []string
if cfg.Password != "" {
// Decide which families are allowed for the selected method. An empty /
// "builtin" value preserves the original combined behaviour so configs
// written before the split keep working.
allowPassword := cfg.AuthMethod == domain.SSHAuthPassword ||
cfg.AuthMethod == domain.SSHAuthBuiltin || cfg.AuthMethod == ""
allowKey := cfg.AuthMethod == domain.SSHAuthKey ||
cfg.AuthMethod == domain.SSHAuthBuiltin || cfg.AuthMethod == ""
if allowPassword && cfg.Password != "" {
methods = append(methods, ssh.Password(cfg.Password)) methods = append(methods, ssh.Password(cfg.Password))
sources = append(sources, "password") sources = append(sources, "password")
} }
if allowKey {
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
if conn, err := net.Dial("unix", sock); err == nil { if conn, err := net.Dial("unix", sock); err == nil {
// 关键: 仅在 agent 真正持有 key 时才把它加入 auth methods. // 关键: 仅在 agent 真正持有 key 时才把它加入 auth methods.
@@ -451,7 +434,6 @@ func buildAuthMethods(cfg domain.SSHConfig) ([]ssh.AuthMethod, string, error) {
} else { } else {
sshLog().Debug("home dir unavailable for ssh keys", "err", err) sshLog().Debug("home dir unavailable for ssh keys", "err", err)
} }
}
if len(sources) == 0 { if len(sources) == 0 {
return methods, "none", nil return methods, "none", nil
} }
@@ -1,230 +0,0 @@
// kerberos_uploader.go — an SSHUploader implementation that delegates to
// the system /usr/bin/ssh + /usr/bin/scp binaries so an existing Kerberos
// (GSSAPI) credential cache populated by `kinit` is reused transparently.
//
// 设计理由 (为什么不用 golang.org/x/crypto/ssh 做 Kerberos):
// - macOS 的 Kerberos 票据默认存放在 API: 类型的 credential cache 中,
// 由系统 GSSD (Heimdal) 守护进程托管. Go 生态的 gokrb5 只能读取
// FILE: 类型 ccache 与 keytab, 无法访问 API: ccache, 因此在 macOS 上
// 用纯 Go 实现 GSSAPI 认证基本不可行.
// - 系统自带的 /usr/bin/ssh 原生集成 Heimdal GSSAPI, 用户 `kinit` 之后
// 的票据可被直接复用. 把上传委托给系统 ssh/scp 是最稳妥的方案, 也与
// 用户"命令行能免密登录即可"的预期完全一致.
// - 仅在 AuthMethod == kerberos 时启用此实现; builtin 路径不受影响.
package ssh
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path"
"strconv"
"strings"
"time"
"github.com/mmmy/snapgo/internal/domain"
)
// systemSSHPath / systemSCPPath are the absolute paths to the macOS system
// binaries. We use absolute paths rather than relying on $PATH so a hijacked
// PATH cannot redirect us to an arbitrary binary, and because GUI apps on
// macOS often launch with a minimal PATH that omits /usr/bin entirely.
const (
systemSSHPath = "/usr/bin/ssh"
systemSCPPath = "/usr/bin/scp"
)
// KerberosUploader satisfies application.SSHUploader by shelling out to the
// system scp binary with GSSAPI authentication enabled.
type KerberosUploader struct {
cfg domain.SSHConfig
}
// NewKerberosUploader returns an uploader that delegates to system scp.
func NewKerberosUploader(cfg domain.SSHConfig) *KerberosUploader {
return &KerberosUploader{cfg: cfg}
}
// Upload writes data to remoteRelPath (relative to the remote $HOME) by
// staging it in a local temp file and invoking system scp once.
//
// We stage to a temp file because scp transfers files, not stdin; piping a
// stream would require the more fragile `scp -t` sink protocol that we only
// use for the in-process client. A temp file is simpler and robust.
func (u *KerberosUploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
start := time.Now()
cleaned := normaliseRemotePath(remoteRelPath)
if cleaned == "" {
return fmt.Errorf("ssh(kerberos): remote path is empty")
}
sshLog().Info("kerberos uploader start",
"host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
"remote_path", cleaned, "size", len(data))
// 1. Ensure the remote directory tree exists. scp itself will not create
// intermediate directories, so we run a remote `mkdir -p` first.
dir, _ := path.Split(cleaned)
dir = strings.Trim(dir, "/")
if dir != "" {
if err := u.runRemoteMkdir(ctx, dir); err != nil {
sshLog().Error("kerberos uploader mkdir failed",
"dir", dir, "elapsed", time.Since(start), "err", err)
return err
}
}
// 2. Stage the payload to a local temp file for scp to read.
tmp, err := os.CreateTemp("", "snapgo-scp-*.png")
if err != nil {
return fmt.Errorf("ssh(kerberos): create temp: %w", err)
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("ssh(kerberos): write temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("ssh(kerberos): close temp: %w", err)
}
if err := os.Chmod(tmpPath, mode.Perm()); err != nil {
sshLog().Debug("kerberos uploader chmod temp failed", "err", err)
}
// 3. scp the temp file to "user@host:cleaned" (cleaned is relative to
// $HOME, which scp interprets correctly for a remote login shell).
remoteTarget := fmt.Sprintf("%s@%s:%s", u.cfg.User, u.cfg.Host, cleaned)
args := append(u.commonSCPArgs(), tmpPath, remoteTarget)
if err := u.runCommand(ctx, systemSCPPath, args); err != nil {
sshLog().Error("kerberos uploader scp failed",
"remote_path", cleaned, "elapsed", time.Since(start), "err", err)
return err
}
sshLog().Info("kerberos uploader done",
"remote_path", cleaned, "size", len(data), "total_elapsed", time.Since(start))
return nil
}
// runRemoteMkdir runs `mkdir -p ~/<dir>` on the remote host via system ssh.
func (u *KerberosUploader) runRemoteMkdir(ctx context.Context, dir string) error {
remote := fmt.Sprintf("%s@%s", u.cfg.User, u.cfg.Host)
// Quote the directory so spaces / metacharacters cannot break the shell
// command. dir is already normalised (no leading ~ or /).
cmd := "mkdir -p " + shellQuote("./"+dir)
args := append(u.commonSSHArgs(), remote, cmd)
return u.runCommand(ctx, systemSSHPath, args)
}
// commonSSHArgs builds the shared option set that enables GSSAPI auth and
// disables interactive prompts (we want a hard failure, never a hang waiting
// for a password the GUI cannot answer).
func (u *KerberosUploader) commonSSHArgs() []string {
port := u.cfg.Port
if port <= 0 {
port = 22
}
timeout := u.cfg.ConnectTimeoutSecs
if timeout <= 0 {
timeout = 10
}
args := []string{
"-p", strconv.Itoa(port),
"-o", "GSSAPIAuthentication=yes",
"-o", "GSSAPIDelegateCredentials=no",
"-o", "PreferredAuthentications=gssapi-with-mic,gssapi-keyex",
"-o", "PubkeyAuthentication=no",
"-o", "PasswordAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=" + strconv.Itoa(timeout),
}
args = append(args, u.hostKeyArgs()...)
return args
}
// commonSCPArgs mirrors commonSSHArgs but uses scp's uppercase -P port flag.
func (u *KerberosUploader) commonSCPArgs() []string {
port := u.cfg.Port
if port <= 0 {
port = 22
}
timeout := u.cfg.ConnectTimeoutSecs
if timeout <= 0 {
timeout = 10
}
args := []string{
"-P", strconv.Itoa(port),
"-o", "GSSAPIAuthentication=yes",
"-o", "GSSAPIDelegateCredentials=no",
"-o", "PreferredAuthentications=gssapi-with-mic,gssapi-keyex",
"-o", "PubkeyAuthentication=no",
"-o", "PasswordAuthentication=no",
"-o", "KbdInteractiveAuthentication=no",
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=" + strconv.Itoa(timeout),
}
args = append(args, u.hostKeyArgs()...)
return args
}
// hostKeyArgs maps StrictHostKey onto OpenSSH's StrictHostKeyChecking option,
// keeping behaviour consistent with the in-process client.
func (u *KerberosUploader) hostKeyArgs() []string {
if u.cfg.StrictHostKey {
if u.cfg.KnownHostsPath != "" {
return []string{
"-o", "StrictHostKeyChecking=yes",
"-o", "UserKnownHostsFile=" + u.cfg.KnownHostsPath,
}
}
return []string{"-o", "StrictHostKeyChecking=yes"}
}
// Non-strict: accept new keys automatically but still record them. This
// mirrors the in-process InsecureIgnoreHostKey trade-off the UI warns of.
return []string{"-o", "StrictHostKeyChecking=accept-new"}
}
// runCommand executes the given binary, capturing combined stderr so a
// failure surfaces the remote/OpenSSH diagnostic instead of a bare exit code.
func (u *KerberosUploader) runCommand(ctx context.Context, bin string, args []string) error {
cmd := exec.CommandContext(ctx, bin, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
sshLog().Debug("kerberos exec", "bin", bin, "args", strings.Join(args, " "))
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("%s failed: %s", path.Base(bin), msg)
}
return nil
}
// TestKerberosConnection probes the remote host with system ssh, surfacing a
// clear hint when no valid ticket is present.
func TestKerberosConnection(ctx context.Context, cfg domain.SSHConfig) error {
sshLog().Info("kerberos test connection start",
"host", cfg.Host, "user", cfg.User, "port", cfg.Port)
u := &KerberosUploader{cfg: cfg}
remote := fmt.Sprintf("%s@%s", cfg.User, cfg.Host)
args := append(u.commonSSHArgs(), remote, "true")
if err := u.runCommand(ctx, systemSSHPath, args); err != nil {
// Detect the most common cause: no/expired Kerberos ticket.
if !hasKerberosTicket(ctx) {
return fmt.Errorf("no valid Kerberos ticket found — run `kinit %s@BYTEDANCE.COM` first (%w)", cfg.User, err)
}
return err
}
sshLog().Info("kerberos test connection ok")
return nil
}
// hasKerberosTicket reports whether `klist -s` indicates a valid ticket.
// `klist -s` exits 0 when a valid TGT exists, non-zero otherwise.
func hasKerberosTicket(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "/usr/bin/klist", "-s")
return cmd.Run() == nil
}
+2
View File
@@ -0,0 +1,2 @@
VITE_BASE_PATH=/
VITE_DOWNLOAD_URL=https://github.com/mamamiyear/SnapGo/releases/latest/download/SnapGo-0.1.0-arm64.dmg
+28
View File
@@ -0,0 +1,28 @@
# SnapGo Landing
Vite + React landing page for SnapGo.
## Development
```bash
npm install
npm run dev
```
## Build
```bash
npm run build
```
Set `VITE_BASE_PATH` when deploying under a sub-path:
```bash
VITE_BASE_PATH=/SnapGo/ npm run build
```
Optional download URL override:
```bash
VITE_DOWNLOAD_URL=https://example.com/SnapGo-arm64.dmg npm run build
```
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="SnapGo 是一款 macOS 截图工具,截图后自动上传到对象存储或远端服务器,并复制链接或路径。"
/>
<title>SnapGo - 截完图,链接已经在剪贴板</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1781
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "snapgo-landing",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+267
View File
@@ -0,0 +1,267 @@
import {
Apple,
ArrowRight,
CheckCircle2,
ClipboardCheck,
Cloud,
Command,
Download,
FolderInput,
Gauge,
Globe2,
HardDriveUpload,
LockKeyhole,
MousePointer2,
Network,
Server,
Sparkles,
TerminalSquare,
UploadCloud,
} from 'lucide-react';
import appIcon from '../../build/appicon.png';
const defaultDownloadUrl =
'https://github.com/mamamiyear/SnapGo/releases/latest/download/SnapGo-0.1.0-arm64.dmg';
const downloadUrl = import.meta.env.VITE_DOWNLOAD_URL || defaultDownloadUrl;
const providers = ['AWS S3', 'MinIO', 'Cloudflare R2', 'Backblaze B2'];
const workflow = [
{
title: '全局快捷键截图',
detail: '菜单栏常驻,按下 cmd+shift+a 后直接框选当前屏幕内容。',
icon: Command,
},
{
title: '选择上传目标',
detail: '对象存储得到公开 URL,远端服务器得到 SSH 家目录下的路径。',
icon: Network,
},
{
title: '自动复制结果',
detail: '上传完成后,URL 或 ~/snapgo/...png 已经在剪贴板里。',
icon: ClipboardCheck,
},
];
const featureCards = [
{
title: '截图直传对象存储',
copy: '接入 S3 兼容端点,支持路径前缀和自定义公开地址,适合自有图床和 CDN。',
icon: Cloud,
},
{
title: '远端服务器保存',
copy: '通过 SSH/SCP 写入远端主机,复制相对家目录路径,方便登录服务器后立刻定位。',
icon: Server,
},
{
title: '失败也有兜底',
copy: '上传失败时保住截图文件,避免截了一次图却丢了上下文。',
icon: FolderInput,
},
{
title: '为高频沟通而轻',
copy: '截图、上传、复制三步合一,发给聊天、工单、Issue、PR 都不打断手上的事。',
icon: Gauge,
},
];
const comparison = [
'不用打开图床网页',
'不用手动保存再拖拽',
'不用把图片交给陌生服务',
'不用给 CLI Agent 拼 base64',
];
function App() {
return (
<div className="site-shell">
<header className="hero">
<nav className="top-nav" aria-label="主导航">
<a className="brand" href="#top" aria-label="SnapGo 首页">
<img src={appIcon} alt="" />
<span>SnapGo</span>
</a>
<div className="nav-actions">
<a href="#workflow"></a>
<a href="#destinations"></a>
<a className="nav-download" href={downloadUrl}>
<Download size={16} aria-hidden="true" />
</a>
</div>
</nav>
<div className="hero-grid" id="top">
<div className="hero-copy">
<div className="eyebrow">
<Sparkles size={16} aria-hidden="true" />
macOS
</div>
<h1></h1>
<p className="hero-lede">
SnapGo
</p>
<div className="hero-actions">
<a className="primary-cta" href={downloadUrl}>
<Apple size={19} aria-hidden="true" />
macOS Apple silicon
</a>
<a className="secondary-cta" href="#destinations">
<ArrowRight size={17} aria-hidden="true" />
</a>
</div>
<div className="hero-proof" aria-label="核心能力">
<span>S3 </span>
<span>SSH/SCP </span>
<span> URL/</span>
</div>
</div>
<div className="hero-visual" aria-label="SnapGo 截图上传工作流示意">
<img className="hero-app-icon" src={appIcon} alt="SnapGo 应用图标" />
<div className="capture-scene">
<div className="desktop-bar">
<span />
<span />
<span />
</div>
<div className="selection-box">
<div className="selection-label">1280 x 720</div>
<div className="toolbar" aria-hidden="true">
<MousePointer2 size={16} />
<UploadCloud size={16} />
<HardDriveUpload size={16} />
<ClipboardCheck size={16} />
</div>
</div>
<div className="result-stack">
<div className="result-line">
<Globe2 size={15} aria-hidden="true" />
<span>https://cdn.example.com/snapgo/2026/06/shot.png</span>
</div>
<div className="result-line">
<TerminalSquare size={15} aria-hidden="true" />
<span>~/snapgo/2026/06/shot.png</span>
</div>
</div>
</div>
</div>
</div>
</header>
<main>
<section className="quick-strip" aria-label="上传目标">
{providers.map((provider) => (
<span key={provider}>{provider}</span>
))}
<span>Custom CDN</span>
<span>SSH Host</span>
</section>
<section className="section workflow-section" id="workflow">
<div className="section-heading">
<p></p>
<h2></h2>
</div>
<div className="workflow-grid">
{workflow.map((item, index) => {
const Icon = item.icon;
return (
<article className="workflow-card" key={item.title}>
<div className="step-index">0{index + 1}</div>
<Icon size={25} aria-hidden="true" />
<h3>{item.title}</h3>
<p>{item.detail}</p>
</article>
);
})}
</div>
</section>
<section className="section split-section" id="destinations">
<div className="destination-copy">
<p className="section-kicker"></p>
<h2> URL</h2>
<p>
SnapGo
PR Agent
</p>
<ul className="check-list">
{comparison.map((item) => (
<li key={item}>
<CheckCircle2 size={18} aria-hidden="true" />
{item}
</li>
))}
</ul>
</div>
<div className="destination-panel" aria-label="远端配置示意">
<div className="panel-head">
<span>Destination</span>
<strong>Ready</strong>
</div>
<div className="route-row active">
<Cloud size={20} aria-hidden="true" />
<div>
<strong>S3 compatible</strong>
<span>snapgo/2026/06/*.png public URL</span>
</div>
</div>
<div className="route-row">
<Server size={20} aria-hidden="true" />
<div>
<strong>SSH remote</strong>
<span>~/snapgo/2026/06/*.png clipboard path</span>
</div>
</div>
<div className="clipboard-preview">
<ClipboardCheck size={19} aria-hidden="true" />
<code>Copied: https://cdn.example.com/snapgo/shot.png</code>
</div>
</div>
</section>
<section className="section feature-section">
<div className="section-heading">
<p></p>
<h2></h2>
</div>
<div className="feature-grid">
{featureCards.map((feature) => {
const Icon = feature.icon;
return (
<article className="feature-card" key={feature.title}>
<Icon size={24} aria-hidden="true" />
<h3>{feature.title}</h3>
<p>{feature.copy}</p>
</article>
);
})}
</div>
</section>
<section className="final-cta" aria-labelledby="download-title">
<div>
<p className="section-kicker">SnapGo for macOS</p>
<h2 id="download-title"></h2>
<p>
macOS Apple silicon 使
</p>
</div>
<a className="primary-cta final-button" href={downloadUrl}>
<Download size={19} aria-hidden="true" />
macOS Apple silicon
</a>
</section>
</main>
</div>
);
}
export default App;
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+878
View File
@@ -0,0 +1,878 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800&family=Work+Sans:wght@400;500;600;700&display=swap');
:root {
color-scheme: dark;
font-family:
'Work Sans',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
background: #070b14;
color: #f6fbff;
text-rendering: optimizeLegibility;
font-synthesis: none;
--bg: #070b14;
--panel: rgba(11, 20, 36, 0.82);
--panel-strong: #101b30;
--line: rgba(139, 223, 255, 0.18);
--line-strong: rgba(121, 239, 231, 0.38);
--text: #f6fbff;
--muted: #a7bad1;
--cyan: #42edf2;
--blue: #3a9bff;
--purple: #8d63ff;
--mint: #5df0bd;
--orange: #ff7a2f;
--orange-deep: #e85f18;
--shadow-cyan: rgba(66, 237, 242, 0.28);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
background:
linear-gradient(120deg, rgba(66, 237, 242, 0.08), transparent 34%),
linear-gradient(220deg, rgba(141, 99, 255, 0.12), transparent 42%),
var(--bg);
}
a {
color: inherit;
text-decoration: none;
}
button,
a {
-webkit-tap-highlight-color: transparent;
}
.site-shell {
min-height: 100vh;
overflow: hidden;
}
.hero {
position: relative;
min-height: 88svh;
padding: 18px clamp(18px, 4vw, 64px) 56px;
background:
linear-gradient(90deg, rgba(66, 237, 242, 0.05) 1px, transparent 1px),
linear-gradient(0deg, rgba(66, 237, 242, 0.04) 1px, transparent 1px),
linear-gradient(135deg, #08111f 0%, #0c1223 42%, #070b14 100%);
background-size:
72px 72px,
72px 72px,
auto;
}
.hero::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(110deg, rgba(66, 237, 242, 0.16), transparent 28%),
linear-gradient(290deg, rgba(141, 99, 255, 0.18), transparent 30%);
mask-image: linear-gradient(to bottom, #000 0%, transparent 88%);
pointer-events: none;
}
.top-nav,
.hero-grid,
.quick-strip,
.section,
.final-cta {
width: min(1180px, calc(100vw - 36px));
margin: 0 auto;
}
.top-nav {
position: relative;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
min-height: 58px;
}
.brand,
.nav-actions,
.nav-download,
.hero-actions,
.hero-proof,
.eyebrow,
.quick-strip,
.check-list li {
display: flex;
align-items: center;
}
.brand {
gap: 10px;
font-family: Outfit, sans-serif;
font-weight: 800;
font-size: 20px;
letter-spacing: 0;
}
.brand img {
width: 34px;
height: 34px;
border-radius: 8px;
box-shadow: 0 0 24px rgba(66, 237, 242, 0.22);
}
.nav-actions {
gap: 18px;
color: #c2d2e6;
font-size: 14px;
}
.nav-actions a {
transition: color 180ms ease;
}
.nav-actions a:hover {
color: var(--text);
}
.nav-download {
gap: 7px;
min-height: 36px;
padding: 0 13px;
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
}
.nav-download:hover {
border-color: rgba(66, 237, 242, 0.5);
}
.hero-grid {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: minmax(0, 1.04fr) minmax(360px, 0.96fr);
gap: clamp(26px, 6vw, 72px);
align-items: center;
min-height: calc(88svh - 88px);
padding: 42px 0 26px;
}
.hero-copy {
max-width: 690px;
}
.eyebrow {
width: fit-content;
gap: 8px;
margin-bottom: 18px;
padding: 7px 11px;
border: 1px solid rgba(93, 240, 189, 0.24);
border-radius: 8px;
color: #c6fff0;
background: rgba(93, 240, 189, 0.08);
font-size: 14px;
font-weight: 600;
}
h1,
h2,
h3 {
font-family: Outfit, sans-serif;
letter-spacing: 0;
}
h1 {
margin: 0;
max-width: 780px;
font-size: clamp(46px, 7vw, 88px);
line-height: 0.95;
}
.hero-lede {
margin: 24px 0 0;
max-width: 650px;
color: var(--muted);
font-size: clamp(17px, 2vw, 21px);
line-height: 1.72;
}
.hero-actions {
flex-wrap: wrap;
gap: 13px;
margin-top: 32px;
}
.primary-cta,
.secondary-cta {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 50px;
border-radius: 8px;
font-weight: 700;
transition:
transform 180ms ease,
border-color 180ms ease,
background 180ms ease,
color 180ms ease,
box-shadow 180ms ease;
}
.primary-cta {
padding: 0 20px;
color: #190b04;
background: linear-gradient(180deg, #ff9c54 0%, var(--orange) 100%);
box-shadow: 0 16px 40px rgba(255, 122, 47, 0.28);
}
.primary-cta:hover {
transform: translateY(-1px);
background: linear-gradient(180deg, #ffac6e 0%, #ff812f 100%);
box-shadow: 0 18px 44px rgba(255, 122, 47, 0.38);
}
.primary-cta:focus-visible,
.secondary-cta:focus-visible,
.nav-actions a:focus-visible {
outline: 3px solid rgba(66, 237, 242, 0.72);
outline-offset: 3px;
}
.secondary-cta {
padding: 0 18px;
border: 1px solid rgba(151, 190, 232, 0.22);
color: #dbeeff;
background: rgba(255, 255, 255, 0.05);
}
.secondary-cta:hover {
border-color: rgba(66, 237, 242, 0.45);
color: var(--text);
}
.hero-proof {
flex-wrap: wrap;
gap: 10px;
margin-top: 26px;
}
.hero-proof span {
padding: 7px 10px;
border: 1px solid rgba(139, 223, 255, 0.16);
border-radius: 8px;
color: #c5d9ef;
background: rgba(255, 255, 255, 0.045);
font-size: 13px;
}
.hero-visual {
position: relative;
min-height: 530px;
}
.hero-app-icon {
position: absolute;
top: -4%;
right: -8%;
width: min(420px, 88%);
border-radius: 30px;
opacity: 0.92;
filter: drop-shadow(0 28px 60px rgba(66, 237, 242, 0.18));
}
.capture-scene {
position: absolute;
right: 0;
bottom: 4%;
width: min(510px, 100%);
min-height: 360px;
border: 1px solid rgba(139, 223, 255, 0.24);
border-radius: 8px;
background:
linear-gradient(120deg, rgba(13, 30, 54, 0.88), rgba(9, 15, 30, 0.94)),
repeating-linear-gradient(
90deg,
transparent 0 46px,
rgba(66, 237, 242, 0.06) 47px 48px
);
box-shadow:
0 28px 80px rgba(0, 0, 0, 0.44),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(12px);
overflow: hidden;
}
.desktop-bar {
display: flex;
gap: 7px;
height: 38px;
padding: 14px 16px;
border-bottom: 1px solid rgba(139, 223, 255, 0.14);
}
.desktop-bar span {
width: 10px;
height: 10px;
border-radius: 50%;
background: #3b4860;
}
.desktop-bar span:nth-child(1) {
background: #ff7a2f;
}
.desktop-bar span:nth-child(2) {
background: #f8c75d;
}
.desktop-bar span:nth-child(3) {
background: #5df0bd;
}
.selection-box {
position: absolute;
left: 54px;
top: 76px;
width: 68%;
height: 150px;
border: 2px solid var(--cyan);
background:
linear-gradient(135deg, rgba(66, 237, 242, 0.1), rgba(141, 99, 255, 0.08)),
rgba(255, 255, 255, 0.03);
box-shadow:
0 0 0 999px rgba(0, 0, 0, 0.26),
0 0 34px var(--shadow-cyan);
}
.selection-label {
position: absolute;
top: -34px;
left: -2px;
padding: 5px 8px;
border-radius: 6px;
color: #07121f;
background: var(--cyan);
font-size: 12px;
font-weight: 700;
}
.toolbar {
position: absolute;
right: -1px;
bottom: -48px;
display: flex;
gap: 8px;
padding: 8px;
border: 1px solid rgba(139, 223, 255, 0.18);
border-radius: 8px;
background: rgba(6, 11, 21, 0.92);
color: #dff9ff;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
}
.toolbar svg {
padding: 7px;
width: 32px;
height: 32px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.07);
}
.toolbar svg:nth-child(2) {
color: var(--cyan);
}
.toolbar svg:nth-child(3) {
color: var(--mint);
}
.toolbar svg:nth-child(4) {
color: var(--orange);
}
.result-stack {
position: absolute;
left: 28px;
right: 28px;
bottom: 28px;
display: grid;
gap: 10px;
}
.result-line {
display: flex;
align-items: center;
gap: 9px;
min-height: 38px;
padding: 0 12px;
border: 1px solid rgba(139, 223, 255, 0.16);
border-radius: 8px;
color: #cfe4f7;
background: rgba(255, 255, 255, 0.06);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
}
.result-line span {
overflow: hidden;
text-overflow: ellipsis;
}
.quick-strip {
position: relative;
z-index: 3;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
margin-top: -26px;
padding: 18px;
border: 1px solid rgba(139, 223, 255, 0.15);
border-radius: 8px;
background: rgba(9, 17, 31, 0.92);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.28);
}
.quick-strip span {
min-height: 32px;
padding: 7px 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: #bfd2e9;
background: rgba(255, 255, 255, 0.04);
font-size: 13px;
font-weight: 600;
}
.section {
padding: 100px 0 10px;
}
.section-heading {
max-width: 700px;
margin-bottom: 34px;
}
.section-heading p,
.section-kicker {
margin: 0 0 10px;
color: var(--mint);
font-size: 13px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.section-heading h2,
.split-section h2,
.final-cta h2 {
margin: 0;
color: var(--text);
font-size: clamp(32px, 4.2vw, 54px);
line-height: 1.04;
}
.workflow-grid,
.feature-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.workflow-card,
.feature-card {
min-height: 230px;
padding: 24px;
border: 1px solid var(--line);
border-radius: 8px;
background: linear-gradient(180deg, rgba(16, 30, 53, 0.88), rgba(8, 15, 28, 0.88));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.workflow-card {
position: relative;
overflow: hidden;
}
.workflow-card::after {
content: '';
position: absolute;
left: 24px;
right: 24px;
bottom: 0;
height: 2px;
background: linear-gradient(90deg, var(--cyan), var(--purple));
}
.step-index {
margin-bottom: 26px;
color: rgba(255, 255, 255, 0.28);
font-family: Outfit, sans-serif;
font-size: 48px;
font-weight: 800;
line-height: 0.82;
}
.workflow-card svg,
.feature-card svg {
color: var(--cyan);
}
.workflow-card h3,
.feature-card h3 {
margin: 18px 0 10px;
font-size: 22px;
}
.workflow-card p,
.feature-card p,
.destination-copy p,
.final-cta p {
margin: 0;
color: var(--muted);
line-height: 1.72;
}
.split-section {
display: grid;
grid-template-columns: 0.9fr 1.1fr;
gap: clamp(28px, 6vw, 76px);
align-items: center;
padding-top: 120px;
}
.destination-copy p:not(.section-kicker) {
margin-top: 20px;
font-size: 17px;
}
.check-list {
display: grid;
gap: 12px;
margin: 28px 0 0;
padding: 0;
list-style: none;
}
.check-list li {
gap: 10px;
color: #dcecff;
font-weight: 600;
}
.check-list svg {
flex: 0 0 auto;
color: var(--mint);
}
.destination-panel {
padding: 20px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background:
linear-gradient(180deg, rgba(19, 36, 65, 0.86), rgba(7, 13, 24, 0.92)),
var(--panel-strong);
box-shadow:
0 28px 72px rgba(0, 0, 0, 0.36),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.panel-head,
.route-row,
.clipboard-preview {
display: flex;
align-items: center;
}
.panel-head {
justify-content: space-between;
margin-bottom: 18px;
color: #8fa8c5;
font-size: 13px;
}
.panel-head strong {
color: var(--mint);
}
.route-row {
gap: 14px;
min-height: 86px;
padding: 16px;
border: 1px solid rgba(139, 223, 255, 0.14);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
}
.route-row + .route-row {
margin-top: 12px;
}
.route-row.active {
border-color: rgba(66, 237, 242, 0.5);
background: rgba(66, 237, 242, 0.08);
}
.route-row svg {
flex: 0 0 auto;
color: var(--cyan);
}
.route-row strong,
.route-row span {
display: block;
}
.route-row strong {
margin-bottom: 5px;
font-size: 17px;
}
.route-row span {
color: var(--muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
overflow-wrap: anywhere;
}
.clipboard-preview {
gap: 10px;
margin-top: 14px;
padding: 14px;
border-radius: 8px;
color: #07121f;
background: linear-gradient(90deg, var(--cyan), var(--mint));
}
.clipboard-preview code {
color: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feature-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.feature-card {
min-height: 250px;
}
.feature-card p {
font-size: 15px;
}
.final-cta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 28px;
align-items: center;
margin-top: 110px;
margin-bottom: 60px;
padding: 34px;
border: 1px solid rgba(255, 122, 47, 0.32);
border-radius: 8px;
background:
linear-gradient(120deg, rgba(255, 122, 47, 0.12), transparent 40%),
linear-gradient(300deg, rgba(66, 237, 242, 0.13), transparent 42%),
#0b1424;
}
.final-cta p:not(.section-kicker) {
margin-top: 14px;
}
.final-button {
min-width: 252px;
}
@media (max-width: 980px) {
.hero {
min-height: auto;
}
.hero-grid,
.split-section,
.final-cta {
grid-template-columns: 1fr;
}
.hero-grid {
min-height: auto;
padding-top: 38px;
}
.hero-visual {
min-height: 480px;
}
.hero-app-icon {
right: 3%;
width: min(360px, 78%);
}
.capture-scene {
right: 4%;
}
.workflow-grid,
.feature-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.final-button {
width: fit-content;
}
}
@media (max-width: 680px) {
.hero {
padding: 14px 18px 24px;
}
.top-nav,
.hero-grid,
.quick-strip,
.section,
.final-cta {
width: 100%;
}
.hero-grid {
position: relative;
display: block;
padding-top: 30px;
padding-bottom: 10px;
}
.top-nav {
align-items: flex-start;
}
.nav-actions a:not(.nav-download) {
display: none;
}
.brand img {
width: 32px;
height: 32px;
}
h1 {
font-size: clamp(39px, 13.5vw, 52px);
}
.hero-lede {
margin-top: 18px;
font-size: 16px;
line-height: 1.62;
}
.hero-actions {
align-items: stretch;
flex-direction: column;
margin-top: 22px;
}
.primary-cta,
.secondary-cta {
width: 100%;
}
.hero-proof {
display: none;
}
.hero-visual {
position: absolute;
top: 72px;
right: -18px;
z-index: -1;
width: 180px;
min-height: 180px;
opacity: 0.24;
pointer-events: none;
}
.hero-app-icon {
position: static;
width: 180px;
transform: none;
}
.capture-scene {
display: none;
}
.quick-strip {
margin-top: 0;
justify-content: flex-start;
padding: 12px;
}
.section {
padding-top: 70px;
padding-right: 18px;
padding-left: 18px;
}
.split-section > *,
.final-cta > *,
.workflow-card,
.feature-card,
.destination-copy,
.destination-panel,
.route-row > div {
min-width: 0;
width: 100%;
}
.workflow-grid,
.feature-grid {
grid-template-columns: 1fr;
}
.workflow-card,
.feature-card {
min-height: auto;
}
.destination-panel {
padding: 14px;
}
.clipboard-preview code {
white-space: normal;
}
.final-cta {
margin-top: 70px;
padding: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition: none !important;
}
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DOWNLOAD_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
function normalizeBasePath(value: string | undefined) {
const raw = value?.trim();
if (!raw || raw === '/') return '/';
if (raw === './') return './';
if (/^https?:\/\//.test(raw)) {
return raw.endsWith('/') ? raw : `${raw}/`;
}
return `/${raw.replace(/^\/+|\/+$/g, '')}/`;
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
base: normalizeBasePath(env.VITE_BASE_PATH),
plugins: [react()],
};
});
+2 -2
View File
@@ -36,8 +36,8 @@ func main() {
err := wails.Run(&options.App{ err := wails.Run(&options.App{
Title: "SnapGo", Title: "SnapGo",
Width: 1000, Width: 1080,
Height: 820, Height: 720,
MinWidth: 720, MinWidth: 720,
MinHeight: 520, MinHeight: 520,
AssetServer: &assetserver.Options{ AssetServer: &assetserver.Options{
-12
View File
@@ -74,18 +74,6 @@ func nativeOverlaySaveRemote(x, y, w, h C.int, annotationsJSON *C.char) {
}() }()
} }
//export nativeOverlaySummarize
func nativeOverlaySummarize(x, y, w, h C.int, annotationsJSON *C.char) {
app := consumeNativeOverlayApp()
if app == nil {
return
}
result := nativeCaptureResult(x, y, w, h, annotationsJSON)
go func() {
_ = app.SummarizeNativeRegion(result)
}()
}
//export nativeOverlayCancel //export nativeOverlayCancel
func nativeOverlayCancel() { func nativeOverlayCancel() {
app := consumeNativeOverlayApp() app := consumeNativeOverlayApp()
+9 -33
View File
@@ -15,7 +15,6 @@ extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotat
extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON);
extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir); extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir);
extern void nativeOverlaySaveRemote(int x, int y, int w, int h, const char *annotationsJSON); extern void nativeOverlaySaveRemote(int x, int y, int w, int h, const char *annotationsJSON);
extern void nativeOverlaySummarize(int x, int y, int w, int h, const char *annotationsJSON);
extern void nativeOverlayCancel(void); extern void nativeOverlayCancel(void);
static NSWindow *nativeOverlayWindow = nil; static NSWindow *nativeOverlayWindow = nil;
@@ -102,8 +101,9 @@ static id nativeOverlayKeyMonitor = nil;
// as returning a +1 retained object. // as returning a +1 retained object.
@property(strong) NSButton *clipboardButton; @property(strong) NSButton *clipboardButton;
@property(strong) NSButton *saveButton; @property(strong) NSButton *saveButton;
// `saveRemoteButton` triggers a placeholder action ("not yet supported")
// per product spec; the icon comes from save-remote.svg.
@property(strong) NSButton *saveRemoteButton; @property(strong) NSButton *saveRemoteButton;
@property(strong) NSButton *summaryButton;
@property(strong) NSButton *uploadButton; @property(strong) NSButton *uploadButton;
@property(strong) NSButton *penButton; @property(strong) NSButton *penButton;
@property(strong) NSButton *rectButton; @property(strong) NSButton *rectButton;
@@ -120,7 +120,6 @@ static id nativeOverlayKeyMonitor = nil;
- (void)copySelection; - (void)copySelection;
- (void)saveSelection; - (void)saveSelection;
- (void)saveRemoteSelection; - (void)saveRemoteSelection;
- (void)summarizeSelection;
- (void)cancelSelection; - (void)cancelSelection;
@end @end
@@ -145,7 +144,6 @@ static id nativeOverlayKeyMonitor = nil;
_clipboardButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(copySelection)]; _clipboardButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(copySelection)];
_saveButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveSelection)]; _saveButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveSelection)];
_saveRemoteButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveRemoteSelection)]; _saveRemoteButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(saveRemoteSelection)];
_summaryButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(summarizeSelection)];
_uploadButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(confirmSelection)]; _uploadButton = [SnipHoverButton buttonWithTitle:@"" target:self action:@selector(confirmSelection)];
// Tooltip strings shown on hover for each action button. // Tooltip strings shown on hover for each action button.
// Localized in Chinese to match the rest of the action UI surface. // Localized in Chinese to match the rest of the action UI surface.
@@ -153,7 +151,6 @@ static id nativeOverlayKeyMonitor = nil;
[_clipboardButton setToolTip:@"复制图片"]; [_clipboardButton setToolTip:@"复制图片"];
[_saveButton setToolTip:@"保存本地"]; [_saveButton setToolTip:@"保存本地"];
[_saveRemoteButton setToolTip:@"保存远端"]; [_saveRemoteButton setToolTip:@"保存远端"];
[_summaryButton setToolTip:@"复制总结"];
[_uploadButton setToolTip:@"上传云端"]; [_uploadButton setToolTip:@"上传云端"];
_penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)]; _penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)];
_rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)]; _rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)];
@@ -174,10 +171,10 @@ static id nativeOverlayKeyMonitor = nil;
// Add the action toolbar background BEFORE the buttons so it sits // Add the action toolbar background BEFORE the buttons so it sits
// behind them in the view hierarchy (AppKit z-order = subview order). // behind them in the view hierarchy (AppKit z-order = subview order).
[self addSubview:_actionToolbarBg]; [self addSubview:_actionToolbarBg];
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) { for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) {
[self addSubview:view]; [self addSubview:view];
} }
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _actionToolbarBg]) { for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _actionToolbarBg]) {
[view setHidden:YES]; [view setHidden:YES];
} }
[_sizeLabel setHidden:YES]; [_sizeLabel setHidden:YES];
@@ -251,7 +248,6 @@ static id nativeOverlayKeyMonitor = nil;
// save-remote.svg — provided by the user; its content is the same arrow // save-remote.svg — provided by the user; its content is the same arrow
// icon as the previous upload, so we reuse it verbatim here. // icon as the previous upload, so we reuse it verbatim here.
NSImage *saveRemoteIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M481.749333 353.792c16.682667-16.64 43.690667-16.64 60.373334 0l120.917333 120.832 3.541333 4.010667a42.666667 42.666667 0 0 1-63.872 56.32L554.666667 486.954667V896a42.666667 42.666667 0 0 1-85.333334 0v-409.173333l-48.170666 48.128a42.666667 42.666667 0 0 1-60.330667 0l-3.541333-4.010667a42.666667 42.666667 0 0 1 3.541333-56.32zM512 85.333333c122.538667 0 227.84 73.813333 273.92 179.370667A213.333333 213.333333 0 0 1 725.333333 682.666667a42.666667 42.666667 0 0 1-4.992-85.034667L725.333333 597.333333a128 128 0 0 0 36.394667-250.794666l-38.144-11.264-15.914667-36.437334a213.376 213.376 0 0 0-407.296 58.026667l-7.381333 58.368-57.173333 13.824A85.418667 85.418667 0 0 0 256 597.333333h42.666667a42.666667 42.666667 0 0 1 0 85.333334H256a170.666667 170.666667 0 0 1-40.277333-336.554667A298.709333 298.709333 0 0 1 512 85.333333z' fill='white'/></svg>"]; NSImage *saveRemoteIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M481.749333 353.792c16.682667-16.64 43.690667-16.64 60.373334 0l120.917333 120.832 3.541333 4.010667a42.666667 42.666667 0 0 1-63.872 56.32L554.666667 486.954667V896a42.666667 42.666667 0 0 1-85.333334 0v-409.173333l-48.170666 48.128a42.666667 42.666667 0 0 1-60.330667 0l-3.541333-4.010667a42.666667 42.666667 0 0 1 3.541333-56.32zM512 85.333333c122.538667 0 227.84 73.813333 273.92 179.370667A213.333333 213.333333 0 0 1 725.333333 682.666667a42.666667 42.666667 0 0 1-4.992-85.034667L725.333333 597.333333a128 128 0 0 0 36.394667-250.794666l-38.144-11.264-15.914667-36.437334a213.376 213.376 0 0 0-407.296 58.026667l-7.381333 58.368-57.173333 13.824A85.418667 85.418667 0 0 0 256 597.333333h42.666667a42.666667 42.666667 0 0 1 0 85.333334H256a170.666667 170.666667 0 0 1-40.277333-336.554667A298.709333 298.709333 0 0 1 512 85.333333z' fill='white'/></svg>"];
NSImage *summaryIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M256 128h448l192 192v576H256V128z m64 64v640h512V352H672V192H320z m416 45.248V288h50.752L736 237.248zM384 448h384v64H384v-64z m0 128h384v64H384v-64z m0 128h256v64H384v-64zM192 256v704h512v64H128V256h64z' fill='white'/></svg>"];
// upload.svg — newly replaced "send/cloud" icon supplied by the user. // upload.svg — newly replaced "send/cloud" icon supplied by the user.
NSImage *uploadIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M550.528 544.128L640 539.776V678.4l178.005333-178.005333L640 322.389333v129.024l-72.618667 10.922667c-155.52 23.424-251.733333 73.557333-312.874666 164.437333 84.906667-50.602667 178.261333-76.928 296.021333-82.645333zM554.666667 629.376c-27.392 1.322667-53.034667 3.968-77.141334 7.808l-8.192 1.365333c-136.704 23.637333-224.810667 87.168-310.954666 176.128-26.154667 27.008-51.882667 59.648-51.882667 59.648-14.848 18.176-24.021333 14.037333-20.352-9.173333 0 0 4.394667-31.402667 11.690667-65.792C144.938667 577.066667 250.581333 423.68 554.666667 377.941333V209.066667a38.4 38.4 0 0 1 65.536-27.136l291.328 291.285333a38.4 38.4 0 0 1 0 54.314667l-291.328 291.285333A38.4 38.4 0 0 1 554.666667 791.637333v-162.261333z' fill='white'/></svg>"]; NSImage *uploadIcon = [self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M550.528 544.128L640 539.776V678.4l178.005333-178.005333L640 322.389333v129.024l-72.618667 10.922667c-155.52 23.424-251.733333 73.557333-312.874666 164.437333 84.906667-50.602667 178.261333-76.928 296.021333-82.645333zM554.666667 629.376c-27.392 1.322667-53.034667 3.968-77.141334 7.808l-8.192 1.365333c-136.704 23.637333-224.810667 87.168-310.954666 176.128-26.154667 27.008-51.882667 59.648-51.882667 59.648-14.848 18.176-24.021333 14.037333-20.352-9.173333 0 0 4.394667-31.402667 11.690667-65.792C144.938667 577.066667 250.581333 423.68 554.666667 377.941333V209.066667a38.4 38.4 0 0 1 65.536-27.136l291.328 291.285333a38.4 38.4 0 0 1 0 54.314667l-291.328 291.285333A38.4 38.4 0 0 1 554.666667 791.637333v-162.261333z' fill='white'/></svg>"];
@@ -259,7 +255,6 @@ static id nativeOverlayKeyMonitor = nil;
[self styleIconButton:_clipboardButton image:copyIcon]; [self styleIconButton:_clipboardButton image:copyIcon];
[self styleIconButton:_saveButton image:saveIcon]; [self styleIconButton:_saveButton image:saveIcon];
[self styleIconButton:_saveRemoteButton image:saveRemoteIcon]; [self styleIconButton:_saveRemoteButton image:saveRemoteIcon];
[self styleIconButton:_summaryButton image:summaryIcon];
[self styleIconButton:_uploadButton image:uploadIcon]; [self styleIconButton:_uploadButton image:uploadIcon];
// Configure hover colors for the 5 action buttons. White is the shared // Configure hover colors for the 5 action buttons. White is the shared
@@ -272,13 +267,11 @@ static id nativeOverlayKeyMonitor = nil;
SnipHoverButton *clipboardHB = (SnipHoverButton *)_clipboardButton; SnipHoverButton *clipboardHB = (SnipHoverButton *)_clipboardButton;
SnipHoverButton *saveHB = (SnipHoverButton *)_saveButton; SnipHoverButton *saveHB = (SnipHoverButton *)_saveButton;
SnipHoverButton *saveRemoteHB = (SnipHoverButton *)_saveRemoteButton; SnipHoverButton *saveRemoteHB = (SnipHoverButton *)_saveRemoteButton;
SnipHoverButton *summaryHB = (SnipHoverButton *)_summaryButton;
SnipHoverButton *uploadHB = (SnipHoverButton *)_uploadButton; SnipHoverButton *uploadHB = (SnipHoverButton *)_uploadButton;
cancelHB.baseColor = baseWhite; cancelHB.hoverColor = hoverRed; cancelHB.baseColor = baseWhite; cancelHB.hoverColor = hoverRed;
clipboardHB.baseColor = baseWhite; clipboardHB.hoverColor = hoverBlue; clipboardHB.baseColor = baseWhite; clipboardHB.hoverColor = hoverBlue;
saveHB.baseColor = baseWhite; saveHB.hoverColor = hoverBlue; saveHB.baseColor = baseWhite; saveHB.hoverColor = hoverBlue;
saveRemoteHB.baseColor = baseWhite; saveRemoteHB.hoverColor = hoverBlue; saveRemoteHB.baseColor = baseWhite; saveRemoteHB.hoverColor = hoverBlue;
summaryHB.baseColor = baseWhite; summaryHB.hoverColor = hoverBlue;
uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue; uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue;
[self styleIconButton:_penButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M742.72 752.064c-29.696 28.576-42.976 48.96-42.976 77.12 0 98.4 143.776 142.464 229.728 65.536l-21.344-23.84c-66.976 59.936-176.384 26.4-176.384-41.696 0-16.832 9.28-31.104 33.184-54.08l13.44-12.736c61.024-58.016 75.328-102.72 29.312-179.84-43.84-73.44-96.8-88.288-162.784-50.56-47.232 27.04-68.64 47.456-208.32 190.4-70.624 72.288-153.92 77.088-217.344 26.784-59.712-47.328-79.872-127.36-42.176-188.64 24.512-39.84 62.656-72.896 136.64-124.48 5.952-4.192 27.04-18.816 31.104-21.664 12.16-8.448 21.536-15.104 30.4-21.536 107.552-78.272 140.8-139.136 86.048-219.904-76.992-113.472-202.304-90.016-367.744 61.472l21.6 23.616c153.088-140.224 256.896-159.616 319.68-67.104 41.632 61.408 16.896 106.688-78.4 176.032-8.64 6.304-17.92 12.832-29.888 21.184l-31.136 21.632c-77.504 54.08-117.984 89.184-145.536 133.984-46.784 76.032-22.176 173.664 49.536 230.496 76.224 60.416 177.952 54.56 260.096-29.504 136.16-139.36 158.08-160.224 201.312-184.96 50.656-28.96 84.416-19.52 119.424 39.168 36.896 61.824 27.52 91.392-23.808 140.16 0.096-0.064-10.976 10.368-13.632 12.96z' fill='white'/></svg>"]]; [self styleIconButton:_penButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M742.72 752.064c-29.696 28.576-42.976 48.96-42.976 77.12 0 98.4 143.776 142.464 229.728 65.536l-21.344-23.84c-66.976 59.936-176.384 26.4-176.384-41.696 0-16.832 9.28-31.104 33.184-54.08l13.44-12.736c61.024-58.016 75.328-102.72 29.312-179.84-43.84-73.44-96.8-88.288-162.784-50.56-47.232 27.04-68.64 47.456-208.32 190.4-70.624 72.288-153.92 77.088-217.344 26.784-59.712-47.328-79.872-127.36-42.176-188.64 24.512-39.84 62.656-72.896 136.64-124.48 5.952-4.192 27.04-18.816 31.104-21.664 12.16-8.448 21.536-15.104 30.4-21.536 107.552-78.272 140.8-139.136 86.048-219.904-76.992-113.472-202.304-90.016-367.744 61.472l21.6 23.616c153.088-140.224 256.896-159.616 319.68-67.104 41.632 61.408 16.896 106.688-78.4 176.032-8.64 6.304-17.92 12.832-29.888 21.184l-31.136 21.632c-77.504 54.08-117.984 89.184-145.536 133.984-46.784 76.032-22.176 173.664 49.536 230.496 76.224 60.416 177.952 54.56 260.096-29.504 136.16-139.36 158.08-160.224 201.312-184.96 50.656-28.96 84.416-19.52 119.424 39.168 36.896 61.824 27.52 91.392-23.808 140.16 0.096-0.064-10.976 10.368-13.632 12.96z' fill='white'/></svg>"]];
@@ -598,7 +591,6 @@ static id nativeOverlayKeyMonitor = nil;
[_clipboardButton setHidden:!visible]; [_clipboardButton setHidden:!visible];
[_saveButton setHidden:!visible]; [_saveButton setHidden:!visible];
[_saveRemoteButton setHidden:!visible]; [_saveRemoteButton setHidden:!visible];
[_summaryButton setHidden:!visible];
[_uploadButton setHidden:!visible]; [_uploadButton setHidden:!visible];
[_actionToolbarBg setHidden:!visible]; [_actionToolbarBg setHidden:!visible];
[_penButton setHidden:!visible]; [_penButton setHidden:!visible];
@@ -616,12 +608,12 @@ static id nativeOverlayKeyMonitor = nil;
[_sizeLabel setStringValue:[NSString stringWithFormat:@"%.0f × %.0f", _selection.size.width, _selection.size.height]]; [_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)]; [_sizeLabel setFrame:NSMakeRect(_selection.origin.x, MAX(0, _selection.origin.y - 26), 110, 22)];
// Action toolbar layout — 6 icon buttons, each 28x28, separated by 8px, // Action toolbar layout — 5 icon buttons, each 28x28, separated by 8px,
// with 4px outer padding. Total = 6*28 + 5*8 + 2*4 = 216px wide. // with 4px outer padding. Total = 5*28 + 4*8 + 2*4 = 180px wide.
CGFloat actionBtnSize = 28; CGFloat actionBtnSize = 28;
CGFloat actionGap = 8; CGFloat actionGap = 8;
CGFloat actionPad = 4; CGFloat actionPad = 4;
NSInteger actionCount = 6; NSInteger actionCount = 5;
CGFloat toolbarW = actionPad * 2 + actionBtnSize * actionCount + actionGap * (actionCount - 1); CGFloat toolbarW = actionPad * 2 + actionBtnSize * actionCount + actionGap * (actionCount - 1);
CGFloat toolbarH = 40; CGFloat toolbarH = 40;
CGFloat x = _selection.origin.x + _selection.size.width - toolbarW; CGFloat x = _selection.origin.x + _selection.size.width - toolbarW;
@@ -639,14 +631,10 @@ static id nativeOverlayKeyMonitor = nil;
[_clipboardButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 1, btnY, actionBtnSize, actionBtnSize)]; [_clipboardButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 1, btnY, actionBtnSize, actionBtnSize)];
[_saveButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 2, btnY, actionBtnSize, actionBtnSize)]; [_saveButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 2, btnY, actionBtnSize, actionBtnSize)];
[_saveRemoteButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 3, btnY, actionBtnSize, actionBtnSize)]; [_saveRemoteButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 3, btnY, actionBtnSize, actionBtnSize)];
[_summaryButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)]; [_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, btnY, actionBtnSize, actionBtnSize)];
[_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 5, btnY, actionBtnSize, actionBtnSize)];
// Mark toolbar sits to the LEFT of the action toolbar (same row) with an
// 8px gap between the two groups, so they never overlap on tiny selections.
CGFloat markW = 170; CGFloat markW = 170;
CGFloat markGap = 8; CGFloat markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - markW));
CGFloat markX = MAX(0, x - markGap - markW);
[_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)]; [_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)];
[_rectButton setFrame:NSMakeRect(markX + 36, y + 4, 28, 28)]; [_rectButton setFrame:NSMakeRect(markX + 36, y + 4, 28, 28)];
[_ellipseButton setFrame:NSMakeRect(markX + 68, y + 4, 28, 28)]; [_ellipseButton setFrame:NSMakeRect(markX + 68, y + 4, 28, 28)];
@@ -808,18 +796,6 @@ static id nativeOverlayKeyMonitor = nil;
NSString *json = [self annotationsJSON]; NSString *json = [self annotationsJSON];
nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]); nativeOverlaySaveRemote((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
} }
// `summarizeSelection` uploads the screenshot to S3, sends it to the
// configured multimodal model, and copies the generated summary.
- (void)summarizeSelection {
if (!_hasSelection) {
return;
}
NSRect r = [self globalRectForSelection:_selection];
[self closeOverlayWindow];
NSString *json = [self annotationsJSON];
nativeOverlaySummarize((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
}
@end @end
// screenContainingCursor returns the NSScreen the cursor is currently on, // screenContainingCursor returns the NSScreen the cursor is currently on,
-163
View File
@@ -1,163 +0,0 @@
//go:build darwin
package main
/*
#cgo CFLAGS: -x objective-c -fobjc-arc -fblocks
#cgo LDFLAGS: -framework AppKit -framework QuartzCore
#include <stdlib.h>
#include <dispatch/dispatch.h>
#import <AppKit/AppKit.h>
#import <QuartzCore/QuartzCore.h>
static NSPanel *operationPanel = nil;
static NSTextField *operationTitleLabel = nil;
static NSTextField *operationDetailLabel = nil;
static NSTextField *operationIconLabel = nil;
static NSProgressIndicator *operationSpinner = nil;
static NSString *statusString(const char *value) {
if (value == NULL) {
return @"";
}
NSString *text = [NSString stringWithUTF8String:value];
return text ?: @"";
}
static void ensureOperationPanel(void) {
if (operationPanel != nil) {
return;
}
NSRect frame = NSMakeRect(0, 0, 360, 118);
operationPanel = [[NSPanel alloc]
initWithContentRect:frame
styleMask:NSWindowStyleMaskBorderless
backing:NSBackingStoreBuffered
defer:NO];
[operationPanel setOpaque:NO];
[operationPanel setBackgroundColor:[NSColor clearColor]];
[operationPanel setLevel:NSFloatingWindowLevel];
[operationPanel setHidesOnDeactivate:NO];
[operationPanel setCollectionBehavior:
NSWindowCollectionBehaviorCanJoinAllSpaces |
NSWindowCollectionBehaviorFullScreenAuxiliary |
NSWindowCollectionBehaviorStationary];
NSView *root = [[NSView alloc] initWithFrame:frame];
[root setWantsLayer:YES];
[[root layer] setCornerRadius:14];
[[root layer] setBackgroundColor:[[NSColor colorWithCalibratedWhite:0.08 alpha:0.92] CGColor]];
[[root layer] setShadowColor:[[NSColor blackColor] CGColor]];
[[root layer] setShadowOpacity:0.28];
[[root layer] setShadowRadius:18];
[[root layer] setShadowOffset:CGSizeMake(0, -8)];
operationSpinner = [[NSProgressIndicator alloc] initWithFrame:NSMakeRect(24, 42, 34, 34)];
[operationSpinner setStyle:NSProgressIndicatorStyleSpinning];
[operationSpinner setIndeterminate:YES];
operationIconLabel = [NSTextField labelWithString:@"OK"];
[operationIconLabel setFrame:NSMakeRect(21, 43, 42, 30)];
[operationIconLabel setAlignment:NSTextAlignmentCenter];
[operationIconLabel setFont:[NSFont systemFontOfSize:17 weight:NSFontWeightSemibold]];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:74.0/255.0 green:222.0/255.0 blue:128.0/255.0 alpha:1.0]];
[operationIconLabel setHidden:YES];
operationTitleLabel = [NSTextField labelWithString:@""];
[operationTitleLabel setFrame:NSMakeRect(76, 61, 260, 24)];
[operationTitleLabel setFont:[NSFont systemFontOfSize:15 weight:NSFontWeightSemibold]];
[operationTitleLabel setTextColor:[NSColor whiteColor]];
operationDetailLabel = [NSTextField labelWithString:@""];
[operationDetailLabel setFrame:NSMakeRect(76, 34, 260, 22)];
[operationDetailLabel setFont:[NSFont systemFontOfSize:12 weight:NSFontWeightRegular]];
[operationDetailLabel setTextColor:[NSColor colorWithCalibratedWhite:0.82 alpha:1.0]];
[operationDetailLabel setLineBreakMode:NSLineBreakByTruncatingTail];
[root addSubview:operationSpinner];
[root addSubview:operationIconLabel];
[root addSubview:operationTitleLabel];
[root addSubview:operationDetailLabel];
[operationPanel setContentView:root];
}
static void snipShowOperationStatus(const char *titleC, const char *detailC, int state) {
NSString *title = statusString(titleC);
NSString *detail = statusString(detailC);
dispatch_async(dispatch_get_main_queue(), ^{
ensureOperationPanel();
[operationTitleLabel setStringValue:title];
[operationDetailLabel setStringValue:detail];
if (state == 0) {
[operationIconLabel setHidden:YES];
[operationSpinner setHidden:NO];
[operationSpinner startAnimation:nil];
} else {
[operationSpinner stopAnimation:nil];
[operationSpinner setHidden:YES];
[operationIconLabel setHidden:NO];
if (state == 2) {
[operationIconLabel setStringValue:@"!"];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:248.0/255.0 green:113.0/255.0 blue:113.0/255.0 alpha:1.0]];
} else {
[operationIconLabel setStringValue:@"OK"];
[operationIconLabel setTextColor:[NSColor colorWithCalibratedRed:74.0/255.0 green:222.0/255.0 blue:128.0/255.0 alpha:1.0]];
}
}
NSScreen *screen = [NSScreen mainScreen];
if (screen != nil) {
NSRect visible = [screen visibleFrame];
NSRect panelFrame = [operationPanel frame];
panelFrame.origin.x = NSMidX(visible) - panelFrame.size.width / 2;
panelFrame.origin.y = NSMaxY(visible) - panelFrame.size.height - 48;
[operationPanel setFrame:panelFrame display:YES];
}
[operationPanel orderFrontRegardless];
});
}
static void snipHideOperationStatus(void) {
dispatch_async(dispatch_get_main_queue(), ^{
if (operationPanel != nil) {
[operationSpinner stopAnimation:nil];
[operationPanel orderOut:nil];
}
});
}
*/
import "C"
import (
"sync/atomic"
"time"
"unsafe"
)
const (
operationStatusRunning = iota
operationStatusSuccess
operationStatusError
)
var operationStatusSeq atomic.Uint64
func showOperationStatus(title, detail string, state int) {
operationStatusSeq.Add(1)
ctitle := C.CString(title)
cdetail := C.CString(detail)
defer C.free(unsafe.Pointer(ctitle))
defer C.free(unsafe.Pointer(cdetail))
C.snipShowOperationStatus(ctitle, cdetail, C.int(state))
}
func hideOperationStatusAfter(delay time.Duration) {
seq := operationStatusSeq.Load()
time.AfterFunc(delay, func() {
if operationStatusSeq.Load() == seq {
C.snipHideOperationStatus()
}
})
}
-15
View File
@@ -1,15 +0,0 @@
//go:build !darwin
package main
import "time"
const (
operationStatusRunning = iota
operationStatusSuccess
operationStatusError
)
func showOperationStatus(_ string, _ string, _ int) {}
func hideOperationStatusAfter(_ time.Duration) {}