Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,22 @@ Every launch begins at the start menu. While that menu is open, Try Omarchy beha
Restarting from inside Omarchy reboots the guest in the same Try Omarchy app.
Shutting down Omarchy closes the app and leaves it closed.

## Processor cores and memory

Choose **Configure…** next to **Resources** in the start menu to set the VM's
processor cores and memory. **Save** remembers both values for the next launch;
**Cancel** discards edits, and **Use Defaults** restores the original allocation
in the editor. Shut down Omarchy and launch it again to change a running VM's
resources.

The default is 8 cores (or all cores on a smaller Mac) and 4 GiB of memory.
You can select from 4 cores up to all of this Mac's cores, and memory in whole
GiB starting at 2 GiB. Allocations above 4 GiB leave at least 4 GiB of physical
memory for macOS; an 8 GiB Mac therefore allows up to 4 GiB, and a 16 GiB Mac up
to 12 GiB. This is a physical-memory limit, not a measurement of currently free
memory. A saved value that no longer fits the host falls back to its default
without overwriting the saved choice.

## 1Password

Install 1Password from the Omarchy menu. On ARM64 guests, Try Omarchy downloads
Expand Down
13 changes: 13 additions & 0 deletions macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ variable still wins, so the development and test override keeps working
unchanged. Reset composes its environment exactly as a launch does, so it
always erases the workspace the user is actually running.

The Resources editor stores CPU count and whole GiB of RAM together in the
versioned `vmResourcePreferences` UserDefaults value. The start menu and launch
resolve the same host limits: 4 through all host CPUs, and 2 GiB through installed
RAM minus 4 GiB, with the historical 4 GiB default always allowed. Out-of-range
saved values resolve independently to their defaults without rewriting storage.
The launcher independently checks `OMARCHY_QEMU_GPU_CPUS` and
`OMARCHY_QEMU_GPU_MEMORY_GIB` before touching VM storage and passes them to QEMU's
`-smp` and `-m`. Ordinary app launches replace inherited values with the displayed
selection; direct script invocations can set these variables for development.
Storage-only resets strip both keys, and boot recovery keeps its existing small
allocation. Resource changes take effect on the next launch, without rebuilding
the app or replacing its signature.

Port forwarding is one versioned generic mapping list. The editor's **Add SSH**
action only inserts the ordinary TCP `2222 → 22` preset; users may edit it like
any other mapping. The signed shell parser remains the sole QEMU `hostfwd`
Expand Down
2 changes: 2 additions & 0 deletions macos/Sources/OmarchyVMHelper/QEMUGPULauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ enum QEMUGPURuntimeEnvironment {
AudioLaunchConfiguration.inputDeviceNameKey,
SharedFolderPolicy.environmentKey,
PortForwardPolicy.environmentKey,
VMResourceLaunchConfiguration.cpuEnvironmentKey,
VMResourceLaunchConfiguration.memoryEnvironmentKey,
] {
environment.removeValue(forKey: key)
}
Expand Down
4 changes: 4 additions & 0 deletions macos/Sources/OmarchyVMHelper/StartMenuPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ struct StartMenuPortForwardingPresentation: Equatable {
/// of AppKit makes the important behavior testable without relying on window
/// positions, font metrics, run-loop timing, or the current display size.
enum StartMenuPresentation {
static func resources(_ resources: VMResources) -> String {
"\(resources.cpuCount) processor cores · \(resources.memoryGiB) GiB memory"
}

static let incompatibleWorkspaceDetail = "The saved VM uses a storage or boot format this version can’t use, or its data folder contains multiple saved VMs. Reset Omarchy to create a compatible VM. Resetting permanently erases everything in the VM."

static let bootRecoveryConfirmationTitle = "Prepare this saved VM once?"
Expand Down
45 changes: 42 additions & 3 deletions macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
private let setSharedFolderEnabled: (Bool) -> Void
private let portForwardingStatus: () -> [PortForwardMapping]
private let savePortForwarding: ([PortForwardMapping]) -> String?
private let resources: () -> VMResources
private let resourceLimits: VMResourceLimits
private let saveResources: (VMResources) -> Void
private let immersiveMode: () -> Bool
private let setImmersiveMode: (Bool) -> Void
private let launch: () -> Void
Expand All @@ -220,6 +223,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
private var resetConfirmationPrompt: ResetConfirmationPrompt?
private weak var startMenuScrollView: NSScrollView?
private(set) var portForwardingEditor: PortForwardingEditor?
private var resourceEditor: VMResourceEditor?
private weak var immersiveCaption: NSTextField?
private lazy var permissionWindowRestorer = PermissionWindowRestorer(
canRestore: { [weak self] in
Expand All @@ -232,6 +236,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
&& self.window.attachedSheet == nil
&& NSApp.modalWindow == nil
&& self.portForwardingEditor == nil
&& self.resourceEditor == nil
},
isApplicationActive: { NSApp.isActive },
orderFrontRegardless: { [weak self] frame in
Expand Down Expand Up @@ -281,6 +286,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
setSharedFolderEnabled: @escaping (Bool) -> Void,
portForwardingStatus: @escaping () -> [PortForwardMapping] = { [] },
savePortForwarding: @escaping ([PortForwardMapping]) -> String? = { _ in nil },
resources: @escaping () -> VMResources = { VMResourceLimits.current.defaults },
resourceLimits: VMResourceLimits = .current,
saveResources: @escaping (VMResources) -> Void = { _ in },
immersiveMode: @escaping () -> Bool = { true },
setImmersiveMode: @escaping (Bool) -> Void = { _ in },
launch: @escaping () -> Void
Expand All @@ -305,12 +313,15 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
self.setSharedFolderEnabled = setSharedFolderEnabled
self.portForwardingStatus = portForwardingStatus
self.savePortForwarding = savePortForwarding
self.resources = resources
self.resourceLimits = resourceLimits
self.saveResources = saveResources
self.immersiveMode = immersiveMode
self.setImmersiveMode = setImmersiveMode
self.launch = launch

window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 760),
contentRect: NSRect(x: 0, y: 0, width: 600, height: 832),
styleMask: [.titled, .closable, .fullSizeContentView],
backing: .buffered,
defer: false
Expand Down Expand Up @@ -339,7 +350,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
// which any difference in system font metrics turned into a button
// clipped off the window.
let availableHeight = max(480, visibleFrame.height - 32)
window.setContentSize(NSSize(width: 600, height: min(760, availableHeight)))
window.setContentSize(NSSize(width: 600, height: min(832, availableHeight)))
}
}

Expand Down Expand Up @@ -367,6 +378,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
resetConfirmationPrompt = nil
portForwardingEditor?.dismiss()
portForwardingEditor = nil
resourceEditor?.dismiss()
resourceEditor = nil
window.orderOut(nil)
}

Expand Down Expand Up @@ -558,6 +571,16 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
minimumHeight: 90
)
let immersiveRow = immersiveSettingRow(isEnabled: immersiveMode())
let selectedResources = resources()
let resourceRow = permissionRow(
symbolName: "cpu",
title: "Resources",
detail: StartMenuPresentation.resources(selectedResources),
granted: selectedResources != resourceLimits.defaults,
statusLabels: ("● Custom", "○ Default"),
actions: [("Configure…", #selector(beginResourceConfiguration))],
minimumHeight: 72
)

let storageStatus = storageLocationStatus()
var storageRow: NSView?
Expand Down Expand Up @@ -614,7 +637,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
if let storageRow {
permissionRowViews.append(storageRow)
}
permissionRowViews.append(contentsOf: [portForwardingRow, immersiveRow])
permissionRowViews.append(contentsOf: [resourceRow, portForwardingRow, immersiveRow])

var permissionRowsAndSeparators: [NSView] = []
for (index, row) in permissionRowViews.enumerated() {
Expand Down Expand Up @@ -1318,6 +1341,22 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
editor.beginSheet(for: window)
}

@objc private func beginResourceConfiguration() {
guard !launchInProgress, !resetInProgress, window.attachedSheet == nil else { return }
permissionWindowRestorer.cancel()
let editor = VMResourceEditor(
resources: resources(),
limits: resourceLimits,
save: { [weak self] resources in self?.saveResources(resources) },
didClose: { [weak self] in
self?.resourceEditor = nil
self?.render()
}
)
resourceEditor = editor
editor.beginSheet(for: window)
}

@objc private func changeImmersiveMode(_ sender: NSSwitch) {
guard !launchInProgress, !resetInProgress else { return }
let isEnabled = sender.state == .on
Expand Down
21 changes: 20 additions & 1 deletion macos/Sources/OmarchyVMHelper/VMApplicationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
private let sharedFolderStore: SharedFolderPreferenceStore
private let portForwardingStore: PortForwardingPreferenceStore
private let fullscreenPreferenceStore: FullscreenPreferenceStore
private let resourcePreferenceStore: VMResourcePreferenceStore
private let resourceLimits: VMResourceLimits
private let storageLocationStore: StorageLocationPreferenceStore
private let volumeProbe: VolumeProbing
private let volumeRootDetector: VolumeRootDetecting
Expand Down Expand Up @@ -92,6 +94,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
sharedFolderStore: SharedFolderPreferenceStore = SharedFolderPreferenceStore(),
portForwardingStore: PortForwardingPreferenceStore = PortForwardingPreferenceStore(),
fullscreenPreferenceStore: FullscreenPreferenceStore = FullscreenPreferenceStore(),
resourcePreferenceStore: VMResourcePreferenceStore = VMResourcePreferenceStore(),
resourceLimits: VMResourceLimits = .current,
storageLocationStore: StorageLocationPreferenceStore = StorageLocationPreferenceStore(),
volumeProbe: VolumeProbing = URLVolumeProbe(),
volumeRootDetector: VolumeRootDetecting = FileManagerVolumeRootDetector(),
Expand All @@ -106,6 +110,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
self.sharedFolderStore = sharedFolderStore
self.portForwardingStore = portForwardingStore
self.fullscreenPreferenceStore = fullscreenPreferenceStore
self.resourcePreferenceStore = resourcePreferenceStore
self.resourceLimits = resourceLimits
self.storageLocationStore = storageLocationStore
self.volumeProbe = volumeProbe
self.volumeRootDetector = volumeRootDetector
Expand Down Expand Up @@ -196,6 +202,14 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
savePortForwarding: { [weak self] mappings in
self?.savePortForwarding(mappings)
},
resources: { [weak self, resourceLimits] in
guard let self else { return resourceLimits.defaults }
return self.resourceLimits.resolve(self.resourcePreferenceStore.load())
},
resourceLimits: resourceLimits,
saveResources: { [weak self] resources in
self?.resourcePreferenceStore.save(resources)
},
immersiveMode: { [weak self] in
self?.fullscreenPreferenceStore.load().isImmersive ?? true
},
Expand Down Expand Up @@ -413,8 +427,13 @@ final class VMApplicationController: NSObject, NSApplicationDelegate {
baseEnvironment: forwarding.environment,
preferences: fullscreenPreferenceStore.load()
)
let storage = StorageLocationLaunchConfiguration.make(
let resources = VMResourceLaunchConfiguration.make(
baseEnvironment: fullscreen.environment,
preferences: resourcePreferenceStore.load(),
limits: resourceLimits
)
let storage = StorageLocationLaunchConfiguration.make(
baseEnvironment: resources.environment,
preference: storageLocationStore.load(),
metrics: bundledMetrics,
probe: volumeProbe,
Expand Down
156 changes: 156 additions & 0 deletions macos/Sources/OmarchyVMHelper/VMResourceEditor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import AppKit

/// Edits a draft; only Save publishes it to the next VM launch.
@MainActor
final class VMResourceEditor: NSObject, NSTextFieldDelegate {
private let limits: VMResourceLimits
private let saveHandler: (VMResources) -> Void
private let closeHandler: () -> Void
private let alert = NSAlert()
private let cpuField = NSTextField()
private let memoryField = NSTextField()
private let cpuStepper = NSStepper()
private let memoryStepper = NSStepper()
private let validationLabel = NSTextField(wrappingLabelWithString: "")

init(
resources: VMResources,
limits: VMResourceLimits,
save: @escaping (VMResources) -> Void,
didClose: @escaping () -> Void
) {
self.limits = limits
saveHandler = save
closeHandler = didClose
super.init()

alert.messageText = "Virtual machine resources"
alert.informativeText = "Changes apply on the next launch. This Mac has \(limits.hostCPUCount) processor cores and \(limits.hostMemoryBytes / VMResourceLimits.bytesPerGiB) GiB of memory."
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
alert.buttons[0].identifier = NSUserInterfaceItemIdentifier("vm-resources-save")
alert.buttons[1].identifier = NSUserInterfaceItemIdentifier("vm-resources-cancel")

let cpuRow = resourceRow(
title: "Processor cores", field: cpuField, stepper: cpuStepper,
range: limits.cpuRange, identifier: "vm-resources-cpu"
)
let memoryRow = resourceRow(
title: "Memory (GiB)", field: memoryField, stepper: memoryStepper,
range: limits.memoryRange, identifier: "vm-resources-memory"
)
let headroom = NSTextField(wrappingLabelWithString:
"Memory above 4 GiB leaves at least 4 GiB for macOS. You can use all processor cores."
)
headroom.font = .systemFont(ofSize: 12)
headroom.textColor = .secondaryLabelColor
validationLabel.font = .systemFont(ofSize: 12)
validationLabel.textColor = .systemRed
validationLabel.identifier = NSUserInterfaceItemIdentifier("vm-resources-validation")
validationLabel.setAccessibilityElement(true)

let defaults = NSButton(title: "Use Defaults", target: self, action: #selector(useDefaults))
defaults.bezelStyle = .rounded
defaults.identifier = NSUserInterfaceItemIdentifier("vm-resources-defaults")

let stack = NSStackView(views: [cpuRow, memoryRow, headroom, validationLabel, defaults])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
let accessory = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 220))
accessory.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: accessory.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: accessory.trailingAnchor),
stack.topAnchor.constraint(equalTo: accessory.topAnchor),
stack.bottomAnchor.constraint(lessThanOrEqualTo: accessory.bottomAnchor),
headroom.widthAnchor.constraint(equalTo: stack.widthAnchor),
validationLabel.widthAnchor.constraint(equalTo: stack.widthAnchor),
validationLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: 32),
])
alert.accessoryView = accessory
setFields(resources)
}

func beginSheet(for parent: NSWindow) {
alert.beginSheetModal(for: parent) { [weak self] response in
guard let self else { return }
if response == .alertFirstButtonReturn,
let resources = try? self.limits.validate(
cpuCount: self.cpuField.stringValue, memoryGiB: self.memoryField.stringValue
) {
self.saveHandler(resources)
}
self.closeHandler()
}
}

func dismiss() {
alert.window.sheetParent?.endSheet(alert.window, returnCode: .alertSecondButtonReturn)
alert.window.orderOut(nil)
}

func controlTextDidChange(_ notification: Notification) {
updateValidation()
}

private func resourceRow(
title: String, field: NSTextField, stepper: NSStepper,
range: ClosedRange<Int>, identifier: String
) -> NSView {
let label = NSTextField(labelWithString: title)
label.widthAnchor.constraint(equalToConstant: 140).isActive = true
field.delegate = self
field.identifier = NSUserInterfaceItemIdentifier(identifier)
field.setAccessibilityLabel(title)
field.setAccessibilityHelp("Whole number from \(range.lowerBound) to \(range.upperBound)")
field.widthAnchor.constraint(equalToConstant: 76).isActive = true
stepper.minValue = Double(range.lowerBound)
stepper.maxValue = Double(range.upperBound)
stepper.increment = 1
stepper.valueWraps = false
stepper.target = self
stepper.action = #selector(step(_:))
stepper.setAccessibilityLabel(title)
stepper.identifier = NSUserInterfaceItemIdentifier("\(identifier)-stepper")
let row = NSStackView(views: [label, field, stepper])
row.orientation = .horizontal
row.alignment = .centerY
row.spacing = 8
return row
}

@objc private func step(_ sender: NSStepper) {
let field = sender === cpuStepper ? cpuField : memoryField
field.stringValue = String(sender.integerValue)
updateValidation()
}

@objc private func useDefaults() {
setFields(limits.defaults)
}

private func setFields(_ resources: VMResources) {
cpuField.stringValue = String(resources.cpuCount)
memoryField.stringValue = String(resources.memoryGiB)
updateValidation()
}

private func updateValidation() {
if let cpus = Int(cpuField.stringValue), limits.cpuRange.contains(cpus) {
cpuStepper.integerValue = cpus
}
if let memory = Int(memoryField.stringValue), limits.memoryRange.contains(memory) {
memoryStepper.integerValue = memory
}
do {
_ = try limits.validate(cpuCount: cpuField.stringValue, memoryGiB: memoryField.stringValue)
validationLabel.stringValue = ""
alert.buttons[0].isEnabled = true
} catch {
validationLabel.stringValue = error.localizedDescription
alert.buttons[0].isEnabled = false
}
}
}
Loading
Loading