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"),

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,15 +1,8 @@
//
// 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
@ -43,7 +36,7 @@ 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
@ -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,7 +85,7 @@ 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
@ -101,7 +100,7 @@ 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]) {

View file

@ -32,12 +32,16 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
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 {
@ -49,21 +53,21 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
} }
/// 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,

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")!
} }
@ -22,7 +15,7 @@ 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() {
@ -46,7 +39,12 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
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)
@ -90,7 +88,8 @@ enum APIKeyManager {
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])

View file

@ -25,7 +25,8 @@ 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")
@ -75,7 +76,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
/// 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
} }
@ -205,7 +206,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
} }
// 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()
} }
@ -232,9 +233,9 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
} }
} }
// Provide the feed URL dynamically based on update channel /// Provide the feed URL dynamically based on update channel
@objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? { @objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
return UpdateChannel.current.appcastURL.absoluteString UpdateChannel.current.appcastURL.absoluteString
} }
// MARK: - SPUStandardUserDriverDelegate // MARK: - SPUStandardUserDriverDelegate
@ -313,7 +314,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
// 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]?,

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
@ -31,7 +31,10 @@ 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)"
)
} }
} }

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 {
@ -61,7 +54,8 @@ actor TerminalManager {
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
} }
@ -70,7 +64,9 @@ actor TerminalManager {
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
@ -90,12 +86,12 @@ actor TerminalManager {
/// 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
@ -122,7 +118,7 @@ actor TerminalManager {
} }
} }
// Helper function for timeout /// Helper function for timeout
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { 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 {
@ -134,7 +130,9 @@ actor TerminalManager {
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
} }
@ -151,13 +149,13 @@ enum TunnelError: LocalizedError {
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 {
@ -43,9 +36,12 @@ public class TunnelClient {
// MARK: - Session Management // MARK: - Session Management
public func createSession(workingDirectory: String? = nil, public func createSession(
workingDirectory: String? = nil,
environment: [String: String]? = nil, environment: [String: String]? = nil,
shell: String? = nil) async throws -> CreateSessionResponse { 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,
@ -74,7 +70,12 @@ public class TunnelClient {
// 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,
@ -113,7 +114,7 @@ public class TunnelClient {
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")
@ -176,7 +177,7 @@ public class TunnelWebSocketClient: NSObject {
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))
} }
@ -185,7 +186,7 @@ public class TunnelWebSocketClient: NSObject {
} }
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)
@ -193,7 +194,7 @@ public class TunnelWebSocketClient: NSObject {
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)")
} }
} }
@ -203,7 +204,7 @@ public class TunnelWebSocketClient: NSObject {
} }
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))
} }
@ -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)
} }
@ -262,11 +272,11 @@ public enum TunnelClientError: LocalizedError {
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
@ -37,7 +30,7 @@ final class TunnelServer: ObservableObject {
@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
} }
@ -123,12 +116,12 @@ final class TunnelServer: ObservableObject {
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",
@ -142,7 +135,7 @@ final class TunnelServer: ObservableObject {
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(
@ -167,10 +160,11 @@ final class TunnelServer: ObservableObject {
} }
// Get session info // Get session info
sessions.get(":sessionId") { request, context async throws -> SessionInfo in sessions.get(":sessionId") { _, context async throws -> SessionInfo in
guard let sessionIdString = context.parameters.get("sessionId", as: String.self), guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
let sessionId = UUID(uuidString: sessionIdString), let sessionId = UUID(uuidString: sessionIdString),
let session = await self.terminalManager.getSession(id: sessionId) else { let session = await self.terminalManager.getSession(id: sessionId)
else {
throw HTTPError(.notFound) throw HTTPError(.notFound)
} }
@ -183,9 +177,10 @@ final class TunnelServer: ObservableObject {
} }
// 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)
else {
throw HTTPError(.badRequest) throw HTTPError(.badRequest)
} }
@ -219,7 +214,6 @@ final class TunnelServer: ObservableObject {
} }
} }
} }
} }
// MARK: - Integration with AppDelegate // MARK: - Integration with AppDelegate
@ -229,7 +223,7 @@ 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

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()
@ -50,7 +42,6 @@ class TunnelServerDemo {
// 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)")
} }
@ -98,7 +89,6 @@ class TunnelServerDemo {
// 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
@ -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
))
} }
} }

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)
)
} }
} }

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")
} }
} }
@ -87,8 +88,12 @@ 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 {

View file

@ -1,10 +1,3 @@
//
// SettingsView.swift
// VibeTunnel
//
// Created by Peter Steinberger on 15.06.25.
//
import SwiftUI import SwiftUI
enum SettingsTab: String, CaseIterable { enum SettingsTab: String, CaseIterable {
@ -14,17 +7,17 @@ enum SettingsTab: String, CaseIterable {
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"
} }
} }
} }
@ -125,7 +118,8 @@ struct GeneralSettingsView: View {
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> {
@ -134,7 +128,8 @@ struct GeneralSettingsView: View {
set: { newValue in set: { newValue in
showInDock = newValue showInDock = newValue
NSApp.setActivationPolicy(newValue ? .regular : .accessory) NSApp.setActivationPolicy(newValue ? .regular : .accessory)
}) }
)
} }
} }
@ -147,7 +142,7 @@ struct AdvancedSettingsView: View {
@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))
} }
@ -213,7 +208,9 @@ 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
.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped"
)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@ -227,8 +224,8 @@ struct AdvancedSettingsView: View {
.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)
} }
} }
@ -279,7 +276,8 @@ struct AdvancedSettingsView: View {
object: nil, object: nil,
userInfo: ["channel": newValue] userInfo: ["channel": newValue]
) )
}) }
)
} }
private func checkForUpdates() { private func checkForUpdates() {

View file

@ -1,5 +1,5 @@
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 {

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 {

View file

@ -1,12 +1,5 @@
//
// 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 {
@ -43,7 +36,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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 {
@ -66,7 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// 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()
} }
} }
} }
@ -76,7 +70,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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() {
@ -91,7 +86,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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()
@ -108,14 +104,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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() {
@ -127,20 +124,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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
@ -172,7 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
@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)
} }