diff --git a/README.md b/README.md index e9ec9879e..601ec8a7c 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/docs/architecture.md b/docs/architecture.md index 1c6f5ca41..c5f40704e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -180,15 +180,14 @@ creates the account on first boot. - The guest normally consumes upstream Arch Linux ARM packages. Hyprland is the documented exception: an upstream package is reproducibly rebuilt with a guarded rounded-border coverage patch for the VM graphics path, then held in - the guest's immutable local repository. While that pin still needs - `libaquamarine.so=13`, the factory rebuilds `aquamarine 0.14.0-2` from the - reviewed Arch PKGBUILD and upstream tarball, then rebuilds Hyprtoolkit - against that library. Both packages are provided by the disposable builder - repository and held alongside Hyprland on guest `IgnorePkg`; mixing the - newer mirror Hyprtoolkit with the older aquamarine cannot resolve. Source - and library hashes are verified, and build paths are remapped for repeatable - output. The ABI builder must pass from an empty cache before refreshing the - transaction lock. + the guest's immutable local repository. The factory rebuilds + `aquamarine 0.15.1-1` from a reviewed Arch-derived PKGBUILD and upstream + tarball, then rebuilds Hyprtoolkit against its `libaquamarine.so=14` ABI, + matching Hyprland 0.56.2. Both packages are provided by the disposable + builder repository and held alongside Hyprland on guest `IgnorePkg`. + Source and library hashes are verified, and build paths are remapped for + repeatable output. The factory uses an official HTTPS ARM mirror and checks + the complete transaction against its reviewed package lock before installing. - The final Arch Linux ARM pacman files live under `/usr/share/try-omarchy/`. An Omarchy-supported `pre-refresh-pacman` hook restores them after a channel refresh writes its x86_64 templates to `/etc`; the upstream templates remain diff --git a/guest/README.md b/guest/README.md index e47f492ae..8eba8ef96 100644 --- a/guest/README.md +++ b/guest/README.md @@ -98,3 +98,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/build.sh b/guest/build.sh index 9a6348ca3..6c66629e7 100755 --- a/guest/build.sh +++ b/guest/build.sh @@ -79,6 +79,20 @@ for command in pacstrap arch-chroot curl git gzip python3 mke2fs mount umount re command -v "$command" >/dev/null || fail "$command is required; use the supplied Arch builder container" done +# Bootstrap Omarchy's package signing key from the same reviewed packaging +# commit as the builder. Fresh builds must not depend on a public keyserver. +( + cd "$guest_dir/keys" + sha256sum -c <<'KEYRING_SUMS' +15d6aac44df688165b2ea35fe0b23af239bbc66a6909c10a5c219e8d94b707de omarchy.gpg +ab0b688815444cffd48d15ca3597c77dbb364d59763c5784fca36691520f00fd omarchy-trusted +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 omarchy-revoked +KEYRING_SUMS +) +install -m 0644 "$guest_dir/keys/omarchy.gpg" "$guest_dir/keys/omarchy-trusted" \ + "$guest_dir/keys/omarchy-revoked" /usr/share/pacman/keyrings/ +pacman-key --populate omarchy + output=$(mkdir -p "$output" && cd "$output" && pwd) work=$(mkdir -p "$work" && cd "$work" && pwd) if [[ -z $source_dir ]]; then @@ -223,6 +237,11 @@ python3 "$guest_dir/scripts/resolve-package-lock.py" \ ln -sfn /proc/self/fd/2 "$dev_root/stderr" ) +# Keep a fresh guest-local keypair while seeding the reviewed repository keys +# before the initial transaction verifies the keyring packages themselves. +pacman-key --gpgdir "$root/etc/pacman.d/gnupg" --init +pacman-key --gpgdir "$root/etc/pacman.d/gnupg" --populate archlinuxarm omarchy + # pacstrap reads configured CacheDir paths for host-cache mode only with -P. # The copied builder config is replaced by configure-rootfs below. Archives # remain under $work across failed staging roots and are still signature-checked. diff --git a/guest/keys/omarchy-revoked b/guest/keys/omarchy-revoked new file mode 100644 index 000000000..e69de29bb diff --git a/guest/keys/omarchy-trusted b/guest/keys/omarchy-trusted new file mode 100644 index 000000000..01b93488d --- /dev/null +++ b/guest/keys/omarchy-trusted @@ -0,0 +1 @@ +40DFB630FF42BCFFB047046CF0134EE680CAC571:4: diff --git a/guest/keys/omarchy.gpg b/guest/keys/omarchy.gpg new file mode 100644 index 000000000..7c5ea36c3 --- /dev/null +++ b/guest/keys/omarchy.gpg @@ -0,0 +1,13 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEaLAsIhYJKwYBBAHaRw8BAQdAYmKmrWMlfpdIPh3QzvEMzzxdNd/kqDl/Bj8H +mCavKji0Gk9tYXJjaHkgPHBrZ3NAb21hcmNoeS5vcmc+iJAEExYKADgWIQRA37Yw +/0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIX +gAAKCRDwE07mgMrFcRF2AQCjjvjgju/kowkN69nenqqRnE+v2MrHmWh2wU0ugIt9 +3gD/bXGugWc5J1vqoT+5aCnJAtfRRmHEfsrAiFgjduB+VA24OARosCwiEgorBgEE +AZdVAQUBAQdAJoU73WdgxE/pEYK5ofyRvQDs1IFRdfJHV9j4Jm+tuDUDAQgHiHgE +GBYKACAWIQRA37Yw/0K8/7BHBGzwE07mgMrFcQUCaLAsIgIbDAAKCRDwE07mgMrF +cYXsAQC5eBEGK0xKsGwtnDDVeNAapx32dpvLgJ94W4DtIpdLlwEA85cSsTiBMuGy +6kwYmHzqw7oFx3VYGXdMNl0SMKzvJA4= +=G7wU +-----END PGP PUBLIC KEY BLOCK----- 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 000000000..d78b91699 --- /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 000000000..b265c818f --- /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 000000000..481c447ed --- /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 000000000..2e102c53d --- /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 b217a1719..67e3a2a3e 100644 --- a/guest/packages.lock.json +++ b/guest/packages.lock.json @@ -13,17 +13,17 @@ "alsa-ucm-conf": "1.2.16.1-1", "aom": "3.15.0-1", "appstream": "1.2.0-1", - "aquamarine": "0.14.0-2", + "aquamarine": "0.15.1-1", "archlinux-keyring": "20260909-1", "archlinuxarm-keyring": "20240419-2", - "at-spi2-core": "2.60.6-1", + "at-spi2-core": "2.60.7-1", "attr": "2.6.0-1", "audit": "4.2.1-1", "avahi": "1:0.9rc5-1", "base": "3-3", - "bash": "5.3.15-1", + "bash": "5.3.20-1", "bash-completion": "2.18.0-1", - "bat": "0.26.1-2", + "bat": "0.26.1-3", "benchmark": "1.9.5-2", "binutils": "2.46+r70+g155188ea10a7-1", "bluez-libs": "5.87-2", @@ -117,7 +117,7 @@ "gperftools": "2.18.1-1", "gpgme": "2.2.0-1", "gpgmepp": "2.2.0-1", - "gpm": "1.20.7.r38.ge82d1a6-6", + "gpm": "1.20.7.r38.ge82d1a6-7", "graphene": "1.10.8-2.1", "graphite": "1:1.3.15-1", "grep": "3.12-2", @@ -139,9 +139,9 @@ "gupnp": "1:1.6.10-1", "gupnp-dlna": "0.12.0-5", "gupnp-igd": "1.6.0-2", - "gvfs": "1.60.3-1", + "gvfs": "1.60.3-3", "gzip": "1.14-2", - "harfbuzz": "14.4.0-1", + "harfbuzz": "14.5.0-1", "hdf5": "2.2.0-1", "hicolor-icon-theme": "0.18-1", "hidapi": "0.15.0-1", @@ -150,12 +150,12 @@ "hwloc": "2.14.0-1", "hyprcursor": "0.1.13-7", "hyprgraphics": "0.5.1-4", - "hyprland": "0.56.1-3", - "hyprland-guiutils": "0.2.2-2", + "hyprland": "0.56.2-3", + "hyprland-guiutils": "0.2.2-3", "hyprlang": "0.6.8-5", "hyprpicker": "0.4.7-4", "hyprsunset": "0.4.0-3", - "hyprtoolkit": "0.5.4-6.1", + "hyprtoolkit": "0.5.4-6.2", "hyprutils": "0.14.2-1", "hyprwayland-scanner": "0.4.6-1", "hyprwire": "0.3.1-3", @@ -164,7 +164,7 @@ "imagemagick": "7.1.2.31-1", "imath": "3.2.3-1", "imv": "5.0.1-2", - "iniparser": "4.2.6-2", + "iniparser": "4.3.0-1", "inotify-tools": "4.25.9.0-1", "iproute2": "7.2.0-1", "iptables": "1:1.8.13-1", @@ -174,7 +174,7 @@ "jansson": "2.15.1-1", "jasper": "4.2.9-1", "jbigkit": "2.1-8", - "jemalloc": "1:5.3.1-3", + "jemalloc": "1:5.4.0-1", "jq": "1.8.2-1", "json-c": "0.19-1", "json-glib": "1.10.8-1", @@ -186,7 +186,7 @@ "lame": "4.0-1", "lcms2": "2.19.1-1", "leancrypto": "1.9.0-1", - "less": "1:704-1", + "less": "1:710-1", "libadwaita": "1:1.9.4-1", "libaec": "1.1.7-1", "libarchive": "3.8.9-1", @@ -266,7 +266,7 @@ "libimobiledevice": "1.4.0-2", "libimobiledevice-glue": "1.3.2-1", "libinih": "62-2", - "libinput": "1.31.3-1", + "libinput": "1.32.0-1", "libiptcdata": "1.0.5-5", "libisl": "0.28-1", "libjpeg-turbo": "3.2.0-2", @@ -309,7 +309,7 @@ "libopenmpt": "0.8.9-1", "libosinfo": "1.12.0-3", "libp11-kit": "0.26.5-1", - "libpcap": "1.10.7-1", + "libpcap": "1.11.0-1", "libpciaccess": "0.19-1", "libpgm": "5.3.128-4", "libpipeline": "1.5.8-1", @@ -330,7 +330,7 @@ "libsamplerate": "0.2.2-3", "libsasl": "2.1.28-5", "libseccomp": "2.6.0-1", - "libsecret": "0.21.7-1", + "libsecret": "0.21.8.2-1", "libsixel": "1.10.5-1", "libsm": "1.2.6-1", "libsndfile": "1.2.2-4", @@ -425,7 +425,7 @@ "man-db": "2.13.1-2", "md4c": "0.5.3-1", "mdadm": "4.6-2", - "mesa": "1:26.2.2-1", + "mesa": "1:26.2.3-1", "minizip": "1:1.3.2-3", "mkinitcpio": "42-1", "mkinitcpio-busybox": "1.36.1-1", @@ -436,7 +436,7 @@ "mpv": "1:0.41.0-6", "msgpack-c": "7.0.1-1", "mtdev": "1.1.7-1", - "mujs": "1.3.9-1", + "mujs": "1.3.10-1", "muparser": "2.3.5-2", "nautilus": "50.3.1-1", "ncurses": "6.6-2", @@ -513,9 +513,9 @@ "qt6-wayland": "6.11.2-1", "quickshell": "0.3.1-1", "re2": "2:2025.11.05-6", - "readline": "8.3.003-1", + "readline": "8.3.6-1", "ripgrep": "15.2.0-1", - "rpm-sequoia": "1.10.2-2", + "rpm-sequoia": "1.10.3-1", "rpm-tools": "6.0.1-2", "rubberband": "4.0.0-2", "sbc": "2.2-1", @@ -573,16 +573,16 @@ "upower": "1.91.4-1", "util-linux": "2.42.3-1", "util-linux-libs": "2.42.3-1", - "uwsm": "0.26.7-1", + "uwsm": "0.27.0-1", "v4l-utils": "1.32.0-2", "v4l2loopback-dkms": "0.15.4-2", "vapoursynth": "79-1", "vid.stab": "1.1.2-1", - "vmaf": "3.2.0-1", + "vmaf": "3.2.1-1", "volume_key": "0.3.12-12", "vulkan-icd-loader": "1.4.357.0-1", - "vulkan-mesa-implicit-layers": "1:26.2.2-1", - "vulkan-swrast": "1:26.2.2-1", + "vulkan-mesa-implicit-layers": "1:26.2.3-1", + "vulkan-swrast": "1:26.2.3-1", "wayland": "1.26.0-1", "wayland-protocols": "1.49-1", "webrtc-audio-processing-1": "1.3-5", @@ -619,7 +619,7 @@ "xorg-xwayland": "24.1.13-1", "xorgproto": "2025.1-1", "xvidcore": "1.3.7-4", - "xxhash": "0.8.3-1", + "xxhash": "0.8.4-1", "xz": "5.8.4-1", "yoga": "3.2.1-1", "yyjson": "0.13.0-1", diff --git a/guest/pacman.aarch64.conf b/guest/pacman.aarch64.conf index c983494cd..4f9008633 100644 --- a/guest/pacman.aarch64.conf +++ b/guest/pacman.aarch64.conf @@ -11,7 +11,7 @@ HoldPkg = pacman glibc # installed modules and DKMS headers on that exact ABI. Hyprland is likewise # held until upstream includes our rounded-border coverage backport; otherwise # a routine full-system update can silently restore the defect. Hold the -# compatible aquamarine/Hyprtoolkit pair too: newer ALARM builds require a +# reviewed aquamarine/Hyprtoolkit pair too: later ALARM builds may require a # different libaquamarine.so and abort the updater with mixed ABI requirements. IgnorePkg = linux-aarch64 linux-aarch64-headers hyprland aquamarine hyprtoolkit Architecture = auto diff --git a/guest/pinned-packages/README.md b/guest/pinned-packages/README.md index 20cddea22..af1af2652 100644 --- a/guest/pinned-packages/README.md +++ b/guest/pinned-packages/README.md @@ -3,22 +3,27 @@ Recipes here are factory-build inputs for the compatible Hyprland dependency set that Arch Linux ARM's rolling repositories no longer provide together. -The factory's ARM repositories use the dated archive in -`inputs.packageRepositorySnapshot` in `guest/spec.json`. This keeps the -upstream Hyprland package on the ABI expected by these rebuilds. Package -signatures remain required, and the complete resolved transaction must match -`guest/packages.lock.json`. The archive is only used by the factory builder; -the installed guest retains its normal mirror configuration. When updating -the snapshot, refresh and review the lockfile together with it. +The factory's ARM repositories use the official HTTPS mirrors selected by +`inputs.packageRepositoryMirrors` in `guest/spec.json`. The previous community +snapshot host stopped resolving in September 2026. Package signatures remain +required, and the complete resolved transaction must match +`guest/packages.lock.json`; a rolling mirror change fails until the lock is +intentionally refreshed and reviewed. The installed guest retains its normal +mirror configuration. The builder tries the second mirror if a package +download fails on the first. -- `aquamarine/PKGBUILD` adapts Arch `0.14.0-2` packaging for aarch64. +Hyprland 0.56.2 and aquamarine 0.15.1 use `libaquamarine.so=14` together. +The rounded-border backport still applies unchanged to the newer Hyprland. + +- `aquamarine/PKGBUILD` adapts the reviewed Arch `0.14.0-2` recipe for + aquamarine `0.15.1-1` on aarch64, with the new upstream archive checksum. - `hyprtoolkit/PKGBUILD` adapts Arch `0.5.4-6` packaging for aarch64 and uses - package release `6.1` to distinguish the rebuild against aquamarine 0.14. + package release `6.2` to distinguish the local rebuild. Packaging commits, recipe digests, upstream tarball digests, and reproduced library digests are pinned in `guest/spec.json`. The factory builds aquamarine first, installs that verified result in the disposable builder, then builds -Hyprtoolkit against `libaquamarine.so=13`. Random build paths are remapped out +Hyprtoolkit against `libaquamarine.so=14`. Random build paths are remapped out of compiler output. Both package metadata and library digests are checked before publishing the temporary `[try-omarchy-abi-pins]` repository. @@ -26,3 +31,12 @@ The finished guest never receives that builder repository. It holds aquamarine and Hyprtoolkit alongside the patched Hyprland on `IgnorePkg`. Remove these holds together only when the patched compositor and its dependency set have been validated against a newer ABI. + +The builder seeds Omarchy's keyring from the unmodified files in +`guest/keys/omarchy*`, taken from `pkgbuilds/omarchy-keyring/` at the already +pinned `omacom-io/omarchy-pkgs` commit +`7e448b90313fea4fb78da9a78607287691d3b241`. Their SHA-256 digests are checked +before import. The signing fingerprint is +`40DFB630FF42BCFFB047046CF0134EE680CAC571`. The fresh guest receives its own +local keypair and the reviewed ARM and Omarchy repository keys before package +installation, avoiding any keyserver lookup to bootstrap `omarchy-keyring`. diff --git a/guest/pinned-packages/aquamarine/PKGBUILD b/guest/pinned-packages/aquamarine/PKGBUILD index 5b39b3c75..427fa32d0 100644 --- a/guest/pinned-packages/aquamarine/PKGBUILD +++ b/guest/pinned-packages/aquamarine/PKGBUILD @@ -1,8 +1,8 @@ # Maintainer: Caleb Maclennan pkgname=aquamarine -pkgver=0.14.0 -pkgrel=2 +pkgver=0.15.1 +pkgrel=1 pkgdesc='a very light linux rendering backend library' arch=(x86_64 aarch64) url="https://github.com/hyprwm/$pkgname" @@ -27,7 +27,7 @@ makedepends=(cmake) provides=("lib$pkgname.so") _archive="$pkgname-$pkgver" source=("$url/archive/v$pkgver/$_archive.tar.gz") -sha256sums=('5dcf0b17f7dd51539fd7e79d68484f04240b3b63cf9f5f21d5b6dea0088168f9') +sha256sums=('2f9de98c0bd1b7b1b09c576e390a2fef436449762fb334163c414f0c300296f2') build() { cd "$_archive" diff --git a/guest/pinned-packages/hyprtoolkit/PKGBUILD b/guest/pinned-packages/hyprtoolkit/PKGBUILD index 230cdbc67..333236552 100644 --- a/guest/pinned-packages/hyprtoolkit/PKGBUILD +++ b/guest/pinned-packages/hyprtoolkit/PKGBUILD @@ -2,7 +2,7 @@ pkgname=hyprtoolkit pkgver=0.5.4 -pkgrel=6.1 +pkgrel=6.2 pkgdesc='A modern C++ Wayland-native GUI toolkit' arch=(x86_64 aarch64) url="https://github.com/hyprwm/$pkgname" diff --git a/guest/scripts/build-pinned-abi-packages.sh b/guest/scripts/build-pinned-abi-packages.sh index b869be626..6820be3e6 100755 --- a/guest/scripts/build-pinned-abi-packages.sh +++ b/guest/scripts/build-pinned-abi-packages.sh @@ -98,8 +98,8 @@ pacman -S --needed --noconfirm \ for name in aquamarine hyprtoolkit; do case "$name" in - aquamarine) expected_version=0.14.0; expected_pkgrel=2 ;; - hyprtoolkit) expected_version=0.5.4; expected_pkgrel=6.1 ;; + aquamarine) expected_version=0.15.1; expected_pkgrel=1 ;; + hyprtoolkit) expected_version=0.5.4; expected_pkgrel=6.2 ;; esac mapfile -t metadata < <(python3 - "$spec" "$guest_dir" "$name" <<'PY' import json @@ -109,7 +109,7 @@ import sys spec = json.loads(pathlib.Path(sys.argv[1]).read_text()) guest = pathlib.Path(sys.argv[2]).resolve(strict=True) pins = spec.get("inputs", {}).get("abiPackagePins") -if pins != [{"name": "aquamarine", "version": "0.14.0-2"}, {"name": "hyprtoolkit", "version": "0.5.4-6.1"}]: +if pins != [{"name": "aquamarine", "version": "0.15.1-1"}, {"name": "hyprtoolkit", "version": "0.5.4-6.2"}]: raise SystemExit("abiPackagePins must contain the reviewed compatible pair") component = spec["supplyChain"][sys.argv[3]] required = ( @@ -287,9 +287,9 @@ with tarfile.open(fileobj=io.BytesIO(raw), mode="r:") as package: pkginfo = info.read().decode() if f"pkgname = {name}\n" not in pkginfo or f"pkgver = {version}-{pkgrel}\n" not in pkginfo or "arch = aarch64\n" not in pkginfo: raise SystemExit("ABI package identity mismatch") - abi = "provides = libaquamarine.so=13-64" if name == "aquamarine" else "depend = libaquamarine.so=13-64" + abi = "provides = libaquamarine.so=14-64" if name == "aquamarine" else "depend = libaquamarine.so=14-64" if abi not in pkginfo: - raise SystemExit("ABI package does not provide or depend on libaquamarine.so=13") + raise SystemExit("ABI package does not provide or depend on libaquamarine.so=14") member = package.extractfile(f"usr/lib/lib{name}.so.{version}") if member is None: raise SystemExit("ABI package is missing its versioned library") diff --git a/guest/scripts/configure-rootfs.sh b/guest/scripts/configure-rootfs.sh index cd6c74902..7632e94e2 100755 --- a/guest/scripts/configure-rootfs.sh +++ b/guest/scripts/configure-rootfs.sh @@ -84,6 +84,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 000000000..a1316a8ca --- /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/scripts/register-patched-hyprland.sh b/guest/scripts/register-patched-hyprland.sh index 11e2d5c51..670d9bb36 100755 --- a/guest/scripts/register-patched-hyprland.sh +++ b/guest/scripts/register-patched-hyprland.sh @@ -407,8 +407,7 @@ section = None for line in source: if re.fullmatch(r"\[[A-Za-z0-9@._+-]+\]", line): section = line[1:-1] - # Keep try-omarchy-abi-pins so Hyprland build deps can still resolve - # libaquamarine.so=13 while ALA only publishes .so=14. + # Keep the reviewed ABI pair for both the compiler and the staged guest. if section in {"omarchy", "try-omarchy-pinned-cache"} or line.startswith("IgnorePkg"): continue output.append(line) diff --git a/guest/scripts/write-builder-pacman-conf.py b/guest/scripts/write-builder-pacman-conf.py index ae311f81c..8b5a84d1b 100755 --- a/guest/scripts/write-builder-pacman-conf.py +++ b/guest/scripts/write-builder-pacman-conf.py @@ -6,7 +6,7 @@ - expose ABI pins rebuilt from reviewed upstream source + Arch PKGBUILD - omit those pin names from IgnorePkg so pacstrap can install them once - keep optional signed packageCachePins ahead of rolling mirrors - - use the dated ARM snapshot without changing the installed guest mirrors + - use the selected ARM mirror without changing the installed guest mirrors [try-omarchy-abi-pins] is unsigned because repo-add writes an unsigned database for the just-built package. Origin is the reproducible rebuild, not TrustAll. @@ -105,7 +105,7 @@ def write_builder_config( abi_repo: Path | None, pinned_cache_repo: Path | None, drop_ignore: set[str], - repository_snapshot: str | None = None, + repository_mirrors: list[str] | None = None, ) -> None: lines = guest_config.read_text().splitlines() options_sections = 0 @@ -117,19 +117,17 @@ def write_builder_config( section = SECTION_RE.fullmatch(line) if section: repository = section.group(1) - # External downloaders need the invoking builder user's access to the - # temporary database, cache, and locally rebuilt package repositories. - if repository_snapshot and line.startswith("DownloadUser"): + # Local package repositories live in private build directories, so the + # downloader must keep the invoking builder user's access to them. + if repository_mirrors and line.startswith("DownloadUser"): continue - # The archive limits concurrent requests more strictly than live mirrors. - if repository_snapshot and line.startswith("ParallelDownloads"): - line = "ParallelDownloads = 1" if ( - repository_snapshot + repository_mirrors and repository in {"core", "extra", "alarm", "aur"} and line.startswith("Include =") ): - line = f"Server = {repository_snapshot}/$repo" + out.extend(f"Server = {mirror}/$repo" for mirror in repository_mirrors) + continue if section and section.group(1) != "options": if abi_repo is not None and not abi_inserted: out.extend( @@ -159,13 +157,6 @@ def write_builder_config( if line == "[options]": options_sections += 1 - if repository_snapshot: - # curl honors Retry-After for HTTP 429; pacman's downloader - # otherwise aborts the transaction on an archive rate limit. - out.append( - "XferCommand = /usr/bin/curl --fail --location --silent --show-error " - "--retry 8 --retry-delay 3 --connect-timeout 30 --output %o %u" - ) if package_cache is not None: out.append(f"CacheDir = {package_cache}") if disable_sandbox: @@ -197,12 +188,17 @@ def main() -> None: spec = json.loads(args.spec.read_text()) lock_packages = json.loads(args.package_lock.read_text())["packages"] pins = load_abi_pins(spec, lock_packages) - repository_snapshot = spec.get("inputs", {}).get("packageRepositorySnapshot") - if repository_snapshot is not None and not re.fullmatch( - r"https://pkgmirror\.sametimetomorrow\.net/aarch64/repos/\d{4}/\d{2}/\d{2}", - repository_snapshot, + repository_mirrors = spec.get("inputs", {}).get("packageRepositoryMirrors") + if repository_mirrors is not None and ( + not isinstance(repository_mirrors, list) + or not repository_mirrors + or any( + not isinstance(mirror, str) + or not re.fullmatch(r"https://[a-z0-9.-]+/aarch64", mirror) + for mirror in repository_mirrors + ) ): - fail("packageRepositorySnapshot must be a dated ARM archive URL") + fail("packageRepositoryMirrors must contain HTTPS ARM mirror URLs") abi_repo = args.abi_repo if pins: @@ -220,7 +216,7 @@ def main() -> None: abi_repo=abi_repo if pins else None, pinned_cache_repo=args.pinned_cache_repo, drop_ignore={pin["name"] for pin in pins}, - repository_snapshot=repository_snapshot, + repository_mirrors=repository_mirrors, ) diff --git a/guest/spec.json b/guest/spec.json index d1d23587a..2345dcfb6 100644 --- a/guest/spec.json +++ b/guest/spec.json @@ -37,14 +37,14 @@ "archLinuxArmPackagesRepository": "https://github.com/archlinuxarm/PKGBUILDs", "archLinuxArmPackagesCommit": "0b5418fc3f62860b191cd872cb2f933f9fc77841", "hyprland": { - "version": "0.56.1", + "version": "0.56.2", "pkgrel": "3.2", - "upstreamPackageVersion": "0.56.1-3", + "upstreamPackageVersion": "0.56.2-3", "repository": "https://github.com/hyprwm/Hyprland", - "commit": "5c9377c15f85c50648f35ca5a213754f95b93ca0", - "url": "https://github.com/hyprwm/Hyprland/releases/download/v0.56.1/source-v0.56.1.tar.gz", - "sha256": "c5b26eb377360358d01839a1de43fdc004a33e56d6a5d442fdad69b9f3a10549", - "upstreamPackageSha256": "4fcb1b5efe019e184a85b234f75151e68fd8f60ace9b06ff59e7ffbd8a280f7a", + "commit": "efb50993780079460b0cbed1363e2166a2de1d9f", + "url": "https://github.com/hyprwm/Hyprland/releases/download/v0.56.2/source-v0.56.2.tar.gz", + "sha256": "03ad3f5ef152ff44116ffd56fcf808486211ecabf4f0ba567108ee746ba5cd2e", + "upstreamPackageSha256": "dba57b0cba04557b7fa3478253c93fe4c8e88737f8dd570c0820177f567e5a95", "patch": "patches/hyprland/rounded-border-coverage.patch", "patchSha256": "5da431cbca37bdd9a66edeb77c3d677b7033d5f91449158e3ffa58a4eb515828", "glazeVersion": "7.2.0", @@ -52,7 +52,7 @@ "glazeUrl": "https://github.com/stephenberry/glaze/archive/refs/tags/v7.2.0.tar.gz", "glazeSha256": "17dba19ae63ae48f94994f00d49d5cb3c8f1306db1046c534c4828662490b7d4", "glazeLicenseSha256": "5d49e66411a0807a7c8d6b911b9a26b59e940c71aebe561a3ad8b0b80ac4b7b6", - "binarySha256": "b0c96f3057f9f4000c5e50adba0f6020dd7f63747e64371adcd4b30b97eabdb9", + "binarySha256": "34499692a552c4f36bce98b0efda02ebca00d2297c830b109b24ad6a64669645", "license": "BSD-3-Clause", "issue": "https://github.com/omacom/try-omarchy/issues/5", "buildPackages": { @@ -62,7 +62,7 @@ "gcc": "16.1.1+r12+g301eb08fa2c5-1", "gcc-libs": "16.1.1+r12+g301eb08fa2c5-1", "glibc": "2.43+r22+g8362e8ce10b2-2", - "hyprland": "0.56.1-3", + "hyprland": "0.56.2-3", "hyprland-protocols": "0.7.0-1", "make": "4.4.1-3", "meson": "1.12.0-1", @@ -72,17 +72,17 @@ } }, "aquamarine": { - "version": "0.14.0", - "pkgrel": "2", + "version": "0.15.1", + "pkgrel": "1", "repository": "https://github.com/hyprwm/aquamarine", - "url": "https://github.com/hyprwm/aquamarine/archive/v0.14.0/aquamarine-0.14.0.tar.gz", - "sha256": "5dcf0b17f7dd51539fd7e79d68484f04240b3b63cf9f5f21d5b6dea0088168f9", + "url": "https://github.com/hyprwm/aquamarine/archive/v0.15.1/aquamarine-0.15.1.tar.gz", + "sha256": "2f9de98c0bd1b7b1b09c576e390a2fef436449762fb334163c414f0c300296f2", "pkgbuild": "pinned-packages/aquamarine/PKGBUILD", - "pkgbuildSha256": "1bd4197238a4f0092216ab2dfd723126d618cceb977d45865e140a488a8f56ff", + "pkgbuildSha256": "90c998ea89b5c806919c102df78ef3f0d7816a9a08c26eac26b4adf44ba59a2a", "packagingRepository": "https://gitlab.archlinux.org/archlinux/packaging/packages/aquamarine.git", "packagingCommit": "8489a8358817a964a923f05ba324996378d81a5d", "license": "BSD-3-Clause", - "binarySha256": "7da003aa60e008e9f514c312f01c1e967983e2c46732d58953735bfaee3fd8aa" + "binarySha256": "1fb6a90079a1f5620f9441d3e8a92426d21c6bbbab2f1ac070651425dae4129d" }, "mise": { "version": "2026.8.11", @@ -189,16 +189,16 @@ }, "hyprtoolkit": { "version": "0.5.4", - "pkgrel": "6.1", + "pkgrel": "6.2", "repository": "https://github.com/hyprwm/hyprtoolkit", "url": "https://github.com/hyprwm/hyprtoolkit/archive/v0.5.4/hyprtoolkit-0.5.4.tar.gz", "sha256": "2fb59789f231c1c4e9154ceffc1e7524c0cae154807c0d57e6166806255b570f", "pkgbuild": "pinned-packages/hyprtoolkit/PKGBUILD", - "pkgbuildSha256": "803f1db19ad1d42e48b638e35256d3dabbe19d1d0b4b3fd584eedf20121256ce", + "pkgbuildSha256": "28c3dabce8c9553cfe283d23f568551d48efa7d51d14658cc8522d5473dd73a6", "packagingRepository": "https://gitlab.archlinux.org/archlinux/packaging/packages/hyprtoolkit.git", "packagingCommit": "1ed230388a2ccb2c857af980235cf25a4f86e39e", "license": "BSD-3-Clause", - "binarySha256": "dc814fad9723bfcf66dbd29b7f8c5cc96fd63a1ff623909e466dd9d011c0cba8" + "binarySha256": "d901177e32b02d6769f5bcf118e43b22061a5a21a3aa77ee72469d4a2db85895" } }, "themes": [ @@ -213,15 +213,18 @@ "packages": "packages.txt", "packageLock": "packages.lock.json", "pacmanConfig": "pacman.aarch64.conf", - "packageRepositorySnapshot": "https://pkgmirror.sametimetomorrow.net/aarch64/repos/2026/09/15", + "packageRepositoryMirrors": [ + "https://ca.us.mirror.archlinuxarm.org/aarch64", + "https://de3.mirror.archlinuxarm.org/aarch64" + ], "abiPackagePins": [ { "name": "aquamarine", - "version": "0.14.0-2" + "version": "0.15.1-1" }, { "name": "hyprtoolkit", - "version": "0.5.4-6.1" + "version": "0.5.4-6.2" } ] }, diff --git a/guest/tests/test_builder_pacman_conf.py b/guest/tests/test_builder_pacman_conf.py index eab315af5..420bc2906 100644 --- a/guest/tests/test_builder_pacman_conf.py +++ b/guest/tests/test_builder_pacman_conf.py @@ -1,8 +1,10 @@ import importlib.util import json +import sys from pathlib import Path import tempfile import unittest +from unittest.mock import patch GUEST = Path(__file__).resolve().parents[1] @@ -44,15 +46,15 @@ def test_missing_toolkit_archive_is_rejected(self): pins = builder.load_abi_pins(self.spec, self.lock) with tempfile.TemporaryDirectory() as directory: repo = Path(directory) - (repo / "aquamarine-0.14.0-2-aarch64.pkg.tar.zst").touch() + (repo / "aquamarine-0.15.1-1-aarch64.pkg.tar.zst").touch() (repo / "try-omarchy-abi-pins.db.tar.gz").touch() with self.assertRaisesRegex(SystemExit, "hyprtoolkit"): builder.ensure_abi_repo(pins, repo) - def test_snapshot_only_replaces_arm_mirrors_in_builder(self): + def test_mirror_only_replaces_arm_mirrors_in_builder(self): guest_config = GUEST / "pacman.aarch64.conf" original = guest_config.read_text() - snapshot = self.spec["inputs"]["packageRepositorySnapshot"] + mirrors = self.spec["inputs"]["packageRepositoryMirrors"] with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "pacman.conf" builder.write_builder_config( @@ -63,22 +65,46 @@ def test_snapshot_only_replaces_arm_mirrors_in_builder(self): abi_repo=None, pinned_cache_repo=None, drop_ignore=set(), - repository_snapshot=snapshot, + repository_mirrors=mirrors, ) config = output.read_text() self.assertEqual(guest_config.read_text(), original) - self.assertEqual(config.count(f"Server = {snapshot}/$repo"), 4) + for mirror in mirrors: + self.assertEqual(config.count(f"Server = {mirror}/$repo"), 4) + self.assertLess(config.index(mirrors[0]), config.index(mirrors[1])) self.assertNotIn("Include = /etc/pacman.d/mirrorlist", config) self.assertIn("SigLevel = Required DatabaseOptional", config) - self.assertIn("ParallelDownloads = 1", config) + self.assertIn("ParallelDownloads = 5", config) self.assertIn("ParallelDownloads = 5", original) - self.assertIn("XferCommand = /usr/bin/curl", config) - self.assertIn("--retry 8", config) + self.assertNotIn("XferCommand", config) self.assertNotIn("XferCommand", original) self.assertNotIn("DownloadUser", config) self.assertIn("DownloadUser = alpm", original) self.assertIn("Server = https://pkgs.omarchy.org/$arch", config) + def test_mirror_rejects_insecure_urls_and_configuration_injection(self): + for mirror in ( + "http://ca.us.mirror.archlinuxarm.org/aarch64", + "https://example.org/aarch64\n[untrusted]", + ): + with self.subTest(mirror=mirror), tempfile.TemporaryDirectory() as directory: + spec_path = Path(directory) / "spec.json" + self.spec["inputs"]["packageRepositoryMirrors"] = [mirror] + spec_path.write_text(json.dumps(self.spec)) + output = Path(directory) / "pacman.conf" + arguments = [ + "write-builder-pacman-conf.py", + "--spec", str(spec_path), + "--guest-dir", str(GUEST), + "--guest-config", str(GUEST / "pacman.aarch64.conf"), + "--package-lock", str(GUEST / "packages.lock.json"), + "--output", str(output), + ] + with patch.object(sys, "argv", arguments): + with self.assertRaisesRegex(SystemExit, "HTTPS ARM mirror URL"): + builder.main() + self.assertFalse(output.exists()) + def test_mirror_toolkit_version_cannot_replace_the_reviewed_pin(self): self.lock["hyprtoolkit"] = "0.5.4-6" with self.assertRaisesRegex(SystemExit, "does not match lock"): diff --git a/guest/tests/test_native_settings.py b/guest/tests/test_native_settings.py new file mode 100644 index 000000000..a5b2e4435 --- /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 000000000..c0af79e2e --- /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/guest/tests/verify.py b/guest/tests/verify.py index 0bfd666e3..c76290265 100755 --- a/guest/tests/verify.py +++ b/guest/tests/verify.py @@ -131,17 +131,26 @@ def main() -> None: ) check(spec["runtime"]["storage"]["expandedSizeMiB"] == 24576, "working disk expands to 24 GiB") check( - set(spec["inputs"]) == {"packages", "packageLock", "pacmanConfig", "abiPackagePins", "packageRepositorySnapshot"}, + set(spec["inputs"]) == {"packages", "packageLock", "pacmanConfig", "abiPackagePins", "packageRepositoryMirrors"}, "spec has a minimal input set", ) for key, value in spec["inputs"].items(): - if key in {"abiPackagePins", "packageRepositorySnapshot"}: + if key in {"abiPackagePins", "packageRepositoryMirrors"}: continue check((GUEST / value).is_file(), f"spec input exists: {value}") + for name, digest in { + "omarchy.gpg": "15d6aac44df688165b2ea35fe0b23af239bbc66a6909c10a5c219e8d94b707de", + "omarchy-trusted": "ab0b688815444cffd48d15ca3597c77dbb364d59763c5784fca36691520f00fd", + "omarchy-revoked": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }.items(): + check( + hashlib.sha256((GUEST / "keys" / name).read_bytes()).hexdigest() == digest, + f"{name} matches the pinned upstream Omarchy packaging keyring", + ) abi_pins = spec["inputs"]["abiPackagePins"] check( - abi_pins == [{"name": "aquamarine", "version": "0.14.0-2"}, {"name": "hyprtoolkit", "version": "0.5.4-6.1"}], - "factory abi pins keep aquamarine on libaquamarine.so=13 for the locked Hyprland", + abi_pins == [{"name": "aquamarine", "version": "0.15.1-1"}, {"name": "hyprtoolkit", "version": "0.5.4-6.2"}], + "factory abi pins keep aquamarine on libaquamarine.so=14 for the locked Hyprland", ) aquamarine = spec.get("supplyChain", {}).get("aquamarine", {}) pkgbuild = GUEST / aquamarine.get("pkgbuild", "") @@ -149,40 +158,40 @@ def main() -> None: check( aquamarine == { - "version": "0.14.0", - "pkgrel": "2", + "version": "0.15.1", + "pkgrel": "1", "repository": "https://github.com/hyprwm/aquamarine", - "url": "https://github.com/hyprwm/aquamarine/archive/v0.14.0/aquamarine-0.14.0.tar.gz", - "sha256": "5dcf0b17f7dd51539fd7e79d68484f04240b3b63cf9f5f21d5b6dea0088168f9", + "url": "https://github.com/hyprwm/aquamarine/archive/v0.15.1/aquamarine-0.15.1.tar.gz", + "sha256": "2f9de98c0bd1b7b1b09c576e390a2fef436449762fb334163c414f0c300296f2", "pkgbuild": "pinned-packages/aquamarine/PKGBUILD", - "pkgbuildSha256": "1bd4197238a4f0092216ab2dfd723126d618cceb977d45865e140a488a8f56ff", + "pkgbuildSha256": "90c998ea89b5c806919c102df78ef3f0d7816a9a08c26eac26b4adf44ba59a2a", "packagingRepository": "https://gitlab.archlinux.org/archlinux/packaging/packages/aquamarine.git", "packagingCommit": "8489a8358817a964a923f05ba324996378d81a5d", "license": "BSD-3-Clause", - "binarySha256": "7da003aa60e008e9f514c312f01c1e967983e2c46732d58953735bfaee3fd8aa", + "binarySha256": "1fb6a90079a1f5620f9441d3e8a92426d21c6bbbab2f1ac070651425dae4129d", } and pkgbuild.is_file() and hashlib.sha256(pkgbuild.read_bytes()).hexdigest() == aquamarine["pkgbuildSha256"] and f"sha256sums=('{aquamarine['sha256']}')" in pkgbuild_text - and "pkgver=0.14.0" in pkgbuild_text - and "pkgrel=2" in pkgbuild_text, - "factory rebuilds aquamarine 0.14 from the reviewed Arch PKGBUILD and upstream tarball", + and "pkgver=0.15.1" in pkgbuild_text + and "pkgrel=1" in pkgbuild_text, + "factory rebuilds aquamarine 0.15.1 from the reviewed Arch PKGBUILD and upstream tarball", ) hyprtoolkit = spec.get("supplyChain", {}).get("hyprtoolkit", {}) toolkit_recipe = GUEST / hyprtoolkit.get("pkgbuild", "") check( hyprtoolkit == { "version": "0.5.4", - "pkgrel": "6.1", + "pkgrel": "6.2", "repository": "https://github.com/hyprwm/hyprtoolkit", "url": "https://github.com/hyprwm/hyprtoolkit/archive/v0.5.4/hyprtoolkit-0.5.4.tar.gz", "sha256": "2fb59789f231c1c4e9154ceffc1e7524c0cae154807c0d57e6166806255b570f", "pkgbuild": "pinned-packages/hyprtoolkit/PKGBUILD", - "pkgbuildSha256": "803f1db19ad1d42e48b638e35256d3dabbe19d1d0b4b3fd584eedf20121256ce", + "pkgbuildSha256": "28c3dabce8c9553cfe283d23f568551d48efa7d51d14658cc8522d5473dd73a6", "packagingRepository": "https://gitlab.archlinux.org/archlinux/packaging/packages/hyprtoolkit.git", "packagingCommit": "1ed230388a2ccb2c857af980235cf25a4f86e39e", "license": "BSD-3-Clause", - "binarySha256": "dc814fad9723bfcf66dbd29b7f8c5cc96fd63a1ff623909e466dd9d011c0cba8" + "binarySha256": "d901177e32b02d6769f5bcf118e43b22061a5a21a3aa77ee72469d4a2db85895" } and toolkit_recipe.is_file() and hashlib.sha256(toolkit_recipe.read_bytes()).hexdigest() == hyprtoolkit["pkgbuildSha256"], @@ -601,14 +610,14 @@ def main() -> None: check( hyprland == { - "version": "0.56.1", + "version": "0.56.2", "pkgrel": "3.2", - "upstreamPackageVersion": "0.56.1-3", + "upstreamPackageVersion": "0.56.2-3", "repository": "https://github.com/hyprwm/Hyprland", - "commit": "5c9377c15f85c50648f35ca5a213754f95b93ca0", - "url": "https://github.com/hyprwm/Hyprland/releases/download/v0.56.1/source-v0.56.1.tar.gz", - "sha256": "c5b26eb377360358d01839a1de43fdc004a33e56d6a5d442fdad69b9f3a10549", - "upstreamPackageSha256": "4fcb1b5efe019e184a85b234f75151e68fd8f60ace9b06ff59e7ffbd8a280f7a", + "commit": "efb50993780079460b0cbed1363e2166a2de1d9f", + "url": "https://github.com/hyprwm/Hyprland/releases/download/v0.56.2/source-v0.56.2.tar.gz", + "sha256": "03ad3f5ef152ff44116ffd56fcf808486211ecabf4f0ba567108ee746ba5cd2e", + "upstreamPackageSha256": "dba57b0cba04557b7fa3478253c93fe4c8e88737f8dd570c0820177f567e5a95", "patch": "patches/hyprland/rounded-border-coverage.patch", "patchSha256": "5da431cbca37bdd9a66edeb77c3d677b7033d5f91449158e3ffa58a4eb515828", "glazeVersion": "7.2.0", @@ -616,7 +625,7 @@ def main() -> None: "glazeUrl": "https://github.com/stephenberry/glaze/archive/refs/tags/v7.2.0.tar.gz", "glazeSha256": "17dba19ae63ae48f94994f00d49d5cb3c8f1306db1046c534c4828662490b7d4", "glazeLicenseSha256": "5d49e66411a0807a7c8d6b911b9a26b59e940c71aebe561a3ad8b0b80ac4b7b6", - "binarySha256": "b0c96f3057f9f4000c5e50adba0f6020dd7f63747e64371adcd4b30b97eabdb9", + "binarySha256": "34499692a552c4f36bce98b0efda02ebca00d2297c830b109b24ad6a64669645", "license": "BSD-3-Clause", "issue": "https://github.com/omacom/try-omarchy/issues/5", "buildPackages": { @@ -626,7 +635,7 @@ def main() -> None: "gcc": "16.1.1+r12+g301eb08fa2c5-1", "gcc-libs": "16.1.1+r12+g301eb08fa2c5-1", "glibc": "2.43+r22+g8362e8ce10b2-2", - "hyprland": "0.56.1-3", + "hyprland": "0.56.2-3", "hyprland-protocols": "0.7.0-1", "make": "4.4.1-3", "meson": "1.12.0-1", diff --git a/macos/Sources/OmarchyVMHelper/DisposableVMWorkspace.swift b/macos/Sources/OmarchyVMHelper/DisposableVMWorkspace.swift new file mode 100644 index 000000000..37158103e --- /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 000000000..dab01199d --- /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 b350ce6a1..fbca4ca80 100644 --- a/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift +++ b/macos/Sources/OmarchyVMHelper/StartMenuWindow.swift @@ -171,6 +171,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private var networkEditor: NetworkEditor? private let immersiveMode: () -> Bool private let setImmersiveMode: (Bool) -> Void + private let startAutomatically: () -> Bool + private let setStartAutomatically: (Bool) -> Void private let languageStatus: () -> LanguageMenuState private let setLanguage: (String?) -> Void private let integrationCacheURL: () -> URL? @@ -191,6 +193,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? @@ -202,7 +210,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 @@ -267,6 +275,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { networkIdentity: VMNetworkIdentityAccess = .unavailable, immersiveMode: @escaping () -> Bool = { true }, setImmersiveMode: @escaping (Bool) -> Void = { _ in }, + startAutomatically: @escaping () -> Bool = { false }, + setStartAutomatically: @escaping (Bool) -> Void = { _ in }, appVersionLabel: String = InstalledAppRelease.current.label, appReleaseActionTitle: @escaping () -> String = { "Check for Updates…" }, checkForAppUpdates: @escaping () -> Void = {}, @@ -303,6 +313,8 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { self.networkIdentity = networkIdentity self.immersiveMode = immersiveMode self.setImmersiveMode = setImmersiveMode + self.startAutomatically = startAutomatically + self.setStartAutomatically = setStartAutomatically self.languageStatus = languageStatus self.setLanguage = setLanguage self.integrationCacheURL = integrationCacheURL @@ -326,6 +338,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 @@ -362,7 +428,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func refreshPermissionStatus() { - guard window.isVisible, !launchInProgress, !resetInProgress else { return } + guard window.isVisible, !controlsBusy, !resetInProgress else { return } render() } @@ -419,7 +485,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 @@ -435,7 +501,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { func launchRequiresReset() { guard launchInProgress else { return } launchInProgress = false - render() + show() let alert = NSAlert() alert.alertStyle = .warning @@ -450,6 +516,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 @@ -462,7 +529,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { func launchDidFail(errorMessage: String) { guard launchInProgress else { return } launchInProgress = false - render() + show() let alert = NSAlert() alert.alertStyle = .critical @@ -473,7 +540,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } func windowShouldClose(_ sender: NSWindow) -> Bool { - NSApp.terminate(nil) + if virtualMachineRunning { + closeSettings() + } else { + NSApp.terminate(nil) + } return false } @@ -493,7 +564,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") @@ -699,7 +770,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 ) } @@ -709,7 +780,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { if let storageRow { integrationRowViews.append(storageRow) } - integrationRowViews.append(contentsOf: [resourceRow, networkingRow, portForwardingRow, immersiveRow, languageRow]) + integrationRowViews.append(contentsOf: [resourceRow, networkingRow, portForwardingRow, immersiveRow, automaticStartSettingRow(), languageRow]) let integrationStatus = GuestIntegrationCache.read(integrationCacheURL()) integrationRowViews.insert(permissionRow( symbolName: "arrow.triangle.2.circlepath", title: "VM integrations", @@ -777,7 +848,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ) reset.identifier = NSUserInterfaceItemIdentifier("reset-button") reset.isEnabled = canResetStorage - && !launchInProgress + && !prelaunchControlsLocked && !resetInProgress && !microphoneRequestInFlight && !cameraRequestInFlight @@ -787,20 +858,26 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { reset.heightAnchor.constraint(equalToConstant: 30).isActive = true reset.widthAnchor.constraint(greaterThanOrEqualToConstant: 154).isActive = true - let launchButtonTitle = launchInProgress ? "Launching Omarchy…" : "Launch Omarchy" + 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 resetAction = virtualMachineRunning && canResetStorage ? manage : reset + + 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 @@ -815,7 +892,6 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } NSLayoutConstraint.activate([ launchButton.heightAnchor.constraint(equalToConstant: 48), - launchButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 500), ]) let resetHeading = sectionHeading("RESET") @@ -844,7 +920,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { resetLabels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) resetDetail.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) resetDetail.trailingAnchor.constraint(lessThanOrEqualTo: resetLabels.trailingAnchor).isActive = true - let resetRow = NSStackView(views: [resetSymbol, resetLabels, reset]) + let resetRow = NSStackView(views: [resetSymbol, resetLabels, resetAction]) resetRow.orientation = .horizontal resetRow.alignment = .centerY resetRow.spacing = 12 @@ -852,15 +928,24 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { resetRow.heightAnchor.constraint(greaterThanOrEqualToConstant: 64).isActive = true let resetCard = themedCard(containing: resetRow, identifier: "reset-card") - let stack = NSStackView(views: [ - headingStack, - permissionHeading, - permissionCard, - integrationHeading, - integrationCard, - resetHeading, - resetCard, - ]) + 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, networking, 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 settingsSections: [NSView] = [permissionHeading, permissionCard, integrationHeading, integrationCard] + let stack = NSStackView(views: virtualMachineRunning + ? [headingStack, runningActions] + settingsSections + [resetHeading, resetCard] + : [headingStack] + settingsSections + [resetHeading, resetCard]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 18 @@ -916,6 +1001,11 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { launchButton.widthAnchor.constraint(equalTo: actions.widthAnchor), ]) + if virtualMachineRunning { + runningActions.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + restartCaption.widthAnchor.constraint(equalTo: runningActions.widthAnchor).isActive = true + } + content.layoutSubtreeIfNeeded() document.layoutSubtreeIfNeeded() preferredContentHeight = ceil(stack.fittingSize.height + actions.fittingSize.height + 58) @@ -1098,7 +1188,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { button.isEnabled = actionsEnabled && !microphoneRequestInFlight && !cameraRequestInFlight - && !launchInProgress + && !controlsBusy && !resetInProgress let identifier = actions.count == 1 ? "permission-action-\(identifier)" @@ -1195,32 +1285,67 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } private func immersiveSettingRow(isEnabled: Bool) -> NSView { + let setting = toggleSettingRow( + titleText: "Immersive", + detailText: StartMenuPresentation.immersiveDetail(isEnabled: isEnabled), + symbolName: "arrow.up.left.and.arrow.down.right", + identifier: "immersive", + accessibilityLabel: "Immersive mode", + isEnabled: isEnabled, + action: #selector(changeImmersiveMode(_:)) + ) + immersiveCaption = setting.caption + return setting.row + } + + private func automaticStartSettingRow() -> NSView { + let detail = virtualMachineRunning + ? "Skip the start menu on launch. Open settings anytime from Omarchy’s Setup menu." + : "Skip this menu on launch. Hold Option while opening the app to show it again." + return toggleSettingRow( + titleText: "Start automatically", + detailText: detail, + symbolName: "play.circle", + identifier: "automatic-start", + accessibilityLabel: "Start automatically", + isEnabled: startAutomatically(), + action: #selector(changeStartAutomatically(_:)) + ).row + } + + private func toggleSettingRow( + titleText: String, + detailText: String, + symbolName: String, + identifier: String, + accessibilityLabel: String, + isEnabled: Bool, + action: Selector + ) -> (row: NSView, caption: NSTextField) { let symbol = NSImageView() symbol.image = NSImage( - systemSymbolName: "arrow.up.left.and.arrow.down.right", + systemSymbolName: symbolName, accessibilityDescription: nil ) symbol.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 19, weight: .medium) symbol.contentTintColor = OmarchyStartMenuTheme.accent - symbol.identifier = NSUserInterfaceItemIdentifier("immersive-symbol") + symbol.identifier = NSUserInterfaceItemIdentifier("\(identifier)-symbol") symbol.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ symbol.widthAnchor.constraint(equalToConstant: 26), symbol.heightAnchor.constraint(equalToConstant: 26), ]) - let title = NSTextField(labelWithString: "Immersive") + let title = NSTextField(labelWithString: titleText) title.font = .monospacedSystemFont(ofSize: 13, weight: .bold) title.textColor = OmarchyStartMenuTheme.foreground - title.identifier = NSUserInterfaceItemIdentifier("immersive-title") + title.identifier = NSUserInterfaceItemIdentifier("\(identifier)-title") - let detailText = StartMenuPresentation.immersiveDetail(isEnabled: isEnabled) let detail = NSTextField(wrappingLabelWithString: detailText) detail.font = .monospacedSystemFont(ofSize: 10, weight: .regular) detail.textColor = OmarchyStartMenuTheme.muted detail.maximumNumberOfLines = 2 - detail.identifier = NSUserInterfaceItemIdentifier("immersive-caption") - immersiveCaption = detail + detail.identifier = NSUserInterfaceItemIdentifier("\(identifier)-caption") let labels = NSStackView(views: [title, detail]) labels.orientation = .vertical @@ -1231,17 +1356,17 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { let toggle = OmarchyToggleButton( isOn: isEnabled, target: self, - action: #selector(changeImmersiveMode(_:)) + action: action ) - toggle.isEnabled = !microphoneRequestInFlight && !launchInProgress && !resetInProgress - toggle.identifier = NSUserInterfaceItemIdentifier("immersive-toggle") - toggle.setAccessibilityLabel("Immersive mode") + toggle.isEnabled = !microphoneRequestInFlight && !controlsBusy && !resetInProgress + toggle.identifier = NSUserInterfaceItemIdentifier("\(identifier)-toggle") + toggle.setAccessibilityLabel(accessibilityLabel) toggle.setAccessibilityTitleUIElement(title) toggle.setAccessibilityHelp(detailText) toggle.translatesAutoresizingMaskIntoConstraints = false let row = NSView() - row.identifier = NSUserInterfaceItemIdentifier("immersive-row") + row.identifier = NSUserInterfaceItemIdentifier("\(identifier)-row") row.translatesAutoresizingMaskIntoConstraints = false row.addSubview(symbol) row.addSubview(labels) @@ -1258,7 +1383,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { ]) labels.setContentHuggingPriority(.defaultLow, for: .horizontal) labels.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - return row + return (row, detail) } @objc private func beginAccessibilityRequest() { @@ -1365,7 +1490,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" @@ -1421,13 +1546,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 } @@ -1459,17 +1584,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 beginNetworkConfiguration() { - guard !launchInProgress, !resetInProgress, networkEditor == nil else { return } + guard !controlsBusy, !resetInProgress, networkEditor == nil else { return } permissionWindowRestorer.cancel() let editor = NetworkEditor(preferences: networkPreferences(), interfaces: VMBridgeInterfaces.available(), identity: networkIdentity, save: saveNetworkPreferences, didClose: { [weak self] in @@ -1481,7 +1608,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { } @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(), @@ -1502,7 +1629,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() @@ -1520,7 +1647,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) @@ -1551,7 +1678,7 @@ final class StartMenuWindow: NSObject, NSWindowDelegate { private func confirmReset() { guard canResetStorage, - !launchInProgress, + !prelaunchControlsLocked, !resetInProgress, !microphoneRequestInFlight, !cameraRequestInFlight, @@ -1595,8 +1722,14 @@ 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) + (sender as? OmarchyToggleButton)?.refreshAppearance() + } + + @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 000000000..165dbb33e --- /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 8fab95904..ce509413e 100644 --- a/macos/Sources/OmarchyVMHelper/VMApplicationController.swift +++ b/macos/Sources/OmarchyVMHelper/VMApplicationController.swift @@ -55,6 +55,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private let portForwardingStore: PortForwardingPreferenceStore private let networkStore: VMNetworkPreferenceStore private let fullscreenPreferenceStore: FullscreenPreferenceStore + private let startupPreferenceStore: StartupPreferenceStore private let resourcePreferenceStore: VMResourcePreferenceStore private let resourceLimits: VMResourceLimits private let languagePreferenceStore: LanguagePreferenceStore @@ -64,6 +65,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 let appReleaseChecker = AppReleaseChecker() private var appReleaseWindow: AppReleaseWindow? private var volumeObserver: NSObjectProtocol? @@ -99,6 +105,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { portForwardingStore: PortForwardingPreferenceStore = PortForwardingPreferenceStore(), networkStore: VMNetworkPreferenceStore = VMNetworkPreferenceStore(), fullscreenPreferenceStore: FullscreenPreferenceStore = FullscreenPreferenceStore(), + startupPreferenceStore: StartupPreferenceStore = StartupPreferenceStore(), resourcePreferenceStore: VMResourcePreferenceStore = VMResourcePreferenceStore(), resourceLimits: VMResourceLimits = .current, languagePreferenceStore: LanguagePreferenceStore = LanguagePreferenceStore(), @@ -117,6 +124,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { self.portForwardingStore = portForwardingStore self.networkStore = networkStore self.fullscreenPreferenceStore = fullscreenPreferenceStore + self.startupPreferenceStore = startupPreferenceStore self.resourcePreferenceStore = resourcePreferenceStore self.resourceLimits = resourceLimits self.languagePreferenceStore = languagePreferenceStore @@ -134,7 +142,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { } observeVolumeUnmounts() observeHostPowerEvents() - showStartMenu() + let startAutomatically = StartupPolicy.shouldStartAutomatically( + isEnabled: startupPreferenceStore.load(), + optionKeyHeld: NSEvent.modifierFlags.contains(.option), + initialArguments: initialArguments + ) + prepareStartMenu(startAutomatically: startAutomatically) appReleaseChecker.checkAutomaticallyIfDue() } @@ -149,7 +162,8 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { 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, @@ -248,6 +262,12 @@ 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) + }, appVersionLabel: appReleaseChecker.installed.label, appReleaseActionTitle: { [weak self] in self?.appReleaseChecker.menuTitle ?? "Check for Updates…" @@ -274,9 +294,13 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { } ) startMenuWindow = startMenu - startMenu.show() - if initialResetRequested { - startMenu.promptForReset() + if startAutomatically { + startMenu.launchOmarchy() + } else { + startMenu.show() + if honorInitialReset && initialResetRequested { + startMenu.promptForReset() + } } } @@ -285,6 +309,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { virtualMachineReachedStart = false pendingHostSleepControlFailure = nil do { + if isDisposable { _ = try disposableWorkspace.prepare() } let accessibilityDecision = AccessibilityLaunchDecision.make( for: AXIsProcessTrusted() ? .authorized : .unavailable ) @@ -303,7 +328,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 } @@ -354,7 +379,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 @@ -510,8 +536,12 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { preference: languagePreferenceStore.load(), supportsSelection: supportsLanguageSelection() ) + var storageEnvironment = language.environment + if let directory = disposableWorkspace.directory { + storageEnvironment[StorageLocationPolicy.environmentKey] = directory.path + } let storage = StorageLocationLaunchConfiguration.make( - baseEnvironment: language.environment, + baseEnvironment: storageEnvironment, preference: storageLocationStore.load(), metrics: bundledMetrics, probe: volumeProbe, @@ -581,7 +611,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) { @@ -738,6 +822,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" @@ -791,7 +876,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { try hostSleepCoordinator.prepareForHostSleep( vmIsRunning: childRunning, - isStopping: lifecycle.isStopping + isStopping: lifecycle.isTerminating ) } catch { fputs( @@ -810,7 +895,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { do { try hostSleepCoordinator.resumeAfterHostWake( vmIsRunning: childRunning, - isStopping: lifecycle.isStopping + isStopping: lifecycle.isTerminating ) cancelHostWakeRetry() } catch { @@ -821,7 +906,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() @@ -838,7 +923,7 @@ final class VMApplicationController: NSObject, NSApplicationDelegate { private func presentHostWakeRecovery(error: Error) { guard childRunning, - !lifecycle.isStopping, + !lifecycle.isTerminating, hostSleepCoordinator.pausedForHostSleep else { return } @@ -861,7 +946,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 } @@ -912,6 +997,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() @@ -919,6 +1011,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, @@ -931,6 +1026,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, @@ -1026,6 +1131,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 debea9e6f..5783c0cbb 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 000000000..814535764 --- /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 000000000..bec5ead5d --- /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 000000000..a2810c8d4 --- /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 toggle = try #require(descendant( + withIdentifier: "automatic-start-toggle", in: content + ) as? NSButton) + #expect(toggle.state == .off) + toggle.performClick(nil) + #expect(automaticStart) + #expect(launchCount == 0) + + menu.launchOmarchy() + menu.launchOmarchy() + #expect(launchCount == 1) + #expect(!menu.window.isVisible) + let launchingToggle = try #require(descendant( + withIdentifier: "automatic-start-toggle", in: content + ) as? NSButton) + #expect(launchingToggle.state == .on) + #expect(!launchingToggle.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 toggle = try #require(descendant( + withIdentifier: "automatic-start-toggle", in: content + ) as? NSButton) + #expect(toggle.isEnabled) + toggle.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-toggle", "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 000000000..795856b73 --- /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 97332a4e3..dee131a42 100755 --- a/macos/Tests/qemu-memory-contract.test.sh +++ b/macos/Tests/qemu-memory-contract.test.sh @@ -62,6 +62,10 @@ fi 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 e035bce92..d2341710b 100755 --- a/macos/Tests/run-qemu-ssh-contract.test.sh +++ b/macos/Tests/run-qemu-ssh-contract.test.sh @@ -74,6 +74,11 @@ fi chmod 755 "$resources/scripts/run-qemu-gpu.sh" chmod 644 "$resources/scripts/qemu-port-forwarding.sh" +mkdir -p "$resources/guest-settings" "$resources/integrations" +printf '{}\n' >"$resources/integrations/manifest.json" +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 @@ -98,6 +103,9 @@ fi if [[ ${1:-} == --bridge-native-audio && ${FAKE_AUDIO_EARLY_EXIT:-0} == 1 ]]; then sleep 0.05 exit 0 +elif [[ ${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 \ @@ -557,6 +565,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' @@ -575,6 +588,25 @@ 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=6,chardev=omarchy-settings-bridge,name=dev.tryomarchy.settings' +assert_contains "$disabled_qemu" \ + 'virtserialport,bus=omarchy-serial.0,nr=5,chardev=omarchy-integrations,name=dev.tryomarchy.integrations' +# A duplicate bus/port pair makes real QEMU exit before its monitor is usable. +python3 - "$test_root/disabled/qemu.log" <<'PYPORTS' +import pathlib +import sys + +ports = set() +for argument in pathlib.Path(sys.argv[1]).read_text().splitlines(): + if not argument.startswith(("virtserialport,", "virtconsole,")): + continue + fields = dict(field.split("=", 1) for field in argument.split(",")[1:]) + port = (fields["bus"], fields["nr"]) + assert port not in ports, f"Duplicate virtual serial port: {port}" + ports.add(port) +assert len(ports) == 7, f"Expected all seven guest channels, got {ports}" +PYPORTS 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' @@ -727,6 +759,11 @@ assert_not_contains "$(<"$test_root/audio-shutdown-race/stderr")" 'native audio run_scenario audio-failure 1 '' FAKE_AUDIO_EARLY_EXIT=1 FAKE_QEMU_WAIT_FOR_TERMINATION=1 assert_contains "$(<"$test_root/audio-failure/stderr")" 'native audio bridge exited while QEMU was running' +# 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 e13a018f7..cd9765716 100755 --- a/macos/build-app.sh +++ b/macos/build-app.sh @@ -197,6 +197,18 @@ install -m 0644 "$macos_dir/qemu-persistent-storage.sh" \ install -m 0644 "$macos_dir/qemu-port-forwarding.sh" \ "$contents/Resources/scripts/qemu-port-forwarding.sh" install -m 0644 "$macos_dir/qemu-networking.sh" "$contents/Resources/scripts/qemu-networking.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 install -m 0644 "$macos_dir/network-identity.py" "$contents/Resources/scripts/network-identity.py" python3 "$repo_dir/integrations/build-bundle.py" "$contents/Resources/integrations" for guest_resource in \ diff --git a/macos/guest-settings.service b/macos/guest-settings.service new file mode 100644 index 000000000..e0eba3855 --- /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 1fe373ce1..e58c516c1 100755 --- a/macos/run-qemu-gpu.sh +++ b/macos/run-qemu-gpu.sh @@ -598,7 +598,7 @@ exact_keys( hyprland_identity = hashlib.sha256( json.dumps(hyprland, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8") ).hexdigest() -if hyprland_identity != "ae82ce3f989eff555f1faa2400ff0ecb3d7b52b4797c6e3f4fca29959e5a7790": +if hyprland_identity != "f41042613023280c808d5bf6258f2f71c1b20d0755f894b53e77defe97db42a7": fail("factory Hyprland component is not the reviewed rounded-border build") aquamarine = exact_keys( supply_chain.get("aquamarine"), @@ -618,19 +618,19 @@ aquamarine = exact_keys( "build spec aquamarine component", ) if aquamarine != { - "version": "0.14.0", - "pkgrel": "2", + "version": "0.15.1", + "pkgrel": "1", "repository": "https://github.com/hyprwm/aquamarine", - "url": "https://github.com/hyprwm/aquamarine/archive/v0.14.0/aquamarine-0.14.0.tar.gz", - "sha256": "5dcf0b17f7dd51539fd7e79d68484f04240b3b63cf9f5f21d5b6dea0088168f9", + "url": "https://github.com/hyprwm/aquamarine/archive/v0.15.1/aquamarine-0.15.1.tar.gz", + "sha256": "2f9de98c0bd1b7b1b09c576e390a2fef436449762fb334163c414f0c300296f2", "pkgbuild": "pinned-packages/aquamarine/PKGBUILD", - "pkgbuildSha256": "1bd4197238a4f0092216ab2dfd723126d618cceb977d45865e140a488a8f56ff", + "pkgbuildSha256": "90c998ea89b5c806919c102df78ef3f0d7816a9a08c26eac26b4adf44ba59a2a", "packagingRepository": "https://gitlab.archlinux.org/archlinux/packaging/packages/aquamarine.git", "packagingCommit": "8489a8358817a964a923f05ba324996378d81a5d", "license": "BSD-3-Clause", - "binarySha256": "7da003aa60e008e9f514c312f01c1e967983e2c46732d58953735bfaee3fd8aa", + "binarySha256": "1fb6a90079a1f5620f9441d3e8a92426d21c6bbbab2f1ac070651425dae4129d", }: - fail("factory aquamarine component is not the reviewed libaquamarine.so=13 rebuild") + fail("factory aquamarine component is not the reviewed libaquamarine.so=14 rebuild") hyprtoolkit = exact_keys( supply_chain.get("hyprtoolkit"), set(aquamarine), @@ -638,18 +638,18 @@ hyprtoolkit = exact_keys( ) if hyprtoolkit != { "version": "0.5.4", - "pkgrel": "6.1", + "pkgrel": "6.2", "repository": "https://github.com/hyprwm/hyprtoolkit", "url": "https://github.com/hyprwm/hyprtoolkit/archive/v0.5.4/hyprtoolkit-0.5.4.tar.gz", "sha256": "2fb59789f231c1c4e9154ceffc1e7524c0cae154807c0d57e6166806255b570f", "pkgbuild": "pinned-packages/hyprtoolkit/PKGBUILD", - "pkgbuildSha256": "803f1db19ad1d42e48b638e35256d3dabbe19d1d0b4b3fd584eedf20121256ce", + "pkgbuildSha256": "28c3dabce8c9553cfe283d23f568551d48efa7d51d14658cc8522d5473dd73a6", "packagingRepository": "https://gitlab.archlinux.org/archlinux/packaging/packages/hyprtoolkit.git", "packagingCommit": "1ed230388a2ccb2c857af980235cf25a4f86e39e", "license": "BSD-3-Clause", - "binarySha256": "dc814fad9723bfcf66dbd29b7f8c5cc96fd63a1ff623909e466dd9d011c0cba8" + "binarySha256": "d901177e32b02d6769f5bcf118e43b22061a5a21a3aa77ee72469d4a2db85895" }: - fail("factory hyprtoolkit component is not the reviewed libaquamarine.so=13 rebuild") + fail("factory hyprtoolkit component is not the reviewed libaquamarine.so=14 rebuild") mise = exact_keys( supply_chain.get("mise"), {"binarySha256", "license", "reportedVersion", "sha256", "url", "version"}, @@ -1445,6 +1445,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" integration_bridge_socket="/tmp/${work_dir##*/}/integrations.sock" audio_route_dir="/tmp/${work_dir##*/}/audio-routes" mkdir -m 700 "$work_dir/audio-routes" @@ -1554,6 +1555,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//,/,,} + # macOS 15 can pass the paused EL2 probe, then abort with HV_BAD_ARGUMENT when # QEMU synchronizes vCPU registers (#211). Keep it on the platform-GIC/EL1 path. # On macOS 26+, probe actual Hypervisor.framework support for EL2 rather than @@ -1627,7 +1638,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 omarchy.virgl_dual_source=1$shared_folder_kernel_argument$ssh_kernel_argument$keyboard_kernel_argument$locale_kernel_argument" + -append "$launch_kernel_command_line omarchy.qemu_virgl=1 omarchy.virgl_dual_source=1$shared_folder_kernel_argument$ssh_kernel_argument$settings_kernel_argument$keyboard_kernel_argument$locale_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" @@ -1643,7 +1654,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=6,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" @@ -1729,7 +1744,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" @@ -1740,6 +1755,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" # The socket file appears before QEMU's main loop accepts connections, and the # helper tears the VM down if the monitor behind this line does not answer. # Use the bundled helper so release launches do not depend on host Python. @@ -1802,9 +1818,14 @@ fi # 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 if [[ $QEMU_NETWORK_MODE == bridged && -f $QEMU_NETWORK_DIRECTORY/failed ]]; then cat "$QEMU_NETWORK_DIRECTORY/log" >&2 @@ -1821,13 +1842,14 @@ while true; do audio_bridge_status=$? fi audio_bridge_pid="" - # QEMU can exit between the process checks, taking the bridge down normally. - for ((attempt = 0; attempt < 20; attempt++)); do - qemu_state=$(ps -p "$qemu_pid" -o state= 2>/dev/null || true) - [[ -n $qemu_state && $qemu_state != *Z* ]] || break + # 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 - [[ -n $qemu_state && $qemu_state != *Z* ]] || break + qemu_is_running || break fail "native audio bridge exited while QEMU was running (status $audio_bridge_status)" fi @@ -1846,6 +1868,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 @@ -1888,6 +1911,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 6be7e42f7..ea0bc7d8b 100755 --- a/scripts/build-cache.py +++ b/scripts/build-cache.py @@ -133,6 +133,11 @@ def component_files(root: Path, component: str) -> list[Path]: paths.extend( [ root / "LICENSE", + 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",