Hermes Swift Mac App Skill
Skill by ara.so — Hermes Skills collection
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
Installation
For End Users
Download the latest DMG from releases:
# Visit https://github.com/hermes-webui/hermes-swift-mac/releases
# Download Hermes-Agent-vX.X.X.dmg
# Open DMG and drag to Applications folder
For Developers - Build from Source
# Install Xcode Command Line Tools if needed
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:
- Compiles the Swift code via
swift build -c release - Creates the
.appbundle structure - Converts
icon.pngtoAppIcon.icns - Copies binaries and resources
- Installs to
/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
Configuration Modes
Direct Mode (Local Hermes)
For Hermes Web UI running on the same Mac:
Default settings:
- Target URL:
http://localhost:8787 - Connection Mode: Direct
// In PreferencesWindowController.swift
// Default URL stored in UserDefaults
UserDefaults.standard.string(forKey: "targetURL") ?? "http://localhost:8787"
SSH Tunnel Mode (Remote 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@hostwithout password) ~/.ssh/known_hostsfile accessible- No password authentication support
// TunnelManager.swift constructs SSH command:
ssh -o StrictHostKeyChecking=accept-new \
-o ExitOnForwardFailure=yes \
-N -L \(localPort):localhost:\(remotePort) \
\(username)@\(host)
Code Examples
Reading User Preferences
// 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")
Starting SSH Tunnel
// 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
}
}
Testing Connection
// 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()
}
Clipboard Integration
// 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)
}
}
System Notifications
// 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)
}
Auto-Update with Sparkle
// 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()
}
}
Building and Running
Development Build
# Build in debug mode
swift build
# Run directly
swift run
# Run tests
swift test
Release Build
# Build release binary
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/
Icon Generation
# Convert PNG to ICNS (done by build.sh)
sips -s format icns icon.png --out AppIcon.icns
# Or manually with 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
Releasing
Create a Release
# Use release script (pushes main, then tag separately)
scripts/release.sh v1.0.9
Why separate pushes? GitHub sometimes drops workflow triggers when commit and tag are pushed together. The script:
- Pushes
mainbranch - Waits briefly
- Pushes the tag separately
This ensures the GitHub Actions workflow fires reliably.
Manual Release Process
# Tag the release
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
GitHub Actions Workflow
The .github/workflows/build-release.yml (not shown in README but typical) would:
- Build the app on
macos-latest - Sign with Developer ID certificate (from secrets)
- Notarize with Apple (requires Apple ID credentials)
- Create DMG with
create-dmgorhdiutil - Upload DMG as release asset
Common Patterns
Checking Tunnel Status
// 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
}
Handling WebView Navigation
// 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)
}
}
Global Keyboard Shortcut
// 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)
}
}
}
Troubleshooting
Connection Error on Launch
Symptom: Blank page or "Unable to connect" error
Solutions:
- Ensure Hermes Web UI is running:
cd ~/hermes-webui-public bash start.sh - Check Target URL in Preferences (⌘,)
- Use Test Connection button to verify
- Check Console.app for errors from "Hermes Agent"
SSH Tunnel Fails Immediately
Symptom: Status shows "Disconnected" right after connecting
Solutions:
- Test SSH key auth works:
ssh user@your-server # Should connect without password - Set up SSH keys if needed:
ssh-keygen -t ed25519 ssh-copy-id user@your-server - Verify remote port is correct (where Hermes actually runs)
- Check
~/.ssh/known_hostsisn't corrupted
Voice Input Not Working
Symptom: Microphone button doesn't respond
Solutions:
- Grant microphone permission:
- System Settings → Privacy & Security → Microphone
- Enable "Hermes Agent"
- Restart app after granting permission
- Check Info.plist includes
NSMicrophoneUsageDescription
Gatekeeper Blocks App
Symptom: "App can't be opened because it is from an unidentified developer"
Solutions:
- Download latest release (v1.0.4+) — these are signed and notarized
- If building from source without signing:
xattr -cr /Applications/Hermes\ Agent.app - Right-click app → Open (first time only)
Blurry App Icon
Symptom: Icon looks pixelated in Dock after building
Solution:
# Refresh icon cache
killall Dock
Port Already in Use
Symptom: "Port forwarding failed" when starting tunnel
Solutions:
# Find what's using the port
lsof -i :8787
# Kill the process or change Local Port in Preferences
Environment Variables
The app doesn't use environment variables directly, but for development:
# Enable WebKit debug logging
export WEBKIT_DEBUG=1
# Sparkle update feed (set in Info.plist normally)
export SPARKLE_APPCAST_URL=https://example.com/appcast.xml
Testing
# Run all tests
swift test
# Run specific test
swift test --filter TunnelManagerTests
# Run with verbose output
swift test --verbose
Example Test
// 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())
}
}
Dependencies
Managed via Swift Package Manager in Package.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.