Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 12 additions & 0 deletions macos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions macos/Sources/OmarchyVMHelper/StartMenuWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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…" },
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
79 changes: 69 additions & 10 deletions macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
32 changes: 29 additions & 3 deletions macos/Sources/OmarchyVMHelper/VMApplicationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) })
Expand Down
2 changes: 1 addition & 1 deletion macos/Tests/OmarchyVMHelperTests/GuestBootFixTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 37 additions & 3 deletions macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -180,6 +213,7 @@ struct StartMenuStartupTests {
setImmersiveMode: { _ in },
startAutomatically: startAutomatically,
setStartAutomatically: setStartAutomatically,
presentStartupSaveError: presentStartupSaveError,
confirmAutomaticStartup: confirmAutomaticStartup,
bootFixCacheURL: bootFixCacheURL,
bootFixIdentity: bootFixIdentity,
Expand Down
Loading
Loading