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 build/bin
node_modules node_modules
frontend/dist frontend/dist
landing/dist
# macOS release artifacts # macOS release artifacts
*.dmg *.dmg
+22 -2
View File
@@ -337,10 +337,20 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
} }
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,
"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{ svc := &application.CaptureAndSSHService{
Uploader: sshpkg.NewUploader(cfg.SSH), Uploader: uploader,
Clipboard: a.clip, Clipboard: a.clip,
Notifier: &runtimeNotifier{ctx: a.ctx}, Notifier: &runtimeNotifier{ctx: a.ctx},
Cfg: cfg.SSH, Cfg: cfg.SSH,
@@ -446,7 +456,17 @@ 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
+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. // 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-Conf' }, { id: 's3', label: 'S3' },
{ id: 'ssh', label: 'SSH-Conf' }, { id: 'ssh', label: 'SSH' },
] ]
interface OverlayPayload { interface OverlayPayload {
+13 -3
View File
@@ -111,14 +111,24 @@ 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 = 180
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, 180, 40, 'right') return placeToolbar(rect.value, ACTION_TOOLBAR_W, 40, 'right')
}) })
const leftToolbarPos = computed(() => { const leftToolbarPos = computed(() => {
if (!rect.value) return null if (!rect.value || !rightToolbarPos.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(() => {
+61 -13
View File
@@ -43,6 +43,7 @@ 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
@@ -77,6 +78,7 @@ function defaultConfig(): AppConfig {
host: '', host: '',
port: 22, port: 22,
user: '', user: '',
authMethod: 'password',
password: '', password: '',
pathPrefix: 'snapgo/', pathPrefix: 'snapgo/',
strictHostKey: false, strictHostKey: false,
@@ -280,14 +282,27 @@ onMounted(load)
placeholder="22" placeholder="22"
/> />
</label> </label>
<label class="field"> <div class="field-row auth-row">
<span>User *</span> <label class="field">
<input v-model="config.ssh.user" placeholder="ubuntu" /> <span>Auth method</span>
</label> <select v-model="config.ssh.authMethod">
<label class="field"> <option value="password">Password</option>
<span>Password (optional)</span> <option value="key">SSH-Key</option>
<input v-model="config.ssh.password" type="password" /> <option value="kerberos">Kerberos</option>
</label> </select>
</label>
<label class="field">
<span>User *</span>
<input v-model="config.ssh.user" placeholder="ubuntu" />
</label>
<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"> <label class="field full">
<span>Remote path (under home)</span> <span>Remote path (under home)</span>
<div class="prefixed-input"> <div class="prefixed-input">
@@ -324,9 +339,23 @@ onMounted(load)
</span> </span>
</label> </label>
</div> </div>
<p v-if="!config.ssh.password" class="hint warn"> <p v-if="config.ssh.authMethod === 'kerberos'" class="hint">
Password is empty please make sure password-less SSH is configured on Kerberos mode delegates to the system <code>ssh</code>/<code>scp</code>.
this machine (e.g. via <code>ssh-copy-id</code> or your <code>ssh-agent</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> </p>
<div class="actions"> <div class="actions">
@@ -414,17 +443,30 @@ 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 {
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;
height: 36px;
background: #fff; background: #fff;
color: inherit; color: inherit;
outline: none; outline: none;
@@ -432,7 +474,8 @@ kbd {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.field input:focus { .field input:focus,
.field select: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);
} }
@@ -560,6 +603,11 @@ kbd {
border-color: #374151; border-color: #374151;
color: #e5e7eb; color: #e5e7eb;
} }
.field select {
background: #111827;
border-color: #374151;
color: #e5e7eb;
}
.field input:disabled { .field input:disabled {
background: #1f2937; background: #1f2937;
color: #6b7280; color: #6b7280;
+2
View File
@@ -57,6 +57,7 @@ export namespace domain {
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;
@@ -72,6 +73,7 @@ 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"];
+34
View File
@@ -33,9 +33,22 @@ 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
@@ -51,6 +64,7 @@ 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"`
@@ -58,6 +72,26 @@ type SSHConfig struct {
ConnectTimeoutSecs int `json:"connectTimeoutSecs"` 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. // 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,
+52 -34
View File
@@ -385,7 +385,14 @@ func shellQuote(s string) string {
} }
// buildAuthMethods chooses authentication methods given the supplied // 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 // 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
@@ -393,47 +400,58 @@ 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 sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" { if allowKey {
if conn, err := net.Dial("unix", sock); err == nil { if sock := os.Getenv("SSH_AUTH_SOCK"); sock != "" {
// 关键: 仅在 agent 真正持有 key 时才把它加入 auth methods. if conn, err := net.Dial("unix", sock); err == nil {
// // 关键: 仅在 agent 真正持有 key 时才把它加入 auth methods.
// macOS 的 launchd ssh-agent 即便没有任何 identity 也会响应, //
// 此时若仍注册 PublicKeysCallback, x/crypto 会把它当作一次空的 // macOS 的 launchd ssh-agent 即便没有任何 identity 也会响应,
// publickey 尝试. 配合服务器的 MaxAuthTries 计数, 这次"空尝试" // 此时若仍注册 PublicKeysCallback, x/crypto 会把它当作一次空的
// 会挤占后续基于磁盘私钥的认证机会, 最终导致 // publickey 尝试. 配合服务器的 MaxAuthTries 计数, 这次"空尝试"
// "[none publickey] no supported methods remain" —— 即便磁盘上 // 会挤占后续基于磁盘私钥的认证机会, 最终导致
// 的 key 本身完全可用. 因此空 agent 必须跳过. // "[none publickey] no supported methods remain" —— 即便磁盘上
ag := agent.NewClient(conn) // 的 key 本身完全可用. 因此空 agent 必须跳过.
if keys, lerr := ag.List(); lerr == nil && len(keys) > 0 { ag := agent.NewClient(conn)
methods = append(methods, ssh.PublicKeysCallback(ag.Signers)) if keys, lerr := ag.List(); lerr == nil && len(keys) > 0 {
sources = append(sources, fmt.Sprintf("agent(%d)", len(keys))) methods = append(methods, ssh.PublicKeysCallback(ag.Signers))
sources = append(sources, fmt.Sprintf("agent(%d)", len(keys)))
} else {
sshLog().Debug("ssh-agent has no identities; skipping",
"sock", sock, "list_err", lerr)
}
} else { } else {
sshLog().Debug("ssh-agent has no identities; skipping", sshLog().Debug("ssh-agent dial failed", "sock", sock, "err", err)
"sock", sock, "list_err", lerr) }
}
home, err := os.UserHomeDir()
if err == nil {
// Probe the common default keys; any unreadable / missing file is
// silently skipped so the user never sees noise about keys they did
// not set up.
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
signer, err := loadPrivateKey(path.Join(home, ".ssh", name))
if err == nil && signer != nil {
methods = append(methods, ssh.PublicKeys(signer))
sources = append(sources, name)
}
} }
} else { } else {
sshLog().Debug("ssh-agent dial failed", "sock", sock, "err", err) sshLog().Debug("home dir unavailable for ssh keys", "err", err)
} }
} }
home, err := os.UserHomeDir()
if err == nil {
// Probe the common default keys; any unreadable / missing file is
// silently skipped so the user never sees noise about keys they did
// not set up.
for _, name := range []string{"id_ed25519", "id_rsa", "id_ecdsa"} {
signer, err := loadPrivateKey(path.Join(home, ".ssh", name))
if err == nil && signer != nil {
methods = append(methods, ssh.PublicKeys(signer))
sources = append(sources, name)
}
}
} else {
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
} }
@@ -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)]; [_saveRemoteButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 3, btnY, actionBtnSize, actionBtnSize)];
[_uploadButton setFrame:NSMakeRect(baseX + (actionBtnSize + actionGap) * 4, 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 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)]; [_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)];