Fix settings window not appearing from menu bar

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,5 @@
//
// TunnelClient.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Combine
import Foundation
/// Client SDK for interacting with the VibeTunnel server
public class TunnelClient {
@ -15,66 +8,74 @@ public class TunnelClient {
private var session: URLSession
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
public init(baseURL: URL = URL(string: "http://localhost:8080")!, apiKey: String) {
self.baseURL = baseURL
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.httpAdditionalHeaders = ["X-API-Key": apiKey]
self.session = URLSession(configuration: config)
decoder.dateDecodingStrategy = .iso8601
encoder.dateEncodingStrategy = .iso8601
}
// MARK: - Health Check
public func checkHealth() async throws -> Bool {
let url = baseURL.appendingPathComponent("health")
let (_, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
return httpResponse.statusCode == 200
}
// MARK: - Session Management
public func createSession(workingDirectory: String? = nil,
environment: [String: String]? = nil,
shell: String? = nil) async throws -> CreateSessionResponse {
public func createSession(
workingDirectory: String? = nil,
environment: [String: String]? = nil,
shell: String? = nil
)
async throws -> CreateSessionResponse {
let url = baseURL.appendingPathComponent("sessions")
let request = CreateSessionRequest(
workingDirectory: workingDirectory,
environment: environment,
shell: shell
)
return try await post(to: url, body: request)
}
public func listSessions() async throws -> [SessionInfo] {
let url = baseURL.appendingPathComponent("sessions")
let response: ListSessionsResponse = try await get(from: url)
return response.sessions
}
public func getSession(id: String) async throws -> SessionInfo {
let url = baseURL.appendingPathComponent("sessions/\(id)")
return try await get(from: url)
}
public func closeSession(id: String) async throws {
let url = baseURL.appendingPathComponent("sessions/\(id)")
try await delete(from: url)
}
// MARK: - Command Execution
public func executeCommand(sessionId: String, command: String, args: [String]? = nil) async throws -> CommandResponse {
public func executeCommand(
sessionId: String,
command: String,
args: [String]? = nil
)
async throws -> CommandResponse {
let url = baseURL.appendingPathComponent("execute")
let request = CommandRequest(
sessionId: sessionId,
@ -82,66 +83,66 @@ public class TunnelClient {
args: args,
environment: nil
)
return try await post(to: url, body: request)
}
// MARK: - WebSocket Connection
public func connectWebSocket(sessionId: String? = nil) -> TunnelWebSocketClient {
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
components.scheme = components.scheme == "https" ? "wss" : "ws"
components.path = components.path + "/ws/terminal"
let wsURL = components.url!
return TunnelWebSocketClient(url: wsURL, apiKey: apiKey, sessionId: sessionId)
}
// MARK: - Private Helpers
private func get<T: Decodable>(from url: URL) async throws -> T {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
return try decoder.decode(T.self, from: data)
}
private func post<T: Encodable, R: Decodable>(to url: URL, body: T) async throws -> R {
private func post<R: Decodable>(to url: URL, body: some Encodable) async throws -> R {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try encoder.encode(body)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
return try decoder.decode(R.self, from: data)
}
private func delete(from url: URL) async throws {
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
let (_, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard httpResponse.statusCode == 204 else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
@ -155,45 +156,45 @@ public class TunnelWebSocketClient: NSObject {
private var sessionId: String?
private var webSocketTask: URLSessionWebSocketTask?
private let messageSubject = PassthroughSubject<WSMessage, Never>()
public var messages: AnyPublisher<WSMessage, Never> {
messageSubject.eraseToAnyPublisher()
}
public init(url: URL, apiKey: String, sessionId: String? = nil) {
self.url = url
self.apiKey = apiKey
self.sessionId = sessionId
super.init()
}
public func connect() {
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
var request = URLRequest(url: url)
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
// Send initial connection message if session ID is provided
if let sessionId = sessionId {
if let sessionId {
send(WSMessage(type: .connect, sessionId: sessionId))
}
// Start receiving messages
receiveMessage()
}
public func send(_ message: WSMessage) {
guard let webSocketTask = webSocketTask else { return }
guard let webSocketTask else { return }
do {
let data = try JSONEncoder().encode(message)
let text = String(data: data, encoding: .utf8) ?? "{}"
let message = URLSessionWebSocketTask.Message.string(text)
webSocketTask.send(message) { error in
if let error = error {
if let error {
print("WebSocket send error: \(error)")
}
}
@ -201,16 +202,16 @@ public class TunnelWebSocketClient: NSObject {
print("Failed to encode message: \(error)")
}
}
public func sendCommand(_ command: String) {
guard let sessionId = sessionId else { return }
guard let sessionId else { return }
send(WSMessage(type: .command, sessionId: sessionId, data: command))
}
public func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil)
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
switch result {
@ -228,10 +229,10 @@ public class TunnelWebSocketClient: NSObject {
@unknown default:
break
}
// Continue receiving messages
self?.receiveMessage()
case .failure(let error):
print("WebSocket receive error: \(error)")
}
@ -242,11 +243,20 @@ public class TunnelWebSocketClient: NSObject {
// MARK: - URLSessionWebSocketDelegate
extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
public func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?
) {
print("WebSocket connected")
}
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
public func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
) {
print("WebSocket disconnected")
messageSubject.send(completion: .finished)
}
@ -258,15 +268,15 @@ public enum TunnelClientError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int)
case decodingError(Error)
public var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from server"
"Invalid response from server"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
"HTTP error: \(statusCode)"
case .decodingError(let error):
return "Decoding error: \(error.localizedDescription)"
"Decoding error: \(error.localizedDescription)"
}
}
}
}

View file

@ -1,24 +1,17 @@
//
// TunnelServer.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import AppKit
import Combine
import Logging
import os
import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdCore
import HTTPTypes
import Logging
import NIOCore
import os
// MARK: - Response Models
/// Server info response
struct ServerInfoResponse: Codable, ResponseEncodable {
struct ServerInfoResponse: Codable, ResponseGenerator {
let name: String
let version: String
let uptime: TimeInterval
@ -32,26 +25,26 @@ final class TunnelServer: ObservableObject {
private let logger = Logger(label: "VibeTunnel.TunnelServer")
private var app: Application<Router<BasicRequestContext>.Responder>?
private let terminalManager = TerminalManager()
@Published var isRunning = false
@Published var lastError: Error?
@Published var connectedClients = 0
init(port: Int = 8080) {
init(port: Int = 8_080) {
self.port = port
}
func start() async throws {
logger.info("Starting tunnel server on port \(port)")
do {
// Build the Hummingbird application
let app = try await buildApplication()
self.app = app
// Start the server
try await app.run()
await MainActor.run {
self.isRunning = true
}
@ -63,23 +56,23 @@ final class TunnelServer: ObservableObject {
throw error
}
}
func stop() async {
logger.info("Stopping tunnel server")
// In Hummingbird 2.x, the application lifecycle is managed differently
// Setting app to nil will trigger cleanup when it's deallocated
self.app = nil
await MainActor.run {
self.isRunning = false
}
}
private func buildApplication() async throws -> Application<Router<BasicRequestContext>.Responder> {
// Create router
let router = Router<BasicRequestContext>()
// Add middleware
router.add(middleware: LogRequestsMiddleware(.info))
router.add(middleware: CORSMiddleware(
@ -88,27 +81,27 @@ final class TunnelServer: ObservableObject {
allowMethods: [.get, .post, .delete, .options]
))
router.add(middleware: AuthenticationMiddleware(apiKeys: APIKeyManager.loadStoredAPIKeys()))
// Configure routes
configureRoutes(router)
// Add WebSocket routes
// TODO: Uncomment when HummingbirdWebSocket package is added
// router.addWebSocketRoutes(terminalManager: terminalManager)
// Create application configuration
let configuration = ApplicationConfiguration(
address: .hostname("127.0.0.1", port: port),
serverName: "VibeTunnel"
)
// Create and configure the application
let app = Application(
responder: router.buildResponder(),
configuration: configuration,
logger: logger
)
// Add cleanup task
// Start cleanup task
Task {
@ -117,18 +110,18 @@ final class TunnelServer: ObservableObject {
try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes
}
}
return app
}
private func configureRoutes(_ router: Router<BasicRequestContext>) {
// Health check endpoint
router.get("/health") { request, context -> HTTPResponse.Status in
return .ok
router.get("/health") { _, _ -> HTTPResponse.Status in
.ok
}
// Server info endpoint
router.get("/info") { request, context async -> ServerInfoResponse in
router.get("/info") { _, _ async -> ServerInfoResponse in
let sessionCount = await self.terminalManager.listSessions().count
return ServerInfoResponse(
name: "VibeTunnel",
@ -137,76 +130,78 @@ final class TunnelServer: ObservableObject {
sessions: sessionCount
)
}
// Session management endpoints
let sessions = router.group("sessions")
// List all sessions
sessions.get("/") { request, context async -> ListSessionsResponse in
let sessions = await self.terminalManager.listSessions()
let sessionInfos = sessions.map { session in
SessionInfo(
id: session.id.uuidString,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
isActive: session.isActive
)
}
return ListSessionsResponse(sessions: sessionInfos)
}
// Create new session
sessions.post("/") { request, context 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(
sessions.get("/") { _, _ async -> ListSessionsResponse in
let sessions = await self.terminalManager.listSessions()
let sessionInfos = sessions.map { session in
SessionInfo(
id: session.id.uuidString,
createdAt: session.createdAt,
lastActivity: session.lastActivity,
isActive: session.isActive
)
}
return ListSessionsResponse(sessions: sessionInfos)
}
// Create new session
sessions.post("/") { request, context 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
sessions.delete(":sessionId") { request, context async throws -> HTTPResponse.Status in
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
let sessionId = UUID(uuidString: sessionIdString) else {
throw HTTPError(.badRequest)
}
sessions.delete(":sessionId") { _, context async throws -> HTTPResponse.Status in
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
let sessionId = UUID(uuidString: sessionIdString)
else {
throw HTTPError(.badRequest)
}
await self.terminalManager.closeSession(id: sessionId)
return .noContent
}
// Command execution endpoint
router.post("/execute") { request, context async throws -> CommandResponse in
let commandRequest = try await request.decode(as: CommandRequest.self, context: context)
guard let sessionId = UUID(uuidString: commandRequest.sessionId) else {
throw HTTPError(.badRequest, message: "Invalid session ID")
}
do {
let (output, error) = try await self.terminalManager.executeCommand(
sessionId: sessionId,
command: commandRequest.command
)
return CommandResponse(
sessionId: commandRequest.sessionId,
output: output.isEmpty ? nil : output,
@ -219,7 +214,6 @@ final class TunnelServer: ObservableObject {
}
}
}
}
// MARK: - Integration with AppDelegate
@ -229,15 +223,15 @@ extension AppDelegate {
Task {
do {
let port = UserDefaults.standard.integer(forKey: "serverPort")
let tunnelServer = TunnelServer(port: port > 0 ? port : 8080)
let tunnelServer = TunnelServer(port: port > 0 ? port : 8_080)
// Store reference if needed
// self.tunnelServer = tunnelServer
try await tunnelServer.start()
} catch {
print("Failed to start tunnel server: \(error)")
// Show error alert
await MainActor.run {
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 Foundation
/// Demo code showing how to use the VibeTunnel server
class TunnelServerDemo {
static func runDemo() async {
// Get the API key (in production, this should be managed securely)
let apiKeys = APIKeyManager.loadStoredAPIKeys()
@ -18,62 +10,61 @@ class TunnelServerDemo {
print("No API key found")
return
}
print("Using API key: \(apiKey)")
// Create client
let client = TunnelClient(apiKey: apiKey)
do {
// Check server health
let isHealthy = try await client.checkHealth()
print("Server healthy: \(isHealthy)")
// Create a new session
let session = try await client.createSession(
workingDirectory: "/tmp",
shell: "/bin/zsh"
)
print("Created session: \(session.sessionId)")
// Execute a command
let response = try await client.executeCommand(
sessionId: session.sessionId,
command: "echo 'Hello from VibeTunnel!'"
)
print("Command output: \(response.output ?? "none")")
// List all sessions
let sessions = try await client.listSessions()
print("Active sessions: \(sessions.count)")
// Close the session
try await client.closeSession(id: session.sessionId)
print("Session closed")
} catch {
print("Demo error: \(error)")
}
}
static func runWebSocketDemo() async {
let apiKeys = APIKeyManager.loadStoredAPIKeys()
guard let apiKey = apiKeys.first else {
print("No API key found")
return
}
let client = TunnelClient(apiKey: apiKey)
do {
// Create a session first
let session = try await client.createSession()
print("Created session for WebSocket: \(session.sessionId)")
// Connect WebSocket
let wsClient = client.connectWebSocket(sessionId: session.sessionId)
wsClient.connect()
// Subscribe to messages
let cancellable = wsClient.messages.sink { message in
switch message.type {
@ -85,20 +76,19 @@ class TunnelServerDemo {
print("Message: \(message.type) - \(message.data ?? "")")
}
}
// Send some commands
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("pwd")
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("ls -la")
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
// Disconnect
wsClient.disconnect()
cancellable.cancel()
} catch {
print("WebSocket demo error: \(error)")
}
@ -107,45 +97,43 @@ class TunnelServerDemo {
// MARK: - cURL Examples
/*
Here are some example cURL commands to test the server:
# Set your API key
export API_KEY="your-api-key-here"
# Health check (no auth required)
curl http://localhost:8080/health
# Get server info
curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
# Create a new session
curl -X POST http://localhost:8080/sessions \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"workingDirectory": "/tmp",
"shell": "/bin/zsh"
}'
# List all sessions
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
# Execute a command
curl -X POST http://localhost:8080/execute \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "your-session-id",
"command": "ls -la"
}'
# Get session info
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
# Close a session
curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
# WebSocket connection (using websocat tool)
websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal
*/
// Here are some example cURL commands to test the server:
//
// # Set your API key
// export API_KEY="your-api-key-here"
//
// # Health check (no auth required)
// curl http://localhost:8080/health
//
// # Get server info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
//
// # Create a new session
// curl -X POST http://localhost:8080/sessions \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "workingDirectory": "/tmp",
// "shell": "/bin/zsh"
// }'
//
// # List all sessions
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
//
// # Execute a command
// curl -X POST http://localhost:8080/execute \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "sessionId": "your-session-id",
// "command": "ls -la"
// }'
//
// # Get session info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # Close a session
// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # WebSocket connection (using websocat tool)
// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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