diff --git a/README.md b/README.md index 50b99c54..e6d49af0 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,13 @@ 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. + +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, CPU, memory, sharing, port forwarding, and immersive mode here. CPU, memory, 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 5825f2c9..ed845265 100644 --- a/guest/README.md +++ b/guest/README.md @@ -81,3 +81,36 @@ 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. + +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 CPU, memory, 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/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/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", diff --git a/guest/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index 3603a7f7..d54d3c0b 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -85,6 +85,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/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_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/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/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 f9de0a99..5ec7a7eb 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -194,6 +194,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private let saveResources: (VMResources) -> Void 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? @@ -207,6 +209,12 @@ 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 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? @@ -217,7 +225,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { canRestore: { [weak self] in guard let self else { return false } return self.window.isVisible - && !self.launchInProgress + && !self.controlsBusy && !self.resetInProgress && !self.microphoneRequestInFlight && !self.cameraRequestInFlight @@ -279,6 +287,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { saveResources: @escaping (VMResources) -> Void = { _ in }, immersiveMode: @escaping () -> Bool = { true }, setImmersiveMode: @escaping (Bool) -> Void = { _ in }, + startAutomatically: @escaping () -> Bool = { false }, + setStartAutomatically: @escaping (Bool) -> Void = { _ in }, launch: @escaping () -> Void ) { self.accessibilityStatus = accessibilityStatus @@ -306,6 +316,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.saveResources = saveResources self.immersiveMode = immersiveMode self.setImmersiveMode = setImmersiveMode + self.startAutomatically = startAutomatically + self.setStartAutomatically = setStartAutomatically self.launch = launch window = NSWindow( @@ -323,6 +335,60 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { window.contentView = content } + 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() + closeRunningSettings?() + } + func show() { prepareForPresentation( visibleFrame: (window.screen ?? NSScreen.main)?.visibleFrame @@ -346,7 +412,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func refreshPermissionStatus() { - guard window.isVisible, !launchInProgress, !resetInProgress else { return } + guard window.isVisible, !controlsBusy, !resetInProgress else { return } render() } @@ -403,7 +469,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 @@ -419,7 +485,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { func launchRequiresReset() { guard launchInProgress else { return } launchInProgress = false - render() + show() let alert = NSAlert() alert.alertStyle = .warning @@ -434,6 +500,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 @@ -446,7 +513,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { func launchDidFail(errorMessage: String) { guard launchInProgress else { return } launchInProgress = false - render() + show() let alert = NSAlert() alert.alertStyle = .critical @@ -457,7 +524,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func windowShouldClose(_ sender: NSWindow) -> Bool { - NSApp.terminate(nil) + if virtualMachineRunning { + closeSettings() + } else { + NSApp.terminate(nil) + } return false } @@ -475,7 +546,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 = .monospacedSystemFont(ofSize: 27, weight: .bold) title.textColor = OmarchyStartMenuTheme.foreground title.identifier = NSUserInterfaceItemIdentifier("app-title") @@ -630,7 +701,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 ) } @@ -701,7 +772,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ) reset.identifier = NSUserInterfaceItemIdentifier("reset-button") reset.isEnabled = canResetStorage - && !launchInProgress + && !prelaunchControlsLocked && !resetInProgress && !microphoneRequestInFlight && !cameraRequestInFlight @@ -711,26 +782,30 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { reset.heightAnchor.constraint(equalToConstant: 30).isActive = true reset.widthAnchor.constraint(greaterThanOrEqualToConstant: 154).isActive = true - let resetViews: [NSView] = [reset] + let manage = OmarchyActionButton(title: "Shut down to manage…", style: .secondary, target: self, action: #selector(shutDownToManage)) + manage.heightAnchor.constraint(equalToConstant: 30).isActive = true + 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 = .centerX resetSection.spacing = 4 - let launchButtonTitle = launchInProgress ? "Launching Omarchy…" : "Launch Omarchy" + let launchButtonTitle = virtualMachineRunning ? "Done" : (launchInProgress ? "Launching Omarchy…" : "Launch Omarchy") let launchButton = OmarchyActionButton( title: launchButtonTitle, style: .primary, target: self, - action: #selector(launchOmarchy) + action: virtualMachineRunning ? #selector(closeSettings) : #selector(launchOmarchy) ) launchButton.keyEquivalent = launchInProgress ? "" : "\r" - launchButton.isEnabled = !launchInProgress + launchButton.isEnabled = virtualMachineRunning || (!launchInProgress && !resetInProgress && !microphoneRequestInFlight - && !cameraRequestInFlight + && !cameraRequestInFlight) launchButton.identifier = NSUserInterfaceItemIdentifier("launch-button") - launchButton.setAccessibilityLabel(launchInProgress ? "Launching Omarchy" : "Launch Omarchy") + launchButton.setAccessibilityLabel(launchButtonTitle) if launchInProgress { let spinner = NSProgressIndicator() spinner.style = .spinning @@ -745,9 +820,46 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } NSLayoutConstraint.activate([ launchButton.heightAnchor.constraint(equalToConstant: 48), - launchButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 500), ]) + let automaticStart = NSButton( + checkboxWithTitle: "Start automatically", + target: self, + action: #selector(changeStartAutomatically(_:)) + ) + automaticStart.attributedTitle = NSAttributedString(string: "Start automatically", attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .semibold), + .foregroundColor: OmarchyStartMenuTheme.foreground, + ]) + automaticStart.state = startAutomatically() ? .on : .off + automaticStart.isEnabled = !controlsBusy && !resetInProgress + automaticStart.identifier = NSUserInterfaceItemIdentifier("automatic-start-checkbox") + let automaticStartHelp = virtualMachineRunning + ? "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 = .monospacedSystemFont(ofSize: 10, weight: .regular) + automaticStartCaption.textColor = OmarchyStartMenuTheme.muted + let restart = OmarchyActionButton(title: "Restart Try Omarchy…", style: .secondary, target: self, action: #selector(restartOmarchy)) + restart.heightAnchor.constraint(equalToConstant: 30).isActive = true + 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." + : "CPU, memory, shared folder, port forwarding, and immersive mode changes apply when Try Omarchy next starts. Restart to apply them now.") + restartCaption.font = .monospacedSystemFont(ofSize: 10, weight: .regular) + restartCaption.textColor = OmarchyStartMenuTheme.muted + 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 + automaticStartSection.spacing = 3 + let footerText = "by @martiano" let footerTitle = NSMutableAttributedString( string: footerText, @@ -777,16 +889,10 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { footer.bottomAnchor.constraint(equalTo: footerContainer.bottomAnchor), ]) - let stack = NSStackView(views: [ - headingStack, - permissionHeading, - permissionCard, - integrationHeading, - integrationCard, - resetSection, - launchButton, - footerContainer, - ]) + let settingsSections: [NSView] = [permissionHeading, permissionCard, integrationHeading, integrationCard, resetSection] + let stack = NSStackView(views: virtualMachineRunning + ? [headingStack, automaticStartSection, launchButton, runningActions] + settingsSections + [footerContainer] + : [headingStack] + settingsSections + [automaticStartSection, launchButton, footerContainer]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 18 @@ -829,10 +935,17 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { permissionCard.widthAnchor.constraint(equalTo: stack.widthAnchor), integrationCard.widthAnchor.constraint(equalTo: stack.widthAnchor), resetSection.widthAnchor.constraint(equalTo: 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), ]) + if virtualMachineRunning { + runningActions.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + restartCaption.widthAnchor.constraint(equalTo: runningActions.widthAnchor).isActive = true + } + content.layoutSubtreeIfNeeded() document.layoutSubtreeIfNeeded() let maximumOffset = max( @@ -1009,7 +1122,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { button.isEnabled = actionsEnabled && !microphoneRequestInFlight && !cameraRequestInFlight - && !launchInProgress + && !controlsBusy && !resetInProgress let identifier = actions.count == 1 ? "permission-action-\(symbolName)" @@ -1144,7 +1257,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { target: self, action: #selector(changeImmersiveMode(_:)) ) - toggle.isEnabled = !microphoneRequestInFlight && !launchInProgress && !resetInProgress + toggle.isEnabled = !microphoneRequestInFlight && !controlsBusy && !resetInProgress toggle.identifier = NSUserInterfaceItemIdentifier("immersive-toggle") toggle.setAccessibilityLabel("Immersive mode") toggle.setAccessibilityTitleUIElement(title) @@ -1276,7 +1389,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" @@ -1332,13 +1445,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 !controlsBusy, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight else { return } @@ -1370,17 +1483,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 !launchInProgress, !resetInProgress, portForwardingEditor == nil else { return } + guard !controlsBusy, !resetInProgress, portForwardingEditor == nil else { return } permissionWindowRestorer.cancel() let editor = PortForwardingEditor( mappings: portForwardingStatus(), @@ -1401,7 +1516,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func beginResourceConfiguration() { - guard !launchInProgress, !resetInProgress, + guard !controlsBusy, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight, resourceEditor == nil, window.attachedSheet == nil else { return } permissionWindowRestorer.cancel() @@ -1419,7 +1534,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @objc private func changeImmersiveMode(_ sender: NSButton) { - guard !launchInProgress, !resetInProgress else { return } + guard !controlsBusy, !resetInProgress else { return } let isEnabled = sender.state == .on setImmersiveMode(isEnabled) let detailText = StartMenuPresentation.immersiveDetail(isEnabled: isEnabled) @@ -1438,7 +1553,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private func confirmReset() { guard canResetStorage, - !launchInProgress, + !prelaunchControlsLocked, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight, @@ -1482,8 +1597,13 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } } - @objc private func launchOmarchy() { - guard !launchInProgress, + @objc private func changeStartAutomatically(_ sender: NSButton) { + guard !controlsBusy, !resetInProgress else { return } + setStartAutomatically(sender.state == .on) + } + + @objc func launchOmarchy() { + guard !prelaunchControlsLocked, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight else { return } 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 b269bd73..e8875a0c 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 resourcePreferenceStore: VMResourcePreferenceStore private let resourceLimits: VMResourceLimits private let storageLocationStore: StorageLocationPreferenceStore @@ -62,6 +63,11 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private let deviceProvider: HostAudioDeviceProviding 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? private let hostSleepCoordinator = VMHostSleepCoordinator() @@ -94,6 +100,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { sharedFolderStore: SharedFolderPreferenceStore = SharedFolderPreferenceStore(), portForwardingStore: PortForwardingPreferenceStore = PortForwardingPreferenceStore(), fullscreenPreferenceStore: FullscreenPreferenceStore = FullscreenPreferenceStore(), + startupPreferenceStore: StartupPreferenceStore = StartupPreferenceStore(), resourcePreferenceStore: VMResourcePreferenceStore = VMResourcePreferenceStore(), resourceLimits: VMResourceLimits = .current, storageLocationStore: StorageLocationPreferenceStore = StorageLocationPreferenceStore(), @@ -110,6 +117,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { self.sharedFolderStore = sharedFolderStore self.portForwardingStore = portForwardingStore self.fullscreenPreferenceStore = fullscreenPreferenceStore + self.startupPreferenceStore = startupPreferenceStore self.resourcePreferenceStore = resourcePreferenceStore self.resourceLimits = resourceLimits self.storageLocationStore = storageLocationStore @@ -122,14 +130,20 @@ 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, honorInitialReset: Bool = true) { + NSApp.setActivationPolicy(ApplicationPresentation.prelaunchActivationPolicy) let resetOptions = [ QEMUGPUStorageOption.resetStorage.rawValue, QEMUGPUStorageOption.resetStorageOnly.rawValue, @@ -218,14 +232,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 honorInitialReset && initialResetRequested { + startMenu.promptForReset() + } } } @@ -234,6 +258,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { virtualMachineReachedStart = false pendingHostSleepControlFailure = nil do { + if isDisposable { _ = try disposableWorkspace.prepare() } let accessibilityDecision = AccessibilityLaunchDecision.make( for: AXIsProcessTrusted() ? .authorized : .unavailable ) @@ -252,7 +277,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 } @@ -303,7 +328,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 @@ -432,8 +458,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { preferences: resourcePreferenceStore.load(), limits: resourceLimits ) + var storageEnvironment = resources.environment + if let directory = disposableWorkspace.directory { + storageEnvironment[StorageLocationPolicy.environmentKey] = directory.path + } let storage = StorageLocationLaunchConfiguration.make( - baseEnvironment: resources.environment, + baseEnvironment: storageEnvironment, preference: storageLocationStore.load(), metrics: bundledMetrics, probe: volumeProbe, @@ -499,7 +529,61 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { virtualMachineReachedStart = true NSApp.setActivationPolicy(ApplicationPresentation.runningActivationPolicy) startMenuWindow?.dismiss() - startMenuWindow = nil + 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 { + 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 || lifecycle.settingsAction != nil), + !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 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) { @@ -645,6 +729,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" @@ -698,7 +783,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { try hostSleepCoordinator.prepareForHostSleep( vmIsRunning: childRunning, - isStopping: lifecycle.isStopping + isStopping: lifecycle.isTerminating ) } catch { fputs( @@ -717,7 +802,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { try hostSleepCoordinator.resumeAfterHostWake( vmIsRunning: childRunning, - isStopping: lifecycle.isStopping + isStopping: lifecycle.isTerminating ) cancelHostWakeRetry() } catch { @@ -728,7 +813,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() @@ -745,7 +830,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private func presentHostWakeRecovery(error: Error) { guard childRunning, - !lifecycle.isStopping, + !lifecycle.isTerminating, hostSleepCoordinator.pausedForHostSleep else { return } @@ -768,7 +853,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 } @@ -819,6 +904,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() @@ -826,6 +918,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, @@ -838,6 +933,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, @@ -927,6 +1032,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/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/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 new file mode 100644 index 00000000..df149a4a --- /dev/null +++ b/macos/Tests/OmarchyVMHelperTests/StartMenuStartupTests.swift @@ -0,0 +1,131 @@ +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) + } + + @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", "permission-action-cpu"] { + let button = try #require(descendant(withIdentifier: identifier, in: content) as? NSButton) + #expect(button.isEnabled) + } + let immersive = try #require(descendant(withIdentifier: "immersive-toggle", in: content) as? NSButton) + #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", "permission-action-cpu", "restart-vm-button", "manage-vm-button"] { + #expect(!(try #require(descendant(withIdentifier: identifier, in: content) as? NSButton)).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 }, + 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 + )) + } +} diff --git a/macos/Tests/qemu-memory-contract.test.sh b/macos/Tests/qemu-memory-contract.test.sh index fc9b21c0..375b0824 100755 --- a/macos/Tests/qemu-memory-contract.test.sh +++ b/macos/Tests/qemu-memory-contract.test.sh @@ -46,6 +46,10 @@ 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 diff --git a/macos/Tests/run-qemu-ssh-contract.test.sh b/macos/Tests/run-qemu-ssh-contract.test.sh index 54a9cde0..7dc881ae 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-authentication \ || ${1:-} == --bridge-native-clipboard \ @@ -431,6 +439,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,gic-version=3,virtualization=on' assert_line_pair "$test_root/disabled/qemu.log" -accel 'hvf,kernel-irqchip=on' @@ -448,6 +461,8 @@ assert_contains "$disabled_qemu" \ 'socket,id=omarchy-authentication-bridge,path=' assert_contains "$disabled_qemu" \ 'virtserialport,bus=omarchy-serial.0,nr=3,chardev=omarchy-authentication-bridge,name=dev.tryomarchy.authentication' +assert_contains "$disabled_qemu" \ + 'virtserialport,bus=omarchy-serial.0,nr=5,chardev=omarchy-settings-bridge,name=dev.tryomarchy.settings' assert_contains "$(<"$test_root/disabled/storage.log")" select-existing assert_contains "$(<"$test_root/disabled/storage.log")" create assert_line_pair "$test_root/disabled/qemu.log" -smp '8,sockets=1,cores=8,threads=1' @@ -498,6 +513,11 @@ assert_line_pair "$test_root/nested-fallback/qemu.log" -machine \ assert_not_contains "$nested_fallback_qemu" virtualization=on assert_not_contains "$nested_fallback_qemu" kernel-irqchip=on +# 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 a10c96b6..598dce04 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -1352,6 +1352,7 @@ audio_bridge_socket="/tmp/${work_dir##*/}/audio.sock" authentication_bridge_socket="/tmp/${work_dir##*/}/authentication.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" @@ -1448,6 +1449,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//,/,,} + # M3 and newer Apple Silicon can expose EL2 to this Linux guest. Probe the # actual Hypervisor.framework capability instead of guessing from a model name; # older Apple Silicon keeps the existing platform-GIC/EL1 launch path. @@ -1505,7 +1516,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" @@ -1521,7 +1532,11 @@ 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=5,chardev=omarchy-settings-bridge,name=dev.tryomarchy.settings' -chardev "stdio,id=omarchy-hvc0,signal=off,logfile=$console_log_option,logappend=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" @@ -1597,7 +1612,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 $authentication_bridge_socket && -S $camera_bridge_socket && -S $clipboard_bridge_socket ]]; then + if [[ -S $qmp_socket && -S $audio_bridge_socket && -S $authentication_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" @@ -1608,6 +1623,7 @@ done [[ -S $authentication_bridge_socket ]] || fail "QEMU did not create its private authentication 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 @@ -1642,9 +1658,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 @@ -1654,6 +1675,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 @@ -1672,6 +1701,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 @@ -1714,6 +1744,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",