Fix server port binding issue and refactor tunnel services

- Change default server port from 800 to 4020 to avoid privileged port restrictions
- Rename TunnelServerDemo.swift to TunnelServer.swift for clarity
- Remove redundant TunnelServerExample.swift
- Update tty-fwd binary
- Add SettingsWindowDelegate for improved window management
- Update UI components and session monitoring
- Add modern Swift refactoring documentation

The server was failing to start because it was trying to bind to port 800,
which requires root privileges on macOS. Port 4020 is now used as the default.
This commit is contained in:
Peter Steinberger 2025-06-16 02:39:19 +02:00
parent 223fa898b1
commit ed19be138b
11 changed files with 210 additions and 165 deletions

View file

@ -1,4 +1,5 @@
import Foundation import Foundation
import Observation
import os.log import os.log
import UserNotifications import UserNotifications
@ -39,14 +40,15 @@ public final class SparkleUpdaterManager: NSObject {
/// Stub implementation of SparkleViewModel /// Stub implementation of SparkleViewModel
@MainActor @MainActor
@available(macOS 10.15, *) @available(macOS 10.15, *)
public final class SparkleViewModel: ObservableObject { @Observable
@Published public var canCheckForUpdates = false public final class SparkleViewModel {
@Published public var isCheckingForUpdates = false public var canCheckForUpdates = false
@Published public var automaticallyChecksForUpdates = true public var isCheckingForUpdates = false
@Published public var automaticallyDownloadsUpdates = false public var automaticallyChecksForUpdates = true
@Published public var updateCheckInterval: TimeInterval = 86400 public var automaticallyDownloadsUpdates = false
@Published public var lastUpdateCheckDate: Date? public var updateCheckInterval: TimeInterval = 86400
@Published public var updateChannel: UpdateChannel = .stable public var lastUpdateCheckDate: Date?
public var updateChannel: UpdateChannel = .stable
private let updaterManager = SparkleUpdaterManager.shared private let updaterManager = SparkleUpdaterManager.shared

View file

@ -1,4 +1,3 @@
import Combine
import Foundation import Foundation
import Logging import Logging
@ -202,11 +201,13 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
private let apiKey: String private let apiKey: String
private var sessionId: String? private var sessionId: String?
private var webSocketTask: URLSessionWebSocketTask? private var webSocketTask: URLSessionWebSocketTask?
private let messageSubject = PassthroughSubject<WSMessage, Never>() private var messageContinuation: AsyncStream<WSMessage>.Continuation?
private let logger = Logger(label: "VibeTunnel.TunnelWebSocketClient") private let logger = Logger(label: "VibeTunnel.TunnelWebSocketClient")
public var messages: AnyPublisher<WSMessage, Never> { public var messages: AsyncStream<WSMessage> {
messageSubject.eraseToAnyPublisher() AsyncStream { continuation in
self.messageContinuation = continuation
}
} }
public init(url: URL, apiKey: String, sessionId: String? = nil) { public init(url: URL, apiKey: String, sessionId: String? = nil) {
@ -258,6 +259,7 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
public func disconnect() { public func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask?.cancel(with: .goingAway, reason: nil)
messageContinuation?.finish()
} }
private func receiveMessage() { private func receiveMessage() {
@ -269,11 +271,11 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
if let data = text.data(using: .utf8), if let data = text.data(using: .utf8),
let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data)
{ {
self?.messageSubject.send(wsMessage) self?.messageContinuation?.yield(wsMessage)
} }
case .data(let data): case .data(let data):
if let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) { if let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) {
self?.messageSubject.send(wsMessage) self?.messageContinuation?.yield(wsMessage)
} }
@unknown default: @unknown default:
break break
@ -307,7 +309,7 @@ extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
reason: Data? reason: Data?
) { ) {
logger.info("WebSocket disconnected with code: \(closeCode)") logger.info("WebSocket disconnected with code: \(closeCode)")
messageSubject.send(completion: .finished) messageContinuation?.finish()
} }
} }

View file

@ -22,7 +22,7 @@ enum ServerError: LocalizedError {
/// HTTP server implementation for the macOS app /// HTTP server implementation for the macOS app
@MainActor @MainActor
@Observable @Observable
public final class TunnelServerDemo { public final class TunnelServer {
public private(set) var isRunning = false public private(set) var isRunning = false
public private(set) var port: Int public private(set) var port: Int
public var lastError: Error? public var lastError: Error?
@ -39,7 +39,7 @@ public final class TunnelServerDemo {
public func start() async throws { public func start() async throws {
guard !isRunning else { return } guard !isRunning else { return }
logger.info("Starting TunnelServerDemo on port \(port)") logger.info("Starting TunnelServer on port \(port)")
do { do {
let router = Router(context: BasicRequestContext.self) let router = Router(context: BasicRequestContext.self)

View file

@ -1,139 +0,0 @@
import Combine
import Foundation
import Logging
/// Demo code showing how to use the VibeTunnel server
enum TunnelServerExample {
private static let logger = Logger(label: "VibeTunnel.TunnelServerDemo")
static func runDemo() async {
// Get the API key (in production, this should be managed securely)
// For demo purposes, using a hardcoded key
let apiKey = "demo-api-key-12345"
logger.info("Using API key: [REDACTED]")
// Create client
let client = TunnelClient(apiKey: apiKey)
do {
// Check server health
let isHealthy = try await client.checkHealth()
logger.info("Server healthy: \(isHealthy)")
// Create a new session
let session = try await client.createSession(
workingDirectory: "/tmp",
shell: "/bin/zsh"
)
logger.info("Created session: \(session.sessionId)")
// Execute a command
let response = try await client.executeCommand(
sessionId: session.sessionId,
command: "echo 'Hello from VibeTunnel!'"
)
logger.info("Command output: \(response.output ?? "none")")
// List all sessions
let sessions = try await client.listSessions()
logger.info("Active sessions: \(sessions.count)")
// Close the session
try await client.closeSession(id: session.sessionId)
logger.info("Session closed")
} catch {
logger.error("Demo error: \(error)")
}
}
static func runWebSocketDemo() async {
// For demo purposes, using a hardcoded key
let apiKey = "demo-api-key-12345"
let client = TunnelClient(apiKey: apiKey)
do {
// Create a session first
let session = try await client.createSession()
logger.info("Created session for WebSocket: \(session.sessionId)")
// Connect WebSocket
guard let wsClient = client.connectWebSocket(sessionId: session.sessionId) else {
logger.error("Failed to create WebSocket client")
return
}
wsClient.connect()
// Subscribe to messages
let cancellable = wsClient.messages.sink { message in
switch message.type {
case .output:
logger.info("Output: \(message.data ?? "")")
case .error:
logger.error("Error: \(message.data ?? "")")
default:
logger.info("Message: \(message.type) - \(message.data ?? "")")
}
}
// Send some commands
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("pwd")
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("ls -la")
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
// Disconnect
wsClient.disconnect()
cancellable.cancel()
} catch {
logger.error("WebSocket demo error: \(error)")
}
}
}
// MARK: - cURL Examples
// Here are some example cURL commands to test the server:
//
// # Set your API key
// export API_KEY="your-api-key-here"
//
// # Health check (no auth required)
// curl http://localhost:8080/health
//
// # Get server info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
//
// # Create a new session
// curl -X POST http://localhost:8080/sessions \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "workingDirectory": "/tmp",
// "shell": "/bin/zsh"
// }'
//
// # List all sessions
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
//
// # Execute a command
// curl -X POST http://localhost:8080/execute \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "sessionId": "your-session-id",
// "command": "ls -la"
// }'
//
// # Get session info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # Close a session
// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # WebSocket connection (using websocat tool)
// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal

Binary file not shown.

View file

@ -31,6 +31,15 @@ extension Notification.Name {
struct SettingsView: View { struct SettingsView: View {
@State private var selectedTab: SettingsTab = .general @State private var selectedTab: SettingsTab = .general
@State private var contentSize: CGSize = .zero
// Define ideal sizes for each tab
private let tabSizes: [SettingsTab: CGSize] = [
.general: CGSize(width: 500, height: 300),
.advanced: CGSize(width: 500, height: 400),
.debug: CGSize(width: 600, height: 650),
.about: CGSize(width: 500, height: 550)
]
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
@ -58,11 +67,19 @@ struct SettingsView: View {
} }
.tag(SettingsTab.about) .tag(SettingsTab.about)
} }
.frame(width: contentSize.width, height: contentSize.height)
.animatedWindowResizing(size: contentSize)
.onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in .onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in
if let tab = notification.object as? SettingsTab { if let tab = notification.object as? SettingsTab {
selectedTab = tab selectedTab = tab
} }
} }
.onChange(of: selectedTab) { _, newTab in
contentSize = tabSizes[newTab] ?? CGSize(width: 500, height: 400)
}
.onAppear {
contentSize = tabSizes[selectedTab] ?? CGSize(width: 500, height: 400)
}
} }
} }
@ -280,7 +297,7 @@ struct AdvancedSettingsView: View {
} }
// Create and start new server with the new port // Create and start new server with the new port
let newServer = TunnelServerDemo(port: port) let newServer = TunnelServer(port: port)
appDelegate.setHTTPServer(newServer) appDelegate.setHTTPServer(newServer)
do { do {
@ -307,7 +324,7 @@ struct AdvancedSettingsView: View {
struct DebugSettingsView: View { struct DebugSettingsView: View {
@State private var httpServer: TunnelServerDemo? @State private var httpServer: TunnelServer?
@State private var lastError: String? @State private var lastError: String?
@State private var testResult: String? @State private var testResult: String?
@State private var isTesting = false @State private var isTesting = false
@ -531,7 +548,7 @@ struct DebugSettingsView: View {
if shouldStart { if shouldStart {
// Create a new server if needed // Create a new server if needed
if httpServer == nil { if httpServer == nil {
let newServer = TunnelServerDemo(port: serverPort) let newServer = TunnelServer(port: serverPort)
httpServer = newServer httpServer = newServer
// Store reference in AppDelegate // Store reference in AppDelegate
if let appDelegate = NSApp.delegate as? AppDelegate { if let appDelegate = NSApp.delegate as? AppDelegate {

View file

@ -0,0 +1,71 @@
import AppKit
import SwiftUI
/// A window delegate that handles animated resizing of the settings window
class SettingsWindowDelegate: NSObject, NSWindowDelegate {
static let shared = SettingsWindowDelegate()
private override init() {
super.init()
}
/// Animates the window to a new size
func animateWindowResize(to newSize: CGSize, duration: TimeInterval = 0.3) {
guard let window = NSApp.windows.first(where: { $0.title.contains("Settings") || $0.title.contains("Preferences") }) else {
return
}
// Calculate the new frame maintaining the window's top-left position
var newFrame = window.frame
let heightDifference = newSize.height - newFrame.height
newFrame.size = newSize
newFrame.origin.y -= heightDifference // Keep top edge in place
// Animate the frame change
NSAnimationContext.runAnimationGroup { context in
context.duration = duration
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(newFrame, display: true)
}
}
func windowWillClose(_ notification: Notification) {
// Clean up if needed
}
}
/// A view modifier that sets up the window delegate for animated resizing
struct AnimatedWindowResizing: ViewModifier {
let size: CGSize
func body(content: Content) -> some View {
content
.onAppear {
setupWindowDelegate()
// Initial resize without animation
SettingsWindowDelegate.shared.animateWindowResize(to: size, duration: 0)
}
.onChange(of: size) { _, newSize in
SettingsWindowDelegate.shared.animateWindowResize(to: newSize)
}
}
private func setupWindowDelegate() {
Task { @MainActor in
// Small delay to ensure window is created
try? await Task.sleep(for: .milliseconds(100))
if let window = NSApp.windows.first(where: { $0.title.contains("Settings") || $0.title.contains("Preferences") }) {
window.delegate = SettingsWindowDelegate.shared
// Disable window resizing by user
window.styleMask.remove(.resizable)
}
}
}
}
extension View {
func animatedWindowResizing(size: CGSize) -> some View {
modifier(AnimatedWindowResizing(size: size))
}
}

View file

@ -12,7 +12,6 @@ struct VibeTunnelApp: App {
Settings { Settings {
SettingsView() SettingsView()
} }
.defaultSize(width: 500, height: 450)
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
Button("About VibeTunnel") { Button("About VibeTunnel") {
@ -37,7 +36,7 @@ struct VibeTunnelApp: App {
@MainActor @MainActor
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
private(set) var sparkleUpdaterManager: SparkleUpdaterManager? private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
private(set) var httpServer: TunnelServerDemo? private(set) var httpServer: TunnelServer?
private let sessionMonitor = SessionMonitor.shared private let sessionMonitor = SessionMonitor.shared
/// Distributed notification name used to ask an existing instance to show the Settings window. /// Distributed notification name used to ask an existing instance to show the Settings window.
@ -84,7 +83,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Initialize and start HTTP server // Initialize and start HTTP server
let serverPort = UserDefaults.standard.integer(forKey: "serverPort") let serverPort = UserDefaults.standard.integer(forKey: "serverPort")
httpServer = TunnelServerDemo(port: serverPort > 0 ? serverPort : 4020) httpServer = TunnelServer(port: serverPort > 0 ? serverPort : 4020)
Task { Task {
do { do {
@ -117,7 +116,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
func setHTTPServer(_ server: TunnelServerDemo?) { func setHTTPServer(_ server: TunnelServer?) {
httpServer = server httpServer = server
} }

View file

@ -8,7 +8,7 @@
import SwiftUI import SwiftUI
struct MenuBarView: View { struct MenuBarView: View {
@EnvironmentObject var sessionMonitor: SessionMonitor @Environment(SessionMonitor.self) var sessionMonitor
@AppStorage("showInDock") private var showInDock = false @AppStorage("showInDock") private var showInDock = false
var body: some View { var body: some View {
@ -139,7 +139,7 @@ struct SessionRowView: View {
struct MenuButtonStyle: ButtonStyle { struct MenuButtonStyle: ButtonStyle {
@State private var isHovered = false @State private var isHovered = false
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: ButtonStyle.Configuration) -> some View {
configuration.label configuration.label
.font(.system(size: 13)) .font(.system(size: 13))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)

View file

@ -0,0 +1,93 @@
# Modern Swift Refactoring Summary
This document summarizes the modernization changes made to the VibeTunnel codebase to align with modern Swift best practices as outlined in `modern-swift.md`.
## Key Changes
### 1. Converted @ObservableObject to @Observable
- **SessionMonitor.swift**: Converted from `@ObservableObject` with `@Published` properties to `@Observable` class
- Removed `import Combine`
- Replaced `@Published` with regular properties
- Changed from `ObservableObject` to `@Observable`
- **TunnelServerDemo.swift**: Converted from `@ObservableObject` to `@Observable`
- Removed `import Combine`
- Simplified property declarations
- **SparkleViewModel**: Converted stub implementation to use `@Observable`
### 2. Replaced Combine with Async/Await
- **SessionMonitor.swift**:
- Replaced `Timer` with `Task` for periodic monitoring
- Used `Task.sleep(for:)` instead of Timer callbacks
- Eliminated nested `Task { }` blocks
- **TunnelClient.swift**:
- Removed `PassthroughSubject` from WebSocket implementation
- Replaced with `AsyncStream<WSMessage>` for message handling
- Updated delegate methods to use `continuation.yield()` instead of `subject.send()`
### 3. Simplified State Management
- **VibeTunnelApp.swift**:
- Changed `@StateObject` to `@State` for SessionMonitor
- Updated `.environmentObject()` to `.environment()` for modern environment injection
- **MenuBarView.swift**:
- Changed `@EnvironmentObject` to `@Environment(SessionMonitor.self)`
- **SettingsView.swift**:
- Removed `ServerObserver` ViewModel completely
- Moved server state directly into view as `@State private var httpServer`
- Simplified server state access with computed properties
### 4. Modernized Async Operations
- **VibeTunnelApp.swift**:
- Replaced `DispatchQueue.main.asyncAfter` with `Task.sleep(for:)`
- Updated all `Task.sleep(nanoseconds:)` to `Task.sleep(for:)` with Duration
- **SettingsView.swift**:
- Replaced `.onAppear` with `.task` for async initialization
- Modernized Task.sleep usage throughout
### 5. Removed Unnecessary Abstractions
- Eliminated `ServerObserver` class - moved logic directly into views
- Removed Combine imports where no longer needed
- Simplified state ownership by keeping it close to where it's used
### 6. Updated Error Handling
- Maintained proper async/await error handling with try/catch
- Removed completion handler patterns where applicable
- Simplified error state management
## Benefits Achieved
1. **Reduced Dependencies**: Removed Combine dependency from most files
2. **Simpler Code**: Eliminated unnecessary ViewModels and abstractions
3. **Better Performance**: Native SwiftUI state management with @Observable
4. **Modern Patterns**: Consistent use of async/await throughout
5. **Cleaner Architecture**: State lives closer to where it's used
## Migration Notes
- All @Observable classes require iOS 17+ / macOS 14+
- AsyncStream provides a more natural API than Combine subjects
- Task-based monitoring is more efficient than Timer-based
- SwiftUI's built-in state management eliminates need for custom ViewModels
## Files Modified
1. `/VibeTunnel/Core/Services/SessionMonitor.swift`
2. `/VibeTunnel/Core/Services/TunnelClient.swift`
3. `/VibeTunnel/Core/Services/TunnelServerDemo.swift`
4. `/VibeTunnel/Core/Services/SparkleUpdaterManager.swift`
5. `/VibeTunnel/VibeTunnelApp.swift`
6. `/VibeTunnel/Views/MenuBarView.swift`
7. `/VibeTunnel/SettingsView.swift`
All changes follow the principles outlined in the Modern Swift guidelines, embracing SwiftUI's native patterns and avoiding unnecessary complexity.