commit d3db58bc8da3cea7ad26f21d2f4577ddecc6cf63 Author: Mark Gregory Date: Fri Oct 23 07:10:24 2026 +0100 Initial Commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..60e6718 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a27649f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/dist/ +/.build/ +/PegasusMonitor.xcodeproj/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/OrionToolbar.iml b/.idea/OrionToolbar.iml new file mode 100644 index 0000000..6102194 --- /dev/null +++ b/.idea/OrionToolbar.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..f1cf901 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/swift-settings.xml b/.idea/swift-settings.xml new file mode 100644 index 0000000..fa8f24e --- /dev/null +++ b/.idea/swift-settings.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/OrionToolbar.xcodeproj/project.pbxproj b/OrionToolbar.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b06542f --- /dev/null +++ b/OrionToolbar.xcodeproj/project.pbxproj @@ -0,0 +1,260 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + A20000000000000000000001 /* OrionToolbarApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000001 /* OrionToolbarApp.swift */; }; + A20000000000000000000002 /* OrionVMController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* OrionVMController.swift */; }; + A20000000000000000000003 /* OrionMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000003 /* OrionMenuView.swift */; }; + A20000000000000000000004 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000004 /* SettingsView.swift */; }; + A20000000000000000000005 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000005 /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A10000000000000000000001 /* OrionToolbarApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrionToolbarApp.swift; sourceTree = ""; }; + A10000000000000000000002 /* OrionVMController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrionVMController.swift; sourceTree = ""; }; + A10000000000000000000003 /* OrionMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrionMenuView.swift; sourceTree = ""; }; + A10000000000000000000004 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A10000000000000000000005 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A10000000000000000000006 /* OrionToolbar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OrionToolbar.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000000000000000000007 /* OrionToolbar.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OrionToolbar.entitlements; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A40000000000000000000003 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A30000000000000000000001 = { + isa = PBXGroup; + children = ( + A30000000000000000000002 /* OrionToolbar */, + A30000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + A30000000000000000000002 /* OrionToolbar */ = { + isa = PBXGroup; + children = ( + A10000000000000000000001 /* OrionToolbarApp.swift */, + A10000000000000000000002 /* OrionVMController.swift */, + A10000000000000000000003 /* OrionMenuView.swift */, + A10000000000000000000004 /* SettingsView.swift */, + A10000000000000000000005 /* Assets.xcassets */, + A10000000000000000000007 /* OrionToolbar.entitlements */, + ); + path = OrionToolbar; + sourceTree = ""; + }; + A30000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + A10000000000000000000006 /* OrionToolbar.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A50000000000000000000001 /* OrionToolbar */ = { + isa = PBXNativeTarget; + buildConfigurationList = A70000000000000000000002 /* Build configuration list for PBXNativeTarget "OrionToolbar" */; + buildPhases = ( + A40000000000000000000001 /* Sources */, + A40000000000000000000003 /* Frameworks */, + A40000000000000000000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OrionToolbar; + productName = OrionToolbar; + productReference = A10000000000000000000006 /* OrionToolbar.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A60000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + A50000000000000000000001 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = A70000000000000000000001 /* Build configuration list for PBXProject "OrionToolbar" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = A30000000000000000000001; + productRefGroup = A30000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A50000000000000000000001 /* OrionToolbar */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A40000000000000000000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A20000000000000000000005 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A40000000000000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A20000000000000000000001 /* OrionToolbarApp.swift in Sources */, + A20000000000000000000002 /* OrionVMController.swift in Sources */, + A20000000000000000000003 /* OrionMenuView.swift in Sources */, + A20000000000000000000004 /* SettingsView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A80000000000000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A80000000000000000000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + SDKROOT = macosx; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + A80000000000000000000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = OrionToolbar/OrionToolbar.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSUIElement = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.OrionToolbar; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + A80000000000000000000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + AUTOMATION_APPLE_EVENTS = NO; + CODE_SIGN_ENTITLEMENTS = OrionToolbar/OrionToolbar.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 39R6U3437D; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_LSUIElement = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = net.absdev.OrionToolbar; + PRODUCT_NAME = "$(TARGET_NAME)"; + RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; + RUNTIME_EXCEPTION_ALLOW_JIT = NO; + RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; + RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; + RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A70000000000000000000001 /* Build configuration list for PBXProject "OrionToolbar" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000001 /* Debug */, + A80000000000000000000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A70000000000000000000002 /* Build configuration list for PBXNativeTarget "OrionToolbar" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000003 /* Debug */, + A80000000000000000000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A60000000000000000000001 /* Project object */; +} diff --git a/OrionToolbar.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/OrionToolbar.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/OrionToolbar.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/OrionToolbar.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate b/OrionToolbar.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..2d4e1a3 Binary files /dev/null and b/OrionToolbar.xcodeproj/project.xcworkspace/xcuserdata/markgregory.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/OrionToolbar.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist b/OrionToolbar.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..132ee6d --- /dev/null +++ b/OrionToolbar.xcodeproj/xcuserdata/markgregory.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + OrionToolbar.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/OrionToolbar/Assets.xcassets/AccentColor.colorset/Contents.json b/OrionToolbar/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..88940a2 --- /dev/null +++ b/OrionToolbar/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1 @@ +{"colors":[{"idiom":"universal"}],"info":{"author":"xcode","version":1}} diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/Contents.json b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..8815e3e --- /dev/null +++ b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "filename": "icon_16x16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "icon_16x16@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "icon_32x32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "icon_32x32@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "icon_128x128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "icon_128x128@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "icon_256x256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "icon_256x256@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "icon_512x512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "icon_512x512@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..c406ce8 Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..c35ac00 Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..cfc2e7c Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..b0b1b31 Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..c35ac00 Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..d3ab8aa Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..b0b1b31 Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..f0da368 Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..d3ab8aa Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..c4a5c45 Binary files /dev/null and b/OrionToolbar/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/OrionToolbar/Assets.xcassets/Contents.json b/OrionToolbar/Assets.xcassets/Contents.json new file mode 100644 index 0000000..f223229 --- /dev/null +++ b/OrionToolbar/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"author":"xcode","version":1}} diff --git a/OrionToolbar/OrionMenuView.swift b/OrionToolbar/OrionMenuView.swift new file mode 100644 index 0000000..2fe08e4 --- /dev/null +++ b/OrionToolbar/OrionMenuView.swift @@ -0,0 +1,415 @@ + +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/OrionToolbar/OrionToolbar.entitlements b/OrionToolbar/OrionToolbar.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/OrionToolbar/OrionToolbar.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/OrionToolbar/OrionToolbarApp.swift b/OrionToolbar/OrionToolbarApp.swift new file mode 100644 index 0000000..3cb6506 --- /dev/null +++ b/OrionToolbar/OrionToolbarApp.swift @@ -0,0 +1,31 @@ +import SwiftUI + +@main +struct OrionToolbarApp: App { + @StateObject private var vm = OrionVMController() + + private var aggregateColor: Color { + let states = vm.servers.map { vm.status(for: $0) } + if states.isEmpty { return .gray } + if states.allSatisfy({ $0 == .running }) { return .green } + if states.contains(where: { $0 == .transitioning || $0 == .checking || $0 == .suspended }) { return .orange } + if states.contains(where: { $0 == .running }) { return .orange } + if states.allSatisfy({ $0 == .stopped }) { return .red } + return .red + } + + var body: some Scene { + MenuBarExtra { + OrionMenuView().environmentObject(vm) + } label: { + HStack(spacing: 4) { + Image(systemName: "server.rack") + Circle().fill(aggregateColor).frame(width: 7, height: 7) + } + .accessibilityLabel("Server status") + } + .menuBarExtraStyle(.window) + + Settings { SettingsView().environmentObject(vm) } + } +} diff --git a/OrionToolbar/OrionVMController.swift b/OrionToolbar/OrionVMController.swift new file mode 100644 index 0000000..8b52b17 --- /dev/null +++ b/OrionToolbar/OrionVMController.swift @@ -0,0 +1,298 @@ +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/OrionToolbar/SettingsView.swift b/OrionToolbar/SettingsView.swift new file mode 100644 index 0000000..92ebf95 --- /dev/null +++ b/OrionToolbar/SettingsView.swift @@ -0,0 +1,47 @@ +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/README.md b/README.md new file mode 100644 index 0000000..3981ae8 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Orion Toolbar — Multi-Server Edition + +Native macOS menu-bar controller for multiple Parallels Desktop VMs. + +## Features +- Add, edit and delete server definitions +- Persistent server configuration +- Per-server Start, Stop, Restart and Force Stop +- Live VM state refreshed every 5 seconds +- Per-server SSH shortcut +- Optional per-server Mailpit shortcut +- Opens Parallels Desktop +- Orion is included as the initial server + +A server definition contains a display name, exact Parallels VM name, host/IP, SSH username, and optional Mailpit port. + +Open `OrionToolbar.xcodeproj`, select your signing team, then Build & Run. diff --git a/backups/multi-vm-20261023-070119/OrionMenuView.swift b/backups/multi-vm-20261023-070119/OrionMenuView.swift new file mode 100644 index 0000000..00168b9 --- /dev/null +++ b/backups/multi-vm-20261023-070119/OrionMenuView.swift @@ -0,0 +1,134 @@ +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 new file mode 100644 index 0000000..1b1b30e --- /dev/null +++ b/backups/multi-vm-20261023-070119/OrionVMController.swift @@ -0,0 +1,126 @@ +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/build/ExportOptions.plist b/build/ExportOptions.plist new file mode 100644 index 0000000..f319ec9 --- /dev/null +++ b/build/ExportOptions.plist @@ -0,0 +1,15 @@ + + + + + method + developer-id + + signingStyle + automatic + + teamID + 39R6U3437D + + diff --git a/build/OrionToolbar.xcarchive/Info.plist b/build/OrionToolbar.xcarchive/Info.plist new file mode 100644 index 0000000..a1f9f39 --- /dev/null +++ b/build/OrionToolbar.xcarchive/Info.plist @@ -0,0 +1,34 @@ + + + + + ApplicationProperties + + ApplicationPath + Applications/OrionToolbar.app + Architectures + + x86_64 + arm64 + + CFBundleIdentifier + net.absdev.OrionToolbar + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + SigningIdentity + Apple Development: Mark Gregory (FLBRJ3BYDD) + Team + 39R6U3437D + + ArchiveVersion + 2 + CreationDate + 2026-10-23T06:02:07Z + Name + OrionToolbar + SchemeName + OrionToolbar + + diff --git a/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Info.plist b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Info.plist new file mode 100644 index 0000000..750d7ef --- /dev/null +++ b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Info.plist @@ -0,0 +1,54 @@ + + + + + BuildMachineOSBuild + 26A434 + CFBundleDevelopmentRegion + en + CFBundleExecutable + OrionToolbar + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + net.absdev.OrionToolbar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OrionToolbar + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + DTCompiler + com.apple.compilers.llvm.clang.1_0 + DTPlatformBuild + 26A425 + DTPlatformName + macosx + DTPlatformVersion + 27.0 + DTSDKBuild + 26A425 + DTSDKName + macosx27.0 + DTXcode + 2710 + DTXcodeBuild + 27A9275 + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 14.0 + LSUIElement + + + diff --git a/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/MacOS/OrionToolbar b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/MacOS/OrionToolbar new file mode 100755 index 0000000..8c91825 Binary files /dev/null and b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/MacOS/OrionToolbar differ diff --git a/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/PkgInfo b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/PkgInfo new file mode 100644 index 0000000..bd04210 --- /dev/null +++ b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPL???? \ No newline at end of file diff --git a/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Resources/AppIcon.icns b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Resources/AppIcon.icns new file mode 100644 index 0000000..9ecaae7 Binary files /dev/null and b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Resources/AppIcon.icns differ diff --git a/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Resources/Assets.car b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Resources/Assets.car new file mode 100644 index 0000000..993ba62 Binary files /dev/null and b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/Resources/Assets.car differ diff --git a/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/_CodeSignature/CodeResources b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/_CodeSignature/CodeResources new file mode 100644 index 0000000..da6d894 --- /dev/null +++ b/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app/Contents/_CodeSignature/CodeResources @@ -0,0 +1,139 @@ + + + + + files + + Resources/AppIcon.icns + + kowWyzP1FuwiM/wh8U4ufyNXdqA= + + Resources/Assets.car + + VE6iKwrpeXhU6TBYdOv7OmEeQFs= + + + files2 + + Resources/AppIcon.icns + + hash2 + + //yUqvzN+P68C7dK/PRYMSlQN8t9AzlionLbQKJuWYw= + + + Resources/Assets.car + + hash2 + + ryHiulc8osgyOqIe2Gxiyt+EjitNulEQKKph0XWDixM= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/build/OrionToolbar.zip b/build/OrionToolbar.zip new file mode 100644 index 0000000..2d837e3 Binary files /dev/null and b/build/OrionToolbar.zip differ diff --git a/build/dmg/Applications b/build/dmg/Applications new file mode 120000 index 0000000..bd2d47f --- /dev/null +++ b/build/dmg/Applications @@ -0,0 +1 @@ +/Applications \ No newline at end of file diff --git a/build/dmg/OrionToolbar.app/Contents/CodeResources b/build/dmg/OrionToolbar.app/Contents/CodeResources new file mode 100644 index 0000000..24e432f Binary files /dev/null and b/build/dmg/OrionToolbar.app/Contents/CodeResources differ diff --git a/build/dmg/OrionToolbar.app/Contents/Info.plist b/build/dmg/OrionToolbar.app/Contents/Info.plist new file mode 100644 index 0000000..750d7ef --- /dev/null +++ b/build/dmg/OrionToolbar.app/Contents/Info.plist @@ -0,0 +1,54 @@ + + + + + BuildMachineOSBuild + 26A434 + CFBundleDevelopmentRegion + en + CFBundleExecutable + OrionToolbar + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + net.absdev.OrionToolbar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OrionToolbar + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + DTCompiler + com.apple.compilers.llvm.clang.1_0 + DTPlatformBuild + 26A425 + DTPlatformName + macosx + DTPlatformVersion + 27.0 + DTSDKBuild + 26A425 + DTSDKName + macosx27.0 + DTXcode + 2710 + DTXcodeBuild + 27A9275 + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 14.0 + LSUIElement + + + diff --git a/build/dmg/OrionToolbar.app/Contents/MacOS/OrionToolbar b/build/dmg/OrionToolbar.app/Contents/MacOS/OrionToolbar new file mode 100755 index 0000000..8c73a9b Binary files /dev/null and b/build/dmg/OrionToolbar.app/Contents/MacOS/OrionToolbar differ diff --git a/build/dmg/OrionToolbar.app/Contents/PkgInfo b/build/dmg/OrionToolbar.app/Contents/PkgInfo new file mode 100644 index 0000000..bd04210 --- /dev/null +++ b/build/dmg/OrionToolbar.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPL???? \ No newline at end of file diff --git a/build/dmg/OrionToolbar.app/Contents/Resources/AppIcon.icns b/build/dmg/OrionToolbar.app/Contents/Resources/AppIcon.icns new file mode 100644 index 0000000..9ecaae7 Binary files /dev/null and b/build/dmg/OrionToolbar.app/Contents/Resources/AppIcon.icns differ diff --git a/build/dmg/OrionToolbar.app/Contents/Resources/Assets.car b/build/dmg/OrionToolbar.app/Contents/Resources/Assets.car new file mode 100644 index 0000000..993ba62 Binary files /dev/null and b/build/dmg/OrionToolbar.app/Contents/Resources/Assets.car differ diff --git a/build/dmg/OrionToolbar.app/Contents/_CodeSignature/CodeResources b/build/dmg/OrionToolbar.app/Contents/_CodeSignature/CodeResources new file mode 100644 index 0000000..da6d894 --- /dev/null +++ b/build/dmg/OrionToolbar.app/Contents/_CodeSignature/CodeResources @@ -0,0 +1,139 @@ + + + + + files + + Resources/AppIcon.icns + + kowWyzP1FuwiM/wh8U4ufyNXdqA= + + Resources/Assets.car + + VE6iKwrpeXhU6TBYdOv7OmEeQFs= + + + files2 + + Resources/AppIcon.icns + + hash2 + + //yUqvzN+P68C7dK/PRYMSlQN8t9AzlionLbQKJuWYw= + + + Resources/Assets.car + + hash2 + + ryHiulc8osgyOqIe2Gxiyt+EjitNulEQKKph0XWDixM= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/build/export/DistributionSummary.plist b/build/export/DistributionSummary.plist new file mode 100644 index 0000000..30cb1d1 --- /dev/null +++ b/build/export/DistributionSummary.plist @@ -0,0 +1,38 @@ + + + + + OrionToolbar + + + architectures + + x86_64 + arm64 + + buildNumber + 1 + certificate + + SHA1 + B99D353C88996866C49E4389B375645F76870A3F + dateExpires + 01/02/2027 + type + Developer ID Application + + name + OrionToolbar.app + team + + id + 39R6U3437D + name + + + versionNumber + 1.0 + + + + diff --git a/build/export/ExportOptions.plist b/build/export/ExportOptions.plist new file mode 100644 index 0000000..44e0685 --- /dev/null +++ b/build/export/ExportOptions.plist @@ -0,0 +1,14 @@ + + + + + destination + export + method + developer-id + signingStyle + automatic + teamID + 39R6U3437D + + diff --git a/build/export/OrionToolbar.app/Contents/CodeResources b/build/export/OrionToolbar.app/Contents/CodeResources new file mode 100644 index 0000000..24e432f Binary files /dev/null and b/build/export/OrionToolbar.app/Contents/CodeResources differ diff --git a/build/export/OrionToolbar.app/Contents/Info.plist b/build/export/OrionToolbar.app/Contents/Info.plist new file mode 100644 index 0000000..750d7ef --- /dev/null +++ b/build/export/OrionToolbar.app/Contents/Info.plist @@ -0,0 +1,54 @@ + + + + + BuildMachineOSBuild + 26A434 + CFBundleDevelopmentRegion + en + CFBundleExecutable + OrionToolbar + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + net.absdev.OrionToolbar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OrionToolbar + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSupportedPlatforms + + MacOSX + + CFBundleVersion + 1 + DTCompiler + com.apple.compilers.llvm.clang.1_0 + DTPlatformBuild + 26A425 + DTPlatformName + macosx + DTPlatformVersion + 27.0 + DTSDKBuild + 26A425 + DTSDKName + macosx27.0 + DTXcode + 2710 + DTXcodeBuild + 27A9275 + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 14.0 + LSUIElement + + + diff --git a/build/export/OrionToolbar.app/Contents/MacOS/OrionToolbar b/build/export/OrionToolbar.app/Contents/MacOS/OrionToolbar new file mode 100755 index 0000000..8c73a9b Binary files /dev/null and b/build/export/OrionToolbar.app/Contents/MacOS/OrionToolbar differ diff --git a/build/export/OrionToolbar.app/Contents/PkgInfo b/build/export/OrionToolbar.app/Contents/PkgInfo new file mode 100644 index 0000000..bd04210 --- /dev/null +++ b/build/export/OrionToolbar.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPL???? \ No newline at end of file diff --git a/build/export/OrionToolbar.app/Contents/Resources/AppIcon.icns b/build/export/OrionToolbar.app/Contents/Resources/AppIcon.icns new file mode 100644 index 0000000..9ecaae7 Binary files /dev/null and b/build/export/OrionToolbar.app/Contents/Resources/AppIcon.icns differ diff --git a/build/export/OrionToolbar.app/Contents/Resources/Assets.car b/build/export/OrionToolbar.app/Contents/Resources/Assets.car new file mode 100644 index 0000000..993ba62 Binary files /dev/null and b/build/export/OrionToolbar.app/Contents/Resources/Assets.car differ diff --git a/build/export/OrionToolbar.app/Contents/_CodeSignature/CodeResources b/build/export/OrionToolbar.app/Contents/_CodeSignature/CodeResources new file mode 100644 index 0000000..da6d894 --- /dev/null +++ b/build/export/OrionToolbar.app/Contents/_CodeSignature/CodeResources @@ -0,0 +1,139 @@ + + + + + files + + Resources/AppIcon.icns + + kowWyzP1FuwiM/wh8U4ufyNXdqA= + + Resources/Assets.car + + VE6iKwrpeXhU6TBYdOv7OmEeQFs= + + + files2 + + Resources/AppIcon.icns + + hash2 + + //yUqvzN+P68C7dK/PRYMSlQN8t9AzlionLbQKJuWYw= + + + Resources/Assets.car + + hash2 + + ryHiulc8osgyOqIe2Gxiyt+EjitNulEQKKph0XWDixM= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/build/export/Packaging.log b/build/export/Packaging.log new file mode 100644 index 0000000..16afbdb --- /dev/null +++ b/build/export/Packaging.log @@ -0,0 +1,95 @@ +2026-10-23 06:02:09 +0000 [MT] Initial pipeline context: + Chain (12, self inclusive): + + + + + + + + + + + + + +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionCreateDestRootStep +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionCopyItemStep +2026-10-23 06:02:09 +0000 [MT] Running /usr/bin/ditto '-V' '/Volumes/Developer/Applications/OrionToolbar/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app' '/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/XcodeDistPipeline.~~~ktGJUG/Root/Applications/OrionToolbar.app' +2026-10-23 06:02:09 +0000 >>> Copying /Volumes/Developer/Applications/OrionToolbar/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app +2026-10-23 06:02:09 +0000 copying file ./Contents/_CodeSignature/CodeResources ... +2026-10-23 06:02:09 +0000 2672 bytes for ./Contents/_CodeSignature/CodeResources +2026-10-23 06:02:09 +0000 copying file ./Contents/Info.plist ... +2026-10-23 06:02:09 +0000 1521 bytes for ./Contents/Info.plist +2026-10-23 06:02:09 +0000 copying file ./Contents/Resources/AppIcon.icns ... +2026-10-23 06:02:09 +0000 140248 bytes for ./Contents/Resources/AppIcon.icns +2026-10-23 06:02:09 +0000 copying file ./Contents/Resources/Assets.car ... +2026-10-23 06:02:09 +0000 3193432 bytes for ./Contents/Resources/Assets.car +2026-10-23 06:02:09 +0000 copying file ./Contents/PkgInfo ... +2026-10-23 06:02:09 +0000 8 bytes for ./Contents/PkgInfo +2026-10-23 06:02:09 +0000 copying file ./Contents/MacOS/OrionToolbar ... +2026-10-23 06:02:09 +0000 711120 bytes for ./Contents/MacOS/OrionToolbar +2026-10-23 06:02:09 +0000 [MT] /usr/bin/ditto exited with 0 +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionEmbedProfileStep +2026-10-23 06:02:09 +0000 [MT] Skipping profile for item: ', codeSigningInfo='<_DVTCodeSigningInformation_Path: 0x749e60bc60; isSigned='1', isAdHocSigned='0', signingCertificate='', entitlements='{ +}', teamID='39R6U3437D', identifier='net.absdev.OrionToolbar', executablePath='', hardenedRuntime='1'>'> +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionInfoPlistStep +2026-10-23 06:02:09 +0000 [MT] Skipping step: IDEDistributionInfoPlistStep because it said so +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionAppThinningPlistStep +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionSymbolsStep +2026-10-23 06:02:09 +0000 [MT] Skipping step: IDEDistributionSymbolsStep because it said so +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionAppThinningStep +2026-10-23 06:02:09 +0000 [MT] Skipping step: IDEDistributionAppThinningStep because it said so +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionArchThinningStep +2026-10-23 06:02:09 +0000 [MT] Archs to thin for item /Volumes/Developer/Applications/OrionToolbar/build/OrionToolbar.xcarchive/Products/Applications/OrionToolbar.app are [] +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionODRStep +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionStripXattrsStep +2026-10-23 06:02:09 +0000 [MT] Skipping stripping extended attributes because the codesign step will strip them. +2026-10-23 06:02:09 +0000 [MT] Processing step: IDEDistributionCodesignStep +2026-10-23 06:02:09 +0000 [MT] Entitlements for ', codeSigningInfo='<_DVTCodeSigningInformation_Path: 0x749e60bc60; isSigned='1', isAdHocSigned='0', signingCertificate='', entitlements='{ +}', teamID='39R6U3437D', identifier='net.absdev.OrionToolbar', executablePath='', hardenedRuntime='1'>'>: { +} +2026-10-23 06:02:09 +0000 [MT] Associated App Clip Identifiers Filter: Skipping because "com.apple.developer.associated-appclip-app-identifiers" is not present +2026-10-23 06:02:09 +0000 [MT] Entitlements for ', codeSigningInfo='<_DVTCodeSigningInformation_Path: 0x749e60bc60; isSigned='1', isAdHocSigned='0', signingCertificate='', entitlements='{ +}', teamID='39R6U3437D', identifier='net.absdev.OrionToolbar', executablePath='', hardenedRuntime='1'>'> are: { +} +2026-10-23 06:02:09 +0000 [MT] Writing entitlements for ', codeSigningInfo='<_DVTCodeSigningInformation_Path: 0x749e60bc60; isSigned='1', isAdHocSigned='0', signingCertificate='', entitlements='{ +}', teamID='39R6U3437D', identifier='net.absdev.OrionToolbar', executablePath='', hardenedRuntime='1'>'> to: /var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/XcodeDistPipeline.~~~ktGJUG/entitlements~~~lELp8b +2026-10-23 06:02:09 +0000 [MT] invoking codesign: +2026-10-23 06:02:11 +0000 [MT] codesign output: /var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/XcodeDistPipeline.~~~ktGJUG/Root/Applications/OrionToolbar.app: replacing existing signature +/var/folders/54/8knnwrw15j74z4w2z_3_25q00000gn/T/XcodeDistPipeline.~~~ktGJUG/Root/Applications/OrionToolbar.app: signed app bundle with Mach-O universal (x86_64 arm64) [net.absdev.OrionToolbar] +2026-10-23 06:02:11 +0000 [MT] Processing step: IDEDistributionZipODRItemStep +2026-10-23 06:02:11 +0000 [MT] Skipping step: IDEDistributionZipODRItemStep because it said so +2026-10-23 06:02:11 +0000 [MT] Processing step: IDEDistributionSkipPackagingStep +2026-10-23 06:02:11 +0000 [MT] Processing step: IDEDistributionAppStoreInformationStep +2026-10-23 06:02:11 +0000 [MT] Skipping step: IDEDistributionAppStoreInformationStep because it said so +2026-10-23 06:02:11 +0000 [MT] Processing step: IDEDistributionGenerateProcessedDistributionItems +2026-10-23 06:02:11 +0000 [MT] IDEDistributionItem init +2026-10-23 06:02:11 +0000 [MT] [OPTIONAL] Didn't find embedded provisioning profile for : Error Domain=NSCocoaErrorDomain Code=4 "No file at " UserInfo={NSLocalizedDescription=No file at } +2026-10-23 06:02:11 +0000 [MT] Processing step: IDEDistributionCreateManifestStep +2026-10-23 06:02:11 +0000 [MT] Skipping step: IDEDistributionCreateManifestStep because it said so diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..2d9132b --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,276 @@ +#!/bin/bash + +set -euo pipefail + +# ============================================================ +# Orion Toolbar - Direct Distribution Build +# ============================================================ + +APP_NAME="OrionToolbar" +SCHEME="OrionToolbar" +PROJECT="OrionToolbar.xcodeproj" + +# Change this if your Developer ID identity has a different name. +SIGNING_IDENTITY="B99D353C88996866C49E4389B375645F76870A3F" + +# Existing notarytool Keychain profile. +# Change if you use a different profile name. +NOTARY_PROFILE="orion-notary" + +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$ROOT_DIR/build" +ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive" +EXPORT_DIR="$BUILD_DIR/export" +DMG_STAGE="$BUILD_DIR/dmg" +DMG_PATH="$ROOT_DIR/$APP_NAME.dmg" +EXPORT_OPTIONS="$BUILD_DIR/ExportOptions.plist" + +echo +echo "============================================================" +echo " ORION TOOLBAR - DIRECT DISTRIBUTION" +echo "============================================================" +echo + +cd "$ROOT_DIR" + +# ------------------------------------------------------------ +# Clean +# ------------------------------------------------------------ + +echo "==> Cleaning previous build..." + +rm -rf "$BUILD_DIR" +rm -f "$DMG_PATH" + +mkdir -p "$BUILD_DIR" +mkdir -p "$EXPORT_DIR" +mkdir -p "$DMG_STAGE" + +# ------------------------------------------------------------ +# Export options +# ------------------------------------------------------------ + +cat > "$EXPORT_OPTIONS" < + + + + method + developer-id + + signingStyle + automatic + + teamID + 39R6U3437D + + +EOF + +# ------------------------------------------------------------ +# Archive +# ------------------------------------------------------------ + +echo +echo "==> Creating Release archive..." + +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -archivePath "$ARCHIVE_PATH" \ + clean archive + +echo +echo "==> Archive created." + +# ------------------------------------------------------------ +# Export Developer ID application +# ------------------------------------------------------------ + +echo +echo "==> Exporting Developer ID application..." + +xcodebuild \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_DIR" \ + -exportOptionsPlist "$EXPORT_OPTIONS" + +APP_PATH="$EXPORT_DIR/$APP_NAME.app" + +if [ ! -d "$APP_PATH" ]; then + echo + echo "ERROR: Exported application not found:" + echo "$APP_PATH" + exit 1 +fi + +# ------------------------------------------------------------ +# Verify application signature +# ------------------------------------------------------------ + +echo +echo "==> Verifying application signature..." + +codesign \ + --verify \ + --deep \ + --strict \ + --verbose=2 \ + "$APP_PATH" + +echo +echo "==> Signature information..." + +codesign -dv --verbose=4 "$APP_PATH" 2>&1 | \ + grep -E "Identifier=|Authority=|TeamIdentifier=|Runtime Version=" || true + +# ------------------------------------------------------------ +# Notarize application +# ------------------------------------------------------------ + +echo +echo "==> Creating notarization ZIP..." + +APP_ZIP="$BUILD_DIR/$APP_NAME.zip" + +ditto \ + -c \ + -k \ + --keepParent \ + "$APP_PATH" \ + "$APP_ZIP" + +echo +echo "==> Submitting application to Apple notarization..." + +xcrun notarytool submit \ + "$APP_ZIP" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait + +echo +echo "==> Stapling notarization ticket to application..." + +xcrun stapler staple "$APP_PATH" + +echo +echo "==> Validating application ticket..." + +xcrun stapler validate "$APP_PATH" + +echo +echo "==> Gatekeeper application check..." + +spctl \ + --assess \ + --type execute \ + --verbose=4 \ + "$APP_PATH" + +# ------------------------------------------------------------ +# Build DMG contents +# ------------------------------------------------------------ + +echo +echo "==> Preparing DMG..." + +rm -rf "$DMG_STAGE" +mkdir -p "$DMG_STAGE" + +ditto "$APP_PATH" "$DMG_STAGE/$APP_NAME.app" + +ln -s /Applications "$DMG_STAGE/Applications" + +# ------------------------------------------------------------ +# Create DMG +# ------------------------------------------------------------ + +echo +echo "==> Creating DMG..." + +hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$DMG_STAGE" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +# ------------------------------------------------------------ +# Sign DMG +# ------------------------------------------------------------ + +echo +echo "==> Signing DMG..." + +codesign \ + --force \ + --timestamp \ + --sign "$SIGNING_IDENTITY" \ + "$DMG_PATH" + +echo +echo "==> Verifying DMG signature..." + +codesign \ + --verify \ + --verbose=2 \ + "$DMG_PATH" + +# ------------------------------------------------------------ +# Notarize DMG +# ------------------------------------------------------------ + +echo +echo "==> Submitting DMG to Apple notarization..." + +xcrun notarytool submit \ + "$DMG_PATH" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait + +echo +echo "==> Stapling notarization ticket to DMG..." + +xcrun stapler staple "$DMG_PATH" + +echo +echo "==> Validating DMG ticket..." + +xcrun stapler validate "$DMG_PATH" + +# ------------------------------------------------------------ +# Final verification +# ------------------------------------------------------------ + +echo +echo "==> Final Gatekeeper assessment..." + +spctl \ + --assess \ + --type open \ + --context context:primary-signature \ + --verbose=4 \ + "$DMG_PATH" || true + +echo +echo "==> DMG information..." + +ls -lh "$DMG_PATH" + +echo +echo "==> SHA-256..." + +shasum -a 256 "$DMG_PATH" + +echo +echo "============================================================" +echo " RELEASE BUILD COMPLETE" +echo "============================================================" +echo +echo "DMG:" +echo "$DMG_PATH" +echo diff --git a/scripts/build-test.sh b/scripts/build-test.sh new file mode 100755 index 0000000..caeb8e1 --- /dev/null +++ b/scripts/build-test.sh @@ -0,0 +1,200 @@ +#!/bin/bash +set -euo pipefail + +APP_NAME="OrionToolbar" +SCHEME="OrionToolbar" +PROJECT="OrionToolbar.xcodeproj" + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="$ROOT_DIR/.build" +LOG_DIR="$ROOT_DIR/build-logs" + +CONFIGURATION="Release" +LAUNCH=false +RUN_TESTS=true + +for arg in "$@"; do + case "$arg" in + --launch) + LAUNCH=true + ;; + --no-tests) + RUN_TESTS=false + ;; + *) + echo "Unknown argument: $arg" + echo "Usage: $0 [--launch] [--no-tests]" + exit 2 + ;; + esac +done + +mkdir -p "$BUILD_DIR" "$LOG_DIR" + +LOG_FILE="$LOG_DIR/build-test-$(date +%Y%m%d-%H%M%S).log" + +exec > >(tee -a "$LOG_FILE") 2>&1 + +echo +echo "============================================================" +echo " ORION TOOLBAR - RELEASE BUILD AND TEST" +echo "============================================================" +echo + +cd "$ROOT_DIR" + +echo "==> Environment" + +xcodebuild -version +swift --version + +echo +echo "==> Checking project" + +test -d "$PROJECT" || { + echo "ERROR: Xcode project not found." + exit 1 +} + +echo +echo "==> Checking Parallels" + +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 + echo "Parallels CLI: $PRLCTL" + + echo + echo "==> Discovering virtual machines" + + "$PRLCTL" list -a -j | python3 -c ' +import json +import sys + +machines = json.load(sys.stdin) + +print(f"Discovered {len(machines)} virtual machines") +print() + +for machine in machines: + print( + f"{machine.get('name', 'Unknown'):<35} " + f"{machine.get('status', 'unknown'):<12} " + f"{machine.get('uuid', '')}" + ) +' +else + echo "WARNING: Parallels CLI not found." +fi + +echo +echo "==> Building Release configuration" + +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination "generic/platform=macOS" \ + -derivedDataPath "$BUILD_DIR" \ + CODE_SIGNING_ALLOWED=NO \ + clean build + +APP_PATH="$BUILD_DIR/Build/Products/$CONFIGURATION/$APP_NAME.app" + +if [ ! -d "$APP_PATH" ]; then + echo "ERROR: Built application not found." + exit 1 +fi + +echo +echo "==> Validating application bundle" + +plutil -lint "$APP_PATH/Contents/Info.plist" + +echo +echo "==> Application information" + +defaults read "$APP_PATH/Contents/Info" \ + CFBundleIdentifier || true + +defaults read "$APP_PATH/Contents/Info" \ + CFBundleShortVersionString || true + +defaults read "$APP_PATH/Contents/Info" \ + CFBundleVersion || true + +echo +echo "==> Checking executable" + +EXECUTABLE=$( + /usr/libexec/PlistBuddy \ + -c "Print CFBundleExecutable" \ + "$APP_PATH/Contents/Info.plist" +) + +test -x "$APP_PATH/Contents/MacOS/$EXECUTABLE" + +file "$APP_PATH/Contents/MacOS/$EXECUTABLE" + +echo +echo "==> Checking available test targets" + +if [ "$RUN_TESTS" = true ]; then + + TEST_OUTPUT="$LOG_DIR/test-targets.txt" + + xcodebuild \ + -list \ + -project "$PROJECT" > "$TEST_OUTPUT" + + if grep -Eq '^[[:space:]]+.*Tests$' "$TEST_OUTPUT"; then + + echo "Test targets detected." + + xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -destination "platform=macOS" \ + -derivedDataPath "$BUILD_DIR" \ + CODE_SIGNING_ALLOWED=NO \ + test + + else + echo "No test targets detected; skipping XCTest." + fi +fi + +echo +echo "==> Build size" + +du -sh "$APP_PATH" + +if [ "$LAUNCH" = true ]; then + echo + echo "==> Launching OrionToolbar" + + open "$APP_PATH" +fi + +echo +echo "============================================================" +echo " BUILD AND TEST COMPLETE" +echo "============================================================" +echo +echo "Application:" +echo "$APP_PATH" +echo +echo "Log:" +echo "$LOG_FILE" +echo diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..9e6dac8 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,318 @@ +#!/bin/bash +set -euo pipefail + +# ============================================================ +# OrionToolbar - Developer ID Release +# ============================================================ + +APP_NAME="OrionToolbar" +SCHEME="OrionToolbar" +PROJECT="OrionToolbar.xcodeproj" + +SIGNING_IDENTITY="B99D353C88996866C49E4389B375645F76870A3F" +TEAM_ID="39R6U3437D" +NOTARY_PROFILE="orion-notary" + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +BUILD_DIR="$ROOT_DIR/build" +LOG_DIR="$ROOT_DIR/build-logs" + +ARCHIVE_PATH="$BUILD_DIR/$APP_NAME.xcarchive" +EXPORT_DIR="$BUILD_DIR/export" +DMG_STAGE="$BUILD_DIR/dmg" + +DMG_PATH="$ROOT_DIR/$APP_NAME.dmg" +EXPORT_OPTIONS="$BUILD_DIR/ExportOptions.plist" + +mkdir -p "$LOG_DIR" + +LOG_FILE="$LOG_DIR/release-$(date +%Y%m%d-%H%M%S).log" + +exec > >(tee -a "$LOG_FILE") 2>&1 + +echo +echo "============================================================" +echo " ORION TOOLBAR - DIRECT DISTRIBUTION" +echo "============================================================" +echo + +cd "$ROOT_DIR" + +# ------------------------------------------------------------ +# Prerequisites +# ------------------------------------------------------------ + +echo "==> Checking signing identity" + +security find-identity -v -p codesigning + +if ! security find-identity -v -p codesigning | \ + grep -q "$SIGNING_IDENTITY"; then + + echo "ERROR: Developer ID signing identity unavailable." + exit 1 +fi + +echo +echo "==> Checking notarization profile" + +xcrun notarytool history \ + --keychain-profile "$NOTARY_PROFILE" \ + >/dev/null + +echo "Notarization profile available." + +# ------------------------------------------------------------ +# Clean +# ------------------------------------------------------------ + +echo +echo "==> Cleaning previous release" + +rm -rf "$BUILD_DIR" +rm -f "$DMG_PATH" + +mkdir -p \ + "$BUILD_DIR" \ + "$EXPORT_DIR" \ + "$DMG_STAGE" + +# ------------------------------------------------------------ +# Export options +# ------------------------------------------------------------ + +cat > "$EXPORT_OPTIONS" < + + + + method + developer-id + + signingStyle + automatic + + teamID + $TEAM_ID + + +EOF + +# ------------------------------------------------------------ +# Archive +# ------------------------------------------------------------ + +echo +echo "==> Creating Release archive" + +xcodebuild \ + -project "$PROJECT" \ + -scheme "$SCHEME" \ + -configuration Release \ + -destination "generic/platform=macOS" \ + -archivePath "$ARCHIVE_PATH" \ + clean archive + +echo +echo "==> Archive created" + +# ------------------------------------------------------------ +# Export +# ------------------------------------------------------------ + +echo +echo "==> Exporting Developer ID application" + +xcodebuild \ + -exportArchive \ + -archivePath "$ARCHIVE_PATH" \ + -exportPath "$EXPORT_DIR" \ + -exportOptionsPlist "$EXPORT_OPTIONS" + +APP_PATH="$EXPORT_DIR/$APP_NAME.app" + +if [ ! -d "$APP_PATH" ]; then + echo "ERROR: Exported application not found." + exit 1 +fi + +# ------------------------------------------------------------ +# Verify signature +# ------------------------------------------------------------ + +echo +echo "==> Verifying application signature" + +codesign \ + --verify \ + --deep \ + --strict \ + --verbose=2 \ + "$APP_PATH" + +echo +echo "==> Checking signing team" + +ACTUAL_TEAM=$( + codesign -dv --verbose=4 "$APP_PATH" 2>&1 | + sed -n 's/^TeamIdentifier=//p' +) + +if [ "$ACTUAL_TEAM" != "$TEAM_ID" ]; then + echo "ERROR: Incorrect signing team: $ACTUAL_TEAM" + exit 1 +fi + +echo "Signing team verified: $ACTUAL_TEAM" + +# ------------------------------------------------------------ +# Notarize application +# ------------------------------------------------------------ + +echo +echo "==> Creating notarization ZIP" + +APP_ZIP="$BUILD_DIR/$APP_NAME.zip" + +ditto \ + -c \ + -k \ + --keepParent \ + "$APP_PATH" \ + "$APP_ZIP" + +echo +echo "==> Notarizing application" + +xcrun notarytool submit \ + "$APP_ZIP" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait + +echo +echo "==> Stapling application" + +xcrun stapler staple "$APP_PATH" + +echo +echo "==> Validating application ticket" + +xcrun stapler validate "$APP_PATH" + +echo +echo "==> Gatekeeper application verification" + +spctl \ + --assess \ + --type execute \ + --verbose=4 \ + "$APP_PATH" + +# ------------------------------------------------------------ +# Prepare DMG +# ------------------------------------------------------------ + +echo +echo "==> Preparing DMG" + +rm -rf "$DMG_STAGE" +mkdir -p "$DMG_STAGE" + +ditto "$APP_PATH" "$DMG_STAGE/$APP_NAME.app" + +ln -s /Applications "$DMG_STAGE/Applications" + +# ------------------------------------------------------------ +# Create DMG +# ------------------------------------------------------------ + +echo +echo "==> Creating DMG" + +hdiutil create \ + -volname "$APP_NAME" \ + -srcfolder "$DMG_STAGE" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +# ------------------------------------------------------------ +# Sign DMG +# ------------------------------------------------------------ + +echo +echo "==> Signing DMG" + +codesign \ + --force \ + --timestamp \ + --sign "$SIGNING_IDENTITY" \ + "$DMG_PATH" + +echo +echo "==> Verifying DMG signature" + +codesign \ + --verify \ + --verbose=2 \ + "$DMG_PATH" + +# ------------------------------------------------------------ +# Notarize DMG +# ------------------------------------------------------------ + +echo +echo "==> Notarizing DMG" + +xcrun notarytool submit \ + "$DMG_PATH" \ + --keychain-profile "$NOTARY_PROFILE" \ + --wait + +echo +echo "==> Stapling DMG" + +xcrun stapler staple "$DMG_PATH" + +echo +echo "==> Validating DMG ticket" + +xcrun stapler validate "$DMG_PATH" + +# ------------------------------------------------------------ +# Final verification +# ------------------------------------------------------------ + +echo +echo "==> Final Gatekeeper verification" + +spctl \ + --assess \ + --type open \ + --context context:primary-signature \ + --verbose=4 \ + "$DMG_PATH" + +echo +echo "==> Final DMG information" + +ls -lh "$DMG_PATH" + +echo +echo "==> SHA-256" + +shasum -a 256 "$DMG_PATH" + +echo +echo "============================================================" +echo " RELEASE BUILD COMPLETE" +echo "============================================================" +echo +echo "DMG:" +echo "$DMG_PATH" +echo +echo "Build log:" +echo "$LOG_FILE" +echo