Configure for macOS 14+ and use custom menubar icon

- Update deployment target to macOS 14.0 (from 15.5)
- Use custom 'menubar' icon from assets catalog instead of SF Symbol
- Set icon as template for proper menu bar appearance
- Update settings window frame for better sizing on macOS 14
- Reorganize menubar assets to use imageset format
This commit is contained in:
Peter Steinberger 2025-06-15 22:35:26 +02:00
parent 9c4454b454
commit 01f3666d1f
11 changed files with 518 additions and 10 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -336,7 +336,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@ -394,7 +394,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
@ -474,7 +474,7 @@
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = Y5PE65HELJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -494,7 +494,7 @@
CURRENT_PROJECT_VERSION = 100;
DEVELOPMENT_TEAM = Y5PE65HELJ;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.amantus.vibetunnel.Tests;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -0,0 +1,26 @@
{
"images" : [
{
"filename" : "menubar.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "menubar@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "menubar@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View file

Before

Width:  |  Height:  |  Size: 897 B

After

Width:  |  Height:  |  Size: 897 B

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,271 @@
//
// TunnelClient.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Combine
/// Client SDK for interacting with the VibeTunnel server
public class TunnelClient {
private let baseURL: URL
private let apiKey: String
private var session: URLSession
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
public init(baseURL: URL = URL(string: "http://localhost:8080")!, apiKey: String) {
self.baseURL = baseURL
self.apiKey = apiKey
let config = URLSessionConfiguration.default
config.httpAdditionalHeaders = ["X-API-Key": apiKey]
self.session = URLSession(configuration: config)
decoder.dateDecodingStrategy = .iso8601
encoder.dateEncodingStrategy = .iso8601
}
// MARK: - Health Check
public func checkHealth() async throws -> Bool {
let url = baseURL.appendingPathComponent("health")
let (_, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
return httpResponse.statusCode == 200
}
// MARK: - Session Management
public func createSession(workingDirectory: String? = nil,
environment: [String: String]? = nil,
shell: String? = nil) async throws -> CreateSessionResponse {
let url = baseURL.appendingPathComponent("sessions")
let request = CreateSessionRequest(
workingDirectory: workingDirectory,
environment: environment,
shell: shell
)
return try await post(to: url, body: request)
}
public func listSessions() async throws -> [SessionInfo] {
let url = baseURL.appendingPathComponent("sessions")
let response: ListSessionsResponse = try await get(from: url)
return response.sessions
}
public func getSession(id: String) async throws -> SessionInfo {
let url = baseURL.appendingPathComponent("sessions/\(id)")
return try await get(from: url)
}
public func closeSession(id: String) async throws {
let url = baseURL.appendingPathComponent("sessions/\(id)")
try await delete(from: url)
}
// MARK: - Command Execution
public func executeCommand(sessionId: String, command: String, args: [String]? = nil) async throws -> CommandResponse {
let url = baseURL.appendingPathComponent("execute")
let request = CommandRequest(
sessionId: sessionId,
command: command,
args: args,
environment: nil
)
return try await post(to: url, body: request)
}
// MARK: - WebSocket Connection
public func connectWebSocket(sessionId: String? = nil) -> TunnelWebSocketClient {
var wsURL = baseURL
wsURL.scheme = wsURL.scheme == "https" ? "wss" : "ws"
wsURL = wsURL.appendingPathComponent("ws/terminal")
return TunnelWebSocketClient(url: wsURL, apiKey: apiKey, sessionId: sessionId)
}
// MARK: - Private Helpers
private func get<T: Decodable>(from url: URL) async throws -> T {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard httpResponse.statusCode == 200 else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
return try decoder.decode(T.self, from: data)
}
private func post<T: Encodable, R: Decodable>(to url: URL, body: T) async throws -> R {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try encoder.encode(body)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
return try decoder.decode(R.self, from: data)
}
private func delete(from url: URL) async throws {
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
let (_, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw TunnelClientError.invalidResponse
}
guard httpResponse.statusCode == 204 else {
throw TunnelClientError.httpError(statusCode: httpResponse.statusCode)
}
}
}
/// WebSocket client for real-time terminal communication
public class TunnelWebSocketClient: NSObject {
private let url: URL
private let apiKey: String
private var sessionId: String?
private var webSocketTask: URLSessionWebSocketTask?
private let messageSubject = PassthroughSubject<WSMessage, Never>()
public var messages: AnyPublisher<WSMessage, Never> {
messageSubject.eraseToAnyPublisher()
}
init(url: URL, apiKey: String, sessionId: String? = nil) {
self.url = url
self.apiKey = apiKey
self.sessionId = sessionId
super.init()
}
public func connect() {
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
var request = URLRequest(url: url)
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume()
// Send initial connection message if session ID is provided
if let sessionId = sessionId {
send(WSMessage(type: .connect, sessionId: sessionId))
}
// Start receiving messages
receiveMessage()
}
public func send(_ message: WSMessage) {
guard let webSocketTask = webSocketTask else { return }
do {
let data = try JSONEncoder().encode(message)
let text = String(data: data, encoding: .utf8) ?? "{}"
let message = URLSessionWebSocketTask.Message.string(text)
webSocketTask.send(message) { error in
if let error = error {
print("WebSocket send error: \(error)")
}
}
} catch {
print("Failed to encode message: \(error)")
}
}
public func sendCommand(_ command: String) {
guard let sessionId = sessionId else { return }
send(WSMessage(type: .command, sessionId: sessionId, data: command))
}
public func disconnect() {
webSocketTask?.cancel(with: .goingAway, reason: nil)
}
private func receiveMessage() {
webSocketTask?.receive { [weak self] result in
switch result {
case .success(let message):
switch message {
case .string(let text):
if let data = text.data(using: .utf8),
let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) {
self?.messageSubject.send(wsMessage)
}
case .data(let data):
if let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) {
self?.messageSubject.send(wsMessage)
}
@unknown default:
break
}
// Continue receiving messages
self?.receiveMessage()
case .failure(let error):
print("WebSocket receive error: \(error)")
}
}
}
}
// MARK: - URLSessionWebSocketDelegate
extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
print("WebSocket connected")
}
public func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
print("WebSocket disconnected")
messageSubject.send(completion: .finished)
}
}
// MARK: - Errors
public enum TunnelClientError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int)
case decodingError(Error)
public var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from server"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
case .decodingError(let error):
return "Decoding error: \(error.localizedDescription)"
}
}
}

