mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-06-28 05:29:29 +00:00
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:
parent
b79d5a1fb4
commit
c26be3eefd
30 changed files with 1133 additions and 975 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// A custom window size animator that works with SwiftUI Settings windows
|
||||
@MainActor
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue