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