Fix settings window not appearing from menu bar

Replace deprecated showSettingsWindow: selector with NSApp.openSettings() to properly show the Settings window when clicking "Settings..." in the menu bar. This aligns with the correct SwiftUI pattern used in VibeMeter.
This commit is contained in:
Peter Steinberger 2025-06-15 23:54:17 +02:00
parent b80c710c08
commit 702b623d7f
20 changed files with 721 additions and 725 deletions

View file

@ -10,7 +10,8 @@ let package = Package(
products: [
.library(
name: "VibeTunnel",
targets: ["VibeTunnel"])
targets: ["VibeTunnel"]
)
],
dependencies: [
.package(url: "https://github.com/realm/SwiftLint.git", from: "0.57.0"),

View file

@ -9,11 +9,6 @@
"filename" : "menubar@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "menubar@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {

View file

@ -1,15 +1,8 @@
//
// TunnelSession.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Hummingbird
/// Represents a terminal session that can be controlled remotely
public struct TunnelSession: Identifiable, Codable {
public struct TunnelSession: Identifiable, Codable, Sendable {
public let id: UUID
public let createdAt: Date
public var lastActivity: Date
@ -43,7 +36,7 @@ public struct CreateSessionRequest: Codable {
}
/// Response after creating a session
public struct CreateSessionResponse: Codable, ResponseEncodable {
public struct CreateSessionResponse: Codable, ResponseGenerator {
public let sessionId: String
public let createdAt: Date
@ -69,14 +62,20 @@ public struct CommandRequest: Codable {
}
/// Command execution response
public struct CommandResponse: Codable, ResponseEncodable {
public struct CommandResponse: Codable, ResponseGenerator {
public let sessionId: String
public let output: String?
public let error: String?
public let exitCode: Int32?
public let timestamp: Date
public init(sessionId: String, output: String? = nil, error: String? = nil, exitCode: Int32? = nil, timestamp: Date = Date()) {
public init(
sessionId: String,
output: String? = nil,
error: String? = nil,
exitCode: Int32? = nil,
timestamp: Date = Date()
) {
self.sessionId = sessionId
self.output = output
self.error = error
@ -86,7 +85,7 @@ public struct CommandResponse: Codable, ResponseEncodable {
}
/// Session information
public struct SessionInfo: Codable, ResponseEncodable {
public struct SessionInfo: Codable, ResponseGenerator {
public let id: String
public let createdAt: Date
public let lastActivity: Date
@ -101,7 +100,7 @@ public struct SessionInfo: Codable, ResponseEncodable {
}
/// List sessions response
public struct ListSessionsResponse: Codable, ResponseEncodable {
public struct ListSessionsResponse: Codable, ResponseGenerator {
public let sessions: [SessionInfo]
public init(sessions: [SessionInfo]) {

View file

@ -32,12 +32,16 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
public var appcastURL: URL {
switch self {
case .stable:
URL(string: "https://vibetunnel.sh/appcast.xml")!
Self.stableAppcastURL
case .prerelease:
URL(string: "https://vibetunnel.sh/appcast-prerelease.xml")!
Self.prereleaseAppcastURL
}
}
// Static URLs to ensure they're validated at compile time
private static let stableAppcastURL = URL(string: "https://vibetunnel.sh/appcast.xml")!
private static let prereleaseAppcastURL = URL(string: "https://vibetunnel.sh/appcast-prerelease.xml")!
/// Whether this channel includes pre-release versions
public var includesPreReleases: Bool {
switch self {
@ -49,21 +53,21 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
}
/// The current update channel based on user defaults
public static var current: UpdateChannel {
public static var current: Self {
if let rawValue = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: rawValue) {
let channel = Self(rawValue: rawValue) {
return channel
}
return defaultChannel
}
/// The default update channel based on the current app version
public static var defaultChannel: UpdateChannel {
public static var defaultChannel: Self {
defaultChannel(for: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
}
/// Determines if the current app version suggests this channel should be default
public static func defaultChannel(for appVersion: String) -> UpdateChannel {
public static func defaultChannel(for appVersion: String) -> Self {
// First check if this build was marked as a pre-release during build time
if let isPrereleaseValue = Bundle.main.object(forInfoDictionaryKey: "IS_PRERELEASE_BUILD"),
let isPrerelease = isPrereleaseValue as? Bool,

View file

@ -1,18 +1,11 @@
//
// AuthenticationMiddleware.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import CryptoKit
import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdCore
import HTTPTypes
import Logging
import CryptoKit
// Custom HTTP header name for API key
/// Custom HTTP header name for API key
extension HTTPField.Name {
static let xAPIKey = Self("X-API-Key")!
}
@ -22,7 +15,7 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
private let logger = Logger(label: "VibeTunnel.AuthMiddleware")
private let bearerPrefix = "Bearer "
// In production, this should be stored securely and configurable
/// In production, this should be stored securely and configurable
private let validApiKeys: Set<String>
init() {
@ -46,7 +39,12 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
self.validApiKeys = apiKeys
}
public func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
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)
@ -90,7 +88,8 @@ enum APIKeyManager {
static func loadStoredAPIKeys() -> Set<String> {
guard let data = UserDefaults.standard.data(forKey: apiKeyStorageKey),
let keys = try? JSONDecoder().decode(Set<String>.self, from: data) else {
let keys = try? JSONDecoder().decode(Set<String>.self, from: data)
else {
// Generate and store a default key if none exists
let defaultKey = AuthenticationMiddleware<BasicRequestContext>.generateAPIKey()
let keys = Set([defaultKey])

View file

@ -25,7 +25,8 @@ import UserNotifications
/// ```
@MainActor
@Observable
public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate {
public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate,
UNUserNotificationCenterDelegate {
// MARK: Initialization
private nonisolated static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
@ -75,7 +76,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
/// Checks for updates immediately
func checkForUpdates() {
guard let updaterController = updaterController else {
guard let updaterController else {
logger.warning("Updater controller not available")
return
}
@ -205,7 +206,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
}
// Show first reminder after 1 hour
DispatchQueue.main.asyncAfter(deadline: .now() + 3600) { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 3_600) { [weak self] in
Task { @MainActor in
self?.showGentleUpdateReminder()
}
@ -232,9 +233,9 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
}
}
// Provide the feed URL dynamically based on update channel
/// Provide the feed URL dynamically based on update channel
@objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
return UpdateChannel.current.appcastURL.absoluteString
UpdateChannel.current.appcastURL.absoluteString
}
// MARK: - SPUStandardUserDriverDelegate
@ -313,7 +314,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
// MARK: - KVO
public override func observeValue(
override public func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,

View file

@ -1,6 +1,6 @@
import Foundation
import ServiceManagement
import os
import ServiceManagement
/// Protocol defining the interface for managing launch at login functionality.
@MainActor
@ -31,7 +31,10 @@ public struct StartupManager: StartupControlling {
logger.info("Successfully unregistered for launch at login.")
}
} catch {
logger.error("Failed to \(enabled ? "register" : "unregister") for launch at login: \(error.localizedDescription)")
logger
.error(
"Failed to \(enabled ? "register" : "unregister") for launch at login: \(error.localizedDescription)"
)
}
}

View file

@ -1,13 +1,6 @@
//
// TerminalManager.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Combine
import Foundation
import Logging
import Combine
/// Manages terminal sessions and command execution
actor TerminalManager {
@ -61,7 +54,8 @@ actor TerminalManager {
guard var session = sessions[sessionId],
let process = processes[sessionId],
let (stdin, stdout, stderr) = pipes[sessionId],
process.isRunning else {
process.isRunning
else {
throw TunnelError.sessionNotFound
}
@ -70,7 +64,9 @@ actor TerminalManager {
sessions[sessionId] = session
// Send command to stdin
let commandData = (command + "\n").data(using: .utf8)!
guard let commandData = (command + "\n").data(using: .utf8) else {
throw TunnelError.commandExecutionFailed("Failed to encode command")
}
stdin.fileHandleForWriting.write(commandData)
// Read output with timeout
@ -90,12 +86,12 @@ actor TerminalManager {
/// Get all active sessions
func listSessions() -> [TunnelSession] {
return Array(sessions.values)
Array(sessions.values)
}
/// Get a specific session
func getSession(id: UUID) -> TunnelSession? {
return sessions[id]
sessions[id]
}
/// Close a session
@ -122,7 +118,7 @@ actor TerminalManager {
}
}
// Helper function for timeout
/// 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 {
@ -134,7 +130,9 @@ actor TerminalManager {
throw TunnelError.timeout
}
let result = try await group.next()!
guard let result = try await group.next() else {
throw TunnelError.timeout
}
group.cancelAll()
return result
}
@ -151,13 +149,13 @@ enum TunnelError: LocalizedError {
var errorDescription: String? {
switch self {
case .sessionNotFound:
return "Session not found"
"Session not found"
case .commandExecutionFailed(let message):
return "Command execution failed: \(message)"
"Command execution failed: \(message)"
case .timeout:
return "Operation timed out"
"Operation timed out"
case .invalidRequest:
return "Invalid request"
"Invalid request"
}
}
}

View file

@ -1,12 +1,5 @@
//
// TunnelClient.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Combine
import Foundation
/// Client SDK for interacting with the VibeTunnel server
public class TunnelClient {
@ -43,9 +36,12 @@ public class TunnelClient {
// MARK: - Session Management
public func createSession(workingDirectory: String? = nil,
public func createSession(
workingDirectory: String? = nil,
environment: [String: String]? = nil,
shell: String? = nil) async throws -> CreateSessionResponse {
shell: String? = nil
)
async throws -> CreateSessionResponse {
let url = baseURL.appendingPathComponent("sessions")
let request = CreateSessionRequest(
workingDirectory: workingDirectory,
@ -74,7 +70,12 @@ public class TunnelClient {
// MARK: - Command Execution
public func executeCommand(sessionId: String, command: String, args: [String]? = nil) async throws -> CommandResponse {
public func executeCommand(
sessionId: String,
command: String,
args: [String]? = nil
)
async throws -> CommandResponse {
let url = baseURL.appendingPathComponent("execute")
let request = CommandRequest(
sessionId: sessionId,
@ -113,7 +114,7 @@ public class TunnelClient {
return try decoder.decode(T.self, from: data)
}
private func post<T: Encodable, R: Decodable>(to url: URL, body: T) async throws -> R {
private func post<R: Decodable>(to url: URL, body: some Encodable) async throws -> R {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@ -176,7 +177,7 @@ public class TunnelWebSocketClient: NSObject {
webSocketTask?.resume()
// Send initial connection message if session ID is provided
if let sessionId = sessionId {
if let sessionId {
send(WSMessage(type: .connect, sessionId: sessionId))
}
@ -185,7 +186,7 @@ public class TunnelWebSocketClient: NSObject {
}
public func send(_ message: WSMessage) {
guard let webSocketTask = webSocketTask else { return }
guard let webSocketTask else { return }
do {
let data = try JSONEncoder().encode(message)
@ -193,7 +194,7 @@ public class TunnelWebSocketClient: NSObject {
let message = URLSessionWebSocketTask.Message.string(text)
webSocketTask.send(message) { error in
if let error = error {
if let error {
print("WebSocket send error: \(error)")
}
}
@ -203,7 +204,7 @@ public class TunnelWebSocketClient: NSObject {
}
public func sendCommand(_ command: String) {
guard let sessionId = sessionId else { return }
guard let sessionId else { return }
send(WSMessage(type: .command, sessionId: sessionId, data: command))
}
@ -242,11 +243,20 @@ public class TunnelWebSocketClient: NSObject {
// MARK: - URLSessionWebSocketDelegate
extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
public func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?
) {
print("WebSocket connected")
}
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
public func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
) {
print("WebSocket disconnected")
messageSubject.send(completion: .finished)
}
@ -262,11 +272,11 @@ public enum TunnelClientError: LocalizedError {
public var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from server"
"Invalid response from server"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
"HTTP error: \(statusCode)"
case .decodingError(let error):
return "Decoding error: \(error.localizedDescription)"
"Decoding error: \(error.localizedDescription)"
}
}
}

View file

@ -1,24 +1,17 @@
//
// TunnelServer.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import AppKit
import Combine
import Logging
import os
import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdCore
import HTTPTypes
import Logging
import NIOCore
import os
// MARK: - Response Models
/// Server info response
struct ServerInfoResponse: Codable, ResponseEncodable {
struct ServerInfoResponse: Codable, ResponseGenerator {
let name: String
let version: String
let uptime: TimeInterval
@ -37,7 +30,7 @@ final class TunnelServer: ObservableObject {
@Published var lastError: Error?
@Published var connectedClients = 0
init(port: Int = 8080) {
init(port: Int = 8_080) {
self.port = port
}
@ -123,12 +116,12 @@ final class TunnelServer: ObservableObject {
private func configureRoutes(_ router: Router<BasicRequestContext>) {
// Health check endpoint
router.get("/health") { request, context -> HTTPResponse.Status in
return .ok
router.get("/health") { _, _ -> HTTPResponse.Status in
.ok
}
// Server info endpoint
router.get("/info") { request, context async -> ServerInfoResponse in
router.get("/info") { _, _ async -> ServerInfoResponse in
let sessionCount = await self.terminalManager.listSessions().count
return ServerInfoResponse(
name: "VibeTunnel",
@ -142,7 +135,7 @@ final class TunnelServer: ObservableObject {
let sessions = router.group("sessions")
// List all sessions
sessions.get("/") { request, context async -> ListSessionsResponse in
sessions.get("/") { _, _ async -> ListSessionsResponse in
let sessions = await self.terminalManager.listSessions()
let sessionInfos = sessions.map { session in
SessionInfo(
@ -167,10 +160,11 @@ final class TunnelServer: ObservableObject {
}
// Get session info
sessions.get(":sessionId") { request, context async throws -> SessionInfo in
sessions.get(":sessionId") { _, context async throws -> SessionInfo in
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
let sessionId = UUID(uuidString: sessionIdString),
let session = await self.terminalManager.getSession(id: sessionId) else {
let session = await self.terminalManager.getSession(id: sessionId)
else {
throw HTTPError(.notFound)
}
@ -183,9 +177,10 @@ final class TunnelServer: ObservableObject {
}
// Close session
sessions.delete(":sessionId") { request, context async throws -> HTTPResponse.Status in
sessions.delete(":sessionId") { _, context async throws -> HTTPResponse.Status in
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
let sessionId = UUID(uuidString: sessionIdString) else {
let sessionId = UUID(uuidString: sessionIdString)
else {
throw HTTPError(.badRequest)
}
@ -219,7 +214,6 @@ final class TunnelServer: ObservableObject {
}
}
}
}
// MARK: - Integration with AppDelegate
@ -229,7 +223,7 @@ extension AppDelegate {
Task {
do {
let port = UserDefaults.standard.integer(forKey: "serverPort")
let tunnelServer = TunnelServer(port: port > 0 ? port : 8080)
let tunnelServer = TunnelServer(port: port > 0 ? port : 8_080)
// Store reference if needed
// self.tunnelServer = tunnelServer

View file

@ -1,16 +1,8 @@
//
// TunnelServerDemo.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Combine
import Foundation
/// Demo code showing how to use the VibeTunnel server
class TunnelServerDemo {
static func runDemo() async {
// Get the API key (in production, this should be managed securely)
let apiKeys = APIKeyManager.loadStoredAPIKeys()
@ -50,7 +42,6 @@ class TunnelServerDemo {
// Close the session
try await client.closeSession(id: session.sessionId)
print("Session closed")
} catch {
print("Demo error: \(error)")
}
@ -98,7 +89,6 @@ class TunnelServerDemo {
// Disconnect
wsClient.disconnect()
cancellable.cancel()
} catch {
print("WebSocket demo error: \(error)")
}
@ -107,45 +97,43 @@ class TunnelServerDemo {
// MARK: - cURL Examples
/*
Here are some example cURL commands to test the server:
# Set your API key
export API_KEY="your-api-key-here"
# Health check (no auth required)
curl http://localhost:8080/health
# Get server info
curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
# Create a new session
curl -X POST http://localhost:8080/sessions \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"workingDirectory": "/tmp",
"shell": "/bin/zsh"
}'
# List all sessions
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
# Execute a command
curl -X POST http://localhost:8080/execute \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "your-session-id",
"command": "ls -la"
}'
# Get session info
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
# Close a session
curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
# WebSocket connection (using websocat tool)
websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal
*/
// Here are some example cURL commands to test the server:
//
// # Set your API key
// export API_KEY="your-api-key-here"
//
// # Health check (no auth required)
// curl http://localhost:8080/health
//
// # Get server info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
//
// # Create a new session
// curl -X POST http://localhost:8080/sessions \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "workingDirectory": "/tmp",
// "shell": "/bin/zsh"
// }'
//
// # List all sessions
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
//
// # Execute a command
// curl -X POST http://localhost:8080/execute \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "sessionId": "your-session-id",
// "command": "ls -la"
// }'
//
// # Get session info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # Close a session
// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # WebSocket connection (using websocat tool)
// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal

View file

@ -1,14 +1,8 @@
//
// WebSocketHandler.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Hummingbird
import HummingbirdCore
import NIOCore
// import NIOWebSocket // TODO: This is available in swift-nio package
import Logging
@ -39,161 +33,162 @@ public struct WSMessage: Codable {
}
// TODO: Enable when HummingbirdWebSocket package is added
/*
/// 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: WebSocket, 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: WebSocket
var sessionId: UUID?
var isClosed = false
init(id: UUID, websocket: WebSocket) {
self.id = id
self.websocket = websocket
}
}
}
/// Extension to add WebSocket routes to the router
extension RouterBuilder {
mutating 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)
}
}
}
*/
// /// 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: WebSocket, 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: WebSocket
// var sessionId: UUID?
// var isClosed = false
//
// init(id: UUID, websocket: WebSocket) {
// self.id = id
// self.websocket = websocket
// }
// }
// }
//
// /// Extension to add WebSocket routes to the router
// extension RouterBuilder {
// mutating 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

@ -16,7 +16,8 @@ struct MaterialBackgroundModifier: ViewModifier {
content
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(material))
.fill(material)
)
}
}
@ -50,7 +51,8 @@ struct CardStyleModifier: ViewModifier {
init(
cornerRadius: CGFloat = 10,
horizontalPadding: CGFloat = 14,
verticalPadding: CGFloat = 10) {
verticalPadding: CGFloat = 10
) {
self.cornerRadius = cornerRadius
self.horizontalPadding = horizontalPadding
self.verticalPadding = verticalPadding
@ -66,15 +68,17 @@ struct CardStyleModifier: ViewModifier {
// MARK: - View Extensions
public extension View {
extension View {
/// Applies a material background with rounded corners.
///
/// - Parameters:
/// - cornerRadius: Corner radius for the rounded rectangle (default: 10)
/// - material: Material type to use (default: .thickMaterial)
func materialBackground(
public func materialBackground(
cornerRadius: CGFloat = 10,
material: Material = .thickMaterial) -> some View {
material: Material = .thickMaterial
)
-> some View {
modifier(MaterialBackgroundModifier(cornerRadius: cornerRadius, material: material))
}
@ -83,9 +87,11 @@ public extension View {
/// - Parameters:
/// - horizontal: Horizontal padding (default: 16)
/// - vertical: Vertical padding (default: 14)
func standardPadding(
public func standardPadding(
horizontal: CGFloat = 16,
vertical: CGFloat = 14) -> some View {
vertical: CGFloat = 14
)
-> some View {
modifier(StandardPaddingModifier(horizontal: horizontal, vertical: vertical))
}
@ -95,14 +101,17 @@ public extension View {
/// - cornerRadius: Corner radius for the card (default: 10)
/// - horizontalPadding: Horizontal padding (default: 14)
/// - verticalPadding: Vertical padding (default: 10)
func cardStyle(
public func cardStyle(
cornerRadius: CGFloat = 10,
horizontalPadding: CGFloat = 14,
verticalPadding: CGFloat = 10) -> some View {
verticalPadding: CGFloat = 10
)
-> some View {
modifier(CardStyleModifier(
cornerRadius: cornerRadius,
horizontalPadding: horizontalPadding,
verticalPadding: verticalPadding))
verticalPadding: verticalPadding
))
}
}

View file

@ -20,7 +20,8 @@ struct PressEventModifier: ViewModifier {
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in onPress() }
.onEnded { _ in onRelease() })
.onEnded { _ in onRelease() }
)
}
}
@ -30,7 +31,8 @@ struct PointingHandCursorModifier: ViewModifier {
content
.background(
CursorTrackingView()
.allowsHitTesting(false))
.allowsHitTesting(false)
)
}
}

View file

@ -62,7 +62,8 @@ struct AboutView: View {
HoverableLink(
url: "https://github.com/amantus-ai/vibetunnel/issues",
title: "Report an Issue",
icon: "exclamationmark.bubble")
icon: "exclamationmark.bubble"
)
HoverableLink(url: "https://x.com/steipete", title: "Follow @steipete on Twitter", icon: "bird")
}
}
@ -87,8 +88,12 @@ struct HoverableLink: View {
@State
private var isHovering = false
private var destinationURL: URL {
URL(string: url) ?? URL(fileURLWithPath: "/")
}
var body: some View {
Link(destination: URL(string: url)!) {
Link(destination: destinationURL) {
Label(title, systemImage: icon)
.underline(isHovering, color: .accentColor)
}
@ -126,7 +131,8 @@ struct InteractiveAppIcon: View {
color: shadowColor,
radius: shadowRadius,
x: 0,
y: shadowOffset)
y: shadowOffset
)
.animation(.easeInOut(duration: 0.2), value: isHovering)
.animation(.easeInOut(duration: 0.1), value: isPressed)
@ -144,7 +150,8 @@ struct InteractiveAppIcon: View {
}
.pressEvents(
onPress: { isPressed = true },
onRelease: { isPressed = false })
onRelease: { isPressed = false }
)
}
private var shadowColor: Color {

View file

@ -1,10 +1,3 @@
//
// SettingsView.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import SwiftUI
enum SettingsTab: String, CaseIterable {
@ -14,17 +7,17 @@ enum SettingsTab: String, CaseIterable {
var displayName: String {
switch self {
case .general: return "General"
case .advanced: return "Advanced"
case .about: return "About"
case .general: "General"
case .advanced: "Advanced"
case .about: "About"
}
}
var icon: String {
switch self {
case .general: return "gear"
case .advanced: return "gearshape.2"
case .about: return "info.circle"
case .general: "gear"
case .advanced: "gearshape.2"
case .about: "info.circle"
}
}
}
@ -125,7 +118,8 @@ struct GeneralSettingsView: View {
set: { newValue in
autostart = newValue
startupManager.setLaunchAtLogin(enabled: newValue)
})
}
)
}
private var showInDockBinding: Binding<Bool> {
@ -134,7 +128,8 @@ struct GeneralSettingsView: View {
set: { newValue in
showInDock = newValue
NSApp.setActivationPolicy(newValue ? .regular : .accessory)
})
}
)
}
}
@ -147,7 +142,7 @@ struct AdvancedSettingsView: View {
@StateObject private var tunnelServer: TunnelServer
init() {
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8080
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8_080
_tunnelServer = StateObject(wrappedValue: TunnelServer(port: port))
}
@ -213,7 +208,9 @@ struct AdvancedSettingsView: View {
.frame(width: 8, height: 8)
}
}
Text(tunnelServer.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped")
Text(tunnelServer
.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped"
)
.font(.caption)
.foregroundStyle(.secondary)
}
@ -227,8 +224,8 @@ struct AdvancedSettingsView: View {
.tint(tunnelServer.isRunning ? .red : .blue)
}
if tunnelServer.isRunning {
Link("Open in Browser", destination: URL(string: "http://localhost:\(serverPort)")!)
if tunnelServer.isRunning, let serverURL = URL(string: "http://localhost:\(serverPort)") {
Link("Open in Browser", destination: serverURL)
.font(.caption)
}
}
@ -279,7 +276,8 @@ struct AdvancedSettingsView: View {
object: nil,
userInfo: ["channel": newValue]
)
})
}
)
}
private func checkForUpdates() {

View file

@ -1,5 +1,5 @@
import SwiftUI
import AppKit
import SwiftUI
/// Window controller for the About window
final class AboutWindowController {

View file

@ -1,10 +1,3 @@
//
// NSApplication+OpenSettings.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import AppKit
extension NSApplication {

View file

@ -1,12 +1,5 @@
//
// VibeTunnelApp.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import SwiftUI
import AppKit
import SwiftUI
@main
struct VibeTunnelApp: App {
@ -43,7 +36,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let processInfo = ProcessInfo.processInfo
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?.contains("libMainThreadChecker.dylib") ?? false
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
.contains("libMainThreadChecker.dylib") ?? false
// Handle single instance check before doing anything else
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
@ -66,7 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// 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)
NSApp.openSettings()
}
}
}
@ -76,7 +70,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self,
selector: #selector(handleCheckForUpdatesNotification),
name: Notification.Name("checkForUpdates"),
object: nil)
object: nil
)
}
private func handleSingleInstanceCheck() {
@ -91,7 +86,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = "VibeTunnel is already running"
alert.informativeText = "Another instance of VibeTunnel is already running. This instance will now quit."
alert
.informativeText = "Another instance of VibeTunnel is already running. This instance will now quit."
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
@ -108,14 +104,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self,
selector: #selector(handleShowSettingsNotification),
name: Self.showSettingsNotification,
object: nil)
object: nil
)
}
/// Shows the Settings window when another VibeTunnel instance asks us to.
@objc
private func handleShowSettingsNotification(_ notification: Notification) {
NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
NSApp.openSettings()
}
@objc private func handleCheckForUpdatesNotification() {
@ -127,20 +124,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let processInfo = ProcessInfo.processInfo
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?.contains("libMainThreadChecker.dylib") ?? false
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
.contains("libMainThreadChecker.dylib") ?? false
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
DistributedNotificationCenter.default().removeObserver(
self,
name: Self.showSettingsNotification,
object: nil)
object: nil
)
}
// Remove update check notification observer
NotificationCenter.default.removeObserver(
self,
name: Notification.Name("checkForUpdates"),
object: nil)
object: nil
)
}
// MARK: - Status Item
@ -172,7 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
@objc private func showSettings() {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
NSApp.openSettings()
NSApp.activate(ignoringOtherApps: true)
}