From 99eefcaac3b73ad1dc9539d7523cde490a0cba88 Mon Sep 17 00:00:00 2001 From: Eduardo Date: Sat, 3 Oct 2026 01:18:11 +0900 Subject: [PATCH] Store launcher startup choice in VM workspace --- README.md | 2 +- macos/README.md | 12 ++ .../OmarchyVMHelper/StartMenuWindow.swift | 24 ++- .../StartupPreferenceStore.swift | 79 +++++++- .../VMApplicationController.swift | 32 +++- .../GuestBootFixTests.swift | 2 +- .../StartMenuStartupTests.swift | 40 +++- .../StartupPreferenceStoreTests.swift | 176 +++++++++++++++--- 8 files changed, 318 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 99cb0326..354757cf 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ port. The `dtc` mirror should be reverted once kernel.org returns. 2. Open the DMG and drag **Try Omarchy** to **Applications**. 3. Launch **Try Omarchy** from Applications. -By default, every launch begins at the launcher. Enable **Skip launcher** to start Omarchy using your saved settings on subsequent launches. A confirmation explains how to return to the launcher; choose **OK** to enable **Skip launcher** or **Cancel** to leave it off. Hold **Option** while opening the app to show the launcher again and change settings or turn **Skip launcher** off. You can also open **Setup → Try Omarchy Settings** inside Omarchy. Reset requests still show the confirmation flow. Startup checks still show any required recovery or error dialogs. +By default, every launch begins at the launcher. Enable **Skip launcher** to start Omarchy using your saved settings on subsequent launches. A confirmation explains how to return to the launcher; choose **OK** to enable **Skip launcher** or **Cancel** to leave it off. Hold **Option** while opening the app to show the launcher again and change settings or turn **Skip launcher** off. You can also open **Setup → Try Omarchy Settings** inside Omarchy. Skip launcher is saved with the VM workspace; deleting that workspace clears the choice. A missing VM disk always shows the launcher, even if its settings file remains. Reset requests still show the confirmation flow. Startup checks still show any required recovery or error dialogs. While that menu is open, Try Omarchy behaves like a regular Mac app. **Try Omarchy → Settings…** (**Command-comma**) brings the launcher or its open editor forward without discarding drafts. The menus also provide standard text editing, Services, Hide Others, Show All, and window commands, including Bring All to Front. **Help** opens the user guide, maintenance and recovery notes, or the issue tracker in your browser. After the VM starts, that native app chrome steps aside for Omarchy. **Immersive** is on by default, so Omarchy opens Full Screen with the Mac menu bar and Dock hidden. Turn it off to open a resizable window; if you later enter Full Screen, the Mac menu bar and Dock remain available at the screen edges. Whenever the Omarchy window is focused, Command belongs to the guest as Super in either mode; Accessibility permission lets system shortcuts such as Command-Space reach it before macOS. Microphone and camera access are optional. The first launch takes longer while the app prepares Linux and starts Omarchy's account provisioning. diff --git a/macos/README.md b/macos/README.md index b8dd3e36..46c150dd 100644 --- a/macos/README.md +++ b/macos/README.md @@ -131,6 +131,18 @@ 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. +**Skip launcher** is stored in the versioned `launcher-settings.json` file at +that workspace's root (normally `~/Library/Application Support/Try Omarchy/VM/v1`). +It defaults to off when the file is missing, unreadable, or invalid. The old +`startAutomatically` UserDefaults value is ignored; existing users must enable +Skip launcher again. Release and development apps using the same workspace +share this choice. Selecting another workspace uses that workspace's setting. +A choice made before the first VM starts is held in memory and saved after the +storage backend initializes the workspace. Missing persistent disks and +`--ephemeral` sessions always show the launcher. Factory Reset preserves the +workspace setting, but the missing disk forces the next launch through the +launcher; deleting the whole workspace removes the setting too. + Reset deletes the VM without creating a replacement or requiring creation headroom. On the next launch, a missing VM decompresses the bundled factory directly into the staged writable disk; no expanded factory-image cache is diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index 90697898..34ad3d55 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -176,7 +176,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private let immersiveMode: () -> Bool private let setImmersiveMode: (Bool) -> Void private let startAutomatically: () -> Bool - private let setStartAutomatically: (Bool) -> Void + private let setStartAutomatically: (Bool) -> String? + private let presentStartupSaveError: (NSAlert, NSWindow) -> Void private let confirmAutomaticStartup: (NSAlert) -> NSApplication.ModalResponse private let bootFixCacheURL: () -> URL? private let bootFixIdentity: () -> String? @@ -271,7 +272,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { immersiveMode: @escaping () -> Bool = { true }, setImmersiveMode: @escaping (Bool) -> Void = { _ in }, startAutomatically: @escaping () -> Bool = { false }, - setStartAutomatically: @escaping (Bool) -> Void = { _ in }, + setStartAutomatically: @escaping (Bool) -> String? = { _ in nil }, + presentStartupSaveError: @escaping (NSAlert, NSWindow) -> Void = { $0.beginSheetModal(for: $1) }, confirmAutomaticStartup: @escaping (NSAlert) -> NSApplication.ModalResponse = { $0.runModal() }, appVersionLabel: String = InstalledAppRelease.current.label, appReleaseActionTitle: @escaping () -> String = { "Check for Updates…" }, @@ -315,6 +317,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.setImmersiveMode = setImmersiveMode self.startAutomatically = startAutomatically self.setStartAutomatically = setStartAutomatically + self.presentStartupSaveError = presentStartupSaveError self.confirmAutomaticStartup = confirmAutomaticStartup self.bootFixCacheURL = bootFixCacheURL self.bootFixIdentity = bootFixIdentity @@ -1850,14 +1853,25 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { let confirmed = confirmAutomaticStartup(alert) == .alertFirstButtonReturn automaticStartupConfirmationInProgress = false guard confirmed, !controlsBusy, !resetInProgress else { return } - setStartAutomatically(true) - sender.state = .on + saveAutomaticStartup(true, sender: sender) } else { - setStartAutomatically(false) + saveAutomaticStartup(false, sender: sender) } (sender as? OmarchyToggleButton)?.refreshAppearance() } + private func saveAutomaticStartup(_ enabled: Bool, sender: NSButton) { + if let error = setStartAutomatically(enabled) { + sender.state = startAutomatically() ? .on : .off + let alert = NSAlert() + alert.messageText = "Skip launcher couldn’t be saved" + alert.informativeText = error + presentStartupSaveError(alert, window) + } else { + sender.state = enabled ? .on : .off + } + } + @objc func launchOmarchy() { beginLaunch(action: launch) } diff --git a/macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift b/macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift index 165dbb33..6fb85878 100644 --- a/macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift +++ b/macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift @@ -1,30 +1,89 @@ +import Darwin import Foundation -struct StartupPreferenceStore { - static let key = "startAutomatically" - private let defaults: UserDefaults +/// Startup consent belongs to the VM workspace, not the app's preferences domain. +final class StartupPreferenceStore { + static let fileName = "launcher-settings.json" + private var pendingChoices: [URL: Bool] = [:] - init(defaults: UserDefaults = .standard) { - self.defaults = defaults + func load(storageRoot: URL?) -> Bool { + guard let storageRoot else { return false } + if let pending = pendingChoices[storageRoot] { return pending } + let url = storageRoot.appendingPathComponent(Self.fileName) + guard StorageLocationPolicy.hasValidRootMarker(in: storageRoot), + isPrivateEntry(storageRoot, type: S_IFDIR, permissions: 0o700), + isPrivateEntry(url, type: S_IFREG, permissions: 0o600), + let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, + size <= 4096, + let data = try? Data(contentsOf: url), + let payload = try? JSONDecoder().decode(Payload.self, from: data), + payload.schemaVersion == 1 else { return false } + return payload.startAutomatically } - func load() -> Bool { - defaults.bool(forKey: Self.key) + func save(_ enabled: Bool, storageRoot: URL) throws { + // Leave an empty custom folder untouched until the storage backend owns + // it. Otherwise its new settings file would make folder validation fail. + let marker = storageRoot.appendingPathComponent(StorageLocationPolicy.rootMarkerName) + var information = stat() + if lstat(marker.path, &information) != 0, errno == ENOENT { + pendingChoices[storageRoot] = enabled + return + } + try write(enabled, storageRoot: storageRoot) + pendingChoices.removeValue(forKey: storageRoot) } - func save(_ enabled: Bool) { - defaults.set(enabled, forKey: Self.key) + /// Retain a choice made before the first VM launch once storage is ready. + func persistPendingChoice(storageRoot: URL) throws { + guard let enabled = pendingChoices[storageRoot] else { return } + try write(enabled, storageRoot: storageRoot) + pendingChoices.removeValue(forKey: storageRoot) + } + + private func write(_ enabled: Bool, storageRoot: URL) throws { + guard StorageLocationPolicy.hasValidRootMarker(in: storageRoot), + isPrivateEntry(storageRoot, type: S_IFDIR, permissions: 0o700) else { + throw HelperError.io("The VM workspace is unavailable or is not private to this user.") + } + let url = storageRoot.appendingPathComponent(Self.fileName) + var information = stat() + if lstat(url.path, &information) == 0 { + guard isPrivateEntry(url, type: S_IFREG, permissions: 0o600) else { + throw HelperError.io("The launcher settings file is not a private regular file.") + } + } else if errno != ENOENT { + throw HelperError.io("The launcher settings file could not be checked.") + } + let data = try JSONEncoder().encode(Payload(schemaVersion: 1, startAutomatically: enabled)) + try data.write(to: url, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + private func isPrivateEntry(_ url: URL, type: mode_t, permissions: mode_t) -> Bool { + var information = stat() + return lstat(url.path, &information) == 0 + && information.st_mode & S_IFMT == type + && information.st_uid == getuid() + && information.st_mode & 0o777 == permissions + } + + private struct Payload: Codable { + let schemaVersion: Int + let startAutomatically: Bool } } enum StartupPolicy { static func shouldStartAutomatically( isEnabled: Bool, + hasExistingVM: Bool, optionKeyHeld: Bool, initialArguments: [String] ) -> Bool { let resetRequested = initialArguments.first == QEMUGPUStorageOption.resetStorage.rawValue || initialArguments.first == QEMUGPUStorageOption.resetStorageOnly.rawValue - return isEnabled && !optionKeyHeld && !resetRequested + return isEnabled && hasExistingVM && !optionKeyHeld && !resetRequested + && initialArguments.first != QEMUGPUStorageOption.ephemeral.rawValue } } diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 260d6941..495de4a9 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -151,8 +151,14 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { } observeVolumeUnmounts() observeHostPowerEvents() + let startupRoot = startupPreferenceRoot() let startAutomatically = StartupPolicy.shouldStartAutomatically( - isEnabled: startupPreferenceStore.load(), + isEnabled: startupPreferenceStore.load(storageRoot: startupRoot), + hasExistingVM: startupRoot.map { + QEMUGPUStorageSpaceEstimate.hasRecordedPersistentDisk( + stateRoot: $0.path, bundleIdentity: bundledMetrics?.identity + ) + } ?? false, optionKeyHeld: NSEvent.modifierFlags.contains(.option), initialArguments: initialArguments ) @@ -323,10 +329,19 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { ) }, startAutomatically: { [weak self] in - self?.startupPreferenceStore.load() ?? false + guard let self else { return false } + return self.startupPreferenceStore.load(storageRoot: self.startupPreferenceRoot()) }, setStartAutomatically: { [weak self] enabled in - self?.startupPreferenceStore.save(enabled) + guard let self else { return "The VM controller is unavailable." } + do { + if self.isDisposable { _ = try self.disposableWorkspace.prepare() } + guard let root = self.startupPreferenceRoot() else { + return "The VM data folder is unavailable." + } + try self.startupPreferenceStore.save(enabled, storageRoot: root) + return nil + } catch { return error.localizedDescription } }, appVersionLabel: appReleaseChecker.installed.label, appReleaseActionTitle: { [weak self] in @@ -355,6 +370,13 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { } } + private func startupPreferenceRoot() -> URL? { + if isDisposable { return disposableWorkspace.directory } + return QEMUGPUStorageSpaceEstimate.storageRootURL( + environment: baseEnvironment, preference: storageLocationStore.load() + ) + } + private func bootFixCacheURL() -> URL? { guard !isDisposable else { return nil } return GuestBootFixCache.url(storageRoot: QEMUGPUStorageSpaceEstimate.storageRootURL( @@ -718,6 +740,10 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { failHostSleepControlSetup(detail: error.localizedDescription) return } + if let root = startupPreferenceRoot() { + do { try startupPreferenceStore.persistPendingChoice(storageRoot: root) } + catch { fputs("[startup] Could not retain Skip launcher: \(error.localizedDescription)\n", stderr) } + } virtualMachineReachedStart = true if !isDisposable { activeBootFixCacheURL = GuestBootFixCache.url(storageRoot: activeStateRoot.map { URL(fileURLWithPath: $0) }) diff --git a/macos/Tests/OmarchyVMHelperTests/GuestBootFixTests.swift b/macos/Tests/OmarchyVMHelperTests/GuestBootFixTests.swift index b8e2c8aa..17c045fa 100644 --- a/macos/Tests/OmarchyVMHelperTests/GuestBootFixTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/GuestBootFixTests.swift @@ -51,7 +51,7 @@ struct GuestBootFixTests { @Test("Skip Launcher stays enabled while unknown and completed disks launch without update review") func automaticStartup() throws { - #expect(StartupPolicy.shouldStartAutomatically(isEnabled: true, optionKeyHeld: false, initialArguments: [])) + #expect(StartupPolicy.shouldStartAutomatically(isEnabled: true, hasExistingVM: true, optionKeyHeld: false, initialArguments: [])) #expect(!GuestBootFixCache.needsUpdate(cacheURL: nil, expectedIdentity: identity)) for outcome in ["current", "applied", "preserved", "unavailable"] { #expect(!report(outcome: outcome).needsUpdate) diff --git a/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift index 9b18adb7..36675dc6 100644 --- a/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift @@ -15,7 +15,7 @@ struct StartMenuStartupTests { let menu = makeMenu( storageState: { .defaultLocation }, startAutomatically: { automaticStart }, - setStartAutomatically: { automaticStart = $0 }, + setStartAutomatically: { automaticStart = $0; return nil }, confirmAutomaticStartup: { alert in confirmationCount += 1 #expect(!automaticStart) @@ -52,6 +52,38 @@ struct StartMenuStartupTests { #expect(!launchingToggle.isEnabled) } + @Test("A failed startup preference write restores the toggle and explains the error", + arguments: [false, true]) + func failedStartupSave(wasEnabled: Bool) throws { + _ = NSApplication.shared + var attempts = 0 + var errorsPresented = 0 + let menu = makeMenu( + storageState: { .defaultLocation }, + startAutomatically: { wasEnabled }, + setStartAutomatically: { enabled in + attempts += 1 + #expect(enabled == !wasEnabled) + return "The workspace is read-only." + }, + presentStartupSaveError: { alert, _ in + errorsPresented += 1 + #expect(alert.messageText == "Skip launcher couldn’t be saved") + #expect(alert.informativeText == "The workspace is read-only.") + } + ) + defer { menu.dismiss() } + menu.prepareForPresentation(visibleFrame: nil) + let content = try #require(menu.window.contentView) + let toggle = try #require(descendant( + withIdentifier: "automatic-start-toggle", in: content + ) as? NSButton) + toggle.performClick(nil) + #expect(attempts == 1) + #expect(toggle.state == (wasEnabled ? .on : .off)) + #expect(errorsPresented == 1) + } + @Test("Running settings can change startup and close without launching or quitting the VM") func runningSettings() throws { _ = NSApplication.shared @@ -61,7 +93,7 @@ struct StartMenuStartupTests { let menu = makeMenu( storageState: { .defaultLocation }, startAutomatically: { automaticStart }, - setStartAutomatically: { automaticStart = $0 }, + setStartAutomatically: { automaticStart = $0; return nil }, confirmAutomaticStartup: { _ in Issue.record("Disabling automatic startup must not ask for confirmation") return .alertSecondButtonReturn @@ -147,7 +179,8 @@ struct StartMenuStartupTests { private func makeMenu( storageState: @escaping () -> StorageLocationMenuState, startAutomatically: @escaping () -> Bool = { false }, - setStartAutomatically: @escaping (Bool) -> Void = { _ in }, + setStartAutomatically: @escaping (Bool) -> String? = { _ in nil }, + presentStartupSaveError: @escaping (NSAlert, NSWindow) -> Void = { _, _ in }, confirmAutomaticStartup: @escaping (NSAlert) -> NSApplication.ModalResponse = { _ in .alertFirstButtonReturn }, bootFixCacheURL: @escaping () -> URL? = { nil }, bootFixIdentity: @escaping () -> String? = { nil }, @@ -180,6 +213,7 @@ struct StartMenuStartupTests { setImmersiveMode: { _ in }, startAutomatically: startAutomatically, setStartAutomatically: setStartAutomatically, + presentStartupSaveError: presentStartupSaveError, confirmAutomaticStartup: confirmAutomaticStartup, bootFixCacheURL: bootFixCacheURL, bootFixIdentity: bootFixIdentity, diff --git a/macos/Tests/OmarchyVMHelperTests/StartupPreferenceStoreTests.swift b/macos/Tests/OmarchyVMHelperTests/StartupPreferenceStoreTests.swift index 795856b7..0c43f29d 100644 --- a/macos/Tests/OmarchyVMHelperTests/StartupPreferenceStoreTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/StartupPreferenceStoreTests.swift @@ -4,47 +4,171 @@ import Testing @Suite("Automatic startup") struct StartupPreferenceStoreTests { - @Test("Automatic startup is opt-in and the choice persists") + private func temporaryRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("omarchy-startup-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + return root + } + + private func initializeWorkspace(_ root: URL) throws { + let marker = root.appendingPathComponent(StorageLocationPolicy.rootMarkerName) + try Data("\(StorageLocationPolicy.rootMarkerContent)\n".utf8).write(to: marker) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: marker.path) + } + + private func writeSettings(_ contents: String, in root: URL) throws { + let file = root.appendingPathComponent(StartupPreferenceStore.fileName) + try Data(contents.utf8).write(to: file) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + } + + @Test("Skip launcher is opt-in and persists only within its workspace") func savesChoice() throws { - let suiteName = "StartupPreferenceStoreTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suiteName)) - defer { defaults.removePersistentDomain(forName: suiteName) } - let store = StartupPreferenceStore(defaults: defaults) - #expect(!store.load()) + let root = try temporaryRoot() + let other = try temporaryRoot() + defer { + try? FileManager.default.removeItem(at: root) + try? FileManager.default.removeItem(at: other) + } + try initializeWorkspace(root) + try initializeWorkspace(other) + let store = StartupPreferenceStore() + #expect(!store.load(storageRoot: root)) + #expect(!store.load(storageRoot: nil)) + + try store.save(true, storageRoot: root) + #expect(StartupPreferenceStore().load(storageRoot: root)) + #expect(!store.load(storageRoot: other)) + try store.save(false, storageRoot: root) + #expect(!StartupPreferenceStore().load(storageRoot: root)) + try store.save(true, storageRoot: root) + try FileManager.default.removeItem(at: root.appendingPathComponent(StartupPreferenceStore.fileName)) + #expect(!store.load(storageRoot: root)) + try store.save(true, storageRoot: root) + try FileManager.default.removeItem(at: root) + #expect(!store.load(storageRoot: root)) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + try initializeWorkspace(root) + #expect(!store.load(storageRoot: root)) + } + + @Test("A choice before first launch leaves an empty custom folder usable") + func defersFirstChoice() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let store = StartupPreferenceStore() + try store.save(true, storageRoot: root) + #expect(store.load(storageRoot: root)) + #expect(try FileManager.default.contentsOfDirectory(atPath: root.path).isEmpty) + #expect(!StartupPreferenceStore().load(storageRoot: root)) + try initializeWorkspace(root) + try store.persistPendingChoice(storageRoot: root) + #expect(StartupPreferenceStore().load(storageRoot: root)) + try FileManager.default.removeItem(at: root.appendingPathComponent(StartupPreferenceStore.fileName)) + #expect(!store.load(storageRoot: root)) + } - store.save(true) - #expect(StartupPreferenceStore(defaults: defaults).load()) - store.save(false) - #expect(!StartupPreferenceStore(defaults: defaults).load()) + @Test("Missing, damaged and unsupported settings fail closed", arguments: [ + "", "broken", "{}", "{\"schemaVersion\":2,\"startAutomatically\":true}", + "{\"schemaVersion\":1,\"startAutomatically\":\"true\"}", String(repeating: "x", count: 4097), + ]) + func rejectsInvalidSettings(contents: String) throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try initializeWorkspace(root) + try writeSettings(contents, in: root) + #expect(!StartupPreferenceStore().load(storageRoot: root)) } - @Test("Only an enabled preference without Option held skips the menu", + @Test("A settings symlink is neither read nor overwritten") + func rejectsSymlink() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try initializeWorkspace(root) + let target = root.appendingPathComponent("target") + let contents = Data("{\"schemaVersion\":1,\"startAutomatically\":true}".utf8) + try contents.write(to: target) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: target.path) + let file = root.appendingPathComponent(StartupPreferenceStore.fileName) + try FileManager.default.createSymbolicLink(at: file, withDestinationURL: target) + let store = StartupPreferenceStore() + #expect(!store.load(storageRoot: root)) + #expect(throws: HelperError.self) { try store.save(false, storageRoot: root) } + #expect(try Data(contentsOf: target) == contents) + } + + @Test("Workspace resolution follows a custom location and the environment override") + func resolvesWorkspace() { + let preference = StorageLocationPreference(containerPath: "/private/tmp/selected-vm") + #expect(QEMUGPUStorageSpaceEstimate.storageRootURL(environment: [:], preference: preference)?.path + == preference.containerPath) + #expect(QEMUGPUStorageSpaceEstimate.storageRootURL( + environment: [StorageLocationPolicy.environmentKey: "/private/tmp/override-vm"], + preference: preference)?.path == "/private/tmp/override-vm") + #expect(QEMUGPUStorageSpaceEstimate.storageRootURL(environment: [:])?.path + == FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Try Omarchy/VM/v1").path) + } + + @Test("Only an existing VM with an enabled preference and no Option override skips the menu", arguments: [false, true], [false, true]) func respectsPreferenceAndOverride(isEnabled: Bool, optionKeyHeld: Bool) { - #expect(StartupPolicy.shouldStartAutomatically( - isEnabled: isEnabled, - optionKeyHeld: optionKeyHeld, - initialArguments: [] - ) == (isEnabled && !optionKeyHeld)) + for hasExistingVM in [false, true] { + #expect(StartupPolicy.shouldStartAutomatically( + isEnabled: isEnabled, + hasExistingVM: hasExistingVM, + optionKeyHeld: optionKeyHeld, + initialArguments: [] + ) == (isEnabled && hasExistingVM && !optionKeyHeld)) + } } - @Test("Explicit resets always reach the confirmation menu", - arguments: ["--reset-storage", "--reset-storage-only"]) - func resetAlwaysShowsMenu(argument: String) { + @Test("A surviving settings file cannot skip the launcher after the disk is deleted") + func deletedDiskShowsLauncher() throws { + let root = try temporaryRoot() + defer { try? FileManager.default.removeItem(at: root) } + try initializeWorkspace(root) + let diskDirectory = root.appendingPathComponent("disks/current") + try FileManager.default.createDirectory(at: diskDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + let identity = String(repeating: "a", count: 64) + let metadata = diskDirectory.appendingPathComponent("metadata.json") + try Data("{\"bundleIdentity\":\"\(identity)\",\"kind\":\"omarchy-qemu-persistent-disk\",\"schemaVersion\":2,\"sourceRootfs\":{\"bytes\":1,\"sha256\":\"\(identity)\"}}\n".utf8).write(to: metadata) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: metadata.path) + let disk = diskDirectory.appendingPathComponent("rootfs.ext4") + try Data([0]).write(to: disk) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: disk.path) + let store = StartupPreferenceStore() + try store.save(true, storageRoot: root) + func shouldSkip() -> Bool { + StartupPolicy.shouldStartAutomatically( + isEnabled: store.load(storageRoot: root), + hasExistingVM: QEMUGPUStorageSpaceEstimate.hasRecordedPersistentDisk(stateRoot: root.path), + optionKeyHeld: false, initialArguments: []) + } + #expect(shouldSkip()) + try FileManager.default.removeItem(at: disk) + #expect(store.load(storageRoot: root)) + #expect(!shouldSkip()) + } + + @Test("Explicit resets and disposable sessions always show the launcher", + arguments: ["--reset-storage", "--reset-storage-only", "--ephemeral"]) + func freshLaunchShowsMenu(argument: String) { #expect(!StartupPolicy.shouldStartAutomatically( isEnabled: true, + hasExistingVM: true, optionKeyHeld: false, initialArguments: [argument, "/guest"] )) } - @Test("Ephemeral and custom guest launches honor automatic startup", - arguments: [["--ephemeral"], ["/guest"]]) - func otherLaunchModes(arguments: [String]) { + @Test("A custom guest launch with an existing VM honors automatic startup") + func customGuest() { #expect(StartupPolicy.shouldStartAutomatically( - isEnabled: true, - optionKeyHeld: false, - initialArguments: arguments - )) + isEnabled: true, hasExistingVM: true, optionKeyHeld: false, initialArguments: ["/guest"])) } }