Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions guest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SUBSYSTEM=="virtio-ports", ATTR{name}=="dev.tryomarchy.settings", GROUP="users", MODE="0660"
68 changes: 68 additions & 0 deletions guest/native-overlay/usr/local/bin/omarchy-native-settings
Original file line number Diff line number Diff line change
@@ -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())
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 3 additions & 3 deletions guest/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions guest/scripts/configure-rootfs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
76 changes: 76 additions & 0 deletions guest/scripts/install-settings-integration.py
Original file line number Diff line number Diff line change
@@ -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()
47 changes: 47 additions & 0 deletions guest/tests/test_native_settings.py
Original file line number Diff line number Diff line change
@@ -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()
50 changes: 50 additions & 0 deletions guest/tests/test_settings_install.py
Original file line number Diff line number Diff line change
@@ -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())
30 changes: 30 additions & 0 deletions macos/Sources/OmarchyVMHelper/DisposableVMWorkspace.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading