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: [ products: [
.library( .library(
name: "VibeTunnel", name: "VibeTunnel",
targets: ["VibeTunnel"]) targets: ["VibeTunnel"]
)
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/realm/SwiftLint.git", from: "0.57.0"), .package(url: "https://github.com/realm/SwiftLint.git", from: "0.57.0"),
@ -28,4 +29,4 @@ let package = Package(
path: "VibeTunnelTests" path: "VibeTunnelTests"
) )
] ]
) )

View file

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

View file

@ -1,21 +1,14 @@
//
// TunnelSession.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation import Foundation
import Hummingbird import Hummingbird
/// Represents a terminal session that can be controlled remotely /// 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 id: UUID
public let createdAt: Date public let createdAt: Date
public var lastActivity: Date public var lastActivity: Date
public let processID: Int32? public let processID: Int32?
public var isActive: Bool public var isActive: Bool
public init(id: UUID = UUID(), processID: Int32? = nil) { public init(id: UUID = UUID(), processID: Int32? = nil) {
self.id = id self.id = id
self.createdAt = Date() self.createdAt = Date()
@ -23,7 +16,7 @@ public struct TunnelSession: Identifiable, Codable {
self.processID = processID self.processID = processID
self.isActive = true self.isActive = true
} }
public mutating func updateActivity() { public mutating func updateActivity() {
self.lastActivity = Date() self.lastActivity = Date()
} }
@ -34,7 +27,7 @@ public struct CreateSessionRequest: Codable {
public let workingDirectory: String? public let workingDirectory: String?
public let environment: [String: String]? public let environment: [String: String]?
public let shell: String? public let shell: String?
public init(workingDirectory: String? = nil, environment: [String: String]? = nil, shell: String? = nil) { public init(workingDirectory: String? = nil, environment: [String: String]? = nil, shell: String? = nil) {
self.workingDirectory = workingDirectory self.workingDirectory = workingDirectory
self.environment = environment self.environment = environment
@ -43,10 +36,10 @@ public struct CreateSessionRequest: Codable {
} }
/// Response after creating a session /// Response after creating a session
public struct CreateSessionResponse: Codable, ResponseEncodable { public struct CreateSessionResponse: Codable, ResponseGenerator {
public let sessionId: String public let sessionId: String
public let createdAt: Date public let createdAt: Date
public init(sessionId: String, createdAt: Date) { public init(sessionId: String, createdAt: Date) {
self.sessionId = sessionId self.sessionId = sessionId
self.createdAt = createdAt self.createdAt = createdAt
@ -59,7 +52,7 @@ public struct CommandRequest: Codable {
public let command: String public let command: String
public let args: [String]? public let args: [String]?
public let environment: [String: String]? public let environment: [String: String]?
public init(sessionId: String, command: String, args: [String]? = nil, environment: [String: String]? = nil) { public init(sessionId: String, command: String, args: [String]? = nil, environment: [String: String]? = nil) {
self.sessionId = sessionId self.sessionId = sessionId
self.command = command self.command = command
@ -69,14 +62,20 @@ public struct CommandRequest: Codable {
} }
/// Command execution response /// Command execution response
public struct CommandResponse: Codable, ResponseEncodable { public struct CommandResponse: Codable, ResponseGenerator {
public let sessionId: String public let sessionId: String
public let output: String? public let output: String?
public let error: String? public let error: String?
public let exitCode: Int32? public let exitCode: Int32?
public let timestamp: Date 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.sessionId = sessionId
self.output = output self.output = output
self.error = error self.error = error
@ -86,12 +85,12 @@ public struct CommandResponse: Codable, ResponseEncodable {
} }
/// Session information /// Session information
public struct SessionInfo: Codable, ResponseEncodable { public struct SessionInfo: Codable, ResponseGenerator {
public let id: String public let id: String
public let createdAt: Date public let createdAt: Date
public let lastActivity: Date public let lastActivity: Date
public let isActive: Bool public let isActive: Bool
public init(id: String, createdAt: Date, lastActivity: Date, isActive: Bool) { public init(id: String, createdAt: Date, lastActivity: Date, isActive: Bool) {
self.id = id self.id = id
self.createdAt = createdAt self.createdAt = createdAt
@ -101,10 +100,10 @@ public struct SessionInfo: Codable, ResponseEncodable {
} }
/// List sessions response /// List sessions response
public struct ListSessionsResponse: Codable, ResponseEncodable { public struct ListSessionsResponse: Codable, ResponseGenerator {
public let sessions: [SessionInfo] public let sessions: [SessionInfo]
public init(sessions: [SessionInfo]) { public init(sessions: [SessionInfo]) {
self.sessions = sessions self.sessions = sessions
} }
} }

View file

@ -7,7 +7,7 @@ import Foundation
public enum UpdateChannel: String, CaseIterable, Codable, Sendable { public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
case stable case stable
case prerelease case prerelease
/// Human-readable display name for the update channel /// Human-readable display name for the update channel
public var displayName: String { public var displayName: String {
switch self { switch self {
@ -17,7 +17,7 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
"Include Pre-releases" "Include Pre-releases"
} }
} }
/// Detailed description of what each channel includes /// Detailed description of what each channel includes
public var description: String { public var description: String {
switch self { switch self {
@ -27,17 +27,21 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
"Receive both stable releases and beta/pre-release versions" "Receive both stable releases and beta/pre-release versions"
} }
} }
/// The Sparkle appcast URL for this update channel /// The Sparkle appcast URL for this update channel
public var appcastURL: URL { public var appcastURL: URL {
switch self { switch self {
case .stable: case .stable:
URL(string: "https://vibetunnel.sh/appcast.xml")! Self.stableAppcastURL
case .prerelease: 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 /// Whether this channel includes pre-release versions
public var includesPreReleases: Bool { public var includesPreReleases: Bool {
switch self { switch self {
@ -47,38 +51,38 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
true true
} }
} }
/// The current update channel based on user defaults /// 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"), if let rawValue = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: rawValue) { let channel = Self(rawValue: rawValue) {
return channel return channel
} }
return defaultChannel return defaultChannel
} }
/// The default update channel based on the current app version /// 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") defaultChannel(for: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
} }
/// Determines if the current app version suggests this channel should be default /// 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 // First check if this build was marked as a pre-release during build time
if let isPrereleaseValue = Bundle.main.object(forInfoDictionaryKey: "IS_PRERELEASE_BUILD"), if let isPrereleaseValue = Bundle.main.object(forInfoDictionaryKey: "IS_PRERELEASE_BUILD"),
let isPrerelease = isPrereleaseValue as? Bool, let isPrerelease = isPrereleaseValue as? Bool,
isPrerelease { isPrerelease {
return .prerelease return .prerelease
} }
// Otherwise, check if the version string contains pre-release keywords // Otherwise, check if the version string contains pre-release keywords
let prereleaseKeywords = ["beta", "alpha", "rc", "pre", "dev"] let prereleaseKeywords = ["beta", "alpha", "rc", "pre", "dev"]
let lowercaseVersion = appVersion.lowercased() let lowercaseVersion = appVersion.lowercased()
for keyword in prereleaseKeywords where lowercaseVersion.contains(keyword) { for keyword in prereleaseKeywords where lowercaseVersion.contains(keyword) {
return .prerelease return .prerelease
} }
return .stable return .stable
} }
} }
@ -87,4 +91,4 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
extension UpdateChannel: Identifiable { extension UpdateChannel: Identifiable {
public var id: String { rawValue } public var id: String { rawValue }
} }

View file

@ -1,18 +1,11 @@
// import CryptoKit
// AuthenticationMiddleware.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation import Foundation
import HTTPTypes
import Hummingbird import Hummingbird
import HummingbirdCore import HummingbirdCore
import HTTPTypes
import Logging import Logging
import CryptoKit
// Custom HTTP header name for API key /// Custom HTTP header name for API key
extension HTTPField.Name { extension HTTPField.Name {
static let xAPIKey = Self("X-API-Key")! static let xAPIKey = Self("X-API-Key")!
} }
@ -21,14 +14,14 @@ extension HTTPField.Name {
struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware { struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
private let logger = Logger(label: "VibeTunnel.AuthMiddleware") private let logger = Logger(label: "VibeTunnel.AuthMiddleware")
private let bearerPrefix = "Bearer " 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> private let validApiKeys: Set<String>
init() { init() {
// Load API keys from storage // Load API keys from storage
var apiKeys = APIKeyManager.loadStoredAPIKeys() var apiKeys = APIKeyManager.loadStoredAPIKeys()
if apiKeys.isEmpty { if apiKeys.isEmpty {
// Generate a default API key for development // Generate a default API key for development
let defaultKey = Self.generateAPIKey() let defaultKey = Self.generateAPIKey()
@ -38,27 +31,32 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
} else { } else {
logger.info("Authentication initialized with \(apiKeys.count) stored API key(s)") logger.info("Authentication initialized with \(apiKeys.count) stored API key(s)")
} }
self.validApiKeys = apiKeys self.validApiKeys = apiKeys
} }
init(apiKeys: Set<String>) { init(apiKeys: Set<String>) {
self.validApiKeys = apiKeys 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 // Skip authentication for health check and WebSocket upgrade
if request.uri.path == "/health" || request.headers[.upgrade] == "websocket" { if request.uri.path == "/health" || request.headers[.upgrade] == "websocket" {
return try await next(request, context) return try await next(request, context)
} }
// Check for API key in header // Check for API key in header
if let apiKey = request.headers[.xAPIKey] { if let apiKey = request.headers[.xAPIKey] {
if validApiKeys.contains(apiKey) { if validApiKeys.contains(apiKey) {
return try await next(request, context) return try await next(request, context)
} }
} }
// Check for Bearer token // Check for Bearer token
if let authorization = request.headers[.authorization], if let authorization = request.headers[.authorization],
authorization.hasPrefix(bearerPrefix) { authorization.hasPrefix(bearerPrefix) {
@ -67,12 +65,12 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
return try await next(request, context) return try await next(request, context)
} }
} }
// No valid authentication found // No valid authentication found
logger.warning("Unauthorized request to \(request.uri.path)") logger.warning("Unauthorized request to \(request.uri.path)")
throw HTTPError(.unauthorized, message: "Invalid or missing API key") throw HTTPError(.unauthorized, message: "Invalid or missing API key")
} }
/// Generate a secure API key /// Generate a secure API key
static func generateAPIKey() -> String { static func generateAPIKey() -> String {
let randomBytes = SymmetricKey(size: .bits256) let randomBytes = SymmetricKey(size: .bits256)
@ -87,10 +85,11 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
/// API Key management utilities /// API Key management utilities
enum APIKeyManager { enum APIKeyManager {
static let apiKeyStorageKey = "VibeTunnel.APIKeys" static let apiKeyStorageKey = "VibeTunnel.APIKeys"
static func loadStoredAPIKeys() -> Set<String> { static func loadStoredAPIKeys() -> Set<String> {
guard let data = UserDefaults.standard.data(forKey: apiKeyStorageKey), 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 // Generate and store a default key if none exists
let defaultKey = AuthenticationMiddleware<BasicRequestContext>.generateAPIKey() let defaultKey = AuthenticationMiddleware<BasicRequestContext>.generateAPIKey()
let keys = Set([defaultKey]) let keys = Set([defaultKey])
@ -99,22 +98,22 @@ enum APIKeyManager {
} }
return keys return keys
} }
static func saveAPIKeys(_ keys: Set<String>) { static func saveAPIKeys(_ keys: Set<String>) {
if let data = try? JSONEncoder().encode(keys) { if let data = try? JSONEncoder().encode(keys) {
UserDefaults.standard.set(data, forKey: apiKeyStorageKey) UserDefaults.standard.set(data, forKey: apiKeyStorageKey)
} }
} }
static func addAPIKey(_ key: String) { static func addAPIKey(_ key: String) {
var keys = loadStoredAPIKeys() var keys = loadStoredAPIKeys()
keys.insert(key) keys.insert(key)
saveAPIKeys(keys) saveAPIKeys(keys)
} }
static func removeAPIKey(_ key: String) { static func removeAPIKey(_ key: String) {
var keys = loadStoredAPIKeys() var keys = loadStoredAPIKeys()
keys.remove(key) keys.remove(key)
saveAPIKeys(keys) saveAPIKeys(keys)
} }
} }

View file

@ -25,78 +25,79 @@ import UserNotifications
/// ``` /// ```
@MainActor @MainActor
@Observable @Observable
public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate { public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate,
UNUserNotificationCenterDelegate {
// MARK: Initialization // MARK: Initialization
private nonisolated 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 /// Initializes the updater manager and configures Sparkle
override init() { override init() {
super.init() super.init()
Self.staticLogger.info("Initializing SparkleUpdaterManager") Self.staticLogger.info("Initializing SparkleUpdaterManager")
// Initialize the updater controller // Initialize the updater controller
initializeUpdaterController() initializeUpdaterController()
// Set up notification center for gentle reminders // Set up notification center for gentle reminders
setupNotificationCenter() setupNotificationCenter()
// Listen for update channel changes // Listen for update channel changes
setupUpdateChannelListener() setupUpdateChannelListener()
Self.staticLogger Self.staticLogger
.info("SparkleUpdaterManager initialized. Updater controller initialization completed.") .info("SparkleUpdaterManager initialized. Updater controller initialization completed.")
// Only schedule startup update check in release builds // Only schedule startup update check in release builds
#if !DEBUG #if !DEBUG
scheduleStartupUpdateCheck() scheduleStartupUpdateCheck()
#endif #endif
} }
// MARK: Public // MARK: Public
// MARK: Properties // MARK: Properties
/// The shared singleton instance of the updater manager /// The shared singleton instance of the updater manager
static let shared = SparkleUpdaterManager() static let shared = SparkleUpdaterManager()
/// The Sparkle updater controller instance /// The Sparkle updater controller instance
private(set) var updaterController: SPUStandardUpdaterController? private(set) var updaterController: SPUStandardUpdaterController?
/// The logger instance for update events /// The logger instance for update events
private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates") private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
// Track update state // Track update state
private var updateInProgress = false private var updateInProgress = false
private var lastUpdateCheckDate: Date? private var lastUpdateCheckDate: Date?
private var gentleReminderTimer: Timer? private var gentleReminderTimer: Timer?
// MARK: Methods // MARK: Methods
/// Checks for updates immediately /// Checks for updates immediately
func checkForUpdates() { func checkForUpdates() {
guard let updaterController = updaterController else { guard let updaterController else {
logger.warning("Updater controller not available") logger.warning("Updater controller not available")
return return
} }
logger.info("Manual update check initiated") logger.info("Manual update check initiated")
updaterController.checkForUpdates(nil) updaterController.checkForUpdates(nil)
} }
/// Configures the update channel and restarts if needed /// Configures the update channel and restarts if needed
func setUpdateChannel(_ channel: UpdateChannel) { func setUpdateChannel(_ channel: UpdateChannel) {
// Store the channel preference // Store the channel preference
UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel") UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel")
logger.info("Update channel changed to: \(channel.rawValue)") logger.info("Update channel changed to: \(channel.rawValue)")
// Force a new update check with the new feed // Force a new update check with the new feed
checkForUpdates() checkForUpdates()
} }
// MARK: Private // MARK: Private
/// Initializes the Sparkle updater controller /// Initializes the Sparkle updater controller
private func initializeUpdaterController() { private func initializeUpdaterController() {
updaterController = SPUStandardUpdaterController( updaterController = SPUStandardUpdaterController(
@ -104,17 +105,17 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
updaterDelegate: self, updaterDelegate: self,
userDriverDelegate: self userDriverDelegate: self
) )
guard let updater = updaterController?.updater else { guard let updater = updaterController?.updater else {
logger.error("Failed to get updater from controller") logger.error("Failed to get updater from controller")
return return
} }
// Configure updater settings // Configure updater settings
updater.automaticallyChecksForUpdates = true updater.automaticallyChecksForUpdates = true
updater.updateCheckInterval = 60 * 60 // 1 hour updater.updateCheckInterval = 60 * 60 // 1 hour
updater.automaticallyDownloadsUpdates = true updater.automaticallyDownloadsUpdates = true
logger.info(""" logger.info("""
Updater configured: Updater configured:
- Automatic checks: \(updater.automaticallyChecksForUpdates) - Automatic checks: \(updater.automaticallyChecksForUpdates)
@ -122,11 +123,11 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
- Auto download: \(updater.automaticallyDownloadsUpdates) - Auto download: \(updater.automaticallyDownloadsUpdates)
""") """)
} }
/// Sets up the notification center for gentle reminders /// Sets up the notification center for gentle reminders
private func setupNotificationCenter() { private func setupNotificationCenter() {
UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().delegate = self
// Request notification permissions // Request notification permissions
Task { Task {
do { do {
@ -138,7 +139,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
} }
} }
} }
/// Sets up a listener for update channel changes /// Sets up a listener for update channel changes
private func setupUpdateChannelListener() { private func setupUpdateChannelListener() {
// Listen for channel changes via UserDefaults // Listen for channel changes via UserDefaults
@ -149,7 +150,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
context: nil context: nil
) )
} }
/// Schedules an update check after app startup /// Schedules an update check after app startup
private func scheduleStartupUpdateCheck() { private func scheduleStartupUpdateCheck() {
// Check for updates 5 seconds after app launch // Check for updates 5 seconds after app launch
@ -157,16 +158,16 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
self?.checkForUpdatesInBackground() self?.checkForUpdatesInBackground()
} }
} }
/// Checks for updates in the background without UI /// Checks for updates in the background without UI
private func checkForUpdatesInBackground() { private func checkForUpdatesInBackground() {
logger.info("Starting background update check") logger.info("Starting background update check")
lastUpdateCheckDate = Date() lastUpdateCheckDate = Date()
// Sparkle will check in the background when automaticallyChecksForUpdates is true // Sparkle will check in the background when automaticallyChecksForUpdates is true
// We don't need to explicitly call checkForUpdates for background checks // We don't need to explicitly call checkForUpdates for background checks
} }
/// Shows a gentle reminder notification for available updates /// Shows a gentle reminder notification for available updates
@MainActor @MainActor
private func showGentleUpdateReminder() { private func showGentleUpdateReminder() {
@ -174,13 +175,13 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
content.title = "Update Available" content.title = "Update Available"
content.body = "A new version of VibeTunnel is ready to install. Click to update now." content.body = "A new version of VibeTunnel is ready to install. Click to update now."
content.sound = .default content.sound = .default
let request = UNNotificationRequest( let request = UNNotificationRequest(
identifier: "update-reminder", identifier: "update-reminder",
content: content, content: content,
trigger: nil trigger: nil
) )
Task { Task {
do { do {
try await UNUserNotificationCenter.current().add(request) try await UNUserNotificationCenter.current().add(request)
@ -190,12 +191,12 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
} }
} }
} }
/// Schedules periodic gentle reminders for available updates /// Schedules periodic gentle reminders for available updates
private func scheduleGentleReminders() { private func scheduleGentleReminders() {
// Cancel any existing timer // Cancel any existing timer
gentleReminderTimer?.invalidate() gentleReminderTimer?.invalidate()
// Schedule reminders every 4 hours // Schedule reminders every 4 hours
gentleReminderTimer = Timer.scheduledTimer(withTimeInterval: 4 * 60 * 60, repeats: true) { gentleReminderTimer = Timer.scheduledTimer(withTimeInterval: 4 * 60 * 60, repeats: true) {
[weak self] _ in [weak self] _ in
@ -203,42 +204,42 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
self?.showGentleUpdateReminder() self?.showGentleUpdateReminder()
} }
} }
// Show first reminder after 1 hour // 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 Task { @MainActor in
self?.showGentleUpdateReminder() self?.showGentleUpdateReminder()
} }
} }
} }
// MARK: - SPUUpdaterDelegate // MARK: - SPUUpdaterDelegate
@objc public nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { @objc public nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
Task { @MainActor in Task { @MainActor in
Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items") Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items")
} }
} }
@objc public nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { @objc public nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
Task { @MainActor in Task { @MainActor in
Self.staticLogger.info("No update found: \(error.localizedDescription)") Self.staticLogger.info("No update found: \(error.localizedDescription)")
} }
} }
@objc public nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { @objc public nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
Task { @MainActor in Task { @MainActor in
Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)") Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)")
} }
} }
// 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? { @objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
return UpdateChannel.current.appcastURL.absoluteString UpdateChannel.current.appcastURL.absoluteString
} }
// MARK: - SPUStandardUserDriverDelegate // MARK: - SPUStandardUserDriverDelegate
@objc public nonisolated func standardUserDriverWillHandleShowingUpdate( @objc public nonisolated func standardUserDriverWillHandleShowingUpdate(
_ handleShowingUpdate: Bool, _ handleShowingUpdate: Bool,
forUpdate update: SUAppcastItem, forUpdate update: SUAppcastItem,
@ -253,23 +254,23 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
""") """)
} }
} }
@objc public func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { @objc public func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
logger.info("User gave attention to update: \(update.displayVersionString)") logger.info("User gave attention to update: \(update.displayVersionString)")
updateInProgress = true updateInProgress = true
// Cancel gentle reminders since user is aware // Cancel gentle reminders since user is aware
gentleReminderTimer?.invalidate() gentleReminderTimer?.invalidate()
gentleReminderTimer = nil gentleReminderTimer = nil
} }
@objc public func standardUserDriverWillFinishUpdateSession() { @objc public func standardUserDriverWillFinishUpdateSession() {
logger.info("Update session finishing") logger.info("Update session finishing")
updateInProgress = false updateInProgress = false
} }
// MARK: - Background update handling // MARK: - Background update handling
@objc public func updater( @objc public func updater(
_ updater: SPUUpdater, _ updater: SPUUpdater,
willDownloadUpdate item: SUAppcastItem, willDownloadUpdate item: SUAppcastItem,
@ -277,25 +278,25 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
) { ) {
logger.info("Will download update: \(item.displayVersionString)") logger.info("Will download update: \(item.displayVersionString)")
} }
@objc public func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { @objc public func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
logger.info("Update downloaded: \(item.displayVersionString)") logger.info("Update downloaded: \(item.displayVersionString)")
// For background downloads, schedule gentle reminders // For background downloads, schedule gentle reminders
if !updateInProgress { if !updateInProgress {
scheduleGentleReminders() scheduleGentleReminders()
} }
} }
@objc public func updater( @objc public func updater(
_ updater: SPUUpdater, _ updater: SPUUpdater,
willInstallUpdate item: SUAppcastItem willInstallUpdate item: SUAppcastItem
) { ) {
logger.info("Will install update: \(item.displayVersionString)") logger.info("Will install update: \(item.displayVersionString)")
} }
// MARK: - UNUserNotificationCenterDelegate // MARK: - UNUserNotificationCenterDelegate
@objc public func userNotificationCenter( @objc public func userNotificationCenter(
_ center: UNUserNotificationCenter, _ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse, didReceive response: UNNotificationResponse,
@ -303,17 +304,17 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
) { ) {
if response.notification.request.identifier == "update-reminder" { if response.notification.request.identifier == "update-reminder" {
logger.info("User clicked update reminder notification") logger.info("User clicked update reminder notification")
// Trigger the update UI // Trigger the update UI
checkForUpdates() checkForUpdates()
} }
completionHandler() completionHandler()
} }
// MARK: - KVO // MARK: - KVO
public override func observeValue( override public func observeValue(
forKeyPath keyPath: String?, forKeyPath keyPath: String?,
of object: Any?, of object: Any?,
change: [NSKeyValueChangeKey: Any]?, change: [NSKeyValueChangeKey: Any]?,
@ -324,11 +325,11 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
setUpdateChannel(UpdateChannel.current) setUpdateChannel(UpdateChannel.current)
} }
} }
// MARK: - Cleanup // MARK: - Cleanup
deinit { deinit {
UserDefaults.standard.removeObserver(self, forKeyPath: "updateChannel") UserDefaults.standard.removeObserver(self, forKeyPath: "updateChannel")
// Timer is cleaned up automatically when the object is deallocated // Timer is cleaned up automatically when the object is deallocated
} }
} }

