项目地址:https://github.com/Julyos-rgb/wsShell

技术栈:Wails v2 + Go + React + TypeScript + xterm.js + SQLite

适合人群:有 Go 或 React 基础,想学习桌面应用开发、SSH 协议实践、前后端桥接的同学


前言

大家好,今天给大家带来一个完整的桌面应用开源项目 —— wsShell

在日常开发中,我们经常需要通过 SSH 连接服务器。市面上有 Xshell、SecureCRT、Termius 等工具,但你有没有想过:自己动手写一个 SSH 客户端,到底需要哪些技术?难度如何?

wsShell 就是为了回答这个问题而诞生的。它是一个基于 Wails v2 框架(Go 后端 + React 前端)构建的轻量级桌面 SSH 客户端,实现了:

  • SSH 终端(多标签、256色、自适应窗口)
  • SFTP 文件管理(双栏浏览、断点续传)
  • VNC 远程桌面(SSH 隧道代理)
  • 服务器管理(凭证加密存储、分组收藏)

通过这个项目,你将学到:

  1. 如何用 Wails v2 搭建 Go + React 桌面应用
  2. Go 语言如何实现 SSH 协议通信(golang.org/x/crypto/ssh)
  3. 前后端如何通过 Wails Bindings 和 Events 双向通信
  4. 如何用 AES-256-GCM 加密敏感数据
  5. SQLite 在桌面应用中的持久化方案
  6. xterm.js 在桌面环境中的集成技巧
  7. SFTP 文件传输与断点续传的实现
  8. 通过 SSH 隧道代理 VNC 连接的原理

一、项目架构总览

1.1 为什么选择 Wails v2?

在桌面开发领域,常见的跨平台方案有:

方案 语言 包大小 渲染引擎
Electron JavaScript ~100MB+ Chromium
Tauri Rust + JS ~5MB 系统 WebView
Wails Go + JS ~8MB 系统 WebView
Qt C++ ~20MB 自绘

选择 Wails 的理由:

  • Go 后端:天然适合网络编程,SSH 库成熟
  • 系统 WebView:不打包 Chromium,体积小
  • 前后端分离:React 前端 + Go 后端,各司其职
  • 类型安全:Wails 自动生成 TypeScript 绑定

1.2 整体架构

┌──────────────────────────────────────────────────────┐
│               Desktop Application                     │
│                  (Wails Runtime)                       │
├────────────────────┬─────────────────────────────────┤
│    Go Backend      │    React Frontend (embedded)     │
│                    │                                  │
│  ┌──────────────┐  │  ┌────────────────────────────┐ │
│  │ SSHService   │  │  │ Terminal (xterm.js)        │ │
│  │              │◄─┼──│                            │ │
│  └──────────────┘  │  └────────────────────────────┘ │
│  ┌──────────────┐  │  ┌────────────────────────────┐ │
│  │ SFTPManager  │  │  │ FileManager (双栏浏览器)   │ │
│  │              │◄─┼──│                            │ │
│  └──────────────┘  │  └────────────────────────────┘ │
│  ┌──────────────┐  │  ┌────────────────────────────┐ │
│  │ VNC Proxy    │  │  │ VncViewer (noVNC)         │ │
│  │              │◄─┼──│                            │ │
│  └──────────────┘  │  └────────────────────────────┘ │
│  ┌──────────────┐  │  ┌────────────────────────────┐ │
│  │ ConfigManager│  │  │ Sidebar (服务器列表)        │ │
│  │              │◄─┼──│                            │ │
│  └──────────────┘  │  └────────────────────────────┘ │
│  ┌──────────────┐  │  ┌────────────────────────────┐ │
│  │ Encryptor    │  │  │ Zustand State              │ │
│  │ (AES-256)    │  │  │ (状态管理)                  │ │
│  └──────────────┘  │  └────────────────────────────┘ │
│  ┌──────────────┐  │                                 │
│  │ SQLite Store │  │                                 │
│  └──────────────┘  │                                 │
├────────────────────┴─────────────────────────────────┤
│         Wails Bindings (Request-Response)             │
│         Wails Events   (Server → Client Stream)       │
└──────────────────────────────────────────────────────┘

1.3 通信模式

Wails 提供了两种前后端通信方式:

模式一:Bindings(请求-响应)

