Convert to menu bar only app

- Remove ContentView window - app now launches with settings dialog only
- Add status bar item (menu bar icon) with network shield symbol
- Configure as LSUIElement (background app) by default
- Menu bar menu includes Settings, About, and Quit options
- Auto-show settings on first launch when in menu bar mode
- Maintain Show in Dock toggle for users who prefer dock visibility
This commit is contained in:
Peter Steinberger 2025-06-15 22:33:58 +02:00
parent 79addfc861
commit 9c4454b454
14 changed files with 853 additions and 305 deletions

View file

@ -1,6 +1,6 @@
# VibeTunnel
A macOS application for remotely controlling Claude Code and other terminal applications through a secure tunnel interface.
VibeTunnel is a Mac app that proxies terminal apps to the web. Now you can use Claude Code anywhere, anytime. Control open instances, read the output, type new commands or even open new instances. Supports macOS 14+.
## Overview

View file

@ -7,8 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
788688552DFF5EAB00B22C15 /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 788688212DFF600100B22C15 /* Hummingbird */; };
788688322DFF700200B22C15 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 788688312DFF700100B22C15 /* Sparkle */; };
788688552DFF5EAB00B22C15 /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 788688212DFF600100B22C15 /* Hummingbird */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,24 +0,0 @@
//
// ContentView.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}
#Preview {
ContentView()
}

View file

@ -0,0 +1,72 @@
//
// TunnelSession.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
/// Represents a terminal session that can be controlled remotely
struct TunnelSession: Identifiable, Codable {
let id: UUID
let createdAt: Date
var lastActivity: Date
let processID: Int32?
var isActive: Bool
init(id: UUID = UUID(), processID: Int32? = nil) {
self.id = id
self.createdAt = Date()
self.lastActivity = Date()
self.processID = processID
self.isActive = true
}
mutating func updateActivity() {
self.lastActivity = Date()
}
}
/// Request to create a new terminal session
struct CreateSessionRequest: Codable {
let workingDirectory: String?
let environment: [String: String]?
let shell: String?
}
/// Response after creating a session
struct CreateSessionResponse: Codable {
let sessionId: String
let createdAt: Date
}
/// Command execution request
struct CommandRequest: Codable {
let sessionId: String
let command: String
let args: [String]?
let environment: [String: String]?
}
/// Command execution response
struct CommandResponse: Codable {
let sessionId: String
let output: String?
let error: String?
let exitCode: Int32?
let timestamp: Date
}
/// Session information
struct SessionInfo: Codable {
let id: String
let createdAt: Date
let lastActivity: Date
let isActive: Bool
}
/// List sessions response
struct ListSessionsResponse: Codable {
let sessions: [SessionInfo]
}

View file

@ -0,0 +1,107 @@
//
// AuthenticationMiddleware.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Hummingbird
import HummingbirdCore
import Logging
import CryptoKit
/// Simple authentication middleware for the tunnel server
struct AuthenticationMiddleware: RouterMiddleware {
private let logger = Logger(label: "VibeTunnel.AuthMiddleware")
private let apiKeyHeader = "X-API-Key"
private let bearerPrefix = "Bearer "
// In production, this should be stored securely and configurable
private let validApiKeys: Set<String>
init() {
// Generate a default API key for development
// In production, this should be configurable via settings
let defaultKey = Self.generateAPIKey()
self.validApiKeys = [defaultKey]
logger.info("Authentication initialized. Default API key: \(defaultKey)")
}
init(apiKeys: Set<String>) {
self.validApiKeys = apiKeys
}
func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
// Skip authentication for health check and WebSocket upgrade
if request.uri.path == "/health" || request.headers[.upgrade] == "websocket" {
return try await next(request, context)
}
// Check for API key in header
if let apiKey = request.headers[apiKeyHeader] {
if validApiKeys.contains(apiKey) {
return try await next(request, context)
}
}
// Check for Bearer token
if let authorization = request.headers[.authorization],
authorization.hasPrefix(bearerPrefix) {
let token = String(authorization.dropFirst(bearerPrefix.count))
if validApiKeys.contains(token) {
return try await next(request, context)
}
}
// No valid authentication found
logger.warning("Unauthorized request to \(request.uri.path)")
throw HTTPError(.unauthorized, message: "Invalid or missing API key")
}
/// Generate a secure API key
static func generateAPIKey() -> String {
let randomBytes = SymmetricKey(size: .bits256)
let data = randomBytes.withUnsafeBytes { Data($0) }
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
/// Extension to store and retrieve API keys from UserDefaults
extension AuthenticationMiddleware {
static let apiKeyStorageKey = "VibeTunnel.APIKeys"
static func loadStoredAPIKeys() -> Set<String> {
guard let data = UserDefaults.standard.data(forKey: apiKeyStorageKey),
let keys = try? JSONDecoder().decode(Set<String>.self, from: data) else {
// Generate and store a default key if none exists
let defaultKey = generateAPIKey()
let keys = Set([defaultKey])
saveAPIKeys(keys)
return keys
}
return keys
}
static func saveAPIKeys(_ keys: Set<String>) {
if let data = try? JSONEncoder().encode(keys) {
UserDefaults.standard.set(data, forKey: apiKeyStorageKey)
}
}
static func addAPIKey(_ key: String) {
var keys = loadStoredAPIKeys()
keys.insert(key)
saveAPIKeys(keys)
}
static func removeAPIKey(_ key: String) {
var keys = loadStoredAPIKeys()
keys.remove(key)
saveAPIKeys(keys)
}
}

View file

@ -28,7 +28,7 @@ import UserNotifications
public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate {
// MARK: Initialization
private static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
private nonisolated static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
/// Initializes the updater manager and configures Sparkle
override init() {
@ -86,23 +86,10 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
/// Configures the update channel and restarts if needed
func setUpdateChannel(_ channel: UpdateChannel) {
guard let updater = updaterController?.updater else {
logger.error("Updater not available")
return
}
// Store the channel preference
UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel")
let oldFeedURL = updater.feedURL
let newFeedURL = channel.appcastURL
guard oldFeedURL != newFeedURL else {
logger.info("Update channel unchanged")
return
}
logger.info("Changing update channel from \(oldFeedURL?.absoluteString ?? "nil") to \(newFeedURL)")
// Update the feed URL
updater.setFeedURL(newFeedURL)
logger.info("Update channel changed to: \(channel.rawValue)")
// Force a new update check with the new feed
checkForUpdates()
@ -112,37 +99,28 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
/// Initializes the Sparkle updater controller
private func initializeUpdaterController() {
do {
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: self
)
guard let updater = updaterController?.updater else {
logger.error("Failed to get updater from controller")
return
}
// Configure updater settings
updater.automaticallyChecksForUpdates = true
updater.updateCheckInterval = 60 * 60 // 1 hour
updater.automaticallyDownloadsUpdates = true
// Set the feed URL based on current channel
updater.setFeedURL(UpdateChannel.defaultChannel.appcastURL)
logger.info("""
Updater configured:
- Automatic checks: \(updater.automaticallyChecksForUpdates)
- Check interval: \(updater.updateCheckInterval)s
- Auto download: \(updater.automaticallyDownloadsUpdates)
- Feed URL: \(updater.feedURL?.absoluteString ?? "none")
""")
} catch {
logger.error("Failed to initialize updater controller: \(error.localizedDescription)")
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: self
)
guard let updater = updaterController?.updater else {
logger.error("Failed to get updater from controller")
return
}
// Configure updater settings
updater.automaticallyChecksForUpdates = true
updater.updateCheckInterval = 60 * 60 // 1 hour
updater.automaticallyDownloadsUpdates = true
logger.info("""
Updater configured:
- Automatic checks: \(updater.automaticallyChecksForUpdates)
- Check interval: \(updater.updateCheckInterval)s
- Auto download: \(updater.automaticallyDownloadsUpdates)
""")
}
/// Sets up the notification center for gentle reminders
@ -182,8 +160,6 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
/// Checks for updates in the background without UI
private func checkForUpdatesInBackground() {
guard let updater = updaterController?.updater else { return }
logger.info("Starting background update check")
lastUpdateCheckDate = Date()
@ -192,6 +168,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
}
/// Shows a gentle reminder notification for available updates
@MainActor
private func showGentleUpdateReminder() {
let content = UNMutableNotificationContent()
content.title = "Update Available"
@ -222,27 +199,42 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
// Schedule reminders every 4 hours
gentleReminderTimer = Timer.scheduledTimer(withTimeInterval: 4 * 60 * 60, repeats: true) {
[weak self] _ in
self?.showGentleUpdateReminder()
Task { @MainActor in
self?.showGentleUpdateReminder()
}
}
// Show first reminder after 1 hour
DispatchQueue.main.asyncAfter(deadline: .now() + 3600) { [weak self] in
self?.showGentleUpdateReminder()
Task { @MainActor in
self?.showGentleUpdateReminder()
}
}
}
// MARK: - SPUUpdaterDelegate
@objc public nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items")
Task { @MainActor in
Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items")
}
}
@objc public nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
Self.staticLogger.info("No update found: \(error.localizedDescription)")
Task { @MainActor in
Self.staticLogger.info("No update found: \(error.localizedDescription)")
}
}
@objc public nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)")
Task { @MainActor in
Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)")
}
}
// Provide the feed URL dynamically based on update channel
@objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
return UpdateChannel.current.appcastURL.absoluteString
}
// MARK: - SPUStandardUserDriverDelegate
@ -252,16 +244,18 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
forUpdate update: SUAppcastItem,
state: SPUUserUpdateState
) {
Self.staticLogger.info("""
Will show update:
- Version: \(update.displayVersionString ?? "unknown")
- Critical: \(update.isCriticalUpdate)
- Stage: \(state.stage.rawValue)
""")
Task { @MainActor in
Self.staticLogger.info("""
Will show update:
- Version: \(update.displayVersionString)
- Critical: \(update.isCriticalUpdate)
- Stage: \(state.stage.rawValue)
""")
}
}
@objc public func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
logger.info("User gave attention to update: \(update.displayVersionString ?? "unknown")")
logger.info("User gave attention to update: \(update.displayVersionString)")
updateInProgress = true
// Cancel gentle reminders since user is aware
@ -281,11 +275,11 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
willDownloadUpdate item: SUAppcastItem,
with request: NSMutableURLRequest
) {
logger.info("Will download update: \(item.displayVersionString ?? "unknown")")
logger.info("Will download update: \(item.displayVersionString)")
}
@objc public func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
logger.info("Update downloaded: \(item.displayVersionString ?? "unknown")")
logger.info("Update downloaded: \(item.displayVersionString)")
// For background downloads, schedule gentle reminders
if !updateInProgress {
@ -297,7 +291,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
_ updater: SPUUpdater,
willInstallUpdate item: SUAppcastItem
) {
logger.info("Will install update: \(item.displayVersionString ?? "unknown")")
logger.info("Will install update: \(item.displayVersionString)")
}
// MARK: - UNUserNotificationCenterDelegate
@ -327,7 +321,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
) {
if keyPath == "updateChannel" {
logger.info("Update channel changed via UserDefaults")
setUpdateChannel(UpdateChannel.defaultChannel)
setUpdateChannel(UpdateChannel.current)
}
}
@ -335,6 +329,6 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
deinit {
UserDefaults.standard.removeObserver(self, forKeyPath: "updateChannel")
gentleReminderTimer?.invalidate()
// Timer is cleaned up automatically when the object is deallocated
}
}