View file

@ -1,6 +1,6 @@
import Foundation import Foundation
import ServiceManagement
import os import os
import ServiceManagement
/// Protocol defining the interface for managing launch at login functionality. /// Protocol defining the interface for managing launch at login functionality.
@MainActor @MainActor
@ -18,9 +18,9 @@ public protocol StartupControlling: Sendable {
@MainActor @MainActor
public struct StartupManager: StartupControlling { public struct StartupManager: StartupControlling {
private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "startup") private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "startup")
public init() {} public init() {}
public func setLaunchAtLogin(enabled: Bool) { public func setLaunchAtLogin(enabled: Bool) {
do { do {
if enabled { if enabled {
@ -31,11 +31,14 @@ public struct StartupManager: StartupControlling {
logger.info("Successfully unregistered for launch at login.") logger.info("Successfully unregistered for launch at login.")
} }
} catch { } 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)"
)
} }
} }
public var isLaunchAtLoginEnabled: Bool { public var isLaunchAtLoginEnabled: Bool {
SMAppService.mainApp.status == .enabled SMAppService.mainApp.status == .enabled
} }
} }

View file

@ -1,13 +1,6 @@
// import Combine
// TerminalManager.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation import Foundation
import Logging import Logging
import Combine
/// Manages terminal sessions and command execution /// Manages terminal sessions and command execution
actor TerminalManager { actor TerminalManager {
@ -15,89 +8,92 @@ actor TerminalManager {
private var processes: [UUID: Process] = [:] private var processes: [UUID: Process] = [:]
private var pipes: [UUID: (stdin: Pipe, stdout: Pipe, stderr: Pipe)] = [:] private var pipes: [UUID: (stdin: Pipe, stdout: Pipe, stderr: Pipe)] = [:]
private let logger = Logger(label: "VibeTunnel.TerminalManager") private let logger = Logger(label: "VibeTunnel.TerminalManager")
/// Create a new terminal session /// Create a new terminal session
func createSession(request: CreateSessionRequest) throws -> TunnelSession { func createSession(request: CreateSessionRequest) throws -> TunnelSession {
let session = TunnelSession() let session = TunnelSession()
sessions[session.id] = session sessions[session.id] = session
// Set up process and pipes // Set up process and pipes
let process = Process() let process = Process()
let stdinPipe = Pipe() let stdinPipe = Pipe()
let stdoutPipe = Pipe() let stdoutPipe = Pipe()
let stderrPipe = Pipe() let stderrPipe = Pipe()
// Configure the process // Configure the process
process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh") process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh")
process.standardInput = stdinPipe process.standardInput = stdinPipe
process.standardOutput = stdoutPipe process.standardOutput = stdoutPipe
process.standardError = stderrPipe process.standardError = stderrPipe
if let workingDirectory = request.workingDirectory { if let workingDirectory = request.workingDirectory {
process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)
} }
if let environment = request.environment { if let environment = request.environment {
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new } process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
} }
// Start the process // Start the process
do { do {
try process.run() try process.run()
processes[session.id] = process processes[session.id] = process
pipes[session.id] = (stdinPipe, stdoutPipe, stderrPipe) pipes[session.id] = (stdinPipe, stdoutPipe, stderrPipe)
logger.info("Created session \(session.id) with process \(process.processIdentifier)") logger.info("Created session \(session.id) with process \(process.processIdentifier)")
} catch { } catch {
sessions.removeValue(forKey: session.id) sessions.removeValue(forKey: session.id)
throw error throw error
} }
return session return session
} }
/// Execute a command in a session /// Execute a command in a session
func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) { func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) {
guard var session = sessions[sessionId], guard var session = sessions[sessionId],
let process = processes[sessionId], let process = processes[sessionId],
let (stdin, stdout, stderr) = pipes[sessionId], let (stdin, stdout, stderr) = pipes[sessionId],
process.isRunning else { process.isRunning
else {
throw TunnelError.sessionNotFound throw TunnelError.sessionNotFound
} }
// Update session activity // Update session activity
session.updateActivity() session.updateActivity()
sessions[sessionId] = session sessions[sessionId] = session
// Send command to stdin // 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) stdin.fileHandleForWriting.write(commandData)
// Read output with timeout // Read output with timeout
let outputData = try await withTimeout(seconds: 5) { let outputData = try await withTimeout(seconds: 5) {
stdout.fileHandleForReading.availableData stdout.fileHandleForReading.availableData
} }
let errorData = try await withTimeout(seconds: 0.1) { let errorData = try await withTimeout(seconds: 0.1) {
stderr.fileHandleForReading.availableData stderr.fileHandleForReading.availableData
} }
let output = String(data: outputData, encoding: .utf8) ?? "" let output = String(data: outputData, encoding: .utf8) ?? ""
let error = String(data: errorData, encoding: .utf8) ?? "" let error = String(data: errorData, encoding: .utf8) ?? ""
return (output, error) return (output, error)
} }
/// Get all active sessions /// Get all active sessions
func listSessions() -> [TunnelSession] { func listSessions() -> [TunnelSession] {
return Array(sessions.values) Array(sessions.values)
} }
/// Get a specific session /// Get a specific session
func getSession(id: UUID) -> TunnelSession? { func getSession(id: UUID) -> TunnelSession? {
return sessions[id] sessions[id]
} }
/// Close a session /// Close a session
func closeSession(id: UUID) { func closeSession(id: UUID) {
if let process = processes[id] { if let process = processes[id] {
@ -106,14 +102,14 @@ actor TerminalManager {
} }
pipes.removeValue(forKey: id) pipes.removeValue(forKey: id)
sessions.removeValue(forKey: id) sessions.removeValue(forKey: id)
logger.info("Closed session \(id)") logger.info("Closed session \(id)")
} }
/// Clean up inactive sessions /// Clean up inactive sessions
func cleanupInactiveSessions(olderThan minutes: Int = 30) { func cleanupInactiveSessions(olderThan minutes: Int = 30) {
let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60)) let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60))
for (id, session) in sessions { for (id, session) in sessions {
if session.lastActivity < cutoffDate { if session.lastActivity < cutoffDate {
closeSession(id: id) closeSession(id: id)
@ -121,20 +117,22 @@ actor TerminalManager {
} }
} }
} }
// Helper function for timeout /// Helper function for timeout
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { group.addTask {
try await operation() try await operation()
} }
group.addTask { group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw TunnelError.timeout throw TunnelError.timeout
} }
let result = try await group.next()! guard let result = try await group.next() else {
throw TunnelError.timeout
}
group.cancelAll() group.cancelAll()
return result return result
} }
@ -147,17 +145,17 @@ enum TunnelError: LocalizedError {
case commandExecutionFailed(String) case commandExecutionFailed(String)
case timeout case timeout
case invalidRequest case invalidRequest
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .sessionNotFound: case .sessionNotFound:
return "Session not found" "Session not found"
case .commandExecutionFailed(let message): case .commandExecutionFailed(let message):
return "Command execution failed: \(message)" "Command execution failed: \(message)"
case .timeout: case .timeout:
return "Operation timed out" "Operation timed out"
case .invalidRequest: 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 Combine
import Foundation
/// Client SDK for interacting with the VibeTunnel server /// Client SDK for interacting with the VibeTunnel server
public class TunnelClient { public class TunnelClient {
@ -15,66 +8,74 @@ public class TunnelClient {
private var session: URLSession private var session: URLSession
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
public init(baseURL: URL = URL(string: "http://localhost:8080")!, apiKey: String) { public init(baseURL: URL = URL(string: "http://localhost:8080")!, apiKey: String) {
self.baseURL = baseURL self.baseURL = baseURL
self.apiKey = apiKey self.apiKey = apiKey
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.httpAdditionalHeaders = ["X-API-Key": apiKey] config.httpAdditionalHeaders = ["X-API-Key": apiKey]
self.session = URLSession(configuration: config) self.session = URLSession(configuration: config)
decoder.dateDecodingStrategy = .iso8601 decoder.dateDecodingStrategy = .iso8601
encoder.dateEncodingStrategy = .iso8601 encoder.dateEncodingStrategy = .iso8601
} }
// MARK: - Health Check // MARK: - Health Check
public func checkHealth() async throws -> Bool { public func checkHealth() async throws -> Bool {
let url = baseURL.appendingPathComponent("health") let url = baseURL.appendingPathComponent("health")
let (_, response) = try await session.data(from: url) let (_, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse throw TunnelClientError.invalidResponse
} }
return httpResponse.statusCode == 200 return httpResponse.statusCode == 200
} }
// MARK: - Session Management // MARK: - Session Management
public func createSession(workingDirectory: String? = nil, public func createSession(
environment: [String: String]? = nil, workingDirectory: String? = nil,
shell: String? = nil) async throws -> CreateSessionResponse { environment: [String: String]? = nil,
shell: String? = nil
)
async throws -> CreateSessionResponse {
let url = baseURL.appendingPathComponent("sessions") let url = baseURL.appendingPathComponent("sessions")
let request = CreateSessionRequest( let request = CreateSessionRequest(
workingDirectory: workingDirectory, workingDirectory: workingDirectory,
environment: environment, environment: environment,
shell: shell shell: shell
) )
return try await post(to: url, body: request) return try await post(to: url, body: request)
} }
public func listSessions() async throws -> [SessionInfo] { public func listSessions() async throws -> [SessionInfo] {
let url = baseURL.appendingPathComponent("sessions") let url = baseURL.appendingPathComponent("sessions")
let response: ListSessionsResponse = try await get(from: url) let response: ListSessionsResponse = try await get(from: url)
return response.sessions return response.sessions
} }
public func getSession(id: String) async throws -> SessionInfo { public func getSession(id: String) async throws -> SessionInfo {
let url = baseURL.appendingPathComponent("sessions/\(id)") let url = baseURL.appendingPathComponent("sessions/\(id)")
return try await get(from: url) return try await get(from: url)
} }
public func closeSession(id: String) async throws { public func closeSession(id: String) async throws {
let url = baseURL.appendingPathComponent("sessions/\(id)") let url = baseURL.appendingPathComponent("sessions/\(id)")
try await delete(from: url) try await delete(from: url)
} }
// MARK: - Command Execution // 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 url = baseURL.appendingPathComponent("execute")
let request = CommandRequest( let request = CommandRequest(
sessionId: sessionId, sessionId: sessionId,
@ -82,66 +83,66 @@ public class TunnelClient {
args: args, args: args,
environment: nil environment: nil
) )
return try await post(to: url, body: request) return try await post(to: url, body: request)
} }
// MARK: - WebSocket Connection // MARK: - WebSocket Connection
public func connectWebSocket(sessionId: String? = nil) -> TunnelWebSocketClient { public func connectWebSocket(sessionId: String? = nil) -> TunnelWebSocketClient {
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
components.scheme = components.scheme == "https" ? "wss" : "ws" components.scheme = components.scheme == "https" ? "wss" : "ws"
components.path = components.path + "/ws/terminal" components.path = components.path + "/ws/terminal"
let wsURL = components.url! let wsURL = components.url!
return TunnelWebSocketClient(url: wsURL, apiKey: apiKey, sessionId: sessionId) return TunnelWebSocketClient(url: wsURL, apiKey: apiKey, sessionId: sessionId)
} }
// MARK: - Private Helpers // MARK: - Private Helpers
private func get<T: Decodable>(from url: URL) async throws -> T { private func get<T: Decodable>(from url: URL) async throws -> T {
let (data, response) = try await session.data(from: url) let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse throw TunnelClientError.invalidResponse
} }
guard httpResponse.statusCode == 200 else { guard httpResponse.statusCode == 200 else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode) throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
} }
return try decoder.decode(T.self, from: data) 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) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try encoder.encode(body) request.httpBody = try encoder.encode(body)
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse throw TunnelClientError.invalidResponse
} }
guard (200...299).contains(httpResponse.statusCode) else { guard (200...299).contains(httpResponse.statusCode) else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode) throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
} }
return try decoder.decode(R.self, from: data) return try decoder.decode(R.self, from: data)
} }
private func delete(from url: URL) async throws { private func delete(from url: URL) async throws {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "DELETE" request.httpMethod = "DELETE"
let (_, response) = try await session.data(for: request) let (_, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else { guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse throw TunnelClientError.invalidResponse
} }
guard httpResponse.statusCode == 204 else { guard httpResponse.statusCode == 204 else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode) throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
} }
@ -155,45 +156,45 @@ public class TunnelWebSocketClient: NSObject {
private var sessionId: String? private var sessionId: String?
private var webSocketTask: URLSessionWebSocketTask? private var webSocketTask: URLSessionWebSocketTask?
private let messageSubject = PassthroughSubject<WSMessage, Never>() private let messageSubject = PassthroughSubject<WSMessage, Never>()
public var messages: AnyPublisher<WSMessage, Never> { public var messages: AnyPublisher<WSMessage, Never> {
messageSubject.eraseToAnyPublisher() messageSubject.eraseToAnyPublisher()
} }
public init(url: URL, apiKey: String, sessionId: String? = nil) { public init(url: URL, apiKey: String, sessionId: String? = nil) {
self.url = url self.url = url
self.apiKey = apiKey self.apiKey = apiKey
self.sessionId = sessionId self.sessionId = sessionId
super.init() super.init()
} }
public func connect() { public func connect() {
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil) let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key") request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
webSocketTask = session.webSocketTask(with: request) webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume() webSocketTask?.resume()
// Send initial connection message if session ID is provided // Send initial connection message if session ID is provided
if let sessionId = sessionId { if let sessionId {
send(WSMessage(type: .connect, sessionId: sessionId)) send(WSMessage(type: .connect, sessionId: sessionId))
} }
// Start receiving messages // Start receiving messages
receiveMessage() receiveMessage()
} }
public func send(_ message: WSMessage) { public func send(_ message: WSMessage) {
guard let webSocketTask = webSocketTask else { return } guard let webSocketTask else { return }
do { do {
let data = try JSONEncoder().encode(message) let data = try JSONEncoder().encode(message)
let text = String(data: data, encoding: .utf8) ?? "{}" let text = String(data: data, encoding: .utf8) ?? "{}"
let message = URLSessionWebSocketTask.Message.string(text) let message = URLSessionWebSocketTask.Message.string(text)
webSocketTask.send(message) { error in webSocketTask.send(message) { error in
if let error = error { if let error {
print("WebSocket send error: \(error)") print("WebSocket send error: \(error)")
} }
} }
@ -201,16 +202,16 @@ public class TunnelWebSocketClient: NSObject {
print("Failed to encode message: \(error)") print("Failed to encode message: \(error)")
} }
} }
public func sendCommand(_ command: String) { public func sendCommand(_ command: String) {
guard let sessionId = sessionId else { return } guard let sessionId else { return }
send(WSMessage(type: .command, sessionId: sessionId, data: command)) send(WSMessage(type: .command, sessionId: sessionId, data: command))
} }
public func disconnect() { public func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask?.cancel(with: .goingAway, reason: nil)
} }
private func receiveMessage() { private func receiveMessage() {
webSocketTask?.receive { [weak self] result in webSocketTask?.receive { [weak self] result in
switch result { switch result {
@ -228,10 +229,10 @@ public class TunnelWebSocketClient: NSObject {
@unknown default: @unknown default:
break break
} }
// Continue receiving messages // Continue receiving messages
self?.receiveMessage() self?.receiveMessage()
case .failure(let error): case .failure(let error):
print("WebSocket receive error: \(error)") print("WebSocket receive error: \(error)")
} }
@ -242,11 +243,20 @@ public class TunnelWebSocketClient: NSObject {
// MARK: - URLSessionWebSocketDelegate // MARK: - URLSessionWebSocketDelegate
extension TunnelWebSocketClient: 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") 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") print("WebSocket disconnected")
messageSubject.send(completion: .finished) messageSubject.send(completion: .finished)
} }
@ -258,15 +268,15 @@ public enum TunnelClientError: LocalizedError {
case invalidResponse case invalidResponse
case httpError(statusCode: Int) case httpError(statusCode: Int)
case decodingError(Error) case decodingError(Error)
public var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .invalidResponse: case .invalidResponse:
return "Invalid response from server" "Invalid response from server"
case .httpError(let statusCode): case .httpError(let statusCode):
return "HTTP error: \(statusCode)" "HTTP error: \(statusCode)"
case .decodingError(let error): 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 AppKit
import Combine import Combine
import Logging import Foundation
import os import HTTPTypes
import Hummingbird import Hummingbird
import HummingbirdCore import HummingbirdCore
import HTTPTypes import Logging
import NIOCore import NIOCore
import os
// MARK: - Response Models // MARK: - Response Models
/// Server info response /// Server info response
struct ServerInfoResponse: Codable, ResponseEncodable { struct ServerInfoResponse: Codable, ResponseGenerator {
let name: String let name: String
let version: String let version: String
let uptime: TimeInterval let uptime: TimeInterval
@ -32,26 +25,26 @@ final class TunnelServer: ObservableObject {
private let logger = Logger(label: "VibeTunnel.TunnelServer") private let logger = Logger(label: "VibeTunnel.TunnelServer")
private var app: Application<Router<BasicRequestContext>.Responder>? private var app: Application<Router<BasicRequestContext>.Responder>?
private let terminalManager = TerminalManager() private let terminalManager = TerminalManager()
@Published var isRunning = false @Published var isRunning = false
@Published var lastError: Error? @Published var lastError: Error?
@Published var connectedClients = 0 @Published var connectedClients = 0
init(port: Int = 8080) { init(port: Int = 8_080) {
self.port = port self.port = port
} }
func start() async throws { func start() async throws {
logger.info("Starting tunnel server on port \(port)") logger.info("Starting tunnel server on port \(port)")
do { do {
// Build the Hummingbird application // Build the Hummingbird application
let app = try await buildApplication() let app = try await buildApplication()
self.app = app self.app = app
// Start the server // Start the server
try await app.run() try await app.run()
await MainActor.run { await MainActor.run {
self.isRunning = true self.isRunning = true
} }
@ -63,23 +56,23 @@ final class TunnelServer: ObservableObject {
throw error throw error
} }
} }
func stop() async { func stop() async {
logger.info("Stopping tunnel server") logger.info("Stopping tunnel server")
// In Hummingbird 2.x, the application lifecycle is managed differently // In Hummingbird 2.x, the application lifecycle is managed differently
// Setting app to nil will trigger cleanup when it's deallocated // Setting app to nil will trigger cleanup when it's deallocated
self.app = nil self.app = nil
await MainActor.run { await MainActor.run {
self.isRunning = false self.isRunning = false
} }
} }
private func buildApplication() async throws -> Application<Router<BasicRequestContext>.Responder> { private func buildApplication() async throws -> Application<Router<BasicRequestContext>.Responder> {
// Create router // Create router
let router = Router<BasicRequestContext>() let router = Router<BasicRequestContext>()
// Add middleware // Add middleware
router.add(middleware: LogRequestsMiddleware(.info)) router.add(middleware: LogRequestsMiddleware(.info))
router.add(middleware: CORSMiddleware( router.add(middleware: CORSMiddleware(
@ -88,27 +81,27 @@ final class TunnelServer: ObservableObject {
allowMethods: [.get, .post, .delete, .options] allowMethods: [.get, .post, .delete, .options]
)) ))
router.add(middleware: AuthenticationMiddleware(apiKeys: APIKeyManager.loadStoredAPIKeys())) router.add(middleware: AuthenticationMiddleware(apiKeys: APIKeyManager.loadStoredAPIKeys()))
// Configure routes // Configure routes
configureRoutes(router) configureRoutes(router)
// Add WebSocket routes // Add WebSocket routes
// TODO: Uncomment when HummingbirdWebSocket package is added // TODO: Uncomment when HummingbirdWebSocket package is added
// router.addWebSocketRoutes(terminalManager: terminalManager) // router.addWebSocketRoutes(terminalManager: terminalManager)
// Create application configuration // Create application configuration
let configuration = ApplicationConfiguration( let configuration = ApplicationConfiguration(
address: .hostname("127.0.0.1", port: port), address: .hostname("127.0.0.1", port: port),
serverName: "VibeTunnel" serverName: "VibeTunnel"
) )
// Create and configure the application // Create and configure the application
let app = Application( let app = Application(
responder: router.buildResponder(), responder: router.buildResponder(),
configuration: configuration, configuration: configuration,
logger: logger logger: logger
) )
// Add cleanup task // Add cleanup task
// Start cleanup task // Start cleanup task
Task { Task {
@ -117,18 +110,18 @@ final class TunnelServer: ObservableObject {
try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes
} }
} }
return app return app
} }
private func configureRoutes(_ router: Router<BasicRequestContext>) { private func configureRoutes(_ router: Router<BasicRequestContext>) {
// Health check endpoint // Health check endpoint
router.get("/health") { request, context -> HTTPResponse.Status in router.get("/health") { _, _ -> HTTPResponse.Status in
return .ok .ok
} }
// Server info endpoint // Server info endpoint
router.get("/info") { request, context async -> ServerInfoResponse in router.get("/info") { _, _ async -> ServerInfoResponse in
let sessionCount = await self.terminalManager.listSessions().count let sessionCount = await self.terminalManager.listSessions().count
return ServerInfoResponse( return ServerInfoResponse(
name: "VibeTunnel", name: "VibeTunnel",
@ -137,76 +130,78 @@ final class TunnelServer: ObservableObject {
sessions: sessionCount sessions: sessionCount
) )
} }
// Session management endpoints // Session management endpoints
let sessions = router.group("sessions") let sessions = router.group("sessions")
// List all sessions // List all sessions
sessions.get("/") { request, context async -> ListSessionsResponse in sessions.get("/") { _, _ async -> ListSessionsResponse in
let sessions = await self.terminalManager.listSessions() let sessions = await self.terminalManager.listSessions()
let sessionInfos = sessions.map { session in let sessionInfos = sessions.map { session in
SessionInfo( 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 async throws -> 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 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 {
throw HTTPError(.notFound)
}
return SessionInfo(
id: session.id.uuidString, id: session.id.uuidString,
createdAt: session.createdAt, createdAt: session.createdAt,
lastActivity: session.lastActivity, lastActivity: session.lastActivity,
isActive: session.isActive isActive: session.isActive
) )
}
return ListSessionsResponse(sessions: sessionInfos)
} }
// Create new session
sessions.post("/") { request, context async throws -> 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") { _, 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 {
throw HTTPError(.notFound)
}
return SessionInfo(
id: session.id.uuidString,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
isActive: session.isActive
)
}
// Close session // 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), guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
let sessionId = UUID(uuidString: sessionIdString) else { let sessionId = UUID(uuidString: sessionIdString)
throw HTTPError(.badRequest) else {
} throw HTTPError(.badRequest)
}
await self.terminalManager.closeSession(id: sessionId) await self.terminalManager.closeSession(id: sessionId)
return .noContent return .noContent
} }
// Command execution endpoint // Command execution endpoint
router.post("/execute") { request, context async throws -> CommandResponse in router.post("/execute") { request, context async throws -> CommandResponse in
let commandRequest = try await request.decode(as: CommandRequest.self, context: context) let commandRequest = try await request.decode(as: CommandRequest.self, context: context)
guard let sessionId = UUID(uuidString: commandRequest.sessionId) else { guard let sessionId = UUID(uuidString: commandRequest.sessionId) else {
throw HTTPError(.badRequest, message: "Invalid session ID") throw HTTPError(.badRequest, message: "Invalid session ID")
} }
do { do {
let (output, error) = try await self.terminalManager.executeCommand( let (output, error) = try await self.terminalManager.executeCommand(
sessionId: sessionId, sessionId: sessionId,
command: commandRequest.command command: commandRequest.command
) )
return CommandResponse( return CommandResponse(
sessionId: commandRequest.sessionId, sessionId: commandRequest.sessionId,
output: output.isEmpty ? nil : output, output: output.isEmpty ? nil : output,
@ -219,7 +214,6 @@ final class TunnelServer: ObservableObject {
} }
} }
} }
} }
// MARK: - Integration with AppDelegate // MARK: - Integration with AppDelegate
@ -229,15 +223,15 @@ extension AppDelegate {
Task { Task {
do { do {
let port = UserDefaults.standard.integer(forKey: "serverPort") 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 // Store reference if needed
// self.tunnelServer = tunnelServer // self.tunnelServer = tunnelServer
try await tunnelServer.start() try await tunnelServer.start()
} catch { } catch {
print("Failed to start tunnel server: \(error)") print("Failed to start tunnel server: \(error)")
// Show error alert // Show error alert
await MainActor.run { await MainActor.run {
let alert = NSAlert() let alert = NSAlert()
@ -249,4 +243,4 @@ extension AppDelegate {
} }
} }
} }
} }

View file

@ -1,16 +1,8 @@
//
// TunnelServerDemo.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Combine import Combine
import Foundation
/// Demo code showing how to use the VibeTunnel server /// Demo code showing how to use the VibeTunnel server
class TunnelServerDemo { class TunnelServerDemo {
static func runDemo() async { static func runDemo() async {
// Get the API key (in production, this should be managed securely) // Get the API key (in production, this should be managed securely)
let apiKeys = APIKeyManager.loadStoredAPIKeys() let apiKeys = APIKeyManager.loadStoredAPIKeys()
@ -18,62 +10,61 @@ class TunnelServerDemo {
print("No API key found") print("No API key found")
return return
} }
print("Using API key: \(apiKey)") print("Using API key: \(apiKey)")
// Create client // Create client
let client = TunnelClient(apiKey: apiKey) let client = TunnelClient(apiKey: apiKey)
do { do {
// Check server health // Check server health
let isHealthy = try await client.checkHealth() let isHealthy = try await client.checkHealth()
print("Server healthy: \(isHealthy)") print("Server healthy: \(isHealthy)")
// Create a new session // Create a new session
let session = try await client.createSession( let session = try await client.createSession(
workingDirectory: "/tmp", workingDirectory: "/tmp",
shell: "/bin/zsh" shell: "/bin/zsh"
) )
print("Created session: \(session.sessionId)") print("Created session: \(session.sessionId)")
// Execute a command // Execute a command
let response = try await client.executeCommand( let response = try await client.executeCommand(
sessionId: session.sessionId, sessionId: session.sessionId,
command: "echo 'Hello from VibeTunnel!'" command: "echo 'Hello from VibeTunnel!'"
) )
print("Command output: \(response.output ?? "none")") print("Command output: \(response.output ?? "none")")
// List all sessions // List all sessions
let sessions = try await client.listSessions() let sessions = try await client.listSessions()
print("Active sessions: \(sessions.count)") print("Active sessions: \(sessions.count)")
// Close the session // Close the session
try await client.closeSession(id: session.sessionId) try await client.closeSession(id: session.sessionId)
print("Session closed") print("Session closed")
} catch { } catch {
print("Demo error: \(error)") print("Demo error: \(error)")
} }
} }
static func runWebSocketDemo() async { static func runWebSocketDemo() async {
let apiKeys = APIKeyManager.loadStoredAPIKeys() let apiKeys = APIKeyManager.loadStoredAPIKeys()
guard let apiKey = apiKeys.first else { guard let apiKey = apiKeys.first else {
print("No API key found") print("No API key found")
return return
} }
let client = TunnelClient(apiKey: apiKey) let client = TunnelClient(apiKey: apiKey)
do { do {
// Create a session first // Create a session first
let session = try await client.createSession() let session = try await client.createSession()
print("Created session for WebSocket: \(session.sessionId)") print("Created session for WebSocket: \(session.sessionId)")
// Connect WebSocket // Connect WebSocket
let wsClient = client.connectWebSocket(sessionId: session.sessionId) let wsClient = client.connectWebSocket(sessionId: session.sessionId)
wsClient.connect() wsClient.connect()
// Subscribe to messages // Subscribe to messages
let cancellable = wsClient.messages.sink { message in let cancellable = wsClient.messages.sink { message in
switch message.type { switch message.type {
@ -85,20 +76,19 @@ class TunnelServerDemo {
print("Message: \(message.type) - \(message.data ?? "")") print("Message: \(message.type) - \(message.data ?? "")")
} }
} }
// Send some commands // Send some commands
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("pwd") wsClient.sendCommand("pwd")
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("ls -la") wsClient.sendCommand("ls -la")
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
// Disconnect // Disconnect
wsClient.disconnect() wsClient.disconnect()
cancellable.cancel() cancellable.cancel()
} catch { } catch {
print("WebSocket demo error: \(error)") print("WebSocket demo error: \(error)")
} }
@ -107,45 +97,43 @@ class TunnelServerDemo {
// MARK: - cURL Examples // MARK: - cURL Examples
/* // Here are some example cURL commands to test the server:
Here are some example cURL commands to test the server: //
// # Set your API key
# Set your API key // export API_KEY="your-api-key-here"
export API_KEY="your-api-key-here" //
// # Health check (no auth required)
# Health check (no auth required) // curl http://localhost:8080/health
curl http://localhost:8080/health //
// # Get server info
# Get server info // curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
curl -H "X-API-Key: $API_KEY" http://localhost:8080/info //
// # Create a new session
# Create a new session // curl -X POST http://localhost:8080/sessions \
curl -X POST http://localhost:8080/sessions \ // -H "X-API-Key: $API_KEY" \
-H "X-API-Key: $API_KEY" \ // -H "Content-Type: application/json" \
-H "Content-Type: application/json" \ // -d '{
-d '{ // "workingDirectory": "/tmp",
"workingDirectory": "/tmp", // "shell": "/bin/zsh"
"shell": "/bin/zsh" // }'
}' //
// # List all sessions
# List all sessions // curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions //
// # Execute a command
# Execute a command // curl -X POST http://localhost:8080/execute \
curl -X POST http://localhost:8080/execute \ // -H "X-API-Key: $API_KEY" \
-H "X-API-Key: $API_KEY" \ // -H "Content-Type: application/json" \
-H "Content-Type: application/json" \ // -d '{
-d '{ // "sessionId": "your-session-id",
"sessionId": "your-session-id", // "command": "ls -la"
"command": "ls -la" // }'
}' //
// # Get session info
# Get session info // curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id //
// # Close a session
# Close a session // curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id //
// # WebSocket connection (using websocat tool)
# WebSocket connection (using websocat tool) // websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal
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 Foundation
import Hummingbird import Hummingbird
import HummingbirdCore import HummingbirdCore
import NIOCore import NIOCore
// import NIOWebSocket // TODO: This is available in swift-nio package // import NIOWebSocket // TODO: This is available in swift-nio package
import Logging import Logging
@ -29,7 +23,7 @@ public struct WSMessage: Codable {
public let sessionId: String? public let sessionId: String?
public let data: String? public let data: String?
public let timestamp: Date public let timestamp: Date
public init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) { public init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) {
self.type = type self.type = type
self.sessionId = sessionId self.sessionId = sessionId
@ -39,161 +33,162 @@ public struct WSMessage: Codable {
} }
// TODO: Enable when HummingbirdWebSocket package is added // TODO: Enable when HummingbirdWebSocket package is added
/* // /// Handles WebSocket connections for real-time terminal communication
/// Handles WebSocket connections for real-time terminal communication // final class WebSocketHandler {
final class WebSocketHandler { // private let terminalManager: TerminalManager
private let terminalManager: TerminalManager // private let logger = Logger(label: "VibeTunnel.WebSocketHandler")
private let logger = Logger(label: "VibeTunnel.WebSocketHandler") // private var activeConnections: [UUID: WebSocketHandler.Connection] = [:]
private var activeConnections: [UUID: WebSocketHandler.Connection] = [:] //
// init(terminalManager: TerminalManager) {
init(terminalManager: TerminalManager) { // self.terminalManager = terminalManager
self.terminalManager = terminalManager // }
} //
// /// Handle incoming WebSocket connection
/// Handle incoming WebSocket connection // func handle(ws: WebSocket, context: some RequestContext) async {
func handle(ws: WebSocket, context: some RequestContext) async { // let connectionId = UUID()
let connectionId = UUID() // let connection = Connection(id: connectionId, websocket: ws)
let connection = Connection(id: connectionId, websocket: ws) //
// await MainActor.run {
await MainActor.run { // activeConnections[connectionId] = connection
activeConnections[connectionId] = connection // }
} //
// logger.info("WebSocket connection established: \(connectionId)")
logger.info("WebSocket connection established: \(connectionId)") //
// // Set up message handlers
// Set up message handlers // ws.onText { [weak self] ws, text in
ws.onText { [weak self] ws, text in // await self?.handleTextMessage(text, connection: connection)
await self?.handleTextMessage(text, connection: connection) // }
} //
// ws.onBinary { [weak self] ws, buffer in
ws.onBinary { [weak self] ws, buffer in // // Handle binary data if needed
// Handle binary data if needed // self?.logger.debug("Received binary data: \(buffer.readableBytes) bytes")
self?.logger.debug("Received binary data: \(buffer.readableBytes) bytes") // }
} //
// ws.onClose { [weak self] closeCode in
ws.onClose { [weak self] closeCode in // await self?.handleClose(connection: connection)
await self?.handleClose(connection: connection) // }
} //
// // Send initial connection acknowledgment
// Send initial connection acknowledgment // await sendMessage(WSMessage(type: .connect, data: "Connected to VibeTunnel"), to: connection)
await sendMessage(WSMessage(type: .connect, data: "Connected to VibeTunnel"), to: connection) //
// // Keep connection alive with periodic pings
// Keep connection alive with periodic pings // Task {
Task { // while !Task.isCancelled && !connection.isClosed {
while !Task.isCancelled && !connection.isClosed { // await sendMessage(WSMessage(type: .ping), to: connection)
await sendMessage(WSMessage(type: .ping), to: connection) // try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds // }
} // }
} // }
} //
// private func handleTextMessage(_ text: String, connection: Connection) async {
private func handleTextMessage(_ text: String, connection: Connection) async { // guard let data = text.data(using: .utf8),
guard let data = text.data(using: .utf8), // let message = try? JSONDecoder().decode(WSMessage.self, from: data) else {
let message = try? JSONDecoder().decode(WSMessage.self, from: data) else { // logger.error("Failed to decode WebSocket message: \(text)")
logger.error("Failed to decode WebSocket message: \(text)") // await sendError("Invalid message format", to: connection)
await sendError("Invalid message format", to: connection) // return
return // }
} //
// switch message.type {
switch message.type { // case .connect:
case .connect: // // Handle session connection
// Handle session connection // if let sessionId = message.sessionId,
if let sessionId = message.sessionId, // let uuid = UUID(uuidString: sessionId) {
let uuid = UUID(uuidString: sessionId) { // connection.sessionId = uuid
connection.sessionId = uuid // await sendMessage(WSMessage(type: .output, sessionId: sessionId, data: "Session connected"), to:
await sendMessage(WSMessage(type: .output, sessionId: sessionId, data: "Session connected"), to: connection) // connection)
} // }
//
case .command: // case .command:
// Execute command in terminal session // // Execute command in terminal session
guard let sessionId = connection.sessionId, // guard let sessionId = connection.sessionId,
let command = message.data else { // let command = message.data else {
await sendError("Session ID and command required", to: connection) // await sendError("Session ID and command required", to: connection)
return // return
} // }
//
do { // do {
let (output, error) = try await terminalManager.executeCommand(sessionId: sessionId, command: command) // let (output, error) = try await terminalManager.executeCommand(sessionId: sessionId, command: command)
//
if !output.isEmpty { // if !output.isEmpty {
await sendMessage(WSMessage(type: .output, sessionId: sessionId.uuidString, data: output), to: connection) // 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) // if !error.isEmpty {
} // await sendMessage(WSMessage(type: .error, sessionId: sessionId.uuidString, data: error), to:
} catch { // connection)
await sendError(error.localizedDescription, to: connection) // }
} // } catch {
// await sendError(error.localizedDescription, to: connection)
case .ping: // }
// Respond to ping with pong //
await sendMessage(WSMessage(type: .pong), to: connection) // case .ping:
// // Respond to ping with pong
case .close: // await sendMessage(WSMessage(type: .pong), to: connection)
// Close the session //
if let sessionId = connection.sessionId { // case .close:
await terminalManager.closeSession(id: sessionId) // // Close the session
} // if let sessionId = connection.sessionId {
try? await connection.websocket.close() // await terminalManager.closeSession(id: sessionId)
// }
default: // try? await connection.websocket.close()
logger.warning("Unhandled message type: \(message.type)") //
} // default:
} // logger.warning("Unhandled message type: \(message.type)")
// }
private func handleClose(connection: Connection) async { // }
logger.info("WebSocket connection closed: \(connection.id)") //
// private func handleClose(connection: Connection) async {
await MainActor.run { // logger.info("WebSocket connection closed: \(connection.id)")
activeConnections.removeValue(forKey: 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) // // Clean up associated session if any
} // if let sessionId = connection.sessionId {
// await terminalManager.closeSession(id: sessionId)
connection.isClosed = true // }
} //
// connection.isClosed = true
private func sendMessage(_ message: WSMessage, to connection: Connection) async { // }
do { //
let data = try JSONEncoder().encode(message) // private func sendMessage(_ message: WSMessage, to connection: Connection) async {
let text = String(data: data, encoding: .utf8) ?? "{}" // do {
try await connection.websocket.send(text: text) // let data = try JSONEncoder().encode(message)
} catch { // let text = String(data: data, encoding: .utf8) ?? "{}"
logger.error("Failed to send WebSocket message: \(error)") // 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) //
} // 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 // /// WebSocket connection wrapper
let websocket: WebSocket // class Connection {
var sessionId: UUID? // let id: UUID
var isClosed = false // let websocket: WebSocket
// var sessionId: UUID?
init(id: UUID, websocket: WebSocket) { // var isClosed = false
self.id = id //
self.websocket = websocket // 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) { // /// Extension to add WebSocket routes to the router
let wsHandler = WebSocketHandler(terminalManager: terminalManager) // extension RouterBuilder {
// mutating func addWebSocketRoutes(terminalManager: TerminalManager) {
// WebSocket endpoint for terminal streaming // let wsHandler = WebSocketHandler(terminalManager: terminalManager)
ws("/ws/terminal") { request, ws, context in //
await wsHandler.handle(ws: ws, context: context) // // 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 content
.background( .background(
RoundedRectangle(cornerRadius: cornerRadius) RoundedRectangle(cornerRadius: cornerRadius)
.fill(material)) .fill(material)
)
} }
} }
@ -50,7 +51,8 @@ struct CardStyleModifier: ViewModifier {
init( init(
cornerRadius: CGFloat = 10, cornerRadius: CGFloat = 10,
horizontalPadding: CGFloat = 14, horizontalPadding: CGFloat = 14,
verticalPadding: CGFloat = 10) { verticalPadding: CGFloat = 10
) {
self.cornerRadius = cornerRadius self.cornerRadius = cornerRadius
self.horizontalPadding = horizontalPadding self.horizontalPadding = horizontalPadding
self.verticalPadding = verticalPadding self.verticalPadding = verticalPadding
@ -66,15 +68,17 @@ struct CardStyleModifier: ViewModifier {
// MARK: - View Extensions // MARK: - View Extensions
public extension View { extension View {
/// Applies a material background with rounded corners. /// Applies a material background with rounded corners.
/// ///
/// - Parameters: /// - Parameters:
/// - cornerRadius: Corner radius for the rounded rectangle (default: 10) /// - cornerRadius: Corner radius for the rounded rectangle (default: 10)
/// - material: Material type to use (default: .thickMaterial) /// - material: Material type to use (default: .thickMaterial)
func materialBackground( public func materialBackground(
cornerRadius: CGFloat = 10, cornerRadius: CGFloat = 10,
material: Material = .thickMaterial) -> some View { material: Material = .thickMaterial
)
-> some View {
modifier(MaterialBackgroundModifier(cornerRadius: cornerRadius, material: material)) modifier(MaterialBackgroundModifier(cornerRadius: cornerRadius, material: material))
} }
@ -83,9 +87,11 @@ public extension View {
/// - Parameters: /// - Parameters:
/// - horizontal: Horizontal padding (default: 16) /// - horizontal: Horizontal padding (default: 16)
/// - vertical: Vertical padding (default: 14) /// - vertical: Vertical padding (default: 14)
func standardPadding( public func standardPadding(
horizontal: CGFloat = 16, horizontal: CGFloat = 16,
vertical: CGFloat = 14) -> some View { vertical: CGFloat = 14
)
-> some View {
modifier(StandardPaddingModifier(horizontal: horizontal, vertical: vertical)) modifier(StandardPaddingModifier(horizontal: horizontal, vertical: vertical))
} }
@ -95,14 +101,17 @@ public extension View {
/// - cornerRadius: Corner radius for the card (default: 10) /// - cornerRadius: Corner radius for the card (default: 10)
/// - horizontalPadding: Horizontal padding (default: 14) /// - horizontalPadding: Horizontal padding (default: 14)
/// - verticalPadding: Vertical padding (default: 10) /// - verticalPadding: Vertical padding (default: 10)
func cardStyle( public func cardStyle(
cornerRadius: CGFloat = 10, cornerRadius: CGFloat = 10,
horizontalPadding: CGFloat = 14, horizontalPadding: CGFloat = 14,
verticalPadding: CGFloat = 10) -> some View { verticalPadding: CGFloat = 10
)
-> some View {
modifier(CardStyleModifier( modifier(CardStyleModifier(
cornerRadius: cornerRadius, cornerRadius: cornerRadius,
horizontalPadding: horizontalPadding, horizontalPadding: horizontalPadding,
verticalPadding: verticalPadding)) verticalPadding: verticalPadding
))
} }
} }
@ -183,4 +192,4 @@ public extension View {
.padding() .padding()
.frame(width: 400) .frame(width: 400)
.background(Color(NSColor.windowBackgroundColor)) .background(Color(NSColor.windowBackgroundColor))
} }

View file

@ -20,7 +20,8 @@ struct PressEventModifier: ViewModifier {
.simultaneousGesture( .simultaneousGesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)
.onChanged { _ in onPress() } .onChanged { _ in onPress() }
.onEnded { _ in onRelease() }) .onEnded { _ in onRelease() }
)
} }
} }
@ -30,7 +31,8 @@ struct PointingHandCursorModifier: ViewModifier {
content content
.background( .background(
CursorTrackingView() CursorTrackingView()
.allowsHitTesting(false)) .allowsHitTesting(false)
)
} }
} }
@ -56,4 +58,4 @@ class CursorTrackingNSView: NSView {
super.viewDidMoveToWindow() super.viewDidMoveToWindow()
window?.invalidateCursorRects(for: self) window?.invalidateCursorRects(for: self)
} }
} }

View file

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

View file

@ -1,30 +1,23 @@
//
// SettingsView.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import SwiftUI import SwiftUI
enum SettingsTab: String, CaseIterable { enum SettingsTab: String, CaseIterable {
case general case general
case advanced case advanced
case about case about
var displayName: String { var displayName: String {
switch self { switch self {
case .general: return "General" case .general: "General"
case .advanced: return "Advanced" case .advanced: "Advanced"
case .about: return "About" case .about: "About"
} }
} }
var icon: String { var icon: String {
switch self { switch self {
case .general: return "gear" case .general: "gear"
case .advanced: return "gearshape.2" case .advanced: "gearshape.2"
case .about: return "info.circle" case .about: "info.circle"
} }
} }
} }
@ -35,7 +28,7 @@ extension Notification.Name {
struct SettingsView: View { struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general @State private var selectedTab: SettingsTab = .general
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
GeneralSettingsView() GeneralSettingsView()
@ -43,13 +36,13 @@ struct SettingsView: View {
Label(SettingsTab.general.displayName, systemImage: SettingsTab.general.icon) Label(SettingsTab.general.displayName, systemImage: SettingsTab.general.icon)
} }
.tag(SettingsTab.general) .tag(SettingsTab.general)
AdvancedSettingsView() AdvancedSettingsView()
.tabItem { .tabItem {
Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon) Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon)
} }
.tag(SettingsTab.advanced) .tag(SettingsTab.advanced)
AboutView() AboutView()
.tabItem { .tabItem {
Label(SettingsTab.about.displayName, systemImage: SettingsTab.about.icon) Label(SettingsTab.about.displayName, systemImage: SettingsTab.about.icon)
@ -69,9 +62,9 @@ struct GeneralSettingsView: View {
@AppStorage("autostart") private var autostart = false @AppStorage("autostart") private var autostart = false
@AppStorage("showNotifications") private var showNotifications = true @AppStorage("showNotifications") private var showNotifications = true
@AppStorage("showInDock") private var showInDock = false @AppStorage("showInDock") private var showInDock = false
private let startupManager = StartupManager() private let startupManager = StartupManager()
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@ -83,7 +76,7 @@ struct GeneralSettingsView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
// Show Notifications // Show Notifications
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Toggle("Show notifications", isOn: $showNotifications) Toggle("Show notifications", isOn: $showNotifications)
@ -95,7 +88,7 @@ struct GeneralSettingsView: View {
Text("Application") Text("Application")
.font(.headline) .font(.headline)
} }
Section { Section {
// Show in Dock // Show in Dock
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@ -118,23 +111,25 @@ struct GeneralSettingsView: View {
autostart = startupManager.isLaunchAtLoginEnabled autostart = startupManager.isLaunchAtLoginEnabled
} }
} }
private var launchAtLoginBinding: Binding<Bool> { private var launchAtLoginBinding: Binding<Bool> {
Binding( Binding(
get: { autostart }, get: { autostart },
set: { newValue in set: { newValue in
autostart = newValue autostart = newValue
startupManager.setLaunchAtLogin(enabled: newValue) startupManager.setLaunchAtLogin(enabled: newValue)
}) }
)
} }
private var showInDockBinding: Binding<Bool> { private var showInDockBinding: Binding<Bool> {
Binding( Binding(
get: { showInDock }, get: { showInDock },
set: { newValue in set: { newValue in
showInDock = newValue showInDock = newValue
NSApp.setActivationPolicy(newValue ? .regular : .accessory) NSApp.setActivationPolicy(newValue ? .regular : .accessory)
}) }
)
} }
} }
@ -142,19 +137,19 @@ struct AdvancedSettingsView: View {
@AppStorage("debugMode") private var debugMode = false @AppStorage("debugMode") private var debugMode = false
@AppStorage("serverPort") private var serverPort = "8080" @AppStorage("serverPort") private var serverPort = "8080"
@AppStorage("updateChannel") private var updateChannelRaw = UpdateChannel.stable.rawValue @AppStorage("updateChannel") private var updateChannelRaw = UpdateChannel.stable.rawValue
@State private var isCheckingForUpdates = false @State private var isCheckingForUpdates = false
@StateObject private var tunnelServer: TunnelServer @StateObject private var tunnelServer: TunnelServer
init() { 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)) _tunnelServer = StateObject(wrappedValue: TunnelServer(port: port))
} }
var updateChannel: UpdateChannel { var updateChannel: UpdateChannel {
UpdateChannel(rawValue: updateChannelRaw) ?? .stable UpdateChannel(rawValue: updateChannelRaw) ?? .stable
} }
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
@ -176,7 +171,7 @@ struct AdvancedSettingsView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
// Check for Updates // Check for Updates
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@ -185,9 +180,9 @@ struct AdvancedSettingsView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
Button("Check Now") { Button("Check Now") {
checkForUpdates() checkForUpdates()
} }
@ -199,7 +194,7 @@ struct AdvancedSettingsView: View {
Text("Updates") Text("Updates")
.font(.headline) .font(.headline)
} }
Section { Section {
// Tunnel Server // Tunnel Server
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@ -213,26 +208,28 @@ struct AdvancedSettingsView: View {
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
} }
} }
Text(tunnelServer.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped") Text(tunnelServer
.font(.caption) .isRunning ? "Server is running on port \(serverPort)" : "Server is stopped"
.foregroundStyle(.secondary) )
.font(.caption)
.foregroundStyle(.secondary)
} }
Spacer() Spacer()
Button(tunnelServer.isRunning ? "Stop" : "Start") { Button(tunnelServer.isRunning ? "Stop" : "Start") {
toggleServer() toggleServer()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(tunnelServer.isRunning ? .red : .blue) .tint(tunnelServer.isRunning ? .red : .blue)
} }
if tunnelServer.isRunning { if tunnelServer.isRunning, let serverURL = URL(string: "http://localhost:\(serverPort)") {
Link("Open in Browser", destination: URL(string: "http://localhost:\(serverPort)")!) Link("Open in Browser", destination: serverURL)
.font(.caption) .font(.caption)
} }
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
Text("Server port:") Text("Server port:")
@ -249,7 +246,7 @@ struct AdvancedSettingsView: View {
Text("Server") Text("Server")
.font(.headline) .font(.headline)
} }
Section { Section {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Toggle("Debug mode", isOn: $debugMode) Toggle("Debug mode", isOn: $debugMode)
@ -267,7 +264,7 @@ struct AdvancedSettingsView: View {
.navigationTitle("Advanced Settings") .navigationTitle("Advanced Settings")
} }
} }
private var updateChannelBinding: Binding<UpdateChannel> { private var updateChannelBinding: Binding<UpdateChannel> {
Binding( Binding(
get: { updateChannel }, get: { updateChannel },
@ -279,20 +276,21 @@ struct AdvancedSettingsView: View {
object: nil, object: nil,
userInfo: ["channel": newValue] userInfo: ["channel": newValue]
) )
}) }
)
} }
private func checkForUpdates() { private func checkForUpdates() {
isCheckingForUpdates = true isCheckingForUpdates = true
NotificationCenter.default.post(name: Notification.Name("checkForUpdates"), object: nil) NotificationCenter.default.post(name: Notification.Name("checkForUpdates"), object: nil)
// Reset after a delay // Reset after a delay
Task { Task {
try? await Task.sleep(for: .seconds(2)) try? await Task.sleep(for: .seconds(2))
isCheckingForUpdates = false isCheckingForUpdates = false
} }
} }
private func toggleServer() { private func toggleServer() {
Task { Task {
if tunnelServer.isRunning { if tunnelServer.isRunning {
@ -317,4 +315,4 @@ struct AdvancedSettingsView: View {
#Preview { #Preview {
SettingsView() SettingsView()
} }

View file

@ -1,14 +1,14 @@
import SwiftUI
import AppKit import AppKit
import SwiftUI
/// Window controller for the About window /// Window controller for the About window
final class AboutWindowController { final class AboutWindowController {
static let shared = AboutWindowController() static let shared = AboutWindowController()
private var window: NSWindow? private var window: NSWindow?
private init() {} private init() {}
func showWindow() { func showWindow() {
// Check if About window is already open // Check if About window is already open
if let existingWindow = window, existingWindow.isVisible { if let existingWindow = window, existingWindow.isVisible {
@ -16,11 +16,11 @@ final class AboutWindowController {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
return return
} }
// Create new About window // Create new About window
let aboutView = AboutView() let aboutView = AboutView()
let hostingController = NSHostingController(rootView: aboutView) let hostingController = NSHostingController(rootView: aboutView)
let newWindow = NSWindow(contentViewController: hostingController) let newWindow = NSWindow(contentViewController: hostingController)
newWindow.identifier = NSUserInterfaceItemIdentifier("AboutWindow") newWindow.identifier = NSUserInterfaceItemIdentifier("AboutWindow")
newWindow.title = "About VibeTunnel" newWindow.title = "About VibeTunnel"
@ -28,12 +28,12 @@ final class AboutWindowController {
newWindow.setContentSize(NSSize(width: 570, height: 600)) newWindow.setContentSize(NSSize(width: 570, height: 600))
newWindow.center() newWindow.center()
newWindow.isReleasedWhenClosed = false newWindow.isReleasedWhenClosed = false
// Store reference to window // Store reference to window
self.window = newWindow self.window = newWindow
// Show window // Show window
newWindow.makeKeyAndOrderFront(nil) newWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
} }

View file

@ -1,10 +1,3 @@
//
// NSApplication+OpenSettings.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import AppKit import AppKit
extension NSApplication { extension NSApplication {
@ -20,4 +13,4 @@ extension NSApplication {
performSelector(onMainThread: Selector(("showSettingsWindow:")), with: nil, waitUntilDone: false) performSelector(onMainThread: Selector(("showSettingsWindow:")), with: nil, waitUntilDone: false)
} }
} }
} }

View file

@ -1,30 +1,23 @@
//
// VibeTunnelApp.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import SwiftUI
import AppKit import AppKit
import SwiftUI
@main @main
struct VibeTunnelApp: App { struct VibeTunnelApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) @NSApplicationDelegateAdaptor(AppDelegate.self)
var appDelegate var appDelegate
var body: some Scene { var body: some Scene {
#if os(macOS) #if os(macOS)
Settings { Settings {
SettingsView() SettingsView()
} }
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
Button("About VibeTunnel") { Button("About VibeTunnel") {
showAboutInSettings() showAboutInSettings()
}
} }
} }
}
#endif #endif
} }
} }
@ -35,147 +28,154 @@ struct VibeTunnelApp: App {
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
private(set) var sparkleUpdaterManager: SparkleUpdaterManager? private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
private var statusItem: NSStatusItem? private var statusItem: NSStatusItem?
/// Distributed notification name used to ask an existing instance to show the Settings window. /// Distributed notification name used to ask an existing instance to show the Settings window.
private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings") private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings")
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
let processInfo = ProcessInfo.processInfo let processInfo = ProcessInfo.processInfo
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 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 // Handle single instance check before doing anything else
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug { if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
handleSingleInstanceCheck() handleSingleInstanceCheck()
registerForDistributedNotifications() registerForDistributedNotifications()
} }
// Initialize Sparkle updater manager // Initialize Sparkle updater manager
sparkleUpdaterManager = SparkleUpdaterManager() sparkleUpdaterManager = SparkleUpdaterManager()
// Configure activation policy based on settings (default to menu bar only) // Configure activation policy based on settings (default to menu bar only)
let showInDock = UserDefaults.standard.bool(forKey: "showInDock") let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
NSApp.setActivationPolicy(showInDock ? .regular : .accessory) NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
// Setup status item (menu bar icon) // Setup status item (menu bar icon)
setupStatusItem() setupStatusItem()
// Show settings on first launch or when no window is open // Show settings on first launch or when no window is open
if !showInDock { if !showInDock {
// For menu bar apps, we need to ensure the settings window is accessible // For menu bar apps, we need to ensure the settings window is accessible
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if NSApp.windows.isEmpty || NSApp.windows.allSatisfy({ !$0.isVisible }) { if NSApp.windows.isEmpty || NSApp.windows.allSatisfy({ !$0.isVisible }) {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) NSApp.openSettings()
} }
} }
} }
// Listen for update check requests // Listen for update check requests
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, self,
selector: #selector(handleCheckForUpdatesNotification), selector: #selector(handleCheckForUpdatesNotification),
name: Notification.Name("checkForUpdates"), name: Notification.Name("checkForUpdates"),
object: nil) object: nil
)
} }
private func handleSingleInstanceCheck() { private func handleSingleInstanceCheck() {
let runningApps = NSRunningApplication let runningApps = NSRunningApplication
.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "") .runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "")
if runningApps.count > 1 { if runningApps.count > 1 {
// Send notification to existing instance to show settings // Send notification to existing instance to show settings
DistributedNotificationCenter.default().post(name: Self.showSettingsNotification, object: nil) DistributedNotificationCenter.default().post(name: Self.showSettingsNotification, object: nil)
// Show alert that another instance is running // Show alert that another instance is running
DispatchQueue.main.async { DispatchQueue.main.async {
let alert = NSAlert() let alert = NSAlert()
alert.messageText = "VibeTunnel is already running" 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.alertStyle = .informational
alert.addButton(withTitle: "OK") alert.addButton(withTitle: "OK")
alert.runModal() alert.runModal()
// Terminate this instance // Terminate this instance
NSApp.terminate(nil) NSApp.terminate(nil)
} }
return return
} }
} }
private func registerForDistributedNotifications() { private func registerForDistributedNotifications() {
DistributedNotificationCenter.default().addObserver( DistributedNotificationCenter.default().addObserver(
self, self,
selector: #selector(handleShowSettingsNotification), selector: #selector(handleShowSettingsNotification),
name: Self.showSettingsNotification, name: Self.showSettingsNotification,
object: nil) object: nil
)
} }
/// Shows the Settings window when another VibeTunnel instance asks us to. /// Shows the Settings window when another VibeTunnel instance asks us to.
@objc @objc
private func handleShowSettingsNotification(_ notification: Notification) { private func handleShowSettingsNotification(_ notification: Notification) {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) NSApp.openSettings()
} }
@objc private func handleCheckForUpdatesNotification() { @objc private func handleCheckForUpdatesNotification() {
sparkleUpdaterManager?.checkForUpdates() sparkleUpdaterManager?.checkForUpdates()
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
// Remove distributed notification observer // Remove distributed notification observer
let processInfo = ProcessInfo.processInfo let processInfo = ProcessInfo.processInfo
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 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 { if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
DistributedNotificationCenter.default().removeObserver( DistributedNotificationCenter.default().removeObserver(
self, self,
name: Self.showSettingsNotification, name: Self.showSettingsNotification,
object: nil) object: nil
)
} }
// Remove update check notification observer // Remove update check notification observer
NotificationCenter.default.removeObserver( NotificationCenter.default.removeObserver(
self, self,
name: Notification.Name("checkForUpdates"), name: Notification.Name("checkForUpdates"),
object: nil) object: nil
)
} }
// MARK: - Status Item // MARK: - Status Item
private func setupStatusItem() { private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button { if let button = statusItem?.button {
button.image = NSImage(named: "menubar") button.image = NSImage(named: "menubar")
button.image?.isTemplate = true button.image?.isTemplate = true
button.action = #selector(statusItemClicked) button.action = #selector(statusItemClicked)
button.target = self button.target = self
} }
// Create menu // Create menu
let menu = NSMenu() let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ",")) menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: "")) menu.addItem(NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: ""))
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem?.menu = menu statusItem?.menu = menu
} }
@objc private func statusItemClicked() { @objc private func statusItemClicked() {
// Left click shows menu // Left click shows menu
} }
@objc private func showSettings() { @objc private func showSettings() {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) NSApp.openSettings()
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
@objc private func showAbout() { @objc private func showAbout() {
showAboutInSettings() showAboutInSettings()
} }