2026-04-12 00:42:51 -07:00
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
struct MenuBarView: View {
|
2026-04-12 22:14:13 -07:00
|
|
|
@ObservedObject var controller: DaemonController
|
2026-04-12 00:42:51 -07:00
|
|
|
@ObservedObject var preferences: AppPreferences
|
|
|
|
|
@Environment(\.openWindow) private var openWindow
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
VStack(spacing: 0) {
|
|
|
|
|
header.padding(.horizontal,14).padding(.top,14).padding(.bottom,10)
|
|
|
|
|
Divider()
|
|
|
|
|
statusGrid.padding(.horizontal,14).padding(.vertical,10)
|
|
|
|
|
Divider()
|
|
|
|
|
if !controller.connectedClients.isEmpty {
|
|
|
|
|
connectionsSection.padding(.horizontal,14).padding(.vertical,8)
|
|
|
|
|
Divider()
|
|
|
|
|
}
|
|
|
|
|
actionsSection.padding(.horizontal,14).padding(.vertical,8)
|
|
|
|
|
Divider()
|
|
|
|
|
footer.padding(.horizontal,14).padding(.vertical,8)
|
|
|
|
|
}
|
|
|
|
|
.frame(width: 300)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var header: some View {
|
|
|
|
|
HStack(spacing:10) {
|
|
|
|
|
Circle().fill(stateColor).frame(width:10,height:10).shadow(color:stateColor.opacity(0.6),radius:4)
|
|
|
|
|
VStack(alignment:.leading,spacing:2) {
|
|
|
|
|
Text("PadXcode Daemon").font(.headline)
|
|
|
|
|
Text(controller.serverState.statusLabel).font(.subheadline).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
Spacer()
|
2026-04-12 22:14:13 -07:00
|
|
|
Button {
|
|
|
|
|
Task {
|
|
|
|
|
if controller.serverState.isRunning { await controller.stop() }
|
|
|
|
|
else if case .starting = controller.serverState { } // ignore during startup
|
|
|
|
|
else { await controller.start() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
label: {
|
|
|
|
|
if case .starting = controller.serverState {
|
|
|
|
|
ProgressView().scaleEffect(0.6).frame(width:44)
|
|
|
|
|
} else {
|
|
|
|
|
Text(controller.serverState.isRunning ? "Stop" : "Start").frame(width:44)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.bordered).controlSize(.small)
|
|
|
|
|
.tint(controller.serverState.isRunning ? .red : .green)
|
|
|
|
|
.disabled({ if case .starting = controller.serverState { return true }; return false }())
|
2026-04-12 00:42:51 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var statusGrid: some View {
|
|
|
|
|
Grid(alignment:.leading,horizontalSpacing:12,verticalSpacing:6) {
|
|
|
|
|
GridRow {
|
|
|
|
|
StatusCell(label:"Port", value:"\(preferences.port)", icon:"antenna.radiowaves.left.and.right")
|
|
|
|
|
StatusCell(label:"Uptime", value:controller.serverState.uptime ?? "—", icon:"clock")
|
|
|
|
|
}
|
|
|
|
|
GridRow {
|
|
|
|
|
StatusCell(label:"Tailscale", value:preferences.tailscaleIP, icon:"network")
|
|
|
|
|
StatusCell(label:"Builds", value:"\(controller.buildCount)", icon:"hammer")
|
|
|
|
|
}
|
|
|
|
|
GridRow {
|
|
|
|
|
StatusCell(label:"Clients", value:"\(controller.connectedClients.count)", icon:"ipad.and.iphone")
|
|
|
|
|
StatusCell(label:"Config", value:preferences.developmentTeam.isEmpty ? "⚠️ No Team ID" : "✓ Signed", icon:"checkmark.seal")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var connectionsSection: some View {
|
|
|
|
|
VStack(alignment:.leading,spacing:4) {
|
|
|
|
|
Text("Active Connections").font(.caption.bold()).foregroundStyle(.secondary)
|
|
|
|
|
ForEach(controller.connectedClients) { c in
|
|
|
|
|
HStack(spacing:6) {
|
|
|
|
|
Circle().fill(.green).frame(width:6,height:6)
|
|
|
|
|
Text(c.type.rawValue).font(.caption)
|
|
|
|
|
Spacer()
|
|
|
|
|
Text(c.sessionId.prefix(8)).font(.system(.caption2,design:.monospaced)).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var actionsSection: some View {
|
|
|
|
|
VStack(spacing:2) {
|
|
|
|
|
MenuAction(label:"Restart Server", icon:"arrow.clockwise") { Task { await controller.restart() } }
|
|
|
|
|
MenuAction(label:"View Log", icon:"doc.text.magnifyingglass") { openWindow(id:"log"); NSApp.activate(ignoringOtherApps:true) }
|
|
|
|
|
MenuAction(label:"Copy Server URL", icon:"doc.on.clipboard") {
|
|
|
|
|
let url = "http://\(preferences.tailscaleIP):\(preferences.port)"
|
|
|
|
|
NSPasteboard.general.clearContents(); NSPasteboard.general.setString(url, forType:.string)
|
|
|
|
|
}
|
|
|
|
|
MenuAction(label:"Open Config Folder", icon:"folder") {
|
|
|
|
|
NSWorkspace.shared.open(URL(fileURLWithPath:NSHomeDirectory()).appendingPathComponent(".padxcode"))
|
|
|
|
|
}
|
|
|
|
|
MenuAction(label:"Settings…", icon:"gearshape", shortcut:",") { openSettingsWindow() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var footer: some View {
|
|
|
|
|
HStack {
|
|
|
|
|
Text("v1.0.0").font(.caption2).foregroundStyle(.tertiary)
|
|
|
|
|
Spacer()
|
|
|
|
|
Button("Quit PadXcode Daemon") { Task { await controller.stop(); NSApp.terminate(nil) } }
|
|
|
|
|
.buttonStyle(.plain).font(.caption).foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var stateColor: Color {
|
|
|
|
|
switch controller.serverState {
|
|
|
|
|
case .running: return .green; case .starting: return .yellow
|
|
|
|
|
case .error: return .red; case .stopped: return .secondary
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func openSettingsWindow() {
|
|
|
|
|
openWindow(id:"settings")
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline:.now()+0.05) {
|
|
|
|
|
NSApp.setActivationPolicy(.regular); NSApp.activate(ignoringOtherApps:true)
|
|
|
|
|
NSApp.windows.first(where:{ $0.title == "PadXcode Daemon Settings" })?.makeKeyAndOrderFront(nil)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct StatusCell: View {
|
|
|
|
|
let label: String; let value: String; let icon: String
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(spacing:5) {
|
|
|
|
|
Image(systemName:icon).font(.caption).foregroundStyle(.secondary).frame(width:14)
|
|
|
|
|
VStack(alignment:.leading,spacing:0) {
|
|
|
|
|
Text(label).font(.caption2).foregroundStyle(.tertiary)
|
|
|
|
|
Text(value).font(.system(.caption,design:.monospaced)).lineLimit(1)
|
|
|
|
|
}
|
|
|
|
|
}.frame(maxWidth:.infinity,alignment:.leading)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct MenuAction: View {
|
|
|
|
|
let label: String; let icon: String; var shortcut: String? = nil; let action: () -> Void
|
|
|
|
|
var body: some View {
|
|
|
|
|
Button(action:action) {
|
|
|
|
|
HStack {
|
|
|
|
|
Image(systemName:icon).frame(width:16).foregroundStyle(.secondary)
|
|
|
|
|
Text(label)
|
|
|
|
|
Spacer()
|
|
|
|
|
if let sc = shortcut { Text("⌘\(sc)").font(.caption).foregroundStyle(.tertiary) }
|
|
|
|
|
}.contentShape(Rectangle()).padding(.horizontal,4).padding(.vertical,5)
|
|
|
|
|
}.buttonStyle(.plain)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct MenuBarIcon: View {
|
|
|
|
|
let controller: DaemonController?
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(spacing:3) {
|
|
|
|
|
Image(systemName: iconName).symbolRenderingMode(.palette).foregroundStyle(iconColor, .primary)
|
|
|
|
|
if let u = controller?.serverState.uptime { Text(u).font(.system(size:10,design:.monospaced)) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
private var iconName: String {
|
|
|
|
|
switch controller?.serverState {
|
|
|
|
|
case .running: return "dot.radiowaves.left.and.right"; case .starting: return "arrow.clockwise.circle"
|
|
|
|
|
case .error: return "exclamationmark.circle"; default: return "antenna.radiowaves.left.and.right.slash"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
private var iconColor: Color {
|
|
|
|
|
switch controller?.serverState {
|
|
|
|
|
case .running: return .green; case .starting: return .yellow; case .error: return .red; default: return .secondary
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|