This commit is contained in:
Peter Steinberger 2025-06-16 02:23:04 +02:00
parent c6a6f67872
commit be56723fbe
8 changed files with 431 additions and 120 deletions

View file

@ -0,0 +1,128 @@
//
// SessionMonitor.swift
// VibeTunnel
//
// Created by Assistant on 6/16/25.
//
import Foundation
import Combine
/// Monitors tty-fwd sessions and provides real-time session count
@MainActor
class SessionMonitor: ObservableObject {
static let shared = SessionMonitor()
@Published var sessionCount: Int = 0
@Published var sessions: [String: SessionInfo] = [:]
@Published var lastError: String?
private var timer: Timer?
private let refreshInterval: TimeInterval = 5.0 // Check every 5 seconds
private var serverPort: Int
struct SessionInfo: Codable {
let cmdline: [String]
let cwd: String
let exit_code: Int?
let name: String
let pid: Int
let started_at: String
let status: String
let stdin: String
let `stream-out`: String
var isRunning: Bool {
status == "running"
}
}
private init() {
let port = UserDefaults.standard.integer(forKey: "serverPort")
self.serverPort = port > 0 ? port : 8080
}
func startMonitoring() {
stopMonitoring()
// Update port from UserDefaults in case it changed
let port = UserDefaults.standard.integer(forKey: "serverPort")
self.serverPort = port > 0 ? port : 8080
// Initial fetch
Task {
await fetchSessions()
}
// Set up periodic fetching
timer = Timer.scheduledTimer(withTimeInterval: refreshInterval, repeats: true) { _ in
Task { @MainActor in
await self.fetchSessions()
}
}
}
func stopMonitoring() {
timer?.invalidate()
timer = nil
}
@MainActor
private func fetchSessions() async {
do {
// First check if server is running
let healthURL = URL(string: "http://localhost:\(serverPort)/health")!
let healthRequest = URLRequest(url: healthURL, timeoutInterval: 2.0)
do {
let (_, healthResponse) = try await URLSession.shared.data(for: healthRequest)
guard let httpResponse = healthResponse as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
// Server not running
self.sessions = [:]
self.sessionCount = 0
self.lastError = nil
return
}
} catch {
// Server not reachable
self.sessions = [:]
self.sessionCount = 0
self.lastError = nil
return
}
// Server is running, fetch sessions
let url = URL(string: "http://localhost:\(serverPort)/sessions")!
let request = URLRequest(url: url, timeoutInterval: 5.0)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
self.lastError = "Failed to fetch sessions"
return
}
// Parse JSON response
let sessionsData = try JSONDecoder().decode([String: SessionInfo].self, from: data)
self.sessions = sessionsData
// Count only running sessions
self.sessionCount = sessionsData.values.filter { $0.isRunning }.count
self.lastError = nil
} catch {
// Don't set error for connection issues when server is likely not running
if !(error is URLError) {
self.lastError = "Error fetching sessions: \(error.localizedDescription)"
}
// Clear sessions on error
self.sessions = [:]
self.sessionCount = 0
}
}
func refreshNow() async {
await fetchSessions()
}
}

View file

