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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ test:
@cd $(ROOT)/macos && SWIFT_MODULECACHE_PATH=$(ROOT)/macos/.build/module-cache/swift CLANG_MODULE_CACHE_PATH=$(ROOT)/macos/.build/module-cache/clang swift test --disable-sandbox
@$(ROOT)/macos/Tests/qemu-port-forwarding.test.sh
@$(ROOT)/macos/Tests/run-qemu-ssh-contract.test.sh
@$(ROOT)/macos/Tests/qemu-memory-contract.test.sh
@$(ROOT)/macos/Tests/qemu-power-actions.test.sh
@$(ROOT)/macos/Tests/qemu-persistent-storage.test.sh

Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,25 @@ Loopback binding prevents devices on Wi-Fi, Ethernet, or the wider LAN from
connecting. It does not isolate the listener from other users or processes on
the same Mac; guest SSH authentication is still required.

## Giving Omarchy more memory

Use **Memory** on the start menu to pick how much of the Mac's RAM the guest
boots with. The default is 4 GiB, and the menu only offers larger allocations
(6, 8, 12, or 16 GiB) that leave macOS at least 8 GiB for itself, so an 8 GiB
Mac shows the default alone. The choice is not tied to installation: change it
before any launch, and it applies the next time Omarchy starts. Memory is a
boot-time QEMU setting, never part of the guest image or VM data, so switching
allocations never needs a reset and never touches your files. A stored choice
that no longer fits the Mac it runs on falls back to the default.

Scripted launches can set `OMARCHY_QEMU_GPU_MEMORY_MIB` (a whole number of
MiB) instead. The launcher's own rule is looser than the menu's: it refuses
values below the guest's 2048 MiB minimum, and values above the 4096 default
that would leave the host under 4 GiB. The default itself always boots, and
an environment value the menu would not offer (say 12 GiB on a 16 GiB Mac)
is still accepted — the menu is deliberately conservative, the launcher is a
safety floor.

## Requirements

- Apple Silicon Mac (`arm64`)
Expand Down
10 changes: 10 additions & 0 deletions macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ 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.

Guest memory follows the same preference pattern: the start menu's **Memory**
row stores its choice in `UserDefaults` and publishes it to the launcher as
`OMARCHY_QEMU_GPU_MEMORY_MIB`. The app only ever exports a value it resolved
against this host (non-default choices must leave macOS 8 GiB), while the
launcher independently enforces the guest's 2048 MiB manifest minimum and, for
values above the 4096 default, a 4 GiB host floor — so a hand-set environment
value gets the loose safety rule, not the menu's conservative one. Storage
resets strip the variable like the other integration settings; memory is a
boot-time `-m` allocation and never part of the guest image or VM data.

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
111 changes: 111 additions & 0 deletions macos/Sources/OmarchyVMHelper/MemoryPreferences.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Foundation

/// How much host RAM the guest boots with. The value is a boot-time QEMU
/// setting (`-m`), so a change always applies on the next launch; it is never
/// baked into the guest image or the persistent VM data.
enum MemoryPolicy {
/// Matches `recommendedMemoryMiB` in the guest runtime manifest, which the
/// launcher verifies at build time.
static let defaultMemoryMiB = 4096

/// Matches `minimumMemoryMiB` in the guest runtime manifest.
static let minimumMemoryMiB = 2048

/// The fixed menu of allocations the start menu can offer. A short list
/// keeps the row a one-click choice instead of a text field that needs
/// validation feedback.
static let choicesMiB = [4096, 6144, 8192, 12288, 16384]

/// A non-default choice is offered only when it leaves the host this much
/// memory. macOS under ~8 GiB of headroom pushes the host into swapping,
/// which makes the guest slower, not faster.
static let hostHeadroomMiB = 8192

static let environmentKey = "OMARCHY_QEMU_GPU_MEMORY_MIB"

static func hostMemoryMiB() -> Int {
Int(ProcessInfo.processInfo.physicalMemory / (1024 * 1024))
}

/// The allocations the start menu offers on a host with this much RAM.
/// The default is always available, so the row never goes empty.
static func allowedChoicesMiB(hostMemoryMiB: Int) -> [Int] {
choicesMiB.filter {
$0 == defaultMemoryMiB || $0 + hostHeadroomMiB <= hostMemoryMiB
}
}

/// The allocation to actually launch with. A stored preference that no
/// longer fits this host (or was never a listed choice) falls back to the
/// default instead of failing the launch.
static func resolvedMemoryMiB(preferredMiB: Int, hostMemoryMiB: Int) -> Int {
allowedChoicesMiB(hostMemoryMiB: hostMemoryMiB).contains(preferredMiB)
? preferredMiB
: defaultMemoryMiB
}

static func displayLabel(memoryMiB: Int) -> String {
memoryMiB % 1024 == 0
? "\(memoryMiB / 1024) GiB"
: "\(memoryMiB) MiB"
}
}

struct MemoryPreferences: Equatable {
var memoryMiB: Int

static let defaults = Self(memoryMiB: MemoryPolicy.defaultMemoryMiB)
}

