Loading...
Loading...
Build and configure the native macOS desktop app wrapper for Hermes Web UI with Swift and WKWebView
npx skill4agent add aradotso/hermes-skills hermes-swift-mac-appSkill by ara.so — Hermes Skills collection
# Visit https://github.com/hermes-webui/hermes-swift-mac/releases
# Download Hermes-Agent-vX.X.X.dmg
# Open DMG and drag to Applications folder# 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.shbuild.shswift build -c release.appicon.pngAppIcon.icns/Applications/Hermes Agent.appSources/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 automationhttp://localhost:8787// In PreferencesWindowController.swift
// Default URL stored in UserDefaults
UserDefaults.standard.string(forKey: "targetURL") ?? "http://localhost:8787"ssh user@host~/.ssh/known_hosts// TunnelManager.swift constructs SSH command:
ssh -o StrictHostKeyChecking=accept-new \
-o ExitOnForwardFailure=yes \
-N -L \(localPort):localhost:\(remotePort) \
\(username)@\(host)// 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")// 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
}
}// 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()
}// 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)
}
}// 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)
}// 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()
}
}# Build in debug mode
swift build
# Run directly
swift run
# Run tests
swift test# Build release binary
swift build -c release
# Or use build script (compiles + installs)
./build.shHermesAgent.app//Applications/# 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# Use release script (pushes main, then tag separately)
scripts/release.sh v1.0.9main# 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/workflows/build-release.ymlmacos-latestcreate-dmghdiutil// 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
}// 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)
}
}// 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)
}
}
}cd ~/hermes-webui-public
bash start.shssh user@your-server
# Should connect without passwordssh-keygen -t ed25519
ssh-copy-id user@your-server~/.ssh/known_hostsNSMicrophoneUsageDescriptionxattr -cr /Applications/Hermes\ Agent.app# Refresh icon cache
killall Dock# Find what's using the port
lsof -i :8787
# Kill the process or change Local Port in Preferences# 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# Run all tests
swift test
# Run specific test
swift test --filter TunnelManagerTests
# Run with verbose output
swift test --verbose// 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())
}
}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")
]
)
]