mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
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:
parent
b80c710c08
commit
702b623d7f
20 changed files with 721 additions and 725 deletions
|
|
@ -10,7 +10,8 @@ let package = Package(
|
||||||
products: [
|
products: [
|
||||||
.library(
|
.library(
|
||||||
name: "VibeTunnel",
|
name: "VibeTunnel",
|
||||||
targets: ["VibeTunnel"])
|
targets: ["VibeTunnel"]
|
||||||
|
)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/realm/SwiftLint.git", from: "0.57.0"),
|
.package(url: "https://github.com/realm/SwiftLint.git", from: "0.57.0"),
|
||||||
|
|
@ -28,4 +29,4 @@ let package = Package(
|
||||||
path: "VibeTunnelTests"
|
path: "VibeTunnelTests"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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" : {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
//
|
|
||||||
// TunnelSession.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by VibeTunnel on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
|
|
||||||
/// Represents a terminal session that can be controlled remotely
|
/// Represents a terminal session that can be controlled remotely
|
||||||
public struct TunnelSession: Identifiable, Codable {
|
public struct TunnelSession: Identifiable, Codable, Sendable {
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public var lastActivity: Date
|
public var lastActivity: Date
|
||||||
public let processID: Int32?
|
public let processID: Int32?
|
||||||
public var isActive: Bool
|
public var isActive: Bool
|
||||||
|
|
||||||
public init(id: UUID = UUID(), processID: Int32? = nil) {
|
public init(id: UUID = UUID(), processID: Int32? = nil) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.createdAt = Date()
|
self.createdAt = Date()
|
||||||
|
|
@ -23,7 +16,7 @@ public struct TunnelSession: Identifiable, Codable {
|
||||||
self.processID = processID
|
self.processID = processID
|
||||||
self.isActive = true
|
self.isActive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
public mutating func updateActivity() {
|
public mutating func updateActivity() {
|
||||||
self.lastActivity = Date()
|
self.lastActivity = Date()
|
||||||
}
|
}
|
||||||
|
|
@ -34,7 +27,7 @@ public struct CreateSessionRequest: Codable {
|
||||||
public let workingDirectory: String?
|
public let workingDirectory: String?
|
||||||
public let environment: [String: String]?
|
public let environment: [String: String]?
|
||||||
public let shell: String?
|
public let shell: String?
|
||||||
|
|
||||||
public init(workingDirectory: String? = nil, environment: [String: String]? = nil, shell: String? = nil) {
|
public init(workingDirectory: String? = nil, environment: [String: String]? = nil, shell: String? = nil) {
|
||||||
self.workingDirectory = workingDirectory
|
self.workingDirectory = workingDirectory
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
|
@ -43,10 +36,10 @@ public struct CreateSessionRequest: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response after creating a session
|
/// Response after creating a session
|
||||||
public struct CreateSessionResponse: Codable, ResponseEncodable {
|
public struct CreateSessionResponse: Codable, ResponseGenerator {
|
||||||
public let sessionId: String
|
public let sessionId: String
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
|
|
||||||
public init(sessionId: String, createdAt: Date) {
|
public init(sessionId: String, createdAt: Date) {
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
|
|
@ -59,7 +52,7 @@ public struct CommandRequest: Codable {
|
||||||
public let command: String
|
public let command: String
|
||||||
public let args: [String]?
|
public let args: [String]?
|
||||||
public let environment: [String: String]?
|
public let environment: [String: String]?
|
||||||
|
|
||||||
public init(sessionId: String, command: String, args: [String]? = nil, environment: [String: String]? = nil) {
|
public init(sessionId: String, command: String, args: [String]? = nil, environment: [String: String]? = nil) {
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
self.command = command
|
self.command = command
|
||||||
|
|
@ -69,14 +62,20 @@ public struct CommandRequest: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Command execution response
|
/// Command execution response
|
||||||
public struct CommandResponse: Codable, ResponseEncodable {
|
public struct CommandResponse: Codable, ResponseGenerator {
|
||||||
public let sessionId: String
|
public let sessionId: String
|
||||||
public let output: String?
|
public let output: String?
|
||||||
public let error: String?
|
public let error: String?
|
||||||
public let exitCode: Int32?
|
public let exitCode: Int32?
|
||||||
public let timestamp: Date
|
public let timestamp: Date
|
||||||
|
|
||||||
public init(sessionId: String, output: String? = nil, error: String? = nil, exitCode: Int32? = nil, timestamp: Date = Date()) {
|
public init(
|
||||||
|
sessionId: String,
|
||||||
|
output: String? = nil,
|
||||||
|
error: String? = nil,
|
||||||
|
exitCode: Int32? = nil,
|
||||||
|
timestamp: Date = Date()
|
||||||
|
) {
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
self.output = output
|
self.output = output
|
||||||
self.error = error
|
self.error = error
|
||||||
|
|
@ -86,12 +85,12 @@ public struct CommandResponse: Codable, ResponseEncodable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Session information
|
/// Session information
|
||||||
public struct SessionInfo: Codable, ResponseEncodable {
|
public struct SessionInfo: Codable, ResponseGenerator {
|
||||||
public let id: String
|
public let id: String
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let lastActivity: Date
|
public let lastActivity: Date
|
||||||
public let isActive: Bool
|
public let isActive: Bool
|
||||||
|
|
||||||
public init(id: String, createdAt: Date, lastActivity: Date, isActive: Bool) {
|
public init(id: String, createdAt: Date, lastActivity: Date, isActive: Bool) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
|
|
@ -101,10 +100,10 @@ public struct SessionInfo: Codable, ResponseEncodable {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List sessions response
|
/// List sessions response
|
||||||
public struct ListSessionsResponse: Codable, ResponseEncodable {
|
public struct ListSessionsResponse: Codable, ResponseGenerator {
|
||||||
public let sessions: [SessionInfo]
|
public let sessions: [SessionInfo]
|
||||||
|
|
||||||
public init(sessions: [SessionInfo]) {
|
public init(sessions: [SessionInfo]) {
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import Foundation
|
||||||
public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
||||||
case stable
|
case stable
|
||||||
case prerelease
|
case prerelease
|
||||||
|
|
||||||
/// Human-readable display name for the update channel
|
/// Human-readable display name for the update channel
|
||||||
public var displayName: String {
|
public var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -17,7 +17,7 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
||||||
"Include Pre-releases"
|
"Include Pre-releases"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detailed description of what each channel includes
|
/// Detailed description of what each channel includes
|
||||||
public var description: String {
|
public var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -27,17 +27,21 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
||||||
"Receive both stable releases and beta/pre-release versions"
|
"Receive both stable releases and beta/pre-release versions"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Sparkle appcast URL for this update channel
|
/// The Sparkle appcast URL for this update channel
|
||||||
public var appcastURL: URL {
|
public var appcastURL: URL {
|
||||||
switch self {
|
switch self {
|
||||||
case .stable:
|
case .stable:
|
||||||
URL(string: "https://vibetunnel.sh/appcast.xml")!
|
Self.stableAppcastURL
|
||||||
case .prerelease:
|
case .prerelease:
|
||||||
URL(string: "https://vibetunnel.sh/appcast-prerelease.xml")!
|
Self.prereleaseAppcastURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static URLs to ensure they're validated at compile time
|
||||||
|
private static let stableAppcastURL = URL(string: "https://vibetunnel.sh/appcast.xml")!
|
||||||
|
private static let prereleaseAppcastURL = URL(string: "https://vibetunnel.sh/appcast-prerelease.xml")!
|
||||||
|
|
||||||
/// Whether this channel includes pre-release versions
|
/// Whether this channel includes pre-release versions
|
||||||
public var includesPreReleases: Bool {
|
public var includesPreReleases: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -47,38 +51,38 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The current update channel based on user defaults
|
/// The current update channel based on user defaults
|
||||||
public static var current: UpdateChannel {
|
public static var current: Self {
|
||||||
if let rawValue = UserDefaults.standard.string(forKey: "updateChannel"),
|
if let rawValue = UserDefaults.standard.string(forKey: "updateChannel"),
|
||||||
let channel = UpdateChannel(rawValue: rawValue) {
|
let channel = Self(rawValue: rawValue) {
|
||||||
return channel
|
return channel
|
||||||
}
|
}
|
||||||
return defaultChannel
|
return defaultChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The default update channel based on the current app version
|
/// The default update channel based on the current app version
|
||||||
public static var defaultChannel: UpdateChannel {
|
public static var defaultChannel: Self {
|
||||||
defaultChannel(for: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
|
defaultChannel(for: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determines if the current app version suggests this channel should be default
|
/// Determines if the current app version suggests this channel should be default
|
||||||
public static func defaultChannel(for appVersion: String) -> UpdateChannel {
|
public static func defaultChannel(for appVersion: String) -> Self {
|
||||||
// First check if this build was marked as a pre-release during build time
|
// First check if this build was marked as a pre-release during build time
|
||||||
if let isPrereleaseValue = Bundle.main.object(forInfoDictionaryKey: "IS_PRERELEASE_BUILD"),
|
if let isPrereleaseValue = Bundle.main.object(forInfoDictionaryKey: "IS_PRERELEASE_BUILD"),
|
||||||
let isPrerelease = isPrereleaseValue as? Bool,
|
let isPrerelease = isPrereleaseValue as? Bool,
|
||||||
isPrerelease {
|
isPrerelease {
|
||||||
return .prerelease
|
return .prerelease
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, check if the version string contains pre-release keywords
|
// Otherwise, check if the version string contains pre-release keywords
|
||||||
let prereleaseKeywords = ["beta", "alpha", "rc", "pre", "dev"]
|
let prereleaseKeywords = ["beta", "alpha", "rc", "pre", "dev"]
|
||||||
let lowercaseVersion = appVersion.lowercased()
|
let lowercaseVersion = appVersion.lowercased()
|
||||||
|
|
||||||
for keyword in prereleaseKeywords where lowercaseVersion.contains(keyword) {
|
for keyword in prereleaseKeywords where lowercaseVersion.contains(keyword) {
|
||||||
return .prerelease
|
return .prerelease
|
||||||
}
|
}
|
||||||
|
|
||||||
return .stable
|
return .stable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,4 +91,4 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
||||||
|
|
||||||
extension UpdateChannel: Identifiable {
|
extension UpdateChannel: Identifiable {
|
||||||
public var id: String { rawValue }
|
public var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
//
|
import CryptoKit
|
||||||
// AuthenticationMiddleware.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by VibeTunnel on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import HTTPTypes
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
import HummingbirdCore
|
import HummingbirdCore
|
||||||
import HTTPTypes
|
|
||||||
import Logging
|
import Logging
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// Custom HTTP header name for API key
|
/// Custom HTTP header name for API key
|
||||||
extension HTTPField.Name {
|
extension HTTPField.Name {
|
||||||
static let xAPIKey = Self("X-API-Key")!
|
static let xAPIKey = Self("X-API-Key")!
|
||||||
}
|
}
|
||||||
|
|
@ -21,14 +14,14 @@ extension HTTPField.Name {
|
||||||
struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
|
struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
|
||||||
private let logger = Logger(label: "VibeTunnel.AuthMiddleware")
|
private let logger = Logger(label: "VibeTunnel.AuthMiddleware")
|
||||||
private let bearerPrefix = "Bearer "
|
private let bearerPrefix = "Bearer "
|
||||||
|
|
||||||
// In production, this should be stored securely and configurable
|
/// In production, this should be stored securely and configurable
|
||||||
private let validApiKeys: Set<String>
|
private let validApiKeys: Set<String>
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Load API keys from storage
|
// Load API keys from storage
|
||||||
var apiKeys = APIKeyManager.loadStoredAPIKeys()
|
var apiKeys = APIKeyManager.loadStoredAPIKeys()
|
||||||
|
|
||||||
if apiKeys.isEmpty {
|
if apiKeys.isEmpty {
|
||||||
// Generate a default API key for development
|
// Generate a default API key for development
|
||||||
let defaultKey = Self.generateAPIKey()
|
let defaultKey = Self.generateAPIKey()
|
||||||
|
|
@ -38,27 +31,32 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
|
||||||
} else {
|
} else {
|
||||||
logger.info("Authentication initialized with \(apiKeys.count) stored API key(s)")
|
logger.info("Authentication initialized with \(apiKeys.count) stored API key(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.validApiKeys = apiKeys
|
self.validApiKeys = apiKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
init(apiKeys: Set<String>) {
|
init(apiKeys: Set<String>) {
|
||||||
self.validApiKeys = apiKeys
|
self.validApiKeys = apiKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
public func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response {
|
func handle(
|
||||||
|
_ request: Request,
|
||||||
|
context: Context,
|
||||||
|
next: (Request, Context) async throws -> Response
|
||||||
|
)
|
||||||
|
async throws -> Response {
|
||||||
// Skip authentication for health check and WebSocket upgrade
|
// Skip authentication for health check and WebSocket upgrade
|
||||||
if request.uri.path == "/health" || request.headers[.upgrade] == "websocket" {
|
if request.uri.path == "/health" || request.headers[.upgrade] == "websocket" {
|
||||||
return try await next(request, context)
|
return try await next(request, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for API key in header
|
// Check for API key in header
|
||||||
if let apiKey = request.headers[.xAPIKey] {
|
if let apiKey = request.headers[.xAPIKey] {
|
||||||
if validApiKeys.contains(apiKey) {
|
if validApiKeys.contains(apiKey) {
|
||||||
return try await next(request, context)
|
return try await next(request, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Bearer token
|
// Check for Bearer token
|
||||||
if let authorization = request.headers[.authorization],
|
if let authorization = request.headers[.authorization],
|
||||||
authorization.hasPrefix(bearerPrefix) {
|
authorization.hasPrefix(bearerPrefix) {
|
||||||
|
|
@ -67,12 +65,12 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
|
||||||
return try await next(request, context)
|
return try await next(request, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No valid authentication found
|
// No valid authentication found
|
||||||
logger.warning("Unauthorized request to \(request.uri.path)")
|
logger.warning("Unauthorized request to \(request.uri.path)")
|
||||||
throw HTTPError(.unauthorized, message: "Invalid or missing API key")
|
throw HTTPError(.unauthorized, message: "Invalid or missing API key")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a secure API key
|
/// Generate a secure API key
|
||||||
static func generateAPIKey() -> String {
|
static func generateAPIKey() -> String {
|
||||||
let randomBytes = SymmetricKey(size: .bits256)
|
let randomBytes = SymmetricKey(size: .bits256)
|
||||||
|
|
@ -87,10 +85,11 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
|
||||||
/// API Key management utilities
|
/// API Key management utilities
|
||||||
enum APIKeyManager {
|
enum APIKeyManager {
|
||||||
static let apiKeyStorageKey = "VibeTunnel.APIKeys"
|
static let apiKeyStorageKey = "VibeTunnel.APIKeys"
|
||||||
|
|
||||||
static func loadStoredAPIKeys() -> Set<String> {
|
static func loadStoredAPIKeys() -> Set<String> {
|
||||||
guard let data = UserDefaults.standard.data(forKey: apiKeyStorageKey),
|
guard let data = UserDefaults.standard.data(forKey: apiKeyStorageKey),
|
||||||
let keys = try? JSONDecoder().decode(Set<String>.self, from: data) else {
|
let keys = try? JSONDecoder().decode(Set<String>.self, from: data)
|
||||||
|
else {
|
||||||
// Generate and store a default key if none exists
|
// Generate and store a default key if none exists
|
||||||
let defaultKey = AuthenticationMiddleware<BasicRequestContext>.generateAPIKey()
|
let defaultKey = AuthenticationMiddleware<BasicRequestContext>.generateAPIKey()
|
||||||
let keys = Set([defaultKey])
|
let keys = Set([defaultKey])
|
||||||
|
|
@ -99,22 +98,22 @@ enum APIKeyManager {
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveAPIKeys(_ keys: Set<String>) {
|
static func saveAPIKeys(_ keys: Set<String>) {
|
||||||
if let data = try? JSONEncoder().encode(keys) {
|
if let data = try? JSONEncoder().encode(keys) {
|
||||||
UserDefaults.standard.set(data, forKey: apiKeyStorageKey)
|
UserDefaults.standard.set(data, forKey: apiKeyStorageKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func addAPIKey(_ key: String) {
|
static func addAPIKey(_ key: String) {
|
||||||
var keys = loadStoredAPIKeys()
|
var keys = loadStoredAPIKeys()
|
||||||
keys.insert(key)
|
keys.insert(key)
|
||||||
saveAPIKeys(keys)
|
saveAPIKeys(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func removeAPIKey(_ key: String) {
|
static func removeAPIKey(_ key: String) {
|
||||||
var keys = loadStoredAPIKeys()
|
var keys = loadStoredAPIKeys()
|
||||||
keys.remove(key)
|
keys.remove(key)
|
||||||
saveAPIKeys(keys)
|
saveAPIKeys(keys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,78 +25,79 @@ import UserNotifications
|
||||||
/// ```
|
/// ```
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate {
|
public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate,
|
||||||
|
UNUserNotificationCenterDelegate {
|
||||||
// MARK: Initialization
|
// MARK: Initialization
|
||||||
|
|
||||||
private nonisolated static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
|
private nonisolated static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
|
||||||
|
|
||||||
/// Initializes the updater manager and configures Sparkle
|
/// Initializes the updater manager and configures Sparkle
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
Self.staticLogger.info("Initializing SparkleUpdaterManager")
|
Self.staticLogger.info("Initializing SparkleUpdaterManager")
|
||||||
|
|
||||||
// Initialize the updater controller
|
// Initialize the updater controller
|
||||||
initializeUpdaterController()
|
initializeUpdaterController()
|
||||||
|
|
||||||
// Set up notification center for gentle reminders
|
// Set up notification center for gentle reminders
|
||||||
setupNotificationCenter()
|
setupNotificationCenter()
|
||||||
|
|
||||||
// Listen for update channel changes
|
// Listen for update channel changes
|
||||||
setupUpdateChannelListener()
|
setupUpdateChannelListener()
|
||||||
Self.staticLogger
|
Self.staticLogger
|
||||||
.info("SparkleUpdaterManager initialized. Updater controller initialization completed.")
|
.info("SparkleUpdaterManager initialized. Updater controller initialization completed.")
|
||||||
|
|
||||||
// Only schedule startup update check in release builds
|
// Only schedule startup update check in release builds
|
||||||
#if !DEBUG
|
#if !DEBUG
|
||||||
scheduleStartupUpdateCheck()
|
scheduleStartupUpdateCheck()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Public
|
// MARK: Public
|
||||||
|
|
||||||
// MARK: Properties
|
// MARK: Properties
|
||||||
|
|
||||||
/// The shared singleton instance of the updater manager
|
/// The shared singleton instance of the updater manager
|
||||||
static let shared = SparkleUpdaterManager()
|
static let shared = SparkleUpdaterManager()
|
||||||
|
|
||||||
/// The Sparkle updater controller instance
|
/// The Sparkle updater controller instance
|
||||||
private(set) var updaterController: SPUStandardUpdaterController?
|
private(set) var updaterController: SPUStandardUpdaterController?
|
||||||
|
|
||||||
/// The logger instance for update events
|
/// The logger instance for update events
|
||||||
private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
|
private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates")
|
||||||
|
|
||||||
// Track update state
|
// Track update state
|
||||||
private var updateInProgress = false
|
private var updateInProgress = false
|
||||||
private var lastUpdateCheckDate: Date?
|
private var lastUpdateCheckDate: Date?
|
||||||
private var gentleReminderTimer: Timer?
|
private var gentleReminderTimer: Timer?
|
||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
/// Checks for updates immediately
|
/// Checks for updates immediately
|
||||||
func checkForUpdates() {
|
func checkForUpdates() {
|
||||||
guard let updaterController = updaterController else {
|
guard let updaterController else {
|
||||||
logger.warning("Updater controller not available")
|
logger.warning("Updater controller not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("Manual update check initiated")
|
logger.info("Manual update check initiated")
|
||||||
updaterController.checkForUpdates(nil)
|
updaterController.checkForUpdates(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configures the update channel and restarts if needed
|
/// Configures the update channel and restarts if needed
|
||||||
func setUpdateChannel(_ channel: UpdateChannel) {
|
func setUpdateChannel(_ channel: UpdateChannel) {
|
||||||
// Store the channel preference
|
// Store the channel preference
|
||||||
UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel")
|
UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel")
|
||||||
|
|
||||||
logger.info("Update channel changed to: \(channel.rawValue)")
|
logger.info("Update channel changed to: \(channel.rawValue)")
|
||||||
|
|
||||||
// Force a new update check with the new feed
|
// Force a new update check with the new feed
|
||||||
checkForUpdates()
|
checkForUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Private
|
// MARK: Private
|
||||||
|
|
||||||
/// Initializes the Sparkle updater controller
|
/// Initializes the Sparkle updater controller
|
||||||
private func initializeUpdaterController() {
|
private func initializeUpdaterController() {
|
||||||
updaterController = SPUStandardUpdaterController(
|
updaterController = SPUStandardUpdaterController(
|
||||||
|
|
@ -104,17 +105,17 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
updaterDelegate: self,
|
updaterDelegate: self,
|
||||||
userDriverDelegate: self
|
userDriverDelegate: self
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let updater = updaterController?.updater else {
|
guard let updater = updaterController?.updater else {
|
||||||
logger.error("Failed to get updater from controller")
|
logger.error("Failed to get updater from controller")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure updater settings
|
// Configure updater settings
|
||||||
updater.automaticallyChecksForUpdates = true
|
updater.automaticallyChecksForUpdates = true
|
||||||
updater.updateCheckInterval = 60 * 60 // 1 hour
|
updater.updateCheckInterval = 60 * 60 // 1 hour
|
||||||
updater.automaticallyDownloadsUpdates = true
|
updater.automaticallyDownloadsUpdates = true
|
||||||
|
|
||||||
logger.info("""
|
logger.info("""
|
||||||
Updater configured:
|
Updater configured:
|
||||||
- Automatic checks: \(updater.automaticallyChecksForUpdates)
|
- Automatic checks: \(updater.automaticallyChecksForUpdates)
|
||||||
|
|
@ -122,11 +123,11 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
- Auto download: \(updater.automaticallyDownloadsUpdates)
|
- Auto download: \(updater.automaticallyDownloadsUpdates)
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up the notification center for gentle reminders
|
/// Sets up the notification center for gentle reminders
|
||||||
private func setupNotificationCenter() {
|
private func setupNotificationCenter() {
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
// Request notification permissions
|
// Request notification permissions
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
|
@ -138,7 +139,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up a listener for update channel changes
|
/// Sets up a listener for update channel changes
|
||||||
private func setupUpdateChannelListener() {
|
private func setupUpdateChannelListener() {
|
||||||
// Listen for channel changes via UserDefaults
|
// Listen for channel changes via UserDefaults
|
||||||
|
|
@ -149,7 +150,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
context: nil
|
context: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedules an update check after app startup
|
/// Schedules an update check after app startup
|
||||||
private func scheduleStartupUpdateCheck() {
|
private func scheduleStartupUpdateCheck() {
|
||||||
// Check for updates 5 seconds after app launch
|
// Check for updates 5 seconds after app launch
|
||||||
|
|
@ -157,16 +158,16 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
self?.checkForUpdatesInBackground()
|
self?.checkForUpdatesInBackground()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks for updates in the background without UI
|
/// Checks for updates in the background without UI
|
||||||
private func checkForUpdatesInBackground() {
|
private func checkForUpdatesInBackground() {
|
||||||
logger.info("Starting background update check")
|
logger.info("Starting background update check")
|
||||||
lastUpdateCheckDate = Date()
|
lastUpdateCheckDate = Date()
|
||||||
|
|
||||||
// Sparkle will check in the background when automaticallyChecksForUpdates is true
|
// Sparkle will check in the background when automaticallyChecksForUpdates is true
|
||||||
// We don't need to explicitly call checkForUpdates for background checks
|
// We don't need to explicitly call checkForUpdates for background checks
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a gentle reminder notification for available updates
|
/// Shows a gentle reminder notification for available updates
|
||||||
@MainActor
|
@MainActor
|
||||||
private func showGentleUpdateReminder() {
|
private func showGentleUpdateReminder() {
|
||||||
|
|
@ -174,13 +175,13 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
content.title = "Update Available"
|
content.title = "Update Available"
|
||||||
content.body = "A new version of VibeTunnel is ready to install. Click to update now."
|
content.body = "A new version of VibeTunnel is ready to install. Click to update now."
|
||||||
content.sound = .default
|
content.sound = .default
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
let request = UNNotificationRequest(
|
||||||
identifier: "update-reminder",
|
identifier: "update-reminder",
|
||||||
content: content,
|
content: content,
|
||||||
trigger: nil
|
trigger: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await UNUserNotificationCenter.current().add(request)
|
try await UNUserNotificationCenter.current().add(request)
|
||||||
|
|
@ -190,12 +191,12 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedules periodic gentle reminders for available updates
|
/// Schedules periodic gentle reminders for available updates
|
||||||
private func scheduleGentleReminders() {
|
private func scheduleGentleReminders() {
|
||||||
// Cancel any existing timer
|
// Cancel any existing timer
|
||||||
gentleReminderTimer?.invalidate()
|
gentleReminderTimer?.invalidate()
|
||||||
|
|
||||||
// Schedule reminders every 4 hours
|
// Schedule reminders every 4 hours
|
||||||
gentleReminderTimer = Timer.scheduledTimer(withTimeInterval: 4 * 60 * 60, repeats: true) {
|
gentleReminderTimer = Timer.scheduledTimer(withTimeInterval: 4 * 60 * 60, repeats: true) {
|
||||||
[weak self] _ in
|
[weak self] _ in
|
||||||
|
|
@ -203,42 +204,42 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
self?.showGentleUpdateReminder()
|
self?.showGentleUpdateReminder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show first reminder after 1 hour
|
// Show first reminder after 1 hour
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3600) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3_600) { [weak self] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.showGentleUpdateReminder()
|
self?.showGentleUpdateReminder()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SPUUpdaterDelegate
|
// MARK: - SPUUpdaterDelegate
|
||||||
|
|
||||||
@objc public nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
|
@objc public nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items")
|
Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
|
@objc public nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
Self.staticLogger.info("No update found: \(error.localizedDescription)")
|
Self.staticLogger.info("No update found: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
|
@objc public nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)")
|
Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide the feed URL dynamically based on update channel
|
/// Provide the feed URL dynamically based on update channel
|
||||||
@objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
|
@objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
|
||||||
return UpdateChannel.current.appcastURL.absoluteString
|
UpdateChannel.current.appcastURL.absoluteString
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - SPUStandardUserDriverDelegate
|
// MARK: - SPUStandardUserDriverDelegate
|
||||||
|
|
||||||
@objc public nonisolated func standardUserDriverWillHandleShowingUpdate(
|
@objc public nonisolated func standardUserDriverWillHandleShowingUpdate(
|
||||||
_ handleShowingUpdate: Bool,
|
_ handleShowingUpdate: Bool,
|
||||||
forUpdate update: SUAppcastItem,
|
forUpdate update: SUAppcastItem,
|
||||||
|
|
@ -253,23 +254,23 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
""")
|
""")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
|
@objc public func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) {
|
||||||
logger.info("User gave attention to update: \(update.displayVersionString)")
|
logger.info("User gave attention to update: \(update.displayVersionString)")
|
||||||
updateInProgress = true
|
updateInProgress = true
|
||||||
|
|
||||||
// Cancel gentle reminders since user is aware
|
// Cancel gentle reminders since user is aware
|
||||||
gentleReminderTimer?.invalidate()
|
gentleReminderTimer?.invalidate()
|
||||||
gentleReminderTimer = nil
|
gentleReminderTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func standardUserDriverWillFinishUpdateSession() {
|
@objc public func standardUserDriverWillFinishUpdateSession() {
|
||||||
logger.info("Update session finishing")
|
logger.info("Update session finishing")
|
||||||
updateInProgress = false
|
updateInProgress = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Background update handling
|
// MARK: - Background update handling
|
||||||
|
|
||||||
@objc public func updater(
|
@objc public func updater(
|
||||||
_ updater: SPUUpdater,
|
_ updater: SPUUpdater,
|
||||||
willDownloadUpdate item: SUAppcastItem,
|
willDownloadUpdate item: SUAppcastItem,
|
||||||
|
|
@ -277,25 +278,25 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
) {
|
) {
|
||||||
logger.info("Will download update: \(item.displayVersionString)")
|
logger.info("Will download update: \(item.displayVersionString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
|
@objc public func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) {
|
||||||
logger.info("Update downloaded: \(item.displayVersionString)")
|
logger.info("Update downloaded: \(item.displayVersionString)")
|
||||||
|
|
||||||
// For background downloads, schedule gentle reminders
|
// For background downloads, schedule gentle reminders
|
||||||
if !updateInProgress {
|
if !updateInProgress {
|
||||||
scheduleGentleReminders()
|
scheduleGentleReminders()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func updater(
|
@objc public func updater(
|
||||||
_ updater: SPUUpdater,
|
_ updater: SPUUpdater,
|
||||||
willInstallUpdate item: SUAppcastItem
|
willInstallUpdate item: SUAppcastItem
|
||||||
) {
|
) {
|
||||||
logger.info("Will install update: \(item.displayVersionString)")
|
logger.info("Will install update: \(item.displayVersionString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UNUserNotificationCenterDelegate
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
||||||
@objc public func userNotificationCenter(
|
@objc public func userNotificationCenter(
|
||||||
_ center: UNUserNotificationCenter,
|
_ center: UNUserNotificationCenter,
|
||||||
didReceive response: UNNotificationResponse,
|
didReceive response: UNNotificationResponse,
|
||||||
|
|
@ -303,17 +304,17 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
) {
|
) {
|
||||||
if response.notification.request.identifier == "update-reminder" {
|
if response.notification.request.identifier == "update-reminder" {
|
||||||
logger.info("User clicked update reminder notification")
|
logger.info("User clicked update reminder notification")
|
||||||
|
|
||||||
// Trigger the update UI
|
// Trigger the update UI
|
||||||
checkForUpdates()
|
checkForUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - KVO
|
// MARK: - KVO
|
||||||
|
|
||||||
public override func observeValue(
|
override public func observeValue(
|
||||||
forKeyPath keyPath: String?,
|
forKeyPath keyPath: String?,
|
||||||
of object: Any?,
|
of object: Any?,
|
||||||
change: [NSKeyValueChangeKey: Any]?,
|
change: [NSKeyValueChangeKey: Any]?,
|
||||||
|
|
@ -324,11 +325,11 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
||||||
setUpdateChannel(UpdateChannel.current)
|
setUpdateChannel(UpdateChannel.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
UserDefaults.standard.removeObserver(self, forKeyPath: "updateChannel")
|
UserDefaults.standard.removeObserver(self, forKeyPath: "updateChannel")
|
||||||
// Timer is cleaned up automatically when the object is deallocated
|
// Timer is cleaned up automatically when the object is deallocated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import ServiceManagement
|
|
||||||
import os
|
import os
|
||||||
|
import ServiceManagement
|
||||||
|
|
||||||
/// Protocol defining the interface for managing launch at login functionality.
|
/// Protocol defining the interface for managing launch at login functionality.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
@ -18,9 +18,9 @@ public protocol StartupControlling: Sendable {
|
||||||
@MainActor
|
@MainActor
|
||||||
public struct StartupManager: StartupControlling {
|
public struct StartupManager: StartupControlling {
|
||||||
private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "startup")
|
private let logger = Logger(subsystem: "com.amantus.vibetunnel", category: "startup")
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public func setLaunchAtLogin(enabled: Bool) {
|
public func setLaunchAtLogin(enabled: Bool) {
|
||||||
do {
|
do {
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|
@ -31,11 +31,14 @@ public struct StartupManager: StartupControlling {
|
||||||
logger.info("Successfully unregistered for launch at login.")
|
logger.info("Successfully unregistered for launch at login.")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Failed to \(enabled ? "register" : "unregister") for launch at login: \(error.localizedDescription)")
|
logger
|
||||||
|
.error(
|
||||||
|
"Failed to \(enabled ? "register" : "unregister") for launch at login: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isLaunchAtLoginEnabled: Bool {
|
public var isLaunchAtLoginEnabled: Bool {
|
||||||
SMAppService.mainApp.status == .enabled
|
SMAppService.mainApp.status == .enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
//
|
import Combine
|
||||||
// TerminalManager.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by VibeTunnel on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
import Logging
|
||||||
import Combine
|
|
||||||
|
|
||||||
/// Manages terminal sessions and command execution
|
/// Manages terminal sessions and command execution
|
||||||
actor TerminalManager {
|
actor TerminalManager {
|
||||||
|
|
@ -15,89 +8,92 @@ actor TerminalManager {
|
||||||
private var processes: [UUID: Process] = [:]
|
private var processes: [UUID: Process] = [:]
|
||||||
private var pipes: [UUID: (stdin: Pipe, stdout: Pipe, stderr: Pipe)] = [:]
|
private var pipes: [UUID: (stdin: Pipe, stdout: Pipe, stderr: Pipe)] = [:]
|
||||||
private let logger = Logger(label: "VibeTunnel.TerminalManager")
|
private let logger = Logger(label: "VibeTunnel.TerminalManager")
|
||||||
|
|
||||||
/// Create a new terminal session
|
/// Create a new terminal session
|
||||||
func createSession(request: CreateSessionRequest) throws -> TunnelSession {
|
func createSession(request: CreateSessionRequest) throws -> TunnelSession {
|
||||||
let session = TunnelSession()
|
let session = TunnelSession()
|
||||||
sessions[session.id] = session
|
sessions[session.id] = session
|
||||||
|
|
||||||
// Set up process and pipes
|
// Set up process and pipes
|
||||||
let process = Process()
|
let process = Process()
|
||||||
let stdinPipe = Pipe()
|
let stdinPipe = Pipe()
|
||||||
let stdoutPipe = Pipe()
|
let stdoutPipe = Pipe()
|
||||||
let stderrPipe = Pipe()
|
let stderrPipe = Pipe()
|
||||||
|
|
||||||
// Configure the process
|
// Configure the process
|
||||||
process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh")
|
process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh")
|
||||||
process.standardInput = stdinPipe
|
process.standardInput = stdinPipe
|
||||||
process.standardOutput = stdoutPipe
|
process.standardOutput = stdoutPipe
|
||||||
process.standardError = stderrPipe
|
process.standardError = stderrPipe
|
||||||
|
|
||||||
if let workingDirectory = request.workingDirectory {
|
if let workingDirectory = request.workingDirectory {
|
||||||
process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)
|
process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let environment = request.environment {
|
if let environment = request.environment {
|
||||||
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
|
process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the process
|
// Start the process
|
||||||
do {
|
do {
|
||||||
try process.run()
|
try process.run()
|
||||||
processes[session.id] = process
|
processes[session.id] = process
|
||||||
pipes[session.id] = (stdinPipe, stdoutPipe, stderrPipe)
|
pipes[session.id] = (stdinPipe, stdoutPipe, stderrPipe)
|
||||||
|
|
||||||
logger.info("Created session \(session.id) with process \(process.processIdentifier)")
|
logger.info("Created session \(session.id) with process \(process.processIdentifier)")
|
||||||
} catch {
|
} catch {
|
||||||
sessions.removeValue(forKey: session.id)
|
sessions.removeValue(forKey: session.id)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a command in a session
|
/// Execute a command in a session
|
||||||
func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) {
|
func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) {
|
||||||
guard var session = sessions[sessionId],
|
guard var session = sessions[sessionId],
|
||||||
let process = processes[sessionId],
|
let process = processes[sessionId],
|
||||||
let (stdin, stdout, stderr) = pipes[sessionId],
|
let (stdin, stdout, stderr) = pipes[sessionId],
|
||||||
process.isRunning else {
|
process.isRunning
|
||||||
|
else {
|
||||||
throw TunnelError.sessionNotFound
|
throw TunnelError.sessionNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update session activity
|
// Update session activity
|
||||||
session.updateActivity()
|
session.updateActivity()
|
||||||
sessions[sessionId] = session
|
sessions[sessionId] = session
|
||||||
|
|
||||||
// Send command to stdin
|
// Send command to stdin
|
||||||
let commandData = (command + "\n").data(using: .utf8)!
|
guard let commandData = (command + "\n").data(using: .utf8) else {
|
||||||
|
throw TunnelError.commandExecutionFailed("Failed to encode command")
|
||||||
|
}
|
||||||
stdin.fileHandleForWriting.write(commandData)
|
stdin.fileHandleForWriting.write(commandData)
|
||||||
|
|
||||||
// Read output with timeout
|
// Read output with timeout
|
||||||
let outputData = try await withTimeout(seconds: 5) {
|
let outputData = try await withTimeout(seconds: 5) {
|
||||||
stdout.fileHandleForReading.availableData
|
stdout.fileHandleForReading.availableData
|
||||||
}
|
}
|
||||||
|
|
||||||
let errorData = try await withTimeout(seconds: 0.1) {
|
let errorData = try await withTimeout(seconds: 0.1) {
|
||||||
stderr.fileHandleForReading.availableData
|
stderr.fileHandleForReading.availableData
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = String(data: outputData, encoding: .utf8) ?? ""
|
let output = String(data: outputData, encoding: .utf8) ?? ""
|
||||||
let error = String(data: errorData, encoding: .utf8) ?? ""
|
let error = String(data: errorData, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
return (output, error)
|
return (output, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all active sessions
|
/// Get all active sessions
|
||||||
func listSessions() -> [TunnelSession] {
|
func listSessions() -> [TunnelSession] {
|
||||||
return Array(sessions.values)
|
Array(sessions.values)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a specific session
|
/// Get a specific session
|
||||||
func getSession(id: UUID) -> TunnelSession? {
|
func getSession(id: UUID) -> TunnelSession? {
|
||||||
return sessions[id]
|
sessions[id]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close a session
|
/// Close a session
|
||||||
func closeSession(id: UUID) {
|
func closeSession(id: UUID) {
|
||||||
if let process = processes[id] {
|
if let process = processes[id] {
|
||||||
|
|
@ -106,14 +102,14 @@ actor TerminalManager {
|
||||||
}
|
}
|
||||||
pipes.removeValue(forKey: id)
|
pipes.removeValue(forKey: id)
|
||||||
sessions.removeValue(forKey: id)
|
sessions.removeValue(forKey: id)
|
||||||
|
|
||||||
logger.info("Closed session \(id)")
|
logger.info("Closed session \(id)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up inactive sessions
|
/// Clean up inactive sessions
|
||||||
func cleanupInactiveSessions(olderThan minutes: Int = 30) {
|
func cleanupInactiveSessions(olderThan minutes: Int = 30) {
|
||||||
let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60))
|
let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60))
|
||||||
|
|
||||||
for (id, session) in sessions {
|
for (id, session) in sessions {
|
||||||
if session.lastActivity < cutoffDate {
|
if session.lastActivity < cutoffDate {
|
||||||
closeSession(id: id)
|
closeSession(id: id)
|
||||||
|
|
@ -121,20 +117,22 @@ actor TerminalManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function for timeout
|
/// Helper function for timeout
|
||||||
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
|
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
|
||||||
try await withThrowingTaskGroup(of: T.self) { group in
|
try await withThrowingTaskGroup(of: T.self) { group in
|
||||||
group.addTask {
|
group.addTask {
|
||||||
try await operation()
|
try await operation()
|
||||||
}
|
}
|
||||||
|
|
||||||
group.addTask {
|
group.addTask {
|
||||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||||
throw TunnelError.timeout
|
throw TunnelError.timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = try await group.next()!
|
guard let result = try await group.next() else {
|
||||||
|
throw TunnelError.timeout
|
||||||
|
}
|
||||||
group.cancelAll()
|
group.cancelAll()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
@ -147,17 +145,17 @@ enum TunnelError: LocalizedError {
|
||||||
case commandExecutionFailed(String)
|
case commandExecutionFailed(String)
|
||||||
case timeout
|
case timeout
|
||||||
case invalidRequest
|
case invalidRequest
|
||||||
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .sessionNotFound:
|
case .sessionNotFound:
|
||||||
return "Session not found"
|
"Session not found"
|
||||||
case .commandExecutionFailed(let message):
|
case .commandExecutionFailed(let message):
|
||||||
return "Command execution failed: \(message)"
|
"Command execution failed: \(message)"
|
||||||
case .timeout:
|
case .timeout:
|
||||||
return "Operation timed out"
|
"Operation timed out"
|
||||||
case .invalidRequest:
|
case .invalidRequest:
|
||||||
return "Invalid request"
|
"Invalid request"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
//
|
|
||||||
// TunnelClient.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by VibeTunnel on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// Client SDK for interacting with the VibeTunnel server
|
/// Client SDK for interacting with the VibeTunnel server
|
||||||
public class TunnelClient {
|
public class TunnelClient {
|
||||||
|
|
@ -15,66 +8,74 @@ public class TunnelClient {
|
||||||
private var session: URLSession
|
private var session: URLSession
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
|
|
||||||
public init(baseURL: URL = URL(string: "http://localhost:8080")!, apiKey: String) {
|
public init(baseURL: URL = URL(string: "http://localhost:8080")!, apiKey: String) {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.apiKey = apiKey
|
self.apiKey = apiKey
|
||||||
|
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.httpAdditionalHeaders = ["X-API-Key": apiKey]
|
config.httpAdditionalHeaders = ["X-API-Key": apiKey]
|
||||||
self.session = URLSession(configuration: config)
|
self.session = URLSession(configuration: config)
|
||||||
|
|
||||||
decoder.dateDecodingStrategy = .iso8601
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
encoder.dateEncodingStrategy = .iso8601
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Health Check
|
// MARK: - Health Check
|
||||||
|
|
||||||
public func checkHealth() async throws -> Bool {
|
public func checkHealth() async throws -> Bool {
|
||||||
let url = baseURL.appendingPathComponent("health")
|
let url = baseURL.appendingPathComponent("health")
|
||||||
let (_, response) = try await session.data(from: url)
|
let (_, response) = try await session.data(from: url)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
throw TunnelClientError.invalidResponse
|
throw TunnelClientError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
return httpResponse.statusCode == 200
|
return httpResponse.statusCode == 200
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Management
|
// MARK: - Session Management
|
||||||
|
|
||||||
public func createSession(workingDirectory: String? = nil,
|
public func createSession(
|
||||||
environment: [String: String]? = nil,
|
workingDirectory: String? = nil,
|
||||||
shell: String? = nil) async throws -> CreateSessionResponse {
|
environment: [String: String]? = nil,
|
||||||
|
shell: String? = nil
|
||||||
|
)
|
||||||
|
async throws -> CreateSessionResponse {
|
||||||
let url = baseURL.appendingPathComponent("sessions")
|
let url = baseURL.appendingPathComponent("sessions")
|
||||||
let request = CreateSessionRequest(
|
let request = CreateSessionRequest(
|
||||||
workingDirectory: workingDirectory,
|
workingDirectory: workingDirectory,
|
||||||
environment: environment,
|
environment: environment,
|
||||||
shell: shell
|
shell: shell
|
||||||
)
|
)
|
||||||
|
|
||||||
return try await post(to: url, body: request)
|
return try await post(to: url, body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listSessions() async throws -> [SessionInfo] {
|
public func listSessions() async throws -> [SessionInfo] {
|
||||||
let url = baseURL.appendingPathComponent("sessions")
|
let url = baseURL.appendingPathComponent("sessions")
|
||||||
let response: ListSessionsResponse = try await get(from: url)
|
let response: ListSessionsResponse = try await get(from: url)
|
||||||
return response.sessions
|
return response.sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getSession(id: String) async throws -> SessionInfo {
|
public func getSession(id: String) async throws -> SessionInfo {
|
||||||
let url = baseURL.appendingPathComponent("sessions/\(id)")
|
let url = baseURL.appendingPathComponent("sessions/\(id)")
|
||||||
return try await get(from: url)
|
return try await get(from: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func closeSession(id: String) async throws {
|
public func closeSession(id: String) async throws {
|
||||||
let url = baseURL.appendingPathComponent("sessions/\(id)")
|
let url = baseURL.appendingPathComponent("sessions/\(id)")
|
||||||
try await delete(from: url)
|
try await delete(from: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Command Execution
|
// MARK: - Command Execution
|
||||||
|
|
||||||
public func executeCommand(sessionId: String, command: String, args: [String]? = nil) async throws -> CommandResponse {
|
public func executeCommand(
|
||||||
|
sessionId: String,
|
||||||
|
command: String,
|
||||||
|
args: [String]? = nil
|
||||||
|
)
|
||||||
|
async throws -> CommandResponse {
|
||||||
let url = baseURL.appendingPathComponent("execute")
|
let url = baseURL.appendingPathComponent("execute")
|
||||||
let request = CommandRequest(
|
let request = CommandRequest(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
|
|
@ -82,66 +83,66 @@ public class TunnelClient {
|
||||||
args: args,
|
args: args,
|
||||||
environment: nil
|
environment: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
return try await post(to: url, body: request)
|
return try await post(to: url, body: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - WebSocket Connection
|
// MARK: - WebSocket Connection
|
||||||
|
|
||||||
public func connectWebSocket(sessionId: String? = nil) -> TunnelWebSocketClient {
|
public func connectWebSocket(sessionId: String? = nil) -> TunnelWebSocketClient {
|
||||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
|
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
|
||||||
components.scheme = components.scheme == "https" ? "wss" : "ws"
|
components.scheme = components.scheme == "https" ? "wss" : "ws"
|
||||||
components.path = components.path + "/ws/terminal"
|
components.path = components.path + "/ws/terminal"
|
||||||
let wsURL = components.url!
|
let wsURL = components.url!
|
||||||
|
|
||||||
return TunnelWebSocketClient(url: wsURL, apiKey: apiKey, sessionId: sessionId)
|
return TunnelWebSocketClient(url: wsURL, apiKey: apiKey, sessionId: sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func get<T: Decodable>(from url: URL) async throws -> T {
|
private func get<T: Decodable>(from url: URL) async throws -> T {
|
||||||
let (data, response) = try await session.data(from: url)
|
let (data, response) = try await session.data(from: url)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
throw TunnelClientError.invalidResponse
|
throw TunnelClientError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
guard httpResponse.statusCode == 200 else {
|
guard httpResponse.statusCode == 200 else {
|
||||||
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
|
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return try decoder.decode(T.self, from: data)
|
return try decoder.decode(T.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func post<T: Encodable, R: Decodable>(to url: URL, body: T) async throws -> R {
|
private func post<R: Decodable>(to url: URL, body: some Encodable) async throws -> R {
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
request.httpBody = try encoder.encode(body)
|
request.httpBody = try encoder.encode(body)
|
||||||
|
|
||||||
let (data, response) = try await session.data(for: request)
|
let (data, response) = try await session.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
throw TunnelClientError.invalidResponse
|
throw TunnelClientError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
guard (200...299).contains(httpResponse.statusCode) else {
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
|
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
return try decoder.decode(R.self, from: data)
|
return try decoder.decode(R.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func delete(from url: URL) async throws {
|
private func delete(from url: URL) async throws {
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.httpMethod = "DELETE"
|
request.httpMethod = "DELETE"
|
||||||
|
|
||||||
let (_, response) = try await session.data(for: request)
|
let (_, response) = try await session.data(for: request)
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
throw TunnelClientError.invalidResponse
|
throw TunnelClientError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
guard httpResponse.statusCode == 204 else {
|
guard httpResponse.statusCode == 204 else {
|
||||||
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
|
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
|
||||||
}
|
}
|
||||||
|
|
@ -155,45 +156,45 @@ public class TunnelWebSocketClient: NSObject {
|
||||||
private var sessionId: String?
|
private var sessionId: String?
|
||||||
private var webSocketTask: URLSessionWebSocketTask?
|
private var webSocketTask: URLSessionWebSocketTask?
|
||||||
private let messageSubject = PassthroughSubject<WSMessage, Never>()
|
private let messageSubject = PassthroughSubject<WSMessage, Never>()
|
||||||
|
|
||||||
public var messages: AnyPublisher<WSMessage, Never> {
|
public var messages: AnyPublisher<WSMessage, Never> {
|
||||||
messageSubject.eraseToAnyPublisher()
|
messageSubject.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(url: URL, apiKey: String, sessionId: String? = nil) {
|
public init(url: URL, apiKey: String, sessionId: String? = nil) {
|
||||||
self.url = url
|
self.url = url
|
||||||
self.apiKey = apiKey
|
self.apiKey = apiKey
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func connect() {
|
public func connect() {
|
||||||
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
|
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
|
||||||
|
|
||||||
webSocketTask = session.webSocketTask(with: request)
|
webSocketTask = session.webSocketTask(with: request)
|
||||||
webSocketTask?.resume()
|
webSocketTask?.resume()
|
||||||
|
|
||||||
// Send initial connection message if session ID is provided
|
// Send initial connection message if session ID is provided
|
||||||
if let sessionId = sessionId {
|
if let sessionId {
|
||||||
send(WSMessage(type: .connect, sessionId: sessionId))
|
send(WSMessage(type: .connect, sessionId: sessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start receiving messages
|
// Start receiving messages
|
||||||
receiveMessage()
|
receiveMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func send(_ message: WSMessage) {
|
public func send(_ message: WSMessage) {
|
||||||
guard let webSocketTask = webSocketTask else { return }
|
guard let webSocketTask else { return }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(message)
|
let data = try JSONEncoder().encode(message)
|
||||||
let text = String(data: data, encoding: .utf8) ?? "{}"
|
let text = String(data: data, encoding: .utf8) ?? "{}"
|
||||||
let message = URLSessionWebSocketTask.Message.string(text)
|
let message = URLSessionWebSocketTask.Message.string(text)
|
||||||
|
|
||||||
webSocketTask.send(message) { error in
|
webSocketTask.send(message) { error in
|
||||||
if let error = error {
|
if let error {
|
||||||
print("WebSocket send error: \(error)")
|
print("WebSocket send error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -201,16 +202,16 @@ public class TunnelWebSocketClient: NSObject {
|
||||||
print("Failed to encode message: \(error)")
|
print("Failed to encode message: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func sendCommand(_ command: String) {
|
public func sendCommand(_ command: String) {
|
||||||
guard let sessionId = sessionId else { return }
|
guard let sessionId else { return }
|
||||||
send(WSMessage(type: .command, sessionId: sessionId, data: command))
|
send(WSMessage(type: .command, sessionId: sessionId, data: command))
|
||||||
}
|
}
|
||||||
|
|
||||||
public func disconnect() {
|
public func disconnect() {
|
||||||
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveMessage() {
|
private func receiveMessage() {
|
||||||
webSocketTask?.receive { [weak self] result in
|
webSocketTask?.receive { [weak self] result in
|
||||||
switch result {
|
switch result {
|
||||||
|
|
@ -228,10 +229,10 @@ public class TunnelWebSocketClient: NSObject {
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue receiving messages
|
// Continue receiving messages
|
||||||
self?.receiveMessage()
|
self?.receiveMessage()
|
||||||
|
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
print("WebSocket receive error: \(error)")
|
print("WebSocket receive error: \(error)")
|
||||||
}
|
}
|
||||||
|
|
@ -242,11 +243,20 @@ public class TunnelWebSocketClient: NSObject {
|
||||||
// MARK: - URLSessionWebSocketDelegate
|
// MARK: - URLSessionWebSocketDelegate
|
||||||
|
|
||||||
extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
|
extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
|
||||||
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didOpenWithProtocol protocol: String?
|
||||||
|
) {
|
||||||
print("WebSocket connected")
|
print("WebSocket connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
public func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
webSocketTask: URLSessionWebSocketTask,
|
||||||
|
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
||||||
|
reason: Data?
|
||||||
|
) {
|
||||||
print("WebSocket disconnected")
|
print("WebSocket disconnected")
|
||||||
messageSubject.send(completion: .finished)
|
messageSubject.send(completion: .finished)
|
||||||
}
|
}
|
||||||
|
|
@ -258,15 +268,15 @@ public enum TunnelClientError: LocalizedError {
|
||||||
case invalidResponse
|
case invalidResponse
|
||||||
case httpError(statusCode: Int)
|
case httpError(statusCode: Int)
|
||||||
case decodingError(Error)
|
case decodingError(Error)
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .invalidResponse:
|
case .invalidResponse:
|
||||||
return "Invalid response from server"
|
"Invalid response from server"
|
||||||
case .httpError(let statusCode):
|
case .httpError(let statusCode):
|
||||||
return "HTTP error: \(statusCode)"
|
"HTTP error: \(statusCode)"
|
||||||
case .decodingError(let error):
|
case .decodingError(let error):
|
||||||
return "Decoding error: \(error.localizedDescription)"
|
"Decoding error: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,17 @@
|
||||||
//
|
|
||||||
// TunnelServer.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by VibeTunnel on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import Combine
|
import Combine
|
||||||
import Logging
|
import Foundation
|
||||||
import os
|
import HTTPTypes
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
import HummingbirdCore
|
import HummingbirdCore
|
||||||
import HTTPTypes
|
import Logging
|
||||||
import NIOCore
|
import NIOCore
|
||||||
|
import os
|
||||||
|
|
||||||
// MARK: - Response Models
|
// MARK: - Response Models
|
||||||
|
|
||||||
/// Server info response
|
/// Server info response
|
||||||
struct ServerInfoResponse: Codable, ResponseEncodable {
|
struct ServerInfoResponse: Codable, ResponseGenerator {
|
||||||
let name: String
|
let name: String
|
||||||
let version: String
|
let version: String
|
||||||
let uptime: TimeInterval
|
let uptime: TimeInterval
|
||||||
|
|
@ -32,26 +25,26 @@ final class TunnelServer: ObservableObject {
|
||||||
private let logger = Logger(label: "VibeTunnel.TunnelServer")
|
private let logger = Logger(label: "VibeTunnel.TunnelServer")
|
||||||
private var app: Application<Router<BasicRequestContext>.Responder>?
|
private var app: Application<Router<BasicRequestContext>.Responder>?
|
||||||
private let terminalManager = TerminalManager()
|
private let terminalManager = TerminalManager()
|
||||||
|
|
||||||
@Published var isRunning = false
|
@Published var isRunning = false
|
||||||
@Published var lastError: Error?
|
@Published var lastError: Error?
|
||||||
@Published var connectedClients = 0
|
@Published var connectedClients = 0
|
||||||
|
|
||||||
init(port: Int = 8080) {
|
init(port: Int = 8_080) {
|
||||||
self.port = port
|
self.port = port
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() async throws {
|
func start() async throws {
|
||||||
logger.info("Starting tunnel server on port \(port)")
|
logger.info("Starting tunnel server on port \(port)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Build the Hummingbird application
|
// Build the Hummingbird application
|
||||||
let app = try await buildApplication()
|
let app = try await buildApplication()
|
||||||
self.app = app
|
self.app = app
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
try await app.run()
|
try await app.run()
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isRunning = true
|
self.isRunning = true
|
||||||
}
|
}
|
||||||
|
|
@ -63,23 +56,23 @@ final class TunnelServer: ObservableObject {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() async {
|
func stop() async {
|
||||||
logger.info("Stopping tunnel server")
|
logger.info("Stopping tunnel server")
|
||||||
|
|
||||||
// In Hummingbird 2.x, the application lifecycle is managed differently
|
// In Hummingbird 2.x, the application lifecycle is managed differently
|
||||||
// Setting app to nil will trigger cleanup when it's deallocated
|
// Setting app to nil will trigger cleanup when it's deallocated
|
||||||
self.app = nil
|
self.app = nil
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildApplication() async throws -> Application<Router<BasicRequestContext>.Responder> {
|
private func buildApplication() async throws -> Application<Router<BasicRequestContext>.Responder> {
|
||||||
// Create router
|
// Create router
|
||||||
let router = Router<BasicRequestContext>()
|
let router = Router<BasicRequestContext>()
|
||||||
|
|
||||||
// Add middleware
|
// Add middleware
|
||||||
router.add(middleware: LogRequestsMiddleware(.info))
|
router.add(middleware: LogRequestsMiddleware(.info))
|
||||||
router.add(middleware: CORSMiddleware(
|
router.add(middleware: CORSMiddleware(
|
||||||
|
|
@ -88,27 +81,27 @@ final class TunnelServer: ObservableObject {
|
||||||
allowMethods: [.get, .post, .delete, .options]
|
allowMethods: [.get, .post, .delete, .options]
|
||||||
))
|
))
|
||||||
router.add(middleware: AuthenticationMiddleware(apiKeys: APIKeyManager.loadStoredAPIKeys()))
|
router.add(middleware: AuthenticationMiddleware(apiKeys: APIKeyManager.loadStoredAPIKeys()))
|
||||||
|
|
||||||
// Configure routes
|
// Configure routes
|
||||||
configureRoutes(router)
|
configureRoutes(router)
|
||||||
|
|
||||||
// Add WebSocket routes
|
// Add WebSocket routes
|
||||||
// TODO: Uncomment when HummingbirdWebSocket package is added
|
// TODO: Uncomment when HummingbirdWebSocket package is added
|
||||||
// router.addWebSocketRoutes(terminalManager: terminalManager)
|
// router.addWebSocketRoutes(terminalManager: terminalManager)
|
||||||
|
|
||||||
// Create application configuration
|
// Create application configuration
|
||||||
let configuration = ApplicationConfiguration(
|
let configuration = ApplicationConfiguration(
|
||||||
address: .hostname("127.0.0.1", port: port),
|
address: .hostname("127.0.0.1", port: port),
|
||||||
serverName: "VibeTunnel"
|
serverName: "VibeTunnel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create and configure the application
|
// Create and configure the application
|
||||||
let app = Application(
|
let app = Application(
|
||||||
responder: router.buildResponder(),
|
responder: router.buildResponder(),
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
logger: logger
|
logger: logger
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add cleanup task
|
// Add cleanup task
|
||||||
// Start cleanup task
|
// Start cleanup task
|
||||||
Task {
|
Task {
|
||||||
|
|
@ -117,18 +110,18 @@ final class TunnelServer: ObservableObject {
|
||||||
try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes
|
try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureRoutes(_ router: Router<BasicRequestContext>) {
|
private func configureRoutes(_ router: Router<BasicRequestContext>) {
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
router.get("/health") { request, context -> HTTPResponse.Status in
|
router.get("/health") { _, _ -> HTTPResponse.Status in
|
||||||
return .ok
|
.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server info endpoint
|
// Server info endpoint
|
||||||
router.get("/info") { request, context async -> ServerInfoResponse in
|
router.get("/info") { _, _ async -> ServerInfoResponse in
|
||||||
let sessionCount = await self.terminalManager.listSessions().count
|
let sessionCount = await self.terminalManager.listSessions().count
|
||||||
return ServerInfoResponse(
|
return ServerInfoResponse(
|
||||||
name: "VibeTunnel",
|
name: "VibeTunnel",
|
||||||
|
|
@ -137,76 +130,78 @@ final class TunnelServer: ObservableObject {
|
||||||
sessions: sessionCount
|
sessions: sessionCount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session management endpoints
|
// Session management endpoints
|
||||||
let sessions = router.group("sessions")
|
let sessions = router.group("sessions")
|
||||||
|
|
||||||
// List all sessions
|
// List all sessions
|
||||||
sessions.get("/") { request, context async -> ListSessionsResponse in
|
sessions.get("/") { _, _ async -> ListSessionsResponse in
|
||||||
let sessions = await self.terminalManager.listSessions()
|
let sessions = await self.terminalManager.listSessions()
|
||||||
let sessionInfos = sessions.map { session in
|
let sessionInfos = sessions.map { session in
|
||||||
SessionInfo(
|
SessionInfo(
|
||||||
id: session.id.uuidString,
|
|
||||||
createdAt: session.createdAt,
|
|
||||||
lastActivity: session.lastActivity,
|
|
||||||
isActive: session.isActive
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ListSessionsResponse(sessions: sessionInfos)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new session
|
|
||||||
sessions.post("/") { request, context async throws -> CreateSessionResponse in
|
|
||||||
let createRequest = try await request.decode(as: CreateSessionRequest.self, context: context)
|
|
||||||
let session = try await self.terminalManager.createSession(request: createRequest)
|
|
||||||
|
|
||||||
return CreateSessionResponse(
|
|
||||||
sessionId: session.id.uuidString,
|
|
||||||
createdAt: session.createdAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get session info
|
|
||||||
sessions.get(":sessionId") { request, context async throws -> SessionInfo in
|
|
||||||
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
|
|
||||||
let sessionId = UUID(uuidString: sessionIdString),
|
|
||||||
let session = await self.terminalManager.getSession(id: sessionId) else {
|
|
||||||
throw HTTPError(.notFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
return SessionInfo(
|
|
||||||
id: session.id.uuidString,
|
id: session.id.uuidString,
|
||||||
createdAt: session.createdAt,
|
createdAt: session.createdAt,
|
||||||
lastActivity: session.lastActivity,
|
lastActivity: session.lastActivity,
|
||||||
isActive: session.isActive
|
isActive: session.isActive
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
return ListSessionsResponse(sessions: sessionInfos)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
sessions.post("/") { request, context async throws -> CreateSessionResponse in
|
||||||
|
let createRequest = try await request.decode(as: CreateSessionRequest.self, context: context)
|
||||||
|
let session = try await self.terminalManager.createSession(request: createRequest)
|
||||||
|
|
||||||
|
return CreateSessionResponse(
|
||||||
|
sessionId: session.id.uuidString,
|
||||||
|
createdAt: session.createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session info
|
||||||
|
sessions.get(":sessionId") { _, context async throws -> SessionInfo in
|
||||||
|
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
|
||||||
|
let sessionId = UUID(uuidString: sessionIdString),
|
||||||
|
let session = await self.terminalManager.getSession(id: sessionId)
|
||||||
|
else {
|
||||||
|
throw HTTPError(.notFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SessionInfo(
|
||||||
|
id: session.id.uuidString,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
lastActivity: session.lastActivity,
|
||||||
|
isActive: session.isActive
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Close session
|
// Close session
|
||||||
sessions.delete(":sessionId") { request, context async throws -> HTTPResponse.Status in
|
sessions.delete(":sessionId") { _, context async throws -> HTTPResponse.Status in
|
||||||
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
|
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
|
||||||
let sessionId = UUID(uuidString: sessionIdString) else {
|
let sessionId = UUID(uuidString: sessionIdString)
|
||||||
throw HTTPError(.badRequest)
|
else {
|
||||||
}
|
throw HTTPError(.badRequest)
|
||||||
|
}
|
||||||
|
|
||||||
await self.terminalManager.closeSession(id: sessionId)
|
await self.terminalManager.closeSession(id: sessionId)
|
||||||
return .noContent
|
return .noContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command execution endpoint
|
// Command execution endpoint
|
||||||
router.post("/execute") { request, context async throws -> CommandResponse in
|
router.post("/execute") { request, context async throws -> CommandResponse in
|
||||||
let commandRequest = try await request.decode(as: CommandRequest.self, context: context)
|
let commandRequest = try await request.decode(as: CommandRequest.self, context: context)
|
||||||
|
|
||||||
guard let sessionId = UUID(uuidString: commandRequest.sessionId) else {
|
guard let sessionId = UUID(uuidString: commandRequest.sessionId) else {
|
||||||
throw HTTPError(.badRequest, message: "Invalid session ID")
|
throw HTTPError(.badRequest, message: "Invalid session ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (output, error) = try await self.terminalManager.executeCommand(
|
let (output, error) = try await self.terminalManager.executeCommand(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
command: commandRequest.command
|
command: commandRequest.command
|
||||||
)
|
)
|
||||||
|
|
||||||
return CommandResponse(
|
return CommandResponse(
|
||||||
sessionId: commandRequest.sessionId,
|
sessionId: commandRequest.sessionId,
|
||||||
output: output.isEmpty ? nil : output,
|
output: output.isEmpty ? nil : output,
|
||||||
|
|
@ -219,7 +214,6 @@ final class TunnelServer: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Integration with AppDelegate
|
// MARK: - Integration with AppDelegate
|
||||||
|
|
@ -229,15 +223,15 @@ extension AppDelegate {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let port = UserDefaults.standard.integer(forKey: "serverPort")
|
let port = UserDefaults.standard.integer(forKey: "serverPort")
|
||||||
let tunnelServer = TunnelServer(port: port > 0 ? port : 8080)
|
let tunnelServer = TunnelServer(port: port > 0 ? port : 8_080)
|
||||||
|
|
||||||
// Store reference if needed
|
// Store reference if needed
|
||||||
// self.tunnelServer = tunnelServer
|
// self.tunnelServer = tunnelServer
|
||||||
|
|
||||||
try await tunnelServer.start()
|
try await tunnelServer.start()
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to start tunnel server: \(error)")
|
print("Failed to start tunnel server: \(error)")
|
||||||
|
|
||||||
// Show error alert
|
// Show error alert
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
|
|
@ -249,4 +243,4 @@ extension AppDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
//
|
|
||||||
// TunnelServerDemo.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by VibeTunnel on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
/// Demo code showing how to use the VibeTunnel server
|
/// Demo code showing how to use the VibeTunnel server
|
||||||
class TunnelServerDemo {
|
class TunnelServerDemo {
|
||||||
|
|
||||||
static func runDemo() async {
|
static func runDemo() async {
|
||||||
// Get the API key (in production, this should be managed securely)
|
// Get the API key (in production, this should be managed securely)
|
||||||
let apiKeys = APIKeyManager.loadStoredAPIKeys()
|
let apiKeys = APIKeyManager.loadStoredAPIKeys()
|
||||||
|
|
@ -18,62 +10,61 @@ class TunnelServerDemo {
|
||||||
print("No API key found")
|
print("No API key found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
print("Using API key: \(apiKey)")
|
print("Using API key: \(apiKey)")
|
||||||
|
|
||||||
// Create client
|
// Create client
|
||||||
let client = TunnelClient(apiKey: apiKey)
|
let client = TunnelClient(apiKey: apiKey)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Check server health
|
// Check server health
|
||||||
let isHealthy = try await client.checkHealth()
|
let isHealthy = try await client.checkHealth()
|
||||||
print("Server healthy: \(isHealthy)")
|
print("Server healthy: \(isHealthy)")
|
||||||
|
|
||||||
// Create a new session
|
// Create a new session
|
||||||
let session = try await client.createSession(
|
let session = try await client.createSession(
|
||||||
workingDirectory: "/tmp",
|
workingDirectory: "/tmp",
|
||||||
shell: "/bin/zsh"
|
shell: "/bin/zsh"
|
||||||
)
|
)
|
||||||
print("Created session: \(session.sessionId)")
|
print("Created session: \(session.sessionId)")
|
||||||
|
|
||||||
// Execute a command
|
// Execute a command
|
||||||
let response = try await client.executeCommand(
|
let response = try await client.executeCommand(
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
command: "echo 'Hello from VibeTunnel!'"
|
command: "echo 'Hello from VibeTunnel!'"
|
||||||
)
|
)
|
||||||
print("Command output: \(response.output ?? "none")")
|
print("Command output: \(response.output ?? "none")")
|
||||||
|
|
||||||
// List all sessions
|
// List all sessions
|
||||||
let sessions = try await client.listSessions()
|
let sessions = try await client.listSessions()
|
||||||
print("Active sessions: \(sessions.count)")
|
print("Active sessions: \(sessions.count)")
|
||||||
|
|
||||||
// Close the session
|
// Close the session
|
||||||
try await client.closeSession(id: session.sessionId)
|
try await client.closeSession(id: session.sessionId)
|
||||||
print("Session closed")
|
print("Session closed")
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
print("Demo error: \(error)")
|
print("Demo error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func runWebSocketDemo() async {
|
static func runWebSocketDemo() async {
|
||||||
let apiKeys = APIKeyManager.loadStoredAPIKeys()
|
let apiKeys = APIKeyManager.loadStoredAPIKeys()
|
||||||
guard let apiKey = apiKeys.first else {
|
guard let apiKey = apiKeys.first else {
|
||||||
print("No API key found")
|
print("No API key found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = TunnelClient(apiKey: apiKey)
|
let client = TunnelClient(apiKey: apiKey)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Create a session first
|
// Create a session first
|
||||||
let session = try await client.createSession()
|
let session = try await client.createSession()
|
||||||
print("Created session for WebSocket: \(session.sessionId)")
|
print("Created session for WebSocket: \(session.sessionId)")
|
||||||
|
|
||||||
// Connect WebSocket
|
// Connect WebSocket
|
||||||
let wsClient = client.connectWebSocket(sessionId: session.sessionId)
|
let wsClient = client.connectWebSocket(sessionId: session.sessionId)
|
||||||
wsClient.connect()
|
wsClient.connect()
|
||||||
|
|
||||||
// Subscribe to messages
|
// Subscribe to messages
|
||||||
let cancellable = wsClient.messages.sink { message in
|
let cancellable = wsClient.messages.sink { message in
|
||||||
switch message.type {
|
switch message.type {
|
||||||
|
|
@ -85,20 +76,19 @@ class TunnelServerDemo {
|
||||||
print("Message: \(message.type) - \(message.data ?? "")")
|
print("Message: \(message.type) - \(message.data ?? "")")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send some commands
|
// Send some commands
|
||||||
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
||||||
wsClient.sendCommand("pwd")
|
wsClient.sendCommand("pwd")
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
||||||
wsClient.sendCommand("ls -la")
|
wsClient.sendCommand("ls -la")
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
||||||
|
|
||||||
// Disconnect
|
// Disconnect
|
||||||
wsClient.disconnect()
|
wsClient.disconnect()
|
||||||
cancellable.cancel()
|
cancellable.cancel()
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
print("WebSocket demo error: \(error)")
|
print("WebSocket demo error: \(error)")
|
||||||
}
|
}
|
||||||
|
|
@ -107,45 +97,43 @@ class TunnelServerDemo {
|
||||||
|
|
||||||
// MARK: - cURL Examples
|
// MARK: - cURL Examples
|
||||||
|
|
||||||
/*
|
// Here are some example cURL commands to test the server:
|
||||||
Here are some example cURL commands to test the server:
|
//
|
||||||
|
// # Set your API key
|
||||||
# Set your API key
|
// export API_KEY="your-api-key-here"
|
||||||
export API_KEY="your-api-key-here"
|
//
|
||||||
|
// # Health check (no auth required)
|
||||||
# Health check (no auth required)
|
// curl http://localhost:8080/health
|
||||||
curl http://localhost:8080/health
|
//
|
||||||
|
// # Get server info
|
||||||
# Get server info
|
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
|
||||||
curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
|
//
|
||||||
|
// # Create a new session
|
||||||
# Create a new session
|
// curl -X POST http://localhost:8080/sessions \
|
||||||
curl -X POST http://localhost:8080/sessions \
|
// -H "X-API-Key: $API_KEY" \
|
||||||
-H "X-API-Key: $API_KEY" \
|
// -H "Content-Type: application/json" \
|
||||||
-H "Content-Type: application/json" \
|
// -d '{
|
||||||
-d '{
|
// "workingDirectory": "/tmp",
|
||||||
"workingDirectory": "/tmp",
|
// "shell": "/bin/zsh"
|
||||||
"shell": "/bin/zsh"
|
// }'
|
||||||
}'
|
//
|
||||||
|
// # List all sessions
|
||||||
# List all sessions
|
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
|
||||||
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
|
//
|
||||||
|
// # Execute a command
|
||||||
# Execute a command
|
// curl -X POST http://localhost:8080/execute \
|
||||||
curl -X POST http://localhost:8080/execute \
|
// -H "X-API-Key: $API_KEY" \
|
||||||
-H "X-API-Key: $API_KEY" \
|
// -H "Content-Type: application/json" \
|
||||||
-H "Content-Type: application/json" \
|
// -d '{
|
||||||
-d '{
|
// "sessionId": "your-session-id",
|
||||||
"sessionId": "your-session-id",
|
// "command": "ls -la"
|
||||||
"command": "ls -la"
|
// }'
|
||||||
}'
|
//
|
||||||
|
// # Get session info
|
||||||
# Get session info
|
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
|
||||||
curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
|
//
|
||||||
|
// # Close a session
|
||||||
# Close a session
|
// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
|
||||||
curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
|
//
|
||||||
|
// # WebSocket connection (using websocat tool)
|
||||||
# WebSocket connection (using websocat tool)
|
// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal
|
||||||
websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal
|
|
||||||
*/
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
//
|
|
||||||
// WebSocketHandler.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by VibeTunnel on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Hummingbird
|
import Hummingbird
|
||||||
import HummingbirdCore
|
import HummingbirdCore
|
||||||
import NIOCore
|
import NIOCore
|
||||||
|
|
||||||
// import NIOWebSocket // TODO: This is available in swift-nio package
|
// import NIOWebSocket // TODO: This is available in swift-nio package
|
||||||
import Logging
|
import Logging
|
||||||
|
|
||||||
|
|
@ -29,7 +23,7 @@ public struct WSMessage: Codable {
|
||||||
public let sessionId: String?
|
public let sessionId: String?
|
||||||
public let data: String?
|
public let data: String?
|
||||||
public let timestamp: Date
|
public let timestamp: Date
|
||||||
|
|
||||||
public init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) {
|
public init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) {
|
||||||
self.type = type
|
self.type = type
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
|
|
@ -39,161 +33,162 @@ public struct WSMessage: Codable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Enable when HummingbirdWebSocket package is added
|
// TODO: Enable when HummingbirdWebSocket package is added
|
||||||
/*
|
// /// Handles WebSocket connections for real-time terminal communication
|
||||||
/// Handles WebSocket connections for real-time terminal communication
|
// final class WebSocketHandler {
|
||||||
final class WebSocketHandler {
|
// private let terminalManager: TerminalManager
|
||||||
private let terminalManager: TerminalManager
|
// private let logger = Logger(label: "VibeTunnel.WebSocketHandler")
|
||||||
private let logger = Logger(label: "VibeTunnel.WebSocketHandler")
|
// private var activeConnections: [UUID: WebSocketHandler.Connection] = [:]
|
||||||
private var activeConnections: [UUID: WebSocketHandler.Connection] = [:]
|
//
|
||||||
|
// init(terminalManager: TerminalManager) {
|
||||||
init(terminalManager: TerminalManager) {
|
// self.terminalManager = terminalManager
|
||||||
self.terminalManager = terminalManager
|
// }
|
||||||
}
|
//
|
||||||
|
// /// Handle incoming WebSocket connection
|
||||||
/// Handle incoming WebSocket connection
|
// func handle(ws: WebSocket, context: some RequestContext) async {
|
||||||
func handle(ws: WebSocket, context: some RequestContext) async {
|
// let connectionId = UUID()
|
||||||
let connectionId = UUID()
|
// let connection = Connection(id: connectionId, websocket: ws)
|
||||||
let connection = Connection(id: connectionId, websocket: ws)
|
//
|
||||||
|
// await MainActor.run {
|
||||||
await MainActor.run {
|
// activeConnections[connectionId] = connection
|
||||||
activeConnections[connectionId] = connection
|
// }
|
||||||
}
|
//
|
||||||
|
// logger.info("WebSocket connection established: \(connectionId)")
|
||||||
logger.info("WebSocket connection established: \(connectionId)")
|
//
|
||||||
|
// // Set up message handlers
|
||||||
// Set up message handlers
|
// ws.onText { [weak self] ws, text in
|
||||||
ws.onText { [weak self] ws, text in
|
// await self?.handleTextMessage(text, connection: connection)
|
||||||
await self?.handleTextMessage(text, connection: connection)
|
// }
|
||||||
}
|
//
|
||||||
|
// ws.onBinary { [weak self] ws, buffer in
|
||||||
ws.onBinary { [weak self] ws, buffer in
|
// // Handle binary data if needed
|
||||||
// Handle binary data if needed
|
// self?.logger.debug("Received binary data: \(buffer.readableBytes) bytes")
|
||||||
self?.logger.debug("Received binary data: \(buffer.readableBytes) bytes")
|
// }
|
||||||
}
|
//
|
||||||
|
// ws.onClose { [weak self] closeCode in
|
||||||
ws.onClose { [weak self] closeCode in
|
// await self?.handleClose(connection: connection)
|
||||||
await self?.handleClose(connection: connection)
|
// }
|
||||||
}
|
//
|
||||||
|
// // Send initial connection acknowledgment
|
||||||
// Send initial connection acknowledgment
|
// await sendMessage(WSMessage(type: .connect, data: "Connected to VibeTunnel"), to: connection)
|
||||||
await sendMessage(WSMessage(type: .connect, data: "Connected to VibeTunnel"), to: connection)
|
//
|
||||||
|
// // Keep connection alive with periodic pings
|
||||||
// Keep connection alive with periodic pings
|
// Task {
|
||||||
Task {
|
// while !Task.isCancelled && !connection.isClosed {
|
||||||
while !Task.isCancelled && !connection.isClosed {
|
// await sendMessage(WSMessage(type: .ping), to: connection)
|
||||||
await sendMessage(WSMessage(type: .ping), to: connection)
|
// try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds
|
||||||
try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//
|
||||||
|
// private func handleTextMessage(_ text: String, connection: Connection) async {
|
||||||
private func handleTextMessage(_ text: String, connection: Connection) async {
|
// guard let data = text.data(using: .utf8),
|
||||||
guard let data = text.data(using: .utf8),
|
// let message = try? JSONDecoder().decode(WSMessage.self, from: data) else {
|
||||||
let message = try? JSONDecoder().decode(WSMessage.self, from: data) else {
|
// logger.error("Failed to decode WebSocket message: \(text)")
|
||||||
logger.error("Failed to decode WebSocket message: \(text)")
|
// await sendError("Invalid message format", to: connection)
|
||||||
await sendError("Invalid message format", to: connection)
|
// return
|
||||||
return
|
// }
|
||||||
}
|
//
|
||||||
|
// switch message.type {
|
||||||
switch message.type {
|
// case .connect:
|
||||||
case .connect:
|
// // Handle session connection
|
||||||
// Handle session connection
|
// if let sessionId = message.sessionId,
|
||||||
if let sessionId = message.sessionId,
|
// let uuid = UUID(uuidString: sessionId) {
|
||||||
let uuid = UUID(uuidString: sessionId) {
|
// connection.sessionId = uuid
|
||||||
connection.sessionId = uuid
|
// await sendMessage(WSMessage(type: .output, sessionId: sessionId, data: "Session connected"), to:
|
||||||
await sendMessage(WSMessage(type: .output, sessionId: sessionId, data: "Session connected"), to: connection)
|
// connection)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
case .command:
|
// case .command:
|
||||||
// Execute command in terminal session
|
// // Execute command in terminal session
|
||||||
guard let sessionId = connection.sessionId,
|
// guard let sessionId = connection.sessionId,
|
||||||
let command = message.data else {
|
// let command = message.data else {
|
||||||
await sendError("Session ID and command required", to: connection)
|
// await sendError("Session ID and command required", to: connection)
|
||||||
return
|
// return
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
do {
|
// do {
|
||||||
let (output, error) = try await terminalManager.executeCommand(sessionId: sessionId, command: command)
|
// let (output, error) = try await terminalManager.executeCommand(sessionId: sessionId, command: command)
|
||||||
|
//
|
||||||
if !output.isEmpty {
|
// if !output.isEmpty {
|
||||||
await sendMessage(WSMessage(type: .output, sessionId: sessionId.uuidString, data: output), to: connection)
|
// await sendMessage(WSMessage(type: .output, sessionId: sessionId.uuidString, data: output), to:
|
||||||
}
|
// connection)
|
||||||
|
// }
|
||||||
if !error.isEmpty {
|
//
|
||||||
await sendMessage(WSMessage(type: .error, sessionId: sessionId.uuidString, data: error), to: connection)
|
// if !error.isEmpty {
|
||||||
}
|
// await sendMessage(WSMessage(type: .error, sessionId: sessionId.uuidString, data: error), to:
|
||||||
} catch {
|
// connection)
|
||||||
await sendError(error.localizedDescription, to: connection)
|
// }
|
||||||
}
|
// } catch {
|
||||||
|
// await sendError(error.localizedDescription, to: connection)
|
||||||
case .ping:
|
// }
|
||||||
// Respond to ping with pong
|
//
|
||||||
await sendMessage(WSMessage(type: .pong), to: connection)
|
// case .ping:
|
||||||
|
// // Respond to ping with pong
|
||||||
case .close:
|
// await sendMessage(WSMessage(type: .pong), to: connection)
|
||||||
// Close the session
|
//
|
||||||
if let sessionId = connection.sessionId {
|
// case .close:
|
||||||
await terminalManager.closeSession(id: sessionId)
|
// // Close the session
|
||||||
}
|
// if let sessionId = connection.sessionId {
|
||||||
try? await connection.websocket.close()
|
// await terminalManager.closeSession(id: sessionId)
|
||||||
|
// }
|
||||||
default:
|
// try? await connection.websocket.close()
|
||||||
logger.warning("Unhandled message type: \(message.type)")
|
//
|
||||||
}
|
// default:
|
||||||
}
|
// logger.warning("Unhandled message type: \(message.type)")
|
||||||
|
// }
|
||||||
private func handleClose(connection: Connection) async {
|
// }
|
||||||
logger.info("WebSocket connection closed: \(connection.id)")
|
//
|
||||||
|
// private func handleClose(connection: Connection) async {
|
||||||
await MainActor.run {
|
// logger.info("WebSocket connection closed: \(connection.id)")
|
||||||
activeConnections.removeValue(forKey: connection.id)
|
//
|
||||||
}
|
// await MainActor.run {
|
||||||
|
// activeConnections.removeValue(forKey: connection.id)
|
||||||
// Clean up associated session if any
|
// }
|
||||||
if let sessionId = connection.sessionId {
|
//
|
||||||
await terminalManager.closeSession(id: sessionId)
|
// // Clean up associated session if any
|
||||||
}
|
// if let sessionId = connection.sessionId {
|
||||||
|
// await terminalManager.closeSession(id: sessionId)
|
||||||
connection.isClosed = true
|
// }
|
||||||
}
|
//
|
||||||
|
// connection.isClosed = true
|
||||||
private func sendMessage(_ message: WSMessage, to connection: Connection) async {
|
// }
|
||||||
do {
|
//
|
||||||
let data = try JSONEncoder().encode(message)
|
// private func sendMessage(_ message: WSMessage, to connection: Connection) async {
|
||||||
let text = String(data: data, encoding: .utf8) ?? "{}"
|
// do {
|
||||||
try await connection.websocket.send(text: text)
|
// let data = try JSONEncoder().encode(message)
|
||||||
} catch {
|
// let text = String(data: data, encoding: .utf8) ?? "{}"
|
||||||
logger.error("Failed to send WebSocket message: \(error)")
|
// try await connection.websocket.send(text: text)
|
||||||
}
|
// } catch {
|
||||||
}
|
// logger.error("Failed to send WebSocket message: \(error)")
|
||||||
|
// }
|
||||||
private func sendError(_ error: String, to connection: Connection) async {
|
// }
|
||||||
await sendMessage(WSMessage(type: .error, data: error), to: connection)
|
//
|
||||||
}
|
// private func sendError(_ error: String, to connection: Connection) async {
|
||||||
|
// await sendMessage(WSMessage(type: .error, data: error), to: connection)
|
||||||
/// WebSocket connection wrapper
|
// }
|
||||||
class Connection {
|
//
|
||||||
let id: UUID
|
// /// WebSocket connection wrapper
|
||||||
let websocket: WebSocket
|
// class Connection {
|
||||||
var sessionId: UUID?
|
// let id: UUID
|
||||||
var isClosed = false
|
// let websocket: WebSocket
|
||||||
|
// var sessionId: UUID?
|
||||||
init(id: UUID, websocket: WebSocket) {
|
// var isClosed = false
|
||||||
self.id = id
|
//
|
||||||
self.websocket = websocket
|
// init(id: UUID, websocket: WebSocket) {
|
||||||
}
|
// self.id = id
|
||||||
}
|
// self.websocket = websocket
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
/// Extension to add WebSocket routes to the router
|
// }
|
||||||
extension RouterBuilder {
|
//
|
||||||
mutating func addWebSocketRoutes(terminalManager: TerminalManager) {
|
// /// Extension to add WebSocket routes to the router
|
||||||
let wsHandler = WebSocketHandler(terminalManager: terminalManager)
|
// extension RouterBuilder {
|
||||||
|
// mutating func addWebSocketRoutes(terminalManager: TerminalManager) {
|
||||||
// WebSocket endpoint for terminal streaming
|
// let wsHandler = WebSocketHandler(terminalManager: terminalManager)
|
||||||
ws("/ws/terminal") { request, ws, context in
|
//
|
||||||
await wsHandler.handle(ws: ws, context: context)
|
// // WebSocket endpoint for terminal streaming
|
||||||
}
|
// ws("/ws/terminal") { request, ws, context in
|
||||||
}
|
// await wsHandler.handle(ws: ws, context: context)
|
||||||
}
|
// }
|
||||||
*/
|
// }
|
||||||
|
// }
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ struct MaterialBackgroundModifier: ViewModifier {
|
||||||
content
|
content
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: cornerRadius)
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
.fill(material))
|
.fill(material)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +51,8 @@ struct CardStyleModifier: ViewModifier {
|
||||||
init(
|
init(
|
||||||
cornerRadius: CGFloat = 10,
|
cornerRadius: CGFloat = 10,
|
||||||
horizontalPadding: CGFloat = 14,
|
horizontalPadding: CGFloat = 14,
|
||||||
verticalPadding: CGFloat = 10) {
|
verticalPadding: CGFloat = 10
|
||||||
|
) {
|
||||||
self.cornerRadius = cornerRadius
|
self.cornerRadius = cornerRadius
|
||||||
self.horizontalPadding = horizontalPadding
|
self.horizontalPadding = horizontalPadding
|
||||||
self.verticalPadding = verticalPadding
|
self.verticalPadding = verticalPadding
|
||||||
|
|
@ -66,15 +68,17 @@ struct CardStyleModifier: ViewModifier {
|
||||||
|
|
||||||
// MARK: - View Extensions
|
// MARK: - View Extensions
|
||||||
|
|
||||||
public extension View {
|
extension View {
|
||||||
/// Applies a material background with rounded corners.
|
/// Applies a material background with rounded corners.
|
||||||
///
|
///
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - cornerRadius: Corner radius for the rounded rectangle (default: 10)
|
/// - cornerRadius: Corner radius for the rounded rectangle (default: 10)
|
||||||
/// - material: Material type to use (default: .thickMaterial)
|
/// - material: Material type to use (default: .thickMaterial)
|
||||||
func materialBackground(
|
public func materialBackground(
|
||||||
cornerRadius: CGFloat = 10,
|
cornerRadius: CGFloat = 10,
|
||||||
material: Material = .thickMaterial) -> some View {
|
material: Material = .thickMaterial
|
||||||
|
)
|
||||||
|
-> some View {
|
||||||
modifier(MaterialBackgroundModifier(cornerRadius: cornerRadius, material: material))
|
modifier(MaterialBackgroundModifier(cornerRadius: cornerRadius, material: material))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,9 +87,11 @@ public extension View {
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - horizontal: Horizontal padding (default: 16)
|
/// - horizontal: Horizontal padding (default: 16)
|
||||||
/// - vertical: Vertical padding (default: 14)
|
/// - vertical: Vertical padding (default: 14)
|
||||||
func standardPadding(
|
public func standardPadding(
|
||||||
horizontal: CGFloat = 16,
|
horizontal: CGFloat = 16,
|
||||||
vertical: CGFloat = 14) -> some View {
|
vertical: CGFloat = 14
|
||||||
|
)
|
||||||
|
-> some View {
|
||||||
modifier(StandardPaddingModifier(horizontal: horizontal, vertical: vertical))
|
modifier(StandardPaddingModifier(horizontal: horizontal, vertical: vertical))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,14 +101,17 @@ public extension View {
|
||||||
/// - cornerRadius: Corner radius for the card (default: 10)
|
/// - cornerRadius: Corner radius for the card (default: 10)
|
||||||
/// - horizontalPadding: Horizontal padding (default: 14)
|
/// - horizontalPadding: Horizontal padding (default: 14)
|
||||||
/// - verticalPadding: Vertical padding (default: 10)
|
/// - verticalPadding: Vertical padding (default: 10)
|
||||||
func cardStyle(
|
public func cardStyle(
|
||||||
cornerRadius: CGFloat = 10,
|
cornerRadius: CGFloat = 10,
|
||||||
horizontalPadding: CGFloat = 14,
|
horizontalPadding: CGFloat = 14,
|
||||||
verticalPadding: CGFloat = 10) -> some View {
|
verticalPadding: CGFloat = 10
|
||||||
|
)
|
||||||
|
-> some View {
|
||||||
modifier(CardStyleModifier(
|
modifier(CardStyleModifier(
|
||||||
cornerRadius: cornerRadius,
|
cornerRadius: cornerRadius,
|
||||||
horizontalPadding: horizontalPadding,
|
horizontalPadding: horizontalPadding,
|
||||||
verticalPadding: verticalPadding))
|
verticalPadding: verticalPadding
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,4 +192,4 @@ public extension View {
|
||||||
.padding()
|
.padding()
|
||||||
.frame(width: 400)
|
.frame(width: 400)
|
||||||
.background(Color(NSColor.windowBackgroundColor))
|
.background(Color(NSColor.windowBackgroundColor))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ struct PressEventModifier: ViewModifier {
|
||||||
.simultaneousGesture(
|
.simultaneousGesture(
|
||||||
DragGesture(minimumDistance: 0)
|
DragGesture(minimumDistance: 0)
|
||||||
.onChanged { _ in onPress() }
|
.onChanged { _ in onPress() }
|
||||||
.onEnded { _ in onRelease() })
|
.onEnded { _ in onRelease() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,7 +31,8 @@ struct PointingHandCursorModifier: ViewModifier {
|
||||||
content
|
content
|
||||||
.background(
|
.background(
|
||||||
CursorTrackingView()
|
CursorTrackingView()
|
||||||
.allowsHitTesting(false))
|
.allowsHitTesting(false)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,4 +58,4 @@ class CursorTrackingNSView: NSView {
|
||||||
super.viewDidMoveToWindow()
|
super.viewDidMoveToWindow()
|
||||||
window?.invalidateCursorRects(for: self)
|
window?.invalidateCursorRects(for: self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,8 @@ struct AboutView: View {
|
||||||
HoverableLink(
|
HoverableLink(
|
||||||
url: "https://github.com/amantus-ai/vibetunnel/issues",
|
url: "https://github.com/amantus-ai/vibetunnel/issues",
|
||||||
title: "Report an Issue",
|
title: "Report an Issue",
|
||||||
icon: "exclamationmark.bubble")
|
icon: "exclamationmark.bubble"
|
||||||
|
)
|
||||||
HoverableLink(url: "https://x.com/steipete", title: "Follow @steipete on Twitter", icon: "bird")
|
HoverableLink(url: "https://x.com/steipete", title: "Follow @steipete on Twitter", icon: "bird")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,9 +87,13 @@ struct HoverableLink: View {
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var isHovering = false
|
private var isHovering = false
|
||||||
|
|
||||||
|
private var destinationURL: URL {
|
||||||
|
URL(string: url) ?? URL(fileURLWithPath: "/")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: URL(string: url)!) {
|
Link(destination: destinationURL) {
|
||||||
Label(title, systemImage: icon)
|
Label(title, systemImage: icon)
|
||||||
.underline(isHovering, color: .accentColor)
|
.underline(isHovering, color: .accentColor)
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +131,8 @@ struct InteractiveAppIcon: View {
|
||||||
color: shadowColor,
|
color: shadowColor,
|
||||||
radius: shadowRadius,
|
radius: shadowRadius,
|
||||||
x: 0,
|
x: 0,
|
||||||
y: shadowOffset)
|
y: shadowOffset
|
||||||
|
)
|
||||||
.animation(.easeInOut(duration: 0.2), value: isHovering)
|
.animation(.easeInOut(duration: 0.2), value: isHovering)
|
||||||
.animation(.easeInOut(duration: 0.1), value: isPressed)
|
.animation(.easeInOut(duration: 0.1), value: isPressed)
|
||||||
|
|
||||||
|
|
@ -144,7 +150,8 @@ struct InteractiveAppIcon: View {
|
||||||
}
|
}
|
||||||
.pressEvents(
|
.pressEvents(
|
||||||
onPress: { isPressed = true },
|
onPress: { isPressed = true },
|
||||||
onRelease: { isPressed = false })
|
onRelease: { isPressed = false }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shadowColor: Color {
|
private var shadowColor: Color {
|
||||||
|
|
@ -174,4 +181,4 @@ struct InteractiveAppIcon: View {
|
||||||
#Preview("About View") {
|
#Preview("About View") {
|
||||||
AboutView()
|
AboutView()
|
||||||
.frame(width: 570, height: 600)
|
.frame(width: 570, height: 600)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,23 @@
|
||||||
//
|
|
||||||
// SettingsView.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by Peter Steinberger on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum SettingsTab: String, CaseIterable {
|
enum SettingsTab: String, CaseIterable {
|
||||||
case general
|
case general
|
||||||
case advanced
|
case advanced
|
||||||
case about
|
case about
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: return "General"
|
case .general: "General"
|
||||||
case .advanced: return "Advanced"
|
case .advanced: "Advanced"
|
||||||
case .about: return "About"
|
case .about: "About"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .general: return "gear"
|
case .general: "gear"
|
||||||
case .advanced: return "gearshape.2"
|
case .advanced: "gearshape.2"
|
||||||
case .about: return "info.circle"
|
case .about: "info.circle"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +28,7 @@ extension Notification.Name {
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@State private var selectedTab: SettingsTab = .general
|
@State private var selectedTab: SettingsTab = .general
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
GeneralSettingsView()
|
GeneralSettingsView()
|
||||||
|
|
@ -43,13 +36,13 @@ struct SettingsView: View {
|
||||||
Label(SettingsTab.general.displayName, systemImage: SettingsTab.general.icon)
|
Label(SettingsTab.general.displayName, systemImage: SettingsTab.general.icon)
|
||||||
}
|
}
|
||||||
.tag(SettingsTab.general)
|
.tag(SettingsTab.general)
|
||||||
|
|
||||||
AdvancedSettingsView()
|
AdvancedSettingsView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon)
|
Label(SettingsTab.advanced.displayName, systemImage: SettingsTab.advanced.icon)
|
||||||
}
|
}
|
||||||
.tag(SettingsTab.advanced)
|
.tag(SettingsTab.advanced)
|
||||||
|
|
||||||
AboutView()
|
AboutView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label(SettingsTab.about.displayName, systemImage: SettingsTab.about.icon)
|
Label(SettingsTab.about.displayName, systemImage: SettingsTab.about.icon)
|
||||||
|
|
@ -69,9 +62,9 @@ struct GeneralSettingsView: View {
|
||||||
@AppStorage("autostart") private var autostart = false
|
@AppStorage("autostart") private var autostart = false
|
||||||
@AppStorage("showNotifications") private var showNotifications = true
|
@AppStorage("showNotifications") private var showNotifications = true
|
||||||
@AppStorage("showInDock") private var showInDock = false
|
@AppStorage("showInDock") private var showInDock = false
|
||||||
|
|
||||||
private let startupManager = StartupManager()
|
private let startupManager = StartupManager()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -83,7 +76,7 @@ struct GeneralSettingsView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show Notifications
|
// Show Notifications
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Toggle("Show notifications", isOn: $showNotifications)
|
Toggle("Show notifications", isOn: $showNotifications)
|
||||||
|
|
@ -95,7 +88,7 @@ struct GeneralSettingsView: View {
|
||||||
Text("Application")
|
Text("Application")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
// Show in Dock
|
// Show in Dock
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|
@ -118,23 +111,25 @@ struct GeneralSettingsView: View {
|
||||||
autostart = startupManager.isLaunchAtLoginEnabled
|
autostart = startupManager.isLaunchAtLoginEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var launchAtLoginBinding: Binding<Bool> {
|
private var launchAtLoginBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { autostart },
|
get: { autostart },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
autostart = newValue
|
autostart = newValue
|
||||||
startupManager.setLaunchAtLogin(enabled: newValue)
|
startupManager.setLaunchAtLogin(enabled: newValue)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showInDockBinding: Binding<Bool> {
|
private var showInDockBinding: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { showInDock },
|
get: { showInDock },
|
||||||
set: { newValue in
|
set: { newValue in
|
||||||
showInDock = newValue
|
showInDock = newValue
|
||||||
NSApp.setActivationPolicy(newValue ? .regular : .accessory)
|
NSApp.setActivationPolicy(newValue ? .regular : .accessory)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,19 +137,19 @@ struct AdvancedSettingsView: View {
|
||||||
@AppStorage("debugMode") private var debugMode = false
|
@AppStorage("debugMode") private var debugMode = false
|
||||||
@AppStorage("serverPort") private var serverPort = "8080"
|
@AppStorage("serverPort") private var serverPort = "8080"
|
||||||
@AppStorage("updateChannel") private var updateChannelRaw = UpdateChannel.stable.rawValue
|
@AppStorage("updateChannel") private var updateChannelRaw = UpdateChannel.stable.rawValue
|
||||||
|
|
||||||
@State private var isCheckingForUpdates = false
|
@State private var isCheckingForUpdates = false
|
||||||
@StateObject private var tunnelServer: TunnelServer
|
@StateObject private var tunnelServer: TunnelServer
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8080
|
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8_080
|
||||||
_tunnelServer = StateObject(wrappedValue: TunnelServer(port: port))
|
_tunnelServer = StateObject(wrappedValue: TunnelServer(port: port))
|
||||||
}
|
}
|
||||||
|
|
||||||
var updateChannel: UpdateChannel {
|
var updateChannel: UpdateChannel {
|
||||||
UpdateChannel(rawValue: updateChannelRaw) ?? .stable
|
UpdateChannel(rawValue: updateChannelRaw) ?? .stable
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -176,7 +171,7 @@ struct AdvancedSettingsView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Updates
|
// Check for Updates
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
|
@ -185,9 +180,9 @@ struct AdvancedSettingsView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button("Check Now") {
|
Button("Check Now") {
|
||||||
checkForUpdates()
|
checkForUpdates()
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +194,7 @@ struct AdvancedSettingsView: View {
|
||||||
Text("Updates")
|
Text("Updates")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
// Tunnel Server
|
// Tunnel Server
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
|
@ -213,26 +208,28 @@ struct AdvancedSettingsView: View {
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(tunnelServer.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped")
|
Text(tunnelServer
|
||||||
.font(.caption)
|
.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped"
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(tunnelServer.isRunning ? "Stop" : "Start") {
|
Button(tunnelServer.isRunning ? "Stop" : "Start") {
|
||||||
toggleServer()
|
toggleServer()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(tunnelServer.isRunning ? .red : .blue)
|
.tint(tunnelServer.isRunning ? .red : .blue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tunnelServer.isRunning {
|
if tunnelServer.isRunning, let serverURL = URL(string: "http://localhost:\(serverPort)") {
|
||||||
Link("Open in Browser", destination: URL(string: "http://localhost:\(serverPort)")!)
|
Link("Open in Browser", destination: serverURL)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Server port:")
|
Text("Server port:")
|
||||||
|
|
@ -249,7 +246,7 @@ struct AdvancedSettingsView: View {
|
||||||
Text("Server")
|
Text("Server")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Toggle("Debug mode", isOn: $debugMode)
|
Toggle("Debug mode", isOn: $debugMode)
|
||||||
|
|
@ -267,7 +264,7 @@ struct AdvancedSettingsView: View {
|
||||||
.navigationTitle("Advanced Settings")
|
.navigationTitle("Advanced Settings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var updateChannelBinding: Binding<UpdateChannel> {
|
private var updateChannelBinding: Binding<UpdateChannel> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { updateChannel },
|
get: { updateChannel },
|
||||||
|
|
@ -279,20 +276,21 @@ struct AdvancedSettingsView: View {
|
||||||
object: nil,
|
object: nil,
|
||||||
userInfo: ["channel": newValue]
|
userInfo: ["channel": newValue]
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkForUpdates() {
|
private func checkForUpdates() {
|
||||||
isCheckingForUpdates = true
|
isCheckingForUpdates = true
|
||||||
NotificationCenter.default.post(name: Notification.Name("checkForUpdates"), object: nil)
|
NotificationCenter.default.post(name: Notification.Name("checkForUpdates"), object: nil)
|
||||||
|
|
||||||
// Reset after a delay
|
// Reset after a delay
|
||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(for: .seconds(2))
|
try? await Task.sleep(for: .seconds(2))
|
||||||
isCheckingForUpdates = false
|
isCheckingForUpdates = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func toggleServer() {
|
private func toggleServer() {
|
||||||
Task {
|
Task {
|
||||||
if tunnelServer.isRunning {
|
if tunnelServer.isRunning {
|
||||||
|
|
@ -317,4 +315,4 @@ struct AdvancedSettingsView: View {
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import SwiftUI
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
/// Window controller for the About window
|
/// Window controller for the About window
|
||||||
final class AboutWindowController {
|
final class AboutWindowController {
|
||||||
static let shared = AboutWindowController()
|
static let shared = AboutWindowController()
|
||||||
|
|
||||||
private var window: NSWindow?
|
private var window: NSWindow?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func showWindow() {
|
func showWindow() {
|
||||||
// Check if About window is already open
|
// Check if About window is already open
|
||||||
if let existingWindow = window, existingWindow.isVisible {
|
if let existingWindow = window, existingWindow.isVisible {
|
||||||
|
|
@ -16,11 +16,11 @@ final class AboutWindowController {
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new About window
|
// Create new About window
|
||||||
let aboutView = AboutView()
|
let aboutView = AboutView()
|
||||||
let hostingController = NSHostingController(rootView: aboutView)
|
let hostingController = NSHostingController(rootView: aboutView)
|
||||||
|
|
||||||
let newWindow = NSWindow(contentViewController: hostingController)
|
let newWindow = NSWindow(contentViewController: hostingController)
|
||||||
newWindow.identifier = NSUserInterfaceItemIdentifier("AboutWindow")
|
newWindow.identifier = NSUserInterfaceItemIdentifier("AboutWindow")
|
||||||
newWindow.title = "About VibeTunnel"
|
newWindow.title = "About VibeTunnel"
|
||||||
|
|
@ -28,12 +28,12 @@ final class AboutWindowController {
|
||||||
newWindow.setContentSize(NSSize(width: 570, height: 600))
|
newWindow.setContentSize(NSSize(width: 570, height: 600))
|
||||||
newWindow.center()
|
newWindow.center()
|
||||||
newWindow.isReleasedWhenClosed = false
|
newWindow.isReleasedWhenClosed = false
|
||||||
|
|
||||||
// Store reference to window
|
// Store reference to window
|
||||||
self.window = newWindow
|
self.window = newWindow
|
||||||
|
|
||||||
// Show window
|
// Show window
|
||||||
newWindow.makeKeyAndOrderFront(nil)
|
newWindow.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,3 @@
|
||||||
//
|
|
||||||
// NSApplication+OpenSettings.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by Peter Steinberger on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
extension NSApplication {
|
extension NSApplication {
|
||||||
|
|
@ -20,4 +13,4 @@ extension NSApplication {
|
||||||
performSelector(onMainThread: Selector(("showSettingsWindow:")), with: nil, waitUntilDone: false)
|
performSelector(onMainThread: Selector(("showSettingsWindow:")), with: nil, waitUntilDone: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,23 @@
|
||||||
//
|
|
||||||
// VibeTunnelApp.swift
|
|
||||||
// VibeTunnel
|
|
||||||
//
|
|
||||||
// Created by Peter Steinberger on 15.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct VibeTunnelApp: App {
|
struct VibeTunnelApp: App {
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self)
|
@NSApplicationDelegateAdaptor(AppDelegate.self)
|
||||||
var appDelegate
|
var appDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Settings {
|
Settings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
.commands {
|
.commands {
|
||||||
CommandGroup(after: .appInfo) {
|
CommandGroup(after: .appInfo) {
|
||||||
Button("About VibeTunnel") {
|
Button("About VibeTunnel") {
|
||||||
showAboutInSettings()
|
showAboutInSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,147 +28,154 @@ struct VibeTunnelApp: App {
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
|
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
|
||||||
private var statusItem: NSStatusItem?
|
private var statusItem: NSStatusItem?
|
||||||
|
|
||||||
/// Distributed notification name used to ask an existing instance to show the Settings window.
|
/// Distributed notification name used to ask an existing instance to show the Settings window.
|
||||||
private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings")
|
private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings")
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
let processInfo = ProcessInfo.processInfo
|
let processInfo = ProcessInfo.processInfo
|
||||||
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
|
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
|
||||||
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?.contains("libMainThreadChecker.dylib") ?? false
|
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||||
|
.contains("libMainThreadChecker.dylib") ?? false
|
||||||
|
|
||||||
// Handle single instance check before doing anything else
|
// Handle single instance check before doing anything else
|
||||||
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
|
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
|
||||||
handleSingleInstanceCheck()
|
handleSingleInstanceCheck()
|
||||||
registerForDistributedNotifications()
|
registerForDistributedNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Sparkle updater manager
|
// Initialize Sparkle updater manager
|
||||||
sparkleUpdaterManager = SparkleUpdaterManager()
|
sparkleUpdaterManager = SparkleUpdaterManager()
|
||||||
|
|
||||||
// Configure activation policy based on settings (default to menu bar only)
|
// Configure activation policy based on settings (default to menu bar only)
|
||||||
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
|
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
|
||||||
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
|
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
|
||||||
|
|
||||||
// Setup status item (menu bar icon)
|
// Setup status item (menu bar icon)
|
||||||
setupStatusItem()
|
setupStatusItem()
|
||||||
|
|
||||||
// Show settings on first launch or when no window is open
|
// Show settings on first launch or when no window is open
|
||||||
if !showInDock {
|
if !showInDock {
|
||||||
// For menu bar apps, we need to ensure the settings window is accessible
|
// For menu bar apps, we need to ensure the settings window is accessible
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
if NSApp.windows.isEmpty || NSApp.windows.allSatisfy({ !$0.isVisible }) {
|
if NSApp.windows.isEmpty || NSApp.windows.allSatisfy({ !$0.isVisible }) {
|
||||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
NSApp.openSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for update check requests
|
// Listen for update check requests
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleCheckForUpdatesNotification),
|
selector: #selector(handleCheckForUpdatesNotification),
|
||||||
name: Notification.Name("checkForUpdates"),
|
name: Notification.Name("checkForUpdates"),
|
||||||
object: nil)
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSingleInstanceCheck() {
|
private func handleSingleInstanceCheck() {
|
||||||
let runningApps = NSRunningApplication
|
let runningApps = NSRunningApplication
|
||||||
.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "")
|
.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "")
|
||||||
|
|
||||||
if runningApps.count > 1 {
|
if runningApps.count > 1 {
|
||||||
// Send notification to existing instance to show settings
|
// Send notification to existing instance to show settings
|
||||||
DistributedNotificationCenter.default().post(name: Self.showSettingsNotification, object: nil)
|
DistributedNotificationCenter.default().post(name: Self.showSettingsNotification, object: nil)
|
||||||
|
|
||||||
// Show alert that another instance is running
|
// Show alert that another instance is running
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "VibeTunnel is already running"
|
alert.messageText = "VibeTunnel is already running"
|
||||||
alert.informativeText = "Another instance of VibeTunnel is already running. This instance will now quit."
|
alert
|
||||||
|
.informativeText = "Another instance of VibeTunnel is already running. This instance will now quit."
|
||||||
alert.alertStyle = .informational
|
alert.alertStyle = .informational
|
||||||
alert.addButton(withTitle: "OK")
|
alert.addButton(withTitle: "OK")
|
||||||
alert.runModal()
|
alert.runModal()
|
||||||
|
|
||||||
// Terminate this instance
|
// Terminate this instance
|
||||||
NSApp.terminate(nil)
|
NSApp.terminate(nil)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func registerForDistributedNotifications() {
|
private func registerForDistributedNotifications() {
|
||||||
DistributedNotificationCenter.default().addObserver(
|
DistributedNotificationCenter.default().addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleShowSettingsNotification),
|
selector: #selector(handleShowSettingsNotification),
|
||||||
name: Self.showSettingsNotification,
|
name: Self.showSettingsNotification,
|
||||||
object: nil)
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows the Settings window when another VibeTunnel instance asks us to.
|
/// Shows the Settings window when another VibeTunnel instance asks us to.
|
||||||
@objc
|
@objc
|
||||||
private func handleShowSettingsNotification(_ notification: Notification) {
|
private func handleShowSettingsNotification(_ notification: Notification) {
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
NSApp.openSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleCheckForUpdatesNotification() {
|
@objc private func handleCheckForUpdatesNotification() {
|
||||||
sparkleUpdaterManager?.checkForUpdates()
|
sparkleUpdaterManager?.checkForUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
// Remove distributed notification observer
|
// Remove distributed notification observer
|
||||||
let processInfo = ProcessInfo.processInfo
|
let processInfo = ProcessInfo.processInfo
|
||||||
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
|
let isRunningInTests = processInfo.environment["XCTestConfigurationFilePath"] != nil
|
||||||
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
let isRunningInPreview = processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||||
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?.contains("libMainThreadChecker.dylib") ?? false
|
let isRunningInDebug = processInfo.environment["DYLD_INSERT_LIBRARIES"]?
|
||||||
|
.contains("libMainThreadChecker.dylib") ?? false
|
||||||
|
|
||||||
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
|
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
|
||||||
DistributedNotificationCenter.default().removeObserver(
|
DistributedNotificationCenter.default().removeObserver(
|
||||||
self,
|
self,
|
||||||
name: Self.showSettingsNotification,
|
name: Self.showSettingsNotification,
|
||||||
object: nil)
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove update check notification observer
|
// Remove update check notification observer
|
||||||
NotificationCenter.default.removeObserver(
|
NotificationCenter.default.removeObserver(
|
||||||
self,
|
self,
|
||||||
name: Notification.Name("checkForUpdates"),
|
name: Notification.Name("checkForUpdates"),
|
||||||
object: nil)
|
object: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Status Item
|
// MARK: - Status Item
|
||||||
|
|
||||||
private func setupStatusItem() {
|
private func setupStatusItem() {
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
|
||||||
if let button = statusItem?.button {
|
if let button = statusItem?.button {
|
||||||
button.image = NSImage(named: "menubar")
|
button.image = NSImage(named: "menubar")
|
||||||
button.image?.isTemplate = true
|
button.image?.isTemplate = true
|
||||||
button.action = #selector(statusItemClicked)
|
button.action = #selector(statusItemClicked)
|
||||||
button.target = self
|
button.target = self
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create menu
|
// Create menu
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
|
|
||||||
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
|
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
menu.addItem(NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: ""))
|
menu.addItem(NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: ""))
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
|
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
|
||||||
|
|
||||||
statusItem?.menu = menu
|
statusItem?.menu = menu
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func statusItemClicked() {
|
@objc private func statusItemClicked() {
|
||||||
// Left click shows menu
|
// Left click shows menu
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func showSettings() {
|
@objc private func showSettings() {
|
||||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
NSApp.openSettings()
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func showAbout() {
|
@objc private func showAbout() {
|
||||||
showAboutInSettings()
|
showAboutInSettings()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue