mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-08 11:45:58 +00:00
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:
parent
79addfc861
commit
9c4454b454
14 changed files with 853 additions and 305 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
BIN
VibeTunnel/Assets.xcassets/menubar.iconset/icon_16x16.png
Normal file
BIN
VibeTunnel/Assets.xcassets/menubar.iconset/icon_16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 897 B |
BIN
VibeTunnel/Assets.xcassets/menubar.iconset/icon_32x32.png
Normal file
BIN
VibeTunnel/Assets.xcassets/menubar.iconset/icon_32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -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()
|
||||
}
|
||||
72
VibeTunnel/Core/Models/TunnelSession.swift
Normal file
72
VibeTunnel/Core/Models/TunnelSession.swift
Normal 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]
|
||||
}
|
||||
107
VibeTunnel/Core/Services/AuthenticationMiddleware.swift
Normal file
107
VibeTunnel/Core/Services/AuthenticationMiddleware.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
163
VibeTunnel/Core/Services/TerminalManager.swift
Normal file
163
VibeTunnel/Core/Services/TerminalManager.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
196
VibeTunnel/Core/Services/WebSocketHandler.swift
Normal file
196
VibeTunnel/Core/Services/WebSocketHandler.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,8 @@
|
|||
<string>NSApplication</string>
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<true/>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSSupportsSuddenTermination</key>
|
||||
<true/>
|
||||
<key>SUFeedURL</key>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue