3 Commits

Author SHA1 Message Date
mamamiyear 39f0aaae02 refactor: merge mark and action toolbars to the right side
Place the mark toolbar to the left of the action toolbar on the same row
with an 8px gap, so the two button groups never overlap on tiny selections.
2026-06-14 14:30:43 +08:00
mamamiyear 3cd92945ac refactor: optimize setting view 2026-06-12 11:10:22 +08:00
mamamiyear 4576653b4e feat: save to remote can be authed by Kerberos 2026-06-12 10:56:21 +08:00
22 changed files with 420 additions and 3122 deletions
-1
View File
@@ -1,7 +1,6 @@
build/bin
node_modules
frontend/dist
landing/dist
# macOS release artifacts
*.dmg
+22 -2
View File
@@ -337,10 +337,20 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
}
slog.Info("save-remote dispatch",
"host", cfg.SSH.Host, "user", cfg.SSH.User, "port", cfg.SSH.Port,
"png_size", len(pngBytes))
"auth_method", cfg.SSH.AuthMethod, "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{
Uploader: sshpkg.NewUploader(cfg.SSH),
Uploader: uploader,
Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx},
Cfg: cfg.SSH,
@@ -446,7 +456,17 @@ func (a *App) TestConnection(cfg domain.S3Config) error {
func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
slog.Info("RPC TestSSHConnection",
"host", cfg.Host, "user", cfg.User, "port", cfg.Port,
"auth_method", cfg.AuthMethod,
"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 {
slog.Error("RPC TestSSHConnection failed", "err", err)
return err
+2 -2
View File
@@ -46,8 +46,8 @@ const activeTab = ref<SettingsTab>('general')
// a one-line change. The label values match the user-facing tab names.
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
{ id: 'general', label: 'General' },
{ id: 's3', label: 'S3-Conf' },
{ id: 'ssh', label: 'SSH-Conf' },
{ id: 's3', label: 'S3' },
{ id: 'ssh', label: 'SSH' },
]
interface OverlayPayload {
+13 -3
View File
@@ -111,14 +111,24 @@ const sizeLabel = computed(() => {
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
})
const MARK_TOOLBAR_W = 190
const ACTION_TOOLBAR_W = 180
const TOOLBAR_GROUP_GAP = 8
const rightToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 180, 40, 'right')
return placeToolbar(rect.value, ACTION_TOOLBAR_W, 40, 'right')
})
const leftToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 190, 40, 'left')
if (!rect.value || !rightToolbarPos.value) return null
// 标记组与操作组合并到右侧,紧贴在操作组左侧,中间保留间隔
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(() => {
+55 -7
View File
@@ -43,6 +43,7 @@ interface SSHConfig {
host: string
port: number
user: string
authMethod: string
password: string
pathPrefix: string
strictHostKey: boolean
@@ -77,6 +78,7 @@ function defaultConfig(): AppConfig {
host: '',
port: 22,
user: '',
authMethod: 'password',
password: '',
pathPrefix: 'snapgo/',
strictHostKey: false,
@@ -280,14 +282,27 @@ onMounted(load)
placeholder="22"
/>
</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">
<span>User *</span>
<input v-model="config.ssh.user" placeholder="ubuntu" />
</label>
<label class="field">
<span>Password (optional)</span>
<label
v-if="config.ssh.authMethod === 'password'"
class="field"
>
<span>Password</span>
<input v-model="config.ssh.password" type="password" />
</label>
</div>
<label class="field full">
<span>Remote path (under home)</span>
<div class="prefixed-input">
@@ -324,9 +339,23 @@ onMounted(load)
</span>
</label>
</div>
<p v-if="!config.ssh.password" class="hint warn">
Password is empty please make sure password-less SSH is configured on
this machine (e.g. via <code>ssh-copy-id</code> or your <code>ssh-agent</code>).
<p v-if="config.ssh.authMethod === 'kerberos'" class="hint">
Kerberos mode delegates to the system <code>ssh</code>/<code>scp</code>.
Run <code>kinit your.name@BYTEDANCE.COM</code> in a terminal first; tickets
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>
<div class="actions">
@@ -414,17 +443,30 @@ kbd {
.field.full {
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 {
color: #374151;
font-weight: 500;
}
.field input[type='text'],
.field input[type='number'],
.field input:not([type='checkbox']) {
.field input:not([type='checkbox']),
.field select {
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 7px 10px;
font-size: 13px;
line-height: 1.4;
height: 36px;
background: #fff;
color: inherit;
outline: none;
@@ -432,7 +474,8 @@ kbd {
width: 100%;
box-sizing: border-box;
}
.field input:focus {
.field input:focus,
.field select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
}
@@ -560,6 +603,11 @@ kbd {
border-color: #374151;
color: #e5e7eb;
}
.field select {
background: #111827;
border-color: #374151;
color: #e5e7eb;
}
.field input:disabled {
background: #1f2937;
color: #6b7280;
+2
View File
@@ -57,6 +57,7 @@ export namespace domain {
host: string;
port: number;
user: string;
authMethod: string;
password: string;
pathPrefix: string;
strictHostKey: boolean;
@@ -72,6 +73,7 @@ export namespace domain {
this.host = source["host"];
this.port = source["port"];
this.user = source["user"];
this.authMethod = source["authMethod"];
this.password = source["password"];
this.pathPrefix = source["pathPrefix"];
this.strictHostKey = source["strictHostKey"];
+34
View File
@@ -33,9 +33,22 @@ type S3Config struct {
// Field design notes:
// - Host / Port form the destination endpoint. Port defaults to 22 when 0.
// - 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
// local SSH agent or ~/.ssh/id_* keys (i.e. the user is responsible for
// 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
// UI presents it as "~/<PathPrefix>" so the user cannot escape the home
// directory by writing absolute paths. Leading "/" or "~" markers are
@@ -51,6 +64,7 @@ type SSHConfig struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
AuthMethod string `json:"authMethod"`
Password string `json:"password"`
PathPrefix string `json:"pathPrefix"`
StrictHostKey bool `json:"strictHostKey"`
@@ -58,6 +72,26 @@ type SSHConfig struct {
ConnectTimeoutSecs int `json:"connectTimeoutSecs"`
}
// 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"
)
// 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.
//
// We keep S3 / SSH nested so that adding more providers later (e.g. AliyunOSS,
+20 -2
View File
@@ -385,7 +385,14 @@ func shellQuote(s string) string {
}
// buildAuthMethods chooses authentication methods given the supplied
// configuration. See Dial's docstring for the priority order.
// configuration.
//
// 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
// caller can include "password+agent+ed25519" (and similar) in its dial
@@ -393,10 +400,20 @@ func shellQuote(s string) string {
func buildAuthMethods(cfg domain.SSHConfig) ([]ssh.AuthMethod, string, error) {
var methods []ssh.AuthMethod
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))
sources = append(sources, "password")
}
if allowKey {
if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
if conn, err := net.Dial("unix", sock); err == nil {
// 关键: 仅在 agent 真正持有 key 时才把它加入 auth methods.
@@ -434,6 +451,7 @@ func buildAuthMethods(cfg domain.SSHConfig) ([]ssh.AuthMethod, string, error) {
} else {
sshLog().Debug("home dir unavailable for ssh keys", "err", err)
}
}
if len(sources) == 0 {
return methods, "none", nil
}
@@ -0,0 +1,230 @@
// 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
@@ -1,2 +0,0 @@
VITE_BASE_PATH=/
VITE_DOWNLOAD_URL=https://github.com/mamamiyear/SnapGo/releases/latest/download/SnapGo-0.1.0-arm64.dmg
-28
View File
@@ -1,28 +0,0 @@
# 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
@@ -1,16 +0,0 @@
<!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
@@ -1,23 +0,0 @@
{
"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
@@ -1,267 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,878 +0,0 @@
@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
@@ -1,9 +0,0 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DOWNLOAD_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
-21
View File
@@ -1,21 +0,0 @@
{
"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
@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
-21
View File
@@ -1,21 +0,0 @@
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()],
};
});
+4 -1
View File
@@ -633,8 +633,11 @@ static id nativeOverlayKeyMonitor = nil;
[_saveRemoteButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 3, btnY, actionBtnSize, actionBtnSize)];
[_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, 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 markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - markW));
CGFloat markGap = 8;
CGFloat markX = MAX(0, x - markGap - markW);
[_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)];
[_rectButton setFrame:NSMakeRect(markX + 36, y + 4, 28, 28)];
[_ellipseButton setFrame:NSMakeRect(markX + 68, y + 4, 28, 28)];