updates to launcer

This commit is contained in:
Mark Gregory
2026-10-23 15:36:33 +01:00
parent 01c30424d5
commit 7c23ddb107
2 changed files with 1169 additions and 51 deletions
+286 -51
View File
@@ -742,11 +742,18 @@ private enum OrionHelpLibrary {
}
}
struct OrionHelpView: View {
@State private var system = "Fedora"
@State private var selectedGuide = "mailpit"
@State private var system = "Ubuntu"
@State private var selectedGuide = "nginx"
@State private var search = ""
private let navy = Color(red: 0.075, green: 0.105, blue: 0.16)
private let sidebar = Color(red: 0.105, green: 0.145, blue: 0.21)
private let card = Color(red: 0.14, green: 0.18, blue: 0.25)
private let accent = Color(red: 0.22, green: 0.53, blue: 1.0)
private let success = Color(red: 0.20, green: 0.78, blue: 0.52)
private var guides: [OrionHelpGuide] {
OrionHelpLibrary.guides(for: system)
}
@@ -764,90 +771,290 @@ struct OrionHelpView: View {
guides.first { $0.id == selectedGuide }
}
private var categories: [String] {
Array(Set(filtered.map(\.category))).sorted()
}
var body: some View {
NavigationSplitView {
VStack(spacing: 10) {
HStack(spacing: 0) {
sidebarView
.frame(width: 265)
Rectangle()
.fill(.white.opacity(0.08))
.frame(width: 1)
detailView
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(navy)
.preferredColorScheme(.dark)
.frame(minWidth: 880, minHeight: 600)
}
private var sidebarView: some View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(systemName: "books.vertical.fill")
.font(.system(size: 19))
.foregroundStyle(.white)
.frame(width: 40, height: 40)
.background(
LinearGradient(
colors: [accent, .cyan],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 11)
)
VStack(alignment: .leading, spacing: 3) {
Text("Server Help")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
Text("Installation guides")
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.55))
}
}
Text("OPERATING SYSTEM")
.font(.system(size: 10, weight: .bold))
.tracking(1.3)
.foregroundStyle(.white.opacity(0.5))
Picker("Operating System", selection: $system) {
ForEach(OrionHelpLibrary.systems, id: \.self) { name in
Text(name).tag(name)
}
}
.padding(.horizontal, 12)
.padding(.top, 12)
.labelsHidden()
.pickerStyle(.menu)
.frame(maxWidth: .infinity)
List(selection: $selectedGuide) {
ForEach(filtered) { guide in
Label(guide.title, systemImage: icon(for: guide.category))
.tag(guide.id)
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.white.opacity(0.5))
TextField("Search components", text: $search)
.textFieldStyle(.plain)
.foregroundStyle(.white)
}
.padding(10)
.background(
.white.opacity(0.07),
in: RoundedRectangle(cornerRadius: 8)
)
}
.padding(18)
Rectangle()
.fill(.white.opacity(0.08))
.frame(height: 1)
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
ForEach(categories, id: \.self) { category in
VStack(alignment: .leading, spacing: 5) {
Text(category.uppercased())
.font(.system(size: 10, weight: .bold))
.tracking(1.2)
.foregroundStyle(.white.opacity(0.42))
.padding(.horizontal, 13)
.padding(.bottom, 4)
ForEach(filtered.filter { $0.category == category }) { guide in
sidebarRow(guide)
}
}
}
}
.listStyle(.sidebar)
.searchable(text: $search, prompt: "Find components")
.padding(12)
}
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
} detail: {
HStack(spacing: 7) {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(success)
Text("Commands are never executed automatically")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.55))
}
.padding(15)
}
.background(sidebar)
}
private func sidebarRow(_ guide: OrionHelpGuide) -> some View {
let isSelected = selectedGuide == guide.id
return Button {
selectedGuide = guide.id
} label: {
HStack(spacing: 11) {
Image(systemName: icon(for: guide.category))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(isSelected ? .white : categoryColor(guide.category))
.frame(width: 23)
Text(guide.title)
.font(.system(size: 13, weight: isSelected ? .semibold : .medium))
.foregroundStyle(isSelected ? .white : .white.opacity(0.72))
Spacer()
if isSelected {
Image(systemName: "chevron.right")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white.opacity(0.8))
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
isSelected ? accent : Color.clear,
in: RoundedRectangle(cornerRadius: 8)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
private var detailView: some View {
Group {
if let guide = selected {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
Text(guide.category.uppercased())
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 22) {
HStack(spacing: 15) {
Image(systemName: icon(for: guide.category))
.font(.system(size: 25, weight: .semibold))
.foregroundStyle(categoryColor(guide.category))
.frame(width: 58, height: 58)
.background(
categoryColor(guide.category).opacity(0.14),
in: RoundedRectangle(cornerRadius: 14)
)
Text(guide.title)
.font(.largeTitle.bold())
VStack(alignment: .leading, spacing: 5) {
Text(guide.category.uppercased())
.font(.system(size: 10, weight: .bold))
.tracking(1.5)
.foregroundStyle(accent)
Text(guide.description)
.foregroundStyle(.secondary)
Text(guide.title)
.font(.system(size: 27, weight: .bold))
.foregroundStyle(.white)
Text(guide.description)
.font(.system(size: 13))
.foregroundStyle(.white.opacity(0.62))
}
}
HStack(spacing: 8) {
Image(systemName: "desktopcomputer")
.foregroundStyle(accent)
Text(system)
.fontWeight(.semibold)
Text("Installation Guide")
.foregroundStyle(.white.opacity(0.5))
}
.font(.system(size: 12))
.padding(.horizontal, 13)
.padding(.vertical, 9)
.background(
accent.opacity(0.12),
in: Capsule()
)
commandSection(
"Installation",
subtitle: "Install and configure the component",
icon: "terminal.fill",
tint: accent,
text: guide.commands
)
commandSection(
"Verification",
subtitle: "Confirm the installation is working",
icon: "checkmark.circle.fill",
tint: success,
text: guide.verification
)
if !guide.notes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Important Notes", systemImage: "info.circle")
.font(.headline)
HStack(alignment: .top, spacing: 12) {
Image(systemName: "info.circle.fill")
.foregroundStyle(.orange)
.font(.system(size: 18))
Text(guide.notes)
.fixedSize(horizontal: false, vertical: true)
VStack(alignment: .leading, spacing: 7) {
Text("Important Notes")
.font(.system(size: 14, weight: .bold))
Text(guide.notes)
.font(.system(size: 12))
.foregroundStyle(.white.opacity(0.7))
.fixedSize(horizontal: false, vertical: true)
}
}
.padding()
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.4),
in: RoundedRectangle(cornerRadius: 10))
.background(
Color.orange.opacity(0.09),
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(.orange.opacity(0.18))
)
}
Text("Review commands before running them. Package names and service configuration may differ between releases.")
.font(.caption)
.foregroundStyle(.secondary)
Label(
"Review commands before running them. Package names and service configuration can vary between releases.",
systemImage: "shield.lefthalf.filled"
)
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.45))
}
.padding(24)
.padding(28)
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle(guide.title)
} else {
ContentUnavailableView(
"Select a Component",
systemImage: "questionmark.circle"
systemImage: "books.vertical"
)
}
}
.frame(minWidth: 850, minHeight: 580)
}
private func commandSection(_ title: String, text: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(title)
.font(.headline)
private func commandSection(
_ title: String,
subtitle: String,
icon: String,
tint: Color,
text: String
) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 15))
.foregroundStyle(tint)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 15, weight: .bold))
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
@@ -855,29 +1062,57 @@ struct OrionHelpView: View {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
} label: {
Label("Copy Commands", systemImage: "doc.on.doc")
Label("Copy", systemImage: "doc.on.doc")
.font(.system(size: 12, weight: .semibold))
}
.buttonStyle(.bordered)
.tint(tint)
}
Text(text)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.white.opacity(0.88))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(Color(nsColor: .textBackgroundColor),
in: RoundedRectangle(cornerRadius: 9))
.padding(17)
.background(
Color(red: 0.055, green: 0.075, blue: 0.115),
in: RoundedRectangle(cornerRadius: 10)
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(.white.opacity(0.07))
)
}
.padding(17)
.background(
card,
in: RoundedRectangle(cornerRadius: 13)
)
}
private func icon(for category: String) -> String {
switch category {
case "Web Servers": return "globe"
case "PHP": return "chevron.left.forwardslash.chevron.right"
case "Databases": return "externaldrive"
case "Databases": return "externaldrive.fill"
case "Caching": return "memorychip"
case "Email Testing": return "envelope"
case "Administration": return "gearshape"
default: return "hammer"
case "Email Testing": return "envelope.fill"
case "Administration": return "gearshape.fill"
default: return "hammer.fill"
}
}
private func categoryColor(_ category: String) -> Color {
switch category {
case "Web Servers": return .cyan
case "PHP": return .purple
case "Databases": return .orange
case "Caching": return .pink
case "Email Testing": return .mint
case "Administration": return .yellow
default: return .blue
}
}
}
@@ -0,0 +1,883 @@
import AppKit
import SwiftUI
private enum OrionStyle {
static let panel = Color(red: 0.105, green: 0.14, blue: 0.20)
static let card = Color(red: 0.15, green: 0.19, blue: 0.25)
static let blue = Color(red: 0.08, green: 0.39, blue: 0.91)
static let green = Color(red: 0.05, green: 0.88, blue: 0.49)
static let red = Color(red: 0.91, green: 0.19, blue: 0.23)
static let muted = Color(red: 0.62, green: 0.73, blue: 0.86)
}
struct OrionMenuView: View {
@Environment(\.openWindow) private var openWindow
@EnvironmentObject private var vm: OrionVMController
@State private var showRunning = true
@State private var showAll = true
@State private var showIntegrated = true
@State private var pendingRemoval: ServerDefinition?
@State private var pendingForceStop: ParallelsVM?
private var running: [ParallelsVM] {
vm.visibleVMs.filter { $0.status.lowercased() == "running" }
}
var body: some View {
VStack(spacing: 0) {
header
Divider().overlay(.white.opacity(0.10))
ScrollView {
VStack(alignment: .leading, spacing: 15) {
section(icon: "play.fill", title: "Running Virtual Machines", count: "\(running.count) running", accent: OrionStyle.green, expanded: $showRunning) {
if running.isEmpty {
emptyState("No running virtual machines", icon: "moon.zzz")
} else {
ForEach(running) { machine in
runningRow(machine)
}
}
}
section(icon: "desktopcomputer", title: "All Virtual Machines", count: "\(vm.visibleVMs.count) VMs", accent: .cyan, expanded: $showAll) {
if vm.visibleVMs.isEmpty {
emptyState(vm.discovering ? "Discovering Parallels VMs…" : "No virtual machines found", icon: "desktopcomputer.trianglebadge.exclamationmark")
} else {
ForEach(vm.visibleVMs) { machine in
machineRow(machine)
}
}
}
section(icon: "server.rack", title: "Integrated Servers", count: "\(vm.servers.count) servers", accent: OrionStyle.blue, expanded: $showIntegrated) {
if vm.servers.isEmpty {
emptyState("No integrations. Add a VM using + above.", icon: "plus.circle")
} else {
ForEach(vm.servers.filter { server in
!vm.hiddenVMIDs.contains(server.vmUUID ??
vm.discoveredVMs.first(where: { $0.name == server.vmName })?.id ?? "")
}) { server in
integratedRow(server)
}
}
}
}
.padding(14)
}
.frame(height: 610)
Divider().overlay(.white.opacity(0.10))
footer
}
.frame(width: 960)
.background(
LinearGradient(colors: [Color(red: 0.10, green: 0.15, blue: 0.23), Color(red: 0.075, green: 0.09, blue: 0.13)], startPoint: .topTrailing, endPoint: .bottomLeading)
)
.preferredColorScheme(.dark)
.task {
await vm.discoverVMs()
await vm.refreshAll()
while !Task.isCancelled {
await vm.refreshServiceAvailability()
try? await Task.sleep(for: .seconds(10))
}
}
.alert("OrionToolbar", isPresented: Binding(get: { vm.lastError != nil }, set: { if !$0 { vm.lastError = nil } })) {
Button("OK") { vm.lastError = nil }
} message: { Text(vm.lastError ?? "") }
.confirmationDialog("Remove server integration?", isPresented: Binding(get: { pendingRemoval != nil }, set: { if !$0 { pendingRemoval = nil } }), titleVisibility: .visible) {
Button("Remove Integration", role: .destructive) {
if let server = pendingRemoval { vm.removeDefinition(server) }
pendingRemoval = nil
}
Button("Cancel", role: .cancel) { pendingRemoval = nil }
} message: {
Text("The Parallels virtual machine will not be deleted.")
}
.confirmationDialog("Force stop virtual machine?", isPresented: Binding(get: { pendingForceStop != nil }, set: { if !$0 { pendingForceStop = nil } }), titleVisibility: .visible) {
Button("Force Stop", role: .destructive) {
if let machine = pendingForceStop { vm.operate(machine, action: "force-stop") }
pendingForceStop = nil
}
Button("Cancel", role: .cancel) { pendingForceStop = nil }
} message: { Text("Force Stop immediately powers off the VM and can cause data loss.") }
}
private var header: some View {
HStack(spacing: 12) {
Image(systemName: "server.rack")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.cyan)
.frame(width: 37, height: 37)
.background(OrionStyle.blue.opacity(0.24), in: RoundedRectangle(cornerRadius: 10))
Text("OrionToolbar").font(.system(size: 21, weight: .bold))
Circle().fill(vm.discoveredVMs.isEmpty ? Color.orange : OrionStyle.green).frame(width: 8, height: 8)
Text(vm.discoveredVMs.isEmpty ? "Waiting for Parallels" : "Parallels Connected")
.foregroundStyle(OrionStyle.muted).font(.subheadline)
Spacer()
Menu {
if vm.hiddenVMs.isEmpty {
Text("No removed virtual machines")
} else {
ForEach(vm.hiddenVMs) { machine in
Button("Re-add " + machine.name) { vm.restoreVM(machine) }
}
}
} label: {
Label("Re-add VM", systemImage: "plus.rectangle.on.folder")
}
.help("Restore virtual machines removed from OrionToolbar")
if vm.discovering { ProgressView().controlSize(.small) }
Button { Task { await vm.discoverVMs(); await vm.refreshAll() } } label: {
Image(systemName: "arrow.clockwise").font(.system(size: 16, weight: .semibold))
}.buttonStyle(OrionActionStyle(tint: OrionStyle.card)).help("Refresh virtual machines")
Button { showSettings() } label: {
Image(systemName: "gearshape.fill").font(.system(size: 16))
}.buttonStyle(OrionActionStyle(tint: OrionStyle.card)).help("Settings")
}.padding(.horizontal, 17).padding(.vertical, 13)
}
private func section<Content: View>(icon: String, title: String, count: String, accent: Color, expanded: Binding<Bool>, @ViewBuilder content: () -> Content) -> some View {
VStack(spacing: 4) {
Button { expanded.wrappedValue.toggle() } label: {
HStack(spacing: 10) {
Image(systemName: icon).font(.system(size: 17, weight: .bold))
.foregroundStyle(accent).frame(width: 27)
Text(title).font(.system(size: 16, weight: .semibold))
Spacer()
Text(count).font(.caption.weight(.medium))
.foregroundStyle(accent)
.padding(.horizontal, 10).padding(.vertical, 5)
.background(accent.opacity(0.13), in: RoundedRectangle(cornerRadius: 8))
Image(systemName: expanded.wrappedValue ? "chevron.up" : "chevron.down")
.font(.caption.weight(.bold)).foregroundStyle(OrionStyle.muted)
}
.contentShape(Rectangle())
.padding(.horizontal, 11).padding(.vertical, 10)
}.buttonStyle(.plain)
if expanded.wrappedValue {
VStack(spacing: 3) { content() }
.padding(.horizontal, 5).padding(.bottom, 7)
}
}
.background(OrionStyle.panel.opacity(0.92), in: RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(.white.opacity(0.09)))
}
private func emptyState(_ title: String, icon: String) -> some View {
Label(title, systemImage: icon)
.font(.subheadline).foregroundStyle(OrionStyle.muted)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(15)
}
private func icon(_ machine: ParallelsVM) -> some View {
Group {
if let image = vm.icon(for: machine) {
Image(nsImage: image).resizable().scaledToFit()
} else {
Image(systemName: machine.operatingSystem == "Windows" ? "desktopcomputer" : "server.rack")
.font(.system(size: 21, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(osColor(machine.operatingSystem), in: Circle())
}
}.frame(width: 36, height: 36)
}
private func osColor(_ os: String) -> Color {
switch os {
case "Ubuntu": return .orange
case "Debian": return .pink
case "Fedora": return .blue
case "CentOS": return .purple
case "Alpine": return .teal
case "Windows": return .cyan
default: return OrionStyle.blue
}
}
private func state(_ status: String) -> some View {
let s = status.lowercased()
let color: Color = s == "running" ? OrionStyle.green : (s == "stopped" ? OrionStyle.muted : .orange)
return HStack(spacing: 6) {
Circle().fill(color).frame(width: 8, height: 8)
Text(status.capitalized).font(.system(size: 12, weight: .medium))
.foregroundStyle(color)
}.frame(width: 90, alignment: .leading)
}
private func integrated(_ machine: ParallelsVM) -> ServerDefinition? {
vm.servers.first { $0.vmUUID == machine.id || $0.vmName == machine.name }
}
// Native text selection supports Command-C; the copy button works in menu-bar panels.
private func vmIdentity(name: String, ip: String, os: String, fallbackIP: String = "") -> some View {
let address = ip.isEmpty ? fallbackIP : ip
return VStack(alignment: .leading, spacing: 5) {
Text(name)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(1)
HStack(spacing: 6) {
Image(systemName: "network")
.font(.system(size: 10))
.foregroundStyle(OrionStyle.muted)
Text(address.isEmpty ? "No IP assigned" : address)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(OrionStyle.muted)
.textSelection(.enabled)
.lineLimit(1)
if !address.isEmpty {
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(address, forType: .string)
} label: {
Image(systemName: "doc.on.doc")
.font(.system(size: 10))
}
.buttonStyle(.plain)
.foregroundStyle(OrionStyle.muted)
.help("Copy IP address: " + address)
}
if !os.isEmpty {
Text("· " + os)
.font(.system(size: 10))
.foregroundStyle(OrionStyle.muted.opacity(0.75))
.lineLimit(1)
}
}
}
}
private func runningRow(_ machine: ParallelsVM) -> some View {
HStack(spacing: 10) {
icon(machine)
vmIdentity(name: machine.name, ip: machine.ipAddress, os: machine.operatingSystem)
.frame(width: 265, alignment: .leading)
state(machine.status)
Spacer(minLength: 0)
if let server = integrated(machine) {
if !server.host.isEmpty && !server.sshUser.isEmpty && vm.reachableSSH.contains(server.id) {
action("SSH", "terminal", tint: OrionStyle.blue) { vm.openSSH(server) }
}
if !server.host.isEmpty && (server.mailpitPort.map { (1...65535).contains($0) } ?? false) {
action("Mailpit", "globe", tint: OrionStyle.blue) { vm.openMailpit(server) }
}
}
if vm.busyVMIDs.contains(machine.id) {
ProgressView().controlSize(.small)
.frame(width: 70)
.help("Waiting for Parallels to complete the operation")
} else {
action("Restart", "arrow.clockwise", tint: OrionStyle.card) { vm.operate(machine, action: "restart") }
action("Stop", "stop.fill", tint: OrionStyle.red) { vm.operate(machine, action: "stop") }
.padding(.leading, 6)
}
Menu {
Button("Force Stop", role: .destructive) { pendingForceStop = machine }
} label: {
Image(systemName: "ellipsis").frame(width: 22)
}.menuStyle(.borderlessButton).frame(width: 30)
.padding(.leading, 12)
.help("More VM actions")
}
.disabled(vm.busyVMIDs.contains(machine.id))
.padding(.horizontal, 10).padding(.vertical, 7)
.background(OrionStyle.card.opacity(0.75), in: RoundedRectangle(cornerRadius: 10))
}
private func machineRow(_ machine: ParallelsVM) -> some View {
HStack(spacing: 10) {
icon(machine)
HStack(spacing: 6) {
Image(systemName: "line.3.horizontal")
.foregroundStyle(OrionStyle.muted)
vmIdentity(name: machine.name, ip: machine.ipAddress, os: machine.operatingSystem)
}
.frame(width: 280, alignment: .leading)
.contentShape(Rectangle())
.onDrag { NSItemProvider(object: ("vm:" + machine.id) as NSString) }
state(machine.status)
Spacer(minLength: 2)
if vm.busyVMIDs.contains(machine.id) { ProgressView().controlSize(.small) }
else if machine.status.lowercased() == "running" {
action("Stop", "stop.fill", tint: OrionStyle.red) { vm.operate(machine, action: "stop") }
} else if ["suspended", "paused"].contains(machine.status.lowercased()) {
action("Resume", "play.fill", tint: OrionStyle.blue) { vm.operate(machine, action: "resume") }
} else {
action("Start", "play.fill", tint: OrionStyle.blue) { vm.operate(machine, action: "start") }
}
Button {
vm.hideVM(machine)
} label: {
Image(systemName: "minus.circle")
.foregroundStyle(OrionStyle.muted)
}
.buttonStyle(.plain)
.help("Remove from OrionToolbar (keeps the Parallels VM)")
if vm.isIntegrated(machine) {
Image(systemName: "checkmark.circle.fill").foregroundStyle(OrionStyle.green)
.frame(width: 28).help("Integrated")
} else {
Button { vm.integrate(machine) } label: {
Image(systemName: "plus.circle").font(.system(size: 20))
}.buttonStyle(.plain).foregroundStyle(OrionStyle.muted)
.frame(width: 28).help("Integrate VM")
}
}
.padding(.horizontal, 10).padding(.vertical, 7)
.background(OrionStyle.card.opacity(0.75), in: RoundedRectangle(cornerRadius: 10))
.contextMenu {
Button("Remove from OrionToolbar", systemImage: "minus.circle") {
vm.hideVM(machine)
}
}
.onDrop(of: [.plainText], isTargeted: nil) { providers in
guard let provider = providers.first else { return false }
_ = provider.loadObject(ofClass: NSString.self) { item, _ in
guard let string = item as? NSString else { return }
let value = string as String
guard value.hasPrefix("vm:") else { return }
Task { @MainActor in
vm.reorderVM(draggedID: String(value.dropFirst(3)), onto: machine.id)
}
}
return true
}
}
private func integratedRow(_ server: ServerDefinition) -> some View {
let machine = vm.machine(for: server)
let status = machine?.status ?? "unavailable"
return HStack(spacing: 10) {
Group {
if let image = vm.icon(for: server) {
Image(nsImage: image).resizable().scaledToFit()
} else if let machine {
icon(machine)
} else {
Image(systemName: "server.rack").foregroundStyle(.cyan)
}
}.frame(width: 36, height: 36)
HStack(spacing: 5) {
Image(systemName: "line.3.horizontal").foregroundStyle(OrionStyle.muted)
vmIdentity(name: server.name,
ip: machine?.ipAddress ?? "",
os: machine?.operatingSystem ?? "",
fallbackIP: server.host)
}
.frame(width: 280, alignment: .leading)
.onDrag {
NSItemProvider(object: ("server:" + server.id.uuidString) as NSString)
}
state(status)
Spacer(minLength: 0)
if let machine {
if status.lowercased() == "running" {
action("Stop", "stop.fill", tint: OrionStyle.red) { vm.operate(machine, action: "stop") }
} else {
action("Start", "play.fill", tint: OrionStyle.blue) { vm.operate(machine, action: "start") }
}
}
if !server.host.isEmpty && !server.sshUser.isEmpty && vm.reachableSSH.contains(server.id) {
action("SSH", "terminal", tint: OrionStyle.card) { vm.openSSH(server) }
}
if !server.host.isEmpty && (server.mailpitPort.map { (1...65535).contains($0) } ?? false) {
action("Mailpit", "globe", tint: OrionStyle.card) { vm.openMailpit(server) }
}
action("Remove", "trash", tint: OrionStyle.card) { pendingRemoval = server }
}
.disabled(machine.map { vm.busyVMIDs.contains($0.id) } ?? false)
.padding(.horizontal, 10).padding(.vertical, 7)
.background(OrionStyle.card.opacity(0.75), in: RoundedRectangle(cornerRadius: 10))
.onDrop(of: [.plainText], isTargeted: nil) { providers in
guard let provider = providers.first else { return false }
_ = provider.loadObject(ofClass: NSString.self) { item, _ in
guard let string = item as? NSString else { return }
let value = string as String
guard value.hasPrefix("server:"),
let dragged = UUID(uuidString: String(value.dropFirst(7))) else { return }
Task { @MainActor in
vm.reorderServer(draggedID: dragged, onto: server.id)
}
}
return true
}
}
private func action(_ title: String, _ symbol: String, tint: Color, perform: @escaping () -> Void) -> some View {
Button(action: perform) {
Label(title, systemImage: symbol)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
.frame(minWidth: 48)
}.buttonStyle(OrionActionStyle(tint: tint))
}
private var footer: some View {
HStack {
action("Open Parallels", "macwindow", tint: OrionStyle.card) { vm.openParallels() }
Spacer()
action("Help", "questionmark.circle", tint: OrionStyle.card) {
// Capture the menu-bar panel before opening Help.
let menuPanel = NSApp.keyWindow
openWindow(id: "orion-help")
// Dismiss the menu-bar panel so it cannot cover Help.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
if let menuPanel,
menuPanel.title != "OrionToolbar Help" {
menuPanel.orderOut(nil)
}
NSApp.activate(ignoringOtherApps: true)
if let helpWindow = NSApp.windows.first(where: {
$0.title == "OrionToolbar Help"
}) {
helpWindow.level = .normal
helpWindow.makeKeyAndOrderFront(nil)
helpWindow.orderFrontRegardless()
}
}
}
action("Settings…", "gearshape", tint: OrionStyle.card) { showSettings() }
action("Quit", "power", tint: OrionStyle.card) { NSApplication.shared.terminate(nil) }
}.padding(.horizontal, 17).padding(.vertical, 12)
}
private func showSettings() {
OrionSettingsWindowPresenter.present(vm: vm)
}
}
private struct OrionActionStyle: ButtonStyle {
let tint: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.padding(.horizontal, 8).padding(.vertical, 7)
.background(tint.opacity(configuration.isPressed ? 0.65 : 1), in: RoundedRectangle(cornerRadius: 8))
.opacity(configuration.isPressed ? 0.8 : 1)
}
}
// Keep Settings above its originating MenuBarExtra panel.
@MainActor
private final class OrionSettingsWindowPresenter: NSObject, NSWindowDelegate {
private static var current: OrionSettingsWindowPresenter?
private var window: NSWindow?
static func present(vm: OrionVMController) {
if let current, let window = current.window {
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
return
}
let presenter = OrionSettingsWindowPresenter()
current = presenter
presenter.open(vm: vm)
}
private func open(vm: OrionVMController) {
let content = SettingsView()
.environmentObject(vm)
.preferredColorScheme(.dark)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 650, height: 380),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "OrionToolbar Settings"
window.contentViewController = NSHostingController(rootView: content)
window.delegate = self
window.isReleasedWhenClosed = false
window.center()
self.window = window
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
}
func windowWillClose(_ notification: Notification) {
window = nil
Self.current = nil
}
}
// MARK: - Operating System Installation Help
private struct OrionHelpGuide: Identifiable {
let id: String
let title: String
let category: String
let description: String
let commands: String
let verification: String
let notes: String
}
private enum OrionHelpLibrary {
static let systems = [
"Ubuntu",
"Debian",
"Fedora",
"CentOS Stream",
"Alpine"
]
static func guides(for system: String) -> [OrionHelpGuide] {
let apt = system == "Ubuntu" || system == "Debian"
let dnf = system == "Fedora" || system == "CentOS Stream"
let alpine = system == "Alpine"
let update = apt
? "sudo apt update"
: dnf
? "sudo dnf makecache"
: "sudo apk update"
func packages(_ names: String) -> String {
if apt { return "sudo apt install -y \(names)" }
if dnf { return "sudo dnf install -y \(names)" }
return "sudo apk add \(names)"
}
func guide(
_ id: String,
_ title: String,
_ category: String,
_ description: String,
_ commands: String,
_ verification: String,
_ notes: String = ""
) -> OrionHelpGuide {
OrionHelpGuide(
id: id,
title: title,
category: category,
description: description,
commands: commands,
verification: verification,
notes: notes
)
}
let nginx = guide(
"nginx", "Nginx", "Web Servers",
"Install and enable the Nginx web server.",
"""
\(update)
\(packages("nginx"))
sudo systemctl enable --now nginx
""",
"systemctl status nginx --no-pager\ncurl -I http://127.0.0.1",
"On Alpine with OpenRC, use: sudo rc-update add nginx default; sudo rc-service nginx start."
)
let phpPackages = apt
? "php-cli php-fpm php-mysql php-pgsql php-xml php-mbstring php-curl php-zip php-gd php-intl php-bcmath"
: dnf
? "php-cli php-fpm php-mysqlnd php-pgsql php-xml php-mbstring php-curl php-zip php-gd php-intl php-bcmath"
: "php php-fpm php-mysqli php-pgsql php-xml php-mbstring php-curl php-zip php-gd php-intl"
let php = guide(
"php", "PHP and PHP-FPM", "PHP",
"Install the distribution's default PHP version and commonly used extensions.",
"""
\(update)
\(packages(phpPackages))
php -v
php -m
""",
"php -v\nphp -m\nsystemctl list-unit-files '*php*fpm*'",
"PHP versions and PHP-FPM service names vary by distribution. CentOS Stream may require a third-party repository for PHP 8.5. Alpine uses OpenRC rather than systemd."
)
let composer = guide(
"composer", "Composer", "PHP",
"Install the Composer dependency manager.",
"""
\(update)
\(packages("composer"))
""",
"composer --version",
"Distribution packages may provide an older Composer version."
)
let database = guide(
"mariadb", "MariaDB", "Databases",
"Install a MariaDB database server.",
"""
\(update)
\(packages(alpine ? "mariadb mariadb-client" : "mariadb-server"))
\(alpine ? "# Initialise the data directory according to your Alpine release." : "sudo systemctl enable --now mariadb")
""",
"mariadb --version",
"For Alpine, initialise the database and enable the mariadb OpenRC service before use. Secure database accounts and avoid exposing port 3306 publicly."
)
let postgres = guide(
"postgresql", "PostgreSQL", "Databases",
"Install PostgreSQL using the distribution repository.",
"""
\(update)
\(packages(alpine ? "postgresql postgresql-client" : "postgresql postgresql-server"))
""",
"psql --version",
"PostgreSQL cluster initialization and service names differ across distributions. Fedora/CentOS commonly require postgresql-setup --initdb before starting the service."
)
let cachePackage = system == "Fedora" || system == "CentOS Stream"
? "valkey"
: "redis"
let cache = guide(
"cache", "Redis / Valkey", "Caching",
"Install the distribution's Redis-compatible cache server.",
"""
\(update)
\(packages(cachePackage))
\(alpine ? "# Enable and start the appropriate OpenRC service." : "sudo systemctl enable --now \(cachePackage)")
""",
"\(cachePackage == "valkey" ? "valkey-cli" : "redis-cli") ping",
"Keep the cache bound to localhost or a private interface. Do not expose it publicly without appropriate security controls."
)
let mailpit = guide(
"mailpit", "Mailpit", "Email Testing",
"Mailpit provides a local SMTP test server and a browser-based inbox.",
"""
# Install Mailpit from its official release or package instructions.
# Verify the binary after installation:
mailpit --version
# Example foreground test:
mailpit --listen 127.0.0.1:8025 --smtp 127.0.0.1:1025
""",
"curl -I http://127.0.0.1:8025\nss -lntp | grep -E '8025|1025'",
"Web interface: 8025. SMTP: 1025. For OrionToolbar access from macOS, bind the web listener to the VM's private interface and restrict access using the firewall. Do not expose an unauthenticated Mailpit inbox publicly."
)
let cockpit = guide(
"cockpit", "Cockpit", "Administration",
"Install the browser-based Linux administration console.",
"""
\(update)
\(packages("cockpit"))
\(alpine ? "# Configure the Cockpit service if supported by this Alpine release." : "sudo systemctl enable --now cockpit.socket")
""",
"ss -lntp | grep 9090",
"Cockpit typically uses HTTPS port 9090. Package and service availability vary on Alpine."
)
let ssh = guide(
"ssh", "OpenSSH Server", "Administration",
"Install an SSH server for remote administration.",
"""
\(update)
\(packages(alpine ? "openssh" : "openssh-server"))
\(alpine ? "sudo rc-update add sshd default\nsudo rc-service sshd start" : "sudo systemctl enable --now sshd 2>/dev/null || sudo systemctl enable --now ssh")
""",
"ss -lntp | grep ':22'",
"Use SSH keys where possible and restrict access through the firewall."
)
let git = guide(
"git", "Git", "Development",
"Install Git for source control.",
"""
\(update)
\(packages("git"))
""",
"git --version"
)
let node = guide(
"node", "Node.js and npm", "Development",
"Install the distribution's Node.js runtime and npm.",
"""
\(update)
\(packages("nodejs npm"))
""",
"node --version\nnpm --version",
"For specific Node.js versions, use a supported version manager or official distribution packages."
)
let tools = guide(
"tools", "Build Tools", "Development",
"Install compilers and common build utilities.",
"""
\(update)
\(packages(apt ? "build-essential curl wget unzip" : dnf ? "gcc gcc-c++ make curl wget unzip" : "build-base curl wget unzip"))
""",
"gcc --version\nmake --version"
)
let apache = guide(
"apache", "Apache HTTP Server", "Web Servers",
"Install Apache as an alternative to Nginx.",
"""
\(update)
\(packages(apt ? "apache2" : dnf ? "httpd" : "apache2"))
\(alpine ? "sudo rc-update add apache2 default\nsudo rc-service apache2 start" : "sudo systemctl enable --now \(apt ? "apache2" : "httpd")")
""",
"curl -I http://127.0.0.1"
)
return [
nginx, apache, php, composer,
database, postgres, cache, mailpit,
cockpit, ssh, git, node, tools
]
}
}
struct OrionHelpView: View {
@State private var system = "Fedora"
@State private var selectedGuide = "mailpit"
@State private var search = ""
private var guides: [OrionHelpGuide] {
OrionHelpLibrary.guides(for: system)
}
private var filtered: [OrionHelpGuide] {
guard !search.isEmpty else { return guides }
return guides.filter {
$0.title.localizedCaseInsensitiveContains(search) ||
$0.category.localizedCaseInsensitiveContains(search) ||
$0.description.localizedCaseInsensitiveContains(search)
}
}
private var selected: OrionHelpGuide? {
guides.first { $0.id == selectedGuide }
}
var body: some View {
NavigationSplitView {
VStack(spacing: 10) {
Picker("Operating System", selection: $system) {
ForEach(OrionHelpLibrary.systems, id: \.self) { name in
Text(name).tag(name)
}
}
.padding(.horizontal, 12)
.padding(.top, 12)
List(selection: $selectedGuide) {
ForEach(filtered) { guide in
Label(guide.title, systemImage: icon(for: guide.category))
.tag(guide.id)
}
}
.listStyle(.sidebar)
.searchable(text: $search, prompt: "Find components")
}
.navigationSplitViewColumnWidth(min: 220, ideal: 250)
} detail: {
if let guide = selected {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
Text(guide.category.uppercased())
.font(.caption)
.foregroundStyle(.secondary)
Text(guide.title)
.font(.largeTitle.bold())
Text(guide.description)
.foregroundStyle(.secondary)
}
commandSection(
"Installation",
text: guide.commands
)
commandSection(
"Verification",
text: guide.verification
)
if !guide.notes.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Label("Important Notes", systemImage: "info.circle")
.font(.headline)
Text(guide.notes)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.4),
in: RoundedRectangle(cornerRadius: 10))
}
Text("Review commands before running them. Package names and service configuration may differ between releases.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle(guide.title)
} else {
ContentUnavailableView(
"Select a Component",
systemImage: "questionmark.circle"
)
}
}
.frame(minWidth: 850, minHeight: 580)
}
private func commandSection(_ title: String, text: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(title)
.font(.headline)
Spacer()
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
} label: {
Label("Copy Commands", systemImage: "doc.on.doc")
}
}
Text(text)
.font(.system(size: 12, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(Color(nsColor: .textBackgroundColor),
in: RoundedRectangle(cornerRadius: 9))
}
}
private func icon(for category: String) -> String {
switch category {
case "Web Servers": return "globe"
case "PHP": return "chevron.left.forwardslash.chevron.right"
case "Databases": return "externaldrive"
case "Caching": return "memorychip"
case "Email Testing": return "envelope"
case "Administration": return "gearshape"
default: return "hammer"
}
}
}