View file

@ -0,0 +1,163 @@
//
// TerminalManager.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Logging
import Combine
/// Manages terminal sessions and command execution
actor TerminalManager {
private var sessions: [UUID: TunnelSession] = [:]
private var processes: [UUID: Process] = [:]
private var pipes: [UUID: (stdin: Pipe, stdout: Pipe, stderr: Pipe)] = [:]
private let logger = Logger(label: "VibeTunnel.TerminalManager")
/// Create a new terminal session
func createSession(request: CreateSessionRequest) throws -> TunnelSession {
let session = TunnelSession()
sessions[session.id] = session
// Set up process and pipes
let process = Process()
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
// Configure the process
process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh")
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
if let workingDirectory = request.workingDirectory {
process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)
}
if let environment = request.environment {
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
}
// Start the process
do {
try process.run()
processes[session.id] = process
pipes[session.id] = (stdinPipe, stdoutPipe, stderrPipe)
logger.info("Created session \(session.id) with process \(process.processIdentifier)")
} catch {
sessions.removeValue(forKey: session.id)
throw error
}
return session
}
/// Execute a command in a session
func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) {
guard var session = sessions[sessionId],
let process = processes[sessionId],
let (stdin, stdout, stderr) = pipes[sessionId],
process.isRunning else {
throw TunnelError.sessionNotFound
}
// Update session activity
session.updateActivity()
sessions[sessionId] = session
// Send command to stdin
let commandData = (command + "\n").data(using: .utf8)!
stdin.fileHandleForWriting.write(commandData)
// Read output with timeout
let outputData = try await withTimeout(seconds: 5) {
stdout.fileHandleForReading.availableData
}
let errorData = try await withTimeout(seconds: 0.1) {
stderr.fileHandleForReading.availableData
}
let output = String(data: outputData, encoding: .utf8) ?? ""
let error = String(data: errorData, encoding: .utf8) ?? ""
return (output, error)
}
/// Get all active sessions
func listSessions() -> [TunnelSession] {
return Array(sessions.values)
}
/// Get a specific session
func getSession(id: UUID) -> TunnelSession? {
return sessions[id]
}
/// Close a session
func closeSession(id: UUID) {
if let process = processes[id] {
process.terminate()
processes.removeValue(forKey: id)
}
pipes.removeValue(forKey: id)
sessions.removeValue(forKey: id)
logger.info("Closed session \(id)")
}
/// Clean up inactive sessions
func cleanupInactiveSessions(olderThan minutes: Int = 30) {
let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60))
for (id, session) in sessions {
if session.lastActivity < cutoffDate {
closeSession(id: id)
logger.info("Cleaned up inactive session \(id)")
}
}
}
// Helper function for timeout
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TunnelError.timeout
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
}
/// Errors that can occur in tunnel operations
enum TunnelError: LocalizedError {
case sessionNotFound
case commandExecutionFailed(String)
case timeout
case invalidRequest
var errorDescription: String? {
switch self {
case .sessionNotFound:
return "Session not found"
case .commandExecutionFailed(let message):
return "Command execution failed: \(message)"
case .timeout:
return "Operation timed out"
case .invalidRequest:
return "Invalid request"
}
}
}