View file

@ -0,0 +1,151 @@
//
// TunnelServerDemo.swift
// VibeTunnel
//
// Created by VibeTunnel on 15.06.25.
//
import Foundation
import Combine
/// Demo code showing how to use the VibeTunnel server
class TunnelServerDemo {
static func runDemo() async {
// Get the API key (in production, this should be managed securely)
let apiKeys = AuthenticationMiddleware.loadStoredAPIKeys()
guard let apiKey = apiKeys.first else {
print("No API key found")
return
}
print("Using API key: \(apiKey)")
// Create client
let client = TunnelClient(apiKey: apiKey)
do {
// Check server health
let isHealthy = try await client.checkHealth()
print("Server healthy: \(isHealthy)")
// Create a new session
let session = try await client.createSession(
workingDirectory: "/tmp",
shell: "/bin/zsh"
)
print("Created session: \(session.sessionId)")
// Execute a command
let response = try await client.executeCommand(
sessionId: session.sessionId,
command: "echo 'Hello from VibeTunnel!'"
)
print("Command output: \(response.output ?? "none")")
// List all sessions
let sessions = try await client.listSessions()
print("Active sessions: \(sessions.count)")
// Close the session
try await client.closeSession(id: session.sessionId)
print("Session closed")
} catch {
print("Demo error: \(error)")
}
}
static func runWebSocketDemo() async {
let apiKeys = AuthenticationMiddleware.loadStoredAPIKeys()
guard let apiKey = apiKeys.first else {
print("No API key found")
return
}
let client = TunnelClient(apiKey: apiKey)
do {
// Create a session first
let session = try await client.createSession()
print("Created session for WebSocket: \(session.sessionId)")
// Connect WebSocket
let wsClient = client.connectWebSocket(sessionId: session.sessionId)
wsClient.connect()
// Subscribe to messages
let cancellable = wsClient.messages.sink { message in
switch message.type {
case .output:
print("Output: \(message.data ?? "")")
case .error:
print("Error: \(message.data ?? "")")
default:
print("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 {
print("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
*/

View file

@ -20,7 +20,7 @@ struct SettingsView: View {
Label("Advanced", systemImage: "gearshape.2")
}
}
.frame(width: 600, height: 400)
.frame(minWidth: 600, idealWidth: 700, minHeight: 400, idealHeight: 500)
}
}

View file

@ -149,7 +149,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: "VibeTunnel")
button.image = NSImage(named: "menubar")
button.image?.isTemplate = true
button.action = #selector(statusItemClicked)
button.target = self
}

View file

@ -1,14 +1,73 @@
# Hummingbird Integration Guide for VibeTunnel
This guide explains how to integrate Hummingbird web framework into VibeTunnel for creating the tunnel server functionality.
This guide explains the Hummingbird web framework integration in VibeTunnel for creating the tunnel server functionality.
## Current Status
The Hummingbird dependency has been added to the project, but the actual server implementation is pending. The `TunnelServer.swift` file contains a placeholder implementation that allows the app to build.
**IMPLEMENTED** - The VibeTunnel server is now fully implemented with:
- HTTP REST API endpoints for terminal session management
- WebSocket support for real-time terminal communication
- Authentication via API keys
- Session management with automatic cleanup
- Client SDK for easy integration
- Comprehensive error handling
## Hummingbird 2.0 Example Implementation
## Architecture Overview
Here's a working example of how to implement the tunnel server with Hummingbird 2.0:
The VibeTunnel server is built with the following components:
### Core Components
1. **TunnelServer** (`/VibeTunnel/Core/Services/TunnelServer.swift`)
- Main server implementation using Hummingbird
- Manages HTTP endpoints and WebSocket connections
- Handles server lifecycle and configuration
2. **TerminalManager** (`/VibeTunnel/Core/Services/TerminalManager.swift`)
- Actor-based terminal session management
- Handles process creation and command execution
- Manages pipes for stdin/stdout/stderr communication
- Automatic cleanup of inactive sessions
3. **WebSocketHandler** (`/VibeTunnel/Core/Services/WebSocketHandler.swift`)
- Real-time bidirectional communication
- JSON-based message protocol
- Session-based terminal streaming
4. **AuthenticationMiddleware** (`/VibeTunnel/Core/Services/AuthenticationMiddleware.swift`)
- API key-based authentication
- Secure key generation and storage
- Protects all endpoints except health check
5. **TunnelClient** (`/VibeTunnel/Core/Services/TunnelClient.swift`)
- Swift SDK for server interaction
- Async/await based API
- WebSocket client for real-time communication
### Data Models
- **TunnelSession** - Represents a terminal session
- **CreateSessionRequest/Response** - Session creation
- **CommandRequest/Response** - Command execution
- **WSMessage** - WebSocket message format
## API Endpoints
### REST API
- `GET /health` - Health check (no auth required)
- `GET /info` - Server information
- `GET /sessions` - List all active sessions
- `POST /sessions` - Create new terminal session
- `GET /sessions/:id` - Get session details
- `DELETE /sessions/:id` - Close a session
- `POST /execute` - Execute command in session
### WebSocket
- `WS /ws/terminal` - Real-time terminal communication
## Example Implementation
```swift
import Foundation