类似 HTTP 调用,前端直接调用 Go 方法,获取返回值。

// 前端调用
const result = await Connect({
  host: "192.168.1.100",
  port: 22,
  username: "root",
  password: "xxx",
  authType: "password"
})
// result = { success: true, sessionId: "root@192.168.1.100:22" }
// Go 后端方法(自动暴露给前端)
func (s *SSHService) Connect(req ConnectRequest) (ConnectResponse, error) {
    // ... SSH 连接逻辑
}

模式二:Events(服务端推送流)

Go 后端主动向前端推送数据,适合持续的数据流。

// Go 后端推送终端输出
runtime.EventsEmit(s.Ctx, "ssh:"+sessionID+":stdout", data)
// 前端订阅事件
EventsOn("ssh:root@192.168.1.100:22:stdout", (data) => {
    xterm.write(data)  // 写入终端
})

1.4 项目结构

wsShell/
├── main.go                         # 应用入口,Wails 配置
├── app.go                          # App 结构体,服务编排
├── wails.json                      # Wails 项目配置
│
├── internal/                       # Go 后端(按功能模块划分)
│   ├── ssh/
│   │   └── ssh_service.go          # SSH 连接与会话管理
│   ├── sftp/
│   │   └── sftp_manager.go         # SFTP 文件传输
│   ├── config/
│   │   └── config_manager.go       # 服务器配置 CRUD
│   ├── crypto/
│   │   └── encryptor.go            # AES-256-GCM 加解密
│   ├── store/
│   │   ├── sqlite.go               # 数据库初始化
│   │   ├── migrations.go           # 建表迁移
│   │   └── server_repository.go    # Repository 模式
│   └── vnc/
│       └── proxy.go                # VNC WebSocket 代理
│
├── frontend/                       # React 前端
│   ├── src/
│   │   ├── components/
│   │   │   ├── Terminal.tsx         # xterm.js 终端组件
│   │   │   ├── FileManager.tsx      # SFTP 双栏文件管理
│   │   │   ├── VncViewer.tsx        # noVNC 远程桌面
│   │   │   ├── Sidebar.tsx          # 服务器列表侧栏
│   │   │   ├── AddServerDialog.tsx  # 添加/编辑服务器
│   │   │   └── StatusBar.tsx        # 底部状态栏
│   │   ├── stores/
│   │   │   └── ui.ts               # Zustand 状态管理
│   │   ├── types/
│   │   │   └── index.ts            # TypeScript 类型定义
│   │   ├── App.tsx                 # 根组件
│   │   └── main.tsx                # 入口
│   └── wailsjs/                    # Wails 自动生成的绑定(勿手动编辑)
│
└── build/                          # 构建资源(图标、manifest)

二、入口与启动流程

2.1 main.go —— 一切从这里开始

package main

import (
    "embed"
    "github.com/wailsapp/wails/v2"
    "github.com/wailsapp/wails/v2/pkg/options"
    "github.com/wailsapp/wails/v2/pkg/options/assetserver"
)

//go:embed all:frontend/dist
var assets embed.FS  // 将前端构建产物嵌入到二进制文件中

func main() {
    app := NewApp()

    err := wails.Run(&options.App{
        Title:  "wsShell",
        Width:  1024,
        Height: 768,
        AssetServer: &assetserver.Options{
            Assets: assets,  // 前端资源
        },
        BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 1},
        OnStartup: app.startup,
        Bind: []interface{}{  // 暴露给前端的 Go 对象
            app,
            app.sshService,
            app.sftpManager,
            app.configManager,
            app.vncProxy,
        },
    })

    if err != nil {
        println("Error:", err.Error())
    }
}

关键点解读:

  1. //go:embed all:frontend/dist:Go 1.16 的嵌入指令,把 React 构建产物打包进二进制文件,运行时不需要额外的文件
  2. Bind: []interface{}{...}:注册要暴露给前端的 Go 对象。Wails 会为每个对象的公开方法自动生成 TypeScript 绑定
  3. OnStartup: app.startup:应用启动时的回调,在这里注入 Wails Context

2.2 app.go —— 服务编排中心

type App struct {
    ctx           context.Context
    sshService    *ssh.SSHService
    sftpManager   *sftp.SFTPManager
    configManager *config.ConfigManager
    vncProxy      *vnc.Proxy
}

func NewApp() *App {
    repo, err := store.NewServerRepository()  // 初始化 SQLite
    if err != nil {
        log.Fatalf("Failed to initialize server repository: %v", err)
    }

    return &App{
        sshService:    ssh.NewSSHService(),
        sftpManager:   sftp.NewSFTPManager(),
        configManager: config.NewConfigManager(repo),
        vncProxy:      vnc.NewProxy(),
    }
}

func (a *App) startup(ctx context.Context) {
    a.ctx = ctx
    a.sshService.Ctx = ctx     // 注入 Context,用于 Events 事件推送
    a.sftpManager.Ctx = ctx
    a.vncProxy.SetSSHProvider(a.sshService)  // VNC 复用 SSH 连接
}

设计思路:

  • App 是所有服务的容器,负责依赖注入和生命周期管理
  • context.Context 是 Wails 事件系统的基础,必须在 startup 时注入
  • VNC Proxy 通过 SetSSHProvider 获取 SSH 客户端引用,实现 SSH 隧道复用

三、SSH 终端 —— 核心模块详解

3.1 架构设计

SSH 终端是整个项目最核心的模块。核心挑战是:如何将 Go 的 SSH 会话流式输出桥接到前端的 xterm.js?

用户键盘输入 → xterm.js → Wails Binding → Go stdin pipe → 远程服务器
                                                              ↓
远程服务器 → Go stdout goroutine → Wails Event → xterm.js 渲染

3.2 连接建立(Connect)

type SSHService struct {
    mu       sync.RWMutex              // 读写锁,保护并发访问
    sessions map[string]*sshSession    // 会话映射:sessionID → sshSession
    clients  map[string]*sshcrypto.Client  // 客户端映射:sessionID → Client
    Ctx      context.Context           // Wails Context
}

type sshSession struct {
    session *sshcrypto.Session
    stdin   chan string       // 带缓冲的输入通道(容量256)
    done    chan struct{}     // 退出信号
}

Connect 方法的核心流程:

func (s *SSHService) Connect(req ConnectRequest) (ConnectResponse, error) {
    // 1. 配置 SSH 客户端
    config := &sshcrypto.ClientConfig{
        User:            req.Username,
        HostKeyCallback: sshcrypto.InsecureIgnoreHostKey(),  // 注意:生产环境应验证 HostKey
        Timeout:         10 * time.Second,
    }

    // 2. 选择认证方式
    if req.AuthType == "key" && req.PrivateKey != "" {
        signer, _ := sshcrypto.ParsePrivateKey([]byte(req.PrivateKey))
        config.Auth = []sshcrypto.AuthMethod{sshcrypto.PublicKeys(signer)}
    } else {
        config.Auth = []sshcrypto.AuthMethod{sshcrypto.Password(req.Password)}
    }

    // 3. 建立 TCP 连接 → SSH 握手
    client, err := sshcrypto.Dial("tcp", addr, config)

    // 4. 创建 Session,请求 PTY(伪终端)
    session, _ := client.NewSession()
    session.RequestPty("xterm-256color", 40, 120, modes)
    session.Shell()  // 启动 shell

    // 5. 获取 stdin/stdout/stderr 管道
    stdinPipe, _ := session.StdinPipe()
    stdoutPipe, _ := session.StdoutPipe()
    stderrPipe, _ := session.StderrPipe()

    // 6. 启动 goroutine 读取输出,通过 Events 推送到前端
    go func() {
        buf := make([]byte, 4096)
        for {
            n, err := stdoutPipe.Read(buf)
            if n > 0 {
                runtime.EventsEmit(s.Ctx, "ssh:"+sessionID+":stdout", string(buf[:n]))
            }
            if err != nil { close(ss.done); return }
        }
    }()
}

要点解析:

技术点 说明
sync.RWMutex 读写锁。多个终端可同时读取,但增删会话需要写锁
session.RequestPty 请求伪终端。xterm-256color 支持 256 色,40行×120列是初始大小
chan string (缓冲256) stdin 通道。缓冲防止前端快速输入时阻塞
runtime.EventsEmit Wails 事件推送。每个会话用不同的 event name 隔离数据
Session ID 格式 {username}@{host}:{port},如 root@192.168.1.100:22

