hermes-swift-mac-app
Build and configure the native macOS desktop app wrapper for Hermes Web UI with Swift and WKWebView
How do I install this agent skill?
npx skills add https://github.com/reason-machines/hermes-skills --skill hermes-swift-mac-appIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides documentation and code for a native macOS wrapper for the Hermes Web UI. It includes standard development commands, SSH tunneling logic, and clipboard integration. No malicious patterns were detected.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
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.
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/reason-machines/hermes-skills/hermes-swift-mac-app">View hermes-swift-mac-app on skillZs</a>