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

View file

@ -1,4 +1,3 @@
import Combine
import Foundation
import Logging
@ -202,11 +201,13 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
private let apiKey: String
private var sessionId: String?
private var webSocketTask: URLSessionWebSocketTask?
private let messageSubject = PassthroughSubject<WSMessage, Never>()
private var messageContinuation: AsyncStream<WSMessage>.Continuation?
private let logger = Logger(label: "VibeTunnel.TunnelWebSocketClient")
public var messages: AnyPublisher<WSMessage, Never> {
messageSubject.eraseToAnyPublisher()
public var messages: AsyncStream<WSMessage> {
AsyncStream { continuation in
self.messageContinuation = continuation
}
}
public init(url: URL, apiKey: String, sessionId: String? = nil) {
@ -258,6 +259,7 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
public func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil)
messageContinuation?.finish()
}
private func receiveMessage() {
@ -269,11 +271,11 @@ public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
if let data = text.data(using: .utf8),
let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data)
{
self?.messageSubject.send(wsMessage)
self?.messageContinuation?.yield(wsMessage)
}
case .data(let data):
if let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) {
self?.messageSubject.send(wsMessage)
self?.messageContinuation?.yield(wsMessage)
}
@unknown default:
break
@ -307,7 +309,7 @@ extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
reason: Data?
) {
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
@MainActor
@Observable
public final class TunnelServerDemo {
public final class TunnelServer {
public private(set) var isRunning = false
public private(set) var port: Int
public var lastError: Error?
@ -39,7 +39,7 @@ public final class TunnelServerDemo {
public func start() async throws {
guard !isRunning else { return }
logger.info("Starting TunnelServerDemo on port \(port)")
logger.info("Starting TunnelServer on port \(port)")
do {
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 {
@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 {
TabView(selection: $selectedTab) {
@ -58,11 +67,19 @@ struct SettingsView: View {
}
.tag(SettingsTab.about)
}
.frame(width: contentSize.width, height: contentSize.height)
.animatedWindowResizing(size: contentSize)
.onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in
if let tab = notification.object as? SettingsTab {
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
let newServer = TunnelServerDemo(port: port)
let newServer = TunnelServer(port: port)
appDelegate.setHTTPServer(newServer)
do {
@ -307,7 +324,7 @@ struct AdvancedSettingsView: View {
struct DebugSettingsView: View {
@State private var httpServer: TunnelServerDemo?
@State private var httpServer: TunnelServer?
@State private var lastError: String?
@State private var testResult: String?
@State private var isTesting = false
@ -531,7 +548,7 @@ struct DebugSettingsView: View {
if shouldStart {
// Create a new server if needed
if httpServer == nil {
let newServer = TunnelServerDemo(port: serverPort)
let newServer = TunnelServer(port: serverPort)
httpServer = newServer
// Store reference in 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 {
SettingsView()
}
.defaultSize(width: 500, height: 450)
.commands {
CommandGroup(after: .appInfo) {
Button("About VibeTunnel") {
@ -37,7 +36,7 @@ struct VibeTunnelApp: App {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
private(set) var httpServer: TunnelServerDemo?
private(set) var httpServer: TunnelServer?
private let sessionMonitor = SessionMonitor.shared
/// 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
let serverPort = UserDefaults.standard.integer(forKey: "serverPort")
httpServer = TunnelServerDemo(port: serverPort > 0 ? serverPort : 4020)
httpServer = TunnelServer(port: serverPort > 0 ? serverPort : 4020)
Task {
do {
@ -117,7 +116,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
func setHTTPServer(_ server: TunnelServerDemo?) {
func setHTTPServer(_ server: TunnelServer?) {
httpServer = server
}

View file

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