3.3 前端终端组件

const TerminalInstance: React.FC<TerminalInstanceProps> = ({ tab, isActive }) => {
  const terminalRef = useRef<HTMLDivElement>(null)
  const xtermRef = useRef<XTermTerminal | null>(null)

  useEffect(() => {
    // 创建 xterm 实例
    const term = new XTermTerminal({
      theme: terminalTheme,        // 自定义配色
      fontFamily: 'Consolas, Monaco, "Courier New", monospace',
      fontSize: 14,
      lineHeight: 1.2,
      cursorBlink: true,
      scrollback: 10000,           // 回滚缓冲区 10000 行
    })

    term.open(terminalRef.current)

    // 监听用户输入 → 发送到 Go 后端
    term.onData(async (data) => {
      await WriteToSession({ sessionId: tab.sessionId, data })
    })

    xtermRef.current = term
  }, [tab.id])

  // 订阅后端推送的终端输出
  useEffect(() => {
    const stdoutHandler = (data: string) => { term.write(data) }
    EventsOn(`ssh:${tab.sessionId}:stdout`, stdoutHandler)
    EventsOn(`ssh:${tab.sessionId}:stderr`, stderrHandler)

    return () => {
      EventsOff(`ssh:${tab.sessionId}:stdout`)
      EventsOff(`ssh:${tab.sessionId}:stderr`)
    }
  }, [tab.sessionId])

  // 窗口大小变化 → 通知后端调整 PTY 大小
  useEffect(() => {
    const resizeObserver = new ResizeObserver(() => {
      fitAddon.fit()
      ResizeTerminal({ sessionId: tab.sessionId, rows: term.rows, cols: term.cols })
    })
    resizeObserver.observe(terminalRef.current)
  }, [isActive])
}

数据流图:

┌───────────────────────────────────────────────────┐
│ React Frontend                                     │
│                                                    │
│  用户输入 ──→ term.onData() ──→ WriteToSession()   │
│                                   │                │
│                           Wails Binding             │
│                                   ↓                │
│  term.write() ←── EventsOn() ←── Go 后端           │
│       │                           ↑                │
│       ↓                    stdin.Write(data)        │
│   xterm 渲染                     │                │
│                            SSH Channel → 远程服务器  │
└───────────────────────────────────────────────────┘

3.4 多标签管理

wsShell 支持同一服务器打开多个终端标签。核心思路是复用 SSH 连接,创建新 Session:

func (s *SSHService) CreateShell(req CreateShellRequest) (CreateShellResponse, error) {
    // 复用已有的 SSH Client,创建新的 Session
    client := s.resolveClient(req.BaseSessionID)

    session, _ := client.NewSession()  // 一个 Client 可以创建多个 Session
    session.RequestPty("xterm-256color", 40, 120, modes)
    session.Shell()

    // 新标签的 ID 格式:root@host:22#2
    sessionID := fmt.Sprintf("%s#%d", req.BaseSessionID, len(s.sessions)+1)

    // 同样启动 goroutine 读取输出...
}

前端使用 Zustand 管理标签状态:

interface TerminalTabState {
  terminalTabs: TerminalTab[]
  activeTerminalTabId: string | null
  addTerminalTab: (tab: TerminalTab) => void
  removeTerminalTab: (tabId: string) => void
}

四、SFTP 文件管理

4.1 设计思路

SFTP 模块的核心是复用 SSH 连接来打开 SFTP 子系统:

func (m *SFTPManager) Connect(req ConnectRequest) (ConnectResponse, error) {
    // 1. 建立 SSH 连接
    sshClient, _ := sshcrypto.Dial("tcp", addr, config)

    // 2. 在 SSH 连接上打开 SFTP 子系统
    sftpClient, _ := sftp.NewClient(sshClient)

    // 3. 存储映射关系
    m.clients[sessionID] = sftpClient
    m.sshClients[sessionID] = sshClient
}

4.2 文件列表(ListFiles)

func (m *SFTPManager) ListFiles(req ListFilesRequest) (ListFilesResponse, error) {
    client := m.clients[req.SessionID]
    entries, _ := client.ReadDir(path)  // 读取远程目录

    files := []FileInfo{}
    for _, entry := range entries {
        files = append(files, FileInfo{
            Name:    entry.Name(),
            Size:    entry.Size(),
            Type:    fileType,          // "file" 或 "directory"
            Path:    fullPath,
            ModTime: entry.ModTime().Format("2006-01-02 15:04:05"),
        })
    }
    return ListFilesResponse{Success: true, Files: files}, nil
}

Go 的时间格式化 "2006-01-02 15:04:05" 是 Go 的独特设计——用参考时间来定义格式。

4.3 文件上传与进度推送

func (m *SFTPManager) UploadFile(req UploadRequest) (UploadResponse, error) {
    localFile, _ := os.Open(req.LocalPath)
    remoteFile, _ := client.Create(req.RemotePath)

    buf := make([]byte, 32768)  // 32KB 缓冲区
    var written int64
    total := stat.Size()

    for {
        nr, readErr := localFile.Read(buf)
        if nr > 0 {
            nw, _ := remoteFile.Write(buf[:nr])
            written += int64(nw)

            // 推送上传进度到前端
            if m.Ctx != nil && total > 0 {
                progress := float64(written) / float64(total) * 100
                runtime.EventsEmit(m.Ctx, "sftp:upload:progress", map[string]interface{}{
                    "progress":   progress,
                    "written":    written,
                    "total":      total,
                })
            }
        }
        if readErr == io.EOF { break }
    }
}

4.4 断点续传

断点续传是 wsShell 的亮点功能之一。核心原理:

已传输 50MB / 总 100MB → 连接断开 → 检测远程文件已有 50MB → 从 50MB 处继续
func (m *SFTPManager) ResumeUpload(req ResumeUploadRequest) (ResumeUploadResponse, error) {
    // 获取远程已存在的文件大小
    remoteStat, _ := client.Stat(req.RemotePath)
    offset := remoteStat.Size()

    // 本地文件 Seek 到 offset 位置
    localFile.Seek(offset, io.SeekStart)

    // 远程文件以追加模式打开
    remoteFile, _ := client.OpenFile(req.RemotePath, os.O_WRONLY|os.O_APPEND)

    // 从 offset 开始传输...
}

五、凭证加密存储

5.1 安全架构

用户密码/私钥
    ↓ AES-256-GCM 加密
密文存储到 SQLite
    ↑ AES-256-GCM 解密
读取时还原明文
    ↑
Master Key(32字节)
    ↓ 存储于
Windows: %LOCALAPPDATA%/wsShell/master.key
macOS/Linux: ~/.wsShell/keys/master.key

5.2 AES-256-GCM 实现

func Encrypt(plaintext string) (string, error) {
    block, _ := aes.NewCipher(masterKey)     // AES-256,需要 32 字节密钥
    aesGCM, _ := cipher.NewGCM(block)        // GCM 模式提供认证加密

    nonce := make([]byte, aesGCM.NonceSize()) // 12 字节随机 nonce
    io.ReadFull(rand.Reader, nonce)

    // Seal 将 nonce 前置到密文中:nonce + ciphertext + tag
    ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)
    return base64.StdEncoding.EncodeToString(ciphertext), nil
}

func Decrypt(encoded string) (string, error) {
    ciphertext, _ := base64.StdEncoding.DecodeString(encoded)

    block, _ := aes.NewCipher(masterKey)
    aesGCM, _ := cipher.NewGCM(block)

    nonceSize := aesGCM.NonceSize()
    nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
    plaintext, _ := aesGCM.Open(nil, nonce, ciphertext, nil)

    return string(plaintext), nil
}

为什么选择 AES-GCM 而不是 AES-CBC?

特性 AES-CBC AES-GCM
认证 需要额外 HMAC 内置认证标签
填充 需要 PKCS#7 填充 无需填充
安全性 容易被 padding oracle 攻击 不存在此问题
Go 标准库 cipher.NewCBCEncrypter cipher.NewGCM

六、SQLite 持久化

6.1 数据库初始化

wsShell 使用 modernc.org/sqlite(纯 Go 实现的 SQLite),不需要 CGO:

func GetDB() (*sql.DB, error) {
    once.Do(func() {  // 单例模式,只初始化一次
        homeDir, _ := os.UserHomeDir()
        configDir := filepath.Join(homeDir, ".wsShell")
        os.MkdirAll(configDir, 0755)

        dbPath := filepath.Join(configDir, "wsShell.db")
        db, dbErr = sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
        db.SetMaxOpenConns(1)  // SQLite 单写特性
        dbErr = runMigrations(db)
    })
    return db, nil
}

