hermes-swift-mac-app

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Hermes Swift Mac App Skill

Hermes Swift macOS应用技能

Skill by ara.so — Hermes Skills collection
ara.so提供的技能 — Hermes技能合集

Overview

概述

Hermes Agent is a native macOS desktop wrapper for Hermes Web UI. Built with Swift and WKWebView, it provides a standalone Mac app experience with no Electron or heavy dependencies. The app supports both local Hermes instances and remote servers via SSH tunneling.
Key features:
  • Native macOS app using WKWebView (requires macOS 12+)
  • Direct connection mode for local Hermes Web UI
  • SSH tunnel mode for remote servers
  • Clipboard integration (text and images)
  • System notifications for AI responses
  • Auto-update via Sparkle framework
  • Signed and notarized for Gatekeeper
Hermes Agent是Hermes Web UI的原生macOS桌面包装器。它使用Swift和WKWebView构建,提供独立的Mac应用体验,无需Electron或重型依赖项。该应用支持本地Hermes实例和通过SSH隧道连接远程服务器。
核心功能:
  • 基于WKWebView的原生macOS应用(要求macOS 12+)
  • 本地Hermes Web UI的直接连接模式
  • 远程服务器的SSH隧道模式
  • 剪贴板集成(文本和图片)
  • AI响应的系统通知
  • 基于Sparkle框架的自动更新
  • 已签名并通过Gatekeeper公证

Installation

安装

For End Users

面向终端用户

Download the latest DMG from releases:
bash
undefined
从发布页面下载最新DMG:
bash
undefined

Download Hermes-Agent-vX.X.X.dmg

下载 Hermes-Agent-vX.X.X.dmg

Open DMG and drag to Applications folder

打开DMG并拖拽到应用程序文件夹

undefined
undefined

For Developers - Build from Source

面向开发者 - 从源码构建

bash
undefined
bash
undefined

Install Xcode Command Line Tools if needed

如有需要,安装Xcode命令行工具

xcode-select --install
xcode-select --install

Clone and build

克隆并构建

git clone https://github.com/hermes-webui/hermes-swift-mac.git cd hermes-swift-mac ./build.sh

The `build.sh` script:
1. Compiles the Swift code via `swift build -c release`
2. Creates the `.app` bundle structure
3. Converts `icon.png` to `AppIcon.icns`
4. Copies binaries and resources
5. Installs to `/Applications/Hermes Agent.app`
git clone https://github.com/hermes-webui/hermes-swift-mac.git cd hermes-swift-mac ./build.sh

`build.sh`脚本会执行以下操作:
1. 通过`swift build -c release`编译Swift代码
2. 创建`.app`包结构
3. 将`icon.png`转换为`AppIcon.icns`
4. 复制二进制文件和资源
5. 安装到`/Applications/Hermes Agent.app`

Project Structure

项目结构

Sources/HermesAgent/
├── main.swift                        # Entry point, signal handling
├── AppDelegate.swift                 # App lifecycle, menu bar, Sparkle updater
├── BrowserWindowController.swift     # WKWebView window, clipboard, notifications
├── TunnelManager.swift               # SSH tunnel management
├── PreferencesWindowController.swift # Settings UI
└── SplashWindowController.swift      # Launch splash screen

Package.swift                         # SPM manifest
build.sh                              # Build and install script
scripts/release.sh                    # Release automation
Sources/HermesAgent/
├── main.swift                        # 入口点、信号处理
├── AppDelegate.swift                 # 应用生命周期、菜单栏、Sparkle更新器
├── BrowserWindowController.swift     # WKWebView窗口、剪贴板、通知
├── TunnelManager.swift               # SSH隧道管理
├── PreferencesWindowController.swift # 设置UI
└── SplashWindowController.swift      # 启动闪屏

Package.swift                         # SPM清单
build.sh                              # 构建和安装脚本
scripts/release.sh                    # 发布自动化脚本

Configuration Modes

配置模式

Direct Mode (Local Hermes)

直接模式(本地Hermes)

For Hermes Web UI running on the same Mac:
Default settings:
  • Target URL:
    http://localhost:8787
  • Connection Mode: Direct
swift
// In PreferencesWindowController.swift
// Default URL stored in UserDefaults
UserDefaults.standard.string(forKey: "targetURL") ?? "http://localhost:8787"
适用于同一台Mac上运行的Hermes Web UI:
默认设置:
  • 目标URL:
    http://localhost:8787
  • 连接模式:直接
swift
// 在PreferencesWindowController.swift中
// 默认URL存储在UserDefaults中
UserDefaults.standard.string(forKey: "targetURL") ?? "http://localhost:8787"

SSH Tunnel Mode (Remote Hermes)

SSH隧道模式(远程Hermes)

For Hermes Web UI on a remote server:
Required settings:
  • Connection Mode: SSH Tunnel
  • Username: SSH user on remote server
  • Host: Server hostname or IP
  • Local Port: Port on Mac (default 8787)
  • Remote Port: Port where Hermes runs on server (default 8787)
SSH requirements:
  • Key-based authentication must work (
    ssh user@host
    without password)
  • ~/.ssh/known_hosts
    file accessible
  • No password authentication support
swift
// TunnelManager.swift constructs SSH command:
ssh -o StrictHostKeyChecking=accept-new \
    -o ExitOnForwardFailure=yes \
    -N -L \(localPort):localhost:\(remotePort) \
    \(username)@\(host)
适用于运行在远程服务器上的Hermes Web UI:
必填设置:
  • 连接模式:SSH隧道
  • 用户名:远程服务器上的SSH用户
  • 主机:服务器主机名或IP
  • 本地端口:Mac上的端口(默认8787)
  • 远程端口:服务器上Hermes运行的端口(默认8787)
SSH要求:
  • 必须支持基于密钥的认证(无需密码即可执行
    ssh user@host
  • 可访问
    ~/.ssh/known_hosts
    文件
  • 不支持密码认证
swift
// TunnelManager.swift构造SSH命令:
ssh -o StrictHostKeyChecking=accept-new \
    -o ExitOnForwardFailure=yes \
    -N -L \(localPort):localhost:\(remotePort) \
    \(username)@\(host)

Code Examples

代码示例

Reading User Preferences

读取用户偏好设置

swift
// From PreferencesWindowController.swift
let defaults = UserDefaults.standard

// Get connection mode
let mode = defaults.string(forKey: "connectionMode") ?? "direct"

// Get target URL
let targetURL = defaults.string(forKey: "targetURL") ?? "http://localhost:8787"

// Get SSH settings (tunnel mode)
let sshUsername = defaults.string(forKey: "sshUsername") ?? ""
let sshHost = defaults.string(forKey: "sshHost") ?? ""
let localPort = defaults.integer(forKey: "localPort")
let remotePort = defaults.integer(forKey: "remotePort")
swift
// 来自PreferencesWindowController.swift
let defaults = UserDefaults.standard

// 获取连接模式
let mode = defaults.string(forKey: "connectionMode") ?? "direct"

// 获取目标URL
let targetURL = defaults.string(forKey: "targetURL") ?? "http://localhost:8787"

// 获取SSH设置(隧道模式)
let sshUsername = defaults.string(forKey: "sshUsername") ?? ""
let sshHost = defaults.string(forKey: "sshHost") ?? ""
let localPort = defaults.integer(forKey: "localPort")
let remotePort = defaults.integer(forKey: "remotePort")

Starting SSH Tunnel

启动SSH隧道

swift
// From TunnelManager.swift
class TunnelManager {
    private var sshProcess: Process?
    
    func startTunnel(username: String, host: String, 
                     localPort: Int, remotePort: Int) {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
        process.arguments = [
            "-o", "StrictHostKeyChecking=accept-new",
            "-o", "ExitOnForwardFailure=yes",
            "-N",
            "-L", "\(localPort):localhost:\(remotePort)",
            "\(username)@\(host)"
        ]
        
        try? process.run()
        self.sshProcess = process
        
        // Monitor tunnel health
        monitorTunnel()
    }
    
    func stopTunnel() {
        sshProcess?.terminate()
        sshProcess = nil
    }
}
swift
// 来自TunnelManager.swift
class TunnelManager {
    private var sshProcess: Process?
    
    func startTunnel(username: String, host: String, 
                     localPort: Int, remotePort: Int) {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
        process.arguments = [
            "-o", "StrictHostKeyChecking=accept-new",
            "-o", "ExitOnForwardFailure=yes",
            "-N",
            "-L", "\(localPort):localhost:\(remotePort)",
            "\(username)@\(host)"
        ]
        
        try? process.run()
        self.sshProcess = process
        
        // 监控隧道状态
        monitorTunnel()
    }
    
    func stopTunnel() {
        sshProcess?.terminate()
        sshProcess = nil
    }
}

Testing Connection

测试连接

swift
// From PreferencesWindowController.swift
func testConnection(url: String, completion: @escaping (Bool) -> Void) {
    guard let testURL = URL(string: url) else {
        completion(false)
        return
    }
    
    var request = URLRequest(url: testURL)
    request.timeoutInterval = 5.0
    
    URLSession.shared.dataTask(with: request) { data, response, error in
        DispatchQueue.main.async {
            if let httpResponse = response as? HTTPURLResponse,
               httpResponse.statusCode == 200 {
                completion(true)
            } else {
                completion(false)
            }
        }
    }.resume()
}
swift
// 来自PreferencesWindowController.swift
func testConnection(url: String, completion: @escaping (Bool) -> Void) {
    guard let testURL = URL(string: url) else {
        completion(false)
        return
    }
    
    var request = URLRequest(url: testURL)
    request.timeoutInterval = 5.0
    
    URLSession.shared.dataTask(with: request) { data, response, error in
        DispatchQueue.main.async {
            if let httpResponse = response as? HTTPURLResponse,
               httpResponse.statusCode == 200 {
                completion(true)
            } else {
                completion(false)
            }
        }
    }.resume()
}

Clipboard Integration

剪贴板集成

swift
// From BrowserWindowController.swift
// Paste handler for text and images
@objc func handlePaste(_ sender: Any?) {
    let pasteboard = NSPasteboard.general
    
    if let image = pasteboard.readObjects(forClasses: [NSImage.self])?.first as? NSImage,
       let data = image.tiffRepresentation,
       let bitmap = NSBitmapImageRep(data: data),
       let pngData = bitmap.representation(using: .png, properties: [:]) {
        let base64 = pngData.base64EncodedString()
        let js = "window.pasteImage('data:image/png;base64,\(base64)');"
        webView.evaluateJavaScript(js)
    } else if let string = pasteboard.string(forType: .string) {
        let js = "window.pasteText(\(jsonEscape(string)));"
        webView.evaluateJavaScript(js)
    }
}
swift
// 来自BrowserWindowController.swift
// 处理文本和图片粘贴
@objc func handlePaste(_ sender: Any?) {
    let pasteboard = NSPasteboard.general
    
    if let image = pasteboard.readObjects(forClasses: [NSImage.self])?.first as? NSImage,
       let data = image.tiffRepresentation,
       let bitmap = NSBitmapImageRep(data: data),
       let pngData = bitmap.representation(using: .png, properties: [:]) {
        let base64 = pngData.base64EncodedString()
        let js = "window.pasteImage('data:image/png;base64,\(base64)');"
        webView.evaluateJavaScript(js)
    } else if let string = pasteboard.string(forType: .string) {
        let js = "window.pasteText(\(jsonEscape(string)));"
        webView.evaluateJavaScript(js)
    }
}

System Notifications

系统通知

swift
// From BrowserWindowController.swift
// Show notification when AI response completes
func showNotification(title: String, body: String) {
    let content = UNMutableNotificationContent()
    content.title = title
    content.body = body
    content.sound = .default
    
    let request = UNNotificationRequest(
        identifier: UUID().uuidString,
        content: content,
        trigger: nil
    )
    
    UNUserNotificationCenter.current().add(request)
}
swift
// 来自BrowserWindowController.swift
// AI响应完成时显示通知
func showNotification(title: String, body: String) {
    let content = UNMutableNotificationContent()
    content.title = title
    content.body = body
    content.sound = .default
    
    let request = UNNotificationRequest(
        identifier: UUID().uuidString,
        content: content,
        trigger: nil
    )
    
    UNUserNotificationCenter.current().add(request)
}

Auto-Update with Sparkle

基于Sparkle的自动更新

swift
// From AppDelegate.swift
import Sparkle

class AppDelegate: NSObject, NSApplicationDelegate {
    private var updaterController: SPUStandardUpdaterController?
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        // Initialize Sparkle updater
        updaterController = SPUStandardUpdaterController(
            startingUpdater: true,
            updaterDelegate: nil,
            userDriverDelegate: nil
        )
        
        // Check for updates on launch
        updaterController?.updater.checkForUpdatesInBackground()
    }
    
    @objc func checkForUpdates(_ sender: Any?) {
        updaterController?.updater.checkForUpdates()
    }
}
swift
// 来自AppDelegate.swift
import Sparkle

class AppDelegate: NSObject, NSApplicationDelegate {
    private var updaterController: SPUStandardUpdaterController?
    
    func applicationDidFinishLaunching(_ notification: Notification) {
        // 初始化Sparkle更新器
        updaterController = SPUStandardUpdaterController(
            startingUpdater: true,
            updaterDelegate: nil,
            userDriverDelegate: nil
        )
        
        // 启动时检查更新
        updaterController?.updater.checkForUpdatesInBackground()
    }
    
    @objc func checkForUpdates(_ sender: Any?) {
        updaterController?.updater.checkForUpdates()
    }
}

Building and Running

构建与运行

Development Build

开发构建

bash
undefined
bash
undefined

Build in debug mode

调试模式构建

swift build
swift build

Run directly

直接运行

swift run
swift run

Run tests

运行测试

swift test
undefined
swift test
undefined

Release Build

发布构建

bash
undefined
bash
undefined

Build release binary

构建发布版二进制文件

swift build -c release
swift build -c release

Or use build script (compiles + installs)

或使用构建脚本(编译+安装)

./build.sh

The build script handles:
- Release compilation
- App bundle creation at `HermesAgent.app/`
- Icon conversion (PNG → ICNS)
- Binary signing (if configured)
- Installation to `/Applications/`
./build.sh

构建脚本会处理:
- 发布版编译
- 在`HermesAgent.app/`创建应用包
- 图标转换(PNG → ICNS)
- 二进制签名(若已配置)
- 安装到`/Applications/`

Icon Generation

图标生成

bash
undefined
bash
undefined

Convert PNG to ICNS (done by build.sh)

将PNG转换为ICNS(由build.sh自动完成)

sips -s format icns icon.png --out AppIcon.icns
sips -s format icns icon.png --out AppIcon.icns

Or manually with iconutil

或使用iconutil手动转换

mkdir icon.iconset sips -z 16 16 icon.png --out icon.iconset/icon_16x16.png sips -z 32 32 icon.png --out icon.iconset/icon_16x16@2x.png sips -z 32 32 icon.png --out icon.iconset/icon_32x32.png sips -z 64 64 icon.png --out icon.iconset/icon_32x32@2x.png sips -z 128 128 icon.png --out icon.iconset/icon_128x128.png sips -z 256 256 icon.png --out icon.iconset/icon_128x128@2x.png sips -z 256 256 icon.png --out icon.iconset/icon_256x256.png sips -z 512 512 icon.png --out icon.iconset/icon_256x256@2x.png sips -z 512 512 icon.png --out icon.iconset/icon_512x512.png sips -z 1024 1024 icon.png --out icon.iconset/icon_512x512@2x.png iconutil -c icns icon.iconset
undefined
mkdir icon.iconset sips -z 16 16 icon.png --out icon.iconset/icon_16x16.png sips -z 32 32 icon.png --out icon.iconset/icon_16x16@2x.png sips -z 32 32 icon.png --out icon.iconset/icon_32x32.png sips -z 64 64 icon.png --out icon.iconset/icon_32x32@2x.png sips -z 128 128 icon.png --out icon.iconset/icon_128x128.png sips -z 256 256 icon.png --out icon.iconset/icon_128x128@2x.png sips -z 256 256 icon.png --out icon.iconset/icon_256x256.png sips -z 512 512 icon.png --out icon.iconset/icon_256x256@2x.png sips -z 512 512 icon.png --out icon.iconset/icon_512x512.png sips -z 1024 1024 icon.png --out icon.iconset/icon_512x512@2x.png iconutil -c icns icon.iconset
undefined

Releasing

发布流程

Create a Release

创建发布版本

bash
undefined
bash
undefined

Use release script (pushes main, then tag separately)

使用发布脚本(先推送main分支,再单独推送标签)

scripts/release.sh v1.0.9

**Why separate pushes?** GitHub sometimes drops workflow triggers when commit and tag are pushed together. The script:
1. Pushes `main` branch
2. Waits briefly
3. Pushes the tag separately

This ensures the GitHub Actions workflow fires reliably.
scripts/release.sh v1.0.9

**为何分开推送?** 当提交和标签一起推送时,GitHub有时会丢失工作流触发信号。该脚本会:
1. 推送`main`分支
2. 短暂等待
3. 单独推送标签

这能确保GitHub Actions工作流可靠触发。

Manual Release Process

手动发布流程

bash
undefined
bash
undefined

Tag the release

打发布标签

git tag v1.0.9 git push origin main git push origin v1.0.9
git tag v1.0.9 git push origin main git push origin v1.0.9

If workflow doesn't trigger, run manually:

如果工作流未触发,手动运行:

Actions → Build and Release macOS App → Run workflow → enter tag

操作 → Build and Release macOS App → 运行工作流 → 输入标签

undefined
undefined

GitHub Actions Workflow

GitHub Actions工作流

The
.github/workflows/build-release.yml
(not shown in README but typical) would:
  1. Build the app on
    macos-latest
  2. Sign with Developer ID certificate (from secrets)
  3. Notarize with Apple (requires Apple ID credentials)
  4. Create DMG with
    create-dmg
    or
    hdiutil
  5. Upload DMG as release asset
.github/workflows/build-release.yml
(未在README中展示但为标准配置)通常会:
  1. macos-latest
    环境构建应用
  2. 使用开发者ID证书签名(来自密钥库)
  3. 通过苹果公证(需要Apple ID凭据)
  4. 使用
    create-dmg
    hdiutil
    创建DMG
  5. 将DMG上传为发布资产

Common Patterns

常见模式

Checking Tunnel Status

检查隧道状态

swift
// From TunnelManager.swift
func isTunnelRunning() -> Bool {
    guard let process = sshProcess else { return false }
    return process.isRunning
}

func monitorTunnel() {
    // Check if local port is accessible
    let socket = CFSocketCreate(kCFAllocatorDefault, 
                                PF_INET, 
                                SOCK_STREAM, 
                                IPPROTO_TCP, 
                                0, nil, nil)
    // ... probe localhost:localPort
}
swift
// 来自TunnelManager.swift
func isTunnelRunning() -> Bool {
    guard let process = sshProcess else { return false }
    return process.isRunning
}

func monitorTunnel() {
    // 检查本地端口是否可访问
    let socket = CFSocketCreate(kCFAllocatorDefault, 
                                PF_INET, 
                                SOCK_STREAM, 
                                IPPROTO_TCP, 
                                0, nil, nil)
    // ... 探测localhost:localPort
}

Handling WebView Navigation

处理WebView导航

swift
// From BrowserWindowController.swift
extension BrowserWindowController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, 
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        
        // Open external links in Safari
        if let url = navigationAction.request.url,
           navigationAction.navigationType == .linkActivated {
            NSWorkspace.shared.open(url)
            decisionHandler(.cancel)
            return
        }
        
        decisionHandler(.allow)
    }
}
swift
// 来自BrowserWindowController.swift
extension BrowserWindowController: WKNavigationDelegate {
    func webView(_ webView: WKWebView, 
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        
        // 在Safari中打开外部链接
        if let url = navigationAction.request.url,
           navigationAction.navigationType == .linkActivated {
            NSWorkspace.shared.open(url)
            decisionHandler(.cancel)
            return
        }
        
        decisionHandler(.allow)
    }
}

Global Keyboard Shortcut

全局键盘快捷键

swift
// From AppDelegate.swift
func applicationDidFinishLaunching(_ notification: Notification) {
    // Register ⌘⇧H to bring app forward
    NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
        if event.modifierFlags.contains([.command, .shift]),
           event.charactersIgnoringModifiers == "h" {
            NSApp.activate(ignoringOtherApps: true)
        }
    }
}
swift
// 来自AppDelegate.swift
func applicationDidFinishLaunching(_ notification: Notification) {
    // 注册⌘⇧H快捷键以唤起应用
    NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
        if event.modifierFlags.contains([.command, .shift]),
           event.charactersIgnoringModifiers == "h" {
            NSApp.activate(ignoringOtherApps: true)
        }
    }
}

Troubleshooting

故障排除

Connection Error on Launch

启动时连接错误

Symptom: Blank page or "Unable to connect" error
Solutions:
  1. Ensure Hermes Web UI is running:
    bash
    cd ~/hermes-webui-public
    bash start.sh
  2. Check Target URL in Preferences (⌘,)
  3. Use Test Connection button to verify
  4. Check Console.app for errors from "Hermes Agent"
症状: 空白页面或"无法连接"错误
解决方案:
  1. 确保Hermes Web UI正在运行:
    bash
    cd ~/hermes-webui-public
    bash start.sh
  2. 在偏好设置(⌘,)中检查目标URL
  3. 使用测试连接按钮验证
  4. 在Console.app中查看"Hermes Agent"的错误日志

SSH Tunnel Fails Immediately

SSH隧道立即失败

Symptom: Status shows "Disconnected" right after connecting
Solutions:
  1. Test SSH key auth works:
    bash
    ssh user@your-server
    # Should connect without password
  2. Set up SSH keys if needed:
    bash
    ssh-keygen -t ed25519
    ssh-copy-id user@your-server
  3. Verify remote port is correct (where Hermes actually runs)
  4. Check
    ~/.ssh/known_hosts
    isn't corrupted
症状: 连接后状态立即显示"已断开"
解决方案:
  1. 测试SSH密钥认证是否正常:
    bash
    ssh user@your-server
    # 应无需密码即可连接
  2. 如有需要,设置SSH密钥:
    bash
    ssh-keygen -t ed25519
    ssh-copy-id user@your-server
  3. 验证远程端口是否正确(Hermes实际运行的端口)
  4. 检查
    ~/.ssh/known_hosts
    是否损坏

Voice Input Not Working

语音输入无法工作

Symptom: Microphone button doesn't respond
Solutions:
  1. Grant microphone permission:
    • System Settings → Privacy & Security → Microphone
    • Enable "Hermes Agent"
  2. Restart app after granting permission
  3. Check Info.plist includes
    NSMicrophoneUsageDescription
症状: 麦克风按钮无响应
解决方案:
  1. 授予麦克风权限:
    • 系统设置 → 隐私与安全性 → 麦克风
    • 启用"Hermes Agent"
  2. 授予权限后重启应用
  3. 检查Info.plist是否包含
    NSMicrophoneUsageDescription

Gatekeeper Blocks App

Gatekeeper阻止应用

Symptom: "App can't be opened because it is from an unidentified developer"
Solutions:
  1. Download latest release (v1.0.4+) — these are signed and notarized
  2. If building from source without signing:
    bash
    xattr -cr /Applications/Hermes\ Agent.app
  3. Right-click app → Open (first time only)
症状: "无法打开应用,因为它来自未识别的开发者"
解决方案:
  1. 下载最新版本(v1.0.4+)——这些版本已签名并通过公证
  2. 如果从源码构建且未签名:
    bash
    xattr -cr /Applications/Hermes\ Agent.app
  3. 右键点击应用 → 打开(仅首次需要)

Blurry App Icon

应用图标模糊

Symptom: Icon looks pixelated in Dock after building
Solution:
bash
undefined
症状: 构建后Dock中的图标显示像素化
解决方案:
bash
undefined

Refresh icon cache

刷新图标缓存

killall Dock
undefined
killall Dock
undefined

Port Already in Use

端口已被占用

Symptom: "Port forwarding failed" when starting tunnel
Solutions:
bash
undefined
症状: 启动隧道时显示"端口转发失败"
解决方案:
bash
undefined

Find what's using the port

查找占用端口的进程

lsof -i :8787
lsof -i :8787

Kill the process or change Local Port in Preferences

终止进程或在偏好设置中修改本地端口

undefined
undefined

Environment Variables

环境变量

The app doesn't use environment variables directly, but for development:
bash
undefined
应用本身不直接使用环境变量,但开发时可使用:
bash
undefined

Enable WebKit debug logging

启用WebKit调试日志

export WEBKIT_DEBUG=1
export WEBKIT_DEBUG=1

Sparkle update feed (set in Info.plist normally)

Sparkle更新源(通常在Info.plist中设置)

export SPARKLE_APPCAST_URL=https://example.com/appcast.xml
undefined
export SPARKLE_APPCAST_URL=https://example.com/appcast.xml
undefined

Testing

测试

bash
undefined
bash
undefined

Run all tests

运行所有测试

swift test
swift test

Run specific test

运行特定测试

swift test --filter TunnelManagerTests
swift test --filter TunnelManagerTests

Run with verbose output

带详细输出运行测试

swift test --verbose
undefined
swift test --verbose
undefined

Example Test

测试示例

swift
// From Tests/HermesAgentTests/TunnelManagerTests.swift
import XCTest
@testable import HermesAgent

final class TunnelManagerTests: XCTestCase {
    func testTunnelCreation() {
        let manager = TunnelManager()
        XCTAssertFalse(manager.isTunnelRunning())
        
        manager.startTunnel(
            username: "test",
            host: "localhost",
            localPort: 8787,
            remotePort: 8787
        )
        
        // Wait briefly for process to start
        sleep(1)
        XCTAssertTrue(manager.isTunnelRunning())
        
        manager.stopTunnel()
        XCTAssertFalse(manager.isTunnelRunning())
    }
}
swift
// 来自Tests/HermesAgentTests/TunnelManagerTests.swift
import XCTest
@testable import HermesAgent

final class TunnelManagerTests: XCTestCase {
    func testTunnelCreation() {
        let manager = TunnelManager()
        XCTAssertFalse(manager.isTunnelRunning())
        
        manager.startTunnel(
            username: "test",
            host: "localhost",
            localPort: 8787,
            remotePort: 8787
        )
        
        // 短暂等待进程启动
        sleep(1)
        XCTAssertTrue(manager.isTunnelRunning())
        
        manager.stopTunnel()
        XCTAssertFalse(manager.isTunnelRunning())
    }
}

Dependencies

依赖项

Managed via Swift Package Manager in
Package.swift
:
swift
// Package.swift
dependencies: [
    .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.3.0")
],
targets: [
    .executableTarget(
        name: "HermesAgent",
        dependencies: [
            .product(name: "Sparkle", package: "Sparkle")
        ]
    )
]
Runtime dependencies:
  • macOS 12 (Monterey) or later
  • Xcode Command Line Tools (for building)
  • SSH (provided by macOS)
No external dependencies for users — fully self-contained app.
通过
Package.swift
中的Swift Package Manager管理:
swift
// Package.swift
dependencies: [
    .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.3.0")
],
targets: [
    .executableTarget(
        name: "HermesAgent",
        dependencies: [
            .product(name: "Sparkle", package: "Sparkle")
        ]
    )
]
运行时依赖:
  • macOS 12(Monterey)或更高版本
  • Xcode命令行工具(用于构建)
  • SSH(由macOS提供)
终端用户无需额外依赖——应用完全独立。