mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix settings window not appearing from menu bar
Replace deprecated showSettingsWindow: selector with NSApp.openSettings() to properly show the Settings window when clicking "Settings..." in the menu bar. This aligns with the correct SwiftUI pattern used in VibeMeter.
This commit is contained in:
parent
b80c710c08
commit
702b623d7f
20 changed files with 721 additions and 725 deletions
|
|
@ -10,7 +10,8 @@ let package = Package(
|
|||
products: [
|
||||
.library(
|
||||
name: "VibeTunnel",
|
||||
targets: ["VibeTunnel"])
|
||||
targets: ["VibeTunnel"]
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/realm/SwiftLint.git", from: "0.57.0"),
|
||||
|
|
|
|||
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,15 +1,8 @@
|
|||
//
|
||||
// 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
|
||||
|
|
@ -43,7 +36,7 @@ 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
|
||||
|
||||
|
|
@ -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,7 +85,7 @@ 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
|
||||
|
|
@ -101,7 +100,7 @@ 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]) {
|
||||
|
|
|
|||
|
|
@ -32,12 +32,16 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
|||
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 {
|
||||
|
|
@ -49,21 +53,21 @@ public enum UpdateChannel: String, CaseIterable, Codable, Sendable {
|
|||
}
|
||||
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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")!
|
||||
}
|
||||
|
|
@ -22,7 +15,7 @@ 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() {
|
||||
|
|
@ -46,7 +39,12 @@ struct AuthenticationMiddleware<Context: RequestContext>: RouterMiddleware {
|
|||
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)
|
||||
|
|
@ -90,7 +88,8 @@ enum APIKeyManager {
|
|||
|
||||
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])
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ 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")
|
||||
|
|
@ -75,7 +76,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
|||
|
||||
/// Checks for updates immediately
|
||||
func checkForUpdates() {
|
||||
guard let updaterController = updaterController else {
|
||||
guard let updaterController else {
|
||||
logger.warning("Updater controller not available")
|
||||
return
|
||||
}
|
||||
|
|
@ -205,7 +206,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
|||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
|
@ -232,9 +233,9 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
|||
}
|
||||
}
|
||||
|
||||
// Provide the feed URL dynamically based on update channel
|
||||
/// Provide the feed URL dynamically based on update channel
|
||||
@objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
|
||||
return UpdateChannel.current.appcastURL.absoluteString
|
||||
UpdateChannel.current.appcastURL.absoluteString
|
||||
}
|
||||
|
||||
// MARK: - SPUStandardUserDriverDelegate
|
||||
|
|
@ -313,7 +314,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse
|
|||
|
||||
// MARK: - KVO
|
||||
|
||||
public override func observeValue(
|
||||
override public func observeValue(
|
||||
forKeyPath keyPath: String?,
|
||||
of object: Any?,
|
||||
change: [NSKeyValueChangeKey: Any]?,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Foundation
|
||||
import ServiceManagement
|
||||
import os
|
||||
import ServiceManagement
|
||||
|
||||
/// Protocol defining the interface for managing launch at login functionality.
|
||||
@MainActor
|
||||
|
|
@ -31,7 +31,10 @@ 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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -61,7 +54,8 @@ actor TerminalManager {
|
|||
guard var session = sessions[sessionId],
|
||||
let process = processes[sessionId],
|
||||
let (stdin, stdout, stderr) = pipes[sessionId],
|
||||
process.isRunning else {
|
||||
process.isRunning
|
||||
else {
|
||||
throw TunnelError.sessionNotFound
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +64,9 @@ actor TerminalManager {
|
|||
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
|
||||
|
|
@ -90,12 +86,12 @@ actor TerminalManager {
|
|||
|
||||
/// 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
|
||||
|
|
@ -122,7 +118,7 @@ actor TerminalManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Helper function for timeout
|
||||
/// Helper function for timeout
|
||||
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
|
||||
try await withThrowingTaskGroup(of: T.self) { group in
|
||||
group.addTask {
|
||||
|
|
@ -134,7 +130,9 @@ actor TerminalManager {
|
|||
throw TunnelError.timeout
|
||||
}
|
||||
|
||||
let result = try await group.next()!
|
||||
guard let result = try await group.next() else {
|
||||
throw TunnelError.timeout
|
||||
}
|
||||
group.cancelAll()
|
||||
return result
|
||||
}
|
||||
|
|
@ -151,13 +149,13 @@ enum TunnelError: LocalizedError {
|
|||
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 {
|
||||
|
|
@ -43,9 +36,12 @@ public class TunnelClient {
|
|||
|
||||
// MARK: - Session Management
|
||||
|
||||
public func createSession(workingDirectory: String? = nil,
|
||||
public func createSession(
|
||||
workingDirectory: String? = nil,
|
||||
environment: [String: String]? = nil,
|
||||
shell: String? = nil) async throws -> CreateSessionResponse {
|
||||
shell: String? = nil
|
||||
)
|
||||
async throws -> CreateSessionResponse {
|
||||
let url = baseURL.appendingPathComponent("sessions")
|
||||
let request = CreateSessionRequest(
|
||||
workingDirectory: workingDirectory,
|
||||
|
|
@ -74,7 +70,12 @@ public class TunnelClient {
|
|||
|
||||
// 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,
|
||||
|
|
@ -113,7 +114,7 @@ public class TunnelClient {
|
|||
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")
|
||||
|
|
@ -176,7 +177,7 @@ public class TunnelWebSocketClient: NSObject {
|
|||
webSocketTask?.resume()
|
||||
|
||||
// Send initial connection message if session ID is provided
|
||||
if let sessionId = sessionId {
|
||||
if let sessionId {
|
||||
send(WSMessage(type: .connect, sessionId: sessionId))
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +186,7 @@ public class TunnelWebSocketClient: NSObject {
|
|||
}
|
||||
|
||||
public func send(_ message: WSMessage) {
|
||||
guard let webSocketTask = webSocketTask else { return }
|
||||
guard let webSocketTask else { return }
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(message)
|
||||
|
|
@ -193,7 +194,7 @@ public class TunnelWebSocketClient: NSObject {
|
|||
let message = URLSessionWebSocketTask.Message.string(text)
|
||||
|
||||
webSocketTask.send(message) { error in
|
||||
if let error = error {
|
||||
if let error {
|
||||
print("WebSocket send error: \(error)")
|
||||
}
|
||||
}
|
||||
|
|
@ -203,7 +204,7 @@ public class TunnelWebSocketClient: NSObject {
|
|||
}
|
||||
|
||||
public func sendCommand(_ command: String) {
|
||||
guard let sessionId = sessionId else { return }
|
||||
guard let sessionId else { return }
|
||||
send(WSMessage(type: .command, sessionId: sessionId, data: command))
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -262,11 +272,11 @@ public enum TunnelClientError: LocalizedError {
|
|||
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
|
||||
|
|
@ -37,7 +30,7 @@ final class TunnelServer: ObservableObject {
|
|||
@Published var lastError: Error?
|
||||
@Published var connectedClients = 0
|
||||
|
||||
init(port: Int = 8080) {
|
||||
init(port: Int = 8_080) {
|
||||
self.port = port
|
||||
}
|
||||
|
||||
|
|
@ -123,12 +116,12 @@ final class TunnelServer: ObservableObject {
|
|||
|
||||
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",
|
||||
|
|
@ -142,7 +135,7 @@ final class TunnelServer: ObservableObject {
|
|||
let sessions = router.group("sessions")
|
||||
|
||||
// List all sessions
|
||||
sessions.get("/") { request, context async -> ListSessionsResponse in
|
||||
sessions.get("/") { _, _ async -> ListSessionsResponse in
|
||||
let sessions = await self.terminalManager.listSessions()
|
||||
let sessionInfos = sessions.map { session in
|
||||
SessionInfo(
|
||||
|
|
@ -167,10 +160,11 @@ final class TunnelServer: ObservableObject {
|
|||
}
|
||||
|
||||
// Get session info
|
||||
sessions.get(":sessionId") { request, context async throws -> SessionInfo in
|
||||
sessions.get(":sessionId") { _, context async throws -> SessionInfo in
|
||||
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
|
||||
let sessionId = UUID(uuidString: sessionIdString),
|
||||
let session = await self.terminalManager.getSession(id: sessionId) else {
|
||||
let session = await self.terminalManager.getSession(id: sessionId)
|
||||
else {
|
||||
throw HTTPError(.notFound)
|
||||
}
|
||||
|
||||
|
|
@ -183,9 +177,10 @@ final class TunnelServer: ObservableObject {
|
|||
}
|
||||
|
||||
// Close session
|
||||
sessions.delete(":sessionId") { request, context async throws -> HTTPResponse.Status in
|
||||
sessions.delete(":sessionId") { _, context async throws -> HTTPResponse.Status in
|
||||
guard let sessionIdString = context.parameters.get("sessionId", as: String.self),
|
||||
let sessionId = UUID(uuidString: sessionIdString) else {
|
||||
let sessionId = UUID(uuidString: sessionIdString)
|
||||
else {
|
||||
throw HTTPError(.badRequest)
|
||||
}
|
||||
|
||||
|
|
@ -219,7 +214,6 @@ final class TunnelServer: ObservableObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Integration with AppDelegate
|
||||
|
|
@ -229,7 +223,7 @@ 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -50,7 +42,6 @@ class TunnelServerDemo {
|
|||
// Close the session
|
||||
try await client.closeSession(id: session.sessionId)
|
||||
print("Session closed")
|
||||
|
||||
} catch {
|
||||
print("Demo error: \(error)")
|
||||
}
|
||||
|
|
@ -98,7 +89,6 @@ class TunnelServerDemo {
|
|||
// 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
|
||||
|
||||
|
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -87,8 +88,12 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
//
|
||||
// SettingsView.swift
|
||||
// VibeTunnel
|
||||
//
|
||||
// Created by Peter Steinberger on 15.06.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsTab: String, CaseIterable {
|
||||
|
|
@ -14,17 +7,17 @@ enum SettingsTab: String, CaseIterable {
|
|||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -125,7 +118,8 @@ struct GeneralSettingsView: View {
|
|||
set: { newValue in
|
||||
autostart = newValue
|
||||
startupManager.setLaunchAtLogin(enabled: newValue)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var showInDockBinding: Binding<Bool> {
|
||||
|
|
@ -134,7 +128,8 @@ struct GeneralSettingsView: View {
|
|||
set: { newValue in
|
||||
showInDock = newValue
|
||||
NSApp.setActivationPolicy(newValue ? .regular : .accessory)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +142,7 @@ struct AdvancedSettingsView: View {
|
|||
@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))
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +208,9 @@ struct AdvancedSettingsView: View {
|
|||
.frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
Text(tunnelServer.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped")
|
||||
Text(tunnelServer
|
||||
.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
|
@ -227,8 +224,8 @@ struct AdvancedSettingsView: View {
|
|||
.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)
|
||||
}
|
||||
}
|
||||
|
|
@ -279,7 +276,8 @@ struct AdvancedSettingsView: View {
|
|||
object: nil,
|
||||
userInfo: ["channel": newValue]
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func checkForUpdates() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Window controller for the About window
|
||||
final class AboutWindowController {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
//
|
||||
// NSApplication+OpenSettings.swift
|
||||
// VibeTunnel
|
||||
//
|
||||
// Created by Peter Steinberger on 15.06.25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
|
||||
extension NSApplication {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
//
|
||||
// VibeTunnelApp.swift
|
||||
// VibeTunnel
|
||||
//
|
||||
// Created by Peter Steinberger on 15.06.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VibeTunnelApp: App {
|
||||
|
|
@ -43,7 +36,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
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 {
|
||||
|
|
@ -66,7 +60,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +70,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
self,
|
||||
selector: #selector(handleCheckForUpdatesNotification),
|
||||
name: Notification.Name("checkForUpdates"),
|
||||
object: nil)
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func handleSingleInstanceCheck() {
|
||||
|
|
@ -91,7 +86,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
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()
|
||||
|
|
@ -108,14 +104,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
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() {
|
||||
|
|
@ -127,20 +124,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
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
|
||||
|
|
@ -172,7 +172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
}
|
||||
|
||||
@objc private func showSettings() {
|
||||
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
|
||||
NSApp.openSettings()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue