mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-30 10:16:10 +00:00
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:
parent
223fa898b1
commit
ed19be138b
11 changed files with 210 additions and 165 deletions
Binary file not shown.
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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.
|
|
@ -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 {
|
||||
|
|
|
|||
71
VibeTunnel/Utilities/SettingsWindowDelegate.swift
Normal file
71
VibeTunnel/Utilities/SettingsWindowDelegate.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
93
docs/modern-swift-refactoring.md
Normal file
93
docs/modern-swift-refactoring.md
Normal 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.
|
||||
Loading…
Reference in a new issue