macOS Development

Project Setup

Requirements

  • macOS 14.0+
  • Xcode 16.0+
  • Swift 6.0

Build & Run

cd mac

# Debug build
xcodebuild -project VibeTunnel.xcodeproj -scheme VibeTunnel

# Release build  
./scripts/build.sh

# With code signing
./scripts/build.sh --sign

# Run directly
open build/Release/VibeTunnel.app

Architecture

Core Components

ComponentLocationPurpose
ServerManagerCore/Services/ServerManager.swiftServer lifecycle
SessionMonitorCore/Services/SessionMonitor.swiftTrack sessions
TTYForwardManagerCore/Services/TTYForwardManager.swiftCLI integration
MenuBarViewModelPresentation/ViewModels/MenuBarViewModel.swiftUI state

Key Patterns

Observable State
@MainActor
@Observable
class ServerManager {
    private(set) var isRunning = false
    private(set) var sessions: [Session] = []
}
Protocol-Based Services
@MainActor
protocol VibeTunnelServer: AnyObject {
    var isRunning: Bool { get }
    func start() async throws
    func stop() async
}
SwiftUI Menu Bar
struct MenuBarView: View {
    @StateObject private var viewModel = MenuBarViewModel()
    
    var body: some View {
        Menu("VT", systemImage: "terminal") {
            ForEach(viewModel.sessions) { session in
                SessionRow(session: session)
            }
        }
    }
}

Server Integration

Embedded Server

VibeTunnel.app/
└── Contents/
    ├── MacOS/
    │   └── VibeTunnel         # Main executable
    └── Resources/
        └── server/
            └── bun-server     # Embedded Bun binary

Server Launch

// ServerManager.swift
func start() async throws {
    let serverPath = Bundle.main.resourcePath! + "/server/bun-server"
    process = Process()
    process.executableURL = URL(fileURLWithPath: serverPath)
    process.arguments = ["--port", port]
    try process.run()
}

Settings Management

UserDefaults Keys

KeyTypeDefaultDescription
serverPortString”4020”Server port
autostartBoolfalseLaunch at login
allowLANBoolfalseLAN connections
useDevServerBoolfalseDevelopment mode

Settings Window

struct SettingsView: View {
    @AppStorage("serverPort") private var port = "4020"
    
    var body: some View {
        Form {
            TextField("Port:", text: $port)
        }
    }
}

App Lifecycle

@main
struct VibeTunnelApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        MenuBarExtra("VibeTunnel", systemImage: "terminal") {
            MenuBarView()
        }
        .menuBarExtraStyle(.menu)
    }
}

Status Updates

// Update menu bar icon based on state
func updateStatusItem() {
    if serverManager.isRunning {
        statusItem.button?.image = NSImage(systemSymbolName: "terminal.fill")
    } else {
        statusItem.button?.image = NSImage(systemSymbolName: "terminal")
    }
}

Code Signing

Entitlements

<!-- VibeTunnel.entitlements -->
<dict>
    <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>

Build Settings

# version.xcconfig
MARKETING_VERSION = 1.0.0
CURRENT_PROJECT_VERSION = 100

# Shared.xcconfig  
CODE_SIGN_IDENTITY = Developer ID Application
DEVELOPMENT_TEAM = TEAMID

Sparkle Updates

Integration

import Sparkle

class UpdateManager {
    let updater = SPUStandardUpdaterController(
        startingUpdater: true,
        updaterDelegate: nil,
        userDriverDelegate: nil
    )
    
    func checkForUpdates() {
        updater.checkForUpdates()
    }
}

Configuration

<!-- Info.plist -->
<key>SUFeedURL</key>
<string>https://vibetunnel.com/appcast.xml</string>
<key>SUEnableAutomaticChecks</key>
<true/>

Debugging

Console Logs

os_log(.debug, log: .server, "Starting server on port %{public}@", port)

View Logs

# In Console.app
# Filter: subsystem:com.steipete.VibeTunnel

# Or via script
./scripts/vtlog.sh -c ServerManager

Testing

Unit Tests

xcodebuild test \
  -project VibeTunnel.xcodeproj \
  -scheme VibeTunnel \
  -destination 'platform=macOS'

UI Tests

class VibeTunnelUITests: XCTestCase {
    func testServerStart() throws {
        let app = XCUIApplication()
        app.launch()
        
        app.menuBarItems["VibeTunnel"].click()
        app.menuItems["Start Server"].click()
        
        XCTAssertTrue(app.menuItems["Stop Server"].exists)
    }
}

Common Issues

IssueSolution
Server won’t startCheck port availability
Menu bar not showingCheck LSUIElement in Info.plist
Updates not workingVerify Sparkle feed URL
Permissions deniedAdd entitlements

See Also