From 9381e81f31294debc0ac02a312590f9e18544c0b Mon Sep 17 00:00:00 2001 From: Pedro Semeano Date: Sat, 5 Sep 2026 16:23:30 +0100 Subject: [PATCH] feat: add field to initial setup to set the vm disk size --- README.md | 11 ++- guest/Containerfile | 2 +- guest/scripts/finalize-rootfs.sh | 4 +- guest/scripts/register-pinned-ttfx.sh | 2 +- guest/scripts/resolve-package-lock.py | 4 +- guest/spec.json | 8 +- guest/tests/verify.py | 10 +-- .../OmarchyVMHelper/DiskCapacity.swift | 90 +++++++++++++++++++ .../OmarchyVMHelper/StartMenuWindow.swift | 83 +++++++++++++++++ .../VMApplicationController.swift | 41 +++++++-- .../DiskCapacityTests.swift | 86 ++++++++++++++++++ .../StartMenuWindowWidthTests.swift | 2 + macos/Tests/run-qemu-ssh-contract.test.sh | 15 +++- macos/run-qemu-gpu.sh | 26 +++++- 14 files changed, 357 insertions(+), 27 deletions(-) create mode 100644 macos/Sources/OmarchyVMHelper/DiskCapacity.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/DiskCapacityTests.swift diff --git a/README.md b/README.md index d3c3ecf..36e7c50 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,11 @@ new VM, after a confirmed **Reset Omarchy**, or for an ephemeral launch. Before Reset is enabled, the confirmation sheet requires typing `Try Omarchy` exactly; cancelling the sheet returns to the start menu without changing the VM. +The start menu's **VM disk limit** setting chooses the capacity of a new or +reset VM in whole GB, with a 10 GB minimum and a 24 GB default. Changing it +does not resize an existing VM; the selected limit takes effect the next time +a VM is created or explicitly reset. + VMs created before paired boot files were introduced are preserved too. On the first launch that needs them, Try Omarchy explains the transition in a **Continue** / **Cancel** dialog before starting recovery. Continue performs a @@ -296,8 +301,10 @@ you choose — it never creates a folder inside it on your behalf. the moment it was created. Network volumes are refused because the VM's disk lock is unreliable on them. Anything else is turned away when you pick it, with the actual format named. -- You need roughly 7 GB free to create the VM, and up to 30 GB as it fills. The - disk is sparse, so it only ever occupies what the guest has actually written. +- You need roughly 7 GB free to create the default VM. With the default 24 GB + disk limit, the workspace can use up to roughly 30 GB including its factory + image. A larger selected limit raises that maximum. The disk is sparse, so + it only occupies what the guest has actually written. - **Changing the location does not move your existing VM.** It stays where it is, and switching back reaches it again. - Do not disconnect the drive while Omarchy is running. macOS refuses a normal diff --git a/guest/Containerfile b/guest/Containerfile index e5a73a9..a50714d 100644 --- a/guest/Containerfile +++ b/guest/Containerfile @@ -32,7 +32,7 @@ COPY --from=bootstrapper /rootfs/ / RUN pacman-key --init && pacman-key --populate archlinuxarm && \ pacman -Syu --noconfirm && \ - pacman -S --needed --noconfirm arch-install-scripts e2fsprogs git python rust=1:1.98.0-1 zstd && \ + pacman -S --needed --noconfirm arch-install-scripts e2fsprogs git python rust=1:1.98.1-1 zstd && \ pacman -Scc --noconfirm WORKDIR /workspace diff --git a/guest/scripts/finalize-rootfs.sh b/guest/scripts/finalize-rootfs.sh index 8849eee..c37b63e 100755 --- a/guest/scripts/finalize-rootfs.sh +++ b/guest/scripts/finalize-rootfs.sh @@ -87,8 +87,8 @@ systemctl enable omarchy-provision-owner.service systemctl enable sddm.service systemctl enable omarchy-native-mac-share.service -# The app expands only the writable APFS clone to 24 GiB. Grow ext4 online so -# Omarchy's update-safety check sees that working capacity. +# The app expands only the writable APFS clone to the configured capacity. +# Grow ext4 online so Omarchy's update-safety check sees that working capacity. [[ -f /usr/lib/systemd/system/systemd-growfs-root.service ]] || { echo "Missing systemd root grow service" >&2; exit 1; } mkdir -p /etc/systemd/system/local-fs.target.wants ln -sfn /usr/lib/systemd/system/systemd-growfs-root.service \ diff --git a/guest/scripts/register-pinned-ttfx.sh b/guest/scripts/register-pinned-ttfx.sh index 75171c2..ce819d8 100755 --- a/guest/scripts/register-pinned-ttfx.sh +++ b/guest/scripts/register-pinned-ttfx.sh @@ -125,7 +125,7 @@ source_date_epoch=${metadata[20]} [[ $architecture == aarch64 ]] || fail "pinned ttfx component supports only aarch64" [[ $target == aarch64-unknown-linux-gnu ]] || fail "unexpected ttfx build target: $target" -[[ $rust_package_version == "rust 1:1.98.0-1" ]] || fail "unexpected ttfx Rust package version" +[[ $rust_package_version == "rust 1:1.98.1-1" ]] || fail "unexpected ttfx Rust package version" [[ $(pacman -Q rust) == "$rust_package_version" ]] || fail "ttfx Rust package identity mismatch" [[ $(rustc --version) == "$rustc_version" ]] || fail "ttfx rustc identity mismatch" [[ $(cargo --version) == "$cargo_version" ]] || fail "ttfx Cargo identity mismatch" diff --git a/guest/scripts/resolve-package-lock.py b/guest/scripts/resolve-package-lock.py index e0034ed..fb31963 100755 --- a/guest/scripts/resolve-package-lock.py +++ b/guest/scripts/resolve-package-lock.py @@ -47,7 +47,9 @@ def main() -> None: check=False, ) if result.returncode: - raise SystemExit(result.stderr or result.stdout) + # pacman reports the failure on stderr but names each unsatisfied + # dependency on stdout, so a diagnosis needs both streams. + raise SystemExit("".join(part for part in (result.stderr, result.stdout) if part).strip()) resolved: dict[str, str] = {} for line in result.stdout.splitlines(): diff --git a/guest/spec.json b/guest/spec.json index a22cbc4..b783dd5 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -89,11 +89,11 @@ "url": "https://github.com/omacom-io/ttfx/archive/refs/tags/v0.3.2.tar.gz", "sha256": "d0c0df4867e7f03142fb7f77c66670d0e8da15534239c1a7abfd89f19dfc00f6", "cargoLockSha256": "49e2091962fc4d425b4cf3bde1a105719b5b50eed0583ec90e85922adb45e2ce", - "binarySha256": "9171a07c752b202a21f80a4ad336a9d093be06a6c96b062e8b5e0c158d2a86d2", + "binarySha256": "d034cc5b9a8d410ce93113ef0a5d27b5ee2327948562bf2b0e756eebd326fa8f", "target": "aarch64-unknown-linux-gnu", - "rustPackageVersion": "rust 1:1.98.0-1", - "rustcVersion": "rustc 1.98.0 (88d9e12ae 2026-08-18) (Arch Linux rust 1:1.98.0-1)", - "cargoVersion": "cargo 1.98.0 (797e8a9bc 2026-08-05) (Arch Linux rust 1:1.98.0-1)", + "rustPackageVersion": "rust 1:1.98.1-1", + "rustcVersion": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1)", + "cargoVersion": "cargo 1.98.1 (797e8a9bc 2026-08-05) (Arch Linux rust 1:1.98.1-1)", "reportedVersion": "ttfx 0.3.2", "license": "MIT", "licenseSha256": "175441de2eb9a0d3f0627c404ad71929336fd98d75926cc27b9e364d35cc7977", diff --git a/guest/tests/verify.py b/guest/tests/verify.py index fbea105..f34e35d 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -342,11 +342,11 @@ def main() -> None: "url": "https://github.com/omacom-io/ttfx/archive/refs/tags/v0.3.2.tar.gz", "sha256": "d0c0df4867e7f03142fb7f77c66670d0e8da15534239c1a7abfd89f19dfc00f6", "cargoLockSha256": "49e2091962fc4d425b4cf3bde1a105719b5b50eed0583ec90e85922adb45e2ce", - "binarySha256": "9171a07c752b202a21f80a4ad336a9d093be06a6c96b062e8b5e0c158d2a86d2", + "binarySha256": "d034cc5b9a8d410ce93113ef0a5d27b5ee2327948562bf2b0e756eebd326fa8f", "target": "aarch64-unknown-linux-gnu", - "rustPackageVersion": "rust 1:1.98.0-1", - "rustcVersion": "rustc 1.98.0 (88d9e12ae 2026-08-18) (Arch Linux rust 1:1.98.0-1)", - "cargoVersion": "cargo 1.98.0 (797e8a9bc 2026-08-05) (Arch Linux rust 1:1.98.0-1)", + "rustPackageVersion": "rust 1:1.98.1-1", + "rustcVersion": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1)", + "cargoVersion": "cargo 1.98.1 (797e8a9bc 2026-08-05) (Arch Linux rust 1:1.98.1-1)", "reportedVersion": "ttfx 0.3.2", "license": "MIT", "licenseSha256": "175441de2eb9a0d3f0627c404ad71929336fd98d75926cc27b9e364d35cc7977", @@ -451,7 +451,7 @@ def main() -> None: check("try-omarchy-guest-work" in container, "guest cache has a project-scoped Docker volume") containerfile = read(GUEST / "Containerfile") check( - "arch-install-scripts e2fsprogs git python rust=1:1.98.0-1 zstd" in containerfile, + "arch-install-scripts e2fsprogs git python rust=1:1.98.1-1 zstd" in containerfile, "guest builder pins Rust for source-built components", ) diff --git a/macos/Sources/OmarchyVMHelper/DiskCapacity.swift b/macos/Sources/OmarchyVMHelper/DiskCapacity.swift new file mode 100644 index 0000000..755979c --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/DiskCapacity.swift @@ -0,0 +1,90 @@ +import Foundation + +struct DiskCapacityPreference: Codable, Equatable { + static let minimumGigabytes = 10 + static let defaultGigabytes = 24 + static let maximumGigabytes = 8_192 + static let bytesPerGigabyte: Int64 = 1_073_741_824 + + var gigabytes: Int + + static let `default` = Self(gigabytes: defaultGigabytes) + + var bytes: Int64 { + Int64(gigabytes) * Self.bytesPerGigabyte + } + + static func validationError(for gigabytes: Int) -> String? { + guard gigabytes >= minimumGigabytes else { + return "The VM disk limit must be at least \(minimumGigabytes) GB." + } + guard gigabytes <= maximumGigabytes else { + return "The VM disk limit cannot exceed \(maximumGigabytes) GB." + } + return nil + } +} + +struct DiskCapacityPreferenceStore { + static let key = "diskCapacityPreferences" + static let schemaVersion = 1 + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> DiskCapacityPreference { + guard let data = defaults.data(forKey: Self.key), + let payload = try? JSONDecoder().decode(Payload.self, from: data), + payload.schemaVersion == Self.schemaVersion, + DiskCapacityPreference.validationError(for: payload.gigabytes) == nil + else { + return .default + } + return DiskCapacityPreference(gigabytes: payload.gigabytes) + } + + func save(_ preference: DiskCapacityPreference) throws { + if let problem = DiskCapacityPreference.validationError(for: preference.gigabytes) { + throw DiskCapacityError.invalid(problem) + } + let payload = Payload( + schemaVersion: Self.schemaVersion, + gigabytes: preference.gigabytes + ) + defaults.set(try JSONEncoder().encode(payload), forKey: Self.key) + } + + private struct Payload: Codable { + let schemaVersion: Int + let gigabytes: Int + } +} + +enum DiskCapacityError: LocalizedError, Equatable { + case invalid(String) + + var errorDescription: String? { + switch self { + case .invalid(let message): + message + } + } +} + +struct DiskCapacityLaunchConfiguration: Equatable { + static let environmentKey = "OMARCHY_QEMU_GPU_DISK_SIZE_GB" + + let environment: [String: String] + + static func make( + baseEnvironment: [String: String], + preference: DiskCapacityPreference + ) -> Self { + var environment = baseEnvironment + environment[environmentKey] = String(preference.gigabytes) + return Self(environment: environment) + } +} diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index a9a8639..05a4bef 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -211,6 +211,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private let validateStorageLocation: (String) -> String? private let chooseStorageLocation: (String) -> String? private let useDefaultStorageLocation: () -> Void + private let diskCapacity: () -> Int + private let saveDiskCapacity: (Int) -> String? private var microphoneRequestInFlight = false private var cameraRequestInFlight = false @@ -275,6 +277,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { validateStorageLocation: @escaping (String) -> String?, chooseStorageLocation: @escaping (String) -> String?, useDefaultStorageLocation: @escaping () -> Void, + diskCapacity: @escaping () -> Int, + saveDiskCapacity: @escaping (Int) -> String?, resetStorage: @escaping () -> Void, sharedFolderStatus: @escaping () -> SharedFolderMenuState, chooseSharedFolder: @escaping (String) -> String?, @@ -299,6 +303,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.validateStorageLocation = validateStorageLocation self.chooseStorageLocation = chooseStorageLocation self.useDefaultStorageLocation = useDefaultStorageLocation + self.diskCapacity = diskCapacity + self.saveDiskCapacity = saveDiskCapacity self.resetStorage = resetStorage self.sharedFolderStatus = sharedFolderStatus self.chooseSharedFolder = chooseSharedFolder @@ -610,10 +616,27 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ) } + let capacityRow: NSView? = canResetStorage + ? permissionRow( + symbolName: "internaldrive", + title: "VM disk limit", + detail: "Maximum capacity for a new or reset VM. Existing VM disks are not resized.", + granted: true, + statusLabels: ( + "\u{25cf} \(diskCapacity()) GB", + "\u{25cf} \(diskCapacity()) GB" + ), + actions: [("Change\u{2026}", #selector(beginDiskCapacitySelection))] + ) + : nil + var permissionRowViews = [accessibilityRow, microphoneRow, cameraRow, sharedFolderRow] if let storageRow { permissionRowViews.append(storageRow) } + if let capacityRow { + permissionRowViews.append(capacityRow) + } permissionRowViews.append(contentsOf: [portForwardingRow, immersiveRow]) var permissionRowsAndSeparators: [NSView] = [] @@ -1390,6 +1413,66 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { render() launch() } + + @objc private func beginDiskCapacitySelection() { + guard canResetStorage, + !launchInProgress, + !resetInProgress + else { return } + presentDiskCapacityEditor(initialText: String(diskCapacity())) + } + + private func presentDiskCapacityEditor(initialText: String) { + let alert = NSAlert() + alert.messageText = "Set VM disk limit" + alert.informativeText = "Enter the maximum virtual disk capacity in GB. The minimum is \(DiskCapacityPreference.minimumGigabytes) GB. This applies when a VM is first created or reset." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Cancel") + + let field = NSTextField(string: initialText) + field.placeholderString = "\(DiskCapacityPreference.defaultGigabytes)" + field.alignment = .right + field.setAccessibilityLabel("VM disk limit in GB") + + let unit = NSTextField(labelWithString: "GB") + let accessory = NSStackView(views: [field, unit]) + accessory.orientation = .horizontal + accessory.alignment = .centerY + accessory.spacing = 8 + accessory.frame = NSRect(x: 0, y: 0, width: 220, height: 26) + field.widthAnchor.constraint(equalToConstant: 170).isActive = true + alert.accessoryView = accessory + + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn, let self else { return } + let entered = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard let gigabytes = Int(entered), String(gigabytes) == entered else { + self.presentDiskCapacityProblem( + "Enter a whole number of GB.", + enteredValue: entered + ) + return + } + if let problem = self.saveDiskCapacity(gigabytes) { + self.presentDiskCapacityProblem(problem, enteredValue: entered) + return + } + self.render() + } + } + + private func presentDiskCapacityProblem(_ message: String, enteredValue: String) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Invalid disk limit" + alert.informativeText = message + alert.addButton(withTitle: "Try Again") + alert.addButton(withTitle: "Cancel") + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + self?.presentDiskCapacityEditor(initialText: enteredValue) + } + } } private final class StartMenuDocumentView: NSView { diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 8db7f51..462acbd 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -55,6 +55,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private let portForwardingStore: PortForwardingPreferenceStore private let fullscreenPreferenceStore: FullscreenPreferenceStore private let storageLocationStore: StorageLocationPreferenceStore + private let diskCapacityStore: DiskCapacityPreferenceStore private let volumeProbe: VolumeProbing private let volumeRootDetector: VolumeRootDetecting private let deviceProvider: HostAudioDeviceProviding @@ -93,6 +94,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { portForwardingStore: PortForwardingPreferenceStore = PortForwardingPreferenceStore(), fullscreenPreferenceStore: FullscreenPreferenceStore = FullscreenPreferenceStore(), storageLocationStore: StorageLocationPreferenceStore = StorageLocationPreferenceStore(), + diskCapacityStore: DiskCapacityPreferenceStore = DiskCapacityPreferenceStore(), volumeProbe: VolumeProbing = URLVolumeProbe(), volumeRootDetector: VolumeRootDetecting = FileManagerVolumeRootDetector(), deviceProvider: HostAudioDeviceProviding = CoreAudioHostAudioDeviceProvider(), @@ -107,6 +109,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { self.portForwardingStore = portForwardingStore self.fullscreenPreferenceStore = fullscreenPreferenceStore self.storageLocationStore = storageLocationStore + self.diskCapacityStore = diskCapacityStore self.volumeProbe = volumeProbe self.volumeRootDetector = volumeRootDetector self.deviceProvider = deviceProvider @@ -178,6 +181,13 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { useDefaultStorageLocation: { [weak self] in self?.useDefaultStorageLocation() }, + diskCapacity: { [weak self] in + self?.diskCapacityStore.load().gigabytes + ?? DiskCapacityPreference.defaultGigabytes + }, + saveDiskCapacity: { [weak self] gigabytes in + self?.saveDiskCapacity(gigabytes) + }, resetStorage: { [weak self] in self?.resetVirtualMachine() }, @@ -416,12 +426,16 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { let storage = StorageLocationLaunchConfiguration.make( baseEnvironment: fullscreen.environment, preference: storageLocationStore.load(), - metrics: bundledMetrics, + metrics: effectiveBundledMetrics, probe: volumeProbe, volumeRootDetector: volumeRootDetector ) + let diskCapacity = DiskCapacityLaunchConfiguration.make( + baseEnvironment: storage.environment, + preference: diskCapacityStore.load() + ) return ChildLaunchContext( - environment: storage.environment, + environment: diskCapacity.environment, stateRoot: storage.stateRoot, portForwardMappings: forwarding.mappings, storageUnavailableReason: storage.unavailableReason @@ -535,7 +549,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private func storageLocationMenuState() -> StorageLocationMenuState { StorageLocationMenuState.make( preference: storageLocationStore.load(), - metrics: bundledMetrics, + metrics: effectiveBundledMetrics, homeDirectory: Self.homeDirectory, environmentOverride: storageEnvironmentOverride, probe: volumeProbe, @@ -548,7 +562,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { _ = try StorageLocationPolicy.validate( path, - metrics: bundledMetrics, + metrics: effectiveBundledMetrics, probe: volumeProbe, volumeRootDetector: volumeRootDetector ) @@ -565,7 +579,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { let resolution = try StorageLocationPolicy.validate( path, - metrics: bundledMetrics, + metrics: effectiveBundledMetrics, probe: volumeProbe, volumeRootDetector: volumeRootDetector ) @@ -582,6 +596,21 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { storageLocationStore.save(.default) } + private var effectiveBundledMetrics: BundledGuestMetrics? { + guard var metrics = bundledMetrics else { return nil } + metrics.workingDiskBytes = diskCapacityStore.load().bytes + return metrics + } + + private func saveDiskCapacity(_ gigabytes: Int) -> String? { + do { + try diskCapacityStore.save(DiskCapacityPreference(gigabytes: gigabytes)) + return nil + } catch { + return error.localizedDescription + } + } + /// What the user decided once their chosen drive turned out to be missing. /// /// Launch and reset must react differently, which is why this reports the @@ -620,7 +649,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { _ = try StorageLocationPolicy.validate( container, - metrics: bundledMetrics, + metrics: effectiveBundledMetrics, probe: volumeProbe, volumeRootDetector: volumeRootDetector ) diff --git a/macos/Tests/OmarchyVMHelperTests/DiskCapacityTests.swift b/macos/Tests/OmarchyVMHelperTests/DiskCapacityTests.swift new file mode 100644 index 0000000..d43baa1 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/DiskCapacityTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import OmarchyVMHelper + +@Suite("VM disk capacity preferences") +struct DiskCapacityPreferenceTests { + @Test("defaults to the current 24 GB capacity") + func defaultsToCurrentCapacity() { + let fixture = DefaultsFixture() + + #expect(fixture.store.load() == .default) + #expect(fixture.store.load().gigabytes == 24) + } + + @Test("persists valid capacities") + func savesCapacity() throws { + let fixture = DefaultsFixture() + try fixture.store.save(DiskCapacityPreference(gigabytes: 120)) + + let reopened = DiskCapacityPreferenceStore(defaults: fixture.defaults) + #expect(reopened.load() == DiskCapacityPreference(gigabytes: 120)) + #expect(reopened.load().bytes == 120 * 1_073_741_824) + } + + @Test("rejects capacities below 10 GB and above the supported maximum") + func rejectsInvalidCapacity() { + let fixture = DefaultsFixture() + + #expect(throws: DiskCapacityError.self) { + try fixture.store.save(DiskCapacityPreference(gigabytes: 9)) + } + #expect(throws: DiskCapacityError.self) { + try fixture.store.save(DiskCapacityPreference(gigabytes: 8_193)) + } + #expect(fixture.store.load() == .default) + } + + @Test("invalid or future preferences fail safely") + func invalidPreferencesUseDefault() throws { + let fixture = DefaultsFixture() + fixture.defaults.set(Data("junk".utf8), forKey: DiskCapacityPreferenceStore.key) + #expect(fixture.store.load() == .default) + + let future = try JSONSerialization.data(withJSONObject: [ + "schemaVersion": DiskCapacityPreferenceStore.schemaVersion + 1, + "gigabytes": 120, + ]) + fixture.defaults.set(future, forKey: DiskCapacityPreferenceStore.key) + #expect(fixture.store.load() == .default) + } + + private final class DefaultsFixture { + let suiteName = "DiskCapacityPreferenceTests.\(UUID().uuidString)" + let defaults: UserDefaults + let store: DiskCapacityPreferenceStore + + init() { + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + store = DiskCapacityPreferenceStore(defaults: defaults) + } + + deinit { + defaults.removePersistentDomain(forName: suiteName) + } + } +} + +@Suite("VM disk capacity launch configuration") +struct DiskCapacityLaunchConfigurationTests { + @Test("publishes the selected capacity and replaces inherited values") + func publishesCapacity() { + let configured = DiskCapacityLaunchConfiguration.make( + baseEnvironment: [ + "KEEP_ME": "yes", + DiskCapacityLaunchConfiguration.environmentKey: "999", + ], + preference: DiskCapacityPreference(gigabytes: 80) + ) + + #expect(configured.environment == [ + "KEEP_ME": "yes", + DiskCapacityLaunchConfiguration.environmentKey: "80", + ]) + } +} diff --git a/macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift b/macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift index cd14b44..f65d409 100644 --- a/macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/StartMenuWindowWidthTests.swift @@ -109,6 +109,8 @@ struct StartMenuWindowWidthTests { validateStorageLocation: { _ in nil }, chooseStorageLocation: { _ in nil }, useDefaultStorageLocation: {}, + diskCapacity: { 24 }, + saveDiskCapacity: { _ in nil }, resetStorage: {}, sharedFolderStatus: { .disabled }, chooseSharedFolder: { _ in nil }, diff --git a/macos/Tests/run-qemu-ssh-contract.test.sh b/macos/Tests/run-qemu-ssh-contract.test.sh index c00269e..7d5e007 100755 --- a/macos/Tests/run-qemu-ssh-contract.test.sh +++ b/macos/Tests/run-qemu-ssh-contract.test.sh @@ -215,7 +215,7 @@ qemu_persistent_storage_stage_selected_boot_kit() { QEMU_PERSISTENT_STORAGE_NEEDS_BOOT_RECOVERY=0 } qemu_persistent_storage_select() { - printf 'select %s\n' "$1" >>"$FAKE_STORAGE_LOG" + printf 'select %s %s\n' "$1" "$7" >>"$FAKE_STORAGE_LOG" if [[ $1 == ephemeral ]]; then mkdir -p "$6" QEMU_SELECTED_DISK="$6/rootfs.ext4" @@ -336,6 +336,19 @@ assert_contains "$disabled_qemu" \ assert_contains "$(<"$test_root/disabled/storage.log")" select-existing assert_contains "$(<"$test_root/disabled/storage.log")" create +run_scenario custom-capacity 0 --reset-storage-only \ + OMARCHY_QEMU_GPU_DISK_SIZE_GB=80 +assert_contains "$(<"$test_root/custom-capacity/storage.log")" \ + 'select reset 85899345920' + +for invalid_capacity in 9 0 10.5 unlimited 8193; do + scenario="invalid-capacity-${invalid_capacity//./-}" + run_scenario "$scenario" 1 --reset-storage-only \ + "OMARCHY_QEMU_GPU_DISK_SIZE_GB=$invalid_capacity" + [[ ! -s $test_root/$scenario/storage.log ]] || \ + fail "invalid disk capacity touched storage" +done + run_scenario non-immersive 0 '' OMARCHY_QEMU_GPU_IMMERSIVE=0 non_immersive_qemu=$(<"$test_root/non-immersive/qemu.log") assert_contains "$non_immersive_qemu" \ diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 2337892..a997878 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -620,11 +620,11 @@ if ttfx != { "url": "https://github.com/omacom-io/ttfx/archive/refs/tags/v0.3.2.tar.gz", "sha256": "d0c0df4867e7f03142fb7f77c66670d0e8da15534239c1a7abfd89f19dfc00f6", "cargoLockSha256": "49e2091962fc4d425b4cf3bde1a105719b5b50eed0583ec90e85922adb45e2ce", - "binarySha256": "9171a07c752b202a21f80a4ad336a9d093be06a6c96b062e8b5e0c158d2a86d2", + "binarySha256": "d034cc5b9a8d410ce93113ef0a5d27b5ee2327948562bf2b0e756eebd326fa8f", "target": "aarch64-unknown-linux-gnu", - "rustPackageVersion": "rust 1:1.98.0-1", - "rustcVersion": "rustc 1.98.0 (88d9e12ae 2026-08-18) (Arch Linux rust 1:1.98.0-1)", - "cargoVersion": "cargo 1.98.0 (797e8a9bc 2026-08-05) (Arch Linux rust 1:1.98.0-1)", + "rustPackageVersion": "rust 1:1.98.1-1", + "rustcVersion": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1)", + "cargoVersion": "cargo 1.98.1 (797e8a9bc 2026-08-05) (Arch Linux rust 1:1.98.1-1)", "reportedVersion": "ttfx 0.3.2", "license": "MIT", "licenseSha256": "175441de2eb9a0d3f0627c404ad71929336fd98d75926cc27b9e364d35cc7977", @@ -815,6 +815,24 @@ if [[ ${OMARCHY_QEMU_GPU_INSPECT_ONLY:-0} == 1 ]]; then exit 0 fi +if [[ -n ${OMARCHY_QEMU_GPU_DISK_SIZE_GB:-} ]]; then + requested_disk_size_gb=$OMARCHY_QEMU_GPU_DISK_SIZE_GB + [[ $requested_disk_size_gb =~ ^[1-9][0-9]*$ ]] || { + fail "OMARCHY_QEMU_GPU_DISK_SIZE_GB must be a whole number" + } + (( requested_disk_size_gb >= 10 )) || { + fail "OMARCHY_QEMU_GPU_DISK_SIZE_GB must be at least 10 GB" + } + (( requested_disk_size_gb <= 8192 )) || { + fail "OMARCHY_QEMU_GPU_DISK_SIZE_GB cannot exceed 8192 GB" + } + requested_disk_bytes=$((requested_disk_size_gb * 1024 * 1024 * 1024)) + (( requested_disk_bytes >= source_disk_bytes )) || { + fail "the requested VM disk is smaller than the bundled factory image" + } + expanded_disk_bytes=$requested_disk_bytes +fi + [[ -f $storage_library && ! -L $storage_library ]] || { fail "persistent-storage library is missing or unsafe: $storage_library" }