diff --git a/OrionToolbar/OrionMenuView.swift b/OrionToolbar/OrionMenuView.swift index 30b33c9..736e188 100644 --- a/OrionToolbar/OrionMenuView.swift +++ b/OrionToolbar/OrionMenuView.swift @@ -309,8 +309,20 @@ struct OrionMenuView: View { } private func showSettings() { + // Activate before opening the Settings scene so it is not left behind + // the menu-bar popover or another application. + NSApp.activate(ignoringOtherApps: true) openSettings() - NSApplication.shared.activate(ignoringOtherApps: true) + // The Settings window is created asynchronously by SwiftUI. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { + $0.title.localizedCaseInsensitiveContains("settings") && + $0.canBecomeKey + }) { + window.makeKeyAndOrderFront(nil) + } + } } } diff --git a/OrionToolbar/OrionMenuView.swift.bak-20261023-094858 b/OrionToolbar/OrionMenuView.swift.bak-20261023-094858 new file mode 100644 index 0000000..30b33c9 --- /dev/null +++ b/OrionToolbar/OrionMenuView.swift.bak-20261023-094858 @@ -0,0 +1,326 @@ +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 { + @EnvironmentObject private var vm: OrionVMController + @Environment(\.openSettings) private var openSettings + @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.discoveredVMs.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.discoveredVMs.count) VMs", accent: .cyan, expanded: $showAll) { + if vm.discoveredVMs.isEmpty { + emptyState(vm.discovering ? "Discovering Parallels VMs…" : "No virtual machines found", icon: "desktopcomputer.trianglebadge.exclamationmark") + } else { + ForEach(vm.discoveredVMs) { 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) { 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() } + .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() + 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(icon: String, title: String, count: String, accent: Color, expanded: Binding, @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 } + } + + private func runningRow(_ machine: ParallelsVM) -> some View { + HStack(spacing: 10) { + icon(machine) + Text(machine.name).font(.system(size: 12, weight: .semibold)).lineLimit(1).frame(width: 210, alignment: .leading) + state(machine.status) + Text(machine.ipAddress.isEmpty ? "—" : machine.ipAddress) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(OrionStyle.muted).lineLimit(1).frame(width: 125, alignment: .leading) + Spacer(minLength: 0) + if let server = integrated(machine) { + action("SSH", "terminal", tint: OrionStyle.blue) { vm.openSSH(server) } + .disabled(server.host.isEmpty || server.sshUser.isEmpty) + action("Mailpit", "globe", tint: OrionStyle.blue) { vm.openMailpit(server) } + .disabled(server.host.isEmpty || server.mailpitPort == nil) + } + 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") } + } + Menu { + Button("Force Stop", role: .destructive) { pendingForceStop = machine } + } label: { + Image(systemName: "ellipsis").frame(width: 16) + }.menuStyle(.borderlessButton).frame(width: 22) + .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) + Text(machine.name).font(.system(size: 12, weight: .semibold)).lineLimit(1).frame(width: 230, alignment: .leading) + state(machine.status) + Text(machine.ipAddress.isEmpty ? "—" : machine.ipAddress) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(OrionStyle.muted).lineLimit(1) + 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") } + } + 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") + } + Menu { + Button("Open in Parallels") { vm.openParallels() } + if machine.status.lowercased() == "running" { + Button("Restart") { vm.operate(machine, action: "restart") } + Button("Force Stop", role: .destructive) { pendingForceStop = machine } + } + } label: { Image(systemName: "chevron.right").foregroundStyle(OrionStyle.muted) } + .menuStyle(.borderlessButton).frame(width: 22) + } + .padding(.horizontal, 10).padding(.vertical, 7) + .background(OrionStyle.card.opacity(0.75), in: RoundedRectangle(cornerRadius: 10)) + } + + private func integratedRow(_ server: ServerDefinition) -> some View { + let machine = vm.discoveredVMs.first { $0.id == server.vmUUID || $0.name == server.vmName } + let status = machine?.status ?? "unavailable" + return HStack(spacing: 10) { + if let machine { icon(machine) } + else { Image(systemName: "server.rack").frame(width: 36, height: 36).foregroundStyle(.cyan) } + Text(server.name).font(.system(size: 12, weight: .semibold)).lineLimit(1).frame(width: 185, alignment: .leading) + state(status) + Text(server.host.isEmpty ? "—" : server.host) + .font(.system(size: 11, design: .monospaced)).foregroundStyle(OrionStyle.muted) + .lineLimit(1).frame(width: 125, alignment: .leading) + 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") } + } + } + action("SSH", "terminal", tint: OrionStyle.card) { vm.openSSH(server) } + .disabled(status.lowercased() != "running" || server.host.isEmpty || server.sshUser.isEmpty) + action("Mailpit", "globe", tint: OrionStyle.card) { vm.openMailpit(server) } + .disabled(status.lowercased() != "running" || server.host.isEmpty || server.mailpitPort == nil) + 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)) + } + + 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("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() { + openSettings() + NSApplication.shared.activate(ignoringOtherApps: true) + } +} + +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) + } +} diff --git a/backups/.gitkeep b/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backups/multi-vm-20261023-070119/OrionMenuView.swift b/backups/multi-vm-20261023-070119/OrionMenuView.swift deleted file mode 100644 index 00168b9..0000000 --- a/backups/multi-vm-20261023-070119/OrionMenuView.swift +++ /dev/null @@ -1,134 +0,0 @@ -import SwiftUI - -struct OrionMenuView: View { - @EnvironmentObject var vm: OrionVMController - @Environment(\.openSettings) private var openSettings - - private var server: ServerDefinition { vm.servers.first ?? .orion } - private var status: OrionVMController.VMStatus { vm.status(for: server) } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - statusRow - separator - menuRow("terminal", "Open SSH in Terminal") { vm.openSSH(server) } - .disabled(status != .running) - menuRow("envelope.fill", "Open Mailpit", tint: .blue) { vm.openMailpit(server) } - .disabled(status != .running || server.mailpitPort == nil) - separator - if status == .stopped { - menuRow("play.fill", "Start", tint: .green) { vm.start(server) } - } else { - menuRow("stop.fill", "Stop", tint: .red) { vm.stop(server) } - .disabled(vm.isBusy(server)) - menuRow("arrow.clockwise", "Restart", tint: .orange) { vm.restart(server) } - .disabled(vm.isBusy(server) || status != .running) - menuRow("exclamationmark.triangle.fill", "Force Stop", tint: .orange) { vm.forceStop(server) } - .disabled(vm.isBusy(server)) - } - separator - menuRow("macwindow", "Open in Parallels Desktop", tint: .red) { vm.openParallels() } - separator - menuRow("gearshape.fill", "Settings…", tint: .secondary) { - openSettings() - NSApplication.shared.activate(ignoringOtherApps: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - NSApplication.shared.activate(ignoringOtherApps: true) - NSApplication.shared.windows - .filter { $0.isVisible } - .forEach { $0.makeKeyAndOrderFront(nil) } - } - } - separator - menuRow("xmark", "Quit OrionToolbar") { NSApplication.shared.terminate(nil) } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(width: 365) - .task { await vm.refreshStatus(server) } - } - - private var statusRow: some View { - Button { - Task { await vm.refreshStatus(server) } - } label: { - HStack(spacing: 12) { - Circle() - .fill(statusDotColor) - .frame(width: 12, height: 12) - - VStack(alignment: .leading, spacing: 3) { - Text(server.name) - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) - - HStack(spacing: 5) { - Text(status.title) - .foregroundStyle(.secondary) - Text("·") - .foregroundStyle(.secondary) - Text(reachabilityText) - .foregroundStyle(reachabilityColor) - } - .font(.system(size: 14)) - } - - Spacer() - if vm.isBusy(server) { - ProgressView().controlSize(.small) - } else { - Image(systemName: "chevron.right") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.secondary) - } - } - .contentShape(Rectangle()) - .frame(height: 62) - } - .buttonStyle(.plain) - } - - private var separator: some View { - Divider().padding(.vertical, 4) - } - - private func menuRow(_ icon: String, _ title: String, tint: Color = .primary, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 14) { - Image(systemName: icon) - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(tint) - .frame(width: 24, alignment: .center) - Text(title) - .font(.system(size: 16)) - .foregroundStyle(.primary) - Spacer() - } - .contentShape(Rectangle()) - .frame(height: 43) - } - .buttonStyle(.plain) - } - - private var statusDotColor: Color { - switch status { - case .running: .green - case .stopped, .unavailable: .red - case .suspended, .transitioning: .orange - case .checking: .gray - } - } - - private var reachabilityText: String { - switch status { - case .running: "Reachable" - case .checking: "Checking…" - case .transitioning: "Working…" - default: "Unavailable" - } - } - - private var reachabilityColor: Color { - status == .running ? .green : .secondary - } -} diff --git a/backups/multi-vm-20261023-070119/OrionVMController.swift b/backups/multi-vm-20261023-070119/OrionVMController.swift deleted file mode 100644 index 1b1b30e..0000000 --- a/backups/multi-vm-20261023-070119/OrionVMController.swift +++ /dev/null @@ -1,126 +0,0 @@ -import AppKit -import Foundation - -struct ServerDefinition: Identifiable, Codable, Hashable { - var id = UUID() - var name: String - var vmName: String - var host: String - var sshUser: String - var sshPort: Int = 22 - var sshIdentityFile: String = "" - var mailpitPort: Int? - - static let orion = ServerDefinition(name: "Orion", vmName: "Ubuntu Linux Server", host: "10.211.55.100", sshUser: "sysadmin", sshPort: 22, sshIdentityFile: "", mailpitPort: 8025) -} - -@MainActor -final class OrionVMController: ObservableObject { - enum VMStatus: Equatable { - case checking, running, stopped, suspended, transitioning, unavailable(String) - var title: String { switch self { case .checking: "Checking…"; case .running: "Running"; case .stopped: "Stopped"; case .suspended: "Suspended"; case .transitioning: "Working…"; case .unavailable: "Unavailable" } } - var symbolName: String { switch self { case .running: "server.rack"; case .stopped: "power"; case .suspended: "pause.circle"; case .transitioning, .checking: "arrow.triangle.2.circlepath"; case .unavailable: "exclamationmark.triangle" } } - } - - @Published var servers: [ServerDefinition] { didSet { saveServers() } } - @Published var statuses: [UUID: VMStatus] = [:] - @Published var busyServers: Set = [] - @Published var lastError: String? - private var timer: Timer? - private let defaultsKey = "serverDefinitions.v2" - - init() { - if let data = UserDefaults.standard.data(forKey: defaultsKey), let saved = try? JSONDecoder().decode([ServerDefinition].self, from: data), !saved.isEmpty { servers = saved } - else { servers = [.orion] } - Task { await refreshAll() } - timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in Task { @MainActor in await self?.refreshAll() } } - } - deinit { timer?.invalidate() } - - func status(for server: ServerDefinition) -> VMStatus { statuses[server.id] ?? .checking } - func isBusy(_ server: ServerDefinition) -> Bool { busyServers.contains(server.id) } - func add(_ server: ServerDefinition) { servers.append(server); Task { await refreshStatus(server) } } - func update(_ server: ServerDefinition) { if let i = servers.firstIndex(where: { $0.id == server.id }) { servers[i] = server; Task { await refreshStatus(server) } } } - func delete(at offsets: IndexSet) { for i in offsets { statuses[servers[i].id] = nil }; servers.remove(atOffsets: offsets) } - - func refreshAll() async { for server in servers { await refreshStatus(server) } } - func refreshStatus(_ server: ServerDefinition) async { - guard !isBusy(server) else { return } - do { - let output = try await runPrlctl(["status", server.vmName]); let value = output.lowercased() - if value.contains("running") { statuses[server.id] = .running } - else if value.contains("stopped") { statuses[server.id] = .stopped } - else if value.contains("suspended") || value.contains("paused") { statuses[server.id] = .suspended } - else { statuses[server.id] = .unavailable(output.trimmingCharacters(in: .whitespacesAndNewlines)) } - } catch { statuses[server.id] = .unavailable(error.localizedDescription) } - } - - func start(_ s: ServerDefinition) { perform(["start", s.vmName], server: s) } - func stop(_ s: ServerDefinition) { perform(["stop", s.vmName], server: s) } - func restart(_ s: ServerDefinition) { perform(["restart", s.vmName], server: s) } - func forceStop(_ s: ServerDefinition) { perform(["stop", s.vmName, "--kill"], server: s) } - - private func perform(_ arguments: [String], server: ServerDefinition) { - guard !isBusy(server) else { return }; busyServers.insert(server.id); statuses[server.id] = .transitioning; lastError = nil - Task { do { _ = try await runPrlctl(arguments) } catch { lastError = error.localizedDescription }; busyServers.remove(server.id); try? await Task.sleep(for: .seconds(1)); await refreshStatus(server) } - } - - func openSSH(_ s: ServerDefinition) { - let userHost = "\(s.sshUser)@\(s.host)" - var command = "/usr/bin/ssh -p \(s.sshPort)" - let identity = s.sshIdentityFile.trimmingCharacters(in: .whitespacesAndNewlines) - if !identity.isEmpty { - command += " -i \(shellQuote((identity as NSString).expandingTildeInPath))" - } - command += " \(shellQuote(userHost))" - - do { - // Terminal reliably opens executable .command files without requiring - // Apple Events/Automation permission from this menu-bar application. - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("OrionToolbar", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - - let safeName = s.name.replacingOccurrences(of: "/", with: "-") - let scriptURL = directory.appendingPathComponent("SSH-\(safeName).command") - let script = """ - #!/bin/zsh - clear - echo "Connecting to \(s.name) (\(userHost))..." - exec \(command) - """ - try script.write(to: scriptURL, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) - - guard let terminalURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Terminal") else { - lastError = "Terminal.app could not be found." - return - } - - let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = true - NSWorkspace.shared.open([scriptURL], withApplicationAt: terminalURL, configuration: configuration) { _, error in - Task { @MainActor in - if let error { self.lastError = "Could not open Terminal: \(error.localizedDescription)" } - } - } - } catch { - lastError = "Could not create SSH launcher: \(error.localizedDescription)" - } - } - - private func shellQuote(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - func openMailpit(_ s: ServerDefinition) { if let p = s.mailpitPort, let url = URL(string: "http://\(s.host):\(p)") { NSWorkspace.shared.open(url) } } - func openParallels() { NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: "/Applications/Parallels Desktop.app"), configuration: .init()) } - - private func saveServers() { if let data = try? JSONEncoder().encode(servers) { UserDefaults.standard.set(data, forKey: defaultsKey) } } - private func prlctlURL() -> URL? { ["/usr/local/bin/prlctl", "/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"].map(URL.init(fileURLWithPath:)).first { FileManager.default.isExecutableFile(atPath: $0.path) } } - private func runPrlctl(_ arguments: [String]) async throws -> String { - guard let executable = prlctlURL() else { throw OrionError.prlctlNotFound } - return try await Task.detached(priority: .userInitiated) { let p = Process(); let pipe = Pipe(); p.executableURL = executable; p.arguments = arguments; p.standardOutput = pipe; p.standardError = pipe; try p.run(); p.waitUntilExit(); let out = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self); guard p.terminationStatus == 0 else { throw OrionError.commandFailed(out) }; return out }.value - } -} - -enum OrionError: LocalizedError { case prlctlNotFound, commandFailed(String); var errorDescription: String? { switch self { case .prlctlNotFound: "Parallels prlctl was not found."; case .commandFailed(let o): o.trimmingCharacters(in: .whitespacesAndNewlines) } } } diff --git a/backups/oriontoolbar_settings_logs_patch-20261023-095023.py b/backups/oriontoolbar_settings_logs_patch-20261023-095023.py new file mode 100644 index 0000000..b9c41c5 --- /dev/null +++ b/backups/oriontoolbar_settings_logs_patch-20261023-095023.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +from pathlib import Path +from datetime import datetime +import shutil + +root = Path('/Volumes/Developer/Applications/OrionToolbar') +menu = root/'OrionToolbar/OrionMenuView.swift' +build = root/'scripts/build.sh' +test = root/'scripts/test.sh' +release = root/'scripts/release.sh' +for p in (menu,build,test,release): + if not p.is_file(): + raise SystemExit(f'Missing file: {p}') +changes = {} +s = menu.read_text() +old = ''' private func showSettings() { + openSettings() + NSApplication.shared.activate(ignoringOtherApps: true) + }''' +new = ''' private func showSettings() { + // Activate before opening the Settings scene so it is not left behind + // the menu-bar popover or another application. + NSApp.activate(ignoringOtherApps: true) + openSettings() + // The Settings window is created asynchronously by SwiftUI. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { + $0.title.localizedCaseInsensitiveContains("settings") && + $0.canBecomeKey + }) { + window.makeKeyAndOrderFront(nil) + } + } + }''' +if old not in s: + raise SystemExit('Settings function does not match expected source; no files changed.') +changes[menu]=s.replace(old,new,1) +s=build.read_text() +s=s.replace('mkdir -p "$ROOT/build-logs"','mkdir -p "$DERIVED/build-logs"').replace('LOG="$ROOT/build-logs/','LOG="$DERIVED/build-logs/') +if 'LOG="$DERIVED/build-logs/' not in s: + raise SystemExit('Build log path not recognized; no files changed.') +changes[build]=s +s=test.read_text() +s=s.replace('mkdir -p "$ROOT/build-logs"','mkdir -p "$ROOT/.build/build-logs"').replace('LOG="$ROOT/build-logs/','LOG="$ROOT/.build/build-logs/') +# Existing test script refers to a nonexistent capitalized Scripts folder. +s=s.replace('"$ROOT/Scripts/build.sh"','"$ROOT/scripts/build.sh"') +if 'LOG="$ROOT/.build/build-logs/' not in s: + raise SystemExit('Test log path not recognized; no files changed.') +changes[test]=s +s=release.read_text() +if 'LOG="$ROOT/.build/build-logs/' not in s: + raise SystemExit('Release log path not recognized; no files changed.') +changes[release]=s +stamp=datetime.now().strftime('%Y%m%d-%H%M%S') +for path,content in changes.items(): + if path.read_text()==content: + print('Unchanged:',path.relative_to(root));continue + backup=path.with_name(f'{path.name}.bak-{stamp}') + shutil.copy2(path,backup) + path.write_text(content) + print('Updated:',path.relative_to(root),'Backup:',backup.name) +log_dir=root/'.build/build-logs' +log_dir.mkdir(parents=True,exist_ok=True) +old_dir=root/'build-logs' +if old_dir.is_dir(): + for file in old_dir.iterdir(): + if file.is_file() and file.suffix=='.log': + destination=log_dir/file.name + if destination.exists(): + destination=log_dir/(file.stem+'-migrated-'+stamp+file.suffix) + shutil.move(str(file),str(destination)) + if not any(old_dir.iterdir()): + old_dir.rmdir() +print('Build logs:',log_dir) diff --git a/backups/vm-fix-20261023-074349/OrionMenuView.swift b/backups/vm-fix-20261023-074349/OrionMenuView.swift deleted file mode 100644 index 2fe08e4..0000000 --- a/backups/vm-fix-20261023-074349/OrionMenuView.swift +++ /dev/null @@ -1,415 +0,0 @@ - -import AppKit -import SwiftUI - -struct OrionMenuView: View { - @EnvironmentObject var vm: OrionVMController - @Environment(\.openSettings) private var openSettings - - @State private var expandedGroups: Set = [] - @State private var expandedServers: Set = [] - - private var groups: [String] { - let preferred = [ - "Windows", "Ubuntu", "Debian", "Fedora", - "CentOS", "Alpine", "Oracle Linux", - "Rocky Linux", "AlmaLinux", "Arch Linux", - "Gentoo", "FreeBSD", "Other" - ] - - return preferred.filter { os in - vm.discoveredVMs.contains { - $0.operatingSystem == os - } - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - header - - Divider() - - ScrollView { - VStack(alignment: .leading, spacing: 4) { - discoverySection - - Divider() - .padding(.vertical, 10) - - integratedSection - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - } - .frame(maxHeight: 560) - - Divider() - - footer - } - .frame(width: 410) - .task { - await vm.discoverVMs() - } - .alert( - "OrionToolbar", - isPresented: Binding( - get: { vm.lastError != nil }, - set: { if !$0 { vm.lastError = nil } } - ) - ) { - Button("OK") { vm.lastError = nil } - } message: { - Text(vm.lastError ?? "") - } - } - - private var header: some View { - HStack { - Image(systemName: "server.rack") - .foregroundStyle(.blue) - - Text("OrionToolbar") - .font(.headline) - - Spacer() - - if vm.discovering { - ProgressView() - .controlSize(.small) - } - - Button { - Task { await vm.discoverVMs() } - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .disabled(vm.discovering) - .help("Refresh Parallels virtual machines") - } - .padding(16) - } - - private var discoverySection: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("PARALLELS VIRTUAL MACHINES") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - - Spacer() - - Text("\(vm.discoveredVMs.count)") - .font(.caption) - .foregroundStyle(.secondary) - } - - if vm.discoveredVMs.isEmpty { - ContentUnavailableView( - "No Virtual Machines", - systemImage: "desktopcomputer", - description: Text( - "Refresh to discover Parallels virtual machines." - ) - ) - .frame(height: 120) - } - - ForEach(groups, id: \.self) { group in - operatingSystemGroup(group) - } - - if !vm.discoveredVMs.isEmpty { - Divider() - .padding(.vertical, 6) - - HStack { - Text("\(vm.selectedVMIDs.count) selected") - .font(.caption) - .foregroundStyle(.secondary) - - Spacer() - - Button("Select All") { - vm.selectAllVMs() - } - - Button("Clear") { - vm.clearVMSelection() - } - .disabled(vm.selectedVMIDs.isEmpty) - } - - Button { - vm.integrateSelectedVMs() - } label: { - Label( - "Integrate Selected", - systemImage: "plus.circle.fill" - ) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(vm.selectedVMIDs.isEmpty) - } - } - } - - private func operatingSystemGroup( - _ group: String - ) -> some View { - let machines = vm.discoveredVMs.filter { - $0.operatingSystem == group - } - - return VStack(alignment: .leading, spacing: 3) { - Button { - if expandedGroups.contains(group) { - expandedGroups.remove(group) - } else { - expandedGroups.insert(group) - } - } label: { - HStack(spacing: 10) { - Image(systemName: - expandedGroups.contains(group) - ? "chevron.down" - : "chevron.right" - ) - .font(.caption) - .frame(width: 12) - - Image(systemName: machines.first?.symbol - ?? "desktopcomputer" - ) - .frame(width: 22) - - Text(group) - .fontWeight(.medium) - - Spacer() - - Text("\(machines.count)") - .foregroundStyle(.secondary) - } - .contentShape(Rectangle()) - .padding(.vertical, 9) - } - .buttonStyle(.plain) - - if expandedGroups.contains(group) { - ForEach(machines) { machine in - machineRow(machine) - } - } - } - } - - private func machineRow( - _ machine: ParallelsVM - ) -> some View { - HStack(alignment: .center, spacing: 10) { - Toggle( - "", - isOn: Binding( - get: { - vm.selectedVMIDs.contains(machine.id) - }, - set: { selected in - if selected { - vm.selectedVMIDs.insert(machine.id) - } else { - vm.selectedVMIDs.remove(machine.id) - } - } - ) - ) - .labelsHidden() - .toggleStyle(.checkbox) - - if let icon = vm.icon(for: machine) { - Image(nsImage: icon) - .resizable() - .scaledToFit() - .frame(width: 30, height: 30) - } else { - Image(systemName: machine.symbol) - .font(.title3) - .frame(width: 30, height: 30) - .foregroundStyle(.secondary) - } - - VStack(alignment: .leading, spacing: 3) { - Text(machine.name) - .font(.system(size: 13, weight: .medium)) - - HStack(spacing: 5) { - Circle() - .fill( - machine.status.lowercased() == "running" - ? Color.green : Color.gray - ) - .frame(width: 7, height: 7) - - Text(machine.status.capitalized) - - if vm.isIntegrated(machine) { - Text("· Integrated") - .foregroundStyle(.green) - } - } - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer(minLength: 0) - } - .padding(.leading, 22) - .padding(.vertical, 6) - } - - private var integratedSection: some View { - VStack(alignment: .leading, spacing: 8) { - Text("INTEGRATED SERVERS") - .font(.caption) - .fontWeight(.semibold) - .foregroundStyle(.secondary) - - ForEach(vm.servers) { server in - integratedServerRow(server) - } - } - } - - private func integratedServerRow( - _ server: ServerDefinition - ) -> some View { - let status = vm.status(for: server) - let expanded = expandedServers.contains(server.id) - - return VStack(alignment: .leading, spacing: 5) { - Button { - if expanded { - expandedServers.remove(server.id) - } else { - expandedServers.insert(server.id) - } - } label: { - HStack(spacing: 10) { - Image(systemName: - expanded ? "chevron.down" : "chevron.right" - ) - .font(.caption) - - Circle() - .fill(status == .running - ? Color.green : Color.gray) - .frame(width: 9, height: 9) - - VStack(alignment: .leading, spacing: 2) { - Text(server.name) - .fontWeight(.medium) - - Text(status.title) - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - } - .contentShape(Rectangle()) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - - if expanded { - VStack(alignment: .leading, spacing: 5) { - if status == .stopped { - action("Start", "play.fill") { - vm.start(server) - } - } else { - action("Stop", "stop.fill") { - vm.stop(server) - } - .disabled(vm.isBusy(server)) - - action("Restart", "arrow.clockwise") { - vm.restart(server) - } - .disabled( - vm.isBusy(server) || - status != .running - ) - - action( - "Force Stop", - "exclamationmark.triangle" - ) { - vm.forceStop(server) - } - .disabled(vm.isBusy(server)) - } - - action("Open SSH in Terminal", "terminal") { - vm.openSSH(server) - } - .disabled( - status != .running || - server.host.isEmpty - ) - - action("Open Mailpit", "envelope") { - vm.openMailpit(server) - } - .disabled( - status != .running || - server.mailpitPort == nil || - server.host.isEmpty - ) - } - .padding(.leading, 26) - .padding(.bottom, 8) - } - } - } - - private func action( - _ title: String, - _ symbol: String, - perform: @escaping () -> Void - ) -> some View { - Button(action: perform) { - Label(title, systemImage: symbol) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 5) - } - .buttonStyle(.plain) - } - - private var footer: some View { - VStack(spacing: 2) { - action("Open Parallels Desktop", "macwindow") { - vm.openParallels() - } - - action("Settings…", "gearshape") { - openSettings() - NSApplication.shared.activate( - ignoringOtherApps: true - ) - } - - Divider() - - action("Quit OrionToolbar", "xmark") { - NSApplication.shared.terminate(nil) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - } -} diff --git a/backups/vm-fix-20261023-074349/OrionVMController.swift b/backups/vm-fix-20261023-074349/OrionVMController.swift deleted file mode 100644 index 8b52b17..0000000 --- a/backups/vm-fix-20261023-074349/OrionVMController.swift +++ /dev/null @@ -1,298 +0,0 @@ -import AppKit -import Foundation - -struct ServerDefinition: Identifiable, Codable, Hashable { - var id = UUID() - var name: String - var vmName: String - var vmUUID: String? = nil - var host: String - var sshUser: String - var sshPort: Int = 22 - var sshIdentityFile: String = "" - var mailpitPort: Int? - - static let orion = ServerDefinition(name: "Orion", vmName: "Ubuntu Linux Server", host: "10.211.55.100", sshUser: "sysadmin", sshPort: 22, sshIdentityFile: "", mailpitPort: 8025) -} - - -struct ParallelsVM: Identifiable, Hashable { - let id: String - let name: String - let status: String - let ipAddress: String - let operatingSystem: String - var bundlePath: String? - - var symbol: String { - switch operatingSystem { - case "Windows": return "desktopcomputer" - case "Ubuntu": return "server.rack" - case "Debian": return "server.rack" - case "Fedora": return "server.rack" - case "CentOS": return "server.rack" - case "Alpine": return "server.rack" - case "Oracle Linux": return "server.rack" - default: return "desktopcomputer" - } - } - - static func detectOS(_ name: String) -> String { - let value = name.lowercased() - - if value.contains("windows") { return "Windows" } - if value.contains("ubuntu") { return "Ubuntu" } - if value.contains("debian") { return "Debian" } - if value.contains("fedora") { return "Fedora" } - if value.contains("centos") { return "CentOS" } - if value.contains("alpine") { return "Alpine" } - if value.contains("oracle") { return "Oracle Linux" } - if value.contains("rocky") { return "Rocky Linux" } - if value.contains("alma") { return "AlmaLinux" } - if value.contains("arch") { return "Arch Linux" } - if value.contains("gentoo") { return "Gentoo" } - if value.contains("freebsd") { return "FreeBSD" } - - return "Other" - } -} - -@MainActor -final class OrionVMController: ObservableObject { - enum VMStatus: Equatable { - case checking, running, stopped, suspended, transitioning, unavailable(String) - var title: String { switch self { case .checking: "Checking…"; case .running: "Running"; case .stopped: "Stopped"; case .suspended: "Suspended"; case .transitioning: "Working…"; case .unavailable: "Unavailable" } } - var symbolName: String { switch self { case .running: "server.rack"; case .stopped: "power"; case .suspended: "pause.circle"; case .transitioning, .checking: "arrow.triangle.2.circlepath"; case .unavailable: "exclamationmark.triangle" } } - } - - @Published var servers: [ServerDefinition] { didSet { saveServers() } } - @Published var statuses: [UUID: VMStatus] = [:] - @Published var busyServers: Set = [] - @Published var lastError: String? - @Published var discoveredVMs: [ParallelsVM] = [] - @Published var selectedVMIDs: Set = [] - @Published var discovering = false - private var timer: Timer? - private let defaultsKey = "serverDefinitions.v2" - - init() { - if let data = UserDefaults.standard.data(forKey: defaultsKey), let saved = try? JSONDecoder().decode([ServerDefinition].self, from: data), !saved.isEmpty { servers = saved } - else { servers = [.orion] } - Task { - await discoverVMs() - await refreshAll() - } - timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in Task { @MainActor in await self?.refreshAll() } } - } - deinit { timer?.invalidate() } - - - func discoverVMs() async { - guard !discovering else { return } - - discovering = true - defer { discovering = false } - - do { - let output = try await runPrlctl(["list", "-a", "-j"]) - - guard let data = output.data(using: .utf8), - let records = try JSONSerialization.jsonObject( - with: data - ) as? [[String: Any]] else { - throw OrionError.commandFailed( - "Invalid Parallels VM discovery response." - ) - } - - discoveredVMs = records.compactMap { record in - guard let uuid = record["uuid"] as? String, - let name = record["name"] as? String else { - return nil - } - - let status = record["status"] as? String ?? "unknown" - let ip = record["ip_configured"] as? String ?? "" - - return ParallelsVM( - id: uuid, - name: name, - status: status, - ipAddress: ip == "-" ? "" : ip, - operatingSystem: ParallelsVM.detectOS(name), - bundlePath: nil - ) - } - .sorted { - $0.name.localizedStandardCompare($1.name) - == .orderedAscending - } - - // Keep selections only for VMs still present. - let available = Set(discoveredVMs.map(\.id)) - selectedVMIDs.formIntersection(available) - - // Associate existing configurations with discovered VMs. - // This preserves the original Orion configuration. - for machine in discoveredVMs { - guard let index = servers.firstIndex(where: { - $0.vmUUID == machine.id || - ($0.vmUUID == nil && $0.vmName == machine.name) - }) else { - continue - } - - if servers[index].vmUUID == nil { - servers[index].vmUUID = machine.id - } - } - - } catch { - lastError = error.localizedDescription - } - } - - func isIntegrated(_ machine: ParallelsVM) -> Bool { - servers.contains { - $0.vmUUID == machine.id || - ($0.vmUUID == nil && $0.vmName == machine.name) - } - } - - func toggleSelection(_ machine: ParallelsVM) { - if selectedVMIDs.contains(machine.id) { - selectedVMIDs.remove(machine.id) - } else { - selectedVMIDs.insert(machine.id) - } - } - - func selectAllVMs() { - selectedVMIDs = Set(discoveredVMs.map(\.id)) - } - - func clearVMSelection() { - selectedVMIDs.removeAll() - } - - func integrateSelectedVMs() { - let selected = discoveredVMs.filter { - selectedVMIDs.contains($0.id) - } - - for machine in selected { - guard !isIntegrated(machine) else { continue } - - let server = ServerDefinition( - name: machine.name, - vmName: machine.name, - vmUUID: machine.id, - host: machine.ipAddress, - sshUser: "sysadmin", - sshPort: 22, - sshIdentityFile: "", - mailpitPort: nil - ) - - add(server) - } - - selectedVMIDs.removeAll() - } - - func icon(for machine: ParallelsVM) -> NSImage? { - guard let path = machine.bundlePath, - FileManager.default.fileExists(atPath: path) else { - return nil - } - - return NSWorkspace.shared.icon(forFile: path) - } - - func status(for server: ServerDefinition) -> VMStatus { statuses[server.id] ?? .checking } - func isBusy(_ server: ServerDefinition) -> Bool { busyServers.contains(server.id) } - func add(_ server: ServerDefinition) { servers.append(server); Task { await refreshStatus(server) } } - func update(_ server: ServerDefinition) { if let i = servers.firstIndex(where: { $0.id == server.id }) { servers[i] = server; Task { await refreshStatus(server) } } } - func delete(at offsets: IndexSet) { for i in offsets { statuses[servers[i].id] = nil }; servers.remove(atOffsets: offsets) } - - func refreshAll() async { for server in servers { await refreshStatus(server) } } - func refreshStatus(_ server: ServerDefinition) async { - guard !isBusy(server) else { return } - do { - let output = try await runPrlctl(["status", server.vmName]); let value = output.lowercased() - if value.contains("running") { statuses[server.id] = .running } - else if value.contains("stopped") { statuses[server.id] = .stopped } - else if value.contains("suspended") || value.contains("paused") { statuses[server.id] = .suspended } - else { statuses[server.id] = .unavailable(output.trimmingCharacters(in: .whitespacesAndNewlines)) } - } catch { statuses[server.id] = .unavailable(error.localizedDescription) } - } - - func start(_ s: ServerDefinition) { perform(["start", s.vmName], server: s) } - func stop(_ s: ServerDefinition) { perform(["stop", s.vmName], server: s) } - func restart(_ s: ServerDefinition) { perform(["restart", s.vmName], server: s) } - func forceStop(_ s: ServerDefinition) { perform(["stop", s.vmName, "--kill"], server: s) } - - private func perform(_ arguments: [String], server: ServerDefinition) { - guard !isBusy(server) else { return }; busyServers.insert(server.id); statuses[server.id] = .transitioning; lastError = nil - Task { do { _ = try await runPrlctl(arguments) } catch { lastError = error.localizedDescription }; busyServers.remove(server.id); try? await Task.sleep(for: .seconds(1)); await refreshStatus(server) } - } - - func openSSH(_ s: ServerDefinition) { - let userHost = "\(s.sshUser)@\(s.host)" - var command = "/usr/bin/ssh -p \(s.sshPort)" - let identity = s.sshIdentityFile.trimmingCharacters(in: .whitespacesAndNewlines) - if !identity.isEmpty { - command += " -i \(shellQuote((identity as NSString).expandingTildeInPath))" - } - command += " \(shellQuote(userHost))" - - do { - // Terminal reliably opens executable .command files without requiring - // Apple Events/Automation permission from this menu-bar application. - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("OrionToolbar", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - - let safeName = s.name.replacingOccurrences(of: "/", with: "-") - let scriptURL = directory.appendingPathComponent("SSH-\(safeName).command") - let script = """ - #!/bin/zsh - clear - echo "Connecting to \(s.name) (\(userHost))..." - exec \(command) - """ - try script.write(to: scriptURL, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) - - guard let terminalURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Terminal") else { - lastError = "Terminal.app could not be found." - return - } - - let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = true - NSWorkspace.shared.open([scriptURL], withApplicationAt: terminalURL, configuration: configuration) { _, error in - Task { @MainActor in - if let error { self.lastError = "Could not open Terminal: \(error.localizedDescription)" } - } - } - } catch { - lastError = "Could not create SSH launcher: \(error.localizedDescription)" - } - } - - private func shellQuote(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - func openMailpit(_ s: ServerDefinition) { if let p = s.mailpitPort, let url = URL(string: "http://\(s.host):\(p)") { NSWorkspace.shared.open(url) } } - func openParallels() { NSWorkspace.shared.openApplication(at: URL(fileURLWithPath: "/Applications/Parallels Desktop.app"), configuration: .init()) } - - private func saveServers() { if let data = try? JSONEncoder().encode(servers) { UserDefaults.standard.set(data, forKey: defaultsKey) } } - private func prlctlURL() -> URL? { ["/usr/local/bin/prlctl", "/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"].map(URL.init(fileURLWithPath:)).first { FileManager.default.isExecutableFile(atPath: $0.path) } } - private func runPrlctl(_ arguments: [String]) async throws -> String { - guard let executable = prlctlURL() else { throw OrionError.prlctlNotFound } - return try await Task.detached(priority: .userInitiated) { let p = Process(); let pipe = Pipe(); p.executableURL = executable; p.arguments = arguments; p.standardOutput = pipe; p.standardError = pipe; try p.run(); p.waitUntilExit(); let out = String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self); guard p.terminationStatus == 0 else { throw OrionError.commandFailed(out) }; return out }.value - } -} - -enum OrionError: LocalizedError { case prlctlNotFound, commandFailed(String); var errorDescription: String? { switch self { case .prlctlNotFound: "Parallels prlctl was not found."; case .commandFailed(let o): o.trimmingCharacters(in: .whitespacesAndNewlines) } } } diff --git a/backups/vm-fix-20261023-074349/SettingsView.swift b/backups/vm-fix-20261023-074349/SettingsView.swift deleted file mode 100644 index 92ebf95..0000000 --- a/backups/vm-fix-20261023-074349/SettingsView.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -struct SettingsView: View { - @EnvironmentObject var vm: OrionVMController - @State private var selection: UUID? - @State private var editing: ServerDefinition? - var body: some View { - HStack(spacing: 0) { - List(selection: $selection) { ForEach(vm.servers) { s in Label(s.name, systemImage: vm.status(for: s).symbolName).tag(s.id) } } - .frame(width: 190) - Divider() - VStack(alignment: .leading, spacing: 14) { - if let id = selection, let server = vm.servers.first(where: { $0.id == id }) { - Text(server.name).font(.title2).fontWeight(.semibold) - LabeledContent("Parallels VM", value: server.vmName); LabeledContent("Host", value: server.host); LabeledContent("SSH user", value: server.sshUser); LabeledContent("SSH port", value: String(server.sshPort)); LabeledContent("SSH key", value: server.sshIdentityFile.isEmpty ? "Default SSH configuration" : server.sshIdentityFile); LabeledContent("Mailpit", value: server.mailpitPort.map(String.init) ?? "Disabled") - HStack { Button("Edit…") { editing = server }; Button("Force Stop", role: .destructive) { vm.forceStop(server) }.disabled(vm.status(for: server) == .stopped) } - Spacer() - } else { ContentUnavailableView("Select a Server", systemImage: "server.rack") } - Divider() - HStack { Button("Add Server…", systemImage: "plus") { editing = ServerDefinition(name: "New Server", vmName: "", host: "", sshUser: "sysadmin", sshPort: 22, sshIdentityFile: "", mailpitPort: nil) }; Spacer(); Button("Delete", role: .destructive) { if let id = selection, let i = vm.servers.firstIndex(where: {$0.id == id}) { vm.delete(at: IndexSet(integer: i)); selection = nil } }.disabled(selection == nil) } - }.padding(20) - }.frame(width: 650, height: 380) - .sheet(item: $editing) { server in ServerEditor(server: server, isNew: !vm.servers.contains(where: {$0.id == server.id})) { value, isNew in if isNew { vm.add(value) } else { vm.update(value) }; selection = value.id } } - } -} - -struct ServerEditor: View { - @Environment(\.dismiss) private var dismiss - @State var server: ServerDefinition - let isNew: Bool - let save: (ServerDefinition, Bool) -> Void - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text(isNew ? "Add Server" : "Edit Server").font(.title2).fontWeight(.semibold) - Form { - TextField("Display name", text: $server.name) - TextField("Parallels VM name", text: $server.vmName) - TextField("IP address / host", text: $server.host) - TextField("SSH user", text: $server.sshUser) - TextField("SSH port", value: $server.sshPort, format: .number) - TextField("SSH identity file (optional)", text: $server.sshIdentityFile, prompt: Text("~/.ssh/id_ed25519")) - TextField("Mailpit port (optional)", text: Binding(get: { server.mailpitPort.map(String.init) ?? "" }, set: { server.mailpitPort = Int($0) })) - } - HStack { Spacer(); Button("Cancel") { dismiss() }; Button("Save") { save(server, isNew); dismiss() }.keyboardShortcut(.defaultAction).disabled(server.name.isEmpty || server.vmName.isEmpty || server.host.isEmpty || server.sshUser.isEmpty || server.sshPort < 1 || server.sshPort > 65535) } - }.padding(20).frame(width: 470) - } -} diff --git a/patches/.gitkeep b/patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/patches/oriontoolbar_settings_logs_patch.py b/patches/oriontoolbar_settings_logs_patch.py new file mode 100644 index 0000000..b9c41c5 --- /dev/null +++ b/patches/oriontoolbar_settings_logs_patch.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +from pathlib import Path +from datetime import datetime +import shutil + +root = Path('/Volumes/Developer/Applications/OrionToolbar') +menu = root/'OrionToolbar/OrionMenuView.swift' +build = root/'scripts/build.sh' +test = root/'scripts/test.sh' +release = root/'scripts/release.sh' +for p in (menu,build,test,release): + if not p.is_file(): + raise SystemExit(f'Missing file: {p}') +changes = {} +s = menu.read_text() +old = ''' private func showSettings() { + openSettings() + NSApplication.shared.activate(ignoringOtherApps: true) + }''' +new = ''' private func showSettings() { + // Activate before opening the Settings scene so it is not left behind + // the menu-bar popover or another application. + NSApp.activate(ignoringOtherApps: true) + openSettings() + // The Settings window is created asynchronously by SwiftUI. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { + $0.title.localizedCaseInsensitiveContains("settings") && + $0.canBecomeKey + }) { + window.makeKeyAndOrderFront(nil) + } + } + }''' +if old not in s: + raise SystemExit('Settings function does not match expected source; no files changed.') +changes[menu]=s.replace(old,new,1) +s=build.read_text() +s=s.replace('mkdir -p "$ROOT/build-logs"','mkdir -p "$DERIVED/build-logs"').replace('LOG="$ROOT/build-logs/','LOG="$DERIVED/build-logs/') +if 'LOG="$DERIVED/build-logs/' not in s: + raise SystemExit('Build log path not recognized; no files changed.') +changes[build]=s +s=test.read_text() +s=s.replace('mkdir -p "$ROOT/build-logs"','mkdir -p "$ROOT/.build/build-logs"').replace('LOG="$ROOT/build-logs/','LOG="$ROOT/.build/build-logs/') +# Existing test script refers to a nonexistent capitalized Scripts folder. +s=s.replace('"$ROOT/Scripts/build.sh"','"$ROOT/scripts/build.sh"') +if 'LOG="$ROOT/.build/build-logs/' not in s: + raise SystemExit('Test log path not recognized; no files changed.') +changes[test]=s +s=release.read_text() +if 'LOG="$ROOT/.build/build-logs/' not in s: + raise SystemExit('Release log path not recognized; no files changed.') +changes[release]=s +stamp=datetime.now().strftime('%Y%m%d-%H%M%S') +for path,content in changes.items(): + if path.read_text()==content: + print('Unchanged:',path.relative_to(root));continue + backup=path.with_name(f'{path.name}.bak-{stamp}') + shutil.copy2(path,backup) + path.write_text(content) + print('Updated:',path.relative_to(root),'Backup:',backup.name) +log_dir=root/'.build/build-logs' +log_dir.mkdir(parents=True,exist_ok=True) +old_dir=root/'build-logs' +if old_dir.is_dir(): + for file in old_dir.iterdir(): + if file.is_file() and file.suffix=='.log': + destination=log_dir/file.name + if destination.exists(): + destination=log_dir/(file.stem+'-migrated-'+stamp+file.suffix) + shutil.move(str(file),str(destination)) + if not any(old_dir.iterdir()): + old_dir.rmdir() +print('Build logs:',log_dir) diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/build.sh b/scripts/build.sh index 718cdf4..46978df 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -15,8 +15,8 @@ APP_NAME=OrionToolbar DERIVED="$ROOT/.build" CONFIGURATION="${CONFIGURATION:-Debug}" [[ "$CONFIGURATION" == Debug || "$CONFIGURATION" == Release ]] || { echo 'CONFIGURATION must be Debug or Release' >&2; exit 2; } -mkdir -p "$ROOT/build-logs" -LOG="$ROOT/build-logs/build-${CONFIGURATION}-$(date +%Y%m%d-%H%M%S).log" +mkdir -p "$DERIVED/build-logs" +LOG="$DERIVED/build-logs/build-${CONFIGURATION}-$(date +%Y%m%d-%H%M%S).log" echo "Building $APP_NAME ($CONFIGURATION)" set -o pipefail xcodebuild -project "$ROOT/OrionToolbar.xcodeproj" -scheme "$APP_NAME" -configuration "$CONFIGURATION" -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO build 2>&1 | tee "$LOG" diff --git a/scripts/build.sh.bak-20261023-094858 b/scripts/build.sh.bak-20261023-094858 new file mode 100755 index 0000000..718cdf4 --- /dev/null +++ b/scripts/build.sh.bak-20261023-094858 @@ -0,0 +1,49 @@ +#!/bin/zsh +set -euo pipefail + +echo "=== CLION LAUNCH DIAGNOSTICS ===" +echo "Script: $0" +echo "Argument count: $#" +echo "Arguments: $*" +echo "Working directory: $PWD" +echo "Parent process: $PPID" +echo "================================" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +APP_NAME=OrionToolbar +DERIVED="$ROOT/.build" +CONFIGURATION="${CONFIGURATION:-Debug}" +[[ "$CONFIGURATION" == Debug || "$CONFIGURATION" == Release ]] || { echo 'CONFIGURATION must be Debug or Release' >&2; exit 2; } +mkdir -p "$ROOT/build-logs" +LOG="$ROOT/build-logs/build-${CONFIGURATION}-$(date +%Y%m%d-%H%M%S).log" +echo "Building $APP_NAME ($CONFIGURATION)" +set -o pipefail +xcodebuild -project "$ROOT/OrionToolbar.xcodeproj" -scheme "$APP_NAME" -configuration "$CONFIGURATION" -derivedDataPath "$DERIVED" CODE_SIGNING_ALLOWED=NO build 2>&1 | tee "$LOG" +APP="$DERIVED/Build/Products/$CONFIGURATION/$APP_NAME.app" +[[ -d "$APP" ]] || { echo "Missing application: $APP" >&2; exit 1; } +plutil -lint "$APP/Contents/Info.plist" +EXE=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/Info.plist") +[[ -x "$APP/Contents/MacOS/$EXE" ]] || { echo 'Application executable missing' >&2; exit 1; } +echo "Build complete: $APP" +echo "Log: $LOG" + + +if [[ "${1:-}" == "--launch" ]]; then + echo "=== LAUNCHING ORIONTOOLBAR ===" + + pkill -x "$APP_NAME" 2>/dev/null || true + sleep 2 + + # Launch independently through macOS Launch Services. + /usr/bin/open -n -a "$APP" + + sleep 3 + + if /usr/bin/pgrep -x "$APP_NAME" >/dev/null; then + echo "SUCCESS: OrionToolbar launched." + else + echo "ERROR: OrionToolbar is not running." + exit 1 + fi +fi diff --git a/scripts/test.sh b/scripts/test.sh index 04a3ed9..56a758b 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -2,12 +2,12 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -mkdir -p "$ROOT/build-logs" -LOG="$ROOT/build-logs/test-$(date +%Y%m%d-%H%M%S).log" +mkdir -p "$ROOT/.build/build-logs" +LOG="$ROOT/.build/build-logs/test-$(date +%Y%m%d-%H%M%S).log" exec > >(tee "$LOG") 2>&1 print '=== OrionToolbar tests ===' # A successful compile is the minimum regression check. -"$ROOT/Scripts/build.sh" +"$ROOT/scripts/build.sh" APP="$ROOT/.build/Build/Products/Debug/OrionToolbar.app" plutil -lint "$APP/Contents/Info.plist" # Verify that the Parallels executable and JSON discovery work independently of SwiftUI. diff --git a/scripts/test.sh.bak-20261023-094858 b/scripts/test.sh.bak-20261023-094858 new file mode 100755 index 0000000..04a3ed9 --- /dev/null +++ b/scripts/test.sh.bak-20261023-094858 @@ -0,0 +1,41 @@ +#!/bin/zsh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +mkdir -p "$ROOT/build-logs" +LOG="$ROOT/build-logs/test-$(date +%Y%m%d-%H%M%S).log" +exec > >(tee "$LOG") 2>&1 +print '=== OrionToolbar tests ===' +# A successful compile is the minimum regression check. +"$ROOT/Scripts/build.sh" +APP="$ROOT/.build/Build/Products/Debug/OrionToolbar.app" +plutil -lint "$APP/Contents/Info.plist" +# Verify that the Parallels executable and JSON discovery work independently of SwiftUI. +PRLCTL='' +for candidate in /usr/local/bin/prlctl '/Applications/Parallels Desktop.app/Contents/MacOS/prlctl'; do + if [[ -x "$candidate" ]]; then PRLCTL="$candidate"; break; fi +done +if [[ -n "$PRLCTL" ]]; then + "$PRLCTL" list -a -j | python3 -c ' +import json,sys +v=json.load(sys.stdin) +assert isinstance(v,list), "Expected Parallels JSON array" +for item in v: + assert isinstance(item,dict), "Invalid VM record" + assert item.get("name"), "VM without name" + assert item.get("uuid"), "VM without UUID" +print("Parallels discovery OK: {} VM(s)".format(len(v))) +' +else + echo 'SKIP: Parallels CLI not installed' +fi +if [[ -d "$ROOT/tests" ]] && find "$ROOT/tests" -name 'test_*.py' -print -quit | grep -q .; then + python3 -m unittest discover -s "$ROOT/tests" -v +fi +# Run Xcode tests only if a test plan is configured for this scheme. +if [[ "${RUN_XCODE_TESTS:-0}" == 1 ]]; then + xcodebuild -project "$ROOT/OrionToolbar.xcodeproj" -scheme OrionToolbar -configuration Debug -destination 'platform=macOS' -derivedDataPath "$ROOT/.build" CODE_SIGNING_ALLOWED=NO test +else + echo 'XCTest not run (set RUN_XCODE_TESTS=1 if the scheme has a test target).' +fi +print "Tests complete. Log: $LOG"