Update to version 1.0.0 build 100 and fix all linting issues

- Set version to 1.0.0 and build number to 100
- Run SwiftFormat to format all Swift files
- Fix all SwiftLint warnings and errors:
  - Replace force unwrapping with safe optional handling
  - Fix redundant string enum values
  - Replace print statements with proper Logger
  - Fix identifier names (w→width, h→height, a→first, b→second)
  - Fix attributes formatting
  - Fix vertical whitespace issues
  - Fix multiple closures with trailing closure syntax
- Configure SwiftFormat and SwiftLint for Swift 6 compatibility:
  - Disable redundantSelf rule to preserve required self references
  - Set --self insert to maintain Swift 6 compliance
  - Add comments about Swift 6 requirements
- Ensure linting and formatting tools don't create conflicts
This commit is contained in:
Peter Steinberger 2025-06-16 23:45:44 +02:00
parent b79d5a1fb4
commit c26be3eefd
30 changed files with 1133 additions and 975 deletions

View file

@ -36,7 +36,8 @@
--disable redundantSelf
# Modern Swift patterns
--self init-only
# For Swift 6 compatibility, preserve self references
--self insert
--selfrequired
--importgrouping testable-last
--patternlet inline

View file

@ -84,6 +84,8 @@ disabled_rules:
- todo
# Disable opening_brace as it conflicts with SwiftFormat's multiline wrapping
- opening_brace
# Note: Swift 6 requires more explicit self references
# SwiftFormat is configured to preserve these with --disable redundantSelf
# Rule parameters
type_name:

View file

@ -481,7 +481,7 @@
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 100;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
@ -498,7 +498,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.3;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -514,7 +514,7 @@
CODE_SIGN_ENTITLEMENTS = VibeTunnel/VibeTunnel.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 100;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
@ -531,7 +531,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1.3;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnel;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@ -543,12 +543,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 100;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnelTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -561,12 +561,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 100;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.1;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = sh.vibetunnel.vibetunnelTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
@ -578,11 +578,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 100;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.steipete.VibeTunnelUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;

View file

@ -2,27 +2,27 @@ import Foundation
/// Dashboard access mode
enum DashboardAccessMode: String, CaseIterable {
case localhost = "localhost"
case network = "network"
case localhost
case network
var displayName: String {
switch self {
case .localhost: "Localhost only"
case .network: "Network"
}
}
var bindAddress: String {
switch self {
case .localhost: "127.0.0.1"
case .network: "0.0.0.0"
}
}
var description: String {
switch self {
case .localhost: "Only accessible from this Mac"
case .network: "Accessible from other devices on the network"
}
}
}
}

View file

@ -1,68 +1,76 @@
import Foundation
import HTTPTypes
import Hummingbird
import HummingbirdCore
import HTTPTypes
import NIOCore
/// Middleware that implements HTTP Basic Authentication
struct BasicAuthMiddleware<Context: RequestContext>: RouterMiddleware {
let password: String
let realm: String
init(password: String, realm: String = "VibeTunnel Dashboard") {
self.password = password
self.realm = realm
}
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 auth for health check endpoint
if request.uri.path == "/api/health" {
return try await next(request, context)
}
// Extract authorization header
guard let authHeader = request.headers[.authorization],
authHeader.hasPrefix("Basic ") else {
authHeader.hasPrefix("Basic ")
else {
return unauthorizedResponse()
}
// Decode base64 credentials
let base64Credentials = String(authHeader.dropFirst(6))
guard let credentialsData = Data(base64Encoded: base64Credentials),
let credentials = String(data: credentialsData, encoding: .utf8) else {
let credentials = String(data: credentialsData, encoding: .utf8)
else {
return unauthorizedResponse()
}
// Split username:password
let parts = credentials.split(separator: ":", maxSplits: 1)
guard parts.count == 2 else {
return unauthorizedResponse()
}
// We ignore the username and only check password
let providedPassword = String(parts[1])
// Verify password
guard providedPassword == password else {
return unauthorizedResponse()
}
// Password correct, continue with request
return try await next(request, context)
}
private func unauthorizedResponse() -> Response {
var headers = HTTPFields()
headers[.wwwAuthenticate] = "Basic realm=\"\(realm)\""
let message = "Authentication required"
var buffer = ByteBuffer()
buffer.writeString(message)
return Response(
status: .unauthorized,
headers: headers,
body: ResponseBody(byteBuffer: buffer)
)
}
}
}

View file

@ -5,7 +5,7 @@ import Logging
/// Format specification: https://docs.asciinema.org/manual/asciicast/v2/
struct CastFileGenerator {
private let logger = Logger(label: "VibeTunnel.CastFileGenerator")
struct CastHeader: Codable {
let version: Int = 2
let width: Int
@ -16,7 +16,7 @@ struct CastFileGenerator {
let command: String?
let title: String?
let env: [String: String]?
enum CodingKeys: String, CodingKey {
case version
case width
@ -29,13 +29,13 @@ struct CastFileGenerator {
case env
}
}
struct CastEvent {
let time: TimeInterval
let eventType: String
let data: String
}
/// Generate a cast file from a session's stream-out file
func generateCastFile(
sessionId: String,
@ -44,49 +44,53 @@ struct CastFileGenerator {
height: Int = 24,
title: String? = nil,
command: String? = nil
) throws -> Data {
)
throws -> Data
{
guard FileManager.default.fileExists(atPath: streamOutPath) else {
throw CastFileError.fileNotFound(streamOutPath)
}
let content = try String(contentsOfFile: streamOutPath, encoding: .utf8)
let lines = content.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
var outputData = Data()
var events: [CastEvent] = []
var startTime: Date?
var sessionWidth = width
var sessionHeight = height
// Parse the stream-out file
for line in lines {
guard let data = line.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) else {
let parsed = try? JSONSerialization.jsonObject(with: data)
else {
continue
}
// Check if it's a header
if let dict = parsed as? [String: Any],
dict["version"] as? Int != nil,
let w = dict["width"] as? Int,
let h = dict["height"] as? Int {
sessionWidth = w
sessionHeight = h
dict["version"] is Int,
let width = dict["width"] as? Int,
let height = dict["height"] as? Int
{
sessionWidth = width
sessionHeight = height
continue
}
// Parse as event [timestamp, type, data]
if let array = parsed as? [Any],
array.count >= 3,
let timestamp = array[0] as? TimeInterval,
let eventType = array[1] as? String,
let eventData = array[2] as? String {
let eventData = array[2] as? String
{
if startTime == nil {
startTime = Date()
}
events.append(CastEvent(
time: timestamp,
eventType: eventType,
@ -94,7 +98,7 @@ struct CastFileGenerator {
))
}
}
// Generate header
let header = CastHeader(
width: sessionWidth,
@ -106,26 +110,26 @@ struct CastFileGenerator {
title: title,
env: nil
)
// Write header as first line
let headerData = try JSONEncoder().encode(header)
outputData.append(headerData)
outputData.append(Data("\n".utf8))
// Write events
let encoder = JSONEncoder()
encoder.outputFormatting = .withoutEscapingSlashes
for event in events {
let eventArray: [Any] = [event.time, event.eventType, event.data]
let eventData = try JSONSerialization.data(withJSONObject: eventArray)
outputData.append(eventData)
outputData.append(Data("\n".utf8))
}
return outputData
}
/// Generate a cast file and save it to disk
func saveCastFile(
sessionId: String,
@ -135,7 +139,9 @@ struct CastFileGenerator {
height: Int = 24,
title: String? = nil,
command: String? = nil
) throws {
)
throws
{
let castData = try generateCastFile(
sessionId: sessionId,
streamOutPath: streamOutPath,
@ -144,16 +150,18 @@ struct CastFileGenerator {
title: title,
command: command
)
try castData.write(to: URL(fileURLWithPath: outputPath))
logger.info("Cast file saved to: \(outputPath)")
}
/// Generate a live cast stream that can be consumed in real-time
func streamCastEvents(
from streamOutPath: String,
startTime: Date
) -> AsyncStream<Data> {
)
-> AsyncStream<Data>
{
AsyncStream { continuation in
Task {
let fileDescriptor = open(streamOutPath, O_RDONLY)
@ -162,24 +170,24 @@ struct CastFileGenerator {
continuation.finish()
return
}
defer {
close(fileDescriptor)
continuation.finish()
}
var lastReadPosition: off_t = 0
while !Task.isCancelled {
let currentPosition = lseek(fileDescriptor, 0, SEEK_END)
let bytesToRead = currentPosition - lastReadPosition
if bytesToRead > 0 {
lseek(fileDescriptor, lastReadPosition, SEEK_SET)
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(bytesToRead) + 1)
defer { buffer.deallocate() }
let bytesRead = read(fileDescriptor, buffer, Int(bytesToRead))
if bytesRead > 0 {
let data = Data(bytes: buffer, count: bytesRead)
@ -197,26 +205,27 @@ struct CastFileGenerator {
lastReadPosition = currentPosition
}
}
// Sleep briefly before checking again
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
}
}
}
}
private func processLineToAsciinemaEvent(line: String, startTime: Date) -> Data? {
guard let data = line.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [Any],
parsed.count >= 3,
let eventType = parsed[1] as? String,
let eventData = parsed[2] as? String else {
let eventData = parsed[2] as? String
else {
return nil
}
let currentTime = Date()
let timestamp = currentTime.timeIntervalSince(startTime)
let event: [Any] = [timestamp, eventType, eventData]
return try? JSONSerialization.data(withJSONObject: event)
}
@ -226,15 +235,15 @@ enum CastFileError: LocalizedError {
case fileNotFound(String)
case invalidFormat
case encodingError
var errorDescription: String? {
switch self {
case .fileNotFound(let path):
return "Stream file not found: \(path)"
"Stream file not found: \(path)"
case .invalidFormat:
return "Invalid stream file format"
"Invalid stream file format"
case .encodingError:
return "Failed to encode cast file"
"Failed to encode cast file"
}
}
}
}

View file

@ -1,18 +1,18 @@
import Foundation
import Security
import os
import Security
/// Service for managing dashboard password in keychain
@MainActor
final class DashboardKeychain {
static let shared = DashboardKeychain()
private let service = "sh.vibetunnel.vibetunnel"
private let account = "dashboard-password"
private let logger = Logger(subsystem: "sh.vibetunnel.vibetunnel", category: "DashboardKeychain")
private init() {}
/// Get the dashboard password from keychain
func getPassword() -> String? {
let query: [String: Any] = [
@ -21,21 +21,22 @@ final class DashboardKeychain {
kSecAttrAccount as String: account,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let password = String(data: data, encoding: .utf8) else {
let password = String(data: data, encoding: .utf8)
else {
logger.debug("No password found in keychain")
return nil
}
logger.debug("Password retrieved from keychain")
return password
}
/// Check if a password exists without retrieving it (won't trigger keychain prompt)
func hasPassword() -> Bool {
let query: [String: Any] = [
@ -46,47 +47,50 @@ final class DashboardKeychain {
kSecReturnAttributes as String: false,
kSecReturnData as String: false
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return status == errSecSuccess
}
/// Set the dashboard password in keychain
func setPassword(_ password: String) -> Bool {
guard !password.isEmpty else {
logger.warning("Attempted to set empty password")
return false
}
let data = password.data(using: .utf8)!
guard let data = password.data(using: .utf8) else {
logger.warning("Failed to convert password to UTF-8 data")
return false
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
// Try to update first
var status = SecItemUpdate(
query as CFDictionary,
[kSecValueData as String: data] as CFDictionary
)
if status == errSecItemNotFound {
// Item doesn't exist, create it
var addQuery = query
addQuery[kSecValueData as String] = data
status = SecItemAdd(addQuery as CFDictionary, nil)
}
let success = status == errSecSuccess
logger.info("Password \(success ? "saved to" : "failed to save to") keychain")
return success
}
/// Delete the dashboard password from keychain
func deletePassword() -> Bool {
let query: [String: Any] = [
@ -94,10 +98,10 @@ final class DashboardKeychain {
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let status = SecItemDelete(query as CFDictionary)
let success = status == errSecSuccess || status == errSecItemNotFound
logger.info("Password \(success ? "deleted from" : "failed to delete from") keychain")
return success
}
}
}

View file

@ -1,12 +1,5 @@
//
// HummingbirdServer.swift
// VibeTunnel
//
// Hummingbird-based HTTP server implementation
//
import Foundation
import Combine
import Foundation
import Hummingbird
import OSLog
@ -16,13 +9,13 @@ final class HummingbirdServer: ServerProtocol {
private var tunnelServer: TunnelServer?
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "HummingbirdServer")
private let logSubject = PassthroughSubject<ServerLogEntry, Never>()
var serverType: ServerMode { .hummingbird }
var isRunning: Bool {
tunnelServer?.isRunning ?? false
}
var port: String = "4020" {
didSet {
// If server is running and port changed, we need to restart
@ -33,65 +26,83 @@ final class HummingbirdServer: ServerProtocol {
}
}
}
var logPublisher: AnyPublisher<ServerLogEntry, Never> {
logSubject.eraseToAnyPublisher()
}
func start() async throws {
guard !isRunning else {
logger.warning("Hummingbird server already running")
return
}
logger.info("Starting Hummingbird server on port \(self.port)")
logSubject.send(ServerLogEntry(level: .info, message: "Initializing Hummingbird server...", source: .hummingbird))
logSubject.send(ServerLogEntry(
level: .info,
message: "Initializing Hummingbird server...",
source: .hummingbird
))
do {
let portInt = Int(port) ?? 4020
let portInt = Int(port) ?? 4_020
let bindAddress = ServerManager.shared.bindAddress
let server = TunnelServer(port: portInt, bindAddress: bindAddress)
tunnelServer = server
try await server.start()
logger.info("Hummingbird server started successfully")
logSubject.send(ServerLogEntry(level: .info, message: "Hummingbird server is ready", source: .hummingbird))
} catch {
logger.error("Failed to start Hummingbird server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry(level: .error, message: "Failed to start: \(error.localizedDescription)", source: .hummingbird))
logSubject.send(ServerLogEntry(
level: .error,
message: "Failed to start: \(error.localizedDescription)",
source: .hummingbird
))
throw error
}
}
func stop() async {
guard let server = tunnelServer, isRunning else {
logger.warning("Hummingbird server not running")
return
}
logger.info("Stopping Hummingbird server")
logSubject.send(ServerLogEntry(level: .info, message: "Shutting down Hummingbird server...", source: .hummingbird))
logSubject.send(ServerLogEntry(
level: .info,
message: "Shutting down Hummingbird server...",
source: .hummingbird
))
do {
try await server.stop()
tunnelServer = nil
logger.info("Hummingbird server stopped")
logSubject.send(ServerLogEntry(level: .info, message: "Hummingbird server shutdown complete", source: .hummingbird))
logSubject.send(ServerLogEntry(
level: .info,
message: "Hummingbird server shutdown complete",
source: .hummingbird
))
} catch {
logger.error("Error stopping Hummingbird server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry(level: .error, message: "Error stopping: \(error.localizedDescription)", source: .hummingbird))
logSubject.send(ServerLogEntry(
level: .error,
message: "Error stopping: \(error.localizedDescription)",
source: .hummingbird
))
}
}
func restart() async throws {
logger.info("Restarting Hummingbird server")
logSubject.send(ServerLogEntry(level: .info, message: "Restarting server", source: .hummingbird))
await stop()
try await start()
}
}
}

View file

@ -81,7 +81,7 @@ final class NgrokService: NgrokTunnelProtocol {
}
}
}
/// Check if auth token exists without triggering keychain prompt
var hasAuthToken: Bool {
KeychainHelper.hasNgrokAuthToken()
@ -148,7 +148,7 @@ final class NgrokService: NgrokTunnelProtocol {
let checkProcess = Process()
checkProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which")
checkProcess.arguments = ["ngrok"]
// Add common Homebrew paths to PATH for the check
var environment = ProcessInfo.processInfo.environment
let currentPath = environment["PATH"] ?? "/usr/bin:/bin"
@ -174,7 +174,10 @@ final class NgrokService: NgrokTunnelProtocol {
// Set up ngrok with auth token
let authProcess = Process()
authProcess.executableURL = URL(fileURLWithPath: ngrokPath)
authProcess.arguments = ["config", "add-authtoken", authToken!]
guard let authToken else {
throw NgrokError.authTokenMissing
}
authProcess.arguments = ["config", "add-authtoken", authToken]
try authProcess.run()
authProcess.waitUntilExit()
@ -232,7 +235,6 @@ final class NgrokService: NgrokTunnelProtocol {
logger.info("ngrok tunnel started: \(url)")
return url
} catch {
logger.error("Failed to start ngrok: \(error)")
throw error
@ -286,7 +288,9 @@ final class NgrokService: NgrokTunnelProtocol {
throw NgrokError.networkError("Operation timed out")
}
let result = try await group.next()!
guard let result = try await group.next() else {
throw NgrokError.networkError("No result received")
}
group.cancelAll()
return result
}
@ -313,7 +317,8 @@ struct AsyncLineSequence: AsyncSequence {
mutating func next() async -> String? {
while true {
if let range = buffer.range(of: "\n".data(using: .utf8)!) {
let lineBreakData = Data("\n".utf8)
if let range = buffer.range(of: lineBreakData) {
let line = String(data: buffer[..<range.lowerBound], encoding: .utf8)
buffer.removeSubrange(..<range.upperBound)
return line
@ -365,7 +370,7 @@ private enum KeychainHelper {
return token
}
/// Check if a token exists without retrieving it (won't trigger keychain prompt)
static func hasNgrokAuthToken() -> Bool {
let query: [String: Any] = [
@ -376,15 +381,17 @@ private enum KeychainHelper {
kSecReturnAttributes as String: false,
kSecReturnData as String: false
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
return status == errSecSuccess
}
static func setNgrokAuthToken(_ token: String) {
let data = token.data(using: .utf8)!
guard let data = token.data(using: .utf8) else {
return
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,

View file

@ -1,21 +1,12 @@
//
// RustServer.swift
// VibeTunnel
//
// Rust tty-fwd binary server implementation
//
import Foundation
import Combine
import Foundation
import OSLog
/// Task tracking for better debugging
enum ServerTaskContext {
@TaskLocal
static var taskName: String?
@TaskLocal
static var serverType: ServerMode?
@TaskLocal static var taskName: String?
@TaskLocal static var serverType: ServerMode?
}
/// Rust tty-fwd server implementation
@ -26,15 +17,18 @@ final class RustServer: ServerProtocol {
private var stderrPipe: Pipe?
private var outputTask: Task<Void, Never>?
private var errorTask: Task<Void, Never>?
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "RustServer")
private let logSubject = PassthroughSubject<ServerLogEntry, Never>()
private let processQueue = DispatchQueue(label: "com.steipete.VibeTunnel.RustServer", qos: .userInitiated)
// Actor to handle process operations on background thread
/// Actor to handle process operations on background thread
private actor ProcessHandler {
private let queue = DispatchQueue(label: "com.steipete.VibeTunnel.RustServer.ProcessHandler", qos: .userInitiated)
private let queue = DispatchQueue(
label: "com.steipete.VibeTunnel.RustServer.ProcessHandler",
qos: .userInitiated
)
func runProcess(_ process: Process) async throws {
try await withCheckedThrowingContinuation { continuation in
queue.async {
@ -47,7 +41,7 @@ final class RustServer: ServerProtocol {
}
}
}
func waitForExit(_ process: Process) async {
await withCheckedContinuation { continuation in
queue.async {
@ -56,7 +50,7 @@ final class RustServer: ServerProtocol {
}
}
}
func terminateProcess(_ process: Process) async {
await withCheckedContinuation { continuation in
queue.async {
@ -66,13 +60,13 @@ final class RustServer: ServerProtocol {
}
}
}
private let processHandler = ProcessHandler()
var serverType: ServerMode { .rust }
private(set) var isRunning = false
var port: String = "" {
didSet {
// If server is running and port changed, we need to restart
@ -83,76 +77,76 @@ final class RustServer: ServerProtocol {
}
}
}
var logPublisher: AnyPublisher<ServerLogEntry, Never> {
logSubject.eraseToAnyPublisher()
}
func start() async throws {
guard !isRunning else {
logger.warning("Rust server already running")
return
}
guard !port.isEmpty else {
let error = RustServerError.invalidPort
logger.error("Port not configured")
logSubject.send(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust))
throw error
}
logger.info("Starting Rust tty-fwd server on port \(self.port)")
logSubject.send(ServerLogEntry(level: .info, message: "Initializing Rust tty-fwd server...", source: .rust))
// Get the tty-fwd binary path
let binaryPath = Bundle.main.path(forResource: "tty-fwd", ofType: nil)
guard let binaryPath = binaryPath else {
guard let binaryPath else {
let error = RustServerError.binaryNotFound
logger.error("tty-fwd binary not found in bundle")
logSubject.send(ServerLogEntry(level: .error, message: error.localizedDescription, source: .rust))
throw error
}
// Ensure binary is executable
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath)
// Verify binary exists and is executable
var isDirectory: ObjCBool = false
let fileExists = FileManager.default.fileExists(atPath: binaryPath, isDirectory: &isDirectory)
logger.info("tty-fwd binary exists: \(fileExists), is directory: \(isDirectory.boolValue)")
if fileExists && !isDirectory.boolValue {
let attributes = try FileManager.default.attributesOfItem(atPath: binaryPath)
if let permissions = attributes[.posixPermissions] as? NSNumber {
logger.info("tty-fwd binary permissions: \(String(permissions.intValue, radix: 8))")
}
}
// Create the process using login shell
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
// Get the Resources directory path
let bundlePath = Bundle.main.bundlePath
let resourcesPath = Bundle.main.resourcePath ?? bundlePath
// Set working directory to Resources directory where both tty-fwd and web folder exist
process.currentDirectoryURL = URL(fileURLWithPath: resourcesPath)
logger.info("Setting working directory to: \(resourcesPath)")
// The web/public directory should be at web/public relative to Resources
let webPublicPath = URL(fileURLWithPath: resourcesPath).appendingPathComponent("web/public")
let webPublicExists = FileManager.default.fileExists(atPath: webPublicPath.path)
logger.info("Web public directory at \(webPublicPath.path) exists: \(webPublicExists)")
// Use absolute path for static directory
let staticPath = webPublicPath.path
// Build command to run tty-fwd through login shell
// Use bind address from ServerManager to control server accessibility
let bindAddress = ServerManager.shared.bindAddress
var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --serve \(bindAddress):\(port)"
// Add password flag if password protection is enabled
if let password = DashboardKeychain.shared.getPassword() {
// Escape the password for shell
@ -163,64 +157,68 @@ final class RustServer: ServerProtocol {
ttyFwdCommand += " --password \"\(escapedPassword)\""
}
process.arguments = ["-l", "-c", ttyFwdCommand]
logger.info("Executing command: /bin/zsh -l -c \"\(ttyFwdCommand)\"")
logger.info("Working directory: \(resourcesPath)")
// Set up environment - login shell will load the rest
var environment = ProcessInfo.processInfo.environment
environment["RUST_LOG"] = "info"
process.environment = environment
// Set up pipes for stdout and stderr
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
self.process = process
self.stdoutPipe = stdoutPipe
self.stderrPipe = stderrPipe
// Start monitoring output
startOutputMonitoring()
// Start the process on background thread
do {
try await processHandler.runProcess(process)
isRunning = true
// Give the server a moment to start
try await Task.sleep(for: .seconds(1))
// Check if process is still running
if !process.isRunning {
logger.error("Process terminated with exit code: \(process.terminationStatus)")
// Try to read any error output
if let stderrPipe = self.stderrPipe {
let errorData = stderrPipe.fileHandleForReading.availableData
if !errorData.isEmpty, let errorOutput = String(data: errorData, encoding: .utf8) {
logger.error("Process stderr: \(errorOutput)")
logSubject.send(ServerLogEntry(level: .error, message: "Process error: \(errorOutput)", source: .rust))
logSubject.send(ServerLogEntry(
level: .error,
message: "Process error: \(errorOutput)",
source: .rust
))
}
}
throw RustServerError.processFailedToStart
}
logger.info("Rust server process started, performing health check...")
logSubject.send(ServerLogEntry(level: .info, message: "Performing health check...", source: .rust))
// Perform health check to ensure server is actually responding
let isHealthy = await performHealthCheck(maxAttempts: 10, delaySeconds: 0.5)
if isHealthy {
logger.info("Rust server started successfully and is responding")
logSubject.send(ServerLogEntry(level: .info, message: "Health check passed ✓", source: .rust))
logSubject.send(ServerLogEntry(level: .info, message: "Rust tty-fwd server is ready", source: .rust))
// Monitor process termination with task context
Task {
await ServerTaskContext.$taskName.withValue("RustServer-monitor-\(port)") {
@ -232,54 +230,65 @@ final class RustServer: ServerProtocol {
} else {
// Server process is running but not responding
logger.error("Rust server process started but is not responding to health checks")
logSubject.send(ServerLogEntry(level: .error, message: "Health check failed - server not responding", source: .rust))
logSubject.send(ServerLogEntry(
level: .error,
message: "Health check failed - server not responding",
source: .rust
))
// Clean up the non-responsive process
process.terminate()
self.process = nil
self.stdoutPipe = nil
self.stderrPipe = nil
isRunning = false
throw RustServerError.serverNotResponding
}
} catch {
isRunning = false
logger.error("Failed to start Rust server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry(level: .error, message: "Failed to start: \(error.localizedDescription)", source: .rust))
logSubject.send(ServerLogEntry(
level: .error,
message: "Failed to start: \(error.localizedDescription)",
source: .rust
))
throw error
}
}
func stop() async {
guard let process = process, isRunning else {
guard let process, isRunning else {
logger.warning("Rust server not running")
return
}
logger.info("Stopping Rust server")
logSubject.send(ServerLogEntry(level: .info, message: "Shutting down Rust tty-fwd server...", source: .rust))
// Cancel output monitoring tasks
outputTask?.cancel()
errorTask?.cancel()
// Terminate the process on background thread
await processHandler.terminateProcess(process)
// Wait for process to terminate (with timeout)
let terminated: Void? = await withTimeoutOrNil(seconds: 5) { [self] in
await self.processHandler.waitForExit(process)
}
if terminated == nil {
// Force kill if termination timeout
process.interrupt()
logger.warning("Force killed Rust server after timeout")
logSubject.send(ServerLogEntry(level: .warning, message: "Force killed server after timeout", source: .rust))
logSubject.send(ServerLogEntry(
level: .warning,
message: "Force killed server after timeout",
source: .rust
))
}
// Clean up
self.process = nil
self.stdoutPipe = nil
@ -287,38 +296,40 @@ final class RustServer: ServerProtocol {
self.outputTask = nil
self.errorTask = nil
isRunning = false
logger.info("Rust server stopped")
logSubject.send(ServerLogEntry(level: .info, message: "Rust tty-fwd server shutdown complete", source: .rust))
}
func restart() async throws {
logger.info("Restarting Rust server")
logSubject.send(ServerLogEntry(level: .info, message: "Restarting server", source: .rust))
await stop()
try await start()
}
// MARK: - Private Methods
private func performHealthCheck(maxAttempts: Int, delaySeconds: Double) async -> Bool {
let healthURL = URL(string: "http://127.0.0.1:\(port)/api/health")!
guard let healthURL = URL(string: "http://127.0.0.1:\(port)/api/health") else {
return false
}
for attempt in 1...maxAttempts {
do {
// Create request with short timeout
var request = URLRequest(url: healthURL)
request.timeoutInterval = 2.0
logSubject.send(ServerLogEntry(
level: .debug,
message: "Health check attempt \(attempt)/\(maxAttempts)...",
source: .rust
))
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
logger.debug("Health check succeeded on attempt \(attempt)")
return true
@ -333,43 +344,44 @@ final class RustServer: ServerProtocol {
))
}
}
// Wait before next attempt (except on last attempt)
if attempt < maxAttempts {
try? await Task.sleep(for: .seconds(delaySeconds))
}
}
return false
}
private func startOutputMonitoring() {
// Capture pipes and port before starting detached tasks
let stdoutPipe = self.stdoutPipe
let stderrPipe = self.stderrPipe
let currentPort = self.port
// Monitor stdout on background thread
outputTask = Task.detached { [weak self] in
ServerTaskContext.$taskName.withValue("RustServer-stdout-\(currentPort)") {
ServerTaskContext.$serverType.withValue(.rust) {
guard let self = self, let pipe = stdoutPipe else { return }
guard let self, let pipe = stdoutPipe else { return }
let handle = pipe.fileHandleForReading
self.logger.debug("Starting stdout monitoring for Rust server on port \(currentPort)")
while !Task.isCancelled {
autoreleasepool {
let data = handle.availableData
if !data.isEmpty, let output = String(data: data, encoding: .utf8) {
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines)
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
// Skip shell initialization messages
if line.contains("zsh:") || line.hasPrefix("Last login:") {
continue
}
Task { @MainActor [weak self] in
guard let self = self else { return }
guard let self else { return }
let level = self.detectLogLevel(from: line)
self.logSubject.send(ServerLogEntry(level: level, message: line, source: .rust))
}
@ -377,52 +389,57 @@ final class RustServer: ServerProtocol {
}
}
}
self.logger.debug("Stopped stdout monitoring for Rust server")
}
}
}
// Monitor stderr on background thread
errorTask = Task.detached { [weak self] in
ServerTaskContext.$taskName.withValue("RustServer-stderr-\(currentPort)") {
ServerTaskContext.$serverType.withValue(.rust) {
guard let self = self, let pipe = stderrPipe else { return }
guard let self, let pipe = stderrPipe else { return }
let handle = pipe.fileHandleForReading
self.logger.debug("Starting stderr monitoring for Rust server on port \(currentPort)")
while !Task.isCancelled {
autoreleasepool {
let data = handle.availableData
if !data.isEmpty, let output = String(data: data, encoding: .utf8) {
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines)
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .newlines)
for line in lines where !line.isEmpty {
// Skip shell initialization messages
if line.contains("zsh:") || line.hasPrefix("Last login:") {
continue
}
Task { @MainActor [weak self] in
guard let self = self else { return }
self.logSubject.send(ServerLogEntry(level: .error, message: line, source: .rust))
guard let self else { return }
self.logSubject.send(ServerLogEntry(
level: .error,
message: line,
source: .rust
))
}
}
}
}
}
self.logger.debug("Stopped stderr monitoring for Rust server")
}
}
}
}
private func monitorProcessTermination() async {
guard let process = process else { return }
guard let process else { return }
// Wait for process exit on background thread
await processHandler.waitForExit(process)
if self.isRunning {
// Unexpected termination
let exitCode = process.terminationStatus
@ -432,9 +449,9 @@ final class RustServer: ServerProtocol {
message: "Server terminated unexpectedly with exit code: \(exitCode)",
source: .rust
))
self.isRunning = false
// Auto-restart on unexpected termination
Task {
try? await Task.sleep(for: .seconds(2))
@ -450,7 +467,7 @@ final class RustServer: ServerProtocol {
}
}
}
private func detectLogLevel(from line: String) -> ServerLogEntry.Level {
let lowercased = line.lowercased()
if lowercased.contains("error") || lowercased.contains("fatal") {
@ -463,21 +480,26 @@ final class RustServer: ServerProtocol {
return .info
}
}
private func withTimeoutOrNil<T: Sendable>(seconds: TimeInterval, operation: @escaping @Sendable () async -> T) async -> T? {
private func withTimeoutOrNil<T: Sendable>(
seconds: TimeInterval,
operation: @escaping @Sendable () async -> T
)
async -> T?
{
await withTaskGroup(of: T?.self) { group in
group.addTask {
await operation()
}
group.addTask {
try? await Task.sleep(for: .seconds(seconds))
return nil
}
let result = await group.next()
group.cancelAll()
return result ?? nil
return result
}
}
}
@ -489,17 +511,17 @@ enum RustServerError: LocalizedError {
case processFailedToStart
case serverNotResponding
case invalidPort
var errorDescription: String? {
switch self {
case .binaryNotFound:
return "The tty-fwd binary was not found in the app bundle"
"The tty-fwd binary was not found in the app bundle"
case .processFailedToStart:
return "The server process failed to start"
"The server process failed to start"
case .serverNotResponding:
return "The server process started but is not responding to health checks"
"The server process started but is not responding to health checks"
case .invalidPort:
return "Server port is not configured"
"Server port is not configured"
}
}
}

View file

@ -1,69 +1,64 @@
//
// ServerManager.swift
// VibeTunnel
//
// Manages server lifecycle and switching between server modes
//
import Foundation
import SwiftUI
import Combine
import OSLog
import Foundation
import Observation
import OSLog
import SwiftUI
/// Manages the active server and handles switching between modes
@MainActor
@Observable
class ServerManager {
static let shared = ServerManager()
private var serverModeString: String {
get { UserDefaults.standard.string(forKey: "serverMode") ?? ServerMode.rust.rawValue }
set { UserDefaults.standard.set(newValue, forKey: "serverMode") }
}
var port: String {
get { UserDefaults.standard.string(forKey: "serverPort") ?? "4020" }
set { UserDefaults.standard.set(newValue, forKey: "serverPort") }
}
var bindAddress: String {
get {
let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "") ?? .localhost
get {
let mode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? ""
) ??
.localhost
return mode.bindAddress
}
set {
set {
// Find the mode that matches this bind address
if let mode = DashboardAccessMode.allCases.first(where: { $0.bindAddress == newValue }) {
UserDefaults.standard.set(mode.rawValue, forKey: "dashboardAccessMode")
}
}
}
private var cleanupOnStartup: Bool {
get { UserDefaults.standard.bool(forKey: "cleanupOnStartup") }
set { UserDefaults.standard.set(newValue, forKey: "cleanupOnStartup") }
}
private(set) var currentServer: ServerProtocol?
private(set) var isRunning = false
private(set) var isSwitching = false
private(set) var lastError: Error?
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "ServerManager")
private var cancellables = Set<AnyCancellable>()
private let logSubject = PassthroughSubject<ServerLogEntry, Never>()
var serverMode: ServerMode {
get { ServerMode(rawValue: serverModeString) ?? .rust }
set { serverModeString = newValue.rawValue }
}
var logPublisher: AnyPublisher<ServerLogEntry, Never> {
logSubject.eraseToAnyPublisher()
}
// Modern async stream for logs
/// Modern async stream for logs
var logStream: AsyncStream<ServerLogEntry> {
AsyncStream { continuation in
// Use logPublisher directly without storing the cancellable
@ -75,11 +70,11 @@ class ServerManager {
}
}
}
private init() {
setupObservers()
}
private func setupObservers() {
// Watch for server mode changes when the value actually changes
// Since we're using @AppStorage, we need to observe changes differently
@ -91,18 +86,18 @@ class ServerManager {
}
.store(in: &cancellables)
}
/// Start the server with current configuration
func start() async {
// Check if we already have a running server
if let existingServer = currentServer {
logger.info("Server already running on port \(existingServer.port)")
// Ensure our state is synced
isRunning = true
lastError = nil
ServerMonitor.shared.isServerRunning = true
// Log for clarity
logSubject.send(ServerLogEntry(
level: .info,
@ -111,39 +106,38 @@ class ServerManager {
))
return
}
// Log that we're starting a server
logSubject.send(ServerLogEntry(
level: .info,
message: "Starting \(serverMode.displayName) server on port \(port)...",
source: serverMode
))
do {
let server = createServer(for: serverMode)
server.port = port
// Subscribe to server logs
server.logPublisher
.sink { [weak self] entry in
self?.logSubject.send(entry)
}
.store(in: &cancellables)
try await server.start()
currentServer = server
isRunning = true
lastError = nil
logger.info("Started \(self.serverMode.displayName) server on port \(self.port)")
// Update ServerMonitor for compatibility
ServerMonitor.shared.isServerRunning = true
// Trigger cleanup of old sessions after server starts
await triggerInitialCleanup()
} catch {
logger.error("Failed to start server: \(error.localizedDescription)")
logSubject.send(ServerLogEntry(
@ -152,7 +146,7 @@ class ServerManager {
source: serverMode
))
lastError = error
// Check if server is actually running despite the error
if let server = currentServer, server.isRunning {
logger.warning("Server reported as running despite startup error, syncing state")
@ -164,55 +158,55 @@ class ServerManager {
}
}
}
/// Stop the current server
func stop() async {
guard let server = currentServer else {
logger.warning("No server running")
return
}
let serverType = server.serverType
logger.info("Stopping \(serverType.displayName) server")
// Log that we're stopping the server
logSubject.send(ServerLogEntry(
level: .info,
message: "Stopping \(serverType.displayName) server...",
source: serverType
))
await server.stop()
currentServer = nil
isRunning = false
// Log that the server has stopped
logSubject.send(ServerLogEntry(
level: .info,
message: "\(serverType.displayName) server stopped",
source: serverType
))
// Update ServerMonitor for compatibility
ServerMonitor.shared.isServerRunning = false
}
/// Restart the current server
func restart() async {
await stop()
await start()
}
/// Switch to a different server mode
func switchMode(to mode: ServerMode) async {
guard mode != serverMode else { return }
isSwitching = true
defer { isSwitching = false }
let oldMode = serverMode
logger.info("Switching from \(oldMode.displayName) to \(mode.displayName)")
// Log the mode switch with a clear separator
logSubject.send(ServerLogEntry(
level: .info,
@ -229,21 +223,21 @@ class ServerManager {
message: "════════════════════════════════════════════════════════",
source: oldMode
))
// Stop current server if running
if currentServer != nil {
await stop()
}
// Add a small delay for visual clarity in logs
try? await Task.sleep(for: .milliseconds(500))
// Update mode
serverMode = mode
// Start new server
await start()
// Log completion
logSubject.send(ServerLogEntry(
level: .info,
@ -261,7 +255,7 @@ class ServerManager {
source: mode
))
}
private func handleServerModeChange() async {
// This is called when serverMode changes via AppStorage
// If we have a running server, switch to the new mode
@ -269,16 +263,16 @@ class ServerManager {
await switchMode(to: serverMode)
}
}
private func createServer(for mode: ServerMode) -> ServerProtocol {
switch mode {
case .hummingbird:
return HummingbirdServer()
HummingbirdServer()
case .rust:
return RustServer()
RustServer()
}
}
/// Trigger cleanup of exited sessions after server startup
private func triggerInitialCleanup() async {
// Check if cleanup on startup is enabled
@ -286,27 +280,31 @@ class ServerManager {
logger.info("Cleanup on startup is disabled in settings")
return
}
logger.info("Triggering initial cleanup of exited sessions")
// Small delay to ensure server is fully ready
try? await Task.sleep(for: .milliseconds(500))
do {
// Create URL for cleanup endpoint
let url = URL(string: "http://localhost:\(port)/api/cleanup-exited")!
guard let url = URL(string: "http://localhost:\(port)/api/cleanup-exited") else {
logger.warning("Failed to create cleanup URL")
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 10
// Make the cleanup request
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
if httpResponse.statusCode == 200 {
// Try to parse the response
if let jsonData = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let cleanedCount = jsonData["cleaned_count"] as? Int {
let cleanedCount = jsonData["cleaned_count"] as? Int
{
logger.info("Initial cleanup completed: cleaned \(cleanedCount) exited sessions")
logSubject.send(ServerLogEntry(
level: .info,
@ -335,4 +333,4 @@ class ServerManager {
))
}
}
}
}

View file

@ -8,25 +8,24 @@ import Observation
public final class ServerMonitor {
public static let shared = ServerMonitor()
// Observable properties
/// Observable properties
public var isRunning: Bool {
isServerRunning
}
public var port: Int {
Int(ServerManager.shared.port) ?? 4020
Int(ServerManager.shared.port) ?? 4_020
}
public var lastError: Error? {
ServerManager.shared.lastError
}
/// Reference to the actual server (kept for backward compatibility)
private weak var server: TunnelServer?
/// Internal state tracking
@ObservationIgnored
public var isServerRunning = false {
@ObservationIgnored public var isServerRunning = false {
didSet {
// Notify observers when state changes
}
@ -51,7 +50,7 @@ public final class ServerMonitor {
await syncWithServerManager()
}
}
/// Syncs state with ServerManager
private func syncWithServerManager() async {
isServerRunning = ServerManager.shared.isRunning
@ -70,7 +69,7 @@ public final class ServerMonitor {
await ServerManager.shared.stop()
await syncWithServerManager()
}
/// Restarts the server
public func restartServer() async throws {
await ServerManager.shared.restart()
@ -82,7 +81,9 @@ public final class ServerMonitor {
guard isRunning else { return false }
do {
let url = URL(string: "http://127.0.0.1:\(port)/api/health")!
guard let url = URL(string: "http://127.0.0.1:\(port)/api/health") else {
return false
}
let request = URLRequest(url: url, timeoutInterval: 2.0)
let (_, response) = try await URLSession.shared.data(for: request)

View file

@ -1,58 +1,51 @@
//
// ServerProtocol.swift
// VibeTunnel
//
// Protocol defining the interface for different server implementations
//
import Foundation
import Combine
import Foundation
/// Common interface for server implementations
@MainActor
protocol ServerProtocol: AnyObject {
/// Current running state of the server
var isRunning: Bool { get }
/// Port the server is configured to use
var port: String { get set }
/// Server type identifier
var serverType: ServerMode { get }
/// Start the server
func start() async throws
/// Stop the server
func stop() async
/// Restart the server
func restart() async throws
/// Publisher for streaming log messages
var logPublisher: AnyPublisher<ServerLogEntry, Never> { get }
}
/// Server mode options
enum ServerMode: String, CaseIterable {
case hummingbird = "hummingbird"
case rust = "rust"
case hummingbird
case rust
var displayName: String {
switch self {
case .hummingbird:
return "Hummingbird"
"Hummingbird"
case .rust:
return "Rust"
"Rust"
}
}
var description: String {
switch self {
case .hummingbird:
return "Built-in Swift server"
"Built-in Swift server"
case .rust:
return "External tty-fwd binary"
"External tty-fwd binary"
}
}
}
@ -65,16 +58,16 @@ struct ServerLogEntry {
case warning
case error
}
let timestamp: Date
let level: Level
let message: String
let source: ServerMode
init(level: Level = .info, message: String, source: ServerMode) {
self.timestamp = Date()
self.level = level
self.message = message
self.source = source
}
}
}

View file

@ -80,7 +80,12 @@ class SessionMonitor {
private func fetchSessions() async {
do {
// First check if server is running
let healthURL = URL(string: "http://127.0.0.1:\(serverPort)/api/health")!
guard let healthURL = URL(string: "http://127.0.0.1:\(serverPort)/api/health") else {
self.sessions = [:]
self.sessionCount = 0
self.lastError = nil
return
}
let healthRequest = URLRequest(url: healthURL, timeoutInterval: 2.0)
do {
@ -103,7 +108,10 @@ class SessionMonitor {
}
// Server is running, fetch sessions
let url = URL(string: "http://127.0.0.1:\(serverPort)/sessions")!
guard let url = URL(string: "http://127.0.0.1:\(serverPort)/sessions") else {
self.lastError = "Invalid URL"
return
}
let request = URLRequest(url: url, timeoutInterval: 5.0)
let (data, response) = try await URLSession.shared.data(for: request)
@ -119,9 +127,8 @@ class SessionMonitor {
self.sessions = sessionsData
// Count only running sessions
self.sessionCount = sessionsData.values.count(where: { $0.isRunning })
self.sessionCount = sessionsData.values.count { $0.isRunning }
self.lastError = nil
} catch {
// Don't set error for connection issues when server is likely not running
if !(error is URLError) {

View file

@ -27,45 +27,45 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {
// Initialize Sparkle with standard configuration
#if DEBUG
// In debug mode, don't start the updater automatically
updaterController = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: self,
userDriverDelegate: nil
)
// In debug mode, don't start the updater automatically
updaterController = SPUStandardUpdaterController(
startingUpdater: false,
updaterDelegate: self,
userDriverDelegate: nil
)
#else
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: nil
)
updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: self,
userDriverDelegate: nil
)
#endif
// Configure automatic updates
if let updater = updaterController?.updater {
#if DEBUG
// Disable automatic checks in debug builds
updater.automaticallyChecksForUpdates = false
updater.automaticallyDownloadsUpdates = false
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates disabled")
// Disable automatic checks in debug builds
updater.automaticallyChecksForUpdates = false
updater.automaticallyDownloadsUpdates = false
logger.info("Sparkle updater initialized in DEBUG mode - automatic updates disabled")
#else
// Enable automatic checking for updates
updater.automaticallyChecksForUpdates = true
// Enable automatic checking for updates
updater.automaticallyChecksForUpdates = true
// Enable automatic downloading of updates
updater.automaticallyDownloadsUpdates = true
// Enable automatic downloading of updates
updater.automaticallyDownloadsUpdates = true
// Set update check interval to 24 hours
updater.updateCheckInterval = 86_400
// Set update check interval to 24 hours
updater.updateCheckInterval = 86_400
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
// Start the updater if it wasn't started during initialization
if !updaterController!.startedUpdater {
updaterController!.updater.startUpdater()
}
logger.info("Sparkle updater initialized successfully with automatic downloads enabled")
// Start the updater if it wasn't started during initialization
if let controller = updaterController, !controller.startedUpdater {
controller.updater.startUpdater()
}
#endif
// Note: feedURL configuration happens through delegate methods
}
}
@ -74,7 +74,7 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {
// Save the channel preference
UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel")
logger.info("Update channel set to: \(channel.rawValue)")
// The actual feed URL will be provided by the delegate method
}
@ -115,24 +115,26 @@ public final class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate {
// MARK: - SPUUpdaterDelegate
extension SparkleUpdaterManager {
nonisolated public func updater(_ updater: SPUUpdater, mayPerformUpdateCheck updateCheck: SPUUpdateCheck) throws {
public nonisolated func updater(_ updater: SPUUpdater, mayPerformUpdateCheck updateCheck: SPUUpdateCheck) throws {
// Allow update checks by default - not throwing an error means the check is allowed
// We could add logic here to prevent checks during certain conditions
}
nonisolated public func allowedChannels(for updater: SPUUpdater) -> Set<String> {
public nonisolated func allowedChannels(for updater: SPUUpdater) -> Set<String> {
// Get the current update channel from UserDefaults
if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: savedChannel) {
let channel = UpdateChannel(rawValue: savedChannel)
{
return channel.includesPreReleases ? Set(["", "prerelease"]) : Set([""])
}
return Set([""]) // Default to stable channel only
}
nonisolated public func feedURLString(for updater: SPUUpdater) -> String? {
public nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
// Provide the appropriate feed URL based on the current update channel
if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: savedChannel) {
let channel = UpdateChannel(rawValue: savedChannel)
{
return channel.appcastURL.absoluteString
}
return UpdateChannel.defaultChannel.appcastURL.absoluteString
@ -167,7 +169,8 @@ public final class SparkleViewModel {
// Load saved update channel
if let savedChannel = UserDefaults.standard.string(forKey: "updateChannel"),
let channel = UpdateChannel(rawValue: savedChannel) {
let channel = UpdateChannel(rawValue: savedChannel)
{
updateChannel = channel
} else {
updateChannel = UpdateChannel.stable
@ -190,7 +193,9 @@ extension ProcessInfo {
fileprivate var installedFromAppStore: Bool {
// Check for App Store receipt
let receiptURL = Bundle.main.appStoreReceiptURL
return receiptURL?.lastPathComponent == "receipt" && FileManager.default
.fileExists(atPath: receiptURL?.path ?? "")
if let receiptURL {
return receiptURL.lastPathComponent == "receipt" && FileManager.default.fileExists(atPath: receiptURL.path)
}
return false
}
}

View file

@ -59,13 +59,13 @@ final class TTYForwardManager {
do {
try process.run()
// Set up a handler to log when the process terminates
process.terminationHandler = { [weak self] process in
self?.logger.info("tty-fwd process terminated with status: \(process.terminationStatus)")
if process.terminationStatus != 0 {
self?.logger.error("tty-fwd process failed with exit code: \(process.terminationStatus)")
// Try to read stderr for error details
if let errorPipe = process.standardError as? Pipe {
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
@ -75,7 +75,7 @@ final class TTYForwardManager {
}
}
}
completion(.success(process))
} catch {
logger.error("Failed to execute tty-fwd: \(error.localizedDescription)")

View file

@ -362,7 +362,7 @@ public enum TunnelClientError: LocalizedError, Equatable {
}
}
public static func == (lhs: TunnelClientError, rhs: TunnelClientError) -> Bool {
public static func == (lhs: Self, rhs: Self) -> Bool {
switch (lhs, rhs) {
case (.invalidResponse, .invalidResponse):
true

View file

@ -110,10 +110,9 @@ public final class TunnelServer {
private var serverTask: Task<Void, Error>?
private let ttyFwdControlDir = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".vibetunnel")
.appendingPathComponent("control").path
private var bindAddress: String
public init(port: Int = 4_020, bindAddress: String = "127.0.0.1") {
self.port = port
self.bindAddress = bindAddress
@ -124,13 +123,12 @@ public final class TunnelServer {
logger.info("Starting TunnelServer on port \(port)")
do {
let router = Router(context: BasicRequestContext.self)
// Add middleware
router.add(middleware: LogRequestsMiddleware(.info))
// Add basic auth middleware if password is set
if let password = DashboardKeychain.shared.getPassword() {
router.add(middleware: BasicAuthMiddleware(password: password))
@ -149,7 +147,7 @@ public final class TunnelServer {
"uptime": ProcessInfo.processInfo.systemUptime
]
let jsonData = try! JSONSerialization.data(withJSONObject: info)
let jsonData = (try? JSONSerialization.data(withJSONObject: info)) ?? Data()
var buffer = ByteBuffer()
buffer.writeBytes(jsonData)
@ -234,9 +232,12 @@ public final class TunnelServer {
// Legacy endpoint for backwards compatibility
router.get("/sessions") { _, _ async -> Response in
let process = await MainActor.run {
TTYForwardManager.shared.createTTYForwardProcess(with: ["--control-path", self.ttyFwdControlDir, "--list-sessions"])
TTYForwardManager.shared.createTTYForwardProcess(with: [
"--control-path",
self.ttyFwdControlDir,
"--list-sessions"
])
}
guard let process else {
@ -274,26 +275,28 @@ public final class TunnelServer {
} else {
// Read error output
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let errorString = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let errorString = String(data: errorData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Provide more descriptive error messages based on exit code
let statusCode = Int(process.terminationStatus)
let errorDescription: String
switch statusCode {
let errorDescription: String = switch statusCode {
case 9:
errorDescription = "Process was killed (SIGKILL). The control directory may not exist or be accessible."
"Process was killed (SIGKILL). The control directory may not exist or be accessible."
case -9:
errorDescription = "Process was terminated by SIGKILL. This might be due to macOS security restrictions."
"Process was terminated by SIGKILL. This might be due to macOS security restrictions."
default:
errorDescription = errorString.isEmpty ? "Process exited with code \(statusCode)" : errorString
errorString.isEmpty ? "Process exited with code \(statusCode)" : errorString
}
// Log additional debugging information
self.logger.error("tty-fwd executable path: \(process.executableURL?.path ?? "unknown")")
self.logger.error("Control directory path: \(self.ttyFwdControlDir)")
self.logger.error("Control directory exists: \(FileManager.default.fileExists(atPath: self.ttyFwdControlDir))")
self.logger
.error(
"Control directory exists: \(FileManager.default.fileExists(atPath: self.ttyFwdControlDir))"
)
self.logger.error("tty-fwd failed with status \(statusCode): \(errorDescription)")
let errorJson =
@ -322,11 +325,11 @@ public final class TunnelServer {
// Serve index.html from root path
router.get("/") { _, _ async -> Response in
return await self.serveStaticFile(path: "index.html")
await self.serveStaticFile(path: "index.html")
}
// Serve static files from web/public folder (catch-all route - must be last)
router.get("**") { request, context async -> Response in
router.get("**") { request, _ async -> Response in
// Get the full path from the request URI
let requestPath = request.uri.path
// Remove leading slash
@ -391,7 +394,6 @@ public final class TunnelServer {
} else {
throw ServerError.failedToStart("Server did not start listening on port \(port)")
}
} catch {
lastError = error
isRunning = false
@ -417,7 +419,9 @@ public final class TunnelServer {
/// Verifies the server is listening by attempting an HTTP health check
private func isServerListening(on port: Int) async -> Bool {
do {
let url = URL(string: "http://127.0.0.1:\(port)/api/health")!
guard let url = URL(string: "http://127.0.0.1:\(port)/api/health") else {
return false
}
let request = URLRequest(url: url, timeoutInterval: 1.0)
let (_, response) = try await URLSession.shared.data(for: request)
@ -438,7 +442,9 @@ public final class TunnelServer {
throw NSError(
domain: "TtyFwdError",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "tty-fwd binary not found. Please ensure the app was built correctly."]
userInfo: [
NSLocalizedDescriptionKey: "tty-fwd binary not found. Please ensure the app was built correctly."
]
)
}
@ -455,40 +461,40 @@ public final class TunnelServer {
return String(data: outputData, encoding: .utf8) ?? ""
} else {
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let errorString = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let errorString = String(data: errorData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Provide more descriptive error messages based on exit code
let statusCode = Int(process.terminationStatus)
let errorDescription: String
switch statusCode {
let errorDescription: String = switch statusCode {
case 1:
errorDescription = "General error: \(errorString.isEmpty ? "Command failed" : errorString)"
"General error: \(errorString.isEmpty ? "Command failed" : errorString)"
case 2:
errorDescription = "Misuse of shell command: \(errorString.isEmpty ? "Invalid arguments" : errorString)"
"Misuse of shell command: \(errorString.isEmpty ? "Invalid arguments" : errorString)"
case 9:
errorDescription = "Process was killed (SIGKILL). The control directory may not exist or be accessible."
"Process was killed (SIGKILL). The control directory may not exist or be accessible."
case -9:
errorDescription = "Process was terminated by SIGKILL. This might be due to macOS security restrictions."
"Process was terminated by SIGKILL. This might be due to macOS security restrictions."
case 126:
errorDescription = "Command found but not executable"
"Command found but not executable"
case 127:
errorDescription = "Command not found"
"Command not found"
case 130:
errorDescription = "Process terminated by Ctrl+C"
"Process terminated by Ctrl+C"
case 139:
errorDescription = "Segmentation fault"
"Segmentation fault"
default:
errorDescription = errorString.isEmpty ? "Process exited with code \(statusCode)" : errorString
errorString.isEmpty ? "Process exited with code \(statusCode)" : errorString
}
// Log additional debugging information for SIGKILL
if statusCode == 9 || statusCode == -9 {
logger.error("tty-fwd executable path: \(process.executableURL?.path ?? "unknown")")
logger.error("Arguments: \(args.joined(separator: " "))")
logger.error("Control directory exists: \(FileManager.default.fileExists(atPath: self.ttyFwdControlDir))")
logger
.error("Control directory exists: \(FileManager.default.fileExists(atPath: self.ttyFwdControlDir))")
}
throw NSError(
domain: "TtyFwdError",
code: statusCode,
@ -550,16 +556,18 @@ public final class TunnelServer {
logger.error("Bundle resource path not found")
return errorResponse(message: "Resource bundle not available", status: .internalServerError)
}
let webPublicPath = resourcePath + "/web/public"
// Sanitize path to prevent directory traversal attacks
let sanitizedPath = path.replacingOccurrences(of: "..", with: "")
let fullPath = webPublicPath + "/" + sanitizedPath
// Check if the web directory exists in Resources
var isWebDirExists: ObjCBool = false
if !FileManager.default.fileExists(atPath: webPublicPath, isDirectory: &isWebDirExists) || !isWebDirExists.boolValue {
if !FileManager.default.fileExists(atPath: webPublicPath, isDirectory: &isWebDirExists) || !isWebDirExists
.boolValue
{
logger.error("Web resources not found at: \(webPublicPath)")
logger.error("Make sure the app was built with the 'Build Web Frontend' phase")
return errorResponse(message: "Web resources not bundled", status: .internalServerError)
@ -633,7 +641,6 @@ public final class TunnelServer {
private func listSessions() async -> Response {
do {
let output = try await executeTtyFwd(args: ["--control-path", ttyFwdControlDir, "--list-sessions"])
let sessionsData = output.data(using: .utf8) ?? Data()
@ -665,9 +672,10 @@ public final class TunnelServer {
lastModified: lastModified,
pid: sessionInfo.pid
)
}.sorted { a, b in
let dateA = ISO8601DateFormatter().date(from: a.lastModified) ?? Date.distantPast
let dateB = ISO8601DateFormatter().date(from: b.lastModified) ?? Date.distantPast
}
.sorted { first, second in
let dateA = ISO8601DateFormatter().date(from: first.lastModified) ?? Date.distantPast
let dateB = ISO8601DateFormatter().date(from: second.lastModified) ?? Date.distantPast
return dateA > dateB
}
@ -694,7 +702,6 @@ public final class TunnelServer {
return errorResponse(message: "Command array is required and cannot be empty", status: .badRequest)
}
let sessionName = "session_\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(9))"
let cwd = resolvePath(sessionRequest.workingDir ?? "", fallback: FileManager.default.currentDirectoryPath)
@ -713,33 +720,34 @@ public final class TunnelServer {
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
process.currentDirectoryPath = cwd
try process.run()
// Wait for session ID from stdout (similar to Node.js implementation)
var sessionId: String?
let outputData = outputPipe.fileHandleForReading.availableData
if !outputData.isEmpty {
let output = String(data: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let output = output, !output.isEmpty {
if let output, !output.isEmpty {
// First line of output should be the session ID (UUID)
sessionId = output
logger.info("Session created with ID: \(sessionId ?? "unknown")")
}
}
// If we didn't get a session ID, wait a bit and try again
if sessionId == nil {
// Wait up to 3 seconds for session ID
let maxAttempts = 30
for _ in 0..<maxAttempts {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
let moreData = outputPipe.fileHandleForReading.availableData
if !moreData.isEmpty {
let output = String(data: moreData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let output = output, !output.isEmpty {
let output = String(data: moreData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let output, !output.isEmpty {
sessionId = output
logger.info("Session created with ID: \(sessionId ?? "unknown")")
break
@ -747,7 +755,7 @@ public final class TunnelServer {
}
}
}
guard let finalSessionId = sessionId else {
logger.error("Failed to get session ID from tty-fwd")
return errorResponse(message: "Failed to create session - no session ID returned")
@ -755,7 +763,6 @@ public final class TunnelServer {
let response = SessionIdResponse(sessionId: finalSessionId)
return jsonResponse(response)
} catch {
logger.error("Error creating session: \(error)")
return errorResponse(message: "Failed to create session")
@ -784,7 +791,6 @@ public final class TunnelServer {
let response = SimpleResponse(success: true, message: "Session killed")
return jsonResponse(response)
} catch {
logger.error("Error killing session: \(error)")
return errorResponse(message: "Failed to kill session")
@ -797,7 +803,6 @@ public final class TunnelServer {
let response = SimpleResponse(success: true, message: "Session cleaned up")
return jsonResponse(response)
} catch {
logger.info("tty-fwd cleanup failed, force removing directory")
let sessionDir = URL(fileURLWithPath: ttyFwdControlDir).appendingPathComponent(sessionId).path
@ -822,16 +827,19 @@ public final class TunnelServer {
guard FileManager.default.fileExists(atPath: streamOutPath) else {
return errorResponse(message: "Session not found", status: .notFound)
}
// Create SSE response with proper headers
let headers: HTTPFields = [
.contentType: "text/event-stream",
.cacheControl: "no-cache, no-store, must-revalidate",
.connection: "keep-alive",
.init("X-Accel-Buffering")!: "no", // Disable proxy buffering
.init("Access-Control-Allow-Origin")!: "*"
]
var headers = HTTPFields()
headers[.contentType] = "text/event-stream"
headers[.cacheControl] = "no-cache, no-store, must-revalidate"
headers[.connection] = "keep-alive"
if let xAccelBuffering = HTTPField.Name("X-Accel-Buffering") {
headers[xAccelBuffering] = "no" // Disable proxy buffering
}
if let accessControlAllowOrigin = HTTPField.Name("Access-Control-Allow-Origin") {
headers[accessControlAllowOrigin] = "*"
}
// Create async sequence for streaming
let stream = AsyncStream<ByteBuffer> { continuation in
let task = Task {
@ -840,50 +848,53 @@ public final class TunnelServer {
continuation: continuation
)
}
continuation.onTermination = { _ in
task.cancel()
}
}
return Response(
status: .ok,
headers: headers,
body: ResponseBody(asyncSequence: stream)
)
}
private func streamFileContents(
streamOutPath: String,
continuation: AsyncStream<ByteBuffer>.Continuation
) async {
)
async
{
let startTime = Date()
var headerSent = false
var fileMonitor: DispatchSourceFileSystemObject?
defer {
// Ensure file monitor is cancelled when function exits
fileMonitor?.cancel()
}
// Send initial connection established message
var initialMessage = ByteBuffer()
initialMessage.writeString(": connected\n\n")
continuation.yield(initialMessage)
// Send existing content first
do {
let content = try String(contentsOfFile: streamOutPath, encoding: .utf8)
let lines = content.components(separatedBy: .newlines)
for line in lines {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if !trimmedLine.isEmpty {
if let data = trimmedLine.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) {
let parsed = try? JSONSerialization.jsonObject(with: data)
{
if let dict = parsed as? [String: Any],
dict["version"] != nil && dict["width"] != nil && dict["height"] != nil {
dict["version"] != nil && dict["width"] != nil && dict["height"] != nil
{
// Send header
var buffer = ByteBuffer()
buffer.writeString("data: \(trimmedLine)\n\n")
@ -893,7 +904,8 @@ public final class TunnelServer {
// Send event with instant timestamp (0)
let instantEvent = [0.0, array[1], array[2]]
if let eventData = try? JSONSerialization.data(withJSONObject: instantEvent),
let eventString = String(data: eventData, encoding: .utf8) {
let eventString = String(data: eventData, encoding: .utf8)
{
var buffer = ByteBuffer()
buffer.writeString("data: \(eventString)\n\n")
continuation.yield(buffer)
@ -905,7 +917,7 @@ public final class TunnelServer {
} catch {
logger.error("Error reading existing content: \(error)")
}
// Send default header if none found
if !headerSent {
let defaultHeader: [String: Any] = [
@ -915,29 +927,30 @@ public final class TunnelServer {
"timestamp": Int(startTime.timeIntervalSince1970),
"env": ["TERM": "xterm-256color"]
]
if let headerData = try? JSONSerialization.data(withJSONObject: defaultHeader),
let headerString = String(data: headerData, encoding: .utf8) {
let headerString = String(data: headerData, encoding: .utf8)
{
var buffer = ByteBuffer()
buffer.writeString("data: \(headerString)\n\n")
continuation.yield(buffer)
}
}
// Stream new content by monitoring file changes
fileMonitor = await monitorFileChanges(
streamOutPath: streamOutPath,
startTime: startTime,
continuation: continuation
)
// Keep the stream open until cancelled with periodic heartbeats
await withTaskCancellationHandler {
// Send heartbeat every 15 seconds to keep connection alive
while !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: 15_000_000_000) // 15 seconds
// Send SSE comment as heartbeat (comments start with ':')
var heartbeat = ByteBuffer()
heartbeat.writeString(": heartbeat\n\n")
@ -950,48 +963,50 @@ public final class TunnelServer {
} onCancel: { [fileMonitor] in
fileMonitor?.cancel()
}
continuation.finish()
}
private func monitorFileChanges(
streamOutPath: String,
startTime: Date,
continuation: AsyncStream<ByteBuffer>.Continuation
) async -> DispatchSourceFileSystemObject? {
)
async -> DispatchSourceFileSystemObject?
{
// Open file for reading
let fileDescriptor = open(streamOutPath, O_RDONLY)
guard fileDescriptor >= 0 else {
logger.error("Failed to open file for monitoring: \(streamOutPath)")
return nil
}
// Store buffer for incomplete lines
var lineBuffer = ""
// Read entire file content from the beginning
let fileSize = lseek(fileDescriptor, 0, SEEK_END)
if fileSize > 0 {
// Seek to beginning
lseek(fileDescriptor, 0, SEEK_SET)
// Read entire file content
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(fileSize) + 1)
defer { buffer.deallocate() }
var totalBytesRead = 0
while totalBytesRead < fileSize {
let bytesRead = read(fileDescriptor, buffer + totalBytesRead, Int(fileSize) - totalBytesRead)
if bytesRead <= 0 { break }
totalBytesRead += bytesRead
}
if totalBytesRead > 0 {
let data = Data(bytes: buffer, count: totalBytesRead)
if let initialContent = String(data: data, encoding: .utf8) {
lineBuffer = initialContent
let lines = lineBuffer.components(separatedBy: .newlines)
// Process all complete lines synchronously to maintain order
for i in 0..<lines.count - 1 {
let line = lines[i]
@ -1001,43 +1016,43 @@ public final class TunnelServer {
continuation: continuation
)
}
// Keep the last incomplete line in buffer
lineBuffer = lines.last ?? ""
}
}
}
// Set position to current end for monitoring new content
var lastReadPosition = lseek(fileDescriptor, 0, SEEK_END)
// Create dispatch source for monitoring file writes
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fileDescriptor,
eventMask: [.write, .extend],
queue: DispatchQueue.main
)
source.setEventHandler { [weak self] in
guard let self = self else { return }
guard let self else { return }
// Get current file size
let currentPosition = lseek(fileDescriptor, 0, SEEK_END)
// Calculate how much new data to read
let bytesToRead = currentPosition - lastReadPosition
guard bytesToRead > 0 else { return }
// Seek to last read position
lseek(fileDescriptor, lastReadPosition, SEEK_SET)
// Read new data
let buffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(bytesToRead) + 1)
defer { buffer.deallocate() }
let bytesRead = read(fileDescriptor, buffer, Int(bytesToRead))
guard bytesRead > 0 else { return }
// Convert to string (handle potential UTF-8 boundary issues)
let data = Data(bytes: buffer, count: bytesRead)
guard let contentString = String(data: data, encoding: .utf8) else {
@ -1045,14 +1060,14 @@ public final class TunnelServer {
// Store the bytes and try again with next chunk
return
}
// Update last read position
lastReadPosition = currentPosition
// Process new content
lineBuffer += contentString
let lines = lineBuffer.components(separatedBy: .newlines)
// Process all complete lines synchronously to maintain order
if lines.count > 1 {
Task { @MainActor in
@ -1069,33 +1084,36 @@ public final class TunnelServer {
lineBuffer = lines.last ?? ""
}
}
source.setCancelHandler {
close(fileDescriptor)
}
// Start monitoring
source.resume()
return source
}
private func processNewLine(
line: String,
startTime: Date,
continuation: AsyncStream<ByteBuffer>.Continuation
) async {
)
async
{
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
if let data = trimmedLine.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) {
let parsed = try? JSONSerialization.jsonObject(with: data)
{
// Skip duplicate headers
if let dict = parsed as? [String: Any],
dict["version"] != nil && dict["width"] != nil && dict["height"] != nil {
dict["version"] != nil && dict["width"] != nil && dict["height"] != nil
{
return
}
if let array = parsed as? [Any], array.count >= 3 {
let currentTime = Date()
let realTimeEvent = [
@ -1103,9 +1121,10 @@ public final class TunnelServer {
array[1],
array[2]
]
if let eventData = try? JSONSerialization.data(withJSONObject: realTimeEvent),
let eventString = String(data: eventData, encoding: .utf8) {
let eventString = String(data: eventData, encoding: .utf8)
{
var buffer = ByteBuffer()
buffer.writeString("data: \(eventString)\n\n")
continuation.yield(buffer)
@ -1119,9 +1138,10 @@ public final class TunnelServer {
"o",
trimmedLine
]
if let eventData = try? JSONSerialization.data(withJSONObject: castEvent),
let eventString = String(data: eventData, encoding: .utf8) {
let eventString = String(data: eventData, encoding: .utf8)
{
var buffer = ByteBuffer()
buffer.writeString("data: \(eventString)\n\n")
continuation.yield(buffer)
@ -1142,7 +1162,7 @@ public final class TunnelServer {
let lines = content.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
var header: [String: Any]? = nil
var header: [String: Any]?
var events: [[Any]] = []
for line in lines {
@ -1199,7 +1219,6 @@ public final class TunnelServer {
headers: [.contentType: "text/plain"],
body: ResponseBody(byteBuffer: buffer)
)
} catch {
logger.error("Error reading session snapshot: \(error)")
return errorResponse(message: "Failed to read session snapshot")
@ -1209,11 +1228,11 @@ public final class TunnelServer {
private func getSessionCast(sessionId: String) async -> Response {
let streamOutPath = URL(fileURLWithPath: ttyFwdControlDir).appendingPathComponent(sessionId)
.appendingPathComponent("stream-out").path
guard FileManager.default.fileExists(atPath: streamOutPath) else {
return errorResponse(message: "Session not found", status: .notFound)
}
do {
// Get session info to extract command and title
let sessionInfoOutput = try await executeTtyFwd(args: [
@ -1221,17 +1240,18 @@ public final class TunnelServer {
ttyFwdControlDir,
"--list-sessions"
])
var sessionCommand: String?
var sessionTitle: String?
if let sessionData = sessionInfoOutput.data(using: .utf8),
let sessions = try? JSONDecoder().decode([String: TtyFwdSession].self, from: sessionData),
let session = sessions[sessionId] {
let session = sessions[sessionId]
{
sessionCommand = session.cmdline.joined(separator: " ")
sessionTitle = "VibeTunnel Session: \(session.name)"
}
// Generate cast file
let castGenerator = CastFileGenerator()
let castData = try castGenerator.generateCastFile(
@ -1242,10 +1262,10 @@ public final class TunnelServer {
title: sessionTitle,
command: sessionCommand
)
var buffer = ByteBuffer()
buffer.writeBytes(castData)
return Response(
status: .ok,
headers: [
@ -1254,7 +1274,6 @@ public final class TunnelServer {
],
body: ResponseBody(byteBuffer: buffer)
)
} catch {
logger.error("Error generating cast file: \(error)")
return errorResponse(message: "Failed to generate cast file")
@ -1288,7 +1307,8 @@ public final class TunnelServer {
guard let sessionData = sessionInfoOutput.data(using: .utf8),
let sessions = try? JSONDecoder().decode([String: TtyFwdSession].self, from: sessionData),
let session = sessions[sessionId] else {
let session = sessions[sessionId]
else {
logger.error("Session \(sessionId) not found in active sessions")
return errorResponse(message: "Session not found", status: .notFound)
}
@ -1304,7 +1324,7 @@ public final class TunnelServer {
let processExists = kill(pid_t(session.pid), 0) == 0
if !processExists {
logger.error("Session \(sessionId) process \(session.pid) is dead, cleaning up")
// Try to cleanup the stale session
do {
_ = try await executeTtyFwd(args: [
@ -1317,16 +1337,25 @@ public final class TunnelServer {
} catch {
logger.error("Failed to cleanup stale session: \(error)")
}
return errorResponse(message: "Session process has died", status: HTTPResponse.Status(code: 410))
}
}
let specialKeys = ["arrow_up", "arrow_down", "arrow_left", "arrow_right", "escape", "enter", "ctrl_enter", "shift_enter"]
let specialKeys = [
"arrow_up",
"arrow_down",
"arrow_left",
"arrow_right",
"escape",
"enter",
"ctrl_enter",
"shift_enter"
]
let isSpecialKey = specialKeys.contains(text)
let startTime = Date()
if isSpecialKey {
_ = try await executeTtyFwd(args: [
"--control-path",
@ -1336,7 +1365,7 @@ public final class TunnelServer {
"--send-key",
text
])
let elapsed = Date().timeIntervalSince(startTime) * 1000
let elapsed = Date().timeIntervalSince(startTime) * 1_000
logger.info("Successfully sent key: \(text) (\(Int(elapsed))ms)")
} else {
_ = try await executeTtyFwd(args: [
@ -1347,17 +1376,16 @@ public final class TunnelServer {
"--send-text",
text
])
let elapsed = Date().timeIntervalSince(startTime) * 1000
let elapsed = Date().timeIntervalSince(startTime) * 1_000
logger.info("Successfully sent text: \(text) (\(Int(elapsed))ms)")
}
struct SuccessResponse: Codable {
let success: Bool
}
let response = SuccessResponse(success: true)
return jsonResponse(response)
} catch let decodingError as DecodingError {
logger.error("Error decoding input request: \(decodingError)")
return errorResponse(message: "Invalid request format", status: .badRequest)
@ -1374,7 +1402,6 @@ public final class TunnelServer {
let response = SimpleResponse(success: true, message: "All exited sessions cleaned up")
return jsonResponse(response)
} catch {
logger.error("Error cleaning up exited sessions: \(error)")
return errorResponse(message: "Failed to cleanup exited sessions")
@ -1417,15 +1444,15 @@ public final class TunnelServer {
size: size,
isDir: isDir
)
}.sorted { a, b in
if a.isDir && !b.isDir { return true }
if !a.isDir && b.isDir { return false }
return a.name.localizedCompare(b.name) == .orderedAscending
}
.sorted { first, second in
if first.isDir && !second.isDir { return true }
if !first.isDir && second.isDir { return false }
return first.name.localizedCompare(second.name) == .orderedAscending
}
let listing = DirectoryListing(absolutePath: expandedPath, files: files)
return jsonResponse(listing)
} catch {
logger.error("Error listing directory: \(error)")
return errorResponse(message: "Failed to list directory")

View file

@ -4,30 +4,36 @@ import AppKit
enum WindowCenteringHelper {
/// Centers a window on the active screen (where the mouse cursor is located)
/// - Parameter window: The NSWindow to center
@MainActor static func centerOnActiveScreen(_ window: NSWindow) {
@MainActor
static func centerOnActiveScreen(_ window: NSWindow) {
if let screen = NSScreen.main ?? NSScreen.screens.first {
let screenFrame = screen.visibleFrame
let windowFrame = window.frame
let newX = screenFrame.midX - windowFrame.width / 2
let newY = screenFrame.midY - windowFrame.height / 2
window.setFrameOrigin(NSPoint(x: newX, y: newY))
}
}
/// Positions a window off-screen (useful for hidden windows)
/// - Parameter window: The NSWindow to position off-screen
@MainActor static func positionOffScreen(_ window: NSWindow) {
@MainActor
static func positionOffScreen(_ window: NSWindow) {
if let screen = NSScreen.main {
let screenFrame = screen.frame
window.setFrame(NSRect(x: screenFrame.midX, y: screenFrame.minY - 1000, width: 1, height: 1), display: false)
window.setFrame(
NSRect(x: screenFrame.midX, y: screenFrame.minY - 1_000, width: 1, height: 1),
display: false
)
}
}
/// Centers a window using the built-in NSWindow center method
/// - Parameter window: The NSWindow to center
@MainActor static func centerDefault(_ window: NSWindow) {
@MainActor
static func centerDefault(_ window: NSWindow) {
window.center()
}
}
}

View file

@ -2,9 +2,12 @@ import SwiftUI
/// Main menu bar view displaying session status and app controls
struct MenuBarView: View {
@Environment(SessionMonitor.self) var sessionMonitor
@Environment(ServerMonitor.self) var serverMonitor
@AppStorage("showInDock") private var showInDock = false
@Environment(SessionMonitor.self)
var sessionMonitor
@Environment(ServerMonitor.self)
var serverMonitor
@AppStorage("showInDock")
private var showInDock = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@ -15,11 +18,12 @@ struct MenuBarView: View {
// Open Dashboard button
Button(action: {
let dashboardURL = URL(string: "http://127.0.0.1:\(serverMonitor.port)")!
NSWorkspace.shared.open(dashboardURL)
}) {
if let dashboardURL = URL(string: "http://127.0.0.1:\(serverMonitor.port)") {
NSWorkspace.shared.open(dashboardURL)
}
}, label: {
Label("Open Dashboard", systemImage: "safari")
}
})
.buttonStyle(MenuButtonStyle())
.disabled(!serverMonitor.isRunning)
@ -31,14 +35,6 @@ struct MenuBarView: View {
.padding(.horizontal, 12)
.padding(.vertical, 8)
// Session list
if sessionMonitor.sessionCount > 0 {
SessionListView(sessions: sessionMonitor.sessions)
.padding(.horizontal, 12)
.padding(.bottom, 4)
.frame(minWidth: 280)
}
Divider()
.padding(.vertical, 4)
@ -48,34 +44,38 @@ struct MenuBarView: View {
// Show Tutorial
Button(action: {
AppDelegate.showWelcomeScreen()
}) {
}, label: {
Label("Show Tutorial", systemImage: "book")
}
})
Divider()
// Website
Button(action: {
if let url = URL(string: "http://vibetunnel.sh") {
NSWorkspace.shared.open(url)
}
}) {
}, label: {
Label("Website", systemImage: "globe")
}
})
// Report Issue
Button(action: {
if let url = URL(string: "https://github.com/amantus-ai/vibetunnel/issues") {
NSWorkspace.shared.open(url)
}
}) {
}, label: {
Label("Report Issue", systemImage: "exclamationmark.triangle")
}
})
Divider()
// Check for Updates
Button(action: {
SparkleUpdaterManager.shared.checkForUpdates()
}) {
}, label: {
Label("Check for Updates…", systemImage: "arrow.down.circle")
}
})
// Version (non-interactive)
Text("Version \(appVersion)")
@ -124,9 +124,9 @@ struct MenuBarView: View {
// Quit button
Button(action: {
NSApplication.shared.terminate(nil)
}) {
}, label: {
Label("Quit", systemImage: "power")
}
})
.buttonStyle(MenuButtonStyle())
.keyboardShortcut("q", modifiers: .command)
}
@ -265,4 +265,3 @@ struct MenuButtonStyle: ButtonStyle {
}
}
}

View file

@ -1,12 +1,5 @@
//
// ServerConsoleView.swift
// VibeTunnel
//
// Console view for displaying server logs
//
import SwiftUI
import Observation
import SwiftUI
/// View for displaying server console logs
struct ServerConsoleView: View {
@ -14,7 +7,7 @@ struct ServerConsoleView: View {
@State private var autoScroll = true
@State private var filterText = ""
@State private var selectedLevel: ServerLogEntry.Level?
var body: some View {
VStack(spacing: 0) {
// Header with controls
@ -23,11 +16,11 @@ struct ServerConsoleView: View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Filter logs...", text: $filterText)
.textFieldStyle(.roundedBorder)
.frame(width: 200)
Picker("Level", selection: $selectedLevel) {
Text("All").tag(nil as ServerLogEntry.Level?)
Text("Debug").tag(ServerLogEntry.Level.debug)
@ -38,20 +31,20 @@ struct ServerConsoleView: View {
.pickerStyle(.menu)
.labelsHidden()
}
Spacer()
// Controls
HStack(spacing: 12) {
Toggle("Auto-scroll", isOn: $autoScroll)
.toggleStyle(.checkbox)
Button(action: { viewModel.clearLogs() }) {
Button(action: viewModel.clearLogs) {
Label("Clear", systemImage: "trash")
}
.buttonStyle(.borderless)
Button(action: { viewModel.exportLogs() }) {
Button(action: viewModel.exportLogs) {
Label("Export", systemImage: "square.and.arrow.up")
}
.buttonStyle(.borderless)
@ -59,9 +52,9 @@ struct ServerConsoleView: View {
}
.padding()
.background(Color(NSColor.controlBackgroundColor))
Divider()
// Console output
ScrollViewReader { proxy in
ScrollView {
@ -70,7 +63,7 @@ struct ServerConsoleView: View {
ServerLogEntryView(entry: entry)
.id(entry.id)
}
// Invisible anchor for auto-scrolling
Color.clear
.frame(height: 1)
@ -94,19 +87,19 @@ struct ServerConsoleView: View {
viewModel.cleanup()
}
}
private var filteredLogs: [ServerLogEntry] {
viewModel.logs.filter { entry in
// Level filter
if let selectedLevel = selectedLevel, entry.level != selectedLevel {
if let selectedLevel, entry.level != selectedLevel {
return false
}
// Text filter
if !filterText.isEmpty {
return entry.message.localizedCaseInsensitiveContains(filterText)
}
return true
}
}
@ -115,7 +108,7 @@ struct ServerConsoleView: View {
/// View for a single log entry
struct ServerLogEntryView: View {
let entry: ServerLogEntry
var body: some View {
HStack(alignment: .top, spacing: 8) {
// Timestamp
@ -123,13 +116,13 @@ struct ServerLogEntryView: View {
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 80, alignment: .leading)
// Level indicator
Circle()
.fill(entry.level.color)
.frame(width: 6, height: 6)
.padding(.top, 6)
// Source badge
Text(entry.source.displayName)
.font(.caption2)
@ -138,7 +131,7 @@ struct ServerLogEntryView: View {
.background(entry.source.color.opacity(0.2))
.foregroundStyle(entry.source.color)
.clipShape(Capsule())
// Message
Text(entry.message)
.textSelection(.enabled)
@ -154,10 +147,10 @@ struct ServerLogEntryView: View {
@Observable
class ServerConsoleViewModel {
private(set) var logs: [ServerLogEntry] = []
private var logTask: Task<Void, Never>?
private let maxLogs = 1000
private let maxLogs = 1_000
init() {
// Subscribe to server logs using async stream
logTask = Task { [weak self] in
@ -166,39 +159,40 @@ class ServerConsoleViewModel {
}
}
}
func cleanup() {
logTask?.cancel()
}
private func addLog(_ entry: ServerLogEntry) {
logs.append(entry)
// Trim old logs if needed
if logs.count > maxLogs {
logs.removeFirst(logs.count - maxLogs)
}
}
func clearLogs() {
logs.removeAll()
}
func exportLogs() {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
let logText = logs.map { entry in
let timestamp = dateFormatter.string(from: entry.timestamp)
let level = String(describing: entry.level).uppercased().padding(toLength: 7, withPad: " ", startingAt: 0)
let source = entry.source.displayName.padding(toLength: 12, withPad: " ", startingAt: 0)
return "[\(timestamp)] [\(level)] [\(source)] \(entry.message)"
}.joined(separator: "\n")
}
.joined(separator: "\n")
let savePanel = NSSavePanel()
savePanel.allowedContentTypes = [.plainText]
savePanel.nameFieldStringValue = "vibetunnel-server-logs.txt"
if savePanel.runModal() == .OK, let url = savePanel.url {
try? logText.write(to: url, atomically: true, encoding: .utf8)
}
@ -216,19 +210,19 @@ extension ServerLogEntry: Identifiable {
extension ServerLogEntry.Level {
var color: Color {
switch self {
case .debug: return .gray
case .info: return .blue
case .warning: return .orange
case .error: return .red
case .debug: .gray
case .info: .blue
case .warning: .orange
case .error: .red
}
}
var textColor: Color {
switch self {
case .debug: return .secondary
case .info: return .primary
case .warning: return .orange
case .error: return .red
case .debug: .secondary
case .info: .primary
case .warning: .orange
case .error: .red
}
}
}
@ -236,8 +230,8 @@ extension ServerLogEntry.Level {
extension ServerMode {
var color: Color {
switch self {
case .hummingbird: return .blue
case .rust: return .orange
case .hummingbird: .blue
case .rust: .orange
}
}
}
}

View file

@ -1,5 +1,6 @@
import SwiftUI
import AppKit
import os.log
import SwiftUI
/// Represents the available tabs in the Settings window
enum SettingsTab: String, CaseIterable {
@ -38,7 +39,8 @@ extension Notification.Name {
struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general
@State private var contentSize: CGSize = .zero
@AppStorage("debugMode") private var debugMode = false
@AppStorage("debugMode")
private var debugMode = false
/// Define ideal sizes for each tab
private let tabSizes: [SettingsTab: CGSize] = [
@ -271,13 +273,13 @@ struct DashboardSettingsView: View {
private var ngrokTokenPresent = false
@AppStorage("dashboardAccessMode")
private var accessModeString = DashboardAccessMode.localhost.rawValue
@State private var password = ""
@State private var confirmPassword = ""
@State private var showPasswordFields = false
@State private var passwordError: String?
@State private var passwordSaved = false
@State private var ngrokAuthToken = ""
@State private var ngrokStatus: NgrokTunnelStatus?
@State private var isStartingNgrok = false
@ -288,14 +290,15 @@ struct DashboardSettingsView: View {
@State private var serverErrorMessage = ""
@State private var isTokenRevealed = false
@State private var maskedToken = ""
private let dashboardKeychain = DashboardKeychain.shared
private let ngrokService = NgrokService.shared
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "DashboardSettings")
private var accessMode: DashboardAccessMode {
DashboardAccessMode(rawValue: accessModeString) ?? .localhost
}
var body: some View {
NavigationStack {
Form {
@ -313,24 +316,24 @@ struct DashboardSettingsView: View {
passwordSaved = false
}
}
Text("Require a password to access the dashboard from remote connections.")
.font(.caption)
.foregroundStyle(.secondary)
if showPasswordFields || (passwordEnabled && !passwordSaved) {
VStack(spacing: 8) {
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
SecureField("Confirm Password", text: $confirmPassword)
.textFieldStyle(.roundedBorder)
if let error = passwordError {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
HStack {
Button("Cancel") {
showPasswordFields = false
@ -340,7 +343,7 @@ struct DashboardSettingsView: View {
passwordError = nil
}
.buttonStyle(.bordered)
Button("Save Password") {
savePassword()
}
@ -350,7 +353,7 @@ struct DashboardSettingsView: View {
}
.padding(.top, 4)
}
if passwordSaved {
HStack {
Image(systemName: "checkmark.circle.fill")
@ -373,10 +376,12 @@ struct DashboardSettingsView: View {
Text("Security")
.font(.headline)
} footer: {
Text("When password protection is enabled, localhost connections can still access without a password. For remote access, any username is accepted - only the password is verified.")
.font(.caption)
Text(
"When password protection is enabled, localhost connections can still access without a password. For remote access, any username is accepted - only the password is verified."
)
.font(.caption)
}
Section {
// Access Mode
VStack(alignment: .leading, spacing: 8) {
@ -401,10 +406,10 @@ struct DashboardSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Divider()
.padding(.vertical, 4)
// Port Configuration
VStack(alignment: .leading, spacing: 4) {
HStack {
@ -414,7 +419,7 @@ struct DashboardSettingsView: View {
.textFieldStyle(.roundedBorder)
.frame(width: 80)
.multilineTextAlignment(.center)
.onChange(of: serverPort) { oldValue, newValue in
.onChange(of: serverPort) { _, newValue in
// Validate port number
if let port = Int(newValue), port > 0, port < 65_536 {
restartServerWithNewPort(port)
@ -429,14 +434,14 @@ struct DashboardSettingsView: View {
Text("Server Configuration")
.font(.headline)
}
Section {
VStack(alignment: .leading, spacing: 12) {
// ngrok Enable Toggle
VStack(alignment: .leading, spacing: 4) {
Toggle("Enable ngrok tunnel", isOn: $ngrokEnabled)
.onChange(of: ngrokEnabled) { oldValue, newValue in
print("ngrok toggle changed from \(oldValue) to \(newValue)")
logger.debug("ngrok toggle changed from \(oldValue) to \(newValue)")
if newValue {
// Add a small delay to ensure auth token is saved to keychain
Task {
@ -488,9 +493,9 @@ struct DashboardSettingsView: View {
}
Button(action: {
toggleTokenVisibility()
}) {
}, label: {
Image(systemName: isTokenRevealed ? "eye.slash" : "eye")
}
})
.buttonStyle(.plain)
.help(isTokenRevealed ? "Hide token" : "Reveal token")
}
@ -500,8 +505,9 @@ struct DashboardSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
Button("ngrok.com") {
NSWorkspace.shared
.open(URL(string: "https://dashboard.ngrok.com/auth/your-authtoken")!)
if let url = URL(string: "https://dashboard.ngrok.com/auth/your-authtoken") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.link)
.font(.caption)
@ -526,7 +532,7 @@ struct DashboardSettingsView: View {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(publicUrl, forType: .string)
}
Button("Open Browser") {
if let url = URL(string: publicUrl) {
NSWorkspace.shared.open(url)
@ -590,58 +596,60 @@ struct DashboardSettingsView: View {
passwordSaved = true
passwordEnabled = true
}
// Check if token exists without triggering keychain
if ngrokService.hasAuthToken && !ngrokTokenPresent {
ngrokTokenPresent = true
}
// Update masked field based on token presence
if ngrokTokenPresent && !isTokenRevealed {
maskedToken = String(repeating: "", count: 12)
}
}
.alert("ngrok Auth Token Required", isPresented: $showingAuthTokenAlert) {
Button("OK") { }
Button("OK") {}
} message: {
Text("Please enter your ngrok auth token before enabling the tunnel. You can get a free auth token at ngrok.com")
Text(
"Please enter your ngrok auth token before enabling the tunnel. You can get a free auth token at ngrok.com"
)
}
.alert("Keychain Access Error", isPresented: $showingKeychainAlert) {
Button("OK") { }
Button("OK") {}
} message: {
Text("Failed to save the auth token to the keychain. Please check your keychain permissions and try again.")
}
.alert("Failed to Restart Server", isPresented: $showingServerErrorAlert) {
Button("OK") { }
Button("OK") {}
} message: {
Text(serverErrorMessage)
}
}
private func savePassword() {
passwordError = nil
guard !password.isEmpty else {
passwordError = "Password cannot be empty"
return
}
guard password == confirmPassword else {
passwordError = "Passwords do not match"
return
}
guard password.count >= 6 else {
passwordError = "Password must be at least 6 characters"
return
}
if dashboardKeychain.setPassword(password) {
passwordSaved = true
showPasswordFields = false
password = ""
confirmPassword = ""
// When password is set for the first time, automatically switch to network mode
if accessMode == .localhost {
accessModeString = DashboardAccessMode.network.rawValue
@ -651,53 +659,53 @@ struct DashboardSettingsView: View {
passwordError = "Failed to save password to keychain"
}
}
private func restartServerWithNewPort(_ port: Int) {
Task {
// Update the port in ServerManager and restart
ServerManager.shared.port = String(port)
await ServerManager.shared.restart()
print("Server restarted on port \(port)")
logger.info("Server restarted on port \(port)")
// Restart session monitoring with new port
SessionMonitor.shared.stopMonitoring()
SessionMonitor.shared.startMonitoring()
}
}
private func restartServerWithNewBindAddress() {
Task {
// Update the bind address in ServerManager and restart
ServerManager.shared.bindAddress = accessMode.bindAddress
await ServerManager.shared.restart()
print("Server restarted with bind address \(accessMode.bindAddress)")
logger.info("Server restarted with bind address \(accessMode.bindAddress)")
// Restart session monitoring
SessionMonitor.shared.stopMonitoring()
SessionMonitor.shared.startMonitoring()
}
}
private func checkAndStartNgrok() {
print("checkAndStartNgrok called")
logger.debug("checkAndStartNgrok called")
// Check if we have a token in the keychain without accessing it
guard ngrokTokenPresent || ngrokService.hasAuthToken else {
print("No auth token stored")
logger.debug("No auth token stored")
ngrokError = "Please enter your ngrok auth token first"
ngrokEnabled = false
showingAuthTokenAlert = true
return
}
// If token hasn't been revealed yet, we need to access it from keychain
if !isTokenRevealed && ngrokAuthToken.isEmpty {
// This will trigger keychain access
if let token = ngrokService.authToken {
ngrokAuthToken = token
print("Retrieved token from keychain for ngrok start")
logger.debug("Retrieved token from keychain for ngrok start")
} else {
print("Failed to retrieve token from keychain")
logger.error("Failed to retrieve token from keychain")
ngrokError = "Failed to access auth token. Please try again."
ngrokEnabled = false
showingKeychainAlert = true
@ -705,20 +713,20 @@ struct DashboardSettingsView: View {
}
}
print("Starting ngrok with auth token present")
logger.debug("Starting ngrok with auth token present")
isStartingNgrok = true
ngrokError = nil
Task {
do {
let port = Int(serverPort) ?? 4_020
print("Starting ngrok on port \(port)")
logger.info("Starting ngrok on port \(port)")
_ = try await ngrokService.start(port: port)
isStartingNgrok = false
ngrokStatus = await ngrokService.getStatus()
print("ngrok started successfully")
logger.info("ngrok started successfully")
} catch {
print("ngrok start error: \(error)")
logger.error("ngrok start error: \(error)")
isStartingNgrok = false
ngrokError = error.localizedDescription
ngrokEnabled = false
@ -733,7 +741,7 @@ struct DashboardSettingsView: View {
// Don't clear the error here - let it remain visible
}
}
private func toggleTokenVisibility() {
if isTokenRevealed {
// Hide the token
@ -780,14 +788,14 @@ struct AdvancedSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
Toggle("Clean up old sessions on startup", isOn: $cleanupOnStartup)
Text("Automatically remove terminated sessions when the app starts.")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
Toggle("Debug mode", isOn: $debugMode)
Text("Enable additional logging and debugging features.")
@ -804,7 +812,7 @@ struct AdvancedSettingsView: View {
.navigationTitle("Advanced Settings")
}
}
private func installCLITool() {
let installer = CLIInstaller()
installer.installCLITool()
@ -823,14 +831,19 @@ struct DebugSettingsView: View {
@State private var lastError: String?
@State private var testResult: String?
@State private var isTesting = false
@AppStorage("debugMode") private var debugMode = false
@AppStorage("logLevel") private var logLevel = "info"
@AppStorage("serverMode") private var serverModeString = ServerMode.rust.rawValue
@AppStorage("debugMode")
private var debugMode = false
@AppStorage("logLevel")
private var logLevel = "info"
@AppStorage("serverMode")
private var serverModeString = ServerMode.rust.rawValue
@State private var serverManager = ServerManager.shared
@State private var isServerHealthy = false
@State private var heartbeatTask: Task<Void, Never>?
@State private var showPurgeConfirmation = false
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "DebugSettings")
private var isServerRunning: Bool {
serverMonitor.isRunning
}
@ -857,10 +870,11 @@ struct DebugSettingsView: View {
.scaleEffect(0.6)
}
}
Text(isServerHealthy ? "Server is running on port \(serverPort)" :
isServerRunning ? "Server starting... (checking health)" : "Server is stopped")
.font(.caption)
.foregroundStyle(.secondary)
Text(isServerHealthy ? "Server is running on port \(serverPort)" :
isServerRunning ? "Server starting... (checking health)" : "Server is stopped"
)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
@ -922,7 +936,7 @@ struct DebugSettingsView: View {
.labelsHidden()
.disabled(serverManager.isSwitching)
}
if serverManager.isSwitching {
HStack {
ProgressView()
@ -942,18 +956,21 @@ struct DebugSettingsView: View {
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
}
Section {
// Server Information
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Status") {
HStack {
Image(systemName: isServerHealthy ? "checkmark.circle.fill" :
isServerRunning ? "exclamationmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(isServerHealthy ? .green :
isServerRunning ? .orange : .secondary)
Text(isServerHealthy ? "Healthy" :
isServerRunning ? "Unhealthy" : "Stopped")
Image(systemName: isServerHealthy ? "checkmark.circle.fill" :
isServerRunning ? "exclamationmark.circle.fill" : "xmark.circle.fill"
)
.foregroundStyle(isServerHealthy ? .green :
isServerRunning ? .orange : .secondary
)
Text(isServerHealthy ? "Healthy" :
isServerRunning ? "Unhealthy" : "Stopped"
)
}
}
@ -965,7 +982,7 @@ struct DebugSettingsView: View {
Text("http://127.0.0.1:\(serverPort)")
.font(.system(.body, design: .monospaced))
}
LabeledContent("Mode") {
Text(serverManager.currentServer?.serverType.displayName ?? "None")
.foregroundStyle(.secondary)
@ -1070,7 +1087,7 @@ struct DebugSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("System Logs")
@ -1098,7 +1115,7 @@ struct DebugSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Welcome Screen")
@ -1112,7 +1129,7 @@ struct DebugSettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("User Defaults")
@ -1155,12 +1172,14 @@ struct DebugSettingsView: View {
isServerHealthy = false
}
.alert("Purge All User Defaults?", isPresented: $showPurgeConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Cancel", role: .cancel) {}
Button("Purge", role: .destructive) {
purgeAllUserDefaults()
}
} message: {
Text("This will remove all stored preferences and reset the app to its default state. The app will quit after purging.")
Text(
"This will remove all stored preferences and reset the app to its default state. The app will quit after purging."
)
}
}
}
@ -1193,7 +1212,10 @@ struct DebugSettingsView: View {
Task {
do {
let url = URL(string: "http://127.0.0.1:\(serverPort)\(endpoint.path)")!
guard let url = URL(string: "http://127.0.0.1:\(serverPort)\(endpoint.path)") else {
testResult = "Invalid URL"
return
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method
@ -1228,7 +1250,7 @@ struct DebugSettingsView: View {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: appDirectory.path)
}
}
private func showServerConsole() {
// Create a new window for the server console
let consoleWindow = NSWindow(
@ -1239,66 +1261,68 @@ struct DebugSettingsView: View {
)
consoleWindow.title = "Server Console"
consoleWindow.center()
let consoleView = ServerConsoleView()
.onDisappear {
// This will be called when the window closes
}
consoleWindow.contentView = NSHostingView(rootView: consoleView)
let windowController = NSWindowController(window: consoleWindow)
windowController.showWindow(nil)
}
private func startHeartbeatMonitoring() {
// Cancel any existing heartbeat task
heartbeatTask?.cancel()
// Start a new heartbeat monitoring task
heartbeatTask = Task {
while !Task.isCancelled {
// Check server health
let healthy = await checkServerHealth()
// Update UI on main actor
await MainActor.run {
isServerHealthy = healthy
}
// Wait before next heartbeat
try? await Task.sleep(for: .seconds(2))
}
}
}
private func checkServerHealth() async -> Bool {
guard isServerRunning else { return false }
do {
let url = URL(string: "http://127.0.0.1:\(serverPort)/api/health")!
guard let url = URL(string: "http://127.0.0.1:\(serverPort)/api/health") else {
return false
}
var request = URLRequest(url: url)
request.timeoutInterval = 1.0 // Quick timeout for heartbeat
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
// Server not responding or error
print("Server health check failed: \(error.localizedDescription)")
logger.error("Server health check failed: \(error.localizedDescription)")
}
return false
}
private func purgeAllUserDefaults() {
// Get the app's bundle identifier
if let bundleIdentifier = Bundle.main.bundleIdentifier {
// Remove all UserDefaults for this app
UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
UserDefaults.standard.synchronize()
// Quit the app after a short delay to ensure the purge completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
NSApplication.shared.terminate(nil)

View file

@ -2,10 +2,12 @@ import SwiftUI
struct WelcomeView: View {
@State private var currentPage = 0
@Environment(\.dismiss) private var dismiss
@AppStorage("hasSeenWelcome") private var hasSeenWelcome = false
@Environment(\.dismiss)
private var dismiss
@AppStorage("hasSeenWelcome")
private var hasSeenWelcome = false
@State private var cliInstaller = CLIInstaller()
var body: some View {
VStack(spacing: 0) {
// Custom page view implementation for macOS
@ -15,19 +17,19 @@ struct WelcomeView: View {
WelcomePageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 2: VT Command
if currentPage == 1 {
VTCommandPageView(cliInstaller: cliInstaller)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 3: Protect Your Dashboard
if currentPage == 2 {
ProtectDashboardPageView()
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)))
}
// Page 4: Accessing Dashboard
if currentPage == 3 {
AccessDashboardPageView()
@ -35,7 +37,7 @@ struct WelcomeView: View {
}
}
.animation(.easeInOut, value: currentPage)
// Custom page indicators and navigation
VStack(spacing: 16) {
// Page indicators
@ -48,11 +50,11 @@ struct WelcomeView: View {
}
}
.padding(.top, 12)
// Navigation button
HStack {
Spacer()
Button(action: handleNextAction) {
Text(buttonTitle)
.frame(minWidth: 80)
@ -71,11 +73,11 @@ struct WelcomeView: View {
currentPage = 0
}
}
private var buttonTitle: String {
currentPage == 3 ? "Finish" : "Next"
}
private func handleNextAction() {
if currentPage < 3 {
withAnimation {
@ -91,36 +93,39 @@ struct WelcomeView: View {
}
// MARK: - Welcome Page
struct WelcomePageView: View {
var body: some View {
VStack(spacing: 40) {
Spacer()
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 20) {
Text("Welcome to VibeTunnel")
.font(.largeTitle)
.fontWeight(.semibold)
Text("Remote control terminals from any device through a secure tunnel.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
Text("You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text(
"You'll be quickly guided through the basics of VibeTunnel.\nThis screen can always be opened from the settings."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
.padding()
@ -128,36 +133,39 @@ struct WelcomePageView: View {
}
// MARK: - VT Command Page
struct VTCommandPageView: View {
var cliInstaller: CLIInstaller
var body: some View {
VStack(spacing: 30) {
Spacer()
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Capturing Terminal Apps")
.font(.largeTitle)
.fontWeight(.semibold)
Text("VibeTunnel can capture any terminal app or terminal.\nJust prefix it with the `vt` command and it will show up on the dashboard.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text(
"VibeTunnel can capture any terminal app or terminal.\nJust prefix it with the `vt` command and it will show up on the dashboard."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text("For example, to remote control Claude Code, type:")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Text("vt claude")
.font(.system(.body, design: .monospaced))
.foregroundColor(.primary)
@ -165,7 +173,7 @@ struct VTCommandPageView: View {
.padding(.vertical, 8)
.background(Color.gray.opacity(0.1))
.cornerRadius(6)
// Install VT Binary button
VStack(spacing: 12) {
if cliInstaller.isInstalled {
@ -183,13 +191,13 @@ struct VTCommandPageView: View {
}
.buttonStyle(.borderedProminent)
.disabled(cliInstaller.isInstalling)
if cliInstaller.isInstalling {
ProgressView()
.scaleEffect(0.8)
}
}
if let error = cliInstaller.lastError {
Text(error)
.font(.caption)
@ -198,7 +206,7 @@ struct VTCommandPageView: View {
}
}
}
Spacer()
}
.padding()
@ -209,53 +217,56 @@ struct VTCommandPageView: View {
}
// MARK: - Protect Dashboard Page
struct ProtectDashboardPageView: View {
@State private var password = ""
@State private var confirmPassword = ""
@State private var showError = false
@State private var errorMessage = ""
@State private var isPasswordSet = false
private let dashboardKeychain = DashboardKeychain.shared
var body: some View {
VStack(spacing: 30) {
Spacer()
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Protect Your Dashboard")
.font(.largeTitle)
.fontWeight(.semibold)
Text("If you want to access your dashboard over the network, set a password now.\nOtherwise, it will only be accessible via localhost.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text(
"If you want to access your dashboard over the network, set a password now.\nOtherwise, it will only be accessible via localhost."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
// Password fields
VStack(spacing: 12) {
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
SecureField("Confirm Password", text: $confirmPassword)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 300)
if showError {
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
}
if isPasswordSet {
HStack {
Image(systemName: "checkmark.circle.fill")
@ -265,49 +276,51 @@ struct ProtectDashboardPageView: View {
}
.font(.caption)
}
Button("Set Password") {
setPassword()
}
.buttonStyle(.bordered)
.disabled(password.isEmpty || isPasswordSet)
Text("Leave empty to skip password protection")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding()
}
private func setPassword() {
showError = false
guard !password.isEmpty else {
return
}
guard password == confirmPassword else {
errorMessage = "Passwords do not match"
showError = true
return
}
guard password.count >= 6 else {
errorMessage = "Password must be at least 6 characters"
showError = true
return
}
if dashboardKeychain.setPassword(password) {
isPasswordSet = true
UserDefaults.standard.set(true, forKey: "dashboardPasswordEnabled")
// When password is set for the first time, automatically switch to network mode
let currentMode = DashboardAccessMode(rawValue: UserDefaults.standard.string(forKey: "dashboardAccessMode") ?? "") ?? .localhost
let currentMode = DashboardAccessMode(rawValue: UserDefaults.standard
.string(forKey: "dashboardAccessMode") ?? ""
) ?? .localhost
if currentMode == .localhost {
UserDefaults.standard.set(DashboardAccessMode.network.rawValue, forKey: "dashboardAccessMode")
}
@ -319,70 +332,76 @@ struct ProtectDashboardPageView: View {
}
// MARK: - Access Dashboard Page
struct AccessDashboardPageView: View {
@AppStorage("ngrokEnabled") private var ngrokEnabled = false
@AppStorage("serverPort") private var serverPort = "4020"
@AppStorage("ngrokEnabled")
private var ngrokEnabled = false
@AppStorage("serverPort")
private var serverPort = "4020"
var body: some View {
VStack(spacing: 30) {
Spacer()
// App icon
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 156, height: 156)
.shadow(radius: 10)
VStack(spacing: 16) {
Text("Accessing Your Dashboard")
.font(.largeTitle)
.fontWeight(.semibold)
Text("To access your terminals from any device, create a tunnel from your device.\n\nThis can be done via **ngrok** in settings or **Tailscale** (recommended).")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
Text(
"To access your terminals from any device, create a tunnel from your device.\n\nThis can be done via **ngrok** in settings or **Tailscale** (recommended)."
)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 480)
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 12) {
// Open Dashboard button
Button(action: {
let dashboardURL = URL(string: "http://127.0.0.1:\(serverPort)")!
NSWorkspace.shared.open(dashboardURL)
}) {
if let dashboardURL = URL(string: "http://127.0.0.1:\(serverPort)") {
NSWorkspace.shared.open(dashboardURL)
}
}, label: {
HStack {
Image(systemName: "safari")
Text("Open Dashboard")
}
}
})
.buttonStyle(.borderedProminent)
.controlSize(.large)
// Tailscale link button
TailscaleLink()
}
}
// Credits
VStack(spacing: 4) {
Text("VibeTunnel is open source and brought to you by")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 4) {
CreditLink(name: "@badlogic", url: "https://mariozechner.at/")
Text("")
.font(.caption)
.foregroundColor(.secondary)
CreditLink(name: "@mitsuhiko", url: "https://lucumr.pocoo.org/")
Text("")
.font(.caption)
.foregroundColor(.secondary)
CreditLink(name: "@steipete", url: "https://steipete.me")
}
}
@ -393,19 +412,22 @@ struct AccessDashboardPageView: View {
}
// MARK: - Tailscale Link Component
struct TailscaleLink: View {
@State private var isHovering = false
var body: some View {
Button(action: {
NSWorkspace.shared.open(URL(string: "https://tailscale.com/")!)
}) {
if let tailscaleURL = URL(string: "https://tailscale.com/") {
NSWorkspace.shared.open(tailscaleURL)
}
}, label: {
HStack {
Image(systemName: "link")
Text("Learn more about Tailscale")
.underline(isHovering, color: .accentColor)
}
}
})
.buttonStyle(.link)
.pointingHandCursor()
.onHover { hovering in
@ -417,19 +439,22 @@ struct TailscaleLink: View {
}
// MARK: - Credit Link Component
struct CreditLink: View {
let name: String
let url: String
@State private var isHovering = false
var body: some View {
Button(action: {
NSWorkspace.shared.open(URL(string: url)!)
}) {
if let linkURL = URL(string: url) {
NSWorkspace.shared.open(linkURL)
}
}, label: {
Text(name)
.font(.caption)
.underline(isHovering, color: .accentColor)
}
})
.buttonStyle(.link)
.pointingHandCursor()
.onHover { hovering in
@ -441,6 +466,7 @@ struct CreditLink: View {
}
// MARK: - Preview
struct WelcomeView_Previews: PreviewProvider {
static var previews: some View {
WelcomeView()

View file

@ -173,7 +173,8 @@ final class ApplicationMover {
guard let plist = try PropertyListSerialization
.propertyList(from: data, options: [], format: nil) as? [String: Any],
let images = plist["images"] as? [[String: Any]] else {
let images = plist["images"] as? [[String: Any]]
else {
logger.debug("ApplicationMover: No disk images found in hdiutil output")
return nil
}
@ -183,7 +184,8 @@ final class ApplicationMover {
if let entities = image["system-entities"] as? [[String: Any]] {
for entity in entities {
if let entityDevName = entity["dev-entry"] as? String,
entityDevName == deviceName {
entityDevName == deviceName
{
logger.debug("Found matching disk image for device: \(deviceName)")
return deviceName
}
@ -193,7 +195,6 @@ final class ApplicationMover {
logger.debug("Device \(deviceName) is not a disk image")
return nil
} catch {
logger.debug("ApplicationMover: Unable to run hdiutil (expected in some environments): \(error)")
return nil
@ -285,7 +286,6 @@ final class ApplicationMover {
// Show success message and offer to relaunch
showMoveSuccessAndRelaunch(newPath: applicationsPath)
} catch {
logger.error("Failed to move app to Applications: \(error)")
showMoveError(error)
@ -355,4 +355,4 @@ final class ApplicationMover {
alert.alertStyle = .warning
alert.runModal()
}
}
}

View file

@ -1,7 +1,7 @@
import AppKit
import Foundation
import os.log
import Observation
import os.log
/// Service responsible for creating symlinks to command line tools with sudo authentication.
///
@ -24,35 +24,35 @@ import Observation
@Observable
final class CLIInstaller {
// MARK: - Properties
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "CLIInstaller")
var isInstalled = false
var isInstalling = false
var lastError: String?
// MARK: - Public Interface
/// Checks if the CLI tool is installed
func checkInstallationStatus() {
let targetPath = "/usr/local/bin/vt"
isInstalled = FileManager.default.fileExists(atPath: targetPath)
logger.info("CLIInstaller: CLI tool installed: \(self.isInstalled)")
}
/// Installs the CLI tool (async version for WelcomeView)
func install() async {
await MainActor.run {
installCLITool()
}
}
/// Installs the vt CLI tool to /usr/local/bin with proper symlink
func installCLITool() {
logger.info("CLIInstaller: Starting CLI tool installation...")
isInstalling = true
lastError = nil
guard let resourcePath = Bundle.main.path(forResource: "vt", ofType: nil) else {
logger.error("CLIInstaller: Could not find vt binary in app bundle")
lastError = "The vt command line tool could not be found in the application bundle."
@ -60,20 +60,22 @@ final class CLIInstaller {
isInstalling = false
return
}
let targetPath = "/usr/local/bin/vt"
logger.info("CLIInstaller: Resource path: \(resourcePath)")
logger.info("CLIInstaller: Target path: \(targetPath)")
// Check if symlink already exists
if FileManager.default.fileExists(atPath: targetPath) {
let alert = NSAlert()
alert.messageText = "CLI Tool Already Installed"
alert.informativeText = "The 'vt' command line tool is already installed at \(targetPath). Would you like to replace it?"
alert
.informativeText =
"The 'vt' command line tool is already installed at \(targetPath). Would you like to replace it?"
alert.addButton(withTitle: "Replace")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .informational
let response = alert.runModal()
if response != .alertFirstButtonReturn {
logger.info("CLIInstaller: User cancelled replacement")
@ -81,93 +83,95 @@ final class CLIInstaller {
return
}
}
// Show confirmation dialog
let confirmAlert = NSAlert()
confirmAlert.messageText = "Install CLI Tool"
confirmAlert.informativeText = "This will create a symlink to the 'vt' command line tool in /usr/local/bin, allowing you to use it from the terminal. Administrator privileges are required."
confirmAlert
.informativeText =
"This will create a symlink to the 'vt' command line tool in /usr/local/bin, allowing you to use it from the terminal. Administrator privileges are required."
confirmAlert.addButton(withTitle: "Install")
confirmAlert.addButton(withTitle: "Cancel")
confirmAlert.alertStyle = .informational
confirmAlert.icon = NSApp.applicationIconImage
let response = confirmAlert.runModal()
if response != .alertFirstButtonReturn {
logger.info("CLIInstaller: User cancelled installation")
isInstalling = false
return
}
// Perform the installation
performInstallation(from: resourcePath, to: targetPath)
}
// MARK: - Private Implementation
/// Performs the actual symlink creation with sudo privileges
private func performInstallation(from sourcePath: String, to targetPath: String) {
logger.info("CLIInstaller: Performing installation from \(sourcePath) to \(targetPath)")
// Create the /usr/local/bin directory if it doesn't exist
let binDirectory = "/usr/local/bin"
let script = """
#!/bin/bash
set -e
# Create /usr/local/bin if it doesn't exist
if [ ! -d "\(binDirectory)" ]; then
mkdir -p "\(binDirectory)"
echo "Created directory \(binDirectory)"
fi
# Remove existing symlink if it exists
if [ -L "\(targetPath)" ] || [ -f "\(targetPath)" ]; then
rm -f "\(targetPath)"
echo "Removed existing file at \(targetPath)"
fi
# Create the symlink
ln -s "\(sourcePath)" "\(targetPath)"
echo "Created symlink from \(sourcePath) to \(targetPath)"
# Make sure the symlink is executable
chmod +x "\(targetPath)"
echo "Set executable permissions on \(targetPath)"
"""
// Write the script to a temporary file
let tempDir = FileManager.default.temporaryDirectory
let scriptURL = tempDir.appendingPathComponent("install_vt_cli.sh")
do {
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
// Make the script executable
let attributes: [FileAttributeKey: Any] = [.posixPermissions: 0o755]
try FileManager.default.setAttributes(attributes, ofItemAtPath: scriptURL.path)
logger.info("CLIInstaller: Created installation script at \(scriptURL.path)")
// Execute with osascript to get sudo dialog
let appleScript = """
do shell script "bash '\(scriptURL.path)'" with administrator privileges
"""
let task = Process()
task.launchPath = "/usr/bin/osascript"
task.arguments = ["-e", appleScript]
let pipe = Pipe()
let errorPipe = Pipe()
task.standardOutput = pipe
task.standardError = errorPipe
try task.run()
task.waitUntilExit()
// Clean up the temporary script
try? FileManager.default.removeItem(at: scriptURL)
if task.terminationStatus == 0 {
logger.info("CLIInstaller: Installation completed successfully")
isInstalled = true
@ -181,7 +185,6 @@ final class CLIInstaller {
isInstalling = false
showError("Installation failed: \(errorString)")
}
} catch {
logger.error("CLIInstaller: Installation failed with error: \(error)")
lastError = "Installation failed: \(error.localizedDescription)"
@ -189,7 +192,7 @@ final class CLIInstaller {
showError("Installation failed: \(error.localizedDescription)")
}
}
/// Shows success message after installation
private func showSuccess() {
let alert = NSAlert()
@ -200,7 +203,7 @@ final class CLIInstaller {
alert.icon = NSApp.applicationIconImage
alert.runModal()
}
/// Shows error message for installation failures
private func showError(_ message: String) {
let alert = NSAlert()
@ -210,4 +213,4 @@ final class CLIInstaller {
alert.alertStyle = .critical
alert.runModal()
}
}
}

View file

@ -7,7 +7,7 @@ import SwiftUI
enum SettingsOpener {
/// SwiftUI's hardcoded settings window identifier
private static let settingsWindowIdentifier = "com_apple_SwiftUI_Settings_window"
/// Opens the Settings window using the environment action via notification
/// This is needed for cases where we can't use SettingsLink (e.g., from notifications)
static func openSettings() {
@ -15,14 +15,14 @@ enum SettingsOpener {
let currentPolicy = NSApp.activationPolicy()
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
// Try the direct menu item approach first (from VibeMeter)
if openSettingsViaMenuItem() {
// Successfully opened via menu item
Task {
try? await Task.sleep(for: .milliseconds(100))
focusSettingsWindow()
// Restore activation policy after a delay
try? await Task.sleep(for: .milliseconds(200))
NSApp.setActivationPolicy(currentPolicy)
@ -30,77 +30,81 @@ enum SettingsOpener {
} else {
// Fallback to notification approach
NotificationCenter.default.post(name: .openSettingsRequest, object: nil)
Task {
try? await Task.sleep(for: .milliseconds(150))
focusSettingsWindow()
// Restore activation policy after a delay
try? await Task.sleep(for: .milliseconds(200))
NSApp.setActivationPolicy(currentPolicy)
}
}
}
/// Opens settings via the native menu item (more reliable)
private static func openSettingsViaMenuItem() -> Bool {
let kAppMenuInternalIdentifier = "app"
let kSettingsLocalizedStringKey = "Settings\\U2026"
if let internalItemAction = NSApp.mainMenu?.item(
withInternalIdentifier: kAppMenuInternalIdentifier)?.submenu?.item(
withLocalizedTitle: kSettingsLocalizedStringKey)?.internalItemAction {
if let internalItemAction = NSApp.mainMenu?
.item(withInternalIdentifier: kAppMenuInternalIdentifier)?
.submenu?
.item(withLocalizedTitle: kSettingsLocalizedStringKey)?
.internalItemAction
{
internalItemAction()
return true
}
return false
}
/// Focuses the settings window without level manipulation
static func focusSettingsWindow() {
// First try the SwiftUI settings window identifier
if let settingsWindow = NSApp.windows.first(where: {
$0.identifier?.rawValue == settingsWindowIdentifier
if let settingsWindow = NSApp.windows.first(where: {
$0.identifier?.rawValue == settingsWindowIdentifier
}) {
bringWindowToFront(settingsWindow)
} else if let settingsWindow = NSApp.windows.first(where: { window in
// Fallback to title-based search
window.isVisible &&
window.styleMask.contains(.titled) &&
(window.title.localizedCaseInsensitiveContains("settings") ||
window.title.localizedCaseInsensitiveContains("preferences"))
window.isVisible &&
window.styleMask.contains(.titled) &&
(window.title.localizedCaseInsensitiveContains("settings") ||
window.title.localizedCaseInsensitiveContains("preferences")
)
}) {
bringWindowToFront(settingsWindow)
}
}
/// Brings a window to front using the most reliable method
private static func bringWindowToFront(_ window: NSWindow) {
// Ensure window is on screen
if window.isMiniaturized {
window.deminiaturize(nil)
}
// Center window on the active screen
WindowCenteringHelper.centerOnActiveScreen(window)
// Multiple methods to ensure window comes to front
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
window.level = .floating // Temporarily set to floating level
window.level = .floating // Temporarily set to floating level
// Reset level after a short delay
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
window.level = .normal
}
NSApp.activate(ignoringOtherApps: true)
// Setup window close observer to restore activation policy
setupWindowCloseObserver(for: window)
}
/// Observes when settings window closes to restore activation policy
private static func setupWindowCloseObserver(for window: NSWindow) {
NotificationCenter.default.addObserver(
@ -118,11 +122,11 @@ enum SettingsOpener {
}
}
}
/// Opens the Settings window and navigates to a specific tab
static func openSettingsTab(_ tab: SettingsTab) {
openSettings()
Task {
// Small delay to ensure the settings window is fully initialized
try? await Task.sleep(for: .milliseconds(150))
@ -139,8 +143,9 @@ enum SettingsOpener {
/// A hidden window view that enables Settings to work in MenuBarExtra-only apps
/// This is a workaround for FB10184971
struct HiddenWindowView: View {
@Environment(\.openSettings) private var openSettings
@Environment(\.openSettings)
private var openSettings
var body: some View {
Color.clear
.frame(width: 1, height: 1)
@ -148,11 +153,11 @@ struct HiddenWindowView: View {
// Configure the window to be invisible
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "HiddenWindow" }) {
// Position window offscreen
WindowCenteringHelper.positionOffScreen(window)
window.backgroundColor = .clear
window.isOpaque = false
window.hasShadow = false
@ -164,7 +169,7 @@ struct HiddenWindowView: View {
window.orderOut(nil)
}
}
// Listen for settings open requests
NotificationCenter.default.addObserver(
forName: .openSettingsRequest,
@ -173,7 +178,7 @@ struct HiddenWindowView: View {
) { _ in
Task { @MainActor in
openSettings()
// Additional check to bring settings to front after environment action
try? await Task.sleep(for: .milliseconds(150))
SettingsOpener.focusSettingsWindow()
@ -195,27 +200,30 @@ extension NSMenuItem {
/// An internal SwiftUI menu item identifier that should be a public property on `NSMenuItem`.
fileprivate var internalIdentifier: String? {
guard let id = Mirror.firstChild(
withLabel: "id", in: self)?.value
withLabel: "id", in: self
)?.value
else {
return nil
}
return "\(id)"
}
/// A callback which is associated directly with this `NSMenuItem`.
fileprivate var internalItemAction: (() -> Void)? {
guard
let platformItemAction = Mirror.firstChild(
withLabel: "platformItemAction", in: self)?.value,
guard let platformItemAction = Mirror.firstChild(
withLabel: "platformItemAction", in: self
)?.value,
let typeErasedCallback = Mirror.firstChild(
in: platformItemAction)?.value
in: platformItemAction
)?.value
else {
return nil
}
return Mirror.firstChild(
in: typeErasedCallback)?.value as? () -> Void
in: typeErasedCallback
)?.value as? () -> Void
}
}
@ -224,51 +232,54 @@ extension NSMenuItem {
extension NSMenu {
/// Get the first `NSMenuItem` whose internal identifier string matches the given value.
fileprivate func item(withInternalIdentifier identifier: String) -> NSMenuItem? {
items.first(where: {
$0.internalIdentifier?.elementsEqual(identifier) ?? false
})
items.first { $0.internalIdentifier?.elementsEqual(identifier) ?? false }
}
/// Get the first `NSMenuItem` whose title is equivalent to the localized string referenced
/// by the given localized string key in the localization table identified by the given table name
/// from the bundle located at the given bundle path.
fileprivate func item(
withLocalizedTitle localizedTitleKey: String,
inTable tableName: String = "MenuCommands",
fromBundle bundlePath: String = "/System/Library/Frameworks/AppKit.framework") -> NSMenuItem? {
fromBundle bundlePath: String = "/System/Library/Frameworks/AppKit.framework"
)
-> NSMenuItem?
{
guard let localizationResource = Bundle(path: bundlePath) else {
return nil
}
return item(withTitle: NSLocalizedString(
localizedTitleKey,
tableName: tableName,
bundle: localizationResource,
comment: ""))
comment: ""
))
}
}
// MARK: - Mirror Extensions (Helper)
private extension Mirror {
extension Mirror {
/// The unconditional first child of the reflection subject.
var firstChild: Child? { children.first }
fileprivate var firstChild: Child? { children.first }
/// The first child of the reflection subject whose label matches the given string.
func firstChild(withLabel label: String) -> Child? {
children.first(where: {
$0.label?.elementsEqual(label) ?? false
})
fileprivate func firstChild(withLabel label: String) -> Child? {
children.first { $0.label?.elementsEqual(label) ?? false }
}
/// The unconditional first child of the given subject.
static func firstChild(in subject: Any) -> Child? {
fileprivate static func firstChild(in subject: Any) -> Child? {
Mirror(reflecting: subject).firstChild
}
/// The first child of the given subject whose label matches the given string.
static func firstChild(
withLabel label: String, in subject: Any) -> Child? {
fileprivate static func firstChild(
withLabel label: String, in subject: Any
)
-> Child?
{
Mirror(reflecting: subject).firstChild(withLabel: label)
}
}
}

View file

@ -1,15 +1,15 @@
import SwiftUI
import AppKit
import SwiftUI
/// Handles the presentation of the welcome screen window
@MainActor
final class WelcomeWindowController: NSWindowController {
static let shared = WelcomeWindowController()
private init() {
let welcomeView = WelcomeView()
let hostingController = NSHostingController(rootView: welcomeView)
let window = NSWindow(contentViewController: hostingController)
window.title = ""
window.styleMask = [.titled, .closable, .fullSizeContentView]
@ -19,9 +19,9 @@ final class WelcomeWindowController: NSWindowController {
window.setFrameAutosaveName("WelcomeWindow")
window.isReleasedWhenClosed = false
window.level = .floating
super.init(window: window)
// Listen for notification to show welcome screen
NotificationCenter.default.addObserver(
self,
@ -30,27 +30,30 @@ final class WelcomeWindowController: NSWindowController {
object: nil
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
guard let window = window else { return }
guard let window else { return }
// Center window on the active screen (screen with mouse cursor)
WindowCenteringHelper.centerOnActiveScreen(window)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
@objc private func handleShowWelcomeNotification() {
@objc
private func handleShowWelcomeNotification() {
show()
}
}
// MARK: - Notification Extension
extension Notification.Name {
static let showWelcomeScreen = Notification.Name("showWelcomeScreen")
}
}

View file

@ -1,6 +1,6 @@
import AppKit
import SwiftUI
import Observation
import SwiftUI
/// A custom window size animator that works with SwiftUI Settings windows
@MainActor

View file

@ -1,4 +1,5 @@
import AppKit
import os.log
import SwiftUI
/// Main entry point for the VibeTunnel macOS application
@ -19,7 +20,7 @@ struct VibeTunnelApp: App {
.windowResizability(.contentSize)
.defaultSize(width: 1, height: 1)
.windowStyle(.hiddenTitleBar)
// Welcome Window
WindowGroup("Welcome", id: "welcome") {
WelcomeView()
@ -27,15 +28,15 @@ struct VibeTunnelApp: App {
.windowResizability(.contentSize)
.defaultSize(width: 580, height: 480)
.windowStyle(.hiddenTitleBar)
Settings {
SettingsView()
}
.commands {
CommandGroup(after: .appInfo) {
SettingsLink(label: {
SettingsLink {
Text("About VibeTunnel")
})
}
.simultaneousGesture(TapGesture().onEnded {
// Navigate to About tab after settings opens
Task {
@ -71,6 +72,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private let sessionMonitor = SessionMonitor.shared
private let serverMonitor = ServerMonitor.shared
private let ngrokService = NgrokService.shared
private let logger = Logger(subsystem: "com.steipete.VibeTunnel", category: "AppDelegate")
/// Distributed notification name used to ask an existing instance to show the Settings window.
private static let showSettingsNotification = Notification.Name("sh.vibetunnel.vibetunnel.showSettings")
@ -86,7 +88,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if !isRunningInPreview, !isRunningInTests, !isRunningInDebug {
handleSingleInstanceCheck()
registerForDistributedNotifications()
// Check if app needs to be moved to Applications folder
let applicationMover = ApplicationMover()
applicationMover.checkAndOfferToMoveToApplications()
@ -105,7 +107,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
showWelcomeScreen()
}
// Listen for update check requests
NotificationCenter.default.addObserver(
self,
@ -113,17 +114,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
name: Notification.Name("checkForUpdates"),
object: nil
)
// Initialize and start HTTP server using ServerManager
Task {
do {
print("Attempting to start HTTP server using ServerManager...")
logger.info("Attempting to start HTTP server using ServerManager...")
await serverManager.start()
print("HTTP server started successfully on port \(serverManager.port)")
print("Server is running: \(serverManager.isRunning)")
print("Server mode: \(serverManager.serverMode.displayName)")
logger.info("HTTP server started successfully on port \(self.serverManager.port)")
logger.info("Server is running: \(self.serverManager.isRunning)")
logger.info("Server mode: \(self.serverManager.serverMode.displayName)")
// Start monitoring sessions after server starts
sessionMonitor.startMonitoring()
@ -133,23 +133,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if let url = URL(string: "http://127.0.0.1:\(serverManager.port)/api/health") {
let (_, response) = try await URLSession.shared.data(from: url)
if let httpResponse = response as? HTTPURLResponse {
print("Server health check response: \(httpResponse.statusCode)")
logger.info("Server health check response: \(httpResponse.statusCode)")
}
}
} catch {
print("Failed to start HTTP server: \(error)")
print("Error type: \(type(of: error))")
print("Error description: \(error.localizedDescription)")
logger.error("Failed to start HTTP server: \(error)")
logger.error("Error type: \(type(of: error))")
logger.error("Error description: \(error.localizedDescription)")
if let nsError = error as NSError? {
print("NSError domain: \(nsError.domain)")
print("NSError code: \(nsError.code)")
print("NSError userInfo: \(nsError.userInfo)")
logger.error("NSError domain: \(nsError.domain)")
logger.error("NSError code: \(nsError.code)")
logger.error("NSError userInfo: \(nsError.userInfo)")
}
}
}
}
private func handleSingleInstanceCheck() {
let runningApps = NSRunningApplication
.runningApplications(withBundleIdentifier: Bundle.main.bundleIdentifier ?? "")
@ -194,14 +193,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func handleCheckForUpdatesNotification() {
sparkleUpdaterManager?.checkForUpdates()
}
/// Shows the welcome screen
private func showWelcomeScreen() {
// Initialize the welcome window controller (singleton will handle the rest)
_ = WelcomeWindowController.shared
WelcomeWindowController.shared.show()
}
/// Public method to show welcome screen (can be called from settings)
static func showWelcomeScreen() {
WelcomeWindowController.shared.show()
@ -239,5 +238,3 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
)
}
}