vibetunnel/mac/VibeTunnel/Presentation/Components/Menu/ServerInfoSection.swift
Helmut Januschka f3b2022d48
Integrate screencap functionality for remote screen sharing (#209)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2025-07-06 03:31:34 +01:00

265 lines
9 KiB
Swift

import AppKit
import SwiftUI
// MARK: - Server Info Header
/// Header section of the menu showing server status and connection info.
///
/// Displays the VibeTunnel logo, server running status, and available
/// connection addresses including local, ngrok, and Tailscale endpoints.
struct ServerInfoHeader: View {
@Environment(ServerManager.self)
var serverManager
@Environment(NgrokService.self)
var ngrokService
@Environment(TailscaleService.self)
var tailscaleService
@Environment(\.colorScheme)
private var colorScheme
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Title and status
HStack {
HStack(spacing: 8) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 24, height: 24)
.cornerRadius(4)
Text("VibeTunnel")
.font(.system(size: 14, weight: .semibold))
}
Spacer()
ServerStatusBadge(
isRunning: serverManager.isRunning
) {
Task {
await serverManager.restart()
}
}
}
// Server address
if serverManager.isRunning {
VStack(alignment: .leading, spacing: 4) {
ServerAddressRow()
if ngrokService.isActive, let publicURL = ngrokService.publicUrl {
ServerAddressRow(
icon: "network",
label: "ngrok:",
address: publicURL,
url: URL(string: publicURL)
)
}
if tailscaleService.isRunning, let hostname = tailscaleService.tailscaleHostname {
ServerAddressRow(
icon: "shield",
label: "Tailscale:",
address: hostname,
url: URL(string: "http://\(hostname):\(serverManager.port)")
)
}
}
}
}
}
}
/// Displays a clickable server address with an icon and label.
///
/// Shows connection endpoints that can be clicked to open in the browser,
/// with support for local addresses, ngrok tunnels, and Tailscale connections.
struct ServerAddressRow: View {
let icon: String
let label: String
let address: String
let url: URL?
@Environment(ServerManager.self)
var serverManager
@Environment(\.colorScheme)
private var colorScheme
@State private var isHovered = false
@State private var showCopiedFeedback = false
init(
icon: String = "server.rack",
label: String = "Local:",
address: String? = nil,
url: URL? = nil
) {
self.icon = icon
self.label = label
self.address = address ?? ""
self.url = url
}
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
.frame(width: 14, alignment: .center)
Text(label)
.font(.system(size: 11))
.foregroundColor(.secondary)
Button(action: {
if let providedUrl = url {
NSWorkspace.shared.open(providedUrl)
} else if computedAddress.starts(with: "127.0.0.1:") {
// For localhost, use DashboardURLBuilder
if let dashboardURL = DashboardURLBuilder.dashboardURL(port: serverManager.port) {
NSWorkspace.shared.open(dashboardURL)
}
} else if let url = URL(string: "http://\(computedAddress)") {
// For other addresses (network IP, etc.), construct URL directly
NSWorkspace.shared.open(url)
}
}) {
Text(computedAddress)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
.underline()
}
.buttonStyle(.plain)
.pointingHandCursor()
// Copy button that appears on hover
if isHovered {
Button(action: {
copyToClipboard()
}) {
Image(systemName: showCopiedFeedback ? "checkmark.circle.fill" : "doc.on.doc")
.font(.system(size: 10))
.foregroundColor(AppColors.Fallback.serverRunning(for: colorScheme))
}
.buttonStyle(.plain)
.pointingHandCursor()
.help(showCopiedFeedback ? "Copied!" : "Copy to clipboard")
.transition(.scale.combined(with: .opacity))
}
}
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovered = hovering
}
}
}
private var computedAddress: String {
if !address.isEmpty {
return address
}
// Default behavior for local server
let bindAddress = serverManager.bindAddress
if bindAddress == "127.0.0.1" {
return "127.0.0.1:\(serverManager.port)"
} else if let localIP = NetworkUtility.getLocalIPAddress() {
return "\(localIP):\(serverManager.port)"
} else {
return "0.0.0.0:\(serverManager.port)"
}
}
private var urlToCopy: String {
// If we have a full URL, return it as-is
if let providedUrl = url {
return providedUrl.absoluteString
}
// For Tailscale, return the full URL
if label == "Tailscale:" && !address.isEmpty {
return "http://\(address):\(serverManager.port)"
}
// For local addresses, build the full URL
if computedAddress.starts(with: "127.0.0.1:") {
return "http://\(computedAddress)"
} else {
return "http://\(computedAddress)"
}
}
private func copyToClipboard() {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(urlToCopy, forType: .string)
// Show feedback
withAnimation(.easeInOut(duration: 0.15)) {
showCopiedFeedback = true
}
// Hide feedback after 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.easeInOut(duration: 0.15)) {
showCopiedFeedback = false
}
}
}
}
/// Visual indicator for server running status.
///
/// Shows a colored badge with status text indicating whether
/// the VibeTunnel server is currently running or stopped.
/// When stopped, the badge is clickable to restart the server.
struct ServerStatusBadge: View {
let isRunning: Bool
let onRestart: (() -> Void)?
@Environment(\.colorScheme)
private var colorScheme
@State private var isHovered = false
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(isRunning ? AppColors.Fallback.serverRunning(for: colorScheme) : AppColors.Fallback
.destructive(for: colorScheme)
)
.frame(width: 6, height: 6)
Text(isRunning ? "Running" : "Stopped")
.font(.system(size: 10, weight: .medium))
.foregroundColor(isRunning ? AppColors.Fallback.serverRunning(for: colorScheme) : AppColors.Fallback
.destructive(for: colorScheme)
)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule()
.fill(isRunning ? AppColors.Fallback.serverRunning(for: colorScheme).opacity(0.1) : AppColors.Fallback
.destructive(for: colorScheme).opacity(0.1)
)
.overlay(
Capsule()
.stroke(
isRunning ? AppColors.Fallback.serverRunning(for: colorScheme).opacity(0.3) : AppColors
.Fallback.destructive(for: colorScheme).opacity(0.3),
lineWidth: 0.5
)
)
)
.opacity(isHovered && !isRunning ? 0.8 : 1.0)
.scaleEffect(isHovered && !isRunning ? 0.95 : 1.0)
.animation(.easeInOut(duration: 0.15), value: isHovered)
.onHover { hovering in
if !isRunning {
isHovered = hovering
}
}
.onTapGesture {
if !isRunning, let onRestart {
onRestart()
}
}
.help(!isRunning ? "Click to restart server" : "")
}
}