关键配置:

  • _journal_mode=WAL:Write-Ahead Logging,允许读写并发
  • _busy_timeout=5000:写冲突时等待 5 秒而非直接报错
  • SetMaxOpenConns(1):SQLite 不支持多连接并发写

6.2 Repository 模式

type ServerRepository interface {
    GetAll() ([]ServerRow, error)
    GetByID(id string) (*ServerRow, error)
    Save(s ServerRow) error
    Update(s ServerRow) error
    Delete(id string) error
    ToggleFavorite(id string) error
}

使用接口而非直接实现,好处是:

  • 业务层(ConfigManager)不依赖具体数据库
  • 方便单元测试(可以 mock Repository)
  • 未来如果切换数据库,只需实现新 Repository

6.3 数据库迁移

func runMigrations(db *sql.DB) error {
    migrations := []string{
        `CREATE TABLE IF NOT EXISTS servers (
            id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            host TEXT NOT NULL,
            port INTEGER DEFAULT 22,
            password TEXT DEFAULT '',      -- 加密存储
            private_key TEXT DEFAULT '',   -- 加密存储
            favorite INTEGER DEFAULT 0,
            tags TEXT DEFAULT '[]',        -- JSON 数组
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        )`,
        `CREATE TABLE IF NOT EXISTS groups (...)`,
        `CREATE TABLE IF NOT EXISTS host_keys (...)`,
    }
    for _, m := range migrations {
        db.Exec(m)
    }
}

七、VNC 远程桌面代理

7.1 设计思路

VNC 连接的安全性是个问题——VNC 协议本身不加密。wsShell 的解决方案是 SSH 隧道代理

noVNC (前端) → WebSocket → Go Proxy → SSH 隧道 → 远程 VNC 服务器

7.2 WebSocket 代理实现

func (p *Proxy) StartProxy(req StartProxyRequest) (StartProxyResponse, error) {
    // 1. 在本地随机端口启动 HTTP 服务器
    listener, _ := net.Listen("tcp", "127.0.0.1:0")
    localPort := listener.Addr().(*net.TCPAddr).Port

    // 2. 注册 WebSocket 路由
    mux := http.NewServeMux()
    mux.HandleFunc("/websockify", func(w http.ResponseWriter, r *http.Request) {
        ps.handleWebSocket(w, r)
    })

    // 3. 启动服务
    go srv.Serve(listener)

    // 4. 返回 WebSocket URL 给前端
    wsURL := fmt.Sprintf("ws://127.0.0.1:%d/websockify", localPort)
}

7.3 WebSocket ↔ TCP 双向转发

func (ps *proxyServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
    wsConn, _ := upgrader.Upgrade(w, r, nil)  // 升级为 WebSocket

    // 通过 SSH 隧道连接远程 VNC
    conn, _ := ps.sshClient.Dial("tcp", ps.target)

    // 双向数据转发
    go func() {
        // TCP → WebSocket
        buf := make([]byte, 32*1024)
        for {
            n, _ := conn.Read(buf)
            wsConn.WriteMessage(websocket.BinaryMessage, buf[:n])
        }
    }()

    go func() {
        // WebSocket → TCP
        for {
            _, reader, _ := wsConn.NextReader()
            io.Copy(conn, reader)
        }
    }()
}

为什么要用 Go 做代理,而不是直接让前端连 VNC?

  1. 安全性:VNC 流量通过 SSH 加密
  2. 跨域限制:浏览器 WebSocket 不能直连远程 TCP
  3. 灵活性:Go 可以控制连接生命周期、添加认证

八、前端状态管理

8.1 Zustand Store 设计

wsShell 使用 Zustand 管理前端状态,分为四个独立的 Store:

// UI 状态(当前标签、主题、对话框)
const useUIStore = create<UIState>((set) => ({
  activeTab: 'terminal',
  activeServerId: null,
  showAddServerDialog: false,
}))

// 连接状态(服务器列表、SSH/SFTP 会话映射)
const useConnectionStore = create<ConnectionState>((set, get) => ({
  servers: [],
  connections: new Map<string, ConnectionInfo>(),
  sftpSessions: new Map<string, string>(),
}))

// 文件传输状态(上传/下载任务列表、进度)
const useTransferStore = create<TransferState>((set) => ({
  transfers: [],
}))

// 终端标签状态(多标签管理)
const useTerminalTabStore = create<TerminalTabState>((set, get) => ({
  terminalTabs: [],
  activeTerminalTabId: null,
}))

为什么选择 Zustand 而不是 Redux?

特性 Redux Zustand
模板代码 多(action、reducer、dispatch) 少(直接 set)
包大小 ~7KB ~1KB
异步处理 需要 redux-thunk/saga 原生支持
学习曲线 陡峭 平缓

九、开发与构建

9.1 环境搭建

# 1. 安装 Go(1.18+)
go version

# 2. 安装 Node.js(16+)
node --version

# 3. 安装 Wails CLI
go install github.com/wailsapp/wails/v2/cmd/wails@latest
wails doctor  # 检查环境

9.2 开发模式

# 克隆项目
git clone https://github.com/Julyos-rgb/wsShell.git
cd wsShell

# 安装前端依赖
cd frontend && npm install && cd ..

# 启动开发模式(支持热重载)
wails dev

wails dev 会同时启动:

  • Go 后端(热重载)
  • Vite 前端开发服务器(HMR)
  • Wails DevTools(调试窗口)

9.3 构建发布

wails build

构建产物在 build/bin/ 目录,是一个独立的可执行文件,无需安装运行时。


十、项目亮点与设计模式总结

10.1 设计模式一览

模式 应用场景 代码位置
单例模式 SQLite 数据库连接(sync.Once store/sqlite.go
Repository 模式 数据访问层抽象 store/server_repository.go
依赖注入 VNC Proxy 注入 SSH Provider app.go
观察者模式 Wails Events 事件订阅 全项目
并发安全 sync.RWMutex 保护共享状态 SSH、SFTP、VNC
Goroutine + Channel SSH stdin 非阻塞写入 ssh/ssh_service.go

10.2 值得学习的技术决策

  1. modernc.org/sqlite 而非 mattn/go-sqlite3

    • 纯 Go 实现,不需要 CGO,交叉编译更简单
    • 缺点是性能略低,但对于桌面应用完全够用
  2. Session ID 设计:user@host:port

    • 直观可读
    • 天然唯一(同一用户不会重复连接同一服务器)
    • 多标签通过 #2 后缀区分
  3. 32KB 缓冲区

    • SFTP 传输使用 32KB 缓冲区
    • 平衡了内存使用和传输效率
    • 同时避免大缓冲区导致的进度更新不细腻
  4. 前端不存储明文密码

    • 密码仅在需要连接时从加密存储中解密
    • Zustand Store 中只保存连接信息,不保存凭证

十一、扩展方向

如果你想基于 wsShell 继续开发,这里有一些建议:

入门级

  • 添加主题切换(深色/浅色)
  • 支持服务器搜索/过滤
  • 添加终端快捷键(复制粘贴、清屏)
  • 支持导出/导入服务器配置

进阶级

  • 实现 HostKey 指纹验证(替代 InsecureIgnoreHostKey
  • 添加 SFTP 文件预览(图片、文本)
  • 支持批量文件操作
  • 添加 SSH 跳板机(ProxyJump)支持

高级

  • 多窗口支持(独立终端窗口)
  • 录制/回放终端操作
  • 实现 Docker 容器管理面板
  • 添加插件系统

结语

wsShell 虽然是一个轻量级项目,但涵盖了很多实战技术点:

  • Go 网络编程:SSH、SFTP、WebSocket、TCP
  • Go 并发编程:goroutine、channel、sync.RWMutex、sync.Once
  • 桌面应用开发:Wails 框架、前后端桥接
  • 前端工程:React、TypeScript、Zustand、xterm.js
  • 安全实践:AES-256-GCM 加密、凭证保护
  • 数据持久化:SQLite、Repository 模式、数据库迁移

希望这个项目和学习笔记对你有帮助!如果觉得不错,欢迎 Star ⭐ 支持一下。

项目地址:https://github.com/Julyos-rgb/wsShell

有任何问题欢迎在 GitHub Issues 中讨论交流!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