@ -39,7 +39,7 @@ public class TunnelClient {
/// Default base URL for the tunnel server
private static let defaultBaseURL: URL = {
guard let url = URL(string: "http://localhost:8080") else {
guard let url = URL(string: "http://127.0.0.1:8080") else {
fatalError("Invalid default base URL - this should never happen with a hardcoded URL")
}
return url

View file

@ -8,6 +8,17 @@ import NIOCore
import NIOPosix
import os
enum ServerError: LocalizedError {
case failedToStart(String)
var errorDescription: String? {
switch self {
case .failedToStart(let message):
return message
}
}
}
/// HTTP server implementation for the macOS app
@MainActor
public final class TunnelServerDemo: ObservableObject {
@ -27,6 +38,8 @@ public final class TunnelServerDemo: ObservableObject {
public func start() async throws {
guard !isRunning else { return }
logger.info("Starting TunnelServerDemo on port \(port)")
do {
let router = Router(context: BasicRequestContext.self)
@ -170,25 +183,43 @@ public final class TunnelServerDemo: ObservableObject {
logger: logger
)
// Store the app reference first
self.app = app
// Run the server in a separate task
serverTask = Task { @Sendable [weak self] in
// Run the server in a detached task to ensure it keeps running
serverTask = Task.detached(priority: .background) { [weak self, logger] in
do {
logger.info("Starting Hummingbird application...")
try await app.run()
logger.info("Hummingbird application stopped")
} catch {
await MainActor.run {
logger.error("Hummingbird error: \(error)")
await MainActor.run { [weak self] in
self?.lastError = error
self?.isRunning = false
}
throw error
}
}
// Give the server a moment to start
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
// Wait for the server to actually start listening
var serverStarted = false
for _ in 0..<10 { // Try for up to 1 second
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
// Check if the server is actually listening
if await isServerListening(on: port) {
serverStarted = true
break
}
}
isRunning = true
logger.info("Server started on port \(port)")
if serverStarted {
isRunning = true
logger.info("Server started and listening on port \(port)")
} else {
throw ServerError.failedToStart("Server did not start listening on port \(port)")
}
} catch {
lastError = error
@ -211,4 +242,20 @@ public final class TunnelServerDemo: ObservableObject {
isRunning = false
}
/// Check if the server is actually listening on the specified port
private func isServerListening(on port: Int) async -> Bool {
do {
let url = URL(string: "http://localhost:\(port)/health")!
let request = URLRequest(url: url, timeoutInterval: 1.0)
let (_, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse {
return httpResponse.statusCode == 200
}
} catch {
// Server not yet ready
}
return false
}
}

View file

@ -59,7 +59,7 @@ struct SettingsView: View {
}
.tag(SettingsTab.about)
}
.frame(minWidth: 400, idealWidth: 400, minHeight: 400, idealHeight: 500)
.frame(minWidth: 200, idealWidth: 200, minHeight: 400, idealHeight: 400)
.onReceive(NotificationCenter.default.publisher(for: .openSettingsTab)) { notification in
if let tab = notification.object as? SettingsTab {
selectedTab = tab
@ -155,12 +155,6 @@ struct AdvancedSettingsView: View {
private var updateChannelRaw = UpdateChannel.stable.rawValue
@State private var isCheckingForUpdates = false
@StateObject private var tunnelServer: TunnelServerDemo
init() {
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8_080
_tunnelServer = StateObject(wrappedValue: TunnelServerDemo(port: port))
}
var updateChannel: UpdateChannel {
UpdateChannel(rawValue: updateChannelRaw) ?? .stable
@ -212,52 +206,22 @@ struct AdvancedSettingsView: View {
}
Section {
// Tunnel Server
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Tunnel Server")
if tunnelServer.isRunning {
Circle()
.fill(.green)
.frame(width: 8, height: 8)
}
}
Text(tunnelServer
.isRunning ? "Server is running on port \(serverPort)" : "Server is stopped"
)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button(tunnelServer.isRunning ? "Stop" : "Start") {
toggleServer()
}
.buttonStyle(.bordered)
.tint(tunnelServer.isRunning ? .red : .blue)
}
if tunnelServer.isRunning, let serverURL = URL(string: "http://localhost:\(serverPort)") {
Link("Open in Browser", destination: serverURL)
.font(.caption)
}
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Server port:")
TextField("", text: $serverPort)
.frame(width: 80)
.disabled(tunnelServer.isRunning)
.onChange(of: serverPort) { oldValue, newValue in
// Validate port number
if let port = Int(newValue), port > 0, port < 65536 {
restartServerWithNewPort(port)
}
}
}
Text("The port used for the local tunnel server. Restart server to apply changes.")
Text("The server will automatically restart when the port is changed.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
} header: {
Text("Server")
.font(.headline)
@ -306,23 +270,36 @@ struct AdvancedSettingsView: View {
isCheckingForUpdates = false
}
}
private func toggleServer() {
private func restartServerWithNewPort(_ port: Int) {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return }
Task {
if tunnelServer.isRunning {
try await tunnelServer.stop()
} else {
do {
try await tunnelServer.start()
} catch {
// Show error alert
await MainActor.run {
let alert = NSAlert()
alert.messageText = "Failed to Start Server"
alert.informativeText = error.localizedDescription
alert.alertStyle = .critical
alert.runModal()
}
// Stop the current server if running
if let server = appDelegate.httpServer, server.isRunning {
try? await server.stop()
}
// Create and start new server with the new port
let newServer = TunnelServerDemo(port: port)
appDelegate.setHTTPServer(newServer)
do {
try await newServer.start()
print("Server restarted on port \(port)")
// Restart session monitoring with new port
SessionMonitor.shared.stopMonitoring()
SessionMonitor.shared.startMonitoring()
} catch {
print("Failed to restart server on port \(port): \(error)")
// Show error alert
await MainActor.run {
let alert = NSAlert()
alert.messageText = "Failed to Restart Server"
alert.informativeText = "Could not start server on port \(port): \(error.localizedDescription)"
alert.alertStyle = .critical
alert.runModal()
}
}
}

View file

@ -6,6 +6,8 @@
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>

View file

@ -5,6 +5,7 @@ import SwiftUI
struct VibeTunnelApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self)
var appDelegate
@StateObject private var sessionMonitor = SessionMonitor.shared
var body: some Scene {
#if os(macOS)
@ -18,6 +19,14 @@ struct VibeTunnelApp: App {
}
}
}
MenuBarExtra {
MenuBarView()
.environmentObject(sessionMonitor)
} label: {
Image("menubar")
.renderingMode(.template)
}
#endif
}
}
@ -27,8 +36,8 @@ struct VibeTunnelApp: App {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private(set) var sparkleUpdaterManager: SparkleUpdaterManager?
private var statusItem: NSStatusItem?
private(set) var httpServer: TunnelServerDemo?
private let sessionMonitor = SessionMonitor.shared
/// Distributed notification name used to ask an existing instance to show the Settings window.
private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings")
@ -53,9 +62,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
NSApp.setActivationPolicy(showInDock ? .regular : .accessory)
// Setup status item (menu bar icon)
setupStatusItem()
// Show settings on first launch or when no window is open
if !showInDock {
// For menu bar apps, we need to ensure the settings window is accessible
@ -75,15 +81,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
)
// Initialize and start HTTP server
let serverPort = UserDefaults.standard.integer(forKey: "httpServerPort")
let serverPort = UserDefaults.standard.integer(forKey: "serverPort")
httpServer = TunnelServerDemo(port: serverPort > 0 ? serverPort : 8080)
Task {
do {
print("Attempting to start HTTP server on port \(httpServer?.port ?? 8080)...")
try await httpServer?.start()
print("HTTP server started automatically on port \(httpServer?.port ?? 8080)")
print("HTTP server started successfully on port \(httpServer?.port ?? 8080)")
print("Server is running: \(httpServer?.isRunning ?? false)")
// Start monitoring sessions after server starts
sessionMonitor.startMonitoring()
// Test the server after a short delay
try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
if let url = URL(string: "http://localhost:\(httpServer?.port ?? 8080)/health") {
let (_, response) = try await URLSession.shared.data(from: url)
if let httpResponse = response as? HTTPURLResponse {
print("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)")
if let nsError = error as NSError? {
print("NSError domain: \(nsError.domain)")
print("NSError code: \(nsError.code)")
print("NSError userInfo: \(nsError.userInfo)")
}
}
}
}
@ -139,6 +166,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
// Stop session monitoring
sessionMonitor.stopMonitoring()
// Stop HTTP server
Task {
try? await httpServer?.stop()
@ -167,52 +197,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
)
}
// MARK: - Status Item
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(named: "menubar")
button.image?.isTemplate = true
button.action = #selector(statusItemClicked)
button.target = self
}
// Create menu
let menu = NSMenu()
let settingsItem = NSMenuItem(title: "Settings\u{2026}", action: #selector(showSettings), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(settingsItem)
menu.addItem(NSMenuItem.separator())
let aboutItem = NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: "")
aboutItem.target = self
menu.addItem(aboutItem)
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusItem?.menu = menu
}
@objc
private func statusItemClicked() {
// Left click shows menu
}
@objc
private func showSettings() {
NSApp.openSettings()
NSApp.activate(ignoringOtherApps: true)
}
@objc
private func showAbout() {
showAboutInSettings()
}
}
/// Shows the About section in the Settings window

View file

@ -0,0 +1,173 @@
//
// MenuBarView.swift
// VibeTunnel
//
// SwiftUI menu bar implementation
//
import SwiftUI
struct MenuBarView: View {
@EnvironmentObject var sessionMonitor: SessionMonitor
@AppStorage("showInDock") private var showInDock = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Session count header
SessionCountView(count: sessionMonitor.sessionCount)
.padding(.horizontal, 12)
.padding(.vertical, 8)
// Session list
if sessionMonitor.sessionCount > 0 {
SessionListView(sessions: sessionMonitor.sessions)
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
Divider()
.padding(.vertical, 4)
// Settings button
Button(action: {
NSApp.openSettings()
NSApp.activate(ignoringOtherApps: true)
}) {
Label("Settings…", systemImage: "gear")
}
.buttonStyle(MenuButtonStyle())
.keyboardShortcut(",", modifiers: .command)
Divider()
.padding(.vertical, 4)
// About button
Button(action: {
showAboutInSettings()
}) {
Label("About VibeTunnel", systemImage: "info.circle")
}
.buttonStyle(MenuButtonStyle())
Divider()
.padding(.vertical, 4)
// Quit button
Button(action: {
NSApplication.shared.terminate(nil)
}) {
Label("Quit", systemImage: "power")
}
.buttonStyle(MenuButtonStyle())
.keyboardShortcut("q", modifiers: .command)
}
.frame(minWidth: 200)
}
}
// MARK: - Session Count View
struct SessionCountView: View {
let count: Int
var body: some View {
HStack {
Image(systemName: "terminal")
.foregroundColor(.secondary)
Text(sessionText)
.font(.system(size: 13))
.foregroundColor(.secondary)
Spacer()
}
}
private var sessionText: String {
count == 1 ? "1 active session" : "\(count) active sessions"
}
}
// MARK: - Session List View
struct SessionListView: View {
let sessions: [String: SessionMonitor.SessionInfo]
var body: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(Array(activeSessions.prefix(5)), id: \.key) { session in
SessionRowView(session: session)
}
if activeSessions.count > 5 {
HStack {
Text(" • ...")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
}
}
}
private var activeSessions: [(key: String, value: SessionMonitor.SessionInfo)] {
sessions.filter { $0.value.isRunning }
.sorted { $0.value.started_at > $1.value.started_at }
}
}
// MARK: - Session Row View
struct SessionRowView: View {
let session: (key: String, value: SessionMonitor.SessionInfo)
var body: some View {
HStack {
Text("\(sessionName)")
.font(.system(size: 12))
.foregroundColor(.secondary)
Spacer()
}
.padding(.vertical, 2)
}
private var sessionName: String {
session.value.name.isEmpty ? session.value.cmdline.first ?? "Unknown" : session.value.name
}
}
// MARK: - Menu Button Style
struct MenuButtonStyle: ButtonStyle {
@State private var isHovered = false
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isHovered ? Color.accentColor.opacity(0.1) : Color.clear)
)
.onHover { hovering in
isHovered = hovering
}
}
}
// MARK: - Helper Functions
/// Shows the About section in the Settings window
private func showAboutInSettings() {
NSApp.openSettings()
Task {
// Small delay to ensure the settings window is fully initialized
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
NotificationCenter.default.post(
name: .openSettingsTab,
object: SettingsTab.about
)
}
NSApp.activate(ignoringOtherApps: true)
}