From f76080f37771ea6d536d0c2e546972b0b2117430 Mon Sep 17 00:00:00 2001 From: Einar Andersson <72999+drdator@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:18:23 +0200 Subject: [PATCH 1/4] Add opt-in automatic startup with Option-key override --- README.md | 4 +- .../OmarchyVMHelper/StartMenuWindow.swift | 42 ++++++++-- .../StartupPreferenceStore.swift | 30 +++++++ .../VMApplicationController.swift | 29 +++++-- .../StartMenuStartupTests.swift | 84 +++++++++++++++++++ .../StartupPreferenceStoreTests.swift | 50 +++++++++++ 6 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/StartupPreferenceStoreTests.swift diff --git a/README.md b/README.md index 44ca55e5..92f50599 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,9 @@ 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. -Every launch begins at the start menu. While that menu is open, Try Omarchy behaves like a regular Mac app with standard Quit, Close Window, and Minimize commands; 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. +By default, every launch begins at the start menu. Enable **Start automatically** to skip this menu on subsequent launches and start Omarchy using your saved settings. Hold **Option** while opening the app to show the menu again and change settings or turn automatic startup off. 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 with standard Quit, Close Window, and Minimize commands; 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. Restarting from inside Omarchy reboots the guest in the same Try Omarchy app. Shutting down Omarchy closes the app and leaves it closed. diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index a9a86398..910c0d60 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -203,6 +203,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private let savePortForwarding: ([PortForwardMapping]) -> String? private let immersiveMode: () -> Bool private let setImmersiveMode: (Bool) -> Void + private let startAutomatically: () -> Bool + private let setStartAutomatically: (Bool) -> Void private let launch: () -> Void private let canResetStorage: Bool private let storageLocation: () -> String? @@ -283,6 +285,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { savePortForwarding: @escaping ([PortForwardMapping]) -> String? = { _ in nil }, immersiveMode: @escaping () -> Bool = { true }, setImmersiveMode: @escaping (Bool) -> Void = { _ in }, + startAutomatically: @escaping () -> Bool = { false }, + setStartAutomatically: @escaping (Bool) -> Void = { _ in }, launch: @escaping () -> Void ) { self.accessibilityStatus = accessibilityStatus @@ -307,6 +311,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.savePortForwarding = savePortForwarding self.immersiveMode = immersiveMode self.setImmersiveMode = setImmersiveMode + self.startAutomatically = startAutomatically + self.setStartAutomatically = setStartAutomatically self.launch = launch window = NSWindow( @@ -399,7 +405,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { func launchDidAbort() { guard launchInProgress else { return } launchInProgress = false - render() + show() } /// Clears the resetting state when the controller refused to start the @@ -415,7 +421,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { func launchRequiresReset() { guard launchInProgress else { return } launchInProgress = false - render() + show() let alert = NSAlert() alert.alertStyle = .warning @@ -430,6 +436,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { /// the gap between presenting the explanation and receiving the answer. func confirmBootRecovery() -> Bool { guard launchInProgress else { return false } + show() let alert = NSAlert() alert.alertStyle = .informational alert.messageText = StartMenuPresentation.bootRecoveryConfirmationTitle @@ -442,7 +449,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { func launchDidFail(errorMessage: String) { guard launchInProgress else { return } launchInProgress = false - render() + show() let alert = NSAlert() alert.alertStyle = .critical @@ -715,6 +722,24 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { launchButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 500), ]) + let automaticStart = NSButton( + checkboxWithTitle: "Start automatically", + target: self, + action: #selector(changeStartAutomatically(_:)) + ) + automaticStart.state = startAutomatically() ? .on : .off + automaticStart.isEnabled = launchButton.isEnabled + automaticStart.identifier = NSUserInterfaceItemIdentifier("automatic-start-checkbox") + let automaticStartHelp = "Skip this menu on launch. Hold Option while opening the app to show it again." + automaticStart.setAccessibilityHelp(automaticStartHelp) + let automaticStartCaption = NSTextField(wrappingLabelWithString: automaticStartHelp) + automaticStartCaption.font = .systemFont(ofSize: 12) + automaticStartCaption.textColor = .secondaryLabelColor + let automaticStartSection = NSStackView(views: [automaticStart, automaticStartCaption]) + automaticStartSection.orientation = .vertical + automaticStartSection.alignment = .leading + automaticStartSection.spacing = 3 + let footerText = "by @martiano" let footerTitle = NSMutableAttributedString( string: footerText, @@ -745,7 +770,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ]) let stack = NSStackView( - views: [headingStack, permissionCard, resetSection, launchButton, footerContainer] + views: [headingStack, permissionCard, resetSection, automaticStartSection, launchButton, footerContainer] ) stack.orientation = .vertical stack.alignment = .leading @@ -785,6 +810,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { stack.bottomAnchor.constraint(equalTo: document.bottomAnchor, constant: -20), permissionCard.widthAnchor.constraint(equalTo: stack.widthAnchor), resetSection.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor), + automaticStartSection.widthAnchor.constraint(equalTo: stack.widthAnchor), + automaticStartCaption.widthAnchor.constraint(equalTo: automaticStartSection.widthAnchor), launchButton.widthAnchor.constraint(equalTo: stack.widthAnchor), footerContainer.widthAnchor.constraint(equalTo: stack.widthAnchor), ]) @@ -1381,7 +1408,12 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } } - @objc private func launchOmarchy() { + @objc private func changeStartAutomatically(_ sender: NSButton) { + guard !launchInProgress, !resetInProgress else { return } + setStartAutomatically(sender.state == .on) + } + + @objc func launchOmarchy() { guard !launchInProgress, !resetInProgress, !microphoneRequestInFlight, diff --git a/macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift b/macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift new file mode 100644 index 00000000..165dbb33 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/StartupPreferenceStore.swift @@ -0,0 +1,30 @@ +import Foundation + +struct StartupPreferenceStore { + static let key = "startAutomatically" + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func load() -> Bool { + defaults.bool(forKey: Self.key) + } + + func save(_ enabled: Bool) { + defaults.set(enabled, forKey: Self.key) + } +} + +enum StartupPolicy { + static func shouldStartAutomatically( + isEnabled: Bool, + optionKeyHeld: Bool, + initialArguments: [String] + ) -> Bool { + let resetRequested = initialArguments.first == QEMUGPUStorageOption.resetStorage.rawValue + || initialArguments.first == QEMUGPUStorageOption.resetStorageOnly.rawValue + return isEnabled && !optionKeyHeld && !resetRequested + } +} diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 8db7f512..9b23eca4 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -54,6 +54,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private let sharedFolderStore: SharedFolderPreferenceStore private let portForwardingStore: PortForwardingPreferenceStore private let fullscreenPreferenceStore: FullscreenPreferenceStore + private let startupPreferenceStore: StartupPreferenceStore private let storageLocationStore: StorageLocationPreferenceStore private let volumeProbe: VolumeProbing private let volumeRootDetector: VolumeRootDetecting @@ -92,6 +93,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { sharedFolderStore: SharedFolderPreferenceStore = SharedFolderPreferenceStore(), portForwardingStore: PortForwardingPreferenceStore = PortForwardingPreferenceStore(), fullscreenPreferenceStore: FullscreenPreferenceStore = FullscreenPreferenceStore(), + startupPreferenceStore: StartupPreferenceStore = StartupPreferenceStore(), storageLocationStore: StorageLocationPreferenceStore = StorageLocationPreferenceStore(), volumeProbe: VolumeProbing = URLVolumeProbe(), volumeRootDetector: VolumeRootDetecting = FileManagerVolumeRootDetector(), @@ -106,6 +108,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { self.sharedFolderStore = sharedFolderStore self.portForwardingStore = portForwardingStore self.fullscreenPreferenceStore = fullscreenPreferenceStore + self.startupPreferenceStore = startupPreferenceStore self.storageLocationStore = storageLocationStore self.volumeProbe = volumeProbe self.volumeRootDetector = volumeRootDetector @@ -116,14 +119,19 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { observeVolumeUnmounts() observeHostPowerEvents() - showStartMenu() + let startAutomatically = StartupPolicy.shouldStartAutomatically( + isEnabled: startupPreferenceStore.load(), + optionKeyHeld: NSEvent.modifierFlags.contains(.option), + initialArguments: initialArguments + ) + prepareStartMenu(startAutomatically: startAutomatically) } func applicationDidBecomeActive(_ notification: Notification) { startMenuWindow?.applicationDidBecomeActive() } - private func showStartMenu() { + private func prepareStartMenu(startAutomatically: Bool) { let resetOptions = [ QEMUGPUStorageOption.resetStorage.rawValue, QEMUGPUStorageOption.resetStorageOnly.rawValue, @@ -204,14 +212,24 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { FullscreenPreferences(isImmersive: isImmersive) ) }, + startAutomatically: { [weak self] in + self?.startupPreferenceStore.load() ?? false + }, + setStartAutomatically: { [weak self] enabled in + self?.startupPreferenceStore.save(enabled) + }, launch: { [weak self] in self?.startVirtualMachine() } ) startMenuWindow = startMenu - startMenu.show() - if initialResetRequested { - startMenu.promptForReset() + if startAutomatically { + startMenu.launchOmarchy() + } else { + startMenu.show() + if initialResetRequested { + startMenu.promptForReset() + } } } @@ -626,6 +644,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { ) return .available } catch { + startMenuWindow?.show() let alert = NSAlert() alert.alertStyle = .critical alert.messageText = "Omarchy\u{2019}s data folder is unavailable" diff --git a/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift new file mode 100644 index 00000000..b4e3592c --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift @@ -0,0 +1,84 @@ +import AppKit +import Testing +@testable import OmarchyVMHelper + +@Suite("Start menu automatic startup", .serialized) +@MainActor +struct StartMenuStartupTests { + @Test("Automatic startup uses the launch action without presenting the menu") + func startsWithoutShowingMenu() throws { + _ = NSApplication.shared + var automaticStart = false + var launchCount = 0 + let menu = makeMenu( + storageState: { .defaultLocation }, + startAutomatically: { automaticStart }, + setStartAutomatically: { automaticStart = $0 }, + launch: { launchCount += 1 } + ) + defer { menu.dismiss() } + menu.prepareForPresentation(visibleFrame: nil) + let content = try #require(menu.window.contentView) + let checkbox = try #require(descendant( + withIdentifier: "automatic-start-checkbox", in: content + ) as? NSButton) + #expect(checkbox.state == .off) + checkbox.performClick(nil) + #expect(automaticStart) + #expect(launchCount == 0) + + menu.launchOmarchy() + menu.launchOmarchy() + #expect(launchCount == 1) + #expect(!menu.window.isVisible) + let launchingCheckbox = try #require(descendant( + withIdentifier: "automatic-start-checkbox", in: content + ) as? NSButton) + #expect(launchingCheckbox.state == .on) + #expect(!launchingCheckbox.isEnabled) + } + + private func makeMenu( + storageState: @escaping () -> StorageLocationMenuState, + startAutomatically: @escaping () -> Bool = { false }, + setStartAutomatically: @escaping (Bool) -> Void = { _ in }, + launch: @escaping () -> Void = {} + ) -> StartMenuWindow { + StartMenuWindow( + accessibilityStatus: { true }, + microphoneStatus: { .authorized }, + cameraStatus: { .authorized }, + requestAccessibility: {}, + requestMicrophone: { completion in completion(true) }, + requestCamera: { completion in completion(true) }, + canResetStorage: true, + storageLocation: { storageState().displayPath }, + storageLocationURL: { + storageState().containerPath.map { URL(fileURLWithPath: $0) } + }, + storageSpaceEstimate: { nil }, + storageLocationStatus: storageState, + validateStorageLocation: { _ in nil }, + chooseStorageLocation: { _ in nil }, + useDefaultStorageLocation: {}, + resetStorage: {}, + sharedFolderStatus: { .disabled }, + chooseSharedFolder: { _ in nil }, + setSharedFolderEnabled: { _ in }, + portForwardingStatus: { [] }, + immersiveMode: { true }, + setImmersiveMode: { _ in }, + startAutomatically: startAutomatically, + setStartAutomatically: setStartAutomatically, + launch: launch + ) + } + + private func descendant(withIdentifier identifier: String, in view: NSView) -> NSView? { + if view.identifier?.rawValue == identifier { return view } + for child in view.subviews { + if let found = descendant(withIdentifier: identifier, in: child) { return found } + } + return nil + } +} diff --git a/macos/Tests/OmarchyVMHelperTests/StartupPreferenceStoreTests.swift b/macos/Tests/OmarchyVMHelperTests/StartupPreferenceStoreTests.swift new file mode 100644 index 00000000..795856b7 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/StartupPreferenceStoreTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import OmarchyVMHelper + +@Suite("Automatic startup") +struct StartupPreferenceStoreTests { + @Test("Automatic startup is opt-in and the choice persists") + 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()) + + store.save(true) + #expect(StartupPreferenceStore(defaults: defaults).load()) + store.save(false) + #expect(!StartupPreferenceStore(defaults: defaults).load()) + } + + @Test("Only an enabled preference without Option held skips the menu", + arguments: [false, true], [false, true]) + func respectsPreferenceAndOverride(isEnabled: Bool, optionKeyHeld: Bool) { + #expect(StartupPolicy.shouldStartAutomatically( + isEnabled: isEnabled, + optionKeyHeld: optionKeyHeld, + initialArguments: [] + ) == (isEnabled && !optionKeyHeld)) + } + + @Test("Explicit resets always reach the confirmation menu", + arguments: ["--reset-storage", "--reset-storage-only"]) + func resetAlwaysShowsMenu(argument: String) { + #expect(!StartupPolicy.shouldStartAutomatically( + isEnabled: true, + optionKeyHeld: false, + initialArguments: [argument, "/guest"] + )) + } + + @Test("Ephemeral and custom guest launches honor automatic startup", + arguments: [["--ephemeral"], ["/guest"]]) + func otherLaunchModes(arguments: [String]) { + #expect(StartupPolicy.shouldStartAutomatically( + isEnabled: true, + optionKeyHeld: false, + initialArguments: arguments + )) + } +} From ff06103297b3b5e761ec991ed6b14025aa4a19a7 Mon Sep 17 00:00:00 2001 From: Einar Andersson <72999+drdator@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:23:44 +0200 Subject: [PATCH 2/4] Open Mac settings from inside Omarchy --- README.md | 2 + guest/README.md | 36 +++++++ .../omarchy/extensions/omarchy-menu.jsonc | 9 ++ .../rules.d/92-omarchy-native-settings.rules | 1 + .../usr/local/bin/omarchy-native-settings | 68 ++++++++++++++ .../applications/try-omarchy-settings.desktop | 10 ++ guest/scripts/configure-rootfs.sh | 1 + guest/tests/test_native_settings.py | 47 ++++++++++ .../NativeSettingsBridge.swift | 94 +++++++++++++++++++ .../OmarchyVMHelper/StartMenuWindow.swift | 70 +++++++++----- .../VMApplicationController.swift | 38 +++++++- .../NativeSettingsBridgeTests.swift | 51 ++++++++++ .../StartMenuStartupTests.swift | 38 ++++++++ macos/run-qemu-gpu.sh | 6 +- 14 files changed, 447 insertions(+), 24 deletions(-) create mode 100644 guest/native-overlay/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc create mode 100644 guest/native-overlay/etc/udev/rules.d/92-omarchy-native-settings.rules create mode 100755 guest/native-overlay/usr/local/bin/omarchy-native-settings create mode 100644 guest/native-overlay/usr/share/applications/try-omarchy-settings.desktop create mode 100644 guest/tests/test_native_settings.py create mode 100644 macos/Sources/OmarchyVMHelper/NativeSettingsBridge.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/NativeSettingsBridgeTests.swift diff --git a/README.md b/README.md index 92f50599..21fdd04a 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ By default, every launch begins at the start menu. Enable **Start automatically* While that menu is open, Try Omarchy behaves like a regular Mac app with standard Quit, Close Window, and Minimize commands; 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. +Inside Omarchy, choose **Setup → Try Omarchy Settings**, search for **Try Omarchy Settings**, or run `omarchy-native-settings` to reopen the Mac settings window. This first version lets you change **Start automatically** while the VM runs; the other controls are disabled. **Done** or closing the window returns to Omarchy without stopping the VM. To change other settings, shut down Omarchy and hold **Option** while reopening the Mac app. Existing VMs need the [guest settings command installed once](guest/README.md#settings-access-from-an-existing-vm). + Restarting from inside Omarchy reboots the guest in the same Try Omarchy app. Shutting down Omarchy closes the app and leaves it closed. diff --git a/guest/README.md b/guest/README.md index 5825f2c9..b06e3e68 100644 --- a/guest/README.md +++ b/guest/README.md @@ -81,3 +81,39 @@ The vendor service generates missing host keys on the writable guest disk. A persistent VM therefore keeps its identity across restarts and app updates, while a Factory Reset or a fresh ephemeral VM gets a new identity. The factory image must never contain shared SSH host private keys. + +## Settings access from an existing VM + +New factory images include **Setup → Try Omarchy Settings** and a searchable +application entry. Both run `omarchy-native-settings`, which sends +`open-settings\n` through `/dev/virtio-ports/dev.tryomarchy.settings`. The Mac +app replies `opened\n` after presenting its window, or `unavailable\n` if it +cannot present settings. The command times out after three seconds and reports +errors through a desktop notification and stderr. The channel only opens the +settings UI; it does not accept preference values or other host commands. + +Updating the Mac app does not install these files on an existing persistent +disk. For this prototype, make this repository available inside the guest +(for example through a shared folder), then run these commands **inside +Omarchy**, from the repository root: + +```sh +sudo install -m 0755 guest/native-overlay/usr/local/bin/omarchy-native-settings /usr/local/bin/omarchy-native-settings +sudo install -m 0644 guest/native-overlay/etc/udev/rules.d/92-omarchy-native-settings.rules /etc/udev/rules.d/92-omarchy-native-settings.rules +sudo install -m 0644 guest/native-overlay/usr/share/applications/try-omarchy-settings.desktop /usr/share/applications/try-omarchy-settings.desktop +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=virtio-ports +``` + +The VM must have been launched with the updated Mac app, which adds the +settings port. Run `omarchy-native-settings` or search the application launcher +for **Try Omarchy Settings**. For the Setup menu entry, merge the +`setup.try-omarchy` entry from +`guest/native-overlay/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc` +into `~/.config/omarchy/extensions/omarchy-menu.jsonc`, preserving any existing +entries, then run `omarchy menu refresh`. + +Only automatic startup is editable while the VM runs in this prototype. The +remaining settings still require shutting down and reopening the Mac app with +Option held. A guest reboot keeps the current QEMU process and does not reload +the Mac launch preferences. diff --git a/guest/native-overlay/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc b/guest/native-overlay/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc new file mode 100644 index 00000000..d78b9169 --- /dev/null +++ b/guest/native-overlay/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc @@ -0,0 +1,9 @@ +{ + "setup.try-omarchy": { + "icon": "", + "label": "Try Omarchy Settings", + "description": "Open the Mac app settings", + "action": "omarchy-native-settings", + "when": "test -w /dev/virtio-ports/dev.tryomarchy.settings" + } +} diff --git a/guest/native-overlay/etc/udev/rules.d/92-omarchy-native-settings.rules b/guest/native-overlay/etc/udev/rules.d/92-omarchy-native-settings.rules new file mode 100644 index 00000000..b265c818 --- /dev/null +++ b/guest/native-overlay/etc/udev/rules.d/92-omarchy-native-settings.rules @@ -0,0 +1 @@ +SUBSYSTEM=="virtio-ports", ATTR{name}=="dev.tryomarchy.settings", GROUP="users", MODE="0660" diff --git a/guest/native-overlay/usr/local/bin/omarchy-native-settings b/guest/native-overlay/usr/local/bin/omarchy-native-settings new file mode 100755 index 00000000..481c447e --- /dev/null +++ b/guest/native-overlay/usr/local/bin/omarchy-native-settings @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Ask the Mac app to show settings over dev.tryomarchy.settings.""" + +import os +from pathlib import Path +import select +import subprocess +import sys +import time + +PORT = Path("/dev/virtio-ports/dev.tryomarchy.settings") + + +def open_settings(descriptor, timeout=3.0): + """Send one bounded request and wait for the Mac app to acknowledge it.""" + deadline = time.monotonic() + timeout + pending = b"open-settings\n" + response = bytearray() + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("The Mac app did not respond. Close and reopen Try Omarchy, then try again.") + readable, writable, _ = select.select( + [] if pending else [descriptor], [descriptor] if pending else [], [], remaining + ) + try: + if writable: + written = os.write(descriptor, pending) + if written == 0: + raise OSError("The Mac settings connection closed.") + pending = pending[written:] + if readable: + data = os.read(descriptor, 64) + if not data: + raise OSError("The Mac settings connection closed.") + response.extend(data) + if b"\n" in response: + if response == b"opened\n": + return + raise OSError("The Mac app cannot open settings right now. Try again when Omarchy is running.") + if len(response) >= 64: + raise OSError("The Mac app sent an invalid settings response.") + except BlockingIOError: + continue + + +def main(): + try: + descriptor = os.open(PORT, os.O_RDWR | os.O_NONBLOCK | os.O_CLOEXEC) + try: + open_settings(descriptor) + finally: + os.close(descriptor) + except (OSError, TimeoutError) as error: + message = str(error) + if isinstance(error, FileNotFoundError): + message = "This VM needs a version of the Try Omarchy Mac app with settings access." + print(f"Try Omarchy Settings: {message}", file=sys.stderr) + try: + subprocess.run(["notify-send", "Try Omarchy Settings", message], check=False, timeout=3) + except (OSError, subprocess.TimeoutExpired): + pass + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/guest/native-overlay/usr/share/applications/try-omarchy-settings.desktop b/guest/native-overlay/usr/share/applications/try-omarchy-settings.desktop new file mode 100644 index 00000000..2e102c53 --- /dev/null +++ b/guest/native-overlay/usr/share/applications/try-omarchy-settings.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=Try Omarchy Settings +Comment=Open the Try Omarchy Mac app settings +Exec=omarchy-native-settings +TryExec=omarchy-native-settings +Icon=preferences-system +Terminal=false +Categories=Settings; +Keywords=Mac;VM;Startup; diff --git a/guest/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index ccbda178..72afde5d 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -76,6 +76,7 @@ chmod 0755 \ "$root/usr/local/bin/omarchy-native-audio-bridge" \ "$root/usr/local/bin/omarchy-native-camera-bridge" \ "$root/usr/local/bin/omarchy-native-clipboard-bridge" \ + "$root/usr/local/bin/omarchy-native-settings" \ "$root/usr/local/bin/omarchy-native-cursor-restore" \ "$root/usr/local/bin/omarchy-native-display-sync" \ "$root/usr/local/bin/omarchy-native-mac-share" \ diff --git a/guest/tests/test_native_settings.py b/guest/tests/test_native_settings.py new file mode 100644 index 00000000..a5b2e443 --- /dev/null +++ b/guest/tests/test_native_settings.py @@ -0,0 +1,47 @@ +import importlib.machinery +import importlib.util +from pathlib import Path +import socket +import unittest + + +COMMAND = Path(__file__).resolve().parents[1] / "native-overlay/usr/local/bin/omarchy-native-settings" +LOADER = importlib.machinery.SourceFileLoader("native_settings", str(COMMAND)) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +settings = importlib.util.module_from_spec(SPEC) +LOADER.exec_module(settings) + + +class NativeSettingsTests(unittest.TestCase): + def setUp(self): + self.guest, self.host = socket.socketpair() + self.guest.setblocking(False) + self.host.settimeout(1) + self.addCleanup(self.guest.close) + self.addCleanup(self.host.close) + + def test_requests_settings_and_waits_for_acknowledgment(self): + self.host.sendall(b"opened\n") + settings.open_settings(self.guest.fileno()) + self.assertEqual(self.host.recv(64), b"open-settings\n") + + def test_unavailable_or_malformed_responses_fail(self): + for response in [b"unavailable\n", b"unknown\n", b"x" * 64]: + with self.subTest(response=response): + self.host.sendall(response) + with self.assertRaises(OSError): + settings.open_settings(self.guest.fileno()) + self.assertEqual(self.host.recv(64), b"open-settings\n") + + def test_missing_reply_times_out(self): + with self.assertRaises(TimeoutError): + settings.open_settings(self.guest.fileno(), timeout=0.02) + + def test_disconnected_host_fails(self): + self.host.close() + with self.assertRaises(OSError): + settings.open_settings(self.guest.fileno()) + + +if __name__ == "__main__": + unittest.main() diff --git a/macos/Sources/OmarchyVMHelper/NativeSettingsBridge.swift b/macos/Sources/OmarchyVMHelper/NativeSettingsBridge.swift new file mode 100644 index 00000000..dab01199 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/NativeSettingsBridge.swift @@ -0,0 +1,94 @@ +import Darwin +import Foundation + +/// One deliberately narrow guest request on dev.tryomarchy.settings. +/// Oversized/unknown lines are discarded without interpreting any payload. +struct SettingsRequestBuffer { + private var line = Data() + private var discarding = false + + mutating func consume(_ data: Data) -> Bool { + var requested = false + for byte in data { + if byte == 10 { + requested = requested || (!discarding && line == Data("open-settings".utf8)) + line.removeAll(keepingCapacity: true) + discarding = false + } else if !discarding { + if line.count < 64 { + line.append(byte) + } else { + line.removeAll(keepingCapacity: true) + discarding = true + } + } + } + return requested + } +} + +/// Lives in the AppKit process so requests can present the existing window. +@MainActor +final class NativeSettingsBridge { + private var source: DispatchSourceRead? + private var requests = SettingsRequestBuffer() + private let descriptor: Int32 + private let openSettings: () -> Bool + + convenience init(socketPath: String, openSettings: @escaping () -> Bool) throws { + let descriptor = try NativeBridgeSocket.connectSecure(path: socketPath, label: "settings") + try self.init(descriptor: descriptor, openSettings: openSettings) + } + + /// Takes ownership of a connected descriptor; also used with socketpair in tests. + init(descriptor: Int32, openSettings: @escaping () -> Bool) throws { + self.descriptor = descriptor + self.openSettings = openSettings + var noSignal: Int32 = 1 + let flags = fcntl(descriptor, F_GETFL) + guard flags >= 0, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) == 0, + setsockopt(descriptor, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout.size)) == 0 else { + Darwin.close(descriptor) + throw HelperError.io("cannot configure settings channel") + } + let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { self?.readRequests() } + } + source.setCancelHandler { Darwin.close(descriptor) } + self.source = source + source.resume() + } + + deinit { source?.cancel() } + + func stop() { + source?.cancel() + source = nil + } + + private func readRequests() { + guard source != nil else { return } + var buffer = [UInt8](repeating: 0, count: 512) + // Bound work on the UI thread even if the guest floods the channel. + var requested = false + for _ in 0..<16 { + let count = Darwin.read(descriptor, &buffer, buffer.count) + if count > 0 { + let nextRequest = requests.consume(Data(buffer.prefix(count))) + requested = requested || nextRequest + } else if count < 0 && errno == EINTR { + continue + } else if count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK) { + break + } else { + stop() + return + } + } + guard requested else { return } + let reply = Data((openSettings() ? "opened\n" : "unavailable\n").utf8) + let written = reply.withUnsafeBytes { Darwin.write(descriptor, $0.baseAddress, $0.count) } + if written != reply.count { stop() } + } +} diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index 910c0d60..3737e777 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -218,6 +218,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private var cameraRequestInFlight = false private var resetInProgress = false private var launchInProgress = false + private var virtualMachineRunning = false + private var closeRunningSettings: (() -> Void)? + private var prelaunchControlsLocked: Bool { launchInProgress || virtualMachineRunning } private var pendingResetSpaceEstimate: String? private var resetConfirmationPrompt: ResetConfirmationPrompt? private weak var startMenuScrollView: NSScrollView? @@ -227,7 +230,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { canRestore: { [weak self] in guard let self else { return false } return self.window.isVisible - && !self.launchInProgress + && !self.prelaunchControlsLocked && !self.resetInProgress && !self.microphoneRequestInFlight && !self.cameraRequestInFlight @@ -328,6 +331,22 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { window.contentView = content } + func virtualMachineDidStart(closeSettings: @escaping () -> Void) { + launchInProgress = false + virtualMachineRunning = true + closeRunningSettings = closeSettings + window.title = "Try Omarchy Settings" + // The VM has its own Cocoa process and may occupy a fullscreen Space. + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.level = .floating + } + + @objc private func closeSettings() { + guard virtualMachineRunning else { return } + dismiss() + closeRunningSettings?() + } + func show() { prepareForPresentation( visibleFrame: (window.screen ?? NSScreen.main)?.visibleFrame @@ -350,7 +369,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func refreshPermissionStatus() { - guard window.isVisible, !launchInProgress, !resetInProgress else { return } + guard window.isVisible, !prelaunchControlsLocked, !resetInProgress else { return } render() } @@ -460,7 +479,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func windowShouldClose(_ sender: NSWindow) -> Bool { - NSApp.terminate(nil) + if virtualMachineRunning { + closeSettings() + } else { + NSApp.terminate(nil) + } return false } @@ -478,7 +501,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { icon.heightAnchor.constraint(equalToConstant: 62), ]) - let title = NSTextField(labelWithString: "Try Omarchy") + let title = NSTextField(labelWithString: virtualMachineRunning ? "Try Omarchy Settings" : "Try Omarchy") title.font = .systemFont(ofSize: 27, weight: .bold) let headingStack = NSStackView(views: [icon, title]) @@ -658,7 +681,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { reset.controlSize = .small reset.contentTintColor = .systemRed reset.isEnabled = canResetStorage - && !launchInProgress + && !prelaunchControlsLocked && !resetInProgress && !microphoneRequestInFlight && !cameraRequestInFlight @@ -672,21 +695,21 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { resetSection.alignment = .leading resetSection.spacing = 4 - let launchButtonTitle = launchInProgress ? "Launching Omarchy…" : "Launch Omarchy" + let launchButtonTitle = virtualMachineRunning ? "Done" : (launchInProgress ? "Launching Omarchy…" : "Launch Omarchy") let launchButtonFont = NSFont.systemFont(ofSize: 16, weight: .semibold) let launchButton = NSButton( title: launchButtonTitle, target: self, - action: #selector(launchOmarchy) + action: virtualMachineRunning ? #selector(closeSettings) : #selector(launchOmarchy) ) launchButton.keyEquivalent = launchInProgress ? "" : "\r" launchButton.bezelStyle = .rounded launchButton.controlSize = .large launchButton.font = launchButtonFont - launchButton.isEnabled = !launchInProgress + launchButton.isEnabled = virtualMachineRunning || (!launchInProgress && !resetInProgress && !microphoneRequestInFlight - && !cameraRequestInFlight + && !cameraRequestInFlight) launchButton.title = "" launchButton.identifier = NSUserInterfaceItemIdentifier("launch-button") let launchButtonLabel = MouseIgnoringTextField(labelWithString: launchButtonTitle) @@ -704,7 +727,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { launchButtonLabel.centerYAnchor.constraint(equalTo: launchButton.centerYAnchor), ]) launchButton.translatesAutoresizingMaskIntoConstraints = false - launchButton.setAccessibilityLabel(launchInProgress ? "Launching Omarchy" : "Launch Omarchy") + launchButton.setAccessibilityLabel(launchButtonTitle) if launchInProgress { let spinner = NSProgressIndicator() spinner.style = .spinning @@ -719,7 +742,6 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } NSLayoutConstraint.activate([ launchButton.heightAnchor.constraint(equalToConstant: 48), - launchButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 500), ]) let automaticStart = NSButton( @@ -730,7 +752,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { automaticStart.state = startAutomatically() ? .on : .off automaticStart.isEnabled = launchButton.isEnabled automaticStart.identifier = NSUserInterfaceItemIdentifier("automatic-start-checkbox") - let automaticStartHelp = "Skip this menu on launch. Hold Option while opening the app to show it again." + let automaticStartHelp = virtualMachineRunning + ? "Skip the start menu when opening Try Omarchy. To change the other settings, shut down Omarchy and hold Option while reopening the Mac app." + : "Skip this menu on launch. Hold Option while opening the app to show it again." automaticStart.setAccessibilityHelp(automaticStartHelp) let automaticStartCaption = NSTextField(wrappingLabelWithString: automaticStartHelp) automaticStartCaption.font = .systemFont(ofSize: 12) @@ -770,7 +794,9 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ]) let stack = NSStackView( - views: [headingStack, permissionCard, resetSection, automaticStartSection, launchButton, footerContainer] + views: virtualMachineRunning + ? [headingStack, automaticStartSection, launchButton, permissionCard, resetSection, footerContainer] + : [headingStack, permissionCard, resetSection, automaticStartSection, launchButton, footerContainer] ) stack.orientation = .vertical stack.alignment = .leading @@ -959,7 +985,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { button.isEnabled = actionsEnabled && !microphoneRequestInFlight && !cameraRequestInFlight - && !launchInProgress + && !prelaunchControlsLocked && !resetInProgress let identifier = actions.count == 1 ? "permission-action-\(symbolName)" @@ -1089,7 +1115,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { toggle.state = isEnabled ? .on : .off toggle.target = self toggle.action = #selector(changeImmersiveMode(_:)) - toggle.isEnabled = !microphoneRequestInFlight && !launchInProgress && !resetInProgress + toggle.isEnabled = !microphoneRequestInFlight && !prelaunchControlsLocked && !resetInProgress toggle.identifier = NSUserInterfaceItemIdentifier("immersive-toggle") toggle.setAccessibilityLabel("Immersive mode") toggle.setAccessibilityTitleUIElement(title) @@ -1221,7 +1247,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func beginStorageLocationSelection() { - guard canResetStorage, !launchInProgress, !resetInProgress else { return } + guard canResetStorage, !prelaunchControlsLocked, !resetInProgress else { return } permissionWindowRestorer.cancel() let panel = NSOpenPanel() panel.title = "Choose where to keep the Omarchy VM" @@ -1277,13 +1303,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func useDefaultStorageLocationAction() { - guard canResetStorage, !launchInProgress, !resetInProgress else { return } + guard canResetStorage, !prelaunchControlsLocked, !resetInProgress else { return } useDefaultStorageLocation() render() } @objc private func beginSharedFolderSelection() { - guard !launchInProgress, + guard !prelaunchControlsLocked, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight else { return } @@ -1325,7 +1351,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func beginPortForwardingConfiguration() { - guard !launchInProgress, !resetInProgress, portForwardingEditor == nil else { return } + guard !prelaunchControlsLocked, !resetInProgress, portForwardingEditor == nil else { return } permissionWindowRestorer.cancel() let editor = PortForwardingEditor( mappings: portForwardingStatus(), @@ -1346,7 +1372,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func changeImmersiveMode(_ sender: NSSwitch) { - guard !launchInProgress, !resetInProgress else { return } + guard !prelaunchControlsLocked, !resetInProgress else { return } let isEnabled = sender.state == .on setImmersiveMode(isEnabled) let detailText = StartMenuPresentation.immersiveDetail(isEnabled: isEnabled) @@ -1364,7 +1390,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private func confirmReset() { guard canResetStorage, - !launchInProgress, + !prelaunchControlsLocked, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight, @@ -1414,7 +1440,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc func launchOmarchy() { - guard !launchInProgress, + guard !prelaunchControlsLocked, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight else { return } diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index 9b23eca4..f22d0f8b 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -61,6 +61,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private let deviceProvider: HostAudioDeviceProviding private let bundledMetrics: BundledGuestMetrics? private var startMenuWindow: StartMenuWindow? + private var settingsBridge: NativeSettingsBridge? + private var settingsReturnApplication: NSRunningApplication? private var volumeObserver: NSObjectProtocol? private var hostPowerObserver: HostPowerNotificationObserver? private let hostSleepCoordinator = VMHostSleepCoordinator() @@ -498,7 +500,34 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { virtualMachineReachedStart = true NSApp.setActivationPolicy(ApplicationPresentation.runningActivationPolicy) startMenuWindow?.dismiss() - startMenuWindow = nil + startMenuWindow?.virtualMachineDidStart { [weak self] in + self?.closeRunningSettings() + } + let settingsSocket = URL(fileURLWithPath: qmpSocketPath) + .deletingLastPathComponent().appendingPathComponent("settings.sock").path + do { + settingsBridge = try NativeSettingsBridge(socketPath: settingsSocket) { [weak self] in + self?.showRunningSettings() ?? false + } + } catch { + // Settings access is optional; losing it must not stop a running VM. + fputs("[settings] \(error.localizedDescription)\n", stderr) + } + } + + private func showRunningSettings() -> Bool { + guard childRunning, virtualMachineReachedStart, !lifecycle.isStopping, + !isPresentingBlockingAlert, let startMenuWindow else { return false } + guard !startMenuWindow.window.isVisible else { return true } + settingsReturnApplication = NSWorkspace.shared.frontmostApplication + startMenuWindow.show() + return true + } + + private func closeRunningSettings() { + startMenuWindow?.dismiss() + settingsReturnApplication?.activate(options: []) + settingsReturnApplication = nil } private func failHostSleepControlSetup(detail: String) { @@ -819,6 +848,13 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private func childDidExit(status: Int32) { guard childRunning else { return } childRunning = false + settingsBridge?.stop() + settingsBridge = nil + settingsReturnApplication = nil + if virtualMachineReachedStart { + startMenuWindow?.dismiss() + startMenuWindow = nil + } let launchAllowedBootRecovery = activeLaunchAllowedBootRecovery activeLaunchAllowedBootRecovery = false cancelHostWakeRetry() diff --git a/macos/Tests/OmarchyVMHelperTests/NativeSettingsBridgeTests.swift b/macos/Tests/OmarchyVMHelperTests/NativeSettingsBridgeTests.swift new file mode 100644 index 00000000..81453576 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/NativeSettingsBridgeTests.swift @@ -0,0 +1,51 @@ +import Darwin +import Foundation +import Testing +@testable import OmarchyVMHelper + +@Suite("Guest settings requests") +struct NativeSettingsBridgeTests { + @Test("Only complete requests open settings, including across read boundaries") + func framing() { + var requests = SettingsRequestBuffer() + let chunks: [(Data, Bool)] = [ + (Data("open-set".utf8), false), + (Data("tings\n".utf8), true), + (Data("reset\nopen-settings /tmp/file\n".utf8), false), + (Data(repeating: 120, count: 100_000), false), + (Data("open-settings\n".utf8), false), + (Data("open-settings\nopen-settings\n".utf8), true), + ] + for (chunk, expected) in chunks { + let requested = requests.consume(chunk) + #expect(requested == expected) + } + } + + @Test("Socket requests reach the UI callback and receive its result", arguments: [true, false]) + @MainActor + func socketRequest(canOpen: Bool) async throws { + var descriptors: [Int32] = [-1, -1] + try #require(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0) + let peer = descriptors[1] + defer { Darwin.close(peer) } + var opened = 0 + let bridge = try NativeSettingsBridge(descriptor: descriptors[0]) { + opened += 1 + return canOpen + } + defer { bridge.stop() } + let request = Data("open-settings\n".utf8) + #expect(request.withUnsafeBytes { Darwin.write(peer, $0.baseAddress, $0.count) } == request.count) + // Yield the main actor to the dispatch source, with a bounded deadline. + for _ in 0..<100 where opened == 0 { + try await Task.sleep(for: .milliseconds(10)) + } + try #require(opened == 1) + var response = [UInt8](repeating: 0, count: 64) + #expect(fcntl(peer, F_SETFL, O_NONBLOCK) == 0) + let count = Darwin.read(peer, &response, response.count) + try #require(count > 0) + #expect(String(decoding: response.prefix(count), as: UTF8.self) == (canOpen ? "opened\n" : "unavailable\n")) + } +} diff --git a/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift index b4e3592c..44b8ce84 100644 --- a/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift @@ -38,6 +38,44 @@ struct StartMenuStartupTests { #expect(!launchingCheckbox.isEnabled) } + @Test("Running settings can change startup and close without launching or quitting the VM") + func runningSettings() throws { + _ = NSApplication.shared + var automaticStart = true + var launchCount = 0 + var closeCount = 0 + let menu = makeMenu( + storageState: { .defaultLocation }, + startAutomatically: { automaticStart }, + setStartAutomatically: { automaticStart = $0 }, + launch: { launchCount += 1 } + ) + defer { menu.dismiss() } + menu.launchOmarchy() + menu.virtualMachineDidStart { closeCount += 1 } + menu.prepareForPresentation(visibleFrame: nil) + let content = try #require(menu.window.contentView) + let checkbox = try #require(descendant( + withIdentifier: "automatic-start-checkbox", in: content + ) as? NSButton) + #expect(checkbox.isEnabled) + checkbox.performClick(nil) + #expect(!automaticStart) + for identifier in ["permission-action-folder", "permission-action-network"] { + let button = try #require(descendant(withIdentifier: identifier, in: content) as? NSButton) + #expect(!button.isEnabled) + } + menu.launchOmarchy() + #expect(launchCount == 1) + let done = try #require(descendant(withIdentifier: "launch-button", in: content) as? NSButton) + #expect(done.isEnabled) + done.performClick(nil) + #expect(closeCount == 1) + #expect(menu.windowShouldClose(menu.window) == false) + #expect(closeCount == 2) + #expect(launchCount == 1) + } + private func makeMenu( storageState: @escaping () -> StorageLocationMenuState, startAutomatically: @escaping () -> Bool = { false }, diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index 23378923..d8e8e7ff 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -1200,6 +1200,7 @@ qmp_socket="/tmp/${work_dir##*/}/qmp.sock" audio_bridge_socket="/tmp/${work_dir##*/}/audio.sock" camera_bridge_socket="/tmp/${work_dir##*/}/camera.sock" clipboard_bridge_socket="/tmp/${work_dir##*/}/clipboard.sock" +settings_bridge_socket="/tmp/${work_dir##*/}/settings.sock" audio_route_dir="/tmp/${work_dir##*/}/audio-routes" mkdir -m 700 "$work_dir/audio-routes" @@ -1333,6 +1334,8 @@ qemu_args=( -device 'virtio-rng-pci,rng=omarchy-rng' -device virtio-balloon-pci -device 'virtio-serial-pci,id=omarchy-serial' + -chardev "socket,id=omarchy-settings-bridge,path=$settings_bridge_socket,server=on,wait=off" + -device 'virtserialport,bus=omarchy-serial.0,nr=3,chardev=omarchy-settings-bridge,name=dev.tryomarchy.settings' -chardev 'stdio,id=omarchy-hvc0,signal=off' -device 'virtconsole,bus=omarchy-serial.0,nr=0,chardev=omarchy-hvc0' -chardev "socket,id=omarchy-audio-bridge,path=$audio_bridge_socket,server=on,wait=off" @@ -1404,7 +1407,7 @@ printf '%s\n' "$qemu_pid" >"$work_dir/.qemu.pid" chmod 600 "$work_dir/.qemu.pid" for ((attempt = 0; attempt < 100; attempt++)); do - if [[ -S $qmp_socket && -S $audio_bridge_socket && -S $camera_bridge_socket && -S $clipboard_bridge_socket ]]; then + if [[ -S $qmp_socket && -S $audio_bridge_socket && -S $camera_bridge_socket && -S $clipboard_bridge_socket && -S $settings_bridge_socket ]]; then break fi kill -0 "$qemu_pid" 2>/dev/null || fail "QEMU exited before creating its private QMP socket" @@ -1414,6 +1417,7 @@ done [[ -S $audio_bridge_socket ]] || fail "QEMU did not create its private audio bridge socket" [[ -S $camera_bridge_socket ]] || fail "QEMU did not create its private camera bridge socket" [[ -S $clipboard_bridge_socket ]] || fail "QEMU did not create its private clipboard bridge socket" +[[ -S $settings_bridge_socket ]] || fail "QEMU did not create its private settings bridge socket" echo "[qemu-gpu] Ready. QMP: $qmp_socket" >&2 # FD 9 deliberately remains open only in QEMU. Letting the sibling audio From 9cb028c2df423a3809deb839330a3f1da9711cc0 Mon Sep 17 00:00:00 2001 From: Einar Andersson <72999+drdator@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:17:19 +0200 Subject: [PATCH 3/4] Make VM settings accessible throughout the Omarchy session --- README.md | 4 +- guest/README.md | 47 +++++----- guest/scripts/install-settings-integration.py | 76 ++++++++++++++++ guest/tests/test_settings_install.py | 50 ++++++++++ .../DisposableVMWorkspace.swift | 30 ++++++ .../OmarchyVMHelper/StartMenuWindow.swift | 91 ++++++++++++++++--- .../VMApplicationController.swift | 79 +++++++++++++--- .../OmarchyVMHelper/VMRunLifecycle.swift | 26 ++++++ .../SettingsLifecycleTests.swift | 52 +++++++++++ .../StartMenuStartupTests.swift | 11 ++- macos/Tests/run-qemu-ssh-contract.test.sh | 18 ++++ macos/build-app.sh | 12 +++ macos/guest-settings.service | 17 ++++ macos/run-qemu-gpu.sh | 33 ++++++- scripts/build-cache.py | 5 + 15 files changed, 492 insertions(+), 59 deletions(-) create mode 100644 guest/scripts/install-settings-integration.py create mode 100644 guest/tests/test_settings_install.py create mode 100644 macos/Sources/OmarchyVMHelper/DisposableVMWorkspace.swift create mode 100644 macos/Tests/OmarchyVMHelperTests/SettingsLifecycleTests.swift create mode 100644 macos/guest-settings.service diff --git a/README.md b/README.md index 21fdd04a..6bfe1c75 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ By default, every launch begins at the start menu. Enable **Start automatically* While that menu is open, Try Omarchy behaves like a regular Mac app with standard Quit, Close Window, and Minimize commands; 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. -Inside Omarchy, choose **Setup → Try Omarchy Settings**, search for **Try Omarchy Settings**, or run `omarchy-native-settings` to reopen the Mac settings window. This first version lets you change **Start automatically** while the VM runs; the other controls are disabled. **Done** or closing the window returns to Omarchy without stopping the VM. To change other settings, shut down Omarchy and hold **Option** while reopening the Mac app. Existing VMs need the [guest settings command installed once](guest/README.md#settings-access-from-an-existing-vm). +Inside Omarchy, choose **Setup → Try Omarchy Settings**, search for **Try Omarchy Settings**, or run `omarchy-native-settings` to reopen the Mac settings window. You can change automatic startup, permissions, sharing, port forwarding, and immersive mode here. Sharing, ports, and immersive mode are saved for the next launch; **Restart Try Omarchy…** shuts down Linux and starts a new VM process to apply them. Save your work first. A disposable VM keeps its disk across this restart until you close the app. + +For VM location and reset, choose **Shut down to manage…**. The settings window stays open even with automatic startup enabled; reset still asks for confirmation. **Done** or closing the running settings window returns to Omarchy without stopping it. Existing VMs [receive settings access automatically](guest/README.md#settings-access-from-an-existing-vm) when launched with the updated app, without a reset or manual installation. Restarting from inside Omarchy reboots the guest in the same Try Omarchy app. Shutting down Omarchy closes the app and leaves it closed. diff --git a/guest/README.md b/guest/README.md index b06e3e68..18e35c0a 100644 --- a/guest/README.md +++ b/guest/README.md @@ -92,28 +92,25 @@ cannot present settings. The command times out after three seconds and reports errors through a desktop notification and stderr. The channel only opens the settings UI; it does not accept preference values or other host commands. -Updating the Mac app does not install these files on an existing persistent -disk. For this prototype, make this repository available inside the guest -(for example through a shared folder), then run these commands **inside -Omarchy**, from the repository root: - -```sh -sudo install -m 0755 guest/native-overlay/usr/local/bin/omarchy-native-settings /usr/local/bin/omarchy-native-settings -sudo install -m 0644 guest/native-overlay/etc/udev/rules.d/92-omarchy-native-settings.rules /etc/udev/rules.d/92-omarchy-native-settings.rules -sudo install -m 0644 guest/native-overlay/usr/share/applications/try-omarchy-settings.desktop /usr/share/applications/try-omarchy-settings.desktop -sudo udevadm control --reload-rules -sudo udevadm trigger --subsystem-match=virtio-ports -``` - -The VM must have been launched with the updated Mac app, which adds the -settings port. Run `omarchy-native-settings` or search the application launcher -for **Try Omarchy Settings**. For the Setup menu entry, merge the -`setup.try-omarchy` entry from -`guest/native-overlay/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc` -into `~/.config/omarchy/extensions/omarchy-menu.jsonc`, preserving any existing -entries, then run `omarchy menu refresh`. - -Only automatic startup is editable while the VM runs in this prototype. The -remaining settings still require shutting down and reopening the Mac app with -Option held. A guest reboot keeps the current QEMU process and does not reload -the Mac launch preferences. +The updated Mac app installs these entry points on existing disks at boot. A +separate read-only 9p share contains only the bundled settings installer and its +files. A systemd boot credential supplies a temporary service that installs +those files, reloads the udev rule, and unmounts the share. This uses systemd's +extra-unit credentials (available since version 256, included in the supported +factory guest) and leaves the guest's default boot target unchanged. Failure is +logged under `try-omarchy-settings.service` and does not prevent normal boot. +The service has a 20-second timeout and retries on the next launch. + +Installation is idempotent. It does not reset the disk, upgrade Linux packages, +or require network access or a user `sudo` command. Existing user menu files +are preserved; those users can search for **Try Omarchy Settings** in the +application launcher. Accounts without a custom extension file also receive +**Setup → Try Omarchy Settings**. Home-directory operations run as that user. + +The settings window saves sharing, port forwarding, and immersive mode for the +next QEMU launch. **Restart Try Omarchy…** requests a clean Linux shutdown and +waits for QEMU to exit before starting a new process with the saved settings. +It never forces a shutdown on a timer. **Shut down to manage…** returns to the +native settings window without automatic startup so location and reset remain +accessible. A normal Linux reboot keeps the current QEMU process and therefore +does not apply these launch settings. diff --git a/guest/scripts/install-settings-integration.py b/guest/scripts/install-settings-integration.py new file mode 100644 index 00000000..a1316a8c --- /dev/null +++ b/guest/scripts/install-settings-integration.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Install only the app's settings entry points, from its read-only boot share.""" + +import os +from pathlib import Path +import pwd +import subprocess +import sys +import tempfile + + +FILES = { + "omarchy-native-settings": ("usr/local/bin/omarchy-native-settings", 0o755), + "92-omarchy-native-settings.rules": ("etc/udev/rules.d/92-omarchy-native-settings.rules", 0o644), + "try-omarchy-settings.desktop": ("usr/share/applications/try-omarchy-settings.desktop", 0o644), +} +MENU = ".config/omarchy/extensions/omarchy-menu.jsonc" + + +def install_file(source, destination, mode): + destination.parent.mkdir(parents=True, exist_ok=True) + if (destination.is_file() and not destination.is_symlink() + and destination.read_bytes() == source.read_bytes() + and destination.stat().st_mode & 0o777 == mode): + return + descriptor, temporary = tempfile.mkstemp(prefix=".try-omarchy-", dir=destination.parent) + try: + with os.fdopen(descriptor, "wb") as output: + output.write(source.read_bytes()) + os.fchmod(output.fileno(), mode) + os.replace(temporary, destination) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def install_menu(source, home): + """Never replace a user's extension file. The desktop entry always exists.""" + destination = home / MENU + destination.parent.mkdir(parents=True, exist_ok=True) + try: + descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644) + except FileExistsError: + return + with os.fdopen(descriptor, "wb") as output: + output.write(source.read_bytes()) + + +def install_system(payload, root): + for name, (relative, mode) in FILES.items(): + install_file(payload / name, root / relative, mode) + install_menu(payload / "omarchy-menu.jsonc", root / "etc/skel") + + +def main(): + payload = Path(__file__).resolve().parent + if sys.argv[1:] == ["--user-menu"]: + install_menu(payload / "omarchy-menu.jsonc", Path(pwd.getpwuid(os.getuid()).pw_dir)) + return + if sys.argv[1:] or os.geteuid() != 0: + raise SystemExit("Run the bundled installer as root, without arguments") + install_system(payload, Path("/")) + subprocess.run(["udevadm", "control", "--reload-rules"], check=True) + subprocess.run(["udevadm", "trigger", "--subsystem-match=virtio-ports"], check=True) + # Run home-directory operations as their owner, never with root privileges. + # Existing custom menus remain untouched and can use the searchable app entry. + for user in pwd.getpwall(): + if 1000 <= user.pw_uid < 65534 and Path(user.pw_dir).is_dir(): + subprocess.run([ + "runuser", "-u", user.pw_name, "--", "python3", str(payload / "install.py"), "--user-menu" + ], check=False) + print("Try Omarchy settings integration installed", flush=True) + + +if __name__ == "__main__": + main() diff --git a/guest/tests/test_settings_install.py b/guest/tests/test_settings_install.py new file mode 100644 index 00000000..c0af79e2 --- /dev/null +++ b/guest/tests/test_settings_install.py @@ -0,0 +1,50 @@ +import importlib.util +from pathlib import Path +import tempfile +import unittest + + +GUEST = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("settings_install", GUEST / "scripts/install-settings-integration.py") +installer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(installer) + + +class SettingsInstallTests(unittest.TestCase): + def test_installs_updates_and_preserves_custom_menu(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "root" + payload = Path(directory) / "payload" + payload.mkdir() + for name, (relative, _) in installer.FILES.items(): + (payload / name).write_bytes((GUEST / "native-overlay" / relative).read_bytes()) + (payload / "omarchy-menu.jsonc").write_bytes( + (GUEST / "native-overlay/etc/skel" / installer.MENU).read_bytes()) + installer.install_system(payload, root) + command = root / "usr/local/bin/omarchy-native-settings" + original_stat = command.stat() + installer.install_system(payload, root) + self.assertEqual(original_stat.st_ino, command.stat().st_ino) + self.assertEqual(0o755, command.stat().st_mode & 0o777) + (payload / "omarchy-native-settings").write_text("updated helper") + installer.install_system(payload, root) + self.assertEqual("updated helper", command.read_text()) + home = root / "home/person" + installer.install_menu(payload / "omarchy-menu.jsonc", home) + menu = home / installer.MENU + self.assertIn("setup.try-omarchy", menu.read_text()) + menu.write_text('// My custom menu\n{"mine": {}}\n') + installer.install_menu(payload / "omarchy-menu.jsonc", home) + self.assertEqual('// My custom menu\n{"mine": {}}\n', menu.read_text()) + + def test_existing_menu_symlink_is_not_followed(self): + with tempfile.TemporaryDirectory() as directory: + home = Path(directory) + target = home / "keep" + target.write_text("keep this") + menu = home / installer.MENU + menu.parent.mkdir(parents=True) + menu.symlink_to(target) + installer.install_menu(target, home) + self.assertTrue(menu.is_symlink()) + self.assertEqual("keep this", target.read_text()) diff --git a/macos/Sources/OmarchyVMHelper/DisposableVMWorkspace.swift b/macos/Sources/OmarchyVMHelper/DisposableVMWorkspace.swift new file mode 100644 index 00000000..37158103 --- /dev/null +++ b/macos/Sources/OmarchyVMHelper/DisposableVMWorkspace.swift @@ -0,0 +1,30 @@ +import Foundation + +/// One disk per app session, including any settings-driven QEMU restarts. +/// The normal storage backend owns locking and disk validation inside it. +final class DisposableVMWorkspace { + private(set) var directory: URL? + + func prepare() throws -> URL { + if let directory { return directory } + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("try-omarchy-disposable-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + self.directory = directory + return directory + } + + /// Call only after the launcher and QEMU have exited. + func remove() { + guard let directory else { return } + do { + try FileManager.default.removeItem(at: directory) + self.directory = nil + } catch { + fputs("[storage] could not remove disposable VM: \(error.localizedDescription)\n", stderr) + } + } +} diff --git a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift index 3737e777..2fbb5838 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -220,7 +220,10 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private var launchInProgress = false private var virtualMachineRunning = false private var closeRunningSettings: (() -> Void)? - private var prelaunchControlsLocked: Bool { launchInProgress || virtualMachineRunning } + private var shutdownInProgress = false + private var requestSettingsAction: ((VMRunLifecycle.SettingsAction) -> Void)? + private var controlsBusy: Bool { launchInProgress || shutdownInProgress } + private var prelaunchControlsLocked: Bool { controlsBusy || virtualMachineRunning } private var pendingResetSpaceEstimate: String? private var resetConfirmationPrompt: ResetConfirmationPrompt? private weak var startMenuScrollView: NSScrollView? @@ -230,7 +233,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { canRestore: { [weak self] in guard let self else { return false } return self.window.isVisible - && !self.prelaunchControlsLocked + && !self.controlsBusy && !self.resetInProgress && !self.microphoneRequestInFlight && !self.cameraRequestInFlight @@ -331,16 +334,54 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { window.contentView = content } - func virtualMachineDidStart(closeSettings: @escaping () -> Void) { + func virtualMachineDidStart( + requestSettingsAction: @escaping (VMRunLifecycle.SettingsAction) -> Void = { _ in }, + closeSettings: @escaping () -> Void + ) { launchInProgress = false virtualMachineRunning = true closeRunningSettings = closeSettings + self.requestSettingsAction = requestSettingsAction window.title = "Try Omarchy Settings" // The VM has its own Cocoa process and may occupy a fullscreen Space. window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.level = .floating } + func shutdownDidBegin() { + shutdownInProgress = true + render() + } + + func shutdownDidFail(_ message: String) { + shutdownInProgress = false + render() + let alert = NSAlert() + alert.messageText = "Omarchy couldn’t shut down" + alert.informativeText = message + alert.beginSheetModal(for: window) + } + + @objc private func restartOmarchy() { requestShutdown(.restart) } + @objc private func shutDownToManage() { requestShutdown(.manage) } + + private func requestShutdown(_ action: VMRunLifecycle.SettingsAction) { + guard virtualMachineRunning, !controlsBusy, + !microphoneRequestInFlight, !cameraRequestInFlight, + window.attachedSheet == nil, portForwardingEditor == nil else { return } + let alert = NSAlert() + alert.messageText = action == .restart ? "Restart Try Omarchy?" : "Shut down Omarchy to manage this VM?" + alert.informativeText = action == .restart + ? "Save your work first. Omarchy will shut down and start again with your saved settings." + : "Save your work first. The settings window will stay open so you can change the VM location or reset it." + alert.addButton(withTitle: action == .restart ? "Restart" : "Shut Down") + alert.addButton(withTitle: "Cancel") + alert.beginSheetModal(for: window) { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + self?.requestSettingsAction?(action) + } + } + @objc private func closeSettings() { guard virtualMachineRunning else { return } dismiss() @@ -369,7 +410,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func refreshPermissionStatus() { - guard window.isVisible, !prelaunchControlsLocked, !resetInProgress else { return } + guard window.isVisible, !controlsBusy, !resetInProgress else { return } render() } @@ -635,7 +676,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { granted: !storageStatus.isDefault && storageStatus.problem == nil, statusLabels: ("\u{25cf} Custom", "\u{25cb} Default"), actions: storageActions, - actionsEnabled: canResetStorage && !storageStatus.isEnvironmentOverride, + actionsEnabled: canResetStorage && !virtualMachineRunning && !storageStatus.isEnvironmentOverride, minimumHeight: storageDetailLines != nil || storageActions.count > 1 ? 90 : 68 ) } @@ -689,7 +730,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ? "Erase this VM and return it to factory settings" : "Reset is unavailable for a disposable VM" - let resetViews: [NSView] = [reset] + let manage = NSButton(title: "Shut down to manage…", target: self, action: #selector(shutDownToManage)) + manage.bezelStyle = .rounded + manage.identifier = NSUserInterfaceItemIdentifier("manage-vm-button") + manage.isEnabled = !controlsBusy + let resetViews: [NSView] = virtualMachineRunning && canResetStorage ? [manage] : [reset] let resetSection = NSStackView(views: resetViews) resetSection.orientation = .vertical resetSection.alignment = .leading @@ -750,15 +795,29 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { action: #selector(changeStartAutomatically(_:)) ) automaticStart.state = startAutomatically() ? .on : .off - automaticStart.isEnabled = launchButton.isEnabled + automaticStart.isEnabled = !controlsBusy && !resetInProgress automaticStart.identifier = NSUserInterfaceItemIdentifier("automatic-start-checkbox") let automaticStartHelp = virtualMachineRunning - ? "Skip the start menu when opening Try Omarchy. To change the other settings, shut down Omarchy and hold Option while reopening the Mac app." + ? "Open these settings anytime from Omarchy’s Setup menu or by searching for Try Omarchy Settings." : "Skip this menu on launch. Hold Option while opening the app to show it again." automaticStart.setAccessibilityHelp(automaticStartHelp) let automaticStartCaption = NSTextField(wrappingLabelWithString: automaticStartHelp) automaticStartCaption.font = .systemFont(ofSize: 12) automaticStartCaption.textColor = .secondaryLabelColor + let restart = NSButton(title: "Restart Try Omarchy…", target: self, action: #selector(restartOmarchy)) + restart.bezelStyle = .rounded + restart.identifier = NSUserInterfaceItemIdentifier("restart-vm-button") + restart.isEnabled = !controlsBusy + let restartCaption = NSTextField(wrappingLabelWithString: shutdownInProgress + ? "Waiting for Omarchy to shut down. Finish saving your work inside Omarchy." + : "Shared folder, port forwarding, and immersive mode changes apply when Try Omarchy next starts. Restart to apply them now.") + restartCaption.font = .systemFont(ofSize: 12) + restartCaption.textColor = .secondaryLabelColor + let runningActions = NSStackView(views: [restartCaption, restart]) + runningActions.orientation = .vertical + runningActions.alignment = .leading + runningActions.spacing = 6 + runningActions.identifier = NSUserInterfaceItemIdentifier("running-settings-actions") let automaticStartSection = NSStackView(views: [automaticStart, automaticStartCaption]) automaticStartSection.orientation = .vertical automaticStartSection.alignment = .leading @@ -795,7 +854,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { let stack = NSStackView( views: virtualMachineRunning - ? [headingStack, automaticStartSection, launchButton, permissionCard, resetSection, footerContainer] + ? [headingStack, automaticStartSection, launchButton, runningActions, permissionCard, resetSection, footerContainer] : [headingStack, permissionCard, resetSection, automaticStartSection, launchButton, footerContainer] ) stack.orientation = .vertical @@ -985,7 +1044,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { button.isEnabled = actionsEnabled && !microphoneRequestInFlight && !cameraRequestInFlight - && !prelaunchControlsLocked + && !controlsBusy && !resetInProgress let identifier = actions.count == 1 ? "permission-action-\(symbolName)" @@ -1115,7 +1174,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { toggle.state = isEnabled ? .on : .off toggle.target = self toggle.action = #selector(changeImmersiveMode(_:)) - toggle.isEnabled = !microphoneRequestInFlight && !prelaunchControlsLocked && !resetInProgress + toggle.isEnabled = !microphoneRequestInFlight && !controlsBusy && !resetInProgress toggle.identifier = NSUserInterfaceItemIdentifier("immersive-toggle") toggle.setAccessibilityLabel("Immersive mode") toggle.setAccessibilityTitleUIElement(title) @@ -1309,7 +1368,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func beginSharedFolderSelection() { - guard !prelaunchControlsLocked, + guard !controlsBusy, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight else { return } @@ -1341,17 +1400,19 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func enableSharedFolder() { + guard !controlsBusy, !resetInProgress else { return } setSharedFolderEnabled(true) render() } @objc private func disableSharedFolder() { + guard !controlsBusy, !resetInProgress else { return } setSharedFolderEnabled(false) render() } @objc private func beginPortForwardingConfiguration() { - guard !prelaunchControlsLocked, !resetInProgress, portForwardingEditor == nil else { return } + guard !controlsBusy, !resetInProgress, portForwardingEditor == nil else { return } permissionWindowRestorer.cancel() let editor = PortForwardingEditor( mappings: portForwardingStatus(), @@ -1372,7 +1433,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func changeImmersiveMode(_ sender: NSSwitch) { - guard !prelaunchControlsLocked, !resetInProgress else { return } + guard !controlsBusy, !resetInProgress else { return } let isEnabled = sender.state == .on setImmersiveMode(isEnabled) let detailText = StartMenuPresentation.immersiveDetail(isEnabled: isEnabled) @@ -1435,7 +1496,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func changeStartAutomatically(_ sender: NSButton) { - guard !launchInProgress, !resetInProgress else { return } + guard !controlsBusy, !resetInProgress else { return } setStartAutomatically(sender.state == .on) } diff --git a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift index f22d0f8b..4c990353 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -62,6 +62,9 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private let bundledMetrics: BundledGuestMetrics? private var startMenuWindow: StartMenuWindow? private var settingsBridge: NativeSettingsBridge? + private var controlSocketPath: String? + private let disposableWorkspace = DisposableVMWorkspace() + private var isDisposable: Bool { initialArguments.first == QEMUGPUStorageOption.ephemeral.rawValue } private var settingsReturnApplication: NSRunningApplication? private var volumeObserver: NSObjectProtocol? private var hostPowerObserver: HostPowerNotificationObserver? @@ -133,7 +136,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { startMenuWindow?.applicationDidBecomeActive() } - private func prepareStartMenu(startAutomatically: Bool) { + private func prepareStartMenu(startAutomatically: Bool, honorInitialReset: Bool = true) { + NSApp.setActivationPolicy(ApplicationPresentation.prelaunchActivationPolicy) let resetOptions = [ QEMUGPUStorageOption.resetStorage.rawValue, QEMUGPUStorageOption.resetStorageOnly.rawValue, @@ -229,7 +233,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { startMenu.launchOmarchy() } else { startMenu.show() - if initialResetRequested { + if honorInitialReset && initialResetRequested { startMenu.promptForReset() } } @@ -240,6 +244,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { virtualMachineReachedStart = false pendingHostSleepControlFailure = nil do { + if isDisposable { _ = try disposableWorkspace.prepare() } let accessibilityDecision = AccessibilityLaunchDecision.make( for: AXIsProcessTrusted() ? .authorized : .unavailable ) @@ -258,7 +263,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { } // Switching to the default is an acceptable way to start a VM, so // both `.available` and `.switchedToDefault` proceed here. - guard resolveStorageLocationAvailability() != .cancelled else { + guard isDisposable || resolveStorageLocationAvailability() != .cancelled else { startMenuWindow?.launchDidAbort() return } @@ -309,7 +314,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { QEMUGPUStorageOption.resetStorage.rawValue, QEMUGPUStorageOption.resetStorageOnly.rawValue, ] - if let first = arguments.first, resetOptions.contains(first) { + if let first = arguments.first, + resetOptions.contains(first) || first == QEMUGPUStorageOption.ephemeral.rawValue { arguments.removeFirst() } return arguments @@ -433,8 +439,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { baseEnvironment: forwarding.environment, preferences: fullscreenPreferenceStore.load() ) + var storageEnvironment = fullscreen.environment + if let directory = disposableWorkspace.directory { + storageEnvironment[StorageLocationPolicy.environmentKey] = directory.path + } let storage = StorageLocationLaunchConfiguration.make( - baseEnvironment: fullscreen.environment, + baseEnvironment: storageEnvironment, preference: storageLocationStore.load(), metrics: bundledMetrics, probe: volumeProbe, @@ -500,9 +510,11 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { virtualMachineReachedStart = true NSApp.setActivationPolicy(ApplicationPresentation.runningActivationPolicy) startMenuWindow?.dismiss() - startMenuWindow?.virtualMachineDidStart { [weak self] in - self?.closeRunningSettings() - } + controlSocketPath = qmpSocketPath + startMenuWindow?.virtualMachineDidStart( + requestSettingsAction: { [weak self] action in self?.shutDownForSettings(action) }, + closeSettings: { [weak self] in self?.closeRunningSettings() } + ) let settingsSocket = URL(fileURLWithPath: qmpSocketPath) .deletingLastPathComponent().appendingPathComponent("settings.sock").path do { @@ -516,7 +528,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { } private func showRunningSettings() -> Bool { - guard childRunning, virtualMachineReachedStart, !lifecycle.isStopping, + guard childRunning, virtualMachineReachedStart, + (!lifecycle.isStopping || lifecycle.settingsAction != nil), !isPresentingBlockingAlert, let startMenuWindow else { return false } guard !startMenuWindow.window.isVisible else { return true } settingsReturnApplication = NSWorkspace.shared.frontmostApplication @@ -530,6 +543,30 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { settingsReturnApplication = nil } + private func shutDownForSettings(_ action: VMRunLifecycle.SettingsAction) { + guard childRunning, virtualMachineReachedStart, !lifecycle.isStopping, + let controlSocketPath else { return } + guard !hostSleepCoordinator.pausedForHostSleep else { + startMenuWindow?.shutdownDidFail("Wait for Omarchy to resume after Mac sleep, then try again.") + return + } + lifecycle.requestSettingsAction(action) + startMenuWindow?.shutdownDidBegin() + do { + let connection = try QMPConnection(socketPath: controlSocketPath, identifierPrefix: "settings") + _ = try connection.execute("system_powerdown") + // ACPI asks Linux to shut down cleanly. Only the launcher's exit + // callback may start the replacement QEMU process; no forced timer. + } catch { + lifecycle.cancelSettingsAction() + startMenuWindow?.shutdownDidFail(error.localizedDescription) + } + } + + func applicationWillTerminate(_ notification: Notification) { + if !childRunning { disposableWorkspace.remove() } + } + private func failHostSleepControlSetup(detail: String) { fputs( "omarchy-vm-helper: host sleep control is unavailable: \(detail)\n", @@ -727,7 +764,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { try hostSleepCoordinator.prepareForHostSleep( vmIsRunning: childRunning, - isStopping: lifecycle.isStopping + isStopping: lifecycle.isTerminating ) } catch { fputs( @@ -746,7 +783,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { try hostSleepCoordinator.resumeAfterHostWake( vmIsRunning: childRunning, - isStopping: lifecycle.isStopping + isStopping: lifecycle.isTerminating ) cancelHostWakeRetry() } catch { @@ -757,7 +794,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { guard !(error is VMHostSleepControlError), hostSleepCoordinator.pausedForHostSleep, childRunning, - !lifecycle.isStopping + !lifecycle.isTerminating else { return } guard hostSleepCoordinator.scheduleWakeRetry({ [weak self] in self?.resumeAfterHostWake() @@ -774,7 +811,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private func presentHostWakeRecovery(error: Error) { guard childRunning, - !lifecycle.isStopping, + !lifecycle.isTerminating, hostSleepCoordinator.pausedForHostSleep else { return } @@ -797,7 +834,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { } private func handleVolumeUnmount(at volume: URL) { - guard childRunning, !lifecycle.isStopping, let root = activeStateRoot else { return } + guard childRunning, !lifecycle.isTerminating, let root = activeStateRoot else { return } let mountPoint = volume.standardizedFileURL.path let prefix = mountPoint.hasSuffix("/") ? mountPoint : mountPoint + "/" guard root == mountPoint || root.hasPrefix(prefix) else { return } @@ -862,6 +899,9 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { let recentStandardError = supervisor.recentStandardError let wasStopping = lifecycle.isStopping + let settingsAction = lifecycle.settingsAction + controlSocketPath = nil + activeStateRoot = nil let presentation = VMExitPresentationDecision.make( status: status, reachedVirtualMachineStart: virtualMachineReachedStart, @@ -874,6 +914,16 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { NSApp.reply(toApplicationShouldTerminate: true) } else if let hostSleepControlFailure { startMenuWindow?.launchDidFail(errorMessage: hostSleepControlFailure) + } else if let settingsAction { + virtualMachineReachedStart = false + // This transition deliberately bypasses automatic startup and the + // original command-line reset request. Reset still needs a new click. + prepareStartMenu(startAutomatically: false, honorInitialReset: false) + if status != 0 { + startMenuWindow?.shutdownDidFail("Omarchy stopped unexpectedly while shutting down. Your saved settings are ready for the next launch.") + } else if settingsAction == .restart { + startMenuWindow?.launchOmarchy() + } } else { if presentation.showsStartupFailure, let startMenuWindow, @@ -963,6 +1013,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { return } + if !childRunning { disposableWorkspace.remove() } exitStatus = status // `stop` + a posted wake-up event only reliably pumps the run loop diff --git a/macos/Sources/OmarchyVMHelper/VMRunLifecycle.swift b/macos/Sources/OmarchyVMHelper/VMRunLifecycle.swift index debea9e6..5783c0cb 100644 --- a/macos/Sources/OmarchyVMHelper/VMRunLifecycle.swift +++ b/macos/Sources/OmarchyVMHelper/VMRunLifecycle.swift @@ -2,10 +2,16 @@ import Darwin import Foundation struct VMRunLifecycle: Equatable { + enum SettingsAction: Equatable { + case restart + case manage + } + private enum StopIntent: Equatable { case none case quit case signal(Int32) + case settings(SettingsAction) } private var stopIntent: StopIntent = .none @@ -14,6 +20,26 @@ struct VMRunLifecycle: Equatable { stopIntent != .none } + var settingsAction: SettingsAction? { + if case .settings(let action) = stopIntent { return action } + return nil + } + + /// A settings shutdown can wait for Linux indefinitely. Continue protecting + /// that live VM through Mac sleep and disk removal until it actually exits. + var isTerminating: Bool { + isStopping && settingsAction == nil + } + + mutating func requestSettingsAction(_ action: SettingsAction) { + guard !isStopping else { return } + stopIntent = .settings(action) + } + + mutating func cancelSettingsAction() { + if settingsAction != nil { stopIntent = .none } + } + mutating func requestQuit() { stopIntent = .quit } diff --git a/macos/Tests/OmarchyVMHelperTests/SettingsLifecycleTests.swift b/macos/Tests/OmarchyVMHelperTests/SettingsLifecycleTests.swift new file mode 100644 index 00000000..bec5ead5 --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/SettingsLifecycleTests.swift @@ -0,0 +1,52 @@ +import Darwin +import Foundation +import Testing +@testable import OmarchyVMHelper + +struct SettingsLifecycleTests { + @Test func quitAndSignalsOverrideSettingsActions() { + var lifecycle = VMRunLifecycle() + lifecycle.requestSettingsAction(.restart) + #expect(lifecycle.settingsAction == .restart) + #expect(lifecycle.isStopping) + #expect(!lifecycle.isTerminating) + lifecycle.requestQuit() + #expect(lifecycle.isTerminating) + #expect(lifecycle.settingsAction == nil) + lifecycle.requestSettingsAction(.restart) + #expect(lifecycle.settingsAction == nil) + lifecycle.childExited() + #expect(!lifecycle.isStopping) + lifecycle.requestSettingsAction(.manage) + lifecycle.requestTermination(signal: SIGTERM) + lifecycle.cancelSettingsAction() + #expect(lifecycle.isStopping) + #expect(lifecycle.settingsAction == nil) + } + + @Test func failedPowerdownCanBeRetried() { + var lifecycle = VMRunLifecycle() + lifecycle.requestSettingsAction(.restart) + lifecycle.cancelSettingsAction() + #expect(!lifecycle.isStopping) + lifecycle.requestSettingsAction(.manage) + #expect(lifecycle.settingsAction == .manage) + lifecycle.childExited() + #expect(lifecycle.settingsAction == nil) + } + + @Test func disposableDiskSurvivesRelaunchAndIsRemovedAtSessionEnd() throws { + let workspace = DisposableVMWorkspace() + defer { workspace.remove() } + let first = try workspace.prepare() + let disk = first.appendingPathComponent("rootfs.ext4") + try Data("saved work".utf8).write(to: disk) + #expect(try workspace.prepare() == first) + #expect(try Data(contentsOf: disk) == Data("saved work".utf8)) + let attributes = try FileManager.default.attributesOfItem(atPath: first.path) + #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o700) + workspace.remove() + #expect(!FileManager.default.fileExists(atPath: first.path)) + #expect(try workspace.prepare() != first) + } +} diff --git a/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift index 44b8ce84..10803916 100644 --- a/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift +++ b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift @@ -63,7 +63,16 @@ struct StartMenuStartupTests { #expect(!automaticStart) for identifier in ["permission-action-folder", "permission-action-network"] { let button = try #require(descendant(withIdentifier: identifier, in: content) as? NSButton) - #expect(!button.isEnabled) + #expect(button.isEnabled) + } + let immersive = try #require(descendant(withIdentifier: "immersive-toggle", in: content) as? NSSwitch) + #expect(immersive.isEnabled) + for identifier in ["restart-vm-button", "manage-vm-button"] { + #expect(try #require(descendant(withIdentifier: identifier, in: content) as? NSButton).isEnabled) + } + menu.shutdownDidBegin() + for identifier in ["automatic-start-checkbox", "permission-action-folder", "permission-action-network", "restart-vm-button", "manage-vm-button"] { + #expect(!(try #require(descendant(withIdentifier: identifier, in: content) as? NSButton)).isEnabled) } menu.launchOmarchy() #expect(launchCount == 1) diff --git a/macos/Tests/run-qemu-ssh-contract.test.sh b/macos/Tests/run-qemu-ssh-contract.test.sh index c00269e0..ac3a1fde 100755 --- a/macos/Tests/run-qemu-ssh-contract.test.sh +++ b/macos/Tests/run-qemu-ssh-contract.test.sh @@ -50,9 +50,17 @@ mkdir -p \ chmod 755 "$resources/scripts/run-qemu-gpu.sh" chmod 644 "$resources/scripts/qemu-port-forwarding.sh" +mkdir -p "$resources/guest-settings" +cp "$macos_dir/guest-settings.service" "$resources/guest-settings/guest-settings.service" +cp "$macos_dir/../guest/scripts/install-settings-integration.py" "$resources/guest-settings/install.py" + cat >"$contents/MacOS/omarchy-vm-helper" <<'SH' #!/bin/bash set -euo pipefail +if [[ ${1:-} == --bridge-native-audio && -n ${FAKE_AUDIO_BRIDGE_LIFETIME:-} ]]; then + sleep "$FAKE_AUDIO_BRIDGE_LIFETIME" + exit "${FAKE_AUDIO_BRIDGE_STATUS:-0}" +fi if [[ ${1:-} == --bridge-native-audio \ || ${1:-} == --bridge-native-clipboard \ || ${1:-} == --bridge-native-camera ]]; then @@ -323,6 +331,11 @@ run_scenario() { run_scenario disabled 0 '' disabled_qemu=$(<"$test_root/disabled/qemu.log") +assert_contains "$disabled_qemu" 'systemd.wants=try-omarchy-settings.service' +assert_contains "$disabled_qemu" "systemd.set_credential_binary=systemd.extra-unit.try-omarchy-settings.service:$(base64 < "$macos_dir/guest-settings.service" | tr -d '\r\n')" +assert_line_pair "$test_root/disabled/qemu.log" -fsdev \ + "local,id=omarchy-settings,path=$resources/guest-settings,security_model=none,readonly=on" +assert_not_contains "$disabled_qemu" 'systemd.unit=' assert_line_pair "$test_root/disabled/qemu.log" -machine \ 'virt,accel=hvf,gic-version=3' assert_not_contains "$disabled_qemu" gic-version=2 @@ -336,6 +349,11 @@ assert_contains "$disabled_qemu" \ assert_contains "$(<"$test_root/disabled/storage.log")" select-existing assert_contains "$(<"$test_root/disabled/storage.log")" create +# A clean guest shutdown may close the bridge before QEMU exits. +run_scenario audio-shutdown 0 '' FAKE_AUDIO_BRIDGE_LIFETIME=0.08 FAKE_QEMU_LIFETIME=0.45 +run_scenario audio-failure 1 '' FAKE_AUDIO_BRIDGE_LIFETIME=0.08 FAKE_AUDIO_BRIDGE_STATUS=7 FAKE_QEMU_LIFETIME=10 +assert_contains "$(<"$test_root/audio-failure/stderr")" 'native audio bridge exited while QEMU was running (status 7)' + 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/build-app.sh b/macos/build-app.sh index ee996975..c5b82037 100755 --- a/macos/build-app.sh +++ b/macos/build-app.sh @@ -172,6 +172,18 @@ install -m 0644 "$macos_dir/qemu-persistent-storage.sh" \ "$contents/Resources/scripts/qemu-persistent-storage.sh" install -m 0644 "$macos_dir/qemu-port-forwarding.sh" \ "$contents/Resources/scripts/qemu-port-forwarding.sh" +# Ship the same narrow settings payload to existing VMs at boot. +settings_payload="$contents/Resources/guest-settings" +mkdir -p "$settings_payload" +install -m 0644 "$macos_dir/guest-settings.service" "$settings_payload/guest-settings.service" +install -m 0644 "$repo_dir/guest/scripts/install-settings-integration.py" "$settings_payload/install.py" +for relative in \ + usr/local/bin/omarchy-native-settings \ + etc/udev/rules.d/92-omarchy-native-settings.rules \ + usr/share/applications/try-omarchy-settings.desktop \ + etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc; do + install -m 0644 "$repo_dir/guest/native-overlay/$relative" "$settings_payload/${relative##*/}" +done for guest_resource in \ LICENSE.omarchy \ SHA256SUMS \ diff --git a/macos/guest-settings.service b/macos/guest-settings.service new file mode 100644 index 00000000..e0eba385 --- /dev/null +++ b/macos/guest-settings.service @@ -0,0 +1,17 @@ +[Unit] +Description=Try Omarchy settings integration +ConditionPathExists=!/etc/initrd-release +After=local-fs.target +Before=display-manager.service + +[Service] +Type=oneshot +StandardOutput=journal+console +StandardError=journal+console +TimeoutStartSec=20 +ExecStartPre=/usr/bin/mkdir -p /run/try-omarchy-settings +ExecStartPre=/usr/bin/modprobe 9pnet_virtio +ExecStartPre=/usr/bin/mount -t 9p -o ro,trans=virtio,version=9p2000.L try-omarchy-settings /run/try-omarchy-settings +ExecStart=/usr/bin/python3 /run/try-omarchy-settings/install.py +ExecStopPost=-/usr/bin/umount /run/try-omarchy-settings +ExecStopPost=-/usr/bin/rmdir /run/try-omarchy-settings diff --git a/macos/run-qemu-gpu.sh b/macos/run-qemu-gpu.sh index d8e8e7ff..a4a407b5 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -1297,6 +1297,16 @@ case ${OMARCHY_QEMU_GPU_IMMERSIVE:-1} in *) fail "OMARCHY_QEMU_GPU_IMMERSIVE must be 0 or 1" ;; esac +# systemd's boot credential creates one temporary service without replacing +# the guest's default target or requiring an agent to already be installed. +settings_payload="$resources_dir/guest-settings" +[[ -f $settings_payload/guest-settings.service && -f $settings_payload/install.py ]] || \ + fail "the bundled settings integration is missing" +settings_unit=$(base64 < "$settings_payload/guest-settings.service" | tr -d '\r\n') +settings_kernel_argument=" systemd.set_credential_binary=systemd.extra-unit.try-omarchy-settings.service:$settings_unit systemd.wants=try-omarchy-settings.service" +# QEMU escapes commas in key-value option values by doubling them. +settings_payload_escaped=${settings_payload//,/,,} + qemu_args=( -name 'Try Omarchy' -machine "$qemu_machine" @@ -1318,7 +1328,7 @@ qemu_args=( -qmp "unix:$qmp_socket,server=on,wait=off" -kernel "$launch_kernel" -initrd "$launch_initramfs" - -append "$launch_kernel_command_line omarchy.qemu_virgl=1$shared_folder_kernel_argument$ssh_kernel_argument" + -append "$launch_kernel_command_line omarchy.qemu_virgl=1$shared_folder_kernel_argument$ssh_kernel_argument$settings_kernel_argument" -drive "if=none,id=omarchy-root,file=$working_disk,format=raw,media=disk,cache=writeback" -device 'virtio-blk-pci,drive=omarchy-root,serial=omarchy-root' -device "$gpu_device" @@ -1333,6 +1343,8 @@ qemu_args=( -object 'rng-random,id=omarchy-rng,filename=/dev/urandom' -device 'virtio-rng-pci,rng=omarchy-rng' -device virtio-balloon-pci + -fsdev "local,id=omarchy-settings,path=$settings_payload_escaped,security_model=none,readonly=on" + -device 'virtio-9p-pci,fsdev=omarchy-settings,mount_tag=try-omarchy-settings,romfile=' -device 'virtio-serial-pci,id=omarchy-serial' -chardev "socket,id=omarchy-settings-bridge,path=$settings_bridge_socket,server=on,wait=off" -device 'virtserialport,bus=omarchy-serial.0,nr=3,chardev=omarchy-settings-bridge,name=dev.tryomarchy.settings' @@ -1444,9 +1456,14 @@ camera_bridge_restarts=0 # Bash 3.2 has no `wait -n`. The native-audio bridge is required for the guest # transport, so watch it alongside QEMU and fail if it exits unexpectedly. +qemu_is_running() { + local state + state=$(ps -p "$qemu_pid" -o state= 2>/dev/null || true) + [[ -n $state && $state != *Z* ]] +} + while true; do - qemu_state=$(ps -p "$qemu_pid" -o state= 2>/dev/null || true) - [[ -n $qemu_state && $qemu_state != *Z* ]] || break + qemu_is_running || break audio_bridge_state=$(ps -p "$audio_bridge_pid" -o state= 2>/dev/null || true) if [[ -z $audio_bridge_state || $audio_bridge_state == *Z* ]]; then @@ -1456,6 +1473,14 @@ while true; do audio_bridge_status=$? fi audio_bridge_pid="" + # QEMU closes its channels before its process finishes exiting. Give that + # teardown a short grace period, then use QEMU's real exit status below. + # A bridge failure while QEMU stays alive must still fail the launch. + for ((attempt = 0; attempt < 40; attempt++)); do + qemu_is_running || break + sleep 0.05 + done + qemu_is_running || break fail "native audio bridge exited while QEMU was running (status $audio_bridge_status)" fi @@ -1474,6 +1499,7 @@ while true; do clipboard_bridge_restarts=$((clipboard_bridge_restarts + 1)) echo "[qemu-gpu] clipboard bridge exited (status $clipboard_bridge_status); restarting ($clipboard_bridge_restarts/5)" >&2 sleep 1 + qemu_is_running || break start_clipboard_bridge else echo "[qemu-gpu] clipboard sharing is unavailable for the rest of this session" >&2 @@ -1495,6 +1521,7 @@ while true; do camera_bridge_restarts=$((camera_bridge_restarts + 1)) echo "[qemu-gpu] camera bridge exited (status $camera_bridge_status); restarting ($camera_bridge_restarts/5)" >&2 sleep 1 + qemu_is_running || break start_camera_bridge else echo "[qemu-gpu] camera sharing is unavailable for the rest of this session" >&2 diff --git a/scripts/build-cache.py b/scripts/build-cache.py index a741a173..a99afe6c 100755 --- a/scripts/build-cache.py +++ b/scripts/build-cache.py @@ -129,6 +129,11 @@ def component_files(root: Path, component: str) -> list[Path]: ] paths.extend( [ + root / "guest/scripts/install-settings-integration.py", + root / "guest/native-overlay/usr/local/bin/omarchy-native-settings", + root / "guest/native-overlay/etc/udev/rules.d/92-omarchy-native-settings.rules", + root / "guest/native-overlay/usr/share/applications/try-omarchy-settings.desktop", + root / "guest/native-overlay/etc/skel/.config/omarchy/extensions/omarchy-menu.jsonc", root / ".build/state/guest.json", root / ".build/state/runtime.json", root / "dist/guest/guest-manifest.json", From 0365a599e51e01d7fdf4c23a4e6e18718ee8c099 Mon Sep 17 00:00:00 2001 From: Einar Andersson <72999+drdator@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:29:44 +0200 Subject: [PATCH 4/4] Refresh moved ARM kernel and orc package locks --- guest/packages.lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/guest/packages.lock.json b/guest/packages.lock.json index 0f1eec7a..3479e285 100644 --- a/guest/packages.lock.json +++ b/guest/packages.lock.json @@ -398,8 +398,8 @@ "libzip": "1.11.4-1", "licenses": "20240728-1", "lilv": "0.28.0-1", - "linux-aarch64": "7.2.3-2", - "linux-aarch64-headers": "7.2.3-2", + "linux-aarch64": "7.2.4-1", + "linux-aarch64-headers": "7.2.4-1", "linux-api-headers": "7.2-1", "llhttp": "9.3.1-1", "llvm-libs": "22.1.8-2", @@ -453,7 +453,7 @@ "openssh": "10.5p1-1", "openssl": "3.6.4-1", "opus": "1.6.1-1", - "orc": "0.4.43-1", + "orc": "0.4.44-1", "osinfo-db": "20260812-1", "p11-kit": "0.26.5-1", "pacman": "7.1.0.r9.g54d9411-2",