struct MemoryPreferenceStore {
static let key = "memoryPreferences"
static let schemaVersion = 1

private let defaults: UserDefaults

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

func load() -> MemoryPreferences {
guard let data = defaults.data(forKey: Self.key),
let payload = try? JSONDecoder().decode(Payload.self, from: data),
payload.schemaVersion == Self.schemaVersion else {
return .defaults
}
return MemoryPreferences(memoryMiB: payload.memoryMiB)
}

func save(_ preferences: MemoryPreferences) {
let payload = Payload(
schemaVersion: Self.schemaVersion,
memoryMiB: preferences.memoryMiB
)
guard let data = try? JSONEncoder().encode(payload) else { return }
defaults.set(data, forKey: Self.key)
}

private struct Payload: Codable {
let schemaVersion: Int
let memoryMiB: Int
}
}

struct MemoryLaunchConfiguration: Equatable {
let environment: [String: String]

static func make(
baseEnvironment: [String: String],
preferences: MemoryPreferences,
hostMemoryMiB: Int
) -> Self {
var environment = baseEnvironment
environment[MemoryPolicy.environmentKey] = String(
MemoryPolicy.resolvedMemoryMiB(
preferredMiB: preferences.memoryMiB,
hostMemoryMiB: hostMemoryMiB
)
)
return Self(environment: environment)
}
}
1 change: 1 addition & 0 deletions macos/Sources/OmarchyVMHelper/QEMUGPULauncher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ enum QEMUGPURuntimeEnvironment {
AudioLaunchConfiguration.inputDeviceNameKey,
SharedFolderPolicy.environmentKey,
PortForwardPolicy.environmentKey,
MemoryPolicy.environmentKey,
] {
environment.removeValue(forKey: key)
}
Expand Down
37 changes: 37 additions & 0 deletions macos/Sources/OmarchyVMHelper/StartMenuPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ struct StartMenuSharedFolderPresentation: Equatable {
let toggleActionTitle: String?
}

struct StartMenuMemoryPresentation: Equatable {
let detail: String
/// The MiB value behind each popup entry, in display order.
let choicesMiB: [Int]
let choiceTitles: [String]
let selectedIndex: Int
/// False when this Mac's RAM fits only the default, so the popup renders
/// disabled rather than offering a single-item "choice".
let isAdjustable: Bool
}

struct StartMenuPortForwardingPresentation: Equatable {
let detail: String
let compactDetailLines: [String]?
Expand Down Expand Up @@ -170,6 +181,32 @@ enum StartMenuPresentation {
)
}

static func memory(
preferredMiB: Int,
hostMemoryMiB: Int
) -> StartMenuMemoryPresentation {
let choices = MemoryPolicy.allowedChoicesMiB(hostMemoryMiB: hostMemoryMiB)
let selected = MemoryPolicy.resolvedMemoryMiB(
preferredMiB: preferredMiB,
hostMemoryMiB: hostMemoryMiB
)
let titles = choices.map { choice in
choice == MemoryPolicy.defaultMemoryMiB
? "\(MemoryPolicy.displayLabel(memoryMiB: choice)) · default"
: MemoryPolicy.displayLabel(memoryMiB: choice)
}
let isAdjustable = choices.count > 1
return StartMenuMemoryPresentation(
detail: isAdjustable
? "Give Omarchy more of this Mac’s memory. Applies on the next launch."
: "This Mac’s memory fits the \(MemoryPolicy.displayLabel(memoryMiB: MemoryPolicy.defaultMemoryMiB)) default.",
choicesMiB: choices,
choiceTitles: titles,
selectedIndex: choices.firstIndex(of: selected) ?? 0,
isAdjustable: isAdjustable
)
}

static func immersiveDetail(isEnabled: Bool) -> String {
isEnabled
? "Mac menu bar and Dock stay hidden while Omarchy is Full Screen."
Expand Down
106 changes: 99 additions & 7 deletions macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
private let savePortForwarding: ([PortForwardMapping]) -> String?
private let immersiveMode: () -> Bool
private let setImmersiveMode: (Bool) -> Void
private let memoryChoiceMiB: () -> Int
private let setMemoryChoiceMiB: (Int) -> Void
private let hostMemoryMiB: () -> Int
private let launch: () -> Void
private let canResetStorage: Bool
private let storageLocation: () -> String?
Expand Down Expand Up @@ -156,6 +159,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
savePortForwarding: @escaping ([PortForwardMapping]) -> String? = { _ in nil },
immersiveMode: @escaping () -> Bool = { true },
setImmersiveMode: @escaping (Bool) -> Void = { _ in },
memoryChoiceMiB: @escaping () -> Int = { MemoryPolicy.defaultMemoryMiB },
setMemoryChoiceMiB: @escaping (Int) -> Void = { _ in },
hostMemoryMiB: @escaping () -> Int = { MemoryPolicy.hostMemoryMiB() },
launch: @escaping () -> Void
) {
self.accessibilityStatus = accessibilityStatus
Expand All @@ -180,10 +186,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
self.savePortForwarding = savePortForwarding
self.immersiveMode = immersiveMode
self.setImmersiveMode = setImmersiveMode
self.memoryChoiceMiB = memoryChoiceMiB
self.setMemoryChoiceMiB = setMemoryChoiceMiB
self.hostMemoryMiB = hostMemoryMiB
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 All @@ -207,12 +216,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
func prepareForPresentation(visibleFrame: NSRect?) {
render()
if let visibleFrame {
// The menu carries six rows once a resettable VM can choose where it
// lives. At 690 the launch button cleared the bottom edge by 15pt,
// which any difference in system font metrics turned into a button
// clipped off the window.
// The memory row added one 72pt row to the menu that previously
// fit at 760. At 690 the launch button cleared the bottom edge by
// 15pt, which any difference in system font metrics turned into a
// button clipped off the window; on displays shorter than the
// window the content scrolls rather than clips.
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 @@ -416,6 +426,12 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
)
let immersiveRow = immersiveSettingRow(isEnabled: immersiveMode())

let memoryPresentation = StartMenuPresentation.memory(
preferredMiB: memoryChoiceMiB(),
hostMemoryMiB: hostMemoryMiB()
)
let memoryRow = memorySettingRow(presentation: memoryPresentation)

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

var permissionRowsAndSeparators: [NSView] = []
for (index, row) in permissionRowViews.enumerated() {
Expand Down Expand Up @@ -959,6 +975,82 @@ final class StartMenuWindow: NSObject, NSWindowDelegate {
return row
}

private func memorySettingRow(presentation: StartMenuMemoryPresentation) -> NSView {
let symbol = NSImageView()
symbol.image = NSImage(systemSymbolName: "memorychip", accessibilityDescription: nil)
symbol.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 19, weight: .medium)
symbol.contentTintColor = .controlAccentColor
symbol.identifier = NSUserInterfaceItemIdentifier("memory-symbol")
symbol.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
symbol.widthAnchor.constraint(equalToConstant: 26),
symbol.heightAnchor.constraint(equalToConstant: 26),
])

let title = NSTextField(labelWithString: "Memory")
title.font = .systemFont(ofSize: 14, weight: .semibold)
title.identifier = NSUserInterfaceItemIdentifier("memory-title")

let detail = NSTextField(wrappingLabelWithString: presentation.detail)
detail.font = .systemFont(ofSize: 12)
detail.textColor = .secondaryLabelColor
detail.maximumNumberOfLines = 2
detail.identifier = NSUserInterfaceItemIdentifier("memory-caption")

let labels = NSStackView(views: [title, detail])
labels.orientation = .vertical
labels.alignment = .leading
labels.spacing = 3
labels.translatesAutoresizingMaskIntoConstraints = false

let popup = NSPopUpButton()
popup.addItems(withTitles: presentation.choiceTitles)
// Each item carries its own MiB value, so the selection callback needs
// no separate index-to-value state that a re-render could desync.
for (item, choiceMiB) in zip(popup.itemArray, presentation.choicesMiB) {
item.tag = choiceMiB
}
popup.selectItem(at: presentation.selectedIndex)
popup.target = self
popup.action = #selector(changeMemoryChoice(_:))
popup.isEnabled = presentation.isAdjustable
&& !microphoneRequestInFlight
&& !cameraRequestInFlight
&& !launchInProgress
&& !resetInProgress
popup.identifier = NSUserInterfaceItemIdentifier("memory-popup")
popup.setAccessibilityLabel("Memory")
popup.setAccessibilityTitleUIElement(title)
popup.setAccessibilityHelp(presentation.detail)
popup.translatesAutoresizingMaskIntoConstraints = false

let row = NSView()
row.identifier = NSUserInterfaceItemIdentifier("memory-row")
row.translatesAutoresizingMaskIntoConstraints = false
row.addSubview(symbol)
row.addSubview(labels)
row.addSubview(popup)
NSLayoutConstraint.activate([
row.heightAnchor.constraint(greaterThanOrEqualToConstant: 72),
symbol.leadingAnchor.constraint(equalTo: row.leadingAnchor),
symbol.centerYAnchor.constraint(equalTo: row.centerYAnchor),
labels.leadingAnchor.constraint(equalTo: symbol.trailingAnchor, constant: 12),
labels.centerYAnchor.constraint(equalTo: row.centerYAnchor),
labels.trailingAnchor.constraint(lessThanOrEqualTo: popup.leadingAnchor, constant: -12),
popup.trailingAnchor.constraint(equalTo: row.trailingAnchor),
popup.centerYAnchor.constraint(equalTo: row.centerYAnchor),
])
labels.setContentHuggingPriority(.defaultLow, for: .horizontal)
labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return row
}

@objc private func changeMemoryChoice(_ sender: NSPopUpButton) {
guard !launchInProgress, !resetInProgress else { return }
guard let choiceMiB = sender.selectedItem?.tag, choiceMiB > 0 else { return }
setMemoryChoiceMiB(choiceMiB)
}

@objc private func beginAccessibilityRequest() {
permissionWindowRestorer.cancel()
requestAccessibility()
Expand Down
Loading
Loading