diff --git a/app.go b/app.go
index eabc3b5..63c7d51 100644
--- a/app.go
+++ b/app.go
@@ -340,12 +340,16 @@ func (a *App) runSaveRemotePipeline(pngBytes []byte) error {
"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.
+ // ssh/scp binaries (reusing the kinit credential cache), bgo delegates to
+ // the internal `bgo scp` client (under a PTY), while builtin uses the
+ // in-process Go SSH client.
var uploader application.SSHUploader
- if cfg.SSH.IsKerberos() {
+ switch {
+ case cfg.SSH.IsKerberos():
uploader = sshpkg.NewKerberosUploader(cfg.SSH)
- } else {
+ case cfg.SSH.IsBgo():
+ uploader = sshpkg.NewBgoUploader(cfg.SSH)
+ default:
uploader = sshpkg.NewUploader(cfg.SSH)
}
@@ -467,6 +471,15 @@ func (a *App) TestSSHConnection(cfg domain.SSHConfig) error {
}
return nil
}
+ // bgo manages its own auth; we can only confirm the binary is installed
+ // and resolvable (a real transfer would need a live remote + PTY).
+ if cfg.IsBgo() {
+ if err := sshpkg.TestBgoConnection(a.ctx, cfg); err != nil {
+ slog.Error("RPC TestSSHConnection (bgo) 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
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 4bcd2ca..a214b56 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -289,6 +289,7 @@ onMounted(load)
+
+
+ bgo mode delegates uploads to the internal bgo scp client,
+ which handles authentication itself. Install bgo and add it to your
+ PATH first — see the
+ setup guide.
+ Note: bgo cannot create remote directories, so screenshots are saved
+ flat into the remote home directory (the Remote path setting is ignored).
+
scp
+// 这样既不引入额外的 PTY 依赖, 又满足 bgo 的运行约束.
+// - bgo scp 不会自动创建远程目录, 且 bgo ssh 无法执行远程命令 (会 panic),
+// 所以无法像 Kerberos 那样先 `mkdir -p`. 故 bgo 模式只能把文件平铺上传
+// 到远端用户的 $HOME 根目录 (文件名内嵌时间戳保证唯一), 上层 service
+// 负责生成扁平文件名.
+// - bgo 自带鉴权, 不需要也不应传 GSSAPI / 密码 / 公钥相关的 ssh 选项.
+package ssh
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/mmmy/snapgo/internal/domain"
+)
+
+// systemScriptPath is the absolute path to the macOS BSD `script` utility,
+// which we use purely to allocate a PTY for bgo. Absolute path avoids PATH
+// hijacking and the minimal PATH GUI apps inherit on macOS.
+const systemScriptPath = "/usr/bin/script"
+
+// bgoSearchPaths lists the locations we probe for the bgo binary when it is
+// not already resolvable via $PATH. GUI apps on macOS launch with a minimal
+// PATH that usually omits ~/.local/bin and Homebrew dirs, so we look there
+// explicitly rather than failing with a confusing "command not found".
+func bgoSearchPaths() []string {
+ home, _ := os.UserHomeDir()
+ return []string{
+ filepath.Join(home, ".local", "bin", "bgo"),
+ "/usr/local/bin/bgo",
+ "/opt/homebrew/bin/bgo",
+ "/opt/bin/bgo",
+ }
+}
+
+// BgoUploader satisfies application.SSHUploader by shelling out to `bgo scp`
+// under a PTY allocated via the system `script` utility.
+type BgoUploader struct {
+ cfg domain.SSHConfig
+}
+
+// NewBgoUploader returns an uploader that delegates to the bgo client.
+func NewBgoUploader(cfg domain.SSHConfig) *BgoUploader {
+ return &BgoUploader{cfg: cfg}
+}
+
+// resolveBgoBinary locates the bgo executable, first via $PATH and then via
+// the well-known install locations. Returns a clear error pointing the user
+// to the setup guide when bgo cannot be found.
+func resolveBgoBinary() (string, error) {
+ if p, err := exec.LookPath("bgo"); err == nil {
+ return p, nil
+ }
+ for _, candidate := range bgoSearchPaths() {
+ if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
+ return candidate, nil
+ }
+ }
+ return "", fmt.Errorf("bgo executable not found — install bgo and add it to PATH (see the setup guide)")
+}
+
+// Upload writes data to a flat remote filename in the user's remote $HOME by
+// staging it locally and invoking `bgo scp` once under a PTY.
+//
+// remoteRelPath is expected to be a flat filename (no directory components),
+// because bgo cannot create remote directories. Any directory components are
+// collapsed to the base name defensively.
+func (u *BgoUploader) Upload(ctx context.Context, remoteRelPath string, data []byte, mode os.FileMode) error {
+ start := time.Now()
+ // Collapse to a flat filename: bgo scp cannot create remote directories.
+ name := path.Base(normaliseRemotePath(remoteRelPath))
+ if name == "" || name == "." || name == "/" {
+ return fmt.Errorf("ssh(bgo): remote filename is empty")
+ }
+
+ bgoBin, err := resolveBgoBinary()
+ if err != nil {
+ sshLog().Error("bgo uploader: binary not found", "err", err)
+ return err
+ }
+
+ sshLog().Info("bgo uploader start",
+ "host", u.cfg.Host, "user", u.cfg.User, "port", u.cfg.Port,
+ "remote_file", name, "size", len(data), "bgo", bgoBin)
+
+ // 1. Stage the payload to a local temp file for scp to read.
+ tmp, err := os.CreateTemp("", "snapgo-bgo-*.png")
+ if err != nil {
+ return fmt.Errorf("ssh(bgo): create temp: %w", err)
+ }
+ tmpPath := tmp.Name()
+ defer os.Remove(tmpPath)
+ if _, err := tmp.Write(data); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("ssh(bgo): write temp: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("ssh(bgo): close temp: %w", err)
+ }
+ if err := os.Chmod(tmpPath, mode.Perm()); err != nil {
+ sshLog().Debug("bgo uploader chmod temp failed", "err", err)
+ }
+
+ // 2. bgo scp under a PTY (via `script`).
+ remoteTarget := fmt.Sprintf("%s@%s:%s", u.cfg.User, bracketHost(u.cfg.Host), name)
+ bgoArgs := u.bgoScpArgs(bgoBin, tmpPath, remoteTarget)
+ if err := u.runUnderPTY(ctx, bgoArgs); err != nil {
+ sshLog().Error("bgo uploader scp failed",
+ "remote_file", name, "elapsed", time.Since(start), "err", err)
+ return err
+ }
+ sshLog().Info("bgo uploader done",
+ "remote_file", name, "size", len(data), "total_elapsed", time.Since(start))
+ return nil
+}
+
+// bgoScpArgs builds the argument vector "bgo scp [-P port] ".
+// bgo manages its own auth, so we pass no ssh -o options here.
+func (u *BgoUploader) bgoScpArgs(bgoBin, src, dst string) []string {
+ args := []string{bgoBin, "scp"}
+ if u.cfg.Port > 0 && u.cfg.Port != 22 {
+ args = append(args, "-P", strconv.Itoa(u.cfg.Port))
+ }
+ args = append(args, src, dst)
+ return args
+}
+
+// runUnderPTY runs the given command vector under a PTY allocated by the
+// system `script` utility, capturing combined output for diagnostics.
+//
+// macOS BSD script signature: `script -q command [args...]`.
+// We discard the typescript by writing to /dev/null.
+func (u *BgoUploader) runUnderPTY(ctx context.Context, cmdVec []string) error {
+ args := append([]string{"-q", "/dev/null"}, cmdVec...)
+ cmd := exec.CommandContext(ctx, systemScriptPath, args...)
+ var combined bytes.Buffer
+ cmd.Stdout = &combined
+ cmd.Stderr = &combined
+ sshLog().Debug("bgo exec", "args", strings.Join(args, " "))
+ if err := cmd.Run(); err != nil {
+ msg := strings.TrimSpace(combined.String())
+ if msg == "" {
+ msg = err.Error()
+ }
+ return fmt.Errorf("bgo scp failed: %s", msg)
+ }
+ return nil
+}
+
+// bracketHost wraps a bare IPv6 literal in [] so the "user@host:path" target
+// parses correctly (e.g. fdbd:dc03:8:379::130 → [fdbd:dc03:8:379::130]).
+// IPv4 addresses and hostnames are returned unchanged. Already-bracketed
+// inputs are left as-is.
+func bracketHost(host string) string {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ return host
+ }
+ if strings.HasPrefix(host, "[") {
+ return host
+ }
+ // An IPv6 literal contains multiple ':' separators; a hostname:port style
+ // is not expected here because the port lives in cfg.Port.
+ if strings.Count(host, ":") >= 2 {
+ return "[" + host + "]"
+ }
+ return host
+}
+
+// TestBgoConnection verifies that the bgo binary is resolvable. A real
+// transfer test is intentionally avoided: bgo requires a PTY and a live
+// remote, and bgo ssh cannot run a harmless remote probe command (it panics).
+// Confirming bgo is installed and on PATH is the most useful signal we can
+// give without performing an actual upload.
+func TestBgoConnection(ctx context.Context, cfg domain.SSHConfig) error {
+ sshLog().Info("bgo test connection start", "host", cfg.Host, "user", cfg.User)
+ bin, err := resolveBgoBinary()
+ if err != nil {
+ return err
+ }
+ sshLog().Info("bgo test connection ok", "bgo", bin)
+ return nil
+}