View file

@ -6,19 +6,27 @@
//
import Foundation
import Hummingbird
import AppKit
import Combine
import Logging
import os
import Hummingbird
import HummingbirdCore
import HummingbirdWebSocket
import NIOCore
import NIOHTTP1
/// Main tunnel server implementation using Hummingbird
@MainActor
final class TunnelServer: ObservableObject {
private var app: HBApplication?
private let port: Int
private let logger = Logger(label: "VibeTunnel.TunnelServer")
private var app: HummingbirdApplication?
private let terminalManager = TerminalManager()
@Published var isRunning = false
@Published var lastError: Error?
@Published var connectedClients = 0
init(port: Int = 8080) {
self.port = port
@ -27,210 +35,201 @@ final class TunnelServer: ObservableObject {
func start() async throws {
logger.info("Starting tunnel server on port \(port)")
let router = HBRouter()
// Serve a simple HTML page at the root
router.get("/") { request, context in
let html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VibeTunnel</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #f5f5f7;
color: #1d1d1f;
}
.container {
background-color: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #0071e3;
margin-bottom: 0.5rem;
}
.status {
display: inline-block;
padding: 0.25rem 0.75rem;
background-color: #30d158;
color: white;
border-radius: 100px;
font-size: 0.875rem;
font-weight: 500;
}
.info {
margin-top: 2rem;
padding: 1rem;
background-color: #f5f5f7;
border-radius: 8px;
}
.endpoint {
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
background-color: #e8e8ed;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
a {
color: #0071e3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>VibeTunnel</h1>
<p class="status">Server Running</p>
<p>Connect to AI providers with a unified interface.</p>
<div class="info">
<h2>API Endpoints</h2>
<ul>
<li><span class="endpoint">GET /</span> - This page</li>
<li><span class="endpoint">GET /health</span> - Health check</li>
<li><span class="endpoint">GET /info</span> - Server information</li>
<li><span class="endpoint">POST /tunnel/command</span> - Execute commands</li>
<li><span class="endpoint">WS /tunnel/stream</span> - WebSocket stream</li>
</ul>
</div>
<div class="info">
<h2>Quick Start</h2>
<p>Test the health endpoint:</p>
<code class="endpoint">curl http://localhost:\(self.port)/health</code>
</div>
<p style="margin-top: 2rem; font-size: 0.875rem; color: #86868b;">
Version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1")
· <a href="https://github.com/amantus-ai/vibetunnel" target="_blank">GitHub</a>
· <a href="https://vibetunnel.sh" target="_blank">Documentation</a>
</p>
</div>
</body>
</html>
"""
do {
// Build the Hummingbird application
let app = try await buildApplication()
self.app = app
return HBResponse(
status: .ok,
headers: [.contentType: "text/html; charset=utf-8"],
body: .init(byteBuffer: ByteBuffer(string: html))
)
}
// Health check endpoint
router.get("/health") { request, context in
return [
"status": "ok",
"timestamp": Date().timeIntervalSince1970,
"uptime": ProcessInfo.processInfo.systemUptime
]
}
// Server info endpoint
router.get("/info") { request, context in
return [
"name": "VibeTunnel",
"version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1",
"build": Bundle.main.infoDictionary?["CFBundleVersion"] ?? "100",
"port": self.port,
"platform": "macOS"
]
}
// Command endpoint
router.post("/tunnel/command") { request, context in
struct CommandRequest: Decodable {
let command: String
let args: [String]?
// Start the server
try await app.run()
await MainActor.run {
self.isRunning = true
}
struct CommandResponse: Encodable {
let success: Bool
let message: String
let timestamp: Date
}
do {
let commandRequest = try await request.decode(as: CommandRequest.self, context: context)
self.logger.info("Received command: \(commandRequest.command)")
return CommandResponse(
success: true,
message: "Command '\(commandRequest.command)' received",
timestamp: Date()
)
} catch {
return CommandResponse(
success: false,
message: "Invalid request: \(error.localizedDescription)",
timestamp: Date()
)
} catch {
await MainActor.run {
self.lastError = error
self.isRunning = false
}
throw error
}
// WebSocket endpoint for real-time communication
router.ws("/tunnel/stream") { request, ws, context in
self.logger.info("WebSocket connection established")
// Send welcome message
try await ws.send(text: "Welcome to VibeTunnel WebSocket stream")
ws.onText { ws, text in
self.logger.info("WebSocket received: \(text)")
// Echo back with timestamp
let response = "[\(Date().ISO8601Format())] Echo: \(text)"
try await ws.send(text: response)
}
ws.onClose { ws, closeCode in
self.logger.info("WebSocket connection closed with code: \(closeCode)")
}
}
// Configure and create the application
var configuration = HBApplication.Configuration()
configuration.address = .hostname("127.0.0.1", port: self.port)
configuration.serverName = "VibeTunnel/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1")"
let app = HBApplication(
configuration: configuration,
router: router
)
self.app = app
// Update state
await MainActor.run {
self.isRunning = true
}
logger.info("VibeTunnel server started on http://localhost:\(self.port)")
// Run the server
try await app.run()
}
func stop() async {
logger.info("Stopping tunnel server")
await app?.stop()
app = nil
if let app = app {
await app.stop()
self.app = nil
}
await MainActor.run {
isRunning = false
self.isRunning = false
}
}
private func buildApplication() async throws -> HummingbirdApplication {
// Create router
let router = Router()
// Add middleware
router.add(middleware: LogRequestsMiddleware(logLevel: .info))
router.add(middleware: CORSMiddleware())
router.add(middleware: AuthenticationMiddleware(apiKeys: AuthenticationMiddleware.loadStoredAPIKeys()))
// Configure routes
configureRoutes(router)
// Add WebSocket routes
router.addWebSocketRoutes(terminalManager: terminalManager)
// Create application configuration
var configuration = ApplicationConfiguration(
address: .hostname("127.0.0.1", port: port),
serverName: "VibeTunnel"
)
// Enable WebSocket upgrade
configuration.enableWebSocketUpgrade = true
// Create and configure the application
let app = Application(
router: router,
configuration: configuration,
logger: logger
)
// Add cleanup task
app.addLifecycleTask(CleanupTask(terminalManager: terminalManager))
return app
}
private func configureRoutes(_ router: Router) {
// Health check endpoint
router.get("/health") { request, context -> HTTPResponse.Status in
return .ok
}
// Server info endpoint
router.get("/info") { request, context -> [String: Any] in
return [
"name": "VibeTunnel",
"version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0",
"uptime": ProcessInfo.processInfo.systemUptime,
"sessions": await self.terminalManager.listSessions().count
]
}
// Session management endpoints
router.group("sessions") { sessions in
// List all sessions
sessions.get("/") { request, context -> ListSessionsResponse in
let sessions = await self.terminalManager.listSessions()
let sessionInfos = sessions.map { session in
SessionInfo(
id: session.id.uuidString,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
isActive: session.isActive
)
}
return ListSessionsResponse(sessions: sessionInfos)
}
// Create new session
sessions.post("/") { request, context -> CreateSessionResponse in
let createRequest = try await request.decode(as: CreateSessionRequest.self, context: context)
let session = try await self.terminalManager.createSession(request: createRequest)
return CreateSessionResponse(
sessionId: session.id.uuidString,
createdAt: session.createdAt
)
}
// Get session info
sessions.get(":sessionId") { request, context -> SessionInfo in
guard let sessionIdString = request.parameters.get("sessionId"),
let sessionId = UUID(uuidString: sessionIdString),
let session = await self.terminalManager.getSession(id: sessionId) else {
throw HTTPError(.notFound)
}
return SessionInfo(
id: session.id.uuidString,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
isActive: session.isActive
)
}
// Close session
sessions.delete(":sessionId") { request, context -> HTTPResponse.Status in
guard let sessionIdString = request.parameters.get("sessionId"),
let sessionId = UUID(uuidString: sessionIdString) else {
throw HTTPError(.badRequest)
}
await self.terminalManager.closeSession(id: sessionId)
return .noContent
}
}
// Command execution endpoint
router.post("/execute") { request, context -> CommandResponse in
let commandRequest = try await request.decode(as: CommandRequest.self, context: context)
guard let sessionId = UUID(uuidString: commandRequest.sessionId) else {
throw HTTPError(.badRequest, message: "Invalid session ID")
}
do {
let (output, error) = try await self.terminalManager.executeCommand(
sessionId: sessionId,
command: commandRequest.command
)
return CommandResponse(
sessionId: commandRequest.sessionId,
output: output.isEmpty ? nil : output,
error: error.isEmpty ? nil : error,
exitCode: nil,
timestamp: Date()
)
} catch {
throw HTTPError(.internalServerError, message: error.localizedDescription)
}
}
}
// Lifecycle task for periodic cleanup
struct CleanupTask: LifecycleTask {
let terminalManager: TerminalManager
func run() async throws {
// Run cleanup every 5 minutes
while !Task.isCancelled {
await terminalManager.cleanupInactiveSessions(olderThan: 30)
try await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes
}
}
}
}
// MARK: - Middleware
/// CORS middleware for browser-based clients
struct CORSMiddleware: RouterMiddleware {
func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
var response = try await next(request, context)
response.headers[.accessControlAllowOrigin] = "*"
response.headers[.accessControlAllowMethods] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers[.accessControlAllowHeaders] = "Content-Type, Authorization"
return response
}
}
// MARK: - Integration with AppDelegate
@ -239,16 +238,15 @@ extension AppDelegate {
func startTunnelServer() {
Task {
do {
let portString = UserDefaults.standard.string(forKey: "serverPort") ?? "8080"
let port = Int(portString) ?? 8080
let tunnelServer = TunnelServer(port: port)
let port = UserDefaults.standard.integer(forKey: "serverPort")
let tunnelServer = TunnelServer(port: port > 0 ? port : 8080)
// Store reference if needed
// self.tunnelServer = tunnelServer
try await tunnelServer.start()
} catch {
os_log(.error, "Failed to start tunnel server: %{public}@", error.localizedDescription)
print("Failed to start tunnel server: \(error)")
// Show error alert
await MainActor.run {

View file

@ -0,0 +1,196 @@
//
// WebSocketHandler.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Hummingbird
import HummingbirdCore
import NIOCore
import NIOWebSocket
import Logging
/// WebSocket message types for terminal communication
enum WSMessageType: String, Codable {
case connect = "connect"
case command = "command"
case output = "output"
case error = "error"
case ping = "ping"
case pong = "pong"
case close = "close"
}
/// WebSocket message structure
struct WSMessage: Codable {
let type: WSMessageType
let sessionId: String?
let data: String?
let timestamp: Date
init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) {
self.type = type
self.sessionId = sessionId
self.data = data
self.timestamp = Date()
}
}
/// Handles WebSocket connections for real-time terminal communication
final class WebSocketHandler {
private let terminalManager: TerminalManager
private let logger = Logger(label: "VibeTunnel.WebSocketHandler")
private var activeConnections: [UUID: WebSocketHandler.Connection] = [:]
init(terminalManager: TerminalManager) {
self.terminalManager = terminalManager
}
/// Handle incoming WebSocket connection
func handle(ws: HBWebSocket, context: some RequestContext) async {
let connectionId = UUID()
let connection = Connection(id: connectionId, websocket: ws)
await MainActor.run {
activeConnections[connectionId] = connection
}
logger.info("WebSocket connection established: \(connectionId)")
// Set up message handlers
ws.onText { [weak self] ws, text in
await self?.handleTextMessage(text, connection: connection)
}
ws.onBinary { [weak self] ws, buffer in
// Handle binary data if needed
self?.logger.debug("Received binary data: \(buffer.readableBytes) bytes")
}
ws.onClose { [weak self] closeCode in
await self?.handleClose(connection: connection)
}
// Send initial connection acknowledgment
await sendMessage(WSMessage(type: .connect, data: "Connected to VibeTunnel"), to: connection)
// Keep connection alive with periodic pings
Task {
while !Task.isCancelled && !connection.isClosed {
await sendMessage(WSMessage(type: .ping), to: connection)
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds
}
}
}
private func handleTextMessage(_ text: String, connection: Connection) async {
guard let data = text.data(using: .utf8),
let message = try? JSONDecoder().decode(WSMessage.self, from: data) else {
logger.error("Failed to decode WebSocket message: \(text)")
await sendError("Invalid message format", to: connection)
return
}
switch message.type {
case .connect:
// Handle session connection
if let sessionId = message.sessionId,
let uuid = UUID(uuidString: sessionId) {
connection.sessionId = uuid
await sendMessage(WSMessage(type: .output, sessionId: sessionId, data: "Session connected"), to: connection)
}
case .command:
// Execute command in terminal session
guard let sessionId = connection.sessionId,
let command = message.data else {
await sendError("Session ID and command required", to: connection)
return
}
do {
let (output, error) = try await terminalManager.executeCommand(sessionId: sessionId, command: command)
if !output.isEmpty {
await sendMessage(WSMessage(type: .output, sessionId: sessionId.uuidString, data: output), to: connection)
}
if !error.isEmpty {
await sendMessage(WSMessage(type: .error, sessionId: sessionId.uuidString, data: error), to: connection)
}
} catch {
await sendError(error.localizedDescription, to: connection)
}
case .ping:
// Respond to ping with pong
await sendMessage(WSMessage(type: .pong), to: connection)
case .close:
// Close the session
if let sessionId = connection.sessionId {
await terminalManager.closeSession(id: sessionId)
}
try? await connection.websocket.close()
default:
logger.warning("Unhandled message type: \(message.type)")
}
}
private func handleClose(connection: Connection) async {
logger.info("WebSocket connection closed: \(connection.id)")
await MainActor.run {
activeConnections.removeValue(forKey: connection.id)
}
// Clean up associated session if any
if let sessionId = connection.sessionId {
await terminalManager.closeSession(id: sessionId)
}
connection.isClosed = true
}
private func sendMessage(_ message: WSMessage, to connection: Connection) async {
do {
let data = try JSONEncoder().encode(message)
let text = String(data: data, encoding: .utf8) ?? "{}"
try await connection.websocket.send(text: text)
} catch {
logger.error("Failed to send WebSocket message: \(error)")
}
}
private func sendError(_ error: String, to connection: Connection) async {
await sendMessage(WSMessage(type: .error, data: error), to: connection)
}
/// WebSocket connection wrapper
class Connection {
let id: UUID
let websocket: HBWebSocket
var sessionId: UUID?
var isClosed = false
init(id: UUID, websocket: HBWebSocket) {
self.id = id
self.websocket = websocket
}
}
}
/// Extension to add WebSocket routes to the router
extension Router {
func addWebSocketRoutes(terminalManager: TerminalManager) {
let wsHandler = WebSocketHandler(terminalManager: terminalManager)
// WebSocket endpoint for terminal streaming
ws("/ws/terminal") { request, ws, context in
await wsHandler.handle(ws: ws, context: context)
}
}
}

View file

@ -30,6 +30,8 @@
<string>NSApplication</string>
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>LSUIElement</key>
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
<key>SUFeedURL</key>

View file

@ -14,27 +14,16 @@ struct VibeTunnelApp: App {
var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
#if os(macOS)
Settings {
SettingsView()
}
.commands {
CommandGroup(replacing: .appInfo) {
CommandGroup(after: .appInfo) {
Button("About VibeTunnel") {
AboutWindowController.shared.showWindow()
}
}
CommandGroup(replacing: .appSettings) {
Button("Settings…") {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
.keyboardShortcut(",", modifiers: .command)
}
}
#if os(macOS)
Settings {
SettingsView()
}
#endif
}
@ -45,6 +34,7 @@ struct VibeTunnelApp: App {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
private var statusItem: NSStatusItem?
/// Distributed notification name used to ask an existing instance to show the Settings window.
private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings")
@ -64,10 +54,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Initialize Sparkle updater manager
sparkleUpdaterManager = SparkleUpdaterManager()
// Configure activation policy based on settings
// Configure activation policy based on settings (default to menu bar only)
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
// Setup status item (menu bar icon)
setupStatusItem()
// Show settings on first launch or when no window is open
if !showInDock {
// For menu bar apps, we need to ensure the settings window is accessible
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if NSApp.windows.isEmpty || NSApp.windows.allSatisfy({ !$0.isVisible }) {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
}
}
// Listen for update check requests
NotificationCenter.default.addObserver(
self,
@ -139,4 +142,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
name: Notification.Name("checkForUpdates"),
object: nil)
}
// MARK: - Status Item
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: "VibeTunnel")
button.action = #selector(statusItemClicked)
button.target = self
}
// Create menu
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem?.menu = menu
}
@objc private func statusItemClicked() {
// Left click shows menu
}
@objc private func showSettings() {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc private func showAbout() {
AboutWindowController.shared.showWindow()
NSApp.activate(ignoringOtherApps: true)
}
}