From e1b0e5e9bb72b15dc494458925655bf32656382b Mon Sep 17 00:00:00 2001 From: Omarchybot Date: Sat, 10 Oct 2026 02:15:56 +0200 Subject: [PATCH] Converge omarchy-mac and omarchy-mx-mac into upstream Omarchy (#14431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add omarchy-hw-platform to tell Apple Silicon and Qualcomm machines apart aarch64 covers Apple Silicon Macs, Snapdragon laptops, Raspberry Pis and VMs, so the CPU architecture can't pick platform setup. omarchy-hw-platform prints apple-silicon, qualcomm, generic-aarch64 or generic from the vendor prefixes in the device tree's root compatible (/proc/device-tree, then /sys/firmware/devicetree/base) and the CPU, and fails when they contradict each other. Its qualcomm answer uses the same evidence as the dragon branch's omarchy-hw-qualcomm-soc, and the fixtures check the two agree. omarchy-hw-apple-silicon is the Apple predicate on top of it. An image built away from its hardware names its target in a root-owned manifest, /var/lib/omarchy/image/target (format=1, platform=). While the root is being built rather than booted, the detector answers from it and never reads the build host's device tree; a booted system always answers from its hardware. As root it restarts in an empty environment, so no variable picks the platform for privileged setup. Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com> * Reject platform gates keyed only on the CPU architecture An architecture check is right for an ABI, a binary or a repository's $arch, not for picking a platform. The lint lists each shipped check after review and fails on a new one, so platform setup goes through omarchy-hw-platform. * Compose default packages from base, architecture and platform lists omarchy-reinstall-pkgs installed the base list on every machine. A platform's default set is now the base list, then install/omarchy-aarch64.packages on aarch64, then the platform's own list when it has one (install/omarchy-qualcomm.packages adds linux-firmware-qcom). omarchy-pkg-defaults prints the set for the detected platform, or for the one named, and reinstall installs it. x86_64 still gets exactly the base list, and an image build gets its target's set. * Early-load thunderbolt only where the kernel builds the module linux-aarch64 has no thunderbolt module, and mkinitcpio fails the whole image over a MODULES entry it cannot find. The optional form, thunderbolt?, is not enough: a systemd initramfs copies MODULES as written into modules-load.d, where "thunderbolt?" names no module, and a kernel without the module would still list it there. thunderbolt_module.conf now adds plain thunderbolt only when modinfo finds it as a module for the kernel being built. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Compose mkinitcpio HOOKS from an early baseline drop-in omarchy_hooks.conf assigned HOOKS outright, and mkinitcpio sources drop-ins in name order, so it ran after any numbered drop-in (90-*.conf) and threw its hooks away. HOOKS now comes from 00-omarchy-hooks.conf, which sorts first, and later drop-ins add to it. The NVIDIA kms filter and the vconsole handling stay in omarchy_hooks.conf. With the drop-ins Omarchy writes, machines keep the same HOOKS, MODULES and FILES; a user drop-in that sets HOOKS and sorts before omarchy_hooks.conf now takes effect instead of being overwritten. Migration 1786605598 now sources the baseline first, since without it the migration would read empty HOOKS and also rebuild hybrid systems. It skips a machine that lacks the baseline. * Resolve the LUKS parent from raw lsblk output lsblk -s prints the ancestor tree with box-drawing prefixes ("└─/dev/nvme0n1p6"), so the crypto_LUKS parent came back as a path that does not exist and first-boot setup stopped with "Could not locate the LUKS device to re-key". -r prints raw names. Factory reset uses the same lookup. * Check the factory-reset passphrase against keyslots only cryptsetup open tries enrolled tokens before the key file, so a TPM2 or FIDO2 token could authorize the re-key with a wrong passphrase. * Test LUKS parent lookup during owner setup and factory reset Exercise raw ancestry output without invoking provisioning or reset. Cover btrfs subvolume roots, absent encryption, failed probes, and explicit cryptdevice specifications. * Cover LUKS parent lookup through an LVM volume group * Journal the first-boot LUKS re-key so an interrupted attempt resumes A first boot killed after the staged install key's slot was retired left the key file behind, and every retry then stopped at "the staged LUKS key no longer unlocks". A lost key file with the boot-time auto-unlock still configured let setup finish with the disk unlocking unattended. The re-key now runs from install/provisioning/luks-rekey.sh and records its phase and slot numbers (never key material) in /var/lib/omarchy/provisioning/luks-rekey.state. It reuses the owner's slot instead of adding a duplicate, retires LUKS1 slots too, and destroys the staged key only after the volume proves it opens nothing. Setup refuses to finish while any part of the provisioning unlock remains. The journal ends at phase=done and leaves together with pending, so a retry after the staged key is retired must use the password the disk already holds. Key checks exclude LUKS2 tokens: cryptsetup open tries an enrolled token (say a TPM2 slot a previous owner left behind) before the key and reports its slot whatever key it was given. Slot listings read only the keyslot section, since a luks2-keyring token looks like a keyslot in luksDump. * Change the root volume's LUKS key before the login and root passwords Installation gives the login user, root and the disk holding / one password, but omarchy-drive-password changed only the LUKS key, so the three drifted apart. For the disk holding / it now checks the current password opens the disk, changes the LUKS key, confirms the new key opens the disk and the old one no longer does, and only then sets the login and root passwords. Any other encrypted drive changes its LUKS key alone. Every drive now asks for the current password with gum instead of cryptsetup's prompt. A journal in ~/.local/state/omarchy/drive-password.state (disk UUID, key slots and phase, never a password, written with the re-key journal helpers) lets the next run finish an interrupted change. The rerun accepts only the old key (removing the slot the interrupted change added) or the new one (retiring the old slot), so another key such as a recovery passphrase never becomes the login password. luksChangeKey is judged by whether the new key opens the disk, not by its exit status, and key checks exclude LUKS2 tokens, which cryptsetup open would otherwise accept in place of any password. Runs are serialised, cryptsetup calls hold a disk lock, a / whose LUKS partition cannot be resolved fails closed, and passwords are never traced. A new password equal to the current one is refused, and --pbkdf argon2id is passed only to LUKS2, so LUKS1 drives can change their password again. * Add omarchy-lifecycle-dispatch for platform boot operations Provisioning, factory reset and update call a fixed set of operations through it. A platform whose boot chain the generic Limine path can't drive registers a directory of root-owned entrypoints in the dispatcher; every other platform gets a no-op and keeps its generic path. Apple Silicon is registered to omarchy-mac-boot's /usr/lib/omarchy/mac-boot, with its required operations failing clearly when the package or an entrypoint is missing. docs/lifecycle-dispatch.md is the contract, including how a Qualcomm boot package would plug in. * Let the platform's boot package own first-boot setup's boot chain and key slots Owner provisioning asks provision-prepare before the owner form, hands the staged unlock to provision-commit and provision-verify where the platform implements both, and records the kept LUKS slot through luks-slots before the staged key is destroyed; the disk password change records the owner's new slot the same way. On x86, Snapdragon and generic aarch64 every call is a no-op and first boot is unchanged. * Reset through the platform's boot package where it owns the boot chain Where a platform implements the reset operations, reset-prepare and reset-verify stage and prove the factory root's boot files, the throwaway slot is added only after that, and reset-commit gets the key on standard input once the factory root is active. A failure before the switch rolls the boot state back and revokes the slot. Elsewhere the Limine UKI path is unchanged. * Check the platform's boot chain after an update omarchy-update-boot runs update-verify after AUR packages, the last sudo-capable step, so it also covers the initramfs rebuilds they trigger. It follows third-party build code, so like AUR it authenticates through the no-update wrapper. A failed verification finishes the update's remaining steps, then exits non-zero without offering the reboot. Where the platform does not implement it nothing runs and nothing asks for root. * Hand a machine to its platform's migration through lifecycle dispatch migrations/1790347292.sh resolves the new optional migrate operation and, where the platform's boot package implements it, runs it as root. The entrypoint moves the machine onto the platform's official package set, idempotently and resumably; its failure leaves the migration pending. Once it succeeds, a root-owned marker spares other accounts the migration and its root prompt. Everywhere else, Macs without the boot package included, the migration is a no-op, and an undetermined platform keeps it pending. * Refuse packages tagged for another platform in a pacman hook A platform package names its platform with groups=('omarchy-platform-'), being what omarchy-hw-platform prints. omarchy-settings ships a pre-transaction hook, the guard it runs and a copy of the detector, so the guard is resident before the runtime and any platform package. The guard reads tags from the synced databases and refuses a transaction that installs a package tagged for another platform; a transaction without a tagged package passes without asking which machine this is. Image builds answer from the image's root-owned target manifest, as the detector does. The guard ships on aarch64 only: no platform package is built for x86_64, so x86 transactions run no hook. Hardware setup starts by proving the guard is resident and checking the packages an installer placed without it. On generic aarch64, where no platform packages exist, a missing guard is logged, not fatal, and on x86 it is expected. docs/platform-guard.md is the contract. Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com> * Enable Voxtype's Vulkan backend only when it is installed omarchy-hw-vulkan reports a Vulkan runtime, not whether this voxtype build has a Vulkan backend. The aarch64 voxtype-bin package ships only the CPU and ONNX binaries, so on any aarch64 machine with a Vulkan driver setup ran `voxtype setup gpu --enable` and printed "Vulkan backend not installed" in the middle of an otherwise working install. Require the backend binary too. x86_64 voxtype-bin always ships /usr/lib/voxtype/voxtype-vulkan, so x86 installs still switch to the GPU backend as before. Ported from omacom/omarchy-mac#483. Co-authored-by: Oliver Lukschander <33756270+oliverlukschander@users.noreply.github.com> * Link the system Widevine CDM into Brave The widevine package registers its CDM for Chromium and Firefox only. Brave looks for the CDM in its own install directory, finds nothing, and DRM sites (Netflix, Apple TV+, Spotify) fail with a blank player and no useful error. This hits machines where Widevine comes from that package, such as aarch64 ones, since no Widevine component is published for them. Link /opt/WidevineCdm/chromium into /opt/brave-bin and /opt/brave-origin-bin through as_root when Brave or Brave Origin is installed, and add a migration that repairs existing installs. Both do nothing when the widevine package is absent or the browser already has a CDM. A standard x86 install has no widevine package, so nothing changes there. Ported from omacom/omarchy-mac#474, extended to Brave Origin. Co-authored-by: Caleb Marshall <68087341+cajomar@users.noreply.github.com> * Report failed setup steps on the console With file logging, run_logged sends a failing leaf's output and its "Failed:" marker to the install log only, so the caller sees setup stop with an exit status and nothing else. A missing command (exit 127) was the case that made this hard to diagnose. Print the failed script, its exit status and the log path on stderr. Stdout logging mode already shows the failure marker and is unchanged, and successful steps stay quiet. Ported from omacom/omarchy-mac#389 (the logging part). Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Let a migration defer itself without stopping the update A migration that exits 75 changed nothing and cannot finish yet: omarchy-migrate leaves it pending with its login notification, runs the migrations after it and exits 0, so omarchy update carries on. Any other failure still stops the queue. The platform migration passes a deferred refusal of its entrypoint through, and defers itself where the dispatcher can't resolve its entrypoint (the platform can't be told, or the entrypoint isn't trusted): nothing has run then, so the update goes on to its boot verification, which fails there, as documented. * Keep Apple Silicon's systemd initramfs on the HOOKS baseline 00-omarchy-hooks.conf set the busybox line on every machine. On a Mac, omarchy-mac-boot's drop-ins then add the Asahi firmware hooks, but its encryption drop-in reads busybox encrypt as a Mac unlocked through cryptdevice= and leaves the line alone. The image came out as "... block encrypt asahi omarchy-vendorfw filesystems ...", with no systemd, sd-encrypt or omarchy-mac-encrypt, so an encrypted Mac could not unlock its disk. Reproduced against an M1 Pro's installed drop-ins. The baseline now asks omarchy-hw-platform. Apple Silicon starts from the systemd line its boot package builds on. A Mac whose own mkinitcpio.conf carries the asahi hook, or busybox encrypt, keeps that line, as a legacy install needs. x86, Snapdragon and generic aarch64 keep today's busybox line, as does a machine with no detector. The detector is the runtime's, or else the copy omarchy-settings ships for its platform guard, so a settings package newer than the runtime still places a Mac. A detector that cannot place the machine stops the build. The HOOKS test now composes the baseline on each platform and on legacy Mac lines. A new test composes it with omarchy-mac-boot 20260926-1.45's drop-ins, kept as fixtures, and checks the exact HOOKS the M1 Pro builds. The NVIDIA test pins its platform. * Hide optional installs that have no package on this machine Not every optional install exists on every architecture: some vendors ship no aarch64 build, and some repositories don't carry a package at all. Each Install row now carries a `when:` guard naming every package its install command adds, checked with omarchy-pkg-available, so a row with nothing to install disappears instead of failing halfway through a pacman transaction. The rows that build from the AUR (Chrome, Edge, Brave, Brave Origin, Zen, NordVPN) are guarded on the CPU architectures their vendors publish for. omarchy-pkg-available asks the sync databases: one `pacman -Slq` per batch, and `pacman -Sp` for a name that list doesn't hold, so a provided name (libappindicator-gtk3) and a version constraint resolve the way `pacman -S` will. The menu's guard prelude sources the same function, so the whole batch still costs one fork. omarchy-reinstall-pkgs reports and skips a default that no configured repository offers instead of failing the transaction. On x86_64 every guarded row resolves against the stable and edge repositories, so the menu is unchanged there. Tests derive each row's packages from its install command on x86_64 and aarch64 and fail when the guard names different ones. Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com> * Run 1Password with software GL where there is no render GPU Electron draws a blank window when the machine has no DRM render node: a VM, a headless or passthrough GPU, or a GPU whose driver isn't up yet (Apple M3 and later on Linux today). omarchy-hw-render-gpu tells whether a renderD node exists, and omarchy-cmd-electron-gl-args prints --ozone-platform=wayland --disable-gpu only when it doesn't. omarchy-cmd-electron-gl-wrap installs a marked PATH wrapper in /usr/local/bin that execs the real binary with those flags, so it follows the machine rather than the moment it was installed. It refuses to replace a launcher it didn't write, or to wrap a binary over itself, and stages the write so a failure leaves the old launcher in place. omarchy-cmd-desktop-exec-repair points the user's desktop entry at the wrapper by replacing only the executable token, keeping arguments, other actions and customizations, with a backup. The 1Password installer uses them when the machine has no render node. With one, which is every x86 machine with a working GPU, nothing changes. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Record with wf-recorder when gpu-screen-recorder can't start gpu-screen-recorder knows NVIDIA, AMD, Intel and Broadcom GPUs and aborts during GL init on anything else, such as Apple Silicon's, before a recording starts. Screen recording now falls back to wf-recorder, when it is installed, if gpu-screen-recorder is missing or exits before the file appears. The picker, file naming, notifications and post-processing are shared. wf-recorder records one audio device, so desktop plus microphone go through a temporary null sink that stop unloads, and its track is pinned to 48 kHz. Portal capture and --resolution stay gpu-screen-recorder only. The finalize pass keeps 48 kHz too: loudnorm declares 192 kHz output, so without -ar the AAC encoder wrote 96 kHz from a 48 kHz recording, which many editors decode as silence, and which on Apple Silicon makes the speaker safety daemon lock the amps. #11092 makes the same one-line change; whichever lands second drops it. Stop and status matched `pgrep -f ^gpu-screen-recorder`, which misses wf-recorder and matches any command line that starts with that path. The script now records the recorder's pid, and the new omarchy-capture-screenrecording-process checks or signals it by its executable, through a pidfd opened before the check so a reused pid can't be signalled. When that pid is no longer a recorder (a recording started before this change, or one that died), every recorder the user runs counts, as it does for the bar indicator and the Stop row, which use the same helper. Where gpu-screen-recorder works, which is every x86 GPU it supports today, it records as before. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: Issac Trotts <15530+ijt@users.noreply.github.com> * Hold logind's lid switch while a display it doesn't count is in use systemd-logind treats a closed lid as docked only when an external display is on a connector type it lists (HDMI, DisplayPort, DVI, VGA...). Displays on "USB" connectors, which Apple Silicon uses for USB-C displays, aren't on that list, and a missing comma fuses DVI-A and Composite into one entry. With such a display in use, closing the lid suspended a laptop that was still being used on it. omarchy-system-lid-inhibit holds a handle-lid-switch inhibitor while one of those connectors is connected and enabled in Hyprland, on a laptop where HandleLidSwitchDocked is ignore, which is exactly the case logind would have ignored had it counted the display. It releases the inhibitor when the display goes. A compositor or logind that doesn't answer changes nothing, since releasing then would suspend a closed lid. The inhibitor runs as a transient user unit bound to the monitor watcher's unit, so a watcher that dies can't leave the lid inhibited until logout. The monitor watcher runs it alongside the clamshell sync. On a machine whose external displays are all on connectors logind counts, it does nothing. * Keep the focused workspace in front when a monitor goes away When a monitor is unplugged, or the lid turns off the laptop panel, Hyprland first warps focus to whichever monitor it lists first and only then moves the removed monitor's workspaces over. The workspace you were on lands hidden behind that monitor's own (Hyprland 0.56.2, CMonitor::onDisconnect). monitor.focused fires before Hyprland records the new focus, so the monitor still reported as active is the one being left: its workspace is remembered until the event loop next turns, and a monitor.removed in that window focuses it again once the removal has finished. An open special workspace already comes along, and any focus or workspace change made before the restore runs wins. * Drive the keyboard backlight from the ambient light sensor Laptops with an ambient light sensor and a keyboard backlight LED now light the keys in the dark and dim them as the room brightens, inverse to display auto-brightness. omarchy-brightness-keyboard-auto polls the sensor every five seconds (preferring an IIO device that names itself ALS, and converting a raw channel with its offset and scale) and maps lux onto the LED, with a deadband so it doesn't chase noise. It pauses while the screen is locked or the lid is closed. A level set with the keyboard-brightness keys wins until the light changes enough that it no longer fits. omarchy-brightness-keyboard records each level it sets in the session's runtime directory (a private state directory without one), so the loop can tell keys turned off on purpose from keys left off by the lock screen or a restored zero, which it lights again. The user unit runs it in the graphical session. Its ExecCondition asks --available, so it is a no-op on machines without both a sensor and a keyboard LED. First run enables it, and a migration enables it for existing installs, writing the enable symlink itself when there is no live user manager. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: Gabe Ohlson <3355909+6abe@users.noreply.github.com> * Defer an image build's hardware setup to the machine's first boot An image is built away from the machine it will run on, so its hardware leaves would probe the builder. While the image manifest the detector already reads (/var/lib/omarchy/image/target) exists, omarchy-apply-hardware queues every leaf of install/hardware/all.sh instead of running it and arms omarchy-provision-hardware.service. On the first boot, before owner setup and the login screen, omarchy-provision-hardware retires the manifest and runs the queue on the real hardware: a failed step stays queued for the next boot (exit 75), and the initramfs is rebuilt once after the last step when a step changed its inputs or asked for it. Bluetooth starts its service on that boot, whose target is already up. Root ignores any environment override of these paths. Without a manifest, as on every ISO install, hardware setup runs exactly as before. Nothing here fetches package databases: an image carries its sync databases and every package its steps install. * Give an image-built root a UTF-8 locale when LANG is still C A root built from a distribution tarball instead of the ISO (Arch Linux ARM's ships LANG=C) never went through the ISO's locale step, so it ran non-UTF-8. The config phase now selects en_US.UTF-8, generating it if needed, but only when LANG is unset, C or POSIX: any named locale is somebody's choice and stays. The ISO writes the locale before this runs, so an ISO install is unchanged. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Let a platform's packages run their own boot, system and user setup Three dispatch operations give a platform one place for the setup it needs beyond Omarchy's, instead of inline platform branches in the setup leaves. The last hardware leaf runs setup-boot, then setup-system, as root, so an image build queues them with the others and its first boot runs them, with image-first-boot (offline-safe, asking for the one boot rebuild instead of building). setup-boot belongs to the boot package and is required on Apple Silicon, where hardware setup has no other way to run the Mac's boot setup; a failed setup-boot stops the leaf before setup-system. setup-user runs as the user being set up, refuses root, and gets only the user's home and session variables: from the last user leaf at finalization (skipped inside an image build, where the detector would read the build host) and again from first run once the session is up. On Apple Silicon setup-boot resolves in omarchy-mac-boot's directory and the other two, both optional, in omarchy-mac's, /usr/lib/omarchy/mac; x86, generic aarch64 and Qualcomm register none, so all three are no-ops there. A failed finalization now keeps first run pending, like any failed first-run step, instead of letting the other steps mark it complete. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Find batteries, lids and panels that aren't named like a PC's Laptop detection assumed PC names: a battery called BAT*, a lid in /proc/acpi, a touchpad or trackpad in the device name, a GPU backlight. Apple Silicon has none of them (macsmc-battery, the SMC lid switch, apple-mtp-multi-touch, apple-panel-bl), and some Framework and Chromebook firmware calls its battery CMB0, so those machines lost the battery panel, laptop-only services, lid handling, the touchpad toggle and display brightness. - Battery presence goes by type instead of name, and skips a battery scoped Device (a wireless mouse or keyboard), so a desktop with one isn't a laptop. - Battery status takes the first UPower battery that powers the machine, reads thresholds and cycles from it (BAT* stays the fallback), shows the magnitude of a signed power_now, and rounds the percentage half-up for display like the bar, while the charge-hold check keeps the unrounded value. - A laptop is also one with any input device that reports a lid switch (SW_LID), whatever its name: Apple's SMC input device has the same name on a Mac mini, where it reports none. Whether the lid is closed comes from ACPI where there is an ACPI lid, as before; otherwise logind is asked, with a short timeout, only when a device reports a lid switch. - A device named touchpad or trackpad still wins; only when there is none does the touchpad match take a mouse udev marks as a touchpad (Apple's multi-touch trackpad), never a touchscreen. - The platform package can describe its displays in /usr/share/omarchy-platform/displays.conf: backlights that never drive the panel (a Touch Bar's), the panel's own where there is no GPU backlight, and a display driver without DDC channels, where a probe walks unrelated I2C buses for about 3 s: there only a connector with a ddc node is probed, and a monitor without one reports no brightness instead of dimming the built-in panel. Omarchy ships no such file and names no device in it; without it the backlight choice and DDC probing are unchanged. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com> Co-authored-by: Sparsh Kumar <22640187+skkumarsparsh@users.noreply.github.com> Co-authored-by: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> * Keep the user's XDG config and state directories in platform user setup setup-user runs with an allowlisted environment, which dropped XDG_CONFIG_HOME and XDG_STATE_HOME. A platform's user setup reads the user's configuration and keeps its setup markers there, so for a user who sets either it looked in, and wrote to, the default directories instead. Both now pass through with the rest of the session; XDG_DATA_HOME and everything else still don't. * Let a platform package add Hyprland defaults of its own A platform's runtime package may need Hyprland defaults of its own (a Mac's capture keys and three-finger workspace swipe, say), and Omarchy's config should carry no branch for them. Three directories under the platform root, /usr/share/omarchy-platform, now take them. The one installed platform package owns that root, Omarchy reads it by literal path and ships nothing there, so without a platform package nothing changes. Each file is loaded by its path, so it can't shadow a module, and one that fails is logged and shown as a notification while the rest of the config loads. - hypr/defaults/*.lua loads before Omarchy's defaults. A chord bound there replaces Omarchy's default for it, and a decorator it adds runs before every later bind, so it can bind what has to run first. It sees the command a bind runs, including the command a shell shortcut such as { menu = "root" } stands for when it binds the shell's global shortcut. - hypr/settings/*.lua loads after Omarchy's defaults and before the theme, for settings that replace Omarchy's. - hypr/gestures/*.lua loads after the user's files, only for what must see them: the gesture registry, which now records on every platform, lets a default gesture step aside for the user's own. The user's files load after the first two and can unbind or rebind any of it. * Let a platform package name keys in the keybindings menu The keybindings menu shows a key by its keysym, which on some keyboards is not what the key says: on a MacBook, XF86MonBrightnessUp and Down are the F2 and F1 keys, so the menu listed rows like "SHIFT + XF86MonBrightnessUp". A platform package can now list names for such keys in /usr/share/omarchy- platform/key-names, one " " per line, and the menu shows those instead. The file is part of the menu cache's key, so installing or changing it refreshes the menu. Without it the menu is as before. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: kx0101 <90087463+kx0101@users.noreply.github.com> * Keep the top bar out of a panel's camera cutout A laptop panel with a camera notch at its top (Apple Silicon MacBooks, for one) covered the middle of the top bar, and a bar shorter than the notch left a sliver of every window beside the camera. Omarchy knows no panel's cutout itself: the platform's own package describes them in /usr/share/omarchy-platform/display-cutouts.json, by connector, physical mode and the rows the cutout covers. On a described panel a top bar is never shorter than the cutout, its center section sits beside the right one, and hiding it parks all of it; [bar] notch-height sets that floor by hand, on that panel only. Without a description, which is every machine without such a package, the bar is as before. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Keep PC and Intel Mac quirks off Apple Silicon A few hardware steps and commands written for PCs and Intel Macs do the wrong thing on Apple Silicon: - The Intel Mac Wi-Fi quirk turns off the Broadcom firmware supplicant for every Mac whose DMI vendor is Apple and whose chip is on its list. Apple Silicon reports Apple Inc. through U-Boot's DMI tables, and the M1 Pro and Max's BCM4387 is on the list, but there the firmware supplicant is the one that works: turning it off makes firmware commands time out and breaks scanning. The install step and its migration now skip Apple Silicon. - fnmode=2 for Apple-like keyboards is left to the Mac's own platform package on Apple Silicon, where the built-in keyboard's top row depends on it. - The SPI keyboard check reads the DMI product name without failing the hardware pass where there is none. - The Windows VM, an x86_64 guest under KVM, runs on x86_64 only, and direct boot (U-Boot finds the bootloader and makes its boot entries itself) refuses Apple Silicon; the menu hides both there. Hibernation setup skips it; its kernel has no hibernation today, and resume there is not wired. Everywhere else each step runs as before. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Let a platform's runtime package adjust the apps it needs to Some apps need a platform's own touch once they are installed. On Apple Silicon, Chromium-family browsers must decode video in software (the hardware decoder renders green frames in them), and Steam only runs through a FEX launcher, which then depends on Steam and must go before Steam can be removed. Until now the Mac runtime did this with inline branches in the installers. Two optional dispatch operations take their place. post-install runs as the user once an installer has installed the app and finished its own setup of it: every browser install (after its flags file, before it says the browser is installed) and Steam's (after the package, before the 32-bit drivers and the first launch). pre-remove runs before Steam's removal drops its packages. Where a platform registers them they refuse root (a platform with nothing registered is a no-op, so sudo installs still work there) and get the same home and session variables as setup-user, and a failure fails the install or stops the removal. On Apple Silicon they resolve in omarchy-mac's directory; every other platform registers none, so installs and removal run as before there. Steam's 32-bit driver step also stops failing the install on a machine with no Intel, AMD or NVIDIA GPU (Apple Silicon, but also Snapdragon or a VM): it has nothing to add there and now says so. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Ask the platform's boot package before an update takes over its files When an Omarchy package's upgrade stops only on files no package owns, the update moves them aside and retries. On a Mac some of those files belong to its boot chain or were written by its image (initramfs and pacman configuration, say), and moving them aside can leave it unable to boot or update. The Mac runtime refused those paths with an inline list. update-takeover ... asks the platform's boot package instead, before anything moves: read-only, exit 0 lets the takeover go ahead, anything else keeps every file and the update fails as it did before the retry existed. The update resolves it as the user and runs it with sudo only where it resolves, inside the authorization the move needs anyway. On Apple Silicon it is required: a Mac without its boot package, or with one too old to answer, keeps its files. Every other platform registers none, so the takeover goes ahead as before, asking no one. * Install the Mac's package set on Apple Silicon omarchy-pkg-defaults already composes install/omarchy-.packages after the base and aarch64 lists, but Apple Silicon had no list, so a Mac got the generic aarch64 set: no omarchy-mac or omarchy-mac-boot, whose entrypoints the dispatcher requires there. install/omarchy-apple-silicon.packages names the Mac's runtime and boot packages, plus the defaults an owner may remove: the startup-volume tool, hardware video decode, Widevine, and wf-recorder, since gpu-screen-recorder can't capture on Apple Silicon and screen recording falls back to it. What a Mac can't work without (its speaker stack, PipeWire's PulseAudio server and realtime scheduling, the GPU's Vulkan driver) comes as omarchy-mac's dependencies, so a new Mac driver needs no Omarchy change. Other platforms compose exactly what they did. Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> * Give existing aarch64 machines the zram swap Omarchy configures omarchy-settings configures swap on zram and tunes memory reclaim for it. x86_64 gets zram-generator from its installer and new aarch64 installs from their default packages, but an aarch64 machine installed before omarchy-aarch64.packages named it has no generator, so the configuration asks for a device nothing creates. Migration 1790328426 installs zram-generator where the platform's default packages name it and it is missing, and starts the zram swap wherever it is down, unless no device is configured (then the generator makes no swap unit) or the administrator masked it. A swap that won't start, or whose unit can't be read, only warns: the generator's unit starts with every boot, so a reboot retries it, and failing the migration would stop every update. A failed install waits (75) for the next migrate, as it does where the platform can't be told, changing nothing. It keeps the name it has in omarchy-mac, so Macs that already ran it skip it. It also retires an /etc copy of the drop-in identical to the packaged one, which earlier Mac setups made and which would hide the packaged file's updates; an edited copy stays. On x86_64 the default packages don't name the generator and there is no copy, so it does nothing. * Keep an ARM machine's repositories when it changes channel omarchy-refresh-pacman and install finalization copied x86_64 templates everywhere, so on an aarch64 machine a channel change or a finished install wrote Arch Linux's x86 [core]/[extra]/[multilib] and dropped Arch Linux ARM and the platform's own repositories. Channel changes now tell the platform apart. x86_64 is unchanged. On aarch64 the machine's configuration is kept, with its repositories, mirrors and signature policies, and only the [omarchy] server moves to the new channel; a configuration whose Omarchy server can't be identified is refused unchanged, and one with no Omarchy repository gets it at the end (not on Apple Silicon, whose own repository must not come first). Only pacman.conf is backed up and rewritten, straight from this process through sudo, never through a file another process could change first. Before that, a channel must be qualified for the platform: a signed upgrade and reboot tested there, not only a published repository. x86_64 keeps stable, rc and edge; Snapdragon and other aarch64 machines take edge, as dragon does; Apple Silicon takes none until the Mac packages qualify on edge. The refresh then checks, as the user, that the channel's omarchy.db publishes the runtime pair, and on Apple Silicon omarchy-mac and omarchy-mac-boot. omarchy-channel-set refuses an unqualified channel before anything else, the dev confirmation included. Install finalization writes a whole template, as before on x86_64. Other aarch64 platforms get Arch Linux ARM (dragon's pacman-aarch64.conf and mirrorlist-aarch64, byte for byte) with its keyring, plus Omarchy on a qualified channel. Apple Silicon gets omarchy-mac's template on a qualified channel and keeps its image's configuration otherwise. In an image build, finalization leaves the keyring to the machine's first boot, so machines flashed from one image don't share a pacman master key. On an unqualified channel, a machine that gets no Omarchy repository is told why. Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Co-authored-by: Jim Martin <1334144+jdvmi00@users.noreply.github.com> * Keep Arch Linux ARM's signing keys current on ARM machines omarchy-update-keyring reinstalls archlinux-keyring before every system upgrade, since keys change without a version bump. An aarch64 machine's Arch Linux ARM repositories are signed with keys from archlinuxarm-keyring, which nothing refreshed, so a rotated ALARM key failed the upgrade that followed. On an aarch64 platform where archlinuxarm-keyring is installed, it is reinstalled in the same transaction. On x86_64 the keyring step runs exactly as before, even where that package happens to be installed. * Mark the runtime's platform files as deciding at runtime The omarchy-settings recipe leaves the mkinitcpio, Limine, memory and USB drop-ins out of its aarch64 package, because they used to assume x86_64. That leaves an aarch64 install without the HOOKS baseline its initramfs needs, and without the zram and reclaim settings every other Omarchy machine gets. Each of those files now decides at runtime whether it applies: the HOOKS baseline asks the platform, thunderbolt is added only where the kernel has the module, the Limine files are read only where Limine is installed, the oomd drop-ins are inert without systemd-oomd, aarch64 installs the zram generator the zram drop-in needs, and the USB autosuspend option is ignored by a kernel that builds usbcore in. default/settings-runtime-profile says so, file by file; a recipe that finds it ships the same files on every architecture (omacom/omarchy-pkgs#638), and older sources keep today's aarch64 package. A test checks that every file it names exists and that each runtime decision is in place. * Set the wireless regulatory domain from the owner's timezone at first-boot setup A deferred install (an Apple Silicon image, an OEM ISO install) runs its hardware steps before the owner picks a timezone, on the UTC placeholder, which names no country, so install/hardware/set-wireless-regdom.sh leaves the regulatory domain unset and the radios stay on the world domain. Owner setup now runs that step again once the owner's timezone is set, and applies it to the radios through wireless-regdb's helper, since no reboot follows setup. An up-front install, which picks the timezone before its hardware steps, is unchanged. * Ask again for a keyboard layout first-boot setup could not apply First-boot setup kept going when the chosen layout did not take: loadkeys errors were ignored, a keymap localectl did not list kept the default, and a failed or partial write of /etc/vconsole.conf was only logged. The owner then typed the disk password in one layout while the boot files asked for it in another, and could only unlock the disk by typing US-style. The keyboard step now fails closed. On a console loadkeys must succeed, the keymap must be in localectl's list exactly (a list localectl cannot produce counts as a failure too, logged apart), and after systemd-firstboot or the localectl fallback, /etc/vconsole.conf is read back in a clean shell and must name the chosen keymap with an XKB layout. Any failure is logged, the owner is told the layout could not be set, and the layout is asked for again, so the password form is never reached with a layout that did not take. * Refresh a platform's own signing keyrings before an update omarchy-update-keyring reinstalls archlinux-keyring (and Arch Linux ARM's on aarch64) before the system upgrade, so a rotated key never fails that upgrade's signature checks. A platform's own repository has a keyring of its own that nothing refreshed: on Apple Silicon, asahi-alarm-keyring for [asahi-alarm]. A platform package now lists those keyrings in /usr/share/omarchy-platform/keyrings, one package name per line, and omarchy-update-keyring reinstalls each one that is installed in the same transaction. It adds no repository the machine didn't already trust. A line that isn't one keyring package name is skipped with a warning, so a mistake in the list can't stop every update. Without the file, as on x86 today, the step runs as before. * Drive an untyped default input from the audio panel Quickshell types only exact media classes, so a virtual source (an Audio/Source/Virtual: EasyEffects' source, or the microphone mapping a platform package adds) has no PwNode.audio. With one as the default input the audio panel showed the input at 0% and its slider changed nothing, the peak meter stayed flat, the microphone widget read it as muted, and the panel could not list it unless it was already the default. Such an input now goes through wpctl for its volume and mute (UntypedInput in shell/Commons, one reader and one pactl subscribe for the whole shell), the panel meters it with the new omarchy-audio-source-level, and the input list takes an untyped node PulseAudio lists as a source. A platform whose audio runs through its own processing can also name nodes that are neither devices nor apps in /usr/share/omarchy-platform/audio.json, which Omarchy ships none of: the panel and the microphone widget leave them out, and a "replaced" node only while its replacement exists. Without the file nothing is hidden. On every machine: the input list leaves out a source whose ports are all unavailable (an empty headset jack) unless it is the default, as the output list does for sinks; choosing an input no longer moves a filter's own capture (a source output with node.link-group), which would feed the filter from the new input or from itself; the shell's own level meters no longer light the microphone widget; and omarchy-audio-sink-availability reads pactl in the C locale for sinks too, since "not available" is translated. * Give first-boot setup an XKB layout for keymaps systemd cannot map systemd-firstboot writes an XKB layout only for keymaps listed in kbd-model-map. Nine the setup form offers are not (Colemak, Azerbaijani, Bulgarian, Czech, Swiss German, Kyrgyz, Norwegian, Polish, Ukrainian), so it wrote KEYMAP= alone, the readback refused it, and owner setup could never get past the keyboard step for them. Setup now adds the XKB settings itself when firstboot left none, matching the console map so the shared password types the same on the desktop: kbd's cz is QWERTY, bg-cp1251 phonetic and azerty French AZERTY, and non-Latin layouts keep a US layout to switch to, as kbd-model-map's own rows do. A test checks every layout the form offers ends with an XKB layout. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Leave keys a firmware key turned off dark The ambient keyboard backlight treated any 0 the keyboard-brightness keys had not recorded as an accident and relit it within five seconds. Firmware Fn keys (Fn+Space on ThinkPads and Framework laptops, Fn+F10 on Dells) write the LED directly, so on those laptops turning the keys off never stuck. The lock screen's off now records a blank, and only that blank or a 1% leftover is taken back; any other off is a choice that pauses auto like the keys do. A wake clears the blank only once the restore succeeded, the command runs one at a time so a blank racing a wake can't leave the LED and record disagreeing, and the loop drops a blank once it has taken the keys back. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Drop the optional-install availability guards Each menu open checked every Install row against the sync databases, which costs most of a second of pacman work on every machine to hide rows only an ARM machine is missing. The fix for those is publishing the ARM packages, not hiding the rows, so this removes omarchy-pkg-available, the Install rows' availability and architecture guards, and reinstall's skipping of defaults no repository offers. The Windows VM row keeps its x86_64 guard, since no package fixes a guest that needs an x86_64 host, and reinstall keeps its aarch64 channel handling. This reverts commit aea001cbde4d0852b311ef42b5e8c380c719f833. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Put the current root back when the factory root cannot take its place Factory reset moved the running root aside, marked the reset committed, then renamed the staged factory root to @. If that second rename failed, cleanup saw a committed reset: it skipped the boot package's rollback and kept the throwaway LUKS slot, and since the swap had not happened it deleted the staged root as well, leaving nothing at @. The reset now commits only once the factory root is at @. A failed activation renames the old root back first, then cleanup rolls the platform's boot state back and revokes the throwaway slot. If that rename fails too, both roots are kept, cleanup still rolls back and revokes, and the error names the rename that recovers the machine. The generic Limine path has already rebuilt its boot files from the factory root by then, as it had before this change. * Leave other recorders alone when the saved recorder pid is stale A saved pid that no longer named a recorder made status and stop select every gpu-screen-recorder and wf-recorder the user ran, so stopping could interrupt, and after five seconds kill, a recording started somewhere else. A stale pid now means no recording of ours is running: the toggle starts a new one, and --stop-recording finds nothing to stop. Stop acts on the recorder the status check selected rather than selecting again. Without a saved pid, as for a recording started before the pid was saved, every recorder the user runs still counts. * Name the audio a wf-recorder recording could not capture With desktop and microphone audio both requested, wf-recorder records a null sink fed by two loopbacks, and a loopback that failed to load was ignored: the recording went ahead without that source, or silent when both failed. A missing null sink or default device dropped audio just as quietly. A source that cannot be captured is now left out with a notification naming it, the loopbacks that did load are unloaded, and the recording carries the source that remains, or no audio track at all. * Show the Edge, Spotify and Dropbox installers on x86_64 only These vendors currently publish Linux builds for x86_64 alone (the AUR's microsoft-edge-stable-bin and spotify are arch=('x86_64'), and Dropbox lists no Linux ARM client), so no native aarch64 build is available and choosing one on an ARM machine only fails. Like the Windows VM row they keep a uname check, which the menu answers once per open without asking pacman. The other Install rows are unchanged. * Revoke the generic reset's throwaway slot when the reset fails The generic Limine path added its throwaway LUKS slot without recording it, so a reset that failed before activation never revoked it. Once the factory root's UKI was on the ESP, a failed activation that put the old root back left a machine that booted the factory kernel and unlocked unattended with a key readable on the ESP, while the screen said the current root was back. The generic slot is now recorded like the platform's and revoked by cleanup whenever the reset fails before the factory root is active. A failed activation on that path says the boot files were already rebuilt from the factory system and to run limine-update before rebooting. * Clear a stale recorder pid when a recording starts The recorder's pid is saved only once its file exists. Until then a pid left by a recording that ended without a stop still counted as stale, so pressing the toggle again while the new recorder started found nothing of ours and could start a second recording. Starting a recording now clears that pid, and during startup the toggle falls back to the user's recorders as before. * Word the dropped-audio notice for every reason a source is left out The notice said the dropped source "could not be captured", which was wrong when the null sink itself failed to load: the microphone was never tried, and desktop audio recorded alone as it always has. It now says the source could not be added to the recording, true for a missing device, a failed loopback and a failed null sink alike. * Show the Heroic and Minecraft installers on x86_64 only Heroic publishes Linux builds for x86_64 alone (its arm64 builds are for macOS and Windows), and Mojang's Linux launcher is a single x86_64 tarball. They join Edge, Spotify and Dropbox under the same uname check, so every row whose vendor ships no native Linux aarch64 build is treated alike. * Ask the recorder script whether its stop has a recording to end The bar indicator and the menu's Stop Screenrecording row asked the process helper whether any recorder the user runs was alive, while the toggle's stop acts only on the recording it saved. With a stale saved pid alongside an unrelated recorder, both offered a stop that did nothing. omarchy-capture-screenrecording --status answers with the selection the stop uses, and both now ask it. Status needs no recordings directory and never notifies, so the recordings-directory check moved below argument parsing and skips it. * Copy every platform's pacman templates into place, as x86_64 does On aarch64 a channel change edited the machine's pacman.conf in place: an awk pass found the [omarchy] server and rewrote its channel, appended a section where there was none, checked the channel's omarchy.db first, and gated it all on a per-platform table of qualified channels. Install finalization appended Omarchy to a template, or on a Mac copied one bundled inside omarchy-mac. What a machine ended up with was never in the repository. Each platform now has a pacman.conf and mirrorlist per channel, copied whole: x86_64's stay in default/pacman, Snapdragon and other aarch64 machines use default/pacman/aarch64, and Apple Silicon default/pacman/apple-silicon, with Omarchy and Asahi ALARM ahead of Arch Linux ARM. Omarchy publishes aarch64 packages on edge alone so far, so every ARM channel points at edge for now and channel changes work there; moving a channel is a template edit. A channel without both files is refused before anything changes, including a dev checkout's own templates. Reinstall goes back to resetting to stable, and the update confirmation loses the missing-repository notice it no longer needs. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Revert "Copy every platform's pacman templates into place, as x86_64 does" This reverts commit 9a07e138cc6c3a19b58d2f3d7eb536dd3042ccc5. * Copy every platform's pacman templates into place, as x86_64 does On aarch64 a channel change edited the machine's pacman.conf in place: an awk pass found the [omarchy] server and rewrote its channel, appended a section where there was none, checked the channel's omarchy.db first, and gated it all on a per-platform table of qualified channels. Install finalization appended Omarchy to a template, or on a Mac copied one bundled inside omarchy-mac. What a machine ended up with was never in the repository. Each platform now has a pacman.conf and mirrorlist per channel, copied whole: x86_64's stay in default/pacman, Snapdragon and other aarch64 machines use default/pacman/aarch64, and Apple Silicon default/pacman/apple-silicon, with Omarchy and Asahi ALARM ahead of Arch Linux ARM. Omarchy publishes aarch64 packages on edge alone so far, so every ARM channel points at edge for now and channel changes work there; moving a channel is a template edit. A channel without both files is refused before anything changes, including a dev checkout's own templates. Reinstall goes back to resetting to stable, and the update confirmation loses the missing-repository notice it no longer needs. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Codex XHigh * Drop the migrations no installed machine needs Fresh installs mark every migration done, and every Apple Silicon machine is a fresh install, so the Mac handoff to omarchy-mac-boot (and the migrate operation it called), zram for aarch64 installs that predate the package list, the Brave Widevine link and the Broadcom migration's Apple Silicon guard never run anywhere. The exit-75 deferral in omarchy-migrate existed only for two of them. Keyboard backlight auto stays on new installs through first run rather than switching on for existing ones, and migration 1786605598 goes back to quattro: the mkinitcpio drop-ins arrive by package. Co-Authored-By: Claude Opus 5.5 * Drop the generic changes Mac support doesn't need The pacman platform guard (and the detector copy omarchy-settings shipped for it, which the HOOKS baseline fell back to), the lint against uname gates, first run staying pending after a failed finalization, run_logged echoing failures to stderr, half-up battery rounding, hiding empty-jack inputs and leaving a filter's capture alone when choosing an input all rode along with the Mac work without serving it. Each goes back to quattro's behaviour and can come back on its own merits. Co-Authored-By: Claude Opus 5.5 * Drop the platform Hyprland slots A platform package could load its own Hyprland binds, settings and gestures: binds that replaced Omarchy's chords and decorated every later bind, settings ahead of the theme, and gestures after the user's files, which needed every hl.gesture call wrapped so a default could step aside. On a Mac those carried macOS habits (a three-finger workspace swipe, tap-to-click off, menus opening on the built-in screen, other screenshot and keyboard-backlight chords), not anything the hardware needs, and Omarchy should behave the same on every machine. The one bind a Mac does need, its lid switch under the SMC's name, joins Omarchy's own lid binds. Co-Authored-By: Claude Opus 5.5 * Keep aarch64 machines off the release channels until a release supports them Every aarch64 channel's template pointed at the edge repository, but a switch to stable or rc installs the release-named omarchy and omarchy-settings. On edge/aarch64 those are the 4.0.4 release line, which has no omarchy-hw-platform, lifecycle dispatch or omarchy-pkg-defaults, so `omarchy-channel-set stable` on a Mac replaced the quattro runtime it runs with one that cannot run it, and succeeded. aarch64 platforms now have edge templates alone, so a switch to stable or rc is refused before anything changes; adding those templates once a release supports aarch64 opens them. Refresh with no channel named, as reinstall runs it, and install finalization for a channel the platform has no templates for take the platform's default channel: stable on x86_64, edge on aarch64. * Leave a filter's own capture alone when choosing an input again d0b94d98c returned omarchy-audio-input-set-default to moving every source output, as not serving Mac support. It does: asahi-audio's microphone DSP on Apple Silicon records the raw array through a filter-chain capture stream (audio_effect.jNNN-mic, node.link-group "filter-chain-..."). On an M1 Pro, choosing the Mac's microphone in the audio panel moved that stream onto the DSP's own output, and the microphone recorded digital silence (-91 dB, from -55 dB of room noise) until PipeWire restarted. With the skip it keeps recording (-54 dB). This restores the skip of source outputs carrying node.link-group, and its test, as they were before that commit. * Say aarch64 offers edge alone where the templates are described The aarch64 edge templates, the template helper and the update-process docs still said every platform has a template per channel and every ARM channel installs from edge, which the previous commit made untrue. * Point the Mac packages at omacom/omarchy-mac-pkgs omarchy-mac and omarchy-mac-boot now live in omacom/omarchy-mac-pkgs, with each package at the top level. The dispatcher, its documentation and the Apple Silicon package list name that repository, and the drop-in test's fixtures, byte for byte the same as omarchy-mac-boot/files/etc/mkinitcpio.conf.d at 4399105, cite it there. * Stop naming a platform migration among the dispatcher's callers The migrate operation went with the Mac migrations, and no caller hands a machine to a platform migration any more, so the header lists only the flows that still dispatch. The optional-operations comment is rewrapped. * Keep the Intel Mac Broadcom migration off Apple Silicon again A fresh install marks every migration done only for the user it creates, so an account added later runs them all on its first update. On an Apple Silicon Mac this one matches: an M1 Pro reports sys_vendor "Apple Inc." and a BCM4387 (14e4:4433), so it would append feature_disable=0x82000, which stops that chip scanning once brcmfmac reloads. Restore the guard and its test case. * Say when Steam's post-install hook runs now that the drivers go first Steam's installer now adds the 32-bit drivers before Steam itself, so the hook runs after both and before the first launch. * Release fallback audio after the recorder exits When wf-recorder ends outside the normal Stop path, status rejects the saved PID before unloading the combined-audio loopbacks. Release those owned modules when the saved recorder is gone, so the next status or stop call ends microphone capture without signaling another recorder. Leave a mix without a saved PID alone: its recorder can still be starting. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Codex Medium * Serialize recording state changes and stale audio cleanup Status can finish checking an exited recorder after another command has replaced its audio modules, and stop finalization can outlive the recorder while a new recording starts. Share a lock across start, stop and cleanup so each command finishes updating its resources before another changes them. Status uses a read-only check when the lock is busy, keeping the bar responsive during the picker. Close the lock descriptor in persistent children so recording and preview cleanup cannot retain it. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> * Keep automatic and manual keyboard brightness updates together A brightness key can update the LED and its chosen-level record after the automatic loop checks for a manual change but before it applies a new target. The automatic write then overwrites the user's choice and deletes the record. Take the manual command's lock for the whole tick, skipping a busy tick and releasing the descriptor on every return. Create the private fallback directory before opening the lock outside a session runtime directory. The normal lock screen waits five seconds before blanking, so an ordinary tick should already see the session locked. The regression exercises a brightness key arriving within a tick instead of relying on an implausibly delayed lock-state check. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Read the split hook baseline in the NVIDIA migration The NVIDIA firmware migration decides whether to rebuild by sourcing the hardware and filter drop-ins. With HOOKS now defined in the earlier baseline, that partial read treats every hybrid machine as if kms had been removed. Source the new baseline first when it exists, keeping the legacy two-file layout working and skipping rebuilds when the effective hooks still include kms. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Use a hardware helper for x86-only menu entries Keep the x86_64 availability checks for Windows and vendor-specific installers in a hardware predicate, so menu conditions use the same command style as other hardware checks. Co-Authored-By: GPT-6 in Codex * Keep restored keyboard levels from pausing automatic brightness Idle wake, quick unlock and hibernate can restore a saved level older than the ambient loop current level without the lock blanking first. Treat that restoration as a one-tick event, preserving existing manual pauses and the normal blank-and-restore path. Consume unmatched and unchanged restore records too, so a later firmware key cannot look like the earlier restore. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Preserve restored keyboard levels across a later lock blank The ambient loop skips a locked session, so a restored level may still be unconsumed when the next blank saves it. Carry that origin through blanking and restore failures so fingerprint unlock cannot make the old saved level look like a manual choice. A brightness key replaces the marker with its chosen level and retains the ordinary blank behavior. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Preserve firmware brightness choices before a lock blank Firmware keys change the LED without updating the record. Carry a pending restoration through the next blank only if the observed LED still matches that restoration, or is still zero from its previous blank. A firmware change otherwise follows the ordinary blank-and-restore path so automatic lighting pauses on the chosen level. Co-Authored-By: GPT-6 in Codex Co-Authored-By: Claude Opus 5.5 High via Pi * Change only the disk password in omarchy-drive-password Changing the system disk's password also set the login and root passwords to match. That changes account-password policy on every platform, x86 included, and overwrites passwords someone chose to keep different, so it moves to its own change with an explicit confirm. The disk change itself stays as it was: the new key must open the disk and the old one must not before the change counts, the journal finishes an interrupted change, and where the platform's boot checks prove the key slots, the owner's new slot is recorded before the run finishes. The journal's confirmed phase is now "record" and no longer needs a login user. * Cancel a pending recording start instead of queueing behind it Starting a recording holds the recorder lock through the picker and until the recorder creates its file. A second toggle or a stop then waited on that lock, with no end while a portal pick or a stuck recorder never finished, and once the start gave up the queued toggle began another recording. While a start is pending, a toggle or stop that cannot take the lock within a second now leaves a cancel marker and returns. The start checks it after the picker, after the webcam overlay and while waiting on its recorder, then stops that recorder and releases its audio. A recorder other than the portal's that has not created its file after 15 seconds is stopped and the next backend tried. * Move automatic keyboard lighting to its own change Ambient-light keyboard backlight is a new feature on every laptop with the sensor, not something Apple hardware needs to run Omarchy, so it leaves this change for a separate review. The keyboard-brightness command returns to upstream's version, and first run no longer enables the user service. * Move workspace refocus on monitor removal to its own change Keeping the viewed workspace in front after a monitor is unplugged changes desktop behaviour on every machine, not just Apple hardware, so it leaves this change for a separate review. * Move prebuilt-image hardware deferral to its own change Building an image away from its machine needs a manifest the detector reads, a queue of hardware steps for the first boot, a first-boot service and a per-machine pacman keyring. An install on the machine itself needs none of it, so it leaves this change for a separate review with an installer and package boundary of its own. Hardware and user setup now run where they are installed, and the platform setup operations take no image argument. * Move the Qualcomm platform to its own change Snapdragon support is separable from Apple compatibility, so the detector no longer names a qualcomm platform: a "qcom," device tree is generic aarch64, which Snapdragon already behaves as everywhere else here, and a tree naming both Apple and Qualcomm is still refused. linux-firmware-qcom's platform list leaves with it. * Keep a cancel that arrives while a recording start finishes A stop could land after the start's last cancel check, while it saved its recording as started, and be dropped with the marker. The start now removes its starting marker before a final check, a request reads that marker only after leaving its own, and one that finds the start gone waits for the lock and acts unless the start already used its request. A recorder ended by a cancel or a readiness timeout gets two seconds after TERM before KILL, so a recorder ignoring TERM cannot hold the start and its lock. * Defer an image build's hardware setup to the machine's first boot Brings back prebuilt-image support split out of #14431 so it gets its own review. An image built away from its machine names its target in a root-owned manifest the detector reads; hardware setup is queued for the machine's first boot, run there by omarchy-provision-hardware, which also makes the machine's own pacman keyring; and the platform setup operations learn they run on an image's first boot. Apple Silicon images built by the omarchy-mac installer need this; an install on the machine itself does not. * Rebuild the initramfs when deferred setup changes module options mkinitcpio's modconf hook copies /etc/modprobe.d into the initramfs, but the deferred first-boot setup only compared mkinitcpio's configuration and the kernel command line when deciding whether to rebuild. A step that only wrote module options, as nvidia.sh does, left the old ones in the boot image. * Put the Omarchy repository first on aarch64, as on x86_64 quattro now searches [omarchy] before the Arch repositories (34016cfb2), and its migration 1791403252 moves the section first in /etc/pacman.conf on every machine. The aarch64 edge template still listed it last, so refreshing pacman on a generic aarch64 machine would undo that migration. Apple Silicon's template already lists it first. * Refuse disk password changes that recovery could not settle An interrupted change to the system disk is finished from the one key slot the password opened. If the old password also sat in another slot, a LUKS2 change made in place and stopped before the old key was retired let the rerun accept the new password as the old one and finish while the other copy still opened the disk. A system disk whose current password opens more than one slot is now refused before anything changes, naming the extra slot to remove and, where the platform records key slots, how to record the ones kept. The journal now notes that check (checked=1). A change journaled without it, by an earlier version, on a disk that has other key slots may still hold a copy of the old password, and no password entered now can show which slot is whose, so it is left for the owner to settle by hand, in every phase, with the commands to do it. On a disk with one slot it settles as before. A new password that already opens a key slot is refused on every drive too: the new key's slot is how the change is followed, and an existing copy made the run take the wrong slot as the owner's. A settled interrupted change now says the disk opens with the password just entered, which is true whether it finished the change or rolled it back. * Ignore the start marker of a recording start that was killed A start killed before it removed its marker left it behind, and a later toggle that had to wait for the lock took that marker for a start still in progress and exited without doing anything. The marker now holds the starting process's pid and counts only while that process runs. * Say why generic aarch64 searches Omarchy's repository first Putting [omarchy] ahead of Arch Linux ARM follows quattro's x86_64 order and its migration 1791403252, which already reorders existing aarch64 machines. It means a package Omarchy also publishes now comes from Omarchy, and a refresh can move it there, downgrading it if Omarchy's build is older. The update-process notes and the template now say so. * Keep a working key in the manual settlement steps When an earlier version's interrupted change has to be settled by hand, the steps now start by finding and keeping the slot of the password to keep, test the other slots with passphrases only, never remove the kept slot, and record a Mac's slots before the journal goes. Before, removing every slot the old password opened could remove the only key when the change had not landed. * Keep Superwhisper's x86_64-only default off aarch64 quattro now ships Superwhisper as the default dictation backend and puts superwhisper-bin in the base list, but it has no aarch64 build. On an aarch64 machine omarchy-reinstall-pkgs and omarchy-upgrade-to-quattro asked for a package no repository carries, Setup > Defaults offered an installer that cannot work, new users were preset to that backend, and the dictation keys, F9 and Right Alt (AltGr on many layouts) included, were bound to it. install/omarchy-x86_64-only.packages names the base packages only x86_64 builds. omarchy-pkg-defaults leaves them out of every aarch64 platform's set, and omarchy-upgrade-to-quattro out of the base list it installs there. The Superwhisper default row shows on x86_64 only, like the other x86_64-only rows. User setup on aarch64 removes the shipped Superwhisper preset, so a new user starts with no backend and picks one; any other selection stays. The dictation keys are bound only once the selected backend's own program is installed, which changes nothing where it is. * Install the stub dictation backend where binding tests expect its keys The dictation keys are now bound only once the selected backend's program is installed. The binding-conflict check selected Voxtype without providing it, so it no longer saw the dictation keys and failed its F9 check; the default-config check now also covers a selected backend that is not installed. * Make the stale start marker test's dead pid reliably dead The test killed a backgrounded sleep to get a pid that no longer runs. When the signal landed before sleep started, wait never returned and the suite timed out, about one run in three. A process that exits on its own gives the same dead pid without the race. * Drop the virtual input level meter the panel ran through a helper Volume and mute for an untyped input still go through wpctl; typed inputs keep Quickshell's peak monitor. Quickshell's monitor takes typed nodes only, so the panel hides the level bar for a virtual input rather than showing an empty bar that reads as a silent mic. * Find recording processes with pgrep instead of a Python /proc scan pgrep matches the kernel's 15-character process name, so gpu-screen-recorder is matched as gpu-screen-reco. That prefix also lets any gpu-screen-reco* executable, or a process that renames itself, pass for a recorder, so each match is confirmed against its /proc exe link, ignoring the " (deleted)" suffix an upgrade leaves. --signal re-checks that link just before signalling each recorder, which narrows the pid-reuse window the old pidfd-based scan closed, and exits 1 when no signal was delivered. --pid rejects values above the Linux pid limit instead of letting bash arithmetic wrap them. * Leave 1Password's software GL wrapper to its own change This reverts f200105c2; the render-GPU check, Electron GL wrapper and desktop entry repair return in a separate branch. * Keep Gliff's x86_64-only package off aarch64 quattro added gliff to the base list and a migration that installs it, but its recipe builds for x86_64 alone. It joins Superwhisper in install/omarchy-x86_64-only.packages, so aarch64 package sets leave it out, and the migration installs it on x86_64 only. * Stub accessory enrollment in the first-boot re-key test Owner setup now enrolls the owner's USB devices and Thunderbolt accessories after the staged LUKS key is gone, and the re-key test's provisioning run reached that step with nothing to answer it. It now stubs both and checks that enrollment runs once, after the staged unlock is retired. * Keep USB authorization from boot off Apple Silicon Blocking USB from the start of kernel boot writes Limine's entry-tool drop-in and checks the Limine menu and UKI directly, but a Mac's boot chain belongs to its boot package. omarchy-setup-security-usb-authorization --boot is refused there before anything changes, and Setup and Remove > Security hide USB at Boot. The boot helper itself refuses enable on Apple Silicon and treats disable as done when nothing is configured, so turning USB device authorization off still works on a Mac; a boot-time setting configured some other way is left with steps to remove it by hand rather than reported removed. USB device authorization after login is unchanged. * Skip fixture-only checks when the shell suite runs as root Root's locale step always writes the real /etc/locale.conf and locale.gen, and root's platform check always reads this machine, so their fixtures do not apply there. The locale test now stops after its static checks as root instead of changing the runner's locale, and the Apple Silicon refusal in the USB authorization test runs unprivileged only. * Fetch aarch64 packages from Omarchy's HTTPS Arch Linux ARM mirror * Name platforms x86, aarch64 and aarch64-apple omarchy-hw-platform now prints the CPU architecture, then the hardware family when it needs handling of its own: x86, aarch64 (every ARM machine without a family) and aarch64-apple (Apple Silicon). A later family such as aarch64-qualcomm or aarch64-n1x follows the same form, so aarch64* always means ARM. The Apple predicate is now omarchy-hw-aarch64-apple; omarchy-hw-apple-silicon stays as a hidden alias while omarchy-mac's packages and units call it. The Apple package list and pacman templates move to install/omarchy-aarch64-apple.packages and default/pacman/aarch64-apple/. Image manifests and omarchy-pkg-defaults still accept apple-silicon, generic-aarch64 and generic from builders written before the names settled, and the mkinitcpio baseline accepts a detector that still says apple-silicon while omarchy-settings and omarchy update apart. * Add omarchy-hw-aarch64 and gate architecture checks on the helpers omarchy-hw-aarch64 is the counterpart of omarchy-hw-x86: true on every ARM machine, Apple Silicon included. The Windows VM asks omarchy-hw-x86 instead of reading uname itself, and the keyring refresh asks omarchy-hw-aarch64, so an x86 update no longer runs the platform detector for it. * Document platform names, helpers and the consistency rule for agents AGENTS.md gains a Platforms section: the x86 / aarch64 / aarch64- naming standard, which helper answers architecture and which answers hardware family, where platform-specific packages, templates, setup and boot code live, and that every platform should get the same experience, with missing packages fixed upstream before any workaround and alternative packages kept as a last resort. --------- Co-authored-by: Marcelo Alcantara Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com> Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com> Co-authored-by: Scott Jones Co-authored-by: Marcelo Alcantara Co-authored-by: Oliver Lukschander <33756270+oliverlukschander@users.noreply.github.com> Co-authored-by: Caleb Marshall <68087341+cajomar@users.noreply.github.com> Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com> Co-authored-by: Issac Trotts <15530+ijt@users.noreply.github.com> Co-authored-by: Gabe Ohlson <3355909+6abe@users.noreply.github.com> Co-authored-by: Sparsh Kumar <22640187+skkumarsparsh@users.noreply.github.com> Co-authored-by: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com> Co-authored-by: kx0101 <90087463+kx0101@users.noreply.github.com> Co-authored-by: Jim Martin <1334144+jdvmi00@users.noreply.github.com> Co-authored-by: Ryan Hughes Co-authored-by: Claude Opus 5.5 Co-authored-by: Codex XHigh Co-authored-by: David Heinemeier Hansson Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com> Co-authored-by: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com> --- AGENTS.md | 24 + agents/skills/install-scripts.md | 2 + bin/omarchy-apply-hardware | 16 +- bin/omarchy-audio-input-set-default | 10 +- bin/omarchy-audio-sink-availability | 21 +- bin/omarchy-battery-present | 14 +- bin/omarchy-battery-status | 33 +- bin/omarchy-brightness-display | 29 +- bin/omarchy-capture-screenrecording | 355 ++++++- bin/omarchy-capture-screenrecording-process | 72 ++ bin/omarchy-channel-set | 34 +- bin/omarchy-drive-password | 438 ++++++++- bin/omarchy-hibernation-setup | 6 + bin/omarchy-hw-aarch64 | 5 + bin/omarchy-hw-aarch64-apple | 17 + bin/omarchy-hw-apple-silicon | 15 + bin/omarchy-hw-display | 46 +- bin/omarchy-hw-laptop | 28 +- bin/omarchy-hw-laptop-closed | 44 +- bin/omarchy-hw-platform | 202 ++++ bin/omarchy-hw-touchpad | 27 +- bin/omarchy-hw-x86 | 5 + bin/omarchy-hyprland-monitor-watch | 1 + bin/omarchy-install-browser | 22 + bin/omarchy-install-dictation-voxtype | 5 +- bin/omarchy-install-gaming-steam | 1 + bin/omarchy-lifecycle-dispatch | 200 ++++ bin/omarchy-menu-keybindings | 27 +- bin/omarchy-pkg-defaults | 53 + bin/omarchy-provision-first-run | 4 + bin/omarchy-provision-hardware | 162 ++++ bin/omarchy-provision-owner | 345 +++++-- bin/omarchy-refresh-pacman | 20 +- bin/omarchy-reinstall-pkgs | 14 +- bin/omarchy-remove-gaming-steam | 1 + bin/omarchy-setup-direct-boot | 8 + bin/omarchy-setup-security-usb-authorization | 7 + bin/omarchy-system-factory-reset | 171 +++- bin/omarchy-system-lid-close | 5 +- bin/omarchy-system-lid-inhibit | 100 ++ bin/omarchy-update | 16 +- bin/omarchy-update-boot | 39 + bin/omarchy-update-keyring | 26 +- ...omarchy-update-system-pkgs-when-conflicted | 11 + bin/omarchy-upgrade-to-quattro | 5 + bin/omarchy-usb-authorization-boot | 20 +- bin/omarchy-windows-vm | 6 + default/hypr/bindings/dictation.lua | 10 +- default/hypr/bindings/utilities.lua | 3 + default/omarchy/omarchy-menu.jsonc | 22 +- default/pacman/aarch64-apple/mirrorlist-edge | 3 + default/pacman/aarch64-apple/pacman-edge.conf | 39 + default/pacman/aarch64/mirrorlist-edge | 3 + default/pacman/aarch64/pacman-edge.conf | 36 + default/settings-runtime-profile | 27 + docs/file-layout.md | 69 +- docs/lifecycle-dispatch.md | 164 ++++ docs/omarchy-shell.md | 10 + docs/update-process.md | 34 +- etc/mkinitcpio.conf.d/00-omarchy-hooks.conf | 37 + etc/mkinitcpio.conf.d/omarchy_hooks.conf | 3 +- etc/mkinitcpio.conf.d/thunderbolt_module.conf | 8 +- install/config/all.sh | 1 + install/config/locale.sh | 50 + install/hardware/all.sh | 2 + .../hardware/apple/fix-brcmfmac-supplicant.sh | 8 + install/hardware/apple/fix-spi-keyboard.sh | 6 +- install/hardware/bluetooth.sh | 10 + install/hardware/fix-fkeys.sh | 10 +- install/hardware/platform-setup.sh | 13 + install/helpers/image-target.sh | 164 ++++ install/helpers/pacman.sh | 32 + install/omarchy-aarch64-apple.packages | 26 + install/omarchy-aarch64.packages | 4 + install/omarchy-x86_64-only.packages | 4 + install/post-install/pacman.sh | 38 +- install/provisioning/luks-rekey.sh | 290 ++++++ .../omarchy-provision-hardware.service | 21 + install/user/all.sh | 3 + install/user/dictation-default.sh | 8 + install/user/platform-setup.sh | 10 + manual/48-security.md | 2 +- migrations/1786391100.sh | 7 + migrations/1786605598.sh | 7 +- migrations/1791542175.sh | 5 +- shell/Commons/AudioNodes.qml | 41 + shell/Commons/AudioNodesModel.js | 96 ++ shell/Commons/Style.qml | 9 +- shell/Commons/UntypedInput.qml | 152 +++ shell/Commons/qmldir | 2 + shell/plugins/bar/Bar.qml | 116 ++- shell/plugins/bar/BarModel.js | 113 +++ .../bar/indicators/ScreenRecording.qml | 2 +- shell/plugins/bar/widgets/Microphone.qml | 31 +- .../notifications/NotificationLogic.js | 14 + shell/plugins/notifications/Service.qml | 10 +- shell/plugins/panels/audio/Model.js | 13 +- shell/plugins/panels/audio/Panel.qml | 85 +- shell/services/PluginBarStateApi.qml | 11 + shell/shell.qml | 14 + test/shell.d/apple-silicon-quirks-test.sh | 93 ++ test/shell.d/audio-input-set-default-test.sh | 76 ++ .../shell.d/audio-source-availability-test.sh | 65 ++ test/shell.d/audio-test.sh | 68 ++ test/shell.d/bar-notch-test.sh | 235 +++++ test/shell.d/bar-test.sh | 4 +- test/shell.d/base-test.sh | 52 + test/shell.d/battery-present-test.sh | 38 + test/shell.d/battery-status-test.sh | 90 +- test/shell.d/brave-widevine-test.sh | 89 ++ test/shell.d/brcmfmac-supplicant-test.sh | 29 +- test/shell.d/brightness-display-test.sh | 54 +- test/shell.d/channel-sudo-boundary-test.sh | 79 ++ test/shell.d/default-apps-test.sh | 3 + test/shell.d/desktop-entry-launch-test.sh | 2 +- test/shell.d/dictation-test.sh | 41 +- test/shell.d/drive-password-test.sh | 856 ++++++++++++++++- test/shell.d/factory-reset-dispatch-test.sh | 339 +++++++ .../90-omarchy-mac.conf | 18 + .../91-omarchy-mac-encrypt.conf | 90 ++ .../92-omarchy-mac-hid.conf | 31 + .../93-omarchy-mac-plymouth.conf | 18 + .../94-omarchy-mac-vconsole.conf | 46 + test/shell.d/fixtures/sudo-boundary-test.sh | 15 +- test/shell.d/hw-display-test.sh | 84 +- test/shell.d/hw-laptop-test.sh | 141 +++ test/shell.d/hw-platform-test.sh | 203 ++++ test/shell.d/hw-touchpad-test.sh | 97 ++ .../hyprland-binding-conflicts-test.sh | 4 +- test/shell.d/hyprland-default-config-test.sh | 6 +- test/shell.d/image-deferred-hardware-test.sh | 665 +++++++++++++ test/shell.d/image-target-platform-test.sh | 442 +++++++++ test/shell.d/keybindings-menu-test.sh | 17 +- test/shell.d/lid-inhibit-test.sh | 398 ++++++++ test/shell.d/lifecycle-dispatch-test.sh | 346 +++++++ test/shell.d/locale-setup-test.sh | 121 +++ test/shell.d/luks-parent-detection-test.sh | 71 ++ test/shell.d/luks-rekey-journal-test.sh | 904 ++++++++++++++++++ .../menu-keybindings-key-names-test.sh | 155 +++ test/shell.d/menu-test.sh | 24 +- .../shell.d/mkinitcpio-apple-drop-ins-test.sh | 76 ++ test/shell.d/mkinitcpio-hooks-test.sh | 264 +++++ test/shell.d/monitor-modeless-test.sh | 2 + test/shell.d/monitor-recovery-test.sh | 7 +- test/shell.d/notifications-test.sh | 17 + test/shell.d/nvidia-kms-hook-test.sh | 35 +- test/shell.d/pacman-templates-test.sh | 173 ++++ test/shell.d/platform-app-hooks-test.sh | 136 +++ test/shell.d/platform-packages-test.sh | 160 ++++ test/shell.d/platform-setup-test.sh | 146 +++ test/shell.d/provision-owner-keyboard-test.sh | 313 ++++++ test/shell.d/provision-owner-regdom-test.sh | 128 +++ .../screenrecording-concurrency-test.sh | 228 +++++ test/shell.d/screenrecording-process-test.sh | 197 ++++ .../screenrecording-sample-rate-test.sh | 30 + .../screenrecording-stale-audio-test.sh | 57 ++ test/shell.d/screenrecording-test.sh | 133 ++- test/shell.d/screenrecording-wf-audio-test.sh | 110 +++ test/shell.d/settings-runtime-profile-test.sh | 27 + test/shell.d/spi-keyboard-test.sh | 33 + test/shell.d/update-boot-verify-test.sh | 127 +++ test/shell.d/update-disk-space-test.sh | 1 + test/shell.d/update-file-conflict-test.sh | 83 +- test/shell.d/update-keyring-test.sh | 66 +- test/shell.d/update-lock-test.sh | 1 + test/shell.d/update-sequence-test.sh | 2 + test/shell.d/usb-authorization-test.sh | 35 + test/shell.d/voxtype-install-test.sh | 74 ++ test/shell.d/windows-vm-compose-test.sh | 9 + .../shell.d/windows-vm-mount-boundary-test.sh | 7 + 170 files changed, 13030 insertions(+), 366 deletions(-) create mode 100755 bin/omarchy-capture-screenrecording-process create mode 100755 bin/omarchy-hw-aarch64 create mode 100755 bin/omarchy-hw-aarch64-apple create mode 100755 bin/omarchy-hw-apple-silicon create mode 100755 bin/omarchy-hw-platform create mode 100755 bin/omarchy-hw-x86 create mode 100755 bin/omarchy-lifecycle-dispatch create mode 100755 bin/omarchy-pkg-defaults create mode 100755 bin/omarchy-provision-hardware create mode 100755 bin/omarchy-system-lid-inhibit create mode 100755 bin/omarchy-update-boot create mode 100644 default/pacman/aarch64-apple/mirrorlist-edge create mode 100644 default/pacman/aarch64-apple/pacman-edge.conf create mode 100644 default/pacman/aarch64/mirrorlist-edge create mode 100644 default/pacman/aarch64/pacman-edge.conf create mode 100644 default/settings-runtime-profile create mode 100644 docs/lifecycle-dispatch.md create mode 100644 etc/mkinitcpio.conf.d/00-omarchy-hooks.conf create mode 100644 install/config/locale.sh create mode 100644 install/hardware/platform-setup.sh create mode 100644 install/helpers/image-target.sh create mode 100644 install/helpers/pacman.sh create mode 100644 install/omarchy-aarch64-apple.packages create mode 100644 install/omarchy-aarch64.packages create mode 100644 install/omarchy-x86_64-only.packages create mode 100644 install/provisioning/luks-rekey.sh create mode 100644 install/provisioning/omarchy-provision-hardware.service create mode 100644 install/user/dictation-default.sh create mode 100644 install/user/platform-setup.sh create mode 100644 shell/Commons/AudioNodes.qml create mode 100644 shell/Commons/AudioNodesModel.js create mode 100644 shell/Commons/UntypedInput.qml create mode 100644 test/shell.d/apple-silicon-quirks-test.sh create mode 100644 test/shell.d/audio-input-set-default-test.sh create mode 100644 test/shell.d/audio-source-availability-test.sh create mode 100644 test/shell.d/bar-notch-test.sh create mode 100755 test/shell.d/battery-present-test.sh create mode 100644 test/shell.d/brave-widevine-test.sh create mode 100644 test/shell.d/factory-reset-dispatch-test.sh create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/90-omarchy-mac.conf create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/91-omarchy-mac-encrypt.conf create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/92-omarchy-mac-hid.conf create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/93-omarchy-mac-plymouth.conf create mode 100644 test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/94-omarchy-mac-vconsole.conf create mode 100755 test/shell.d/hw-laptop-test.sh create mode 100644 test/shell.d/hw-platform-test.sh create mode 100755 test/shell.d/hw-touchpad-test.sh create mode 100644 test/shell.d/image-deferred-hardware-test.sh create mode 100644 test/shell.d/image-target-platform-test.sh create mode 100755 test/shell.d/lid-inhibit-test.sh create mode 100755 test/shell.d/lifecycle-dispatch-test.sh create mode 100644 test/shell.d/locale-setup-test.sh create mode 100644 test/shell.d/luks-parent-detection-test.sh create mode 100755 test/shell.d/luks-rekey-journal-test.sh create mode 100644 test/shell.d/menu-keybindings-key-names-test.sh create mode 100755 test/shell.d/mkinitcpio-apple-drop-ins-test.sh create mode 100755 test/shell.d/mkinitcpio-hooks-test.sh create mode 100755 test/shell.d/pacman-templates-test.sh create mode 100644 test/shell.d/platform-app-hooks-test.sh create mode 100644 test/shell.d/platform-packages-test.sh create mode 100644 test/shell.d/platform-setup-test.sh create mode 100644 test/shell.d/provision-owner-keyboard-test.sh create mode 100644 test/shell.d/provision-owner-regdom-test.sh create mode 100755 test/shell.d/screenrecording-concurrency-test.sh create mode 100755 test/shell.d/screenrecording-process-test.sh create mode 100755 test/shell.d/screenrecording-sample-rate-test.sh create mode 100755 test/shell.d/screenrecording-stale-audio-test.sh create mode 100644 test/shell.d/screenrecording-wf-audio-test.sh create mode 100755 test/shell.d/settings-runtime-profile-test.sh create mode 100644 test/shell.d/spi-keyboard-test.sh create mode 100644 test/shell.d/update-boot-verify-test.sh create mode 100644 test/shell.d/voxtype-install-test.sh diff --git a/AGENTS.md b/AGENTS.md index 6de19cb9..1bee7182 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,30 @@ guidance does not drift from the router. - `$OMARCHY_PATH` is set at the top level by the uwsm session environment and is always available to Omarchy runtime code. - Commands in `bin/` and Quickshell QML should rely on `$OMARCHY_PATH` / `Quickshell.env("OMARCHY_PATH")`; do not derive fallback paths from `HOME`, `Quickshell.shellDir`, or re-export/default `OMARCHY_PATH` manually. +# Platforms + +Omarchy runs on x86 and aarch64 (ARM). A platform name is the CPU architecture, then a hardware family only when that family needs handling of its own: + +- `x86` - every x86_64 machine +- `aarch64` - every ARM machine without a family of its own (Snapdragon laptops, Raspberry Pis and ARM VMs today) +- `aarch64-apple` - Apple Silicon Macs + +A new family is named `aarch64-` (e.g. `aarch64-qualcomm`, `aarch64-n1x`) and is added only once it needs code of its own, so `aarch64*` always means ARM. `omarchy-hw-platform` prints the most specific name. Use these exact names wherever a platform is named: package lists, pacman template directories, image manifests, dispatch registration, tests and docs. Never introduce `generic`, `arm64`, `apple-silicon` or other spellings; the old `apple-silicon`, `generic-aarch64` and `generic` are read only for compatibility with older image builders. + +Ask a helper; don't read `uname -m` or the device tree in feature code: + +- `omarchy-hw-x86` / `omarchy-hw-aarch64` - the CPU architecture. Use them for binary and ABI availability: a package with no aarch64 build, an x86 guest VM, multilib. +- `omarchy-hw-aarch64-apple` (and later `omarchy-hw-aarch64-`) - built on `omarchy-hw-platform`, image-build aware. Use them for hardware behaviour. `omarchy-hw-apple-silicon` is a hidden alias kept for the omarchy-mac packages; don't use it in new code. + +Where platform code lives: + +- Packages: `install/omarchy-base.packages` for everyone, `install/omarchy-aarch64.packages` for every ARM machine, `install/omarchy-.packages` for one family, and base packages with no aarch64 build in `install/omarchy-x86_64-only.packages`. `omarchy-pkg-defaults` composes them. +- Pacman templates: `default/pacman/` for x86, `default/pacman//` for each ARM platform. +- Hardware setup: `install/hardware//`, gated by a predicate. `install/hardware/apple/` holds Intel Mac quirks; Apple Silicon's own setup ships in the omarchy-mac packages. +- Boot chains Omarchy can't drive generically: a platform package behind `omarchy-lifecycle-dispatch` ([`docs/lifecycle-dispatch.md`](docs/lifecycle-dispatch.md)). Platforms that boot Limine with UKIs like x86 keep the generic path. + +The experience should be the same on every platform. When a package or feature is missing or broken on one, the default is to get it fixed upstream (an aarch64 build, a bug report, a patch to the maintainer) and leave the gap visible meanwhile, not to paper over it. When asked for a platform workaround, say so and propose the upstream fix first. Hiding a menu entry or skipping a package is fine only for things that can't exist there (an x86 guest VM, an x86-only proprietary binary). Alternative packages and platform-only replacements are a last resort, used only when upstream can't or won't fix it, with the reason written next to the code. + # Privileged Commands - Follow the "Privilege Escalation" section of `default/agents/skills/omarchy/SKILL.md`. It draws the diff --git a/agents/skills/install-scripts.md b/agents/skills/install-scripts.md index aa993e5e..b117b193 100644 --- a/agents/skills/install-scripts.md +++ b/agents/skills/install-scripts.md @@ -12,6 +12,8 @@ commands and reusable setup leaves: - avoid `exit` in sourced setup scripts unless intentionally aborting setup. - use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths. - keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`. +- in an image build every `run_logged` leaf in `install/hardware/all.sh` is queued and runs on the machine's first boot instead (`bin/omarchy-provision-hardware`, see `docs/file-layout.md`), possibly offline, using packages already in the image, and before any user exists; keep those leaves idempotent and put nothing but `run_logged` lines in `all.sh`. +- gate platform-specific setup on a predicate built on `omarchy-hw-platform` (`omarchy-hw-aarch64-apple`), not on `uname -m`; architecture checks (`omarchy-hw-x86`, `omarchy-hw-aarch64`) are only for ABI, binary availability or a repository's `$arch`. Platform names and where platform code lives are in the Platforms section of `AGENTS.md`. - keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user. - The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist. - prefer helper commands for package and command checks where available. diff --git a/bin/omarchy-apply-hardware b/bin/omarchy-apply-hardware index 89eb8ab1..9f873d5a 100755 --- a/bin/omarchy-apply-hardware +++ b/bin/omarchy-apply-hardware @@ -19,6 +19,9 @@ for diagnostics or after hardware/package updates. --defer-provisioning runs without an install user (deferred-provisioning installs create the user at first boot); group grants are recorded in /var/lib/omarchy/provisioning/groups. + +In an image build (/var/lib/omarchy/image/target exists) nothing runs here: every step is queued +for omarchy-provision-hardware on the machine's first boot. USAGE } @@ -66,10 +69,21 @@ else fi export OMARCHY_INSTALL_USER="$install_user" +# Only omarchy-provision-hardware runs steps as an image's first boot. +unset OMARCHY_IMAGE_DEFERRED_HARDWARE export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}" export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}" export PATH="$OMARCHY_PATH/bin:$PATH" source "$OMARCHY_INSTALL/helpers/logging.sh" -source "$OMARCHY_INSTALL/hardware/all.sh" +source "$OMARCHY_INSTALL/helpers/image-target.sh" + +omarchy_image_init +omarchy_image_lock +if omarchy_image_manifest_present; then + omarchy_image_read_manifest + omarchy_image_defer_hardware +else + source "$OMARCHY_INSTALL/hardware/all.sh" +fi diff --git a/bin/omarchy-audio-input-set-default b/bin/omarchy-audio-input-set-default index 280042d2..97a8c2c7 100755 --- a/bin/omarchy-audio-input-set-default +++ b/bin/omarchy-audio-input-set-default @@ -15,6 +15,14 @@ fi wpctl set-default "$node_id" 2>/dev/null || true pactl set-default-source "$source_name" 2>/dev/null || true -pactl list short source-outputs 2>/dev/null | awk '{ print $1 }' | while read -r output; do +# A filter's own capture (the input half of a filter chain, loopback or echo +# canceller, which PipeWire joins to its output by node.link-group) is the +# processing, not an app: moving it would feed the filter from the new input, or +# from its own output. pactl translates the listing, so it is read in the C +# locale. +LC_ALL=C pactl list source-outputs 2>/dev/null | awk ' + /^Source Output #/ { if (id != "" && !filter) print id; id = substr($3, 2); filter = 0 } + /^[[:space:]]*node\.link-group = / { filter = 1 } + END { if (id != "" && !filter) print id }' | while read -r output; do [[ -n $output ]] && pactl move-source-output "$output" "$source_name" 2>/dev/null || true done diff --git a/bin/omarchy-audio-sink-availability b/bin/omarchy-audio-sink-availability index 531b5bff..7e794b41 100755 --- a/bin/omarchy-audio-sink-availability +++ b/bin/omarchy-audio-sink-availability @@ -1,14 +1,27 @@ #!/bin/bash -# omarchy:summary=Print PulseAudio sink availability for the shell +# omarchy:summary=Print PulseAudio sink or source availability for the shell # omarchy:group=audio +# omarchy:args=[sources] + +# With "sources", print source availability instead: a jack input such as a +# headset microphone reports its port unavailable while nothing is plugged in. +# pactl translates the listing ("not available" too), so it is read in the C +# locale. +kind=sinks +header="Sink #" +if [[ ${1:-} == "sources" ]]; then + kind=sources + header="Source #" +fi # A speaker tuning is a virtual sink in front of the real speakers. Both exist # in the graph, but selecting the physical one would only bypass the tuning, so # report it unavailable and keep it out of the output list. -fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)" +fronted="" +[[ $kind == sinks ]] && fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)" -pactl list sinks 2>/dev/null | awk -v fronted="$fronted" ' +LC_ALL=C pactl list "$kind" 2>/dev/null | awk -v fronted="$fronted" -v header="$header" ' function emit_sink() { if (name == "") return if (fronted != "" && name == fronted) { @@ -18,7 +31,7 @@ pactl list sinks 2>/dev/null | awk -v fronted="$fronted" ' print name "\t" ((port_count == 0 || available) ? 1 : 0) } - /^Sink #/ { + index($0, header) == 1 { emit_sink() name = "" in_ports = 0 diff --git a/bin/omarchy-battery-present b/bin/omarchy-battery-present index fb221e8c..90883a67 100755 --- a/bin/omarchy-battery-present +++ b/bin/omarchy-battery-present @@ -2,10 +2,16 @@ # omarchy:summary=Returns true if a battery is present on the system. -for bat in /sys/class/power_supply/BAT*; do - [[ -r $bat/present ]] && - [[ $(cat $bat/present) == "1" ]] && - [[ $(cat $bat/type) == "Battery" ]] && +# Not every battery is called BAT*: Apple Silicon reports macsmc-battery and +# some Framework and Chromebook firmware CMB0, so decide by type, not by name. +power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}" + +for bat in "$power_supply_path"/*; do + [[ -r $bat/present && $(<"$bat/present") == "1" ]] || continue + [[ -r $bat/type && $(<"$bat/type") == "Battery" ]] || continue + # A wireless mouse or keyboard registers a Battery too, scoped Device. The + # one that powers the machine is scoped System, or unscoped on older drivers. + [[ -r $bat/scope && $(<"$bat/scope") == "Device" ]] && continue exit 0 done diff --git a/bin/omarchy-battery-status b/bin/omarchy-battery-status index cc0a6024..13de4454 100755 --- a/bin/omarchy-battery-status +++ b/bin/omarchy-battery-status @@ -18,11 +18,23 @@ case "${1:-}" in ;; esac -battery=$(upower -e 2>/dev/null | grep BAT | head -n 1) +# UPower names the device after the sysfs entry, and only some kernels call +# that BAT*: Apple Silicon reports battery_macsmc_battery. Match on the class, +# then take the first one that powers the machine: a wireless mouse or keyboard +# is a battery_ device too, and UPower marks it "power supply: no". +battery="" +battery_info="" +while read -r candidate; do + [[ -n $candidate ]] || continue + candidate_info=$(upower -i "$candidate" 2>/dev/null) || continue + if grep -qE '^\s*power supply:\s+yes' <<<"$candidate_info"; then + battery=$candidate + battery_info=$candidate_info + break + fi +done < <(upower -e 2>/dev/null | grep '/battery_') [[ -z $battery ]] && exit 0 -battery_info=$(upower -i "$battery") - percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info") capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info") time_remaining=$(awk '/time to (empty|full)/ { @@ -56,7 +68,10 @@ elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then 'BEGIN { print microamps * microvolts / 1000000000000 }') fi +# Some kernels sign power_now by direction (negative while discharging on +# Apple Silicon); the state field already says which way it flows. power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN { + if (rate < 0) rate = -rate rounded = sprintf("%.1f", rate) sub(/\.0$/, "", rounded) print rounded @@ -65,8 +80,12 @@ state=$(awk '/state/ { print $2; exit }' <<<"$battery_info") threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info") threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info") -[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1) -[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1) +# Read the thresholds from the battery UPower resolved rather than a BAT* glob, +# which misses every battery with another name. +[[ -n $threshold_end || ! -r $battery_path/charge_control_end_threshold ]] || threshold_end=$(<"$battery_path/charge_control_end_threshold") +[[ -n $threshold_start || ! -r $battery_path/charge_control_start_threshold ]] || threshold_start=$(<"$battery_path/charge_control_start_threshold") +[[ -n $threshold_end ]] || threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1) +[[ -n $threshold_start ]] || threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1) ac_online=false for supply in "$power_supply_path"/*; do @@ -107,7 +126,9 @@ if [[ $shell_output == "true" ]]; then printf 'size\t%s\n' "${capacity}Wh" printf 'time\t%s\n' "$time_remaining" - cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1) + cycles="" + [[ ! -r $battery_path/cycle_count ]] || cycles=$(<"$battery_path/cycle_count") + [[ -n $cycles ]] || cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1) [[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles" diff --git a/bin/omarchy-brightness-display b/bin/omarchy-brightness-display index a6a1e830..5a3e534e 100755 --- a/bin/omarchy-brightness-display +++ b/bin/omarchy-brightness-display @@ -40,7 +40,30 @@ use_apple_display() { } use_ddc_display() { - [[ -n $monitor ]] && ! monitor_is_internal + [[ -n $monitor ]] && ! monitor_is_internal && monitor_may_have_ddc +} + +# A platform whose display driver registers no DDC channel says so in its +# displays.conf (see docs/file-layout.md): there a probe finds only unrelated +# I2C buses, slowly, so only a connector with a ddc node is probed. Everywhere +# else every external monitor is. +monitor_may_have_ddc() { + local displays_conf=/usr/share/omarchy-platform/displays.conf directive argument + [[ -r $displays_conf ]] || return 0 + + while IFS=$' \t\r' read -r directive argument || [[ -n $directive ]]; do + if [[ $directive == "ddc-require-connector-ddc" && -z $argument ]]; then + compgen -G "/sys/class/drm/card*-$monitor/ddc" >/dev/null + return + fi + done <"$displays_conf" + return 0 +} + +# The kernel backlight is the built-in panel's, so an external monitor without +# DDC fails rather than dimming the panel. +use_backlight_display() { + [[ -z $monitor ]] || monitor_is_internal } if (( $# == 0 )); then @@ -50,6 +73,8 @@ if (( $# == 0 )); then elif use_ddc_display; then omarchy-brightness-display-ddc "$monitor" exit + elif ! use_backlight_display; then + exit 1 fi device="$(omarchy-hw-display)" || exit 1 @@ -85,6 +110,8 @@ if use_apple_display; then elif use_ddc_display; then brightness="$(omarchy-brightness-display-ddc "$monitor" "$step")" || exit 1 (( no_osd )) || omarchy-osd -i brightness -p "$brightness" +elif ! use_backlight_display; then + exit 1 else # Current device highlighted device="$(omarchy-hw-display)" || exit 1 diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 0c4c5494..e5269ad1 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -2,7 +2,7 @@ # omarchy:summary=Start or stop screen recording # omarchy:group=capture -# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--webcam-size=] [--resolution=] [--stop-recording] +# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=] [--webcam-size=] [--resolution=] [--stop-recording] [--status] # omarchy:examples=omarchy screenrecord | omarchy capture screenrecording --with-desktop-audio # omarchy:aliases=omarchy screenrecord # @@ -14,19 +14,20 @@ # DMA-BUF modifier import on some configurations, leaving recording unable to # start. # -# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and -# the picker target it was launched with) to -# $XDG_RUNTIME_DIR/omarchy-screenrecord.log so users can attach a log when -# reporting capture failures. +# Env: OMARCHY_SCREENRECORD_DEBUG=true appends the recorder's stderr (and the +# picker target it was launched with) to $XDG_RUNTIME_DIR/omarchy-screenrecord.log +# so users can attach a log when reporting capture failures. +# +# Backend: gpu-screen-recorder. Where it is not installed, or it exits before +# the recording starts (it knows only NVIDIA, AMD, Intel and Broadcom GPUs, and +# aborts on others such as Apple Silicon's), wf-recorder records instead when +# it is installed. wf-recorder encodes on the CPU and captures one audio +# device, so desktop and microphone audio together go through a temporary null +# sink. Portal capture and --resolution are gpu-screen-recorder only. [[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}" -if [[ ! -d $OUTPUT_DIR ]]; then - omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR" - exit 1 -fi - DESKTOP_AUDIO="false" MICROPHONE_AUDIO="false" WEBCAM="false" @@ -35,6 +36,7 @@ WEBCAM_SIZE="medium" RESOLUTION="" FULLSCREEN="false" STOP_RECORDING="false" +STATUS="false" # Both of these live in the per-user runtime directory, which is 0700. What # RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes # beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in @@ -46,6 +48,17 @@ STOP_RECORDING="false" RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename" REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region" +# The recorder's pid, so stop and status act on that process alone. +REC_PID_FILE="$RUNTIME_DIR/omarchy-screenrecord-pid" +# The PulseAudio module ids of a combined-audio null sink, for cleanup. +PA_MODULES_FILE="$RUNTIME_DIR/omarchy-screenrecord-pa-modules" +# Present while a start holds the lock; another toggle or stop then leaves +# CANCEL_FILE for that start instead of queueing behind its picker or recorder. +STARTING_FILE="$RUNTIME_DIR/omarchy-screenrecord-starting" +CANCEL_FILE="$RUNTIME_DIR/omarchy-screenrecord-cancel" +# How long a recorder may take to create its file, except through the portal, +# whose picker waits on the user. +READY_TIMEOUT_TICKS=75 LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null") for arg in "$@"; do @@ -58,6 +71,7 @@ for arg in "$@"; do --resolution=*) RESOLUTION="${arg#*=}" ;; --fullscreen) FULLSCREEN="true" ;; --stop-recording) STOP_RECORDING="true" ;; + --status) STATUS="true" ;; esac done @@ -69,6 +83,13 @@ small | medium | large) ;; ;; esac +# --status only answers whether our recording runs, for the bar and the menu, so +# it needs no recordings directory and never notifies. +if [[ $STATUS != "true" && ! -d $OUTPUT_DIR ]]; then + omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR" + exit 1 +fi + start_webcam_overlay() { cleanup_webcam @@ -99,7 +120,7 @@ start_webcam_overlay() { '--vf=lavfi=[crop=ih*8/9:ih]' \ --title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \ --no-border --no-audio --no-osc --osd-level=0 \ - --really-quiet &>/dev/null & + --really-quiet 9>&- &>/dev/null & # The move has to settle before gpu-screen-recorder starts, or the camera is # recorded sliding into its corner. Waiting for the map is what the blind @@ -153,12 +174,132 @@ select_capture_target() { echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}" } -start_screenrecording() { - mkdir -p "$RUNTIME_DIR" || return 1 - # XDG_STATE_HOME may be outside a private home; protect existing fallbacks too. - if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then - chmod 700 "$RUNTIME_DIR" || return 1 +# gpu-screen-recorder first, then wf-recorder, of those installed. The base +# packages carry gpu-screen-recorder, but an architecture may build none. +recorder_backends() { + local backend found=false + + for backend in gpu-screen-recorder wf-recorder; do + if omarchy-cmd-present "$backend"; then + echo "$backend" + found=true + fi + done + + # Neither installed: fail the way a missing gpu-screen-recorder always has. + $found || echo gpu-screen-recorder +} + +# wf-recorder records a single audio device. Map the desktop/mic flags onto one: +# desktop -> default sink monitor, mic -> default source. When both are requested, +# route them through a throwaway null sink so a single track carries both; the +# module ids are saved to PA_MODULES_FILE so stop can unload them. A requested +# source that can't be recorded is left out with a notification naming it, and +# the recording carries whichever source remains. +wf_audio_device() { + local sink="" source="" m + [[ $DESKTOP_AUDIO == "true" ]] && sink=$(pactl get-default-sink 2>/dev/null) + [[ $MICROPHONE_AUDIO == "true" ]] && source=$(pactl get-default-source 2>/dev/null) + + if [[ -n $sink && -n $source ]]; then + if m=$(pactl load-module module-null-sink sink_name=omarchy_screenrec sink_properties=device.description=omarchy_screenrec 2>/dev/null); then + echo "$m" >"$PA_MODULES_FILE" + if m=$(pactl load-module module-loopback source="${sink}.monitor" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then + echo "$m" >>"$PA_MODULES_FILE" + else + sink="" + fi + if m=$(pactl load-module module-loopback source="$source" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then + echo "$m" >>"$PA_MODULES_FILE" + else + source="" + fi + if [[ -n $sink && -n $source ]]; then + echo "omarchy_screenrec.monitor" + return + fi + wf_audio_cleanup + else + # No device to mix into: desktop audio records alone, as it always has. + source="" + fi fi + + [[ $DESKTOP_AUDIO == "true" && -z $sink ]] && wf_audio_unavailable desktop + [[ $MICROPHONE_AUDIO == "true" && -z $source ]] && wf_audio_unavailable microphone + if [[ -n $sink ]]; then + echo "${sink}.monitor" + elif [[ -n $source ]]; then + echo "$source" + fi +} + +wf_audio_unavailable() { + omarchy-notification-send -u critical -t 5000 "Recording without $1 audio" "The $1 audio could not be added to this recording." >/dev/null 2>&1 +} + +wf_audio_cleanup() { + [[ -f $PA_MODULES_FILE ]] || return + # Unload in reverse so the loopbacks drop before the null sink they feed. + tac "$PA_MODULES_FILE" 2>/dev/null | while read -r m; do + [[ -n $m ]] && pactl unload-module "$m" 2>/dev/null + done + rm -f "$PA_MODULES_FILE" +} + +# Launches wf-recorder for the given picker target, backgrounded, with REC_PID set. +launch_wf_recorder() { + local target=$1 filename=$2 + local geom_args=() + + case $target in + monitor:*) + geom_args=(-o "${target#monitor:}") + ;; + region:*) + # region:WxH+X+Y (logical coords) -> wf-recorder -g "X,Y WxH" (same space). + [[ ${target#region:} =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]] || return 1 + geom_args=(-g "${BASH_REMATCH[3]},${BASH_REMATCH[4]} ${BASH_REMATCH[1]}x${BASH_REMATCH[2]}") + ;; + *) + return 1 + ;; + esac + + local audio_args=() dev + dev=$(wf_audio_device) + [[ -n $dev ]] && audio_args=(--audio="$dev") + + # -r 60 caps the framerate: capturing a 120 Hz panel at its native rate makes + # needlessly large files. -R 48000 keeps the AAC track at 48 kHz rather than + # following a sink that advertises 96 kHz into a file many players, and some + # speaker DSPs, play as silence. + wf-recorder "${geom_args[@]}" -r 60 -R 48000 "${audio_args[@]}" -f "$filename" 9>&- 2>>"$LOG_FILE" & + REC_PID=$! +} + +start_cancelled() { + [[ -e $CANCEL_FILE ]] +} + +# Stops a recorder this start launched: TERM, two seconds to exit, then KILL. +end_launched_recorder() { + local pid=$1 waited=0 state + kill "$pid" 2>/dev/null + while (( waited++ < 20 )); do + state=$(<"/proc/$pid/stat") 2>/dev/null || break + state=${state##*) } + [[ ${state:0:1} != "Z" ]] || break + sleep 0.1 + done + kill -KILL "$pid" 2>/dev/null + wait "$pid" 2>/dev/null +} + +start_screenrecording() { + # A pid left by a recording that ended without a stop would otherwise hide + # this one from the toggle until it has started and saved its own. + rm -f "$REC_PID_FILE" local capture_args=() local target @@ -174,6 +315,7 @@ start_screenrecording() { capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}") else target=$(select_capture_target) || return 1 + start_cancelled && return 1 case $target in monitor:*) @@ -187,50 +329,101 @@ start_screenrecording() { fi [[ $WEBCAM == "true" ]] && start_webcam_overlay "$target" + start_cancelled && return 1 local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4" - local audio_devices="" - local audio_args=() + local backend pid="" - [[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output" + for backend in $(recorder_backends); do + [[ $backend == "wf-recorder" && $target == "portal" ]] && continue + echo "===== $(date '+%F %T') args: $* backend: $backend target: $target =====" >>"$LOG_FILE" - if [[ $MICROPHONE_AUDIO == "true" ]]; then - # Merge audio tracks into one - separate tracks only play one at a time in most players - [[ -n $audio_devices ]] && audio_devices+="|" - audio_devices+="default_input" - fi + if [[ $backend == "wf-recorder" ]]; then + launch_wf_recorder "$target" "$filename" || continue + pid=$REC_PID + else + local audio_devices="" + local audio_args=() - [[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac) + [[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output" - echo "===== $(date '+%F %T') args: $* target: $target =====" >>"$LOG_FILE" - gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 2>>"$LOG_FILE" & - local pid=$! + if [[ $MICROPHONE_AUDIO == "true" ]]; then + # Merge audio tracks into one - separate tracks only play one at a time in most players + [[ -n $audio_devices ]] && audio_devices+="|" + audio_devices+="default_input" + fi - while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do - sleep 0.2 + [[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac) + + gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 9>&- 2>>"$LOG_FILE" & + pid=$! + fi + + local ticks=0 + while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do + start_cancelled && break + if [[ $target != "portal" ]] && (( ++ticks > READY_TIMEOUT_TICKS )); then + end_launched_recorder "$pid" + wf_audio_cleanup + rm -f "$filename" + continue 2 + fi + sleep 0.2 + done + + if start_cancelled; then + end_launched_recorder "$pid" + wf_audio_cleanup + rm -f "$filename" + return 1 + fi + + if kill -0 $pid 2>/dev/null; then + echo "$pid" >"$REC_PID_FILE" + echo "$filename" >"$RECORDING_FILE" + toggle_screenrecording_indicator + return 0 + fi + + wf_audio_cleanup + rm -f "$filename" done - if kill -0 $pid 2>/dev/null; then - echo "$filename" >"$RECORDING_FILE" - toggle_screenrecording_indicator - fi + return 1 } +# Selects the recorder this script started, by the pid it saved. A saved pid +# that is no longer a recorder is a recording that ended without a stop, so +# nothing of ours is recording and no other recorder is touched. Without a +# saved pid (a recording started before the pid was saved), every recorder the +# user runs counts, as for the bar indicator and the menu. +select_recorder() { + RECORDER=() + [[ -f $REC_PID_FILE ]] || return 0 + + local pid + pid=$(<"$REC_PID_FILE") + [[ $pid =~ ^[0-9]+$ ]] && omarchy-capture-screenrecording-process --pid "$pid" || return 1 + RECORDER=(--pid "$pid") +} + +# Stops the recorder screenrecording_active selected. stop_screenrecording() { - pkill -SIGINT -f "^gpu-screen-recorder" # SIGINT required to save video properly + omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal INT # SIGINT required to save video properly # Wait a maximum of 5 seconds to finish before hard killing local count=0 - while pgrep -f "^gpu-screen-recorder" >/dev/null && ((count < 50)); do + while omarchy-capture-screenrecording-process "${RECORDER[@]}" && ((count < 50)); do sleep 0.1 count=$((count + 1)) done toggle_screenrecording_indicator cleanup_webcam + wf_audio_cleanup - if pgrep -f "^gpu-screen-recorder" >/dev/null; then - pkill -9 -f "^gpu-screen-recorder" + if omarchy-capture-screenrecording-process "${RECORDER[@]}"; then + omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal KILL omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted." else finalize_recording @@ -251,10 +444,10 @@ stop_screenrecording() { ( sleep 2 rm -f "$preview" - ) & + ) 9>&- & fi - rm -f "$RECORDING_FILE" + rm -f "$RECORDING_FILE" "$REC_PID_FILE" } toggle_screenrecording_indicator() { @@ -262,7 +455,14 @@ toggle_screenrecording_indicator() { } screenrecording_active() { - pgrep -f "^gpu-screen-recorder" >/dev/null + if select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}"; then + return 0 + else + if [[ -f $REC_PID_FILE ]]; then + wf_audio_cleanup + fi + return 1 + fi } finalize_recording() { @@ -283,7 +483,10 @@ finalize_recording() { # Hard-mute the first 400ms to drop the PipeWire capture-open pop (a near-clipping # transient around 130-200ms that a gentle fade-in can't attenuate enough), then a # 50ms fade avoids a click at the boundary before loudnorm normalizes the rest. - args+=(-af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11") + # + # loudnorm declares 192 kHz output, so without -ar the AAC encoder writes + # 96 kHz from a 48 kHz recording. + args+=(-ar 48000 -af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11") fi local processed="${latest%.mp4}-processed.mp4" @@ -294,10 +497,66 @@ finalize_recording() { fi } -if screenrecording_active; then - stop_screenrecording -elif [[ $STOP_RECORDING == "true" ]]; then - exit 1 -else - start_screenrecording || cleanup_webcam +mkdir -p "$RUNTIME_DIR" || exit 1 +# XDG_STATE_HOME may be outside a private home; protect existing fallbacks too. +if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then + chmod 700 "$RUNTIME_DIR" || exit 1 fi + +# Serialize changes to the recorder and its files; background children close 9. +# A busy status query stays read-only so a picker cannot stall the bar. +{ + if [[ $STATUS == "true" ]]; then + if flock -n 9; then + screenrecording_active + else + select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}" + fi + else + # A start still in its picker or waiting on its recorder is cancelled, not + # queued behind: that toggle would otherwise begin another recording. + # The start reads CANCEL_FILE only after it removes STARTING_FILE, so a + # request left while STARTING_FILE stands is always seen. One that finds it + # gone waits its turn, and a request the start used up by then is done. + if ! flock -w 1 9; then + touch "$CANCEL_FILE" + # A start that was killed leaves its marker: it counts only while that + # start still runs. + starter="" + [[ -r $STARTING_FILE ]] && starter=$(<"$STARTING_FILE") + if [[ $starter =~ ^[0-9]+$ ]] && kill -0 "$starter" 2>/dev/null; then + exit 0 + fi + flock 9 || exit 1 + [[ -e $CANCEL_FILE ]] || exit 0 + rm -f "$CANCEL_FILE" + fi + if screenrecording_active; then + stop_screenrecording + elif [[ $STOP_RECORDING == "true" ]]; then + exit 1 + else + rm -f "$CANCEL_FILE" + echo $$ >"$STARTING_FILE" + if start_screenrecording; then + rm -f "$STARTING_FILE" + # Cancelled while the recording was being saved as started. + if start_cancelled; then + end_launched_recorder "$(<"$REC_PID_FILE")" + wf_audio_cleanup + cleanup_webcam + rm -f "$(<"$RECORDING_FILE")" "$REC_PID_FILE" "$RECORDING_FILE" + toggle_screenrecording_indicator + rm -f "$CANCEL_FILE" + fi + # A request after that check is left for its requester, who stops this + # recording once it has the lock. + else + rm -f "$STARTING_FILE" + wf_audio_cleanup + cleanup_webcam + rm -f "$CANCEL_FILE" + fi + fi + fi +} 9>>"$RUNTIME_DIR/omarchy-screenrecord.lock" diff --git a/bin/omarchy-capture-screenrecording-process b/bin/omarchy-capture-screenrecording-process new file mode 100755 index 00000000..bec34707 --- /dev/null +++ b/bin/omarchy-capture-screenrecording-process @@ -0,0 +1,72 @@ +#!/bin/bash + +# omarchy:summary=Check or signal the current user's recording processes +# omarchy:args=[--pid ] [--signal INT|KILL] +# omarchy:hidden=true + +usage() { + echo "Usage: omarchy-capture-screenrecording-process [--pid ]... [--signal INT|KILL]" >&2 + exit 2 +} + +proc_root=${OMARCHY_PROC_ROOT:-/proc} +wanted=" " +signal="" + +while (($#)); do + case $1 in + --pid) + # Compare textually first: bash arithmetic wraps values past 64 bits. + [[ ${2-} =~ ^0*([0-9]+)$ ]] || usage + pid=${BASH_REMATCH[1]} + ((${#pid} <= 10 && 10#$pid <= 4194304)) || usage + wanted+="$((10#$pid)) " + shift 2 + ;; + --signal) + [[ ${2-} == "INT" || ${2-} == "KILL" ]] || usage + signal=$2 + shift 2 + ;; + *) + usage + ;; + esac +done + +is_recorder() { + local exe + exe=$(readlink "$proc_root/$1/exe" 2>/dev/null) || return 1 + # Linux appends this when an upgrade unlinks a still-running executable. + exe=${exe% (deleted)} + exe=${exe##*/} + [[ $exe == "gpu-screen-recorder" || $exe == "wf-recorder" ]] +} + +# Linux truncates a process name to 15 characters: gpu-screen-recorder runs as +# gpu-screen-reco, which any executable sharing that prefix also matches, and a +# process can rename itself, so the executable path confirms each candidate. +matched=() +while read -r pid; do + if [[ $wanted == " " || $wanted == *" $pid "* ]] && is_recorder "$pid"; then + matched+=("$pid") + fi +done < <(pgrep -u "$UID" -x 'gpu-screen-reco(rder)?|wf-recorder') + +if ((${#matched[@]} == 0)); then + exit 1 +fi + +if [[ -n $signal ]]; then + delivered=0 + for pid in "${matched[@]}"; do + # The pid can be reused between discovery and kill; re-checking exe here narrows that window. + if is_recorder "$pid" && kill "-$signal" "$pid" 2>/dev/null; then + delivered=1 + fi + done + # A recorder that exits before its signal has stopped anyway; fail only if none was signalled. + ((delivered)) || exit 1 +fi + +exit 0 diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 039d3292..feee2518 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -60,6 +60,23 @@ link_dev_checkout() { fi done + # The checkout's own channel refresh runs next, from its own templates. + if [[ ! -f $checkout/${templates#"$OMARCHY_PATH"/}/pacman-$pacman_channel.conf || + ! -f $checkout/${templates#"$OMARCHY_PATH"/}/mirrorlist-$pacman_channel ]]; then + fail "Update the checkout before switching to dev; it has no $pacman_channel templates for $platform." + fi + + # On aarch64 one from before it had platform templates would replace this + # machine's repositories with x86's. + if [[ $platform != "x86" ]]; then + for required in bin/omarchy-hw-platform install/helpers/pacman.sh; do + if [[ ! -f $checkout/$required || ! -r $checkout/$required || + ( $required == "bin/omarchy-hw-platform" && ! -x $checkout/$required ) ]]; then + fail "Update the checkout before switching to dev; on $platform it needs $required for this machine's repositories." + fi + done + fi + omarchy-dev-link "$checkout" --no-reboot } @@ -102,9 +119,6 @@ case "$channel" in packages=(omarchy-dev omarchy-settings-dev) ;; dev) - confirm_dev || { echo "Cancelled."; exit 0; } - dev_checkout="$HOME/omarchy" - validate_dev_checkout "$dev_checkout" pacman_channel=edge packages=(omarchy-dev omarchy-settings-dev) ;; @@ -115,6 +129,20 @@ case "$channel" in ;; esac +# A channel this machine's platform has no templates for is refused before +# anything changes (install/helpers/pacman.sh). +source "$OMARCHY_PATH/install/helpers/pacman.sh" +platform=$(omarchy-hw-platform) || fail "Cannot tell this machine's platform; nothing changed." +templates=$(omarchy_pacman_templates "$platform") || exit 1 +[[ -f $templates/pacman-$pacman_channel.conf && -f $templates/mirrorlist-$pacman_channel ]] || + fail "Omarchy has no $pacman_channel channel for $platform; nothing changed." + +if [[ $channel == "dev" ]]; then + confirm_dev || { echo "Cancelled."; exit 0; } + dev_checkout="$HOME/omarchy" + validate_dev_checkout "$dev_checkout" +fi + # A failure past this point leaves the channel switch half-applied, so say how # to pick it back up rather than dying silently under set -e. trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR diff --git a/bin/omarchy-drive-password b/bin/omarchy-drive-password index 2fbbb187..e1d9dc74 100755 --- a/bin/omarchy-drive-password +++ b/bin/omarchy-drive-password @@ -1,32 +1,432 @@ #!/bin/bash -# omarchy:summary=Set a new encryption password for a drive selected. +# omarchy:summary=Set a new password for an encrypted drive # omarchy:requires-sudo=true -encrypted_drives=$(blkid -t TYPE=crypto_LUKS -o device) +# Changes a drive's LUKS key and nothing else. For the disk holding /, the new +# key must open the disk and the old one must not before the change counts. The +# journal records only the disk's UUID, key slots and phase, so a change that +# stops part way is finished by the next run. A platform whose boot checks prove +# the disk's key slots records the owner's new one before the run finishes. + +# Passwords live in variables; never trace them. +set +x + +REKEY_STATE=${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/drive-password.state +source "$OMARCHY_PATH/install/provisioning/luks-rekey.sh" + +# The LUKS partition beneath /, or nothing when / is not encrypted. Fails when +# it cannot tell, including a crypt layer whose LUKS partition lsblk cannot name. +root_luks_device() { + local root_source ancestry device + root_source=$(findmnt -no SOURCE /) && [[ -n $root_source ]] || return 1 + ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${root_source%%[*}") || return 1 + device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry") + if [[ -n $device ]]; then + readlink -f "$device" + elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then + return 1 + fi +} + +# Every LUKS device, from udev's probe as root_luks_device reads it. An +# unprivileged blkid only reads /run/blkid, empty until root runs blkid. lsblk +# repeats a device under each parent (RAID members), so each is listed once. +luks_drives() { + lsblk -nrpo NAME,FSTYPE | awk '$2 == "crypto_LUKS" && !seen[$1]++ { print $1 }' +} + +# Every cryptsetup call holds the disk lock, so one left running by a run that +# was killed finishes before the next run looks at the drive. + +# Print the key slot the password opens. Returns 2 when it opens none, and +# another failure when the drive cannot be checked. cryptsetup open tries +# enrolled tokens (TPM2, FIDO2, keyring) before the key and reports a token's +# slot whatever key it was given, so allow only a token type no token has. +drive_key_slot() { + local drive=$1 password=$2 out status=0 + out=$(printf '%s' "$password" | sudo flock "$disk_lock" env LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file - "$drive" 2>&1) || status=$? + (( status == 0 )) || return "$status" + grep -o 'Key slot [0-9]* unlocked' <<<"$out" | grep -o '[0-9]*' | head -1 +} + +# The enabled key slots, comma-separated. A luks2-keyring token under "Tokens:" +# looks like a keyslot, so read only the keyslot section. +drive_slots() { + local dump + dump=$(sudo flock "$disk_lock" cryptsetup luksDump "$1") || return 1 + awk ' + /^[^ \t]/ { keyslots = ($0 == "Keyslots:") } + keyslots && /^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 } + /^Key Slot [0-9]+: ENABLED/ { sub(":", "", $3); print $3 }' <<<"$dump" | paste -sd, - +} + +# The key slots other than FOUND that the password also opens, comma-separated, +# or nothing. Fails when a slot cannot be checked. +other_key_slots() { + local drive=$1 password=$2 found=$3 slots slot status + local -a others=() + slots=$(drive_slots "$drive") || return 1 + for slot in ${slots//,/ }; do + [[ $slot != "$found" ]] || continue + status=0 + printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup open --test-passphrase --token-type passphrase-only --key-slot "$slot" --key-file - "$drive" >/dev/null 2>&1 || status=$? + if (( status == 0 )); then + others+=("$slot") + elif (( status != 2 )); then + return 1 + fi + done + local IFS=, + printf '%s' "${others[*]}" +} + +kill_slot() { + local drive=$1 password=$2 slot=$3 + printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup luksKillSlot -q --key-file - "$drive" "$slot" +} + +# luksChangeKey writes the new key before it retires the old one (LUKS2 swaps +# them in one header update), so a failure that leaves the new key unable to +# open the drive changed nothing: that returns 2. One that stops in between +# leaves both keys, so the old key is retired here until it opens nothing. +# Sets new_slot. +change_luks_key() { + local drive=$1 current=$2 new=$3 old_slot=$4 remains attempt status + local -a pbkdf=(--iter-time 2000) + + echo "Changing full-disk encryption password for $drive" + if sudo flock "$disk_lock" cryptsetup isLuks --type luks2 "$drive"; then + pbkdf+=(--pbkdf argon2id) + fi + # Both keys travel over stdin, the current one on the first line. + printf '%s\n%s' "$current" "$new" | + sudo flock "$disk_lock" bash -c 'IFS= read -r current; drive=$1; shift; exec cryptsetup luksChangeKey "$@" --key-file <(printf "%s" "$current") "$drive" <(cat)' bash "$drive" "${pbkdf[@]}" + + new_slot=$(drive_key_slot "$drive" "$new") || return + for (( attempt = 0; attempt < 8; attempt++ )); do + status=0 + remains=$(drive_key_slot "$drive" "$current") || status=$? + (( status == 2 )) && return 0 + (( status == 0 )) && [[ $remains != "$new_slot" ]] || return 1 + kill_slot "$drive" "$new" "$remains" || return 1 + done + return 1 +} + +# The entrypoint that records the system disk's key slots for the platform's +# boot checks, or nothing where none does. A machine without its platform's +# boot package at all (dispatch exit 3) predates it, and nothing checks its +# slots. Fails with the dispatcher's message otherwise, such as a boot package +# too old to ship luks-slots. +slot_recorder() { + local out status=0 + out=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve luks-slots 2>&1) || status=$? + if (( status == 0 )); then + printf '%s' "$out" + elif (( status != 3 )); then + printf '%s\n' "$out" >&2 + return 1 + fi +} + +# Refuse a current password that opens any slot but the owner's where the +# platform records the slots: on a disk an earlier setup gave a second key (a +# recovery key), changing that one would leave the owner's password as it was. +# luks-slots --owner prints the recorded slot (0), or exits 4 when no owner slot +# is recorded, as on a Mac set up before its boot package recorded slots; any +# other status is a failure the entrypoint explains, and stops the change. +# Where nothing is recorded, a password that opens the disk's only key slot is +# the owner's, and the change records its new slot (unrecorded_owner). +owner_slot_matches() { + local slot=$1 recorder owner errors reason status=0 + recorder=$(slot_recorder) || return 1 + [[ -n $recorder ]] || return 0 + errors=$(mktemp) || return 1 + owner=$(sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots --owner 2>"$errors") || status=$? + reason=$(<"$errors") + rm -f "$errors" + if (( status == 4 )); then + only_key_slot "$slot" || return 1 + unrecorded_owner=1 + return 0 + elif (( status != 0 )); then + echo "Could not read which key slot holds the system disk password: ${reason:-luks-slots --owner exited $status}" + return 1 + elif [[ ! $owner =~ ^[0-9]+$ ]]; then + echo "Could not read which key slot holds the system disk password: the platform's boot package named '$owner', which is not a key slot." + return 1 + fi + if [[ $owner != "$slot" ]]; then + echo "That password opens another key slot on the system disk, such as a recovery key from an earlier setup. Enter the system disk password." + return 1 + fi +} + +# With no owner slot recorded, the password must open the system disk's one key +# slot: with more keys there is no telling which is the owner's, and recording +# only the owner's would fail the platform's boot check, which wants every kept +# slot recorded. Says how to settle it. +only_key_slot() { + local slot=$1 slots others + slots=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; } + [[ $slots != "$slot" ]] || return 0 + others=$(tr ',' '\n' <<<"$slots" | grep -vx -- "$slot" | paste -sd, -) + echo "No key slot is recorded as the system disk password's, and $root_drive has keys in slots $slots, so which one is yours can't be told. The password you entered opens slot $slot." + if [[ $others =~ ^[0-9]+$ ]]; then + echo "If that password is yours and slot $others holds a recovery key you keep, record both: sudo omarchy-lifecycle-dispatch luks-slots owner=$slot recovery=$others" + echo "Otherwise remove the key you don't use: sudo cryptsetup luksKillSlot $root_drive " + else + echo "Remove the keys you don't use: sudo cryptsetup luksKillSlot $root_drive " + fi + echo "Then run omarchy-drive-password again." + return 1 +} + +# Record the slot the owner's password opens now; a no-op where nothing does. +record_owner_slot() { + local recorder + recorder=$(slot_recorder) || return 1 + [[ -z $recorder ]] || sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots "owner=$1" +} + +# Phase luks: the key change may or may not have landed. Phase record: the new +# key alone opens the recorded slot, which the boot checks still need. +change_system_password() { + local current=$1 new=$2 old_slot=$3 slots status=0 + + slots=$(drive_slots "$root_drive") + if [[ ,$slots, != *",$old_slot,"* ]]; then + echo "Could not read the key slots of $root_drive." + return 1 + fi + # A disk taken as the owner's for holding one key must still hold only it. + if (( unrecorded_owner )) && [[ $slots != "$old_slot" ]]; then + echo "The key slots of $root_drive changed while you entered the new password. Run omarchy-drive-password again." + return 1 + fi + mkdir -p "${REKEY_STATE%/*}" && + rekey_state_put uuid "$root_uuid" old_slot "$old_slot" slots "$slots" checked 1 phase luks || return 1 + + change_luks_key "$root_drive" "$current" "$new" "$old_slot" || status=$? + if (( status == 2 )); then + rm -f "$REKEY_STATE" + echo "The disk password did not change." + return 1 + elif (( status != 0 )); then + echo "Could not confirm the disk password change. Run omarchy-drive-password again to finish." + return 1 + fi + rekey_state_put slot "$new_slot" phase record || return 1 + + if ! record_owner_slot "$new_slot"; then + echo "Could not record the new key slot for the boot checks. Run omarchy-drive-password again to finish." + return 1 + fi + rm -f "$REKEY_STATE" +} + +# A journal left on this system's disk by a change that stopped part way. +change_pending() { + local phase uuid + phase=$(rekey_state_get phase) || return 1 + uuid=$(rekey_state_get uuid) + if [[ $phase == "luks" || $phase == "record" || $phase == "accounts" ]] && [[ -n $uuid && $uuid == "$root_uuid" ]]; then + return 0 + fi + rm -f "$REKEY_STATE" + return 1 +} + +# Settle the disk on the password it opens with now, then record its slot. +# Before the change was confirmed that is the old key (removing the slot the +# change added, if any) or the new key in the one slot the change added +# (retiring the old slot); after, only the confirmed slot. Any other key on the +# disk, such as a recovery passphrase, is refused, and so is a disk that gained +# more than one slot since. +finish_interrupted_change() { + local phase old_slot slots slot header password opened now added extra count=0 status=0 + + phase=$(rekey_state_get phase) + # Earlier versions named the confirmed phase "accounts". + [[ $phase != "accounts" ]] || phase=record + old_slot=$(rekey_state_get old_slot) + slots=$(rekey_state_get slots) + slot=$(rekey_state_get slot) + + if [[ $phase == "luks" && ! ( $old_slot =~ ^[0-9]+$ && ,$slots, == *",$old_slot,"* ) ]] || + [[ $phase == "record" && ! $slot =~ ^[0-9]+$ ]]; then + echo "$REKEY_STATE is unreadable. Remove it once the disk opens with only the password you want." + return 1 + fi + + # Earlier versions journaled the change without checking that the old + # password opened one slot alone (checked=1), so with other slots on the disk + # a copy of it may have outlived the change. No password entered now can show + # which slot was whose, so the owner settles that by hand. + if [[ -z $(rekey_state_get checked) ]]; then + now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; } + if [[ $now == *,* ]]; then + echo "The last password change for $root_drive was started by an earlier version of omarchy-drive-password, and the disk has key slots $now, so it may still hold a copy of the old password." + echo "Settle it by hand, in this order:" + echo "1. Find the slot the password you want to keep opens, and keep it: sudo cryptsetup open --test-passphrase --token-type passphrase-only --verbose $root_drive names it. If only the old password opens the disk, that is the one you keep." + echo "2. If the password you keep is not the old one, check each other slot with the old password (sudo cryptsetup open --test-passphrase --token-type passphrase-only --key-slot $root_drive) and remove each one it opens (sudo cryptsetup luksKillSlot $root_drive ). Never remove the slot from step 1." + if [[ -n $(slot_recorder 2>/dev/null) ]]; then + echo "3. Record the slots you kept for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner= recovery=, with an empty recovery= when no recovery key is left." + fi + echo "Then remove $REKEY_STATE, and run omarchy-drive-password again if the disk should still take a new password." + return 1 + fi + fi + + echo "The last password change for $root_drive stopped part way." + if [[ $phase == "record" ]]; then + header="New encryption password" + else + header="Encryption password that opens $root_drive now (the new one if the change went through)" + fi + password=$(gum input --password --header "$header") || return 1 + + opened=$(drive_key_slot "$root_drive" "$password") || status=$? + if (( status != 0 && status != 2 )); then + echo "Could not check the password against $root_drive." + return 1 + fi + + if [[ $phase == "luks" && -n $opened ]]; then + now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; } + for extra in ${now//,/ }; do + if [[ ,$slots, != *",$extra,"* ]]; then + added=$extra + count=$(( count + 1 )) + fi + done + if (( count > 1 )); then + echo "The key slots of $root_drive changed since the interrupted change. Finish it by hand." + return 1 + fi + + if [[ $opened == "$old_slot" ]]; then + [[ -z $added ]] || kill_slot "$root_drive" "$password" "$added" || return 1 + elif [[ -n $added && $opened == "$added" ]]; then + if [[ ,$now, == *",$old_slot,"* ]]; then + kill_slot "$root_drive" "$password" "$old_slot" || return 1 + fi + else + opened="" + fi + [[ -z $opened ]] || rekey_state_put slot "$opened" phase record || return 1 + elif [[ $phase == "record" && $opened != "$slot" ]]; then + opened="" + fi + + if [[ -z $opened ]]; then + echo "That password does not finish the change on $root_drive. Run omarchy-drive-password again to finish." + return 1 + fi + + if ! record_owner_slot "$opened"; then + echo "Could not record the key slot for the boot checks. Run omarchy-drive-password again to finish." + return 1 + fi + rm -f "$REKEY_STATE" + echo "$root_drive now opens with the password you entered, and the change is settled." +} + +change_selected_drive() { + local encrypted_drives drive current old_slot others new_password confirmation status=0 system_drive=false + + encrypted_drives=$(luks_drives) + if [[ -z $encrypted_drives ]]; then + echo "No encrypted drives available." + return 1 + fi -if [[ -n $encrypted_drives ]]; then if (( $(wc -l <<<"$encrypted_drives") == 1 )); then - drive_to_change="$encrypted_drives" + drive="$encrypted_drives" else - drive_to_change="$(omarchy-drive-select "$encrypted_drives")" + drive="$(omarchy-drive-select "$encrypted_drives")" fi - if [[ -n $drive_to_change ]]; then - new_password=$(gum input --password --header "New encryption password") || exit 1 - [[ -n $new_password ]] || { echo "Password cannot be empty."; exit 1; } - - confirmation=$(gum input --password --header "Confirm new encryption password") || exit 1 - [[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; exit 1; } - - echo "Changing full-disk encryption password for $drive_to_change" - # The new key travels over stdin and reaches cryptsetup as a keyfile via - # <(cat), leaving the tty free for the current-passphrase prompt. - printf "%s" "$new_password" | sudo bash -c 'exec cryptsetup luksChangeKey --pbkdf argon2id --iter-time 2000 "$1" <(cat) ), then run omarchy-drive-password again." + if [[ -n $(slot_recorder 2>/dev/null) ]]; then + echo "Then record the slots you keep for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner= recovery=, with an empty recovery= when no recovery key is left." + fi + echo "The system disk password did not change." + return 1 + fi + fi + + new_password=$(gum input --password --header "New encryption password") || return 1 + [[ -n $new_password ]] || { echo "Password cannot be empty."; return 1; } + + confirmation=$(gum input --password --header "Confirm new encryption password") || return 1 + [[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; return 1; } + [[ $new_password != "$current" ]] || { echo "The new password is the current one."; return 1; } + # Its slot is how the change is followed, so it must not open one already. + status=0 + drive_key_slot "$drive" "$new_password" >/dev/null || status=$? + if (( status == 0 )); then + echo "That password already opens a key slot on $drive. Choose another." + return 1 + elif (( status != 2 )); then + echo "Could not check the password against $drive." + return 1 + fi + status=0 + + if [[ $system_drive == "true" ]]; then + change_system_password "$current" "$new_password" "$old_slot" + else + change_luks_key "$drive" "$current" "$new_password" "$old_slot" || status=$? + if (( status == 2 )); then + echo "The password of $drive did not change." + return 1 + elif (( status != 0 )); then + echo "Could not confirm the password change on $drive." + return 1 + fi + fi +} + +disk_lock=${REKEY_STATE%/*}/drive-password.disk.lock +mkdir -p "${REKEY_STATE%/*}" && touch "$disk_lock" && exec {lock_fd}>"${REKEY_STATE%/*}/drive-password.lock" && flock -n "$lock_fd" || + { echo "Another drive password change is running."; exit 1; } + +unrecorded_owner=0 +root_drive=$(root_luks_device) || { echo "Could not tell which drive holds the system."; exit 1; } +root_uuid="" +if [[ -n $root_drive ]]; then + root_uuid=$(lsblk -dno UUID "$root_drive") && [[ -n $root_uuid ]] || { echo "Could not read the UUID of $root_drive."; exit 1; } +fi + +if change_pending; then + finish_interrupted_change else - echo "No encrypted drives available." - exit 1 + change_selected_drive fi diff --git a/bin/omarchy-hibernation-setup b/bin/omarchy-hibernation-setup index 1aeb0d75..1c2dde0a 100755 --- a/bin/omarchy-hibernation-setup +++ b/bin/omarchy-hibernation-setup @@ -13,6 +13,12 @@ for arg in "$@"; do esac done +# Resume is Limine-oriented and hibernate is unreliable on Asahi. +if omarchy-hw-aarch64-apple; then + echo "Skipping hibernation setup (not supported on Apple Silicon)" + exit 0 +fi + if [[ ! -f /sys/power/image_size ]]; then echo -e "Hibernation is not supported on your system" >&2 exit 0 diff --git a/bin/omarchy-hw-aarch64 b/bin/omarchy-hw-aarch64 new file mode 100755 index 00000000..7c228ac5 --- /dev/null +++ b/bin/omarchy-hw-aarch64 @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Detect whether the CPU is aarch64 (every ARM machine, Apple Silicon included). + +[[ $(uname -m) == "aarch64" ]] diff --git a/bin/omarchy-hw-aarch64-apple b/bin/omarchy-hw-aarch64-apple new file mode 100755 index 00000000..181b4ad6 --- /dev/null +++ b/bin/omarchy-hw-aarch64-apple @@ -0,0 +1,17 @@ +#!/bin/bash -p + +# omarchy:summary=Detect whether the computer is an Apple Silicon Mac. + +# Callers run this by absolute path, from systemd and from the shell, so use the +# detector shipped beside it rather than whichever one PATH finds. -p keeps a +# root caller's exported functions out, so a launch without it is refused, and +# nothing here searches PATH. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +dir=${BASH_SOURCE[0]%/*} +[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=. +platform=$("$dir/omarchy-hw-platform") || exit 1 +[[ $platform == "aarch64-apple" ]] diff --git a/bin/omarchy-hw-apple-silicon b/bin/omarchy-hw-apple-silicon new file mode 100755 index 00000000..aad94b58 --- /dev/null +++ b/bin/omarchy-hw-apple-silicon @@ -0,0 +1,15 @@ +#!/bin/bash -p + +# omarchy:summary=Detect whether the computer is an Apple Silicon Mac (the old name of omarchy-hw-aarch64-apple). +# omarchy:hidden=true + +# Kept while omarchy-mac's packages and units still call it by this name. Like +# the predicate it runs, it refuses a launch without -p and searches no PATH. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +dir=${BASH_SOURCE[0]%/*} +[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=. +exec "$dir/omarchy-hw-aarch64-apple" diff --git a/bin/omarchy-hw-display b/bin/omarchy-hw-display index d3c6d3e6..aa17c341 100755 --- a/bin/omarchy-hw-display +++ b/bin/omarchy-hw-display @@ -5,14 +5,48 @@ backlight_path="${OMARCHY_BACKLIGHT_PATH:-/sys/class/backlight}" +# The platform package may name backlights to skip and to prefer (see +# docs/file-layout.md). +displays_conf=/usr/share/omarchy-platform/displays.conf +skips=() +prefers=() +if [[ -r $displays_conf ]]; then + # \r in IFS: a file saved with CRLF line ends reads the same. + while IFS=$' \t\r' read -r directive argument extra || [[ -n $directive ]]; do + [[ -n $argument && -z $extra && $argument != */* && $argument != . && $argument != .. ]] || continue + case $directive in + backlight-skip) skips+=("$argument") ;; + backlight-prefer) prefers+=("$argument") ;; + esac + done <"$displays_conf" +fi + +usable() { + local skip + # The Touch Bar on T2 Macs registers a backlight that never drives the display panel. + [[ -n $1 && $1 != appletb_backlight ]] || return 1 + for skip in "${skips[@]}"; do + # shellcheck disable=SC2053 # a skip is a glob + [[ $1 == $skip ]] && return 1 + done + return 0 +} + # Start with the first possible output, then refine to the most likely given an order heuristic. -# Glob the candidates in the loop list: [[ ]] does not do pathname expansion. -# The Touch Bar on T2 Macs registers a backlight that never drives the display panel. -device="$(ls -1 "$backlight_path" 2>/dev/null | grep -vx appletb_backlight | head -n1)" +device="" +while IFS= read -r name; do + if usable "$name"; then + device="$name" + break + fi +done < <(ls -1 "$backlight_path" 2>/dev/null) + # gmux comes first: apple-gmux only registers when the kernel has already picked it, and on -# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends. -for candidate in "$backlight_path"/gmux_backlight "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do - if [[ -e $candidate ]]; then +# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends. The +# platform's preferred backlights follow, then the GPU and ACPI ones. +# Glob the candidates in the loop list: [[ ]] does not do pathname expansion. +for candidate in "$backlight_path"/gmux_backlight "${prefers[@]/#/$backlight_path/}" "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do + if [[ -e $candidate ]] && usable "${candidate##*/}"; then device="${candidate##*/}" break fi diff --git a/bin/omarchy-hw-laptop b/bin/omarchy-hw-laptop index 0ffbeeb3..47664d12 100755 --- a/bin/omarchy-hw-laptop +++ b/bin/omarchy-hw-laptop @@ -2,15 +2,39 @@ # omarchy:summary=Returns true when running on a laptop (has a lid or laptop chassis). +acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}" +input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}" +dmi_chassis="${OMARCHY_DMI_CHASSIS_TYPE_PATH:-/sys/class/dmi/id/chassis_type}" + +shopt -s nullglob +acpi_states=("$acpi_lid"/*/state) +input_switches=("$input_class"/*/capabilities/sw) +shopt -u nullglob + # A lid switch is the definitive signal for clamshell-capable hardware. -for state in /proc/acpi/button/lid/*/state; do +for state in "${acpi_states[@]}"; do [[ -e $state ]] && exit 0 done +# Without an ACPI lid (Apple Silicon, for one), any input device that reports a +# lid switch (SW_LID, bit 0 of its switch capabilities, the last word of the +# bitmap) counts. The switch's name varies: Apple's SMC input device carries +# the same name on a Mac mini, where it reports no lid. +for switches in "${input_switches[@]}"; do + [[ -r $switches ]] || continue + read -r -a words <"$switches" || true + (( ${#words[@]} )) || continue + word=${words[-1]} + [[ $word =~ ^[0-9a-fA-F]+$ ]] || continue + (( 16#$word & 1 )) && exit 0 +done + # Fall back to the DMI chassis type for laptops that don't expose an ACPI lid # button. 8=Portable 9=Laptop 10=Notebook 14=Sub Notebook 30=Tablet # 31=Convertible 32=Detachable. -case $(< /sys/class/dmi/id/chassis_type 2>/dev/null) in +chassis_type="" +read -r chassis_type <"$dmi_chassis" 2>/dev/null || true +case $chassis_type in 8 | 9 | 10 | 14 | 30 | 31 | 32) exit 0 ;; esac diff --git a/bin/omarchy-hw-laptop-closed b/bin/omarchy-hw-laptop-closed index 8e03f965..52eb4778 100755 --- a/bin/omarchy-hw-laptop-closed +++ b/bin/omarchy-hw-laptop-closed @@ -3,9 +3,45 @@ # omarchy:summary=Returns true when the laptop lid is closed # omarchy:hidden=true -for state in /proc/acpi/button/lid/*/state; do - [[ -r $state ]] || continue - [[ $(< "$state") == *"closed"* ]] && exit 0 -done +acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}" +input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}" + +shopt -s nullglob +acpi_states=("$acpi_lid"/*/state) +input_switches=("$input_class"/*/capabilities/sw) +shopt -u nullglob + +# An input device that reports a lid switch: SW_LID, bit 0 of the last word +# of its switch bitmap. +has_lid_switch() { + local switches word + local -a words + + for switches in "${input_switches[@]}"; do + [[ -r $switches ]] || continue + read -r -a words <"$switches" || true + (( ${#words[@]} )) || continue + word=${words[-1]} + [[ $word =~ ^[0-9a-fA-F]+$ ]] || continue + (( 16#$word & 1 )) && return 0 + done + return 1 +} + +if (( ${#acpi_states[@]} )); then + # An ACPI lid reports its own state. + for state in "${acpi_states[@]}"; do + [[ -r $state ]] || continue + [[ $(<"$state") == *"closed"* ]] && exit 0 + done +elif has_lid_switch; then + # Elsewhere (Apple Silicon's SMC, for one) only logind reads the switch. Ask + # it only then: it answers "open" when it tracks no lid. This runs in sudo's + # and polkit's PAM stack where fingerprint login is set up, so a stalled + # logind must not hold up every sudo. + lid_state=$(busctl --timeout=2 get-property org.freedesktop.login1 \ + /org/freedesktop/login1 org.freedesktop.login1.Manager LidClosed 2>/dev/null) + [[ $lid_state == "b true" ]] && exit 0 +fi exit 1 diff --git a/bin/omarchy-hw-platform b/bin/omarchy-hw-platform new file mode 100755 index 00000000..737fb136 --- /dev/null +++ b/bin/omarchy-hw-platform @@ -0,0 +1,202 @@ +#!/bin/bash -p + +# omarchy:summary=Print the hardware platform: x86, aarch64 or aarch64-apple. + +# Platform names are the CPU architecture, then the hardware family when it +# needs handling of its own: x86, aarch64 (every ARM machine without one) and +# aarch64-apple (Apple Silicon). A later family is aarch64-, so +# "aarch64*" always means ARM (AGENTS.md, Platforms). +# +# ARM laptops have no DMI, so the board identity is the root node of the device +# tree the firmware hands the kernel: "apple," from Asahi's m1n1 on every +# Apple Silicon Mac, "qcom," on Snapdragon laptops, which are plain aarch64 +# here and must never pass for a Mac. Each compatible string is a NUL-separated +# list, and only a token's vendor prefix counts. +# +# An image is built away from the machine it will run on, so while this root is +# being built rather than booted, the image-target manifest the builder wrote +# names the platform and the build host's device tree is never read. +# +# Fixture roots let unprivileged tests fake the device tree, PID 1 and the image +# root. Naming any of them makes the whole view a fixture: nothing live is read, +# not even the device tree when only the image root is named. A privileged +# caller must not let its environment pick the platform at all. +# Privileged mode (-p) keeps exported functions and BASH_ENV out of this shell, +# so a launch without it is refused. Root then starts over in an empty +# environment and reads only the live paths with a fixed PATH. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +if (( EUID == 0 )) && [[ ${1:-} != --clean-environment ]]; then + exec /usr/bin/env -i PATH=/usr/bin /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@" +fi + +if (( EUID == 0 )); then + export PATH=/usr/bin + fixtures="" +else + fixtures=${OMARCHY_PROC_ROOT:-}${OMARCHY_SYS_ROOT:-}${OMARCHY_IMAGE_ROOT:-} +fi + +if [[ -z $fixtures ]]; then + proc=/proc + sources=(/proc/device-tree/compatible /sys/firmware/devicetree/base/compatible) + image_root="" + image_dir=/var/lib/omarchy/image + image_owner=0 +else + proc=${OMARCHY_PROC_ROOT:-} + sources=() + [[ -z ${OMARCHY_PROC_ROOT:-} ]] || sources+=("$OMARCHY_PROC_ROOT/device-tree/compatible") + [[ -z ${OMARCHY_SYS_ROOT:-} ]] || sources+=("$OMARCHY_SYS_ROOT/firmware/devicetree/base/compatible") + image_root=${OMARCHY_IMAGE_ROOT:-} + image_dir=${image_root:+$image_root/var/lib/omarchy/image} + image_owner=$EUID +fi +manifest=$image_dir/target + +contradiction() { + echo "Error: contradictory platform identity: $1" >&2 + exit 1 +} + +if ! machine=$(uname -m) || [[ -z $machine ]]; then + echo "Error: cannot read the CPU architecture" >&2 + exit 1 +fi + +# A booted system is one systemd runs as PID 1 from this root. An image build +# is told apart by what it shows: no /run/systemd/system (a root with a /run of +# its own), PID 1's root being another (a chroot on the build host, even with +# the host's /run bound in, as arch-chroot does), or PID 1 being something other +# than systemd (a PID namespace whose PID 1 is the build). What cannot be seen, +# such as PID 1's root to a normal user or /proc/1 behind hidepid, never makes a +# build: a booted system always answers from its hardware, so a build runs as +# root. A caller in a private PID namespace looks like a build too, so nothing +# that asks the detector runs with PrivatePIDs=. +booted() { + local comm + + [[ -d $image_root/run/systemd/system ]] || return 1 + if [[ -n $proc && -e $proc/1/root ]]; then + [[ $proc/1/root -ef $image_root/ ]] || return 1 + fi + if [[ -n $proc && -r $proc/1/comm ]]; then + read -r comm <"$proc/1/comm" && [[ $comm == "systemd" ]] || return 1 + fi +} + +# Owned by root (the test's own user for a fixture), not a symlink, and not +# writable by anyone else. +trusted() { + local owner mode + + [[ -e $1 && ! -L $1 ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1 + [[ $owner =~ ^[0-9]+$ && $mode =~ ^[0-7]+$ ]] && (( owner == image_owner && (8#$mode & 8#022) == 0 )) +} + +# The manifest: format=1, platform= one of the platform names, comments and +# unknown keys ignored, and any other line refused. Image builders written before +# the names settled say apple-silicon, generic-aarch64 or generic, which still +# read as aarch64-apple, aarch64 and x86. The detector reads it itself, so it +# stands alone wherever a package ships a copy of it. +image_platform() { + local line key value format="" platform="" + + if [[ ! -d $image_dir || ! -f $manifest ]] || ! trusted "$image_dir" || ! trusted "$manifest"; then + echo "Error: the image manifest $manifest is not a root-owned regular file" >&2 + return 1 + fi + + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && $line != \#* ]] || continue + if [[ $line != *=* ]]; then + echo "Error: the image manifest $manifest is malformed: $line" >&2 + return 1 + fi + key=${line%%=*} + value=${line#*=} + case $key in + format) format=$value ;; + platform) platform=$value ;; + esac + done <"$manifest" + + if [[ $format != "1" ]]; then + echo "Error: the image manifest $manifest is not format=1" >&2 + return 1 + fi + case $platform in + apple-silicon) platform=aarch64-apple ;; + generic-aarch64) platform=aarch64 ;; + generic) platform=x86 ;; + esac + if [[ ! $platform =~ ^(x86|aarch64|aarch64-apple)$ ]]; then + echo "Error: the image manifest $manifest names no known platform: ${platform:-none}" >&2 + return 1 + fi + + echo "$platform" +} + +if [[ -n $image_dir ]] && [[ -e $manifest || -L $manifest ]] && ! booted; then + platform=$(image_platform) || exit 1 + if [[ $platform == "x86" ]]; then + [[ $machine != "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine" + else + [[ $machine == "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine" + fi + echo "$platform" + exit 0 +fi + +vendor="" +vendor_source="" +for source in "${sources[@]}"; do + [[ -r $source ]] || continue + + apple=0 + qcom=0 + mapfile -d '' -t tokens <"$source" + for token in "${tokens[@]}"; do + [[ $token == "apple,"* ]] && apple=1 + [[ $token == "qcom,"* ]] && qcom=1 + done + + if (( apple && qcom )); then + contradiction "$source names both Apple and Qualcomm hardware" + elif (( apple )); then + found=apple + elif (( qcom )); then + found=qcom + else + found=none + fi + + if [[ -n $vendor && $vendor != "$found" ]]; then + contradiction "$vendor_source says $vendor but $source says $found" + fi + vendor=$found + vendor_source=$source +done + +case $vendor in + apple | qcom) + [[ $machine == "aarch64" ]] || contradiction "$vendor_source names $vendor hardware but the CPU is $machine" + if [[ $vendor == "apple" ]]; then + echo aarch64-apple + else + echo aarch64 + fi + ;; + *) + if [[ $machine == "aarch64" ]]; then + echo aarch64 + else + echo x86 + fi + ;; +esac diff --git a/bin/omarchy-hw-touchpad b/bin/omarchy-hw-touchpad index b5e4b777..28ffc4d8 100755 --- a/bin/omarchy-hw-touchpad +++ b/bin/omarchy-hw-touchpad @@ -2,5 +2,30 @@ # omarchy:summary=Print the detected Hyprland touchpad or trackpad device name -device=$(hyprctl devices -j | jq -r '[.mice[] | .name | select(test("touchpad|trackpad"; "i"))] | first // empty') +mice=$(hyprctl devices -j | jq -r '.mice[].name') +device=$(grep -iE -m1 'touchpad|trackpad' <<<"$mice") + +# Some touchpads carry neither word (Apple Silicon's is "apple-mtp-multi-touch"), +# so ask udev which mouse is one. The name alone can't say: some touchscreens' +# mouse interfaces are called multi-touch too. +if [[ -z $device ]]; then + input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}" + for event in "$input_class"/event*; do + [[ -r $event/device/name ]] || continue + # Hyprland's name for a device: the kernel's, lowercased, with spaces, + # commas and newlines as dashes, and -1, -2 and so on after a name another + # device already took. + name=$(<"$event/device/name") + name=${name,,} + name=${name//[ ,$'\n']/-} + udevadm info --query=property "$event" 2>/dev/null | grep -Fxq 'ID_INPUT_TOUCHPAD=1' || continue + while IFS= read -r mouse; do + if [[ $mouse == "$name" || ( $mouse == "$name"-* && ${mouse#"$name"-} =~ ^[0-9]+$ ) ]]; then + device=$mouse + break 2 + fi + done <<<"$mice" + done +fi + [[ -n $device ]] && echo "$device" diff --git a/bin/omarchy-hw-x86 b/bin/omarchy-hw-x86 new file mode 100755 index 00000000..95074173 --- /dev/null +++ b/bin/omarchy-hw-x86 @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Detect whether the CPU is x86_64. + +[[ $(uname -m) == "x86_64" ]] diff --git a/bin/omarchy-hyprland-monitor-watch b/bin/omarchy-hyprland-monitor-watch index 33e8f2c5..b7199e17 100755 --- a/bin/omarchy-hyprland-monitor-watch +++ b/bin/omarchy-hyprland-monitor-watch @@ -10,6 +10,7 @@ sync_clamshell() { ( flock -n 9 || exit 0 omarchy-hyprland-monitor-clamshell + omarchy-system-lid-inhibit ) 9>"$LOCK" } diff --git a/bin/omarchy-install-browser b/bin/omarchy-install-browser index 4593bc72..4e208422 100755 --- a/bin/omarchy-install-browser +++ b/bin/omarchy-install-browser @@ -8,6 +8,8 @@ set -e source "$OMARCHY_PATH/install/helpers/browser-policy.sh" +opt_path="${OMARCHY_OPT_PATH:-/opt}" + setup_chromium_policy_directory() { browser_policy_setup_dir "$1" } @@ -17,6 +19,17 @@ announce_browser_installed() { echo "$1 browser installed. Make it the default via Setup > Defaults > Browser." } +# The widevine package registers its CDM for Chromium and Firefox only; Brave +# looks for one in its own install directory. +link_widevine_cdm() { + local cdm="$opt_path/WidevineCdm/chromium" + local install_dir="$opt_path/$1" + + if [[ -d $cdm && -d $install_dir && ! -e $install_dir/WidevineCdm ]]; then + as_root ln -sfn "$cdm" "$install_dir/WidevineCdm" + fi +} + copy_chromium_flags() { mkdir -p ~/.config cp -f "$OMARCHY_PATH/config/chromium-flags.conf" "$1" @@ -37,6 +50,7 @@ chromium) setup_chromium_policy_directory /etc/chromium/policies/managed copy_chromium_flags ~/.config/chromium-flags.conf omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install chromium announce_browser_installed "Chromium" ;; chrome) @@ -46,6 +60,7 @@ chrome) setup_chromium_policy_directory /etc/opt/chrome/policies/managed copy_chromium_flags ~/.config/chrome-flags.conf omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install chrome announce_browser_installed "Chrome" ;; edge) @@ -55,6 +70,7 @@ edge) setup_chromium_policy_directory /etc/opt/edge/policies/managed copy_chromium_flags ~/.config/microsoft-edge-stable-flags.conf omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install edge announce_browser_installed "Edge" ;; brave) @@ -63,7 +79,9 @@ brave) setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-flags.conf + link_widevine_cdm brave-bin omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install brave announce_browser_installed "Brave" ;; brave-origin) @@ -72,7 +90,9 @@ brave-origin) setup_chromium_policy_directory /etc/brave/policies/managed copy_chromium_flags ~/.config/brave-origin-flags.conf + link_widevine_cdm brave-origin-bin omarchy-theme-set-browser + omarchy-lifecycle-dispatch post-install brave-origin announce_browser_installed "Brave Origin" ;; firefox) @@ -81,6 +101,7 @@ firefox) browser_policy_setup_firefox_distribution /usr/lib/firefox/distribution setup_firefox_wayland + omarchy-lifecycle-dispatch post-install firefox announce_browser_installed "Firefox" ;; zen) @@ -89,6 +110,7 @@ zen) browser_policy_setup_firefox_distribution /opt/zen-browser/distribution setup_firefox_wayland + omarchy-lifecycle-dispatch post-install zen announce_browser_installed "Zen" ;; *) diff --git a/bin/omarchy-install-dictation-voxtype b/bin/omarchy-install-dictation-voxtype index 79011e1d..7202ca4d 100755 --- a/bin/omarchy-install-dictation-voxtype +++ b/bin/omarchy-install-dictation-voxtype @@ -5,6 +5,8 @@ set -e +voxtype_lib_path="${OMARCHY_VOXTYPE_LIB_PATH:-/usr/lib/voxtype}" + if omarchy-cmd-missing voxtype; then if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then omarchy-pkg-add wtype voxtype-bin @@ -17,7 +19,8 @@ config_home="${XDG_CONFIG_HOME:-$HOME/.config}" mkdir -p "$config_home/voxtype" if [[ ! -e $config_home/voxtype/config.toml ]]; then cp "$OMARCHY_PATH/default/voxtype/config.toml" "$config_home/voxtype/" - if omarchy-hw-vulkan; then + # A Vulkan runtime is not enough: aarch64 voxtype-bin ships no Vulkan backend. + if omarchy-hw-vulkan && [[ -x $voxtype_lib_path/voxtype-vulkan ]]; then voxtype setup gpu --enable || true fi fi diff --git a/bin/omarchy-install-gaming-steam b/bin/omarchy-install-gaming-steam index 8b977f2c..6837878b 100755 --- a/bin/omarchy-install-gaming-steam +++ b/bin/omarchy-install-gaming-steam @@ -15,6 +15,7 @@ echo "Installing Steam..." # GPU. omarchy-install-gaming-gpu-lib32 omarchy-pkg-add steam +omarchy-lifecycle-dispatch post-install steam echo "" echo "Steam will start automatically now. This might take a while..." diff --git a/bin/omarchy-lifecycle-dispatch b/bin/omarchy-lifecycle-dispatch new file mode 100755 index 00000000..53fa6c41 --- /dev/null +++ b/bin/omarchy-lifecycle-dispatch @@ -0,0 +1,200 @@ +#!/bin/bash -p + +# omarchy:summary=Run a fixed lifecycle operation through the platform's own packages +# omarchy:args=[--resolve] [arguments...] +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# Provisioning, factory reset, update, boot, system and user setup and app +# installs call a fixed set of operations here. A platform that needs its own +# handling registers directories of root-owned entrypoints below; on every other +# platform each operation is a no-op and the caller keeps its generic path. The +# contract is docs/lifecycle-dispatch.md. +# +# --resolve prints the entrypoint an operation would run, or nothing when it is +# a no-op here, so a caller can choose between a platform implementation and +# its own generic path. +# +# Root runs this from provisioning, factory reset and update, and it decides +# what runs as root. -p keeps a root caller's environment from running code in +# this shell before the first line (bash then ignores BASH_ENV and exported +# functions), so a launch without it is refused. +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +set -euo pipefail + +operations=( + provision-prepare provision-commit provision-verify + reset-prepare reset-verify reset-commit reset-rollback + update-verify update-takeover + luks-slots + setup-boot setup-system setup-user + post-install pre-remove +) + +# Run as the user, never as root, with that user's session. +user_operations=(setup-user post-install pre-remove) + +usage() { + echo "Usage: omarchy-lifecycle-dispatch [--resolve] [arguments...]" >&2 + echo "Operations: ${operations[*]}" >&2 + exit 2 +} + +listed() { + local item=$1 candidate + shift + for candidate in "$@"; do + [[ $candidate == "$item" ]] && return 0 + done + return 1 +} + +resolve=0 +if [[ ${1:-} == "--resolve" ]]; then + resolve=1 + shift +fi +operation=${1:-} +[[ -n $operation ]] || usage +shift +listed "$operation" "${operations[@]}" || usage + +# A privileged caller must not let its environment choose what runs as root: +# root resolves only the fixed paths below, and the detector beside this file +# ignores its fixture roots. Fixture roots exist for unprivileged tests. +fixed_path=/usr/local/sbin:/usr/local/bin:/usr/bin +detect=("$(PATH=$fixed_path dirname -- "${BASH_SOURCE[0]}")/omarchy-hw-platform") +if (( EUID == 0 )); then + export PATH=$fixed_path + detect=(/usr/bin/env -i PATH="$fixed_path" "${detect[@]}") + root="" +else + root=${OMARCHY_LIFECYCLE_ROOT:-} + root=${root%/} + if [[ -n $root && $root != /* ]]; then + echo "Error: OMARCHY_LIFECYCLE_ROOT must be an absolute path" >&2 + exit 1 + fi +fi + +if ! platform=$("${detect[@]}"); then + echo "Error: cannot determine the hardware platform for $operation" >&2 + exit 1 +fi + +# x86 and plain aarch64 machines (Snapdragon laptops among them) have no +# registered package: every operation is a no-op there and callers keep their +# generic path. +# Apple Silicon's boot operations belong to omarchy-mac-boot, and its system and +# user setup and app-install hooks to omarchy-mac (both omacom/omarchy-mac-pkgs). +# Provisioning and factory reset have no other path on a Mac, only update-verify +# checks what an update left in its boot chain, its boot checks prove the kept +# LUKS slots that luks-slots records, and hardware setup has no other way to run +# the Mac's boot setup (setup-boot), so those operations are required. So is +# update-takeover: the Mac boot chain and its image own files no package does, +# and a Mac whose boot package can't vouch for a takeover keeps them. Setup and +# the app-install hooks are optional: a Mac without omarchy-mac keeps Omarchy's +# generic setup and installs. Moving an operation to required also changes +# apple_optional in test/shell.d/lifecycle-dispatch-test.sh and the Apple column +# and registration table in docs/lifecycle-dispatch.md. +case $platform:$operation in + aarch64-apple:setup-system | aarch64-apple:setup-user | aarch64-apple:post-install | aarch64-apple:pre-remove) + implementation=/usr/lib/omarchy/mac + package=omarchy-mac + required=() + ;; + aarch64-apple:*) + implementation=/usr/lib/omarchy/mac-boot + package=omarchy-mac-boot + required=( + provision-prepare provision-commit provision-verify + reset-prepare reset-verify reset-commit reset-rollback + update-verify update-takeover + luks-slots + setup-boot + ) + ;; + *) + exit 0 + ;; +esac + +# Checked once the platform registers the operation, so an installer run with +# sudo still works where the hook is a no-op. +if listed "$operation" "${user_operations[@]}" && (( EUID == 0 )); then + echo "Error: $operation runs as the user, never as root" >&2 + exit 1 +fi + +# Every path from the entrypoint up to / (or the fixture root) must be a real +# file or directory owned by root, or by the unprivileged caller inside a +# fixture root, and writable by nobody else. +trusted() { + local path=$1 top=${root:-/} owner mode + + [[ -f $path && -x $path ]] || return 1 + while true; do + [[ ! -L $path ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$path") || return 1 + if (( owner != 0 )); then + [[ -n $root ]] && (( EUID != 0 && owner == EUID )) || return 1 + fi + (( (8#$mode & 8#022) == 0 )) || return 1 + [[ $path != "$top" ]] || return 0 + [[ $path != "/" ]] || return 1 + path=$(dirname -- "$path") + done +} + +entrypoint=$root$implementation/$operation + +# The package's own pacman record tells a machine without it from one whose +# copy predates the operation. +installed_version() { + local record + for record in "$root/var/lib/pacman/local/$package"-[0-9]*/; do + [[ -d $record ]] || continue + record=${record%/} + printf '%s\n' "${record##*/"$package"-}" + return 0 + done + return 1 +} + +# 3 is a machine without the platform's boot package at all, which a caller may +# tell apart from a failure; 1 is everything else. +if [[ ! -e $entrypoint && ! -L $entrypoint ]]; then + if listed "$operation" "${required[@]}"; then + if version=$(installed_version); then + echo "Error: $operation on $platform needs $implementation/$operation, which $package $version does not provide; update $package" >&2 + exit 1 + fi + echo "Error: $operation on $platform needs $package, which provides $implementation/$operation; it is not installed" >&2 + exit 3 + fi + exit 0 +fi + +if ! trusted "$entrypoint"; then + echo "Error: refusing $implementation/$operation: it must be a root-owned executable file in root-owned directories, writable by no one else" >&2 + exit 1 +fi + +if (( resolve )); then + printf '%s\n' "$entrypoint" + exit 0 +fi + +# A user operation also gets the user's home and session, when there is one, +# and nothing else. +environment=(PATH="$fixed_path") +if listed "$operation" "${user_operations[@]}"; then + for name in HOME USER XDG_CONFIG_HOME XDG_STATE_HOME XDG_RUNTIME_DIR DBUS_SESSION_BUS_ADDRESS WAYLAND_DISPLAY OMARCHY_PATH; do + [[ -z ${!name:-} ]] || environment+=("$name=${!name}") + done +fi +exec /usr/bin/env -i "${environment[@]}" "$entrypoint" "$@" diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 153a4d29..ca5bdef0 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -9,12 +9,25 @@ declare -A LUA_BIND_KEY_MAP declare -A LUA_BIND_DISPATCHER_MAP declare -A LUA_BIND_ARG_MAP +# A platform package can name keys the way its keyboards print them: one +# " " per line (on a MacBook, XF86MonBrightnessUp is F2), in the +# fixed platform root, which no environment variable moves. +key_names_file=/usr/share/omarchy-platform/key-names + # Hyprland reports XKB keycodes for code: bindings. Resolve them to symbols # via the compiled keymap, with a small fallback for common keys so the menu # remains readable if xkbcli cannot resolve a symbol. parse_keycodes() { - awk ' + awk -v key_names="$key_names_file" ' BEGIN { + while ((getline line < key_names) > 0) { + # Blanks around a line and a CRLF line end read the same, as in + # displays.conf. + gsub(/^[ \t\r]+|[ \t\r]+$/, "", line) + if (split(line, name_parts, /[ \t]+/) == 2 && name_parts[1] ~ /^[A-Za-z0-9_]+$/ && name_parts[2] ~ /^[A-Za-z0-9_]+$/) key_name[name_parts[1]] = name_parts[2] + } + close(key_names) + split("10=1 11=2 12=3 13=4 14=5 15=6 16=7 17=8 18=9 19=0 20=MINUS 21=EQUAL 59=COMMA 60=PERIOD 61=SLASH", fallbacks, " ") for (i in fallbacks) { separator = index(fallbacks[i], "=") @@ -59,6 +72,13 @@ parse_keycodes() { symbol = mouse_symbol[code] if (symbol == "") symbol = "mouse:" code sub("mouse:" code, symbol) + } else if ((first = index($0, ",")) > 0) { + # Only the key (the second field) is renamed: the description, the + # dispatcher and its argument are left as they are. + rest = substr($0, first + 1) + second = index(rest, ",") + key = second > 0 ? substr(rest, 1, second - 1) : rest + if (key in key_name) $0 = substr($0, 1, first) key_name[key] (second > 0 ? substr(rest, second) : "") } print @@ -81,7 +101,9 @@ build_lua_bind_cache() { LUA_BIND_DISPATCHER_MAP["$cache_key"]="$dispatcher" LUA_BIND_ARG_MAP["$cache_key"]="$arg" done < <( - lua <<'LUA' + # -E keeps LUA_INIT and LUA_PATH from the environment out of the scan, which + # loads the user's config. + lua -E - <<'LUA' local modifiers = { SHIFT = 1, CTRL = 4, CONTROL = 4, ALT = 8, SUPER = 64 } local function split_keys(keys) @@ -535,6 +557,7 @@ keybindings_cache_key() { { printf 'v14\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' + cat "$key_names_file" 2>/dev/null hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' } diff --git a/bin/omarchy-pkg-defaults b/bin/omarchy-pkg-defaults new file mode 100755 index 00000000..a96d08ce --- /dev/null +++ b/bin/omarchy-pkg-defaults @@ -0,0 +1,53 @@ +#!/bin/bash + +# omarchy:summary=Print the default package set for this computer's platform, or for the named one: the base list plus its architecture's and platform's additions. +# omarchy:args=[x86|aarch64|aarch64-apple] +# omarchy:hidden=true + +set -eo pipefail + +platform=${1:-} +if [[ -z $platform ]]; then + platform=$(omarchy-hw-platform) +fi + +# Image builders written before the platform names settled pass the old ones. +case $platform in + apple-silicon) platform=aarch64-apple ;; + generic-aarch64) platform=aarch64 ;; + generic) platform=x86 ;; +esac + +case $platform in + x86) lists=(base) ;; + aarch64) lists=(base aarch64) ;; + aarch64-apple) lists=(base aarch64 "$platform") ;; + *) + echo "Error: unknown platform '$platform'" >&2 + exit 1 + ;; +esac + +# The base list always exists. An architecture or platform with nothing to add +# has no list of its own. +files=() +for list in "${lists[@]}"; do + file="$OMARCHY_PATH/install/omarchy-$list.packages" + if [[ $list == "base" || -f $file ]]; then + files+=("$file") + fi +done + +names() { + sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' "$@" +} + +# One name per line, first occurrence kept, in list order. An aarch64 platform +# leaves out the base packages that only x86_64 builds. +if [[ $platform == "x86" ]]; then + names "${files[@]}" | awk '!seen[$0]++' +else + names "${files[@]}" | awk -v skips=<(names "$OMARCHY_PATH/install/omarchy-x86_64-only.packages") ' + BEGIN { while ((getline name < skips) > 0) skip[name] = 1 } + !skip[$0] && !seen[$0]++' +fi diff --git a/bin/omarchy-provision-first-run b/bin/omarchy-provision-first-run index eec307ec..8804f6b0 100755 --- a/bin/omarchy-provision-first-run +++ b/bin/omarchy-provision-first-run @@ -75,6 +75,10 @@ run_first_run_step "install agent setup post-update hook" \ run_first_run_step "enable user systemd units" \ bash "$OMARCHY_PATH/install/user/first-run/enable-user-units.sh" +# Finalization ran it too, maybe without a session; the platform finishes what +# needs one here. +run_first_run_step "run the platform's user setup" \ + omarchy-lifecycle-dispatch setup-user run_first_run_step "set GNOME theme" \ bash "$OMARCHY_PATH/install/user/first-run/gnome-theme.sh" run_first_run_step "set GTK primary paste" \ diff --git a/bin/omarchy-provision-hardware b/bin/omarchy-provision-hardware new file mode 100755 index 00000000..fee98763 --- /dev/null +++ b/bin/omarchy-provision-hardware @@ -0,0 +1,162 @@ +#!/bin/bash + +# omarchy:summary=Finish the hardware setup an image build deferred to this machine +# omarchy:requires-sudo=true +# omarchy:hidden=true + +# Started by omarchy-provision-hardware.service on every boot while an image's +# deferred steps remain (see install/helpers/image-target.sh), and safe to call +# from a platform's own first boot as well: runs are serialised. The first run +# retires the image's build manifest, so from then on this machine is itself. +# Steps run in order and each one that succeeds leaves the queue, so a failure +# stops the run and the next run resumes at that step. Once the queue is empty +# the initramfs is rebuilt if any step changed its configuration or asked for +# the rebuild, and the service is disabled. With nothing deferred this does +# nothing. A failed step, or a failure to make the pacman keyring the build +# asked for, exits 75 (it stays queued for the next run); anything else that +# stops the run, such as an untrusted manifest or queue, exits 1. + +set -euo pipefail + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +if (( EUID == 0 )); then + export OMARCHY_PATH=/usr/share/omarchy + export PATH=/usr/share/omarchy/bin:/usr/local/sbin:/usr/local/bin:/usr/bin +elif [[ -z ${OMARCHY_IMAGE_ROOT:-} || -z ${OMARCHY_PATH:-} ]]; then + fail "omarchy-provision-hardware must run as root" +fi +export OMARCHY_INSTALL=$OMARCHY_PATH/install +export OMARCHY_INSTALL_USER="" + +source "$OMARCHY_INSTALL/helpers/logging.sh" +source "$OMARCHY_INSTALL/helpers/image-target.sh" +omarchy_image_init +export OMARCHY_INSTALL_LOG_FILE=$omarchy_image_root/var/log/omarchy-install.log + +queue_present() { + [[ -e $omarchy_image_queue || -L $omarchy_image_queue ]] +} + +# Anything left for the first boot: the manifest, a queue, or the keyring an +# image build asked for, which is owed even when no step was queued. +work_present() { + omarchy_image_manifest_present || queue_present || [[ -e $omarchy_image_keyring_request ]] +} + +work_present || exit 0 +omarchy_image_lock || exit 1 +# Another run may have finished while this one waited for the lock. +work_present || exit 0 + +if omarchy_image_manifest_present; then + omarchy_image_read_manifest || exit 1 + mv -f "$omarchy_image_manifest" "$omarchy_image_manifest.booted" || fail "could not retire $omarchy_image_manifest" + live_platform=$(omarchy-hw-platform) || live_platform=unknown + echo "First boot of an image built for $omarchy_image_platform, on $live_platform hardware" +fi + +remaining=() +if queue_present; then + [[ -f $omarchy_image_queue ]] && omarchy_image_trusted "$omarchy_image_queue" || + fail "$omarchy_image_queue is not a root-owned regular file" + mapfile -t queued <"$omarchy_image_queue" + for step in "${queued[@]}"; do + [[ -z $step ]] || remaining+=("$step") + done +fi + +# What the boot image is built from: mkinitcpio's configuration, the module +# options its modconf hook copies in (leaves such as nvidia.sh write them), and +# the kernel command line Limine's entry tool adds (leaves such as intel/fred.sh +# add to it). +initramfs_inputs() { + local file + + for file in "$omarchy_image_root/etc/mkinitcpio.conf" "$omarchy_image_root/etc/mkinitcpio.conf.d/"* \ + "$omarchy_image_root/etc/modprobe.d/"* "$omarchy_image_root/usr/lib/modprobe.d/"* \ + "$omarchy_image_root/etc/default/limine" "$omarchy_image_root/etc/limine-entry-tool.d/"*; do + [[ -e $file ]] || continue + printf '%s\0' "$file" + cat -- "$file" + done | cksum +} + +# Recorded before the first step and kept until a rebuild lands, so a step that +# changed the initramfs and then failed or lost power still gets one. +if [[ ! -f $omarchy_image_initramfs_baseline ]]; then + baseline=$(mktemp "$omarchy_image_dir/.initramfs-inputs.XXXXXX") && + initramfs_inputs >"$baseline" && chmod 0644 "$baseline" && mv -f "$baseline" "$omarchy_image_initramfs_baseline" || + fail "could not record the initramfs inputs" +fi + +mkdir -p "$(dirname "$OMARCHY_INSTALL_LOG_FILE")" + +# A step that would build the boot image itself (a platform's boot setup) asks +# for this run's rebuild instead, so the image is built once, after the last +# step. The request stays on disk until that rebuild lands. +export OMARCHY_IMAGE_BOOT_REBUILD=$omarchy_image_boot_rebuild +# Tells a step it runs on the booted machine, after boot already brought up the +# targets its units hang off (see install/hardware/bluetooth.sh). +export OMARCHY_IMAGE_DEFERRED_HARDWARE=1 + +# An image build asks for this machine's own pacman keyring (see +# install/post-install/pacman.sh), made before any step installs a package. Any +# keyring the build or an interrupted run left is replaced; the request stays +# until a keyring is complete. +if [[ -e $omarchy_image_keyring_request ]]; then + echo "Creating this machine's pacman keyring" + keyring=$omarchy_image_root/etc/pacman.d/gnupg + if ! { rm -rf "$keyring" && pacman-key --gpgdir "$keyring" --init && + pacman-key --gpgdir "$keyring" --populate; } >"$OMARCHY_INSTALL_LOG_FILE" 2>&1; then + echo "Error: could not create the pacman keyring (see $OMARCHY_INSTALL_LOG_FILE); the next run retries it" >&2 + exit 75 + fi + rm -f "$omarchy_image_keyring_request" +fi + +while (( ${#remaining[@]} )); do + step=${remaining[0]} + [[ $step == install/hardware/*.sh && $step != *..* ]] || fail "Deferred step is not a hardware setup step: $step" + script=$OMARCHY_PATH/$step + + if [[ -L $script ]]; then + fail "Deferred step is a symlink: $step" + elif [[ ! -e $script ]]; then + echo "Skipping deferred hardware step $step: this Omarchy no longer ships it" + else + echo "Running deferred hardware step $step" + if ! run_logged "$script"; then + echo "Error: Deferred hardware step failed: $step (see $OMARCHY_INSTALL_LOG_FILE); the next run resumes here" >&2 + exit 75 + fi + fi + + remaining=("${remaining[@]:1}") + omarchy_image_write_queue "${remaining[@]}" || fail "could not update $omarchy_image_queue" +done + +if [[ -e $omarchy_image_boot_rebuild || $(initramfs_inputs) != "$(<"$omarchy_image_initramfs_baseline")" ]]; then + echo "Rebuilding the initramfs for the deferred hardware setup" + if omarchy-cmd-present limine-mkinitcpio; then + limine-mkinitcpio >"$OMARCHY_INSTALL_LOG_FILE" 2>&1 + else + mkinitcpio -P >"$OMARCHY_INSTALL_LOG_FILE" 2>&1 + fi || fail "Initramfs rebuild failed (see $OMARCHY_INSTALL_LOG_FILE); the next run retries it" + # The new image must be on disk before the queue that owes it is cleared. + sync || fail "could not flush the rebuilt initramfs to disk" +fi +rm -f "$omarchy_image_initramfs_baseline" "$omarchy_image_boot_rebuild" + +# Disabled, not deleted: a platform first boot that runs this while the +# service's start job waits would otherwise fail that job at the next +# daemon-reload. The emptied queue keeps the unit from running. +rm -f "$omarchy_image_systemd_dir/multi-user.target.wants/$omarchy_image_unit" +rm -f "$omarchy_image_queue" +if (( EUID == 0 )); then + systemctl daemon-reload 2>/dev/null || true +fi +echo "Deferred hardware setup is complete" diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index e7823fd8..0a5e6b88 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -9,7 +9,9 @@ # behind. Asks for the user (the configurator's Step 2), creates it with the # groups system setup recorded, finalizes it offline from the stashed Node # tarball, re-keys LUKS from the throwaway install passphrase to the user's -# password, and hands off to SDDM. +# password, and hands off to SDDM. A platform whose boot package owns its boot +# chain prepares it, drops the install key's unlock from it and records the +# kept key slot through omarchy-lifecycle-dispatch. set -euo pipefail @@ -33,6 +35,9 @@ fi export PATH="$OMARCHY_PATH/bin:$PATH" +REKEY_STATE=$PROVISIONING_DIR/luks-rekey.state +source "$OMARCHY_PATH/install/provisioning/luks-rekey.sh" + # The shared setup form — the same questions, prompts, and validation the ISO # configurator's user step asks. The ISO vendors this very file out of the # runtime package it bundles, so an install and the first boot that finishes it @@ -579,14 +584,27 @@ greeter_screen() { # The keyboard step the ISO configurator runs — deferred to first boot for OEM # installs, so the machine's owner picks their own layout. Applied immediately # (live VT + persisted) so the password typed next, and the LUKS re-key below, -# use the chosen layout. +# use the chosen layout. A layout that did not take is asked for again: the +# password form must never be typed in a layout the disk unlock won't use. +# Three failures in a row mean the machine can't set a layout at all (a +# missing or broken localectl, say), so the attempt fails and setup's own +# retry-or-console screen takes over instead of asking forever. keyboard_form() { - local status + local status failures=0 while true; do step "Let's setup your keyboard..." omarchy_prompt_keyboard && status=0 || status=$? - ((status == 0)) && break + if ((status == 0)); then + apply_keyboard "$keyboard" && break + if ((++failures >= 3)); then + log_step "no keyboard layout could be set after $failures attempts" + notice "Setup could not set a keyboard layout on this machine." 4 + return 1 + fi + notice "Could not set the ${keyboard_label:-$keyboard} keyboard layout. Choose it again." 4 + continue + fi # Esc means "back", and nothing precedes the first screen, so re-ask. ((status == OMARCHY_FORM_BACK)) && continue @@ -597,8 +615,6 @@ keyboard_form() { # re-key below. Confirm it so a stray Ctrl+C doesn't bounce the machine. confirm_reboot && exec systemctl reboot done - - apply_keyboard "$keyboard" } confirm_reboot() { @@ -609,21 +625,74 @@ confirm_reboot() { gum confirm --affirmative "Yes, reboot" --negative "No, keep setting up" "Reboot this machine?" } -# Load the layout on the live VT and persist it for the installed system. -# systemd-firstboot writes both the console KEYMAP and the XKB layout Hyprland -# reads, matching what the ISO's configure_keyboard does at install time. A -# keymap localectl doesn't know keeps the default rather than failing (defensive; -# the picker no longer offers any such layout). -apply_keyboard() { - local keymap="$1" - [[ $(tty 2>/dev/null) == /dev/tty* ]] && loadkeys "$keymap" 2>/dev/null || true +# XKB settings for the form's keymaps that kbd-model-map lacks, as its rows for +# the nearest keymap give them, so the desktop types what the console does: +# kbd's cz is QWERTY, bg-cp1251 phonetic and azerty French AZERTY. Non-Latin +# layouts keep a US layout to switch to, so the shared password stays typeable. +keyboard_xkb_settings() { + local layout variant="" options=terminate:ctrl_alt_bksp + local switch=terminate:ctrl_alt_bksp,grp:shifts_toggle,grp_led:scroll - if localectl --no-pager list-keymaps 2>/dev/null | grep -qix "$keymap"; then - systemd-firstboot --keymap="$keymap" --force >>"$LOG_FILE" 2>&1 || \ - localectl set-keymap "$keymap" >>"$LOG_FILE" 2>&1 || \ - log_step "could not persist keymap $keymap" - else - log_step "keymap $keymap unknown to localectl; keeping the default" + case $1 in + colemak) layout=us variant=colemak ;; + azerty) layout=fr ;; + bg-cp1251) layout=bg,us variant=phonetic, options=$switch ;; + cz) layout=cz,us variant=qwerty, options=$switch ;; + de_CH-latin1) layout=ch ;; + kyrgyz) layout=kg,us options=$switch ;; + no-latin1) layout=no ;; + pl) layout=pl ;; + ua) layout=ua,us options=$switch ;; + *) return 1 ;; + esac + + printf 'XKBLAYOUT=%s\nXKBMODEL=pc105\n' "$layout" + [[ -z $variant ]] || printf 'XKBVARIANT=%s\n' "$variant" + printf 'XKBOPTIONS=%s\n' "$options" +} + +# Load the layout on the live VT and persist it for the installed system, or +# fail. systemd-firstboot writes both the console KEYMAP and the XKB layout +# Hyprland reads, matching what the ISO's configure_keyboard does at install +# time. The boot files ask for the disk password in the layout vconsole.conf +# names, so a keymap localectl doesn't know, a failed loadkeys, or a file that +# does not read back as the chosen keymap with an XKB layout is a failure. +apply_keyboard() { + local keymap=$1 keymaps saved + + if [[ $(tty 2>/dev/null) == /dev/tty* ]] && ! loadkeys "$keymap" >>"$LOG_FILE" 2>&1; then + log_step "loadkeys could not load keymap $keymap" + return 1 + fi + + if ! keymaps=$(localectl --no-pager list-keymaps 2>>"$LOG_FILE"); then + log_step "localectl could not list keymaps to check $keymap" + return 1 + fi + if ! grep -qxF -- "$keymap" <<<"$keymaps"; then + log_step "keymap $keymap unknown to localectl" + return 1 + fi + + if ! systemd-firstboot --keymap="$keymap" --force >>"$LOG_FILE" 2>&1 && + ! localectl set-keymap "$keymap" >>"$LOG_FILE" 2>&1; then + log_step "could not persist keymap $keymap" + return 1 + fi + + # systemd-firstboot writes an XKB layout only for keymaps in kbd-model-map, + # and several the form offers are not, so Hyprland would come up in US. + if ! grep -q '^XKBLAYOUT=.' /etc/vconsole.conf 2>/dev/null; then + keyboard_xkb_settings "$keymap" >>/etc/vconsole.conf 2>>"$LOG_FILE" || + log_step "no XKB layout known for keymap $keymap" + fi + + # A clean shell, so nothing this process exported stands in for the file. + saved=$(env -i /bin/bash --noprofile --norc -c \ + '. /etc/vconsole.conf && [[ -n ${XKBLAYOUT:-} ]] && printf %s "${KEYMAP:-}"' 2>>"$LOG_FILE") || saved="" + if [[ $saved != "$keymap" ]]; then + log_step "/etc/vconsole.conf does not read back as keymap $keymap with an XKB layout" + return 1 fi } @@ -714,7 +783,7 @@ luks_device() { local src part src=$(findmnt -no SOURCE / | sed 's/\[.*//') [[ -n $src ]] || return 1 - part=$(lsblk -nspo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') + part=$(lsblk -nsrpo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') [[ -n $part ]] && { echo "$part"; return 0; } return 1 } @@ -822,13 +891,24 @@ configure_hostname() { # step, overwriting the placeholder (UTC) the deferred install seeded. configure_timezone() { [[ -n ${timezone:-} ]] || return 0 - if timedatectl set-timezone "$timezone" 2>&1; then - return 0 + if ! timedatectl set-timezone "$timezone" 2>&1; then + # Fallback only if the zone file exists, so a bad zone can't point + # /etc/localtime at nothing and break every localtime read afterward. + [[ -e /usr/share/zoneinfo/$timezone ]] && + ln -sf "/usr/share/zoneinfo/$timezone" /etc/localtime 2>&1 || true + fi + configure_wireless_regdom +} + +# The install's hardware step found only the UTC placeholder, which names no +# country, so the wireless regulatory domain comes from the owner's timezone +# here. No reboot follows setup, so the radios get it now the way udev applies +# it at every boot. +configure_wireless_regdom() { + bash "$OMARCHY_PATH/install/hardware/set-wireless-regdom.sh" 2>&1 || return 0 + if omarchy-cmd-present set-wireless-regdom; then + set-wireless-regdom 2>&1 || true fi - # Fallback only if the zone file exists, so a bad zone can't point - # /etc/localtime at nothing and break every localtime read afterward. - [[ -e /usr/share/zoneinfo/$timezone ]] && - ln -sf "/usr/share/zoneinfo/$timezone" /etc/localtime 2>&1 || true } finalize_user() { @@ -851,16 +931,99 @@ finalize_user() { "$OMARCHY_PATH/bin/omarchy-provision-user" --force --first-install } +# During the provisioning window the staged key rides in the UKI's initramfs, +# named on the kernel cmdline, so boot unlocks unattended. +PROVISIONING_UNLOCK_FILES=( + /etc/omarchy/provisioning.key + /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf + /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf +) + +limine_auto_unlock_present() { + local file + for file in "${PROVISIONING_UNLOCK_FILES[@]}"; do + [[ -e $file ]] && return 0 + done + return 1 +} + +# Rebuild the UKI without the embedded keyfile. A failed rebuild restores the +# auto-unlock, so the retry still boots unattended. +limine_auto_unlock_drop() { + rm -f "${PROVISIONING_UNLOCK_FILES[@]}" + reset_limine_config + if limine-update >>"$LOG_FILE" 2>&1; then + return 0 + fi + + # Once the staged key is destroyed there is no auto-unlock to restore: the + # boot files already ask for the owner's password. The rebuild is still + # retried for the boot entries. + if [[ -f $PROVISIONING_DIR/luks-key ]]; then + log_step "limine-update failed during re-key; restoring auto-unlock for retry" + install -Dm600 "$PROVISIONING_DIR/luks-key" /etc/omarchy/provisioning.key + echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ + >/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf + echo 'FILES+=(/etc/omarchy/provisioning.key)' >/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf + else + log_step "limine-update failed during re-key; the staged key is gone, so no auto-unlock is restored" + fi + limine-update >>"$LOG_FILE" 2>&1 || true + return 1 +} + +# A platform whose boot package implements provision-commit and provision-verify +# stages the unlock in its own boot chain and owns it; everywhere else it rides +# in the Limine UKI above. Deciding fails closed: the unlock then counts as +# present and cannot be dropped. +UNLOCK_OWNER="" + +unlock_owner() { + [[ -z ${UNLOCK_OWNER:-} ]] || return 0 + + local commit verify + commit=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve provision-commit) || return 1 + verify=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve provision-verify) || return 1 + if [[ -n $commit && -n $verify ]]; then + UNLOCK_OWNER=platform + elif [[ -z $commit && -z $verify ]]; then + UNLOCK_OWNER=limine + else + log_step "the platform implements only one of provision-commit and provision-verify" + return 1 + fi +} + +luks_auto_unlock_present() { + unlock_owner || return 0 + if [[ $UNLOCK_OWNER == "platform" ]]; then + ! "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-verify >>"$LOG_FILE" 2>&1 + else + limine_auto_unlock_present + fi +} + +luks_auto_unlock_drop() { + unlock_owner || return 1 + if [[ $UNLOCK_OWNER == "platform" ]]; then + "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-commit >>"$LOG_FILE" 2>&1 + else + limine_auto_unlock_drop + fi +} + +# The platform records the slot the re-key keeps, and no recovery slot; a no-op +# where nothing does. +luks_record_slots() { + "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots "owner=$1" "recovery=" >>"$LOG_FILE" 2>&1 +} + # Move the LUKS volume from the throwaway install passphrase to the user's -# password: add the user's key, kill every other slot (throwaway + any seller -# keys a reset left behind), then rebuild the UKI without the embedded -# auto-unlock keyfile. -# -# Failing here must be LOUD (abort the attempt, offer retry): silently keeping -# the staged auto-unlock keyfile would leave the disk effectively unencrypted -# forever. +# password and retire every other slot (throwaway + any seller keys a reset +# left behind). luks_rekey journals each step, so an interrupted attempt +# resumes where it stopped. rekey_luks() { - [[ -f $PROVISIONING_DIR/luks-key ]] || return 0 + luks_rekey_pending || return 0 local device if ! device=$(luks_device) || [[ ! -e $device ]]; then @@ -869,61 +1032,32 @@ rekey_luks() { return 1 fi - if ! cryptsetup open --test-passphrase --key-file "$PROVISIONING_DIR/luks-key" "$device" 2>>"$LOG_FILE"; then - log_step "staged LUKS key does not unlock $device" - say --foreground 1 "The staged LUKS key no longer unlocks $device." - return 1 - fi + luks_rekey "$device" +} - # Add the user's key (a retry with a different password just adds another - # slot; all but the current one are killed once the rebuild succeeds). - cryptsetup luksAddKey --key-file "$PROVISIONING_DIR/luks-key" "$device" <(printf '%s' "$password") - - # Rebuild the no-auto-unlock UKI FIRST, keeping the throwaway key and slot as - # a fallback. Only once that succeeds do we kill the other slots and destroy - # the staged key — so a limine-update failure leaves a recoverable, - # still-auto-unlocking state to retry, never a disk locked to a password the - # user may have just changed. - rm -f /etc/omarchy/provisioning.key \ - /etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf \ - /etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf - reset_limine_config - if ! limine-update >>"$LOG_FILE" 2>&1; then - log_step "limine-update failed during re-key; restoring auto-unlock for retry" - install -Dm600 "$PROVISIONING_DIR/luks-key" /etc/omarchy/provisioning.key - echo 'KERNEL_CMDLINE[default]+=" cryptkey=rootfs:/etc/omarchy/provisioning.key"' \ - >/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf - echo 'FILES+=(/etc/omarchy/provisioning.key)' >/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf - limine-update >>"$LOG_FILE" 2>&1 || true - return 1 +# The re-key's fingerprint of the console keyboard layout the boot files ask for +# the disk password in: a hash of vconsole.conf's keymap and XKB settings, in +# file order since a later duplicate wins, so the journal never names the layout. +luks_boot_layout() { + if [[ ! -f /etc/vconsole.conf ]]; then + echo none + return fi + awk '/^[ \t]*(KEYMAP|KEYMAP_TOGGLE|XKB[A-Z_]*)=/ { sub(/^[ \t]+/, ""); print }' /etc/vconsole.conf | + sha256sum | cut -d' ' -f1 +} - local new_slot slot other_slots - new_slot=$(cryptsetup open --test-passphrase --verbose --key-file <(printf '%s' "$password") "$device" 2>&1 | - grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' | head -1) - # Retiring the throwaway/seller slots must be all-or-nothing: if we can't - # identify the user's slot or a kill fails, keep the staged key and retry — - # never shred it while a slot the seller knows still unlocks the disk. - if [[ -z $new_slot ]]; then - log_step "could not identify the user's LUKS slot after re-key; keeping the staged key for retry" - say --foreground 1 "Could not confirm the LUKS re-key; will retry." - return 1 +# A re-key interrupted after the staged key was retired can only finish with +# the password the disk already holds. +rekey_accepts_password() { + local - + set +x + local device + [[ -f $REKEY_STATE ]] || return 0 + if ! device=$(luks_device) || [[ ! -e $device ]]; then + return 0 fi - if ! other_slots=$(cryptsetup luksDump "$device" | awk '/^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }'); then - log_step "luksDump failed while retiring slots; keeping the staged key for retry" - say --foreground 1 "Could not enumerate LUKS slots; will retry." - return 1 - fi - for slot in $other_slots; do - [[ $slot == "$new_slot" ]] && continue - if ! cryptsetup luksKillSlot -q --key-file <(printf '%s' "$password") "$device" "$slot"; then - log_step "failed to kill LUKS slot $slot; keeping the staged key for retry" - say --foreground 1 "Could not remove the throwaway LUKS key; will retry." - return 1 - fi - done - - shred -u "$PROVISIONING_DIR/luks-key" 2>/dev/null || rm -f "$PROVISIONING_DIR/luks-key" + luks_rekey_accepts_password "$device" } # Start the ESP's limine.conf over from the shipped template and drop foreign @@ -990,7 +1124,7 @@ reset_limine_config() { cleanup_oem_state() { # Keep groups + packages: omarchy-system-factory-reset stages the bundled Node # tarball from these live copies when the factory snapshot predates it. - rm -f "$PROVISIONING_DIR/pending" "$PROVISIONING_DIR/authorized_keys" "$PROVISIONING_DIR/setup-user" + rm -f "$PROVISIONING_DIR/pending" "$PROVISIONING_DIR/authorized_keys" "$PROVISIONING_DIR/setup-user" "$REKEY_STATE" rm -f /etc/systemd/system/multi-user.target.wants/omarchy-provision-owner.service systemctl daemon-reload 2>/dev/null || true @@ -1017,7 +1151,7 @@ run_provisioning() { touch "$FINALIZE_WARNING_FLAG" fi - if [[ -f $PROVISIONING_DIR/luks-key ]]; then + if luks_rekey_pending; then log_step "re-keying LUKS to the user's password" echo rekey >"$STATE_FILE" rekey_luks @@ -1034,6 +1168,11 @@ run_provisioning() { limine-update fi + if luks_staged_unlock_remains; then + log_step "refusing to finish setup while the staged LUKS key or its auto-unlock remains" + return 1 + fi + if omarchy-pkg-present usbguard; then log_step "enrolling the owner's USB devices" source "$OMARCHY_PATH/install/helpers/usb-authorization.sh" @@ -1048,11 +1187,32 @@ run_provisioning() { log_step "first-boot setup complete" } +# Where the platform's boot package owns the boot chain, it must be ready to +# finish setup, and able to record the kept key slot, before the owner is asked +# anything. +platform_ready() { + local error + if error=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" provision-prepare 2>&1 >>"$LOG_FILE") && + error=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve luks-slots 2>&1 >/dev/null); then + return 0 + fi + error=${error:-"The boot package could not prepare this machine for setup."} + + log_step "the boot package is not ready for setup: $error" + clear_logo + echo + say --foreground 1 "$error" + sleep 5 + return 1 +} + run_setup() { local status + platform_ready || return 1 + while true; do - keyboard_form + keyboard_form || return 1 user_form && status=0 || status=$? if ((status != 0)); then # Esc unwinds to the keyboard step. Ctrl+C is the only other way out of a @@ -1060,7 +1220,12 @@ run_setup() { ((status == OMARCHY_FORM_SIGNAL)) && confirm_reboot && exec systemctl reboot continue fi - confirm_form && break + confirm_form || continue + if ! rekey_accepts_password; then + notice "Setup already set the disk password. Enter that same password." 4 + continue + fi + break done touch "$LOG_FILE" diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 7406e6ad..5c1eb0d5 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -24,17 +24,29 @@ omarchy_security_install_sudo_cleanup_traps omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_enable_no_update_sudo -channel="${1:-stable}" -if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then +channel="${1:-}" +if [[ -n $channel && $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then echo "Invalid channel: $channel" >&2 exit 2 fi +source "$OMARCHY_PATH/install/helpers/pacman.sh" +if ! platform=$(omarchy-hw-platform); then + echo "Cannot tell this machine's platform. No repository configuration changed." >&2 + exit 1 +fi +templates=$(omarchy_pacman_templates "$platform") || exit 1 +[[ -n $channel ]] || channel=$(omarchy_pacman_default_channel "$platform") || exit 1 +if [[ ! -f $templates/pacman-$channel.conf || ! -f $templates/mirrorlist-$channel ]]; then + echo "Omarchy has no $channel channel for $platform. No repository configuration changed." >&2 + exit 1 +fi + sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak echo "Setting channel to $channel" -sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf -sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist +sudo cp -f "$templates/pacman-$channel.conf" /etc/pacman.conf +sudo cp -f "$templates/mirrorlist-$channel" /etc/pacman.d/mirrorlist # Allow user customization of /etc/pacman.conf before the upgrade runs, so # custom repositories and IgnorePkg entries shape the downgrade-capable diff --git a/bin/omarchy-reinstall-pkgs b/bin/omarchy-reinstall-pkgs index 97aff9c6..156ab9d4 100755 --- a/bin/omarchy-reinstall-pkgs +++ b/bin/omarchy-reinstall-pkgs @@ -1,18 +1,20 @@ #!/bin/bash -# omarchy:summary=Reinstall all default Omarchy packages from the stable channel +# omarchy:summary=Reinstall all default Omarchy packages from the stable channel, or on aarch64 the edge channel # omarchy:requires-sudo=true set -e -# Reinstall all default Omarchy packages from the stable channel and downgrade any packages that are too new. +# Reinstall all default Omarchy packages from the platform's default channel +# (stable, or edge on aarch64) and downgrade any packages that are too new. -# Set the package repository to the stable mirrors +# Set the package repository to the platform's default channel omarchy-refresh-pacman -# Downgrade any packages to the stable setup +# Downgrade any packages to that channel's setup omarchy-update-pacman -Suu --noconfirm -# Ensure all packages are installed -mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$') +# Ensure this platform's default packages are installed +default_packages=$(omarchy-pkg-defaults) +mapfile -t packages <<<"$default_packages" omarchy-update-pacman -Syu --noconfirm --needed "${packages[@]}" diff --git a/bin/omarchy-remove-gaming-steam b/bin/omarchy-remove-gaming-steam index 1b20ec22..16a2570a 100755 --- a/bin/omarchy-remove-gaming-steam +++ b/bin/omarchy-remove-gaming-steam @@ -5,6 +5,7 @@ set -e +omarchy-lifecycle-dispatch pre-remove steam omarchy-pkg-drop steam rm -rf \ diff --git a/bin/omarchy-setup-direct-boot b/bin/omarchy-setup-direct-boot index 56c7fd50..ee9f30f5 100755 --- a/bin/omarchy-setup-direct-boot +++ b/bin/omarchy-setup-direct-boot @@ -3,6 +3,14 @@ # omarchy:summary=Add or remove an EFI boot entry for the Omarchy UKI, allowing the system to boot directly # omarchy:requires-sudo=true +# Apple Silicon boots m1n1, then U-Boot, which finds the bootloader on its own +# and creates its boot entries itself, so there is no direct-boot entry to add +# or remove. +if omarchy-hw-aarch64-apple; then + echo "Error: Direct boot is not supported on Apple Silicon: U-Boot finds the bootloader itself" >&2 + exit 1 +fi + if [[ ! -d /sys/firmware/efi ]]; then echo "Error: System is not booted in UEFI mode" >&2 exit 1 diff --git a/bin/omarchy-setup-security-usb-authorization b/bin/omarchy-setup-security-usb-authorization index ee68641d..ab3e2584 100755 --- a/bin/omarchy-setup-security-usb-authorization +++ b/bin/omarchy-setup-security-usb-authorization @@ -40,6 +40,13 @@ while (( $# > 0 )); do shift done +# Boot-time blocking goes into Limine's own configuration, which a Mac's boot +# package owns, so it is not offered there. +if (( from_boot )) && omarchy-hw-aarch64-apple; then + echo "USB authorization from boot is not available on Apple Silicon yet; USB device authorization after login still works." >&2 + exit 2 +fi + state_dir="$HOME/.local/state/omarchy/usb-authorization" unit_source="$OMARCHY_PATH/default/systemd/user/omarchy-usb-authorization.service" unit="$HOME/.config/systemd/user/omarchy-usb-authorization.service" diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 22c789fd..c46c5cb1 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -17,7 +17,9 @@ # The heavy lifting happens on the next boot via omarchy-system-factory-reset-finish, staged # here. On encrypted installs the LUKS volume is re-keyed to a throwaway # passphrase with an auto-unlock keyfile for the provisioning window; first-boot setup -# re-keys it to the new owner's password and removes the keyfile. +# re-keys it to the new owner's password and removes the keyfile. A platform +# whose boot chain the Limine UKI path can't drive rebuilds, verifies and +# unlocks it through omarchy-lifecycle-dispatch. # # Honest limitations: # - On unencrypted disks this is deletion, not forensic erasure. fstrim helps @@ -70,6 +72,51 @@ root_is_btrfs_at() { [[ $(findmnt -no FSTYPE /) == btrfs ]] && findmnt -no OPTIONS / | grep -q "subvol=/@\(,\|$\)" } +DISPATCH="$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" + +# A platform whose boot package implements the reset operations rebuilds, +# verifies and unlocks the factory root's boot chain itself; everywhere else +# the Limine UKI path below does. Deciding fails closed. +RESET_BOOT="" + +reset_boot_owner() { + [[ -z $RESET_BOOT ]] || return 0 + + local operation path found=0 missing=0 + for operation in reset-prepare reset-verify reset-commit reset-rollback; do + path=$("$DISPATCH" --resolve "$operation") || return 1 + if [[ -n $path ]]; then + found=$((found + 1)) + else + missing=$((missing + 1)) + fi + done + if (( missing == 0 )); then + RESET_BOOT=platform + elif (( found == 0 )); then + RESET_BOOT=generic + else + echo "Error: the boot package implements only some of the factory reset operations" >&2 + return 1 + fi +} + +# Runs a reset operation with its output in the log. A failure keeps the +# operation's last line for the error shown. +RESET_BOOT_ERROR="" + +reset_boot_op() { + local output status=0 + output=$("$DISPATCH" "$@" 2>&1) || status=$? + [[ -z $output ]] || printf '%s\n' "$output" >>"$LOG_FILE" + if (( status == 0 )); then + return 0 + fi + RESET_BOOT_ERROR=$(tail -n 1 <<<"$output") + RESET_BOOT_ERROR=${RESET_BOOT_ERROR:-"$1 failed"} + return 1 +} + # Resolve the crypto_LUKS partition backing the root, or return non-zero if # the root is not on LUKS. Prefers the cmdline cryptdevice= spec (archinstall # writes PARTUUID=, the pre-mounted path UUID=), and falls back to walking the @@ -89,7 +136,7 @@ luks_device() { local src part src=$(findmnt -no SOURCE / | sed 's/\[.*//') [[ -n $src ]] || return 1 - part=$(lsblk -nspo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') + part=$(lsblk -nsrpo NAME,FSTYPE "$src" 2>/dev/null | awk '$2=="crypto_LUKS"{print $1; exit}') [[ -n $part ]] && { echo "$part"; return 0; } return 1 } @@ -107,6 +154,11 @@ generate_passphrase() { } cleanup() { + # Before the switch the old root still boots: the boot package gives it back + # its boot state before the reset's throwaway slot is revoked. + if [[ ${reset_committed:-0} == 0 && ${RESET_BOOT_PREPARED:-0} == 1 ]]; then + reset_boot_op reset-rollback || echo "Error: $RESET_BOOT_ERROR (see $LOG_FILE)" >&2 + fi if mountpoint -q "$TOP_MNT" 2>/dev/null; then if [[ -d $TOP_MNT/$NEXT_NAME && ${swap_done:-0} == 0 ]]; then btrfs subvolume delete --recursive "$TOP_MNT/$NEXT_NAME" >/dev/null 2>&1 || true @@ -114,6 +166,9 @@ cleanup() { umount -R "$TOP_MNT" 2>/dev/null || true fi rmdir "$TOP_MNT" 2>/dev/null || true + if [[ ${reset_committed:-0} == 0 ]]; then + revoke_reset_luks + fi } trap cleanup EXIT @@ -135,8 +190,54 @@ confirm_reset() { [[ $typed == "reset" ]] || fail "Reset not confirmed." } +# The slot the reset's throwaway key opens, or nothing. Restricting cryptsetup +# to a token type no token has keeps an enrolled token from answering for it. +reset_luks_slot() { + local - + set +x + LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only \ + --key-file <(printf '%s' "$RESET_THROWAWAY") "$RESET_LUKS_DEVICE" 2>&1 | + grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' | head -1 || true +} + +# Add the throwaway slot where the platform's boot package owns the unlock. +# Called once reset-verify passed, so a failed rebuild adds no credential. +# reset-commit writes the boot-time key only once the factory root is active: +# a power loss before the switch leaves the old root asking for its password, +# never unlocked unattended. +add_reset_luks_slot() { + local - + set +x + [[ -n ${RESET_LUKS_DEVICE:-} && -n ${RESET_LUKS_AUTH:-} && -n ${RESET_THROWAWAY:-} ]] || fail "the reset is missing its throwaway passphrase" + RESET_LUKS_ADDED=1 + { cryptsetup luksAddKey --key-file <(printf '%s' "$RESET_LUKS_AUTH") "$RESET_LUKS_DEVICE" <(printf '%s' "$RESET_THROWAWAY") ; } 2>>"$LOG_FILE" || + fail "could not add the throwaway LUKS keyslot" + RESET_LUKS_SLOT=$(reset_luks_slot) + [[ -n $RESET_LUKS_SLOT ]] || fail "could not identify the throwaway LUKS keyslot" +} + +# Only the slot this attempt added goes, found by its key if the add was not +# confirmed. The platform's boot-time key is written only after activation, so +# a failed reset has none to revoke. +revoke_reset_luks() { + local - + set +x + [[ ${RESET_LUKS_ADDED:-0} == 1 && -n ${RESET_LUKS_AUTH:-} ]] || return 0 + [[ -n ${RESET_LUKS_SLOT:-} ]] || RESET_LUKS_SLOT=$(reset_luks_slot) + if [[ -n $RESET_LUKS_SLOT ]]; then + { cryptsetup luksKillSlot -q --key-file <(printf '%s' "$RESET_LUKS_AUTH") "$RESET_LUKS_DEVICE" "$RESET_LUKS_SLOT" ; } 2>>"$LOG_FILE" || true + else + echo "Could not find the reset's throwaway LUKS slot on $RESET_LUKS_DEVICE to revoke; check its keyslots" >>"$LOG_FILE" + fi + RESET_LUKS_ADDED=0 + RESET_LUKS_SLOT="" +} + # Re-key to a throwaway passphrase whose keyfile auto-unlocks boot during the -# provisioning window. $1 is the root of the staged factory system. +# provisioning window. $1 is the root of the staged factory system. Where the +# platform's boot package owns the unlock, only the staged key is written here: +# reset-prepare stages the unlock with the rest of the boot files, and the +# throwaway slot follows their verification. stage_luks_rekey() { local next="$1" device passphrase @@ -148,19 +249,29 @@ stage_luks_rekey() { local current while true; do current=$(gum input --password --placeholder "Current disk encryption passphrase" --prompt "Passphrase> ") || exit 1 - if cryptsetup open --test-passphrase --key-file <(printf '%s' "$current") "$device" 2>/dev/null; then + if cryptsetup open --test-passphrase --token-type passphrase-only --key-file <(printf '%s' "$current") "$device" 2>/dev/null; then break fi gum style --foreground 1 "That passphrase does not unlock $device. Try again." done passphrase=$(generate_passphrase) - cryptsetup luksAddKey --key-file <(printf '%s' "$current") "$device" <(printf '%s' "$passphrase") + reset_boot_owner || fail "could not tell how this machine's boot chain is reset" + RESET_LUKS_AUTH=$current + RESET_LUKS_DEVICE=$device + RESET_THROWAWAY=$passphrase + if [[ $RESET_BOOT == "generic" ]]; then + # Revoked like the platform's slot if the reset fails before activation. + RESET_LUKS_ADDED=1 + cryptsetup luksAddKey --key-file <(printf '%s' "$current") "$device" <(printf '%s' "$passphrase") + fi install -d -m 755 "$next$PROVISIONING_DIR" printf '%s' "$passphrase" >"$next$PROVISIONING_DIR/luks-key" chmod 600 "$next$PROVISIONING_DIR/luks-key" + [[ $RESET_BOOT == "generic" ]] || return 0 + install -d -m 755 "$next/etc/omarchy" printf '%s' "$passphrase" >"$next/etc/omarchy/provisioning.key" chmod 600 "$next/etc/omarchy/provisioning.key" @@ -243,6 +354,12 @@ verify_limine_hashes() { rebuild_next_boot() { local next="$1" + reset_boot_owner || fail "could not tell how this machine's boot chain is reset" + if [[ $RESET_BOOT == "platform" ]]; then + rebuild_next_boot_platform "$next" + return + fi + local esp_source esp_mount esp_mount=$(awk '$3 == "vfat" && $2 ~ /^\/(boot|efi)/ { print $2; exit }' "$next/etc/fstab") esp_source=$(awk '$3 == "vfat" && $2 ~ /^\/(boot|efi)/ { print $1; exit }' "$next/etc/fstab") @@ -305,6 +422,23 @@ rebuild_next_boot() { umount "$next$esp_mount" } +# The boot package stages the factory root's boot files and unlock, then proves +# they boot it; reset-rollback undoes both if the reset fails before the switch. +# The throwaway slot is added only once they are proven. +rebuild_next_boot_platform() { + local next="$1" + local -a luks=() + [[ -z ${RESET_LUKS_DEVICE:-} ]] || luks=("$RESET_LUKS_DEVICE") + + RESET_BOOT_PREPARED=1 + log "Rebuilding boot files from the factory system (this can take a minute)" + reset_boot_op reset-prepare "$next" "${luks[@]}" || fail "$RESET_BOOT_ERROR (see $LOG_FILE)" + log "Verifying that the factory system boots" + reset_boot_op reset-verify "$next" || fail "$RESET_BOOT_ERROR (see $LOG_FILE)" + + [[ -z ${RESET_LUKS_DEVICE:-} ]] || add_reset_luks_slot +} + # Both the staged system and the retained baseline must lose the old hashes. scrub_factory_accounts() { local root="$1" user users @@ -417,9 +551,30 @@ stage_full_reset() { log "Activating the factory system" local old="@omarchy-old-$(date +%s)" mv "$top/@" "$top/$old" - mv "$next" "$top/@" + # Until the factory root is at @ the reset is not committed: cleanup has the + # platform's boot package roll its boot state back to the old root and + # revokes the throwaway slot, so the old root goes back to @ first. + if ! mv "$next" "$top/@"; then + if mv "$top/$old" "$top/@"; then + [[ $RESET_BOOT == "platform" ]] || + fail "could not activate the factory system; the current root is back at @, but its boot files were already rebuilt from the factory system: run 'sudo limine-update' before rebooting (see $LOG_FILE)" + fail "could not activate the factory system; the current root is back at @ (see $LOG_FILE)" + fi + # Neither root is at @. Keep the staged one as well for recovery. + swap_done=1 + fail "could not activate the factory system or put the current root back; from a live USB, mount the Btrfs top level, rename $old to @, and rebuild its boot files if it does not boot" + fi + # From here the old root is gone, so the reset's unlock must survive. + reset_committed=1 swap_done=1 sync + + # The boot package that prepared the reset gets the key on standard input, + # never in arguments. A failure costs the first boot one prompt for the + # current disk password. + if [[ ${RESET_BOOT_PREPARED:-0} == 1 ]] && ! reset_boot_op reset-commit < <(printf '%s' "${RESET_THROWAWAY:-}"); then + log "$RESET_BOOT_ERROR" + fi } # A reset is a clone of @factory, so a machine without one has nothing to @@ -457,6 +612,10 @@ main() { require_factory_snapshot + # Decided before anything is asked: a platform whose boot package is missing + # stops here, with the dispatcher's error naming the package. + reset_boot_owner || fail "this machine's boot package cannot factory-reset it" + confirm_reset # The running system's limine-snapper-sync must not rewrite the ESP's diff --git a/bin/omarchy-system-lid-close b/bin/omarchy-system-lid-close index eb3dc097..45283143 100755 --- a/bin/omarchy-system-lid-close +++ b/bin/omarchy-system-lid-close @@ -11,8 +11,9 @@ # omarchy-system-sleep-lock then usually finds the session already secure. # # A docked lid close does not suspend (HandleLidSwitchDocked defaults to -# ignore), so it must not lock either: that is clamshell mode, still in use on -# the external display. +# ignore, and omarchy-system-lid-inhibit covers displays logind does not count), +# so it must not lock either: that is clamshell mode, still in use on the +# external display. if omarchy-hw-laptop-closed && ! omarchy-hw-external-monitors; then omarchy-system-lock >/dev/null 2>&1 || true fi diff --git a/bin/omarchy-system-lid-inhibit b/bin/omarchy-system-lid-inhibit new file mode 100755 index 00000000..2dbd496f --- /dev/null +++ b/bin/omarchy-system-lid-inhibit @@ -0,0 +1,100 @@ +#!/bin/bash + +# omarchy:summary=Keep logind from suspending on lid close while a display it misses is in use +# omarchy:group=system +# omarchy:hidden=true + +# systemd-logind treats a closed lid as docked only when an external display is +# on a connector type it lists (HDMI, DisplayPort, DVI, VGA...), and USB-C +# displays on Apple Silicon arrive on "USB" connectors instead. Left alone, +# logind suspends a laptop still in use on one. Holding its handle-lid-switch +# inhibitor while such a display is in use leaves the lid to Omarchy's +# clamshell handling, as HandleLidSwitchDocked=ignore would. Once released, a +# lid that is still shut suspends at once: logind rechecks a closed lid after +# every event. + +UNIT="omarchy-lid-inhibit.service" +drm_path="${OMARCHY_DRM_PATH:-/sys/class/drm}" +cgroup_path="${OMARCHY_CGROUP_PATH:-/proc/self/cgroup}" + +# Connected connectors of the external types logind does not count, by the name +# Hyprland gives them (card2-USB-2 is USB-2). DVI-A and Composite are on its +# list, but a missing comma there fuses them into one entry. +uncounted_outputs() { + local status connector + + for status in "$drm_path"/card*-*/status; do + [[ -r $status ]] || continue + connector=${status%/status} + connector=${connector##*/} + + case $connector in + card*-USB-* | card*-DVI-A-* | card*-Composite-*) ;; + *) continue ;; + esac + + [[ $(<"$status") == "connected" ]] && printf '%s\n' "${connector#card*-}" + done +} + +held() { + systemctl --user is-active --quiet "$UNIT" +} + +in_use=0 +outputs=$(uncounted_outputs) + +# In use means enabled in Hyprland: a display switched off in the monitor config +# should not keep a closed laptop awake, while one blanked by DPMS still should, +# and the connector's own "enabled" flag cannot tell the two apart on every +# driver. A compositor that does not answer, as while it reconfigures outputs +# around a lid close, changes nothing: releasing then would suspend a closed lid. +if [[ -n $outputs ]]; then + monitors=$(hyprctl monitors all -j 2>/dev/null) || exit 0 + [[ -n $monitors ]] || exit 0 + jq -e --arg outputs "$outputs" \ + '($outputs | split("\n")) as $names | any(.[]; .disabled == false and (.name | IN($names[])))' \ + <<<"$monitors" >/dev/null 2>&1 + case $? in + 0) in_use=1 ;; + 1) ;; + *) exit 0 ;; + esac +fi + +# Only where logind would ignore a docked lid is there anything to stand in for. +# An unanswered query changes nothing, for the same reason as above. +wanted=0 +if (( in_use )) && omarchy-hw-laptop; then + docked_action=$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \ + org.freedesktop.login1.Manager HandleLidSwitchDocked 2>/dev/null) || exit 0 + [[ -n $docked_action ]] || exit 0 + [[ $docked_action == 's "ignore"' ]] && wanted=1 +fi + +# Bound to the user unit of the watcher that keeps it in step, so a watcher that +# is gone cannot leave the lid inhibited until logout. systemd-run unquotes +# property values, so the backslashes in an escaped unit name are doubled. +watcher_binding() { + local cgroup="" unit + + read -r cgroup 2>/dev/null <"$cgroup_path" + unit=${cgroup##*/} + if [[ $cgroup == */user@*.service/* && ( $unit == *.scope || $unit == *.service ) ]]; then + unit=${unit//\\/\\\\} + printf '%s\n' "--property=BindsTo=$unit" "--property=After=$unit" + fi +} + +if (( wanted )); then + if ! held; then + mapfile -t bind < <(watcher_binding) + systemd-run --user --quiet --collect --unit="$UNIT" \ + --description="Omarchy clamshell lid inhibitor" \ + --property=PartOf=graphical-session.target "${bind[@]}" \ + systemd-inhibit --no-ask-password --what=handle-lid-switch --mode=block \ + --who=Omarchy --why="External display in use" sleep infinity + fi +elif held; then + systemctl --user stop "$UNIT" +fi diff --git a/bin/omarchy-update b/bin/omarchy-update index edb6410f..9f32abdd 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -148,6 +148,15 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$PATH" OMARCHY_SUDO_NO_UPDATE=1 omarchy-update-aur-pkgs omarchy_security_revoke_sudo_timestamp + # The last sudo-capable step: the boot files every package step left, AUR + # packages' initramfs rebuilds included, must be shown to boot the updated + # system. It follows third-party build code, so it starts cold and, like AUR, + # authorizes one command through the no-update wrapper without a reusable + # timestamp. Until it passes, the update neither finishes nor offers a reboot. + boot_verified=1 + PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$PATH" OMARCHY_SUDO_NO_UPDATE=1 omarchy-update-boot || boot_verified=0 + omarchy_security_revoke_sudo_timestamp + # The sleep inhibitor covers AUR builds, hooks and mise as well; releasing it # needs no privilege because the held command already dropped to this user. # Release it before offering a reboot: a confirmed reboot can terminate this @@ -156,5 +165,10 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake stop update_stay_awake_stopped=1 - "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only + if (( boot_verified )); then + "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only + else + echo -e "\e[31m\nThe update is not finished: its boot files did not pass verification (see above). Do not reboot until omarchy update completes.\e[0m" >&2 + exit 1 + fi fi diff --git a/bin/omarchy-update-boot b/bin/omarchy-update-boot new file mode 100755 index 00000000..2c3487c7 --- /dev/null +++ b/bin/omarchy-update-boot @@ -0,0 +1,39 @@ +#!/bin/bash + +# omarchy:summary=Verify the platform's boot chain after the update's package changes +# omarchy:hidden=true +# omarchy:requires-sudo=true + +# Proves that the boot files the package steps left boot the updated system; +# omarchy update neither finishes nor offers a reboot until this passes. It is +# the update-verify lifecycle dispatch operation: a no-op where the platform +# registers no boot package, x86 included, and the platform's own entrypoint +# says what failed. + +set -e + +if (( $# )); then + echo "Usage: omarchy-update-boot" >&2 + exit 2 +fi +operation=update-verify + +# Resolving as the user keeps sudo, and its password prompt, out of updates +# with nothing to run. Root resolves again from its own fixed paths before +# anything runs. +errors=$(mktemp) +trap 'rm -f "$errors"' EXIT +status=0 +resolved=$(omarchy-lifecycle-dispatch --resolve "$operation" 2>"$errors") || status=$? + +# 3: the platform's boot package is not installed at all. Such a machine +# predates it and boots a chain the package does not manage, so verification +# warns instead of failing every update until the package is installed. +if (( status == 3 )); then + echo -e "\e[33m\n$(sed 's/^Error: //' "$errors")\nThe boot files were not verified. This machine predates its platform's boot package; updates verify them once it is installed.\e[0m" >&2 +elif (( status != 0 )); then + cat "$errors" >&2 + exit "$status" +elif [[ -n $resolved ]]; then + sudo omarchy-lifecycle-dispatch "$operation" +fi diff --git a/bin/omarchy-update-keyring b/bin/omarchy-update-keyring index 3a1c1c17..55d49e9b 100755 --- a/bin/omarchy-update-keyring +++ b/bin/omarchy-update-keyring @@ -22,8 +22,32 @@ fi # Ensure we have the latest archlinux-keyring, maintainer keys might have changed # Always reinstall, as the keyring can be updated without a package version bump. +# An aarch64 machine's Arch Linux ARM repositories are signed with keys of +# their own, so its keyring is kept current the same way. +keyrings=(archlinux-keyring) +if omarchy-hw-aarch64 && omarchy-pkg-present archlinuxarm-keyring; then + keyrings+=(archlinuxarm-keyring) +fi +# A platform package names the signing keyrings of the repositories it adds, +# one package per line. Only installed ones are refreshed, so this adds no +# repository the machine didn't already trust; a refreshed keyring may carry +# that repository's new keys. A line that isn't one keyring package name is +# skipped with a warning, so a mistake in the list can't stop every update. +platform_keyrings=/usr/share/omarchy-platform/keyrings +if [[ -f $platform_keyrings ]]; then + while read -r keyring extra || [[ -n $keyring ]]; do + [[ -n $keyring && $keyring != \#* ]] || continue + if [[ -n $extra || ! $keyring =~ ^[a-z0-9][a-z0-9@._+-]*-keyring$ ]]; then + echo "Warning: skipping ${keyring}${extra:+ $extra} in $platform_keyrings: not a keyring package name" >&2 + continue + fi + if omarchy-pkg-present "$keyring" && [[ " ${keyrings[*]} " != *" $keyring "* ]]; then + keyrings+=("$keyring") + fi + done <"$platform_keyrings" +fi echo -e "\e[32m\nUpdate Arch signing keys\e[0m" -sudo pacman -Sy --noconfirm archlinux-keyring >/dev/null 2> >(grep -vE '^warning: archlinux-keyring-[^ ]+ is up to date -- reinstalling$' >&2) +sudo pacman -Sy --noconfirm "${keyrings[@]}" >/dev/null 2> >(grep -vE '^warning: [a-z0-9@._+-]+-keyring-[^ ]+ is up to date -- reinstalling$' >&2) # Say "correct" only once the key verifiably is: before the failure checks # above, a failed recv or reinstall still ended here with exit 0. diff --git a/bin/omarchy-update-system-pkgs-when-conflicted b/bin/omarchy-update-system-pkgs-when-conflicted index 58a3a0c0..37d39556 100755 --- a/bin/omarchy-update-system-pkgs-when-conflicted +++ b/bin/omarchy-update-system-pkgs-when-conflicted @@ -98,6 +98,17 @@ mapfile -t leftovers < <( # the moved files inactive for nothing. ((${#leftovers[@]} == $(grep -c ' exists in filesystem' "$errors"))) || exit 1 +# A platform whose boot package owns the boot chain may also have files no +# package owns that it can't do without (written by its image or installer). +# Its boot package vouches for the takeover or refuses it, and the files stay. +# Where the platform registers none, nothing resolves and nothing asks for root. +takeover=$(omarchy-lifecycle-dispatch --resolve update-takeover) || exit 1 +if [[ -n $takeover ]] && ! sudo omarchy-lifecycle-dispatch update-takeover "${leftovers[@]}"; then + echo -e "\e[33m\nThis platform keeps these files, so the upgrade can't take them over:\e[0m" >&2 + printf ' %s\n' "${leftovers[@]}" >&2 + exit 1 +fi + echo -e "\e[33m\nTaking over files pacman doesn't own yet:\e[0m" for path in "${leftovers[@]}"; do sudo mkdir -p "$replaced${path%/*}" diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 9f4a8ae7..7b1caa44 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -807,6 +807,11 @@ install_omarchy_quattro_packages() { if [[ -f /usr/share/omarchy/install/omarchy-base.packages ]]; then log "Installing Omarchy quattro default package set" mapfile -t base_packages < <(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' /usr/share/omarchy/install/omarchy-base.packages) + # Some base packages have no aarch64 build. + if [[ $(uname -m) != "x86_64" && -f /usr/share/omarchy/install/omarchy-x86_64-only.packages ]]; then + mapfile -t base_packages < <(printf '%s\n' "${base_packages[@]}" | + grep -vxF -f <(sed -e 's/[[:space:]]*#.*$//' -e '/^[[:space:]]*$/d' /usr/share/omarchy/install/omarchy-x86_64-only.packages)) + fi if (( ${#base_packages[@]} )); then as_root pacman -S --needed --noconfirm --ask 4 --overwrite='*' "${base_packages[@]}" mark_packages_explicit "${base_packages[@]}" diff --git a/bin/omarchy-usb-authorization-boot b/bin/omarchy-usb-authorization-boot index 2fdd4cbe..deaa9952 100755 --- a/bin/omarchy-usb-authorization-boot +++ b/bin/omarchy-usb-authorization-boot @@ -694,6 +694,22 @@ usb_authorization_sync_and_verify() ( usb_authorization_verify_boot_entries "$expected" "$config" "$esp" "$machine_id" ) +# A Mac's boot chain belongs to its boot package, so boot-time denial is not +# managed here. Disabling what is not configured succeeds, so turning USB +# authorization off still works; anything configured by another route is left +# to the owner, since this cannot rebuild a Mac's boot files. +usb_authorization_apple_boot() { + if [[ ${1:-} == "enable" ]]; then + echo "USB authorization from boot is not available on Apple Silicon yet." >&2 + return 2 + fi + if [[ -e $drop_in ]] || grep -Fqx "$snapshot_begin" "$limine_defaults" 2>/dev/null; then + echo "Boot-time USB authorization is configured in $drop_in or $limine_defaults, which this command cannot remove on Apple Silicon." >&2 + echo "Remove it by hand: delete $drop_in and the lines from '$snapshot_begin' to '$snapshot_end' in $limine_defaults, then rebuild the boot files." >&2 + return 1 + fi +} + usb_authorization_main() { local had_drop_in=0 had_snapshot_setting=0 snapshot_status temporary @@ -791,7 +807,9 @@ main() { fi case "${1:-}" in enable|disable) - if usb_authorization_secure_boot_enabled; then + if /usr/bin/omarchy-hw-aarch64-apple; then + usb_authorization_apple_boot "$1" + elif usb_authorization_secure_boot_enabled; then usb_authorization_boot_transaction "$1" else usb_authorization_main "$1" diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 96a8d575..07f60d7f 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -4,6 +4,12 @@ # omarchy:args= [options] # omarchy:requires-sudo=true +# The VM is an x86_64 Windows guest under KVM, which only an x86_64 host runs. +if ! omarchy-hw-x86; then + echo "Windows VM needs an x86_64 machine (it runs an x86_64 guest under KVM)." >&2 + exit 1 +fi + # The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so # it needs the root-owned Docker daemon. By default the user is NOT in the docker # group (that group is root-equivalent), so Docker access is gated behind a diff --git a/default/hypr/bindings/dictation.lua b/default/hypr/bindings/dictation.lua index aa3e2c69..55b97c2d 100644 --- a/default/hypr/bindings/dictation.lua +++ b/default/hypr/bindings/dictation.lua @@ -1,4 +1,12 @@ -if o.shell_succeeds("omarchy-default-dictation") then +-- Bound only once the chosen backend's own program is installed (Omarchy ships +-- every adapter): an aarch64 machine has no Superwhisper build, and +-- push-to-talk would hold Right Alt (AltGr) for nothing. +local pipe = io.popen("omarchy-default-dictation 2>/dev/null", "r") +local backend = pipe and pipe:read("*l") +if pipe then + pipe:close() +end +if backend and backend:match("^[%w%-]+$") and not o.cmd_missing(backend) then o.bind("SUPER + CTRL + X", "Toggle dictation", "omarchy-dictation toggle") o.bind_hold("F9", "Start dictation (push-to-talk)", "omarchy-dictation start", "Stop dictation (push-to-talk)", "omarchy-dictation stop") -- A modifier's mask changes between its press and release. Match the keysym diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 6ae4ef50..5594bda8 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -34,6 +34,9 @@ o.bind("SUPER + CTRL + Delete", "Toggle laptop display", "omarchy-hyprland-monit o.bind("SUPER + CTRL + ALT + Delete", "Toggle laptop display mirroring", "omarchy-hyprland-monitor-internal-mirror toggle") o.bind("switch:on:Lid Switch", nil, "omarchy-system-lid-close", { locked = true }) o.bind("switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) +-- Apple Silicon reports its lid through the SMC, under its own switch name. +o.bind("switch:on:Apple SMC power/lid events", nil, "omarchy-system-lid-close", { locked = true }) +o.bind("switch:off:Apple SMC power/lid events", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) o.bind("PRINT", "Screenshot", "omarchy-capture-screenshot") o.bind("ALT + PRINT", "Screenrecording", "omarchy-capture-screenrecording --stop-recording || omarchy-menu toggle trigger.capture.screenrecord") diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 4e6d03ca..fcda7c86 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -57,7 +57,7 @@ "trigger.reminder": {"icon":"󰢌","label":"Reminder","aliases":["reminder"]}, "trigger.capture": {"icon":"","label":"Capture","aliases":["capture","screenshot","screenrecord","screen-record","screenrecording"]}, "trigger.capture.screenshot": {"icon":"","label":"Screenshot","action":"omarchy-capture-screenshot"}, - "trigger.capture.screenrecord.stop": {"icon":"","label":"Stop Screenrecording","when":"pgrep -f '^gpu-screen-recorder'","action":"omarchy-capture-screenrecording --stop-recording"}, + "trigger.capture.screenrecord.stop": {"icon":"","label":"Stop Screenrecording","when":"omarchy-capture-screenrecording --status","action":"omarchy-capture-screenrecording --stop-recording"}, "trigger.capture.screenrecord": {"icon":"","label":"Screenrecord"}, "trigger.capture.text": {"icon":"󰴑","label":"Text","action":"omarchy-capture-text"}, "trigger.capture.qr": {"icon":"󰐲","label":"QR Code","action":"omarchy-capture-qr"}, @@ -160,7 +160,7 @@ "setup.default.browser.firefox": {"icon":"","label":"Firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, "setup.default.browser.zen": {"icon":"󰖟","label":"Zen","checked":"[[ \"$(omarchy-default-browser)\" == \"zen\" ]]","action":"omarchy-default-browser zen"}, "setup.default.dictation": {"icon":"","label":"Dictation","title":"Default Dictation"}, - "setup.default.dictation.superwhisper": {"icon":"","label":"Superwhisper","checked":"[[ \"$(omarchy-default-dictation)\" == \"superwhisper\" ]]","action":"omarchy-default-dictation superwhisper"}, + "setup.default.dictation.superwhisper": {"icon":"","label":"Superwhisper","when":"omarchy-hw-x86","checked":"[[ \"$(omarchy-default-dictation)\" == \"superwhisper\" ]]","action":"omarchy-default-dictation superwhisper"}, "setup.default.dictation.voxtype": {"icon":"","label":"Voxtype","checked":"[[ \"$(omarchy-default-dictation)\" == \"voxtype\" ]]","action":"omarchy-default-dictation voxtype"}, "setup.default.terminal": {"icon":"","label":"Terminal","title":"Default Terminal"}, "setup.default.terminal.alacritty": {"icon":"","label":"Alacritty","checked":"[[ \"$(omarchy-default-terminal)\" == \"alacritty\" ]]","action":"omarchy-default-terminal alacritty"}, @@ -189,7 +189,7 @@ "setup.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"}, "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","checked":"systemctl is-enabled --quiet usbguard.service && systemctl --user is-enabled --quiet omarchy-usb-authorization.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-usb-authorization"}, - "setup.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","checked":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-security-usb-authorization --boot'"}, + "setup.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","when":"! omarchy-hw-aarch64-apple","checked":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-security-usb-authorization --boot'"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.ssh-agent": {"icon":"󰣀","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"}, "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, @@ -197,7 +197,7 @@ "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, - "setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, + "setup.direct-boot": {"icon":"","label":"Direct Boot","when":"! omarchy-hw-aarch64-apple","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, "setup.reset": {"icon":"󰑓","label":"Reset Computer","when":"[[ $(findmnt -no FSTYPE /) == btrfs ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-system-factory-reset"}, // Install @@ -221,18 +221,18 @@ "install.webapp": {"icon":"","label":"Web App","action":"omarchy-launch-floating-terminal-with-presentation omarchy-webapp-install"}, "install.terminal": {"icon":"","label":"Terminal"}, "install.tui": {"icon":"","label":"TUI","action":"omarchy-launch-floating-terminal-with-presentation omarchy-tui-install"}, - "install.windows": {"icon":"󰍲","label":"Windows","disabled":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"}, + "install.windows": {"icon":"󰍲","label":"Windows","when":"omarchy-hw-x86","disabled":"[[ -f $HOME/.local/share/applications/windows-vm.desktop ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-windows-vm install'"}, "install.aur": {"icon":"󰣇","label":"AUR","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-aur-install"}, "install.preinstalls": {"icon":"󰏓","label":"Preinstalls","disabled":"[[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-preinstalls"}, "install.browser.chrome": {"icon":"","label":"Chrome","disabled":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser chrome'"}, - "install.browser.edge": {"icon":"󰇩","label":"Edge","disabled":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"}, + "install.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser edge'"}, "install.browser.brave": {"icon":"","label":"Brave","disabled":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave'"}, "install.browser.brave-origin": {"icon":"","label":"Brave Origin","disabled":"omarchy-pkg-present brave-origin-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser brave-origin'"}, "install.browser.firefox": {"icon":"","label":"Firefox","disabled":"omarchy-pkg-present firefox","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser firefox'"}, "install.browser.zen": {"icon":"󰖟","label":"Zen","disabled":"omarchy-pkg-present zen-browser-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-browser zen'"}, "install.service.1password": {"icon":"󰢁","label":"1Password","disabled":"omarchy-pkg-present 1password","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-1password"}, - "install.service.dropbox": {"icon":"","label":"Dropbox","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"}, - "install.service.spotify": {"icon":"󰓇","label":"Spotify","disabled":"omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"}, + "install.service.dropbox": {"icon":"","label":"Dropbox","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"}, + "install.service.spotify": {"icon":"󰓇","label":"Spotify","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"}, "install.service.signal": {"icon":"󰭹","label":"Signal","disabled":"omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"}, "install.service.slack": {"icon":"󰒱","label":"Slack","disabled":"omarchy-pkg-present slack-desktop","action":"omarchy-install-and-launch Slack slack-desktop slack"}, "install.service.tailscale": {"icon":"","label":"Tailscale","disabled":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"}, @@ -263,13 +263,13 @@ "install.ai.t3-code": {"icon":"","iconFont":"omarchy","label":"T3 Code","disabled":"omarchy-pkg-present t3code-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-t3-code"}, "install.gaming.steam": {"icon":"","label":"Steam","disabled":"omarchy-pkg-present steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"}, "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","disabled":"omarchy-pkg-present retroarch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"}, - "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","disabled":"omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"}, + "install.gaming.minecraft": {"icon":"󰍳","label":"Minecraft","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present minecraft-launcher","action":"omarchy-install-and-launch Minecraft minecraft-launcher minecraft-launcher"}, "install.gaming.geforce-now": {"icon":"󰢹","label":"NVIDIA GeForce NOW","disabled":"flatpak info com.nvidia.geforcenow","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-geforce-now"}, "install.gaming.xbox-cloud": {"icon":"","label":"Xbox Cloud Gaming","disabled":"[[ -f \"$HOME/.local/share/applications/Xbox Cloud Gaming.desktop\" ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-xbox-cloud"}, "install.gaming.xbox-controllers": {"icon":"󰂯","label":"Xbox Controllers","disabled":"omarchy-pkg-present xpadneo-dkms","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-xbox-controllers"}, "install.gaming.battlenet": {"icon":"","label":"Battle.net","disabled":"[[ -d $HOME/Games/battlenet ]]","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-battlenet"}, "install.gaming.lutris": {"icon":"","label":"Lutris","disabled":"omarchy-pkg-present lutris","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-lutris"}, - "install.gaming.heroic": {"icon":"󱓟","label":"Heroic (Epic Games)","disabled":"omarchy-pkg-present heroic-games-launcher-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-heroic"}, + "install.gaming.heroic": {"icon":"󱓟","label":"Heroic (Epic Games)","when":"omarchy-hw-x86","disabled":"omarchy-pkg-present heroic-games-launcher-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-heroic"}, "install.gaming.retro-launcher": {"icon":"󰯉","label":"RetroArch Game Launcher","action":"omarchy-games-retro-install"}, "install.development.rails": {"icon":"󰫏","label":"Ruby on Rails","disabled":"[[ -d $HOME/.local/share/mise/installs/ruby ]]","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-dev-env ruby'"}, "install.development.docker-dbs": {"icon":"","label":"Docker DB","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-docker-dbs"}, @@ -313,7 +313,7 @@ "remove.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"}, "remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"}, "remove.security.usb-authorization": {"icon":"󰖀","label":"USB Device Authorization","when":"systemctl is-enabled --quiet usbguard.service","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-usb-authorization"}, - "remove.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","when":"grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-security-usb-authorization --boot-only'"}, + "remove.security.usb-authorization-boot": {"icon":"󰖀","label":"USB at Boot","when":"! omarchy-hw-aarch64-apple && grep -Fqx 'KERNEL_CMDLINE[default]+=\" usbcore.authorized_default=0\"' /etc/limine-entry-tool.d/usb-authorization.conf 2>/dev/null","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-security-usb-authorization --boot-only'"}, "remove.security.sshd": {"icon":"󰣀","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"}, "remove.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"}, "remove.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","when":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-hermes"}, diff --git a/default/pacman/aarch64-apple/mirrorlist-edge b/default/pacman/aarch64-apple/mirrorlist-edge new file mode 100644 index 00000000..74727dba --- /dev/null +++ b/default/pacman/aarch64-apple/mirrorlist-edge @@ -0,0 +1,3 @@ +# Omarchy's mirror of Arch Linux ARM, over HTTPS since the repository databases +# are unsigned. It keeps Arch Linux ARM's $arch/$repo layout. +Server = https://arm-mirror.omarchy.org/$arch/$repo diff --git a/default/pacman/aarch64-apple/pacman-edge.conf b/default/pacman/aarch64-apple/pacman-edge.conf new file mode 100644 index 00000000..2355cd68 --- /dev/null +++ b/default/pacman/aarch64-apple/pacman-edge.conf @@ -0,0 +1,39 @@ +# See the pacman.conf(5) manpage for option and repository directives +# aarch64 has an edge channel alone: Omarchy publishes aarch64 packages on edge +# so far, and stable and rc templates come once a release supports aarch64. + +[options] +Color +ILoveCandy +VerbosePkgLists +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +ParallelDownloads = 5 +DownloadUser = alpm + +# By default, pacman accepts packages signed by keys that its local keyring +# trusts (see pacman-key and its man page), as well as unsigned packages. +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +# pacman searches repositories in the order defined here. Omarchy comes first on +# Apple Silicon, so its builds for the Mac (the Aurora kernel, m1n1, U-Boot and +# the Hyprland stack) win over the same names in Asahi ALARM and Arch Linux ARM. +[omarchy] +Server = https://pkgs.omarchy.org/edge/$arch + +[asahi-alarm] +Server = https://github.com/asahi-alarm/asahi-alarm/releases/download/aarch64 + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[alarm] +Include = /etc/pacman.d/mirrorlist + +[aur] +Include = /etc/pacman.d/mirrorlist diff --git a/default/pacman/aarch64/mirrorlist-edge b/default/pacman/aarch64/mirrorlist-edge new file mode 100644 index 00000000..74727dba --- /dev/null +++ b/default/pacman/aarch64/mirrorlist-edge @@ -0,0 +1,3 @@ +# Omarchy's mirror of Arch Linux ARM, over HTTPS since the repository databases +# are unsigned. It keeps Arch Linux ARM's $arch/$repo layout. +Server = https://arm-mirror.omarchy.org/$arch/$repo diff --git a/default/pacman/aarch64/pacman-edge.conf b/default/pacman/aarch64/pacman-edge.conf new file mode 100644 index 00000000..0401bf5e --- /dev/null +++ b/default/pacman/aarch64/pacman-edge.conf @@ -0,0 +1,36 @@ +# See the pacman.conf(5) manpage for option and repository directives +# Arch Linux ARM provides the aarch64 repositories used below. +# aarch64 has an edge channel alone: Omarchy publishes aarch64 packages on edge +# so far, and stable and rc templates come once a release supports aarch64. + +[options] +Color +ILoveCandy +VerbosePkgLists +HoldPkg = pacman glibc +Architecture = auto +CheckSpace +ParallelDownloads = 5 +DownloadUser = alpm + +# By default, pacman accepts packages signed by keys that its local keyring +# trusts (see pacman-key and its man page), as well as unsigned packages. +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional + +# pacman searches repositories in the order defined here. Omarchy comes first, +# as on x86_64, so a package it also publishes is installed from Omarchy. +[omarchy] +Server = https://pkgs.omarchy.org/edge/$arch + +[core] +Include = /etc/pacman.d/mirrorlist + +[extra] +Include = /etc/pacman.d/mirrorlist + +[alarm] +Include = /etc/pacman.d/mirrorlist + +[aur] +Include = /etc/pacman.d/mirrorlist diff --git a/default/settings-runtime-profile b/default/settings-runtime-profile new file mode 100644 index 00000000..c175ff2a --- /dev/null +++ b/default/settings-runtime-profile @@ -0,0 +1,27 @@ +# omarchy-settings ships the same files on every architecture when its source +# has this file: the recipe stops leaving drop-ins out of the aarch64 package. +# Each platform-specific file below, by its path in this source, takes its +# decision at runtime instead. +# +# etc/mkinitcpio.conf.d/00-omarchy-hooks.conf +# Sets each platform's HOOKS baseline (omarchy-hw-platform), and leaves alone +# a Mac whose own mkinitcpio.conf carries asahi or busybox encrypt. +# etc/mkinitcpio.conf.d/omarchy_hooks.conf +# Only filters kms for NVIDIA and handles the vconsole hooks, from the +# machine's own devices and configuration. +# etc/mkinitcpio.conf.d/thunderbolt_module.conf +# Adds thunderbolt to MODULES only where modinfo finds it as a loadable module. +# etc/limine-entry-tool.d/, default/limine/ +# Read only where Limine and its entry tool are installed. +# etc/systemd/oomd.conf.d/, default/systemd/user/app.slice.d/10-oomd.conf +# Inert unless systemd-oomd runs (install/config/enable-services.sh, +# migration 1785424256). +# default/systemd/zram-generator.conf.d/90-omarchy.conf, +# etc/sysctl.d/99-omarchy-sysctl.conf, etc/tmpfiles.d/omarchy-zswap.conf +# Swap on zram everywhere: the installer provides zram-generator on x86_64, +# install/omarchy-aarch64.packages on aarch64. +# config/omarchy/defaults/dictation +# Presets Superwhisper, which has no aarch64 build; user setup removes the +# preset there (install/user/dictation-default.sh). +# etc/modprobe.d/omarchy-usb-autosuspend.conf +# Applies where usbcore is a module; a kernel that builds it in ignores it. diff --git a/docs/file-layout.md b/docs/file-layout.md index 4abb6745..9831e349 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -54,6 +54,12 @@ behind, which arms `omarchy-provision-owner.service` (shipped from `setup-form.sh`). On first boot `bin/omarchy-provision-owner` creates the user on tty1 and runs the finalize step itself. +On an encrypted install `omarchy-provision-owner` then re-keys LUKS from the staged install key to the owner's password through `install/provisioning/luks-rekey.sh`, which journals each step (phase and slot numbers, never keys) in `/var/lib/omarchy/provisioning/luks-rekey.state` so an interrupted first boot resumes. Setup finishes only once the staged key opens nothing, and removes the journal with `pending`, so a retry after the staged key is retired must use the password the disk holds. On a platform whose boot package owns the boot chain, the check before the owner form, the boot-time unlock and the record of the kept key slot go through `omarchy-lifecycle-dispatch`. See [lifecycle-dispatch.md](lifecycle-dispatch.md). + +`omarchy-drive-password` reuses those journal helpers when the owner changes the password of the disk holding `/`: it changes the LUKS key and confirms the new key opens the disk and the old one no longer does. The login and root passwords are left alone. `~/.local/state/omarchy/drive-password.state` records the disk's UUID, the key slots and the phase (never a password) until the change is confirmed and its slot recorded, and the next run finishes an interrupted change with whichever of the old or new password the disk opens with. + +Prebuilt images are set up away from the machine they will run on, so their hardware setup waits for that machine. The builder writes a root-owned manifest, `/var/lib/omarchy/image/target` (`format=1`, `platform=`), before `omarchy-apply-system`. While it exists, `omarchy-apply-hardware` runs no hardware leaf: it queues each one in `/var/lib/omarchy/image/deferred-steps` and arms `omarchy-provision-hardware.service` (shipped from `install/provisioning/`). On the machine's first boot, before owner setup and the login screen, `bin/omarchy-provision-hardware` renames the manifest to `target.booted` and runs the queue in order, dropping each step that succeeds. Before the first step it makes the machine's own pacman keyring when the build asked for one (`/var/lib/omarchy/image/pacman-keyring`, which install finalization writes on aarch64 platforms other than Apple Silicon instead of running `pacman-key --init`, so no two machines share a master key). A failed step stays queued for the next boot. It refreshes no package databases: the image carries its sync databases and every package its queued steps install, so the queue runs offline (the image build checks that it carries them; a step whose package is missing stays queued). Once the queue is empty it rebuilds the initramfs if a step changed its inputs (the mkinitcpio configuration, module options in `/etc/modprobe.d/` and `/usr/lib/modprobe.d/`, or the kernel command line in `/etc/default/limine` and `/etc/limine-entry-tool.d/`) or asked for the rebuild (`/var/lib/omarchy/image/boot-rebuild`), and disables the service; the unit file stays, so a start job already queued is skipped on the emptied queue rather than failed. A platform's own first boot may run `omarchy-provision-hardware` itself: runs are serialised, and it exits 75 when a step or the keyring failed and stays queued, and 1 when it refused to run (an untrusted manifest or queue) or the final rebuild failed, which the next run retries. Steps know they run on an image's first boot from `OMARCHY_IMAGE_DEFERRED_HARDWARE=1` (Bluetooth starts its service there, since boot already brought up its target). `install/helpers/image-target.sh` holds the contract. Root always uses these fixed paths; no environment variable turns a live system into an image build. + Current generated theme state lives under `~/.local/state/omarchy/current/`. Keep `~/.config/omarchy/` for files a user may intentionally version in a dotfile manager, such as user themes, hooks, @@ -94,7 +100,7 @@ default/applications/*.desktop applications/icons/* ──► omarchy-settings /usr/share/icons/hicolor/{48,256,scalable}/apps/ etc/** ──► omarchy-settings /etc/** (drop-ins we own outright) - ├─ mkinitcpio.conf.d/{omarchy_hooks,thunderbolt_module}.conf + ├─ mkinitcpio.conf.d/{00-omarchy-hooks,omarchy_hooks,thunderbolt_module}.conf ├─ limine-entry-tool.d/{omarchy-defaults,omarchy-uki}.conf ├─ NetworkManager/, sudoers.d/, sysctl.d/, tmpfiles.d/, │ profile.d/omarchy.sh, … (a summary — `ls etc/` for the full ~17-entry tree) @@ -140,6 +146,48 @@ logo.{txt,svg}, icon.{txt,png} ──► omarchy-settings /usr/share/omarchy The hardware-conditional `force-igpu` and `keyboard-backlight` sources also live under `default/systemd/system-sleep/`, but their setup commands publish root-owned copies only on machines that need them; they are not installed by `omarchy-settings`. +### Platform root + +A platform's runtime package (omarchy-mac on Apple Silicon, say) adds its desktop files under a fixed root that only the one installed platform package owns. Omarchy ships nothing there, and a missing root or file means no platform additions. No environment variable moves it; tests reach fixtures through test-only seams, never through a desktop session's environment. + +``` +/usr/share/omarchy-platform/ + displays.conf backlight and DDC hints for the display commands (below) + key-names " " lines the keybindings menu shows in place of keysyms + display-cutouts.json camera cutouts the top bar keeps out of (see omarchy-shell.md) + keyrings signing keyring packages of the repositories the platform adds, one per line + audio.json audio processing nodes the audio panel and microphone widget leave out (below) +``` + +See [lifecycle-dispatch.md](lifecycle-dispatch.md#platform-files). + +`omarchy-update-keyring` reinstalls each installed package `keyrings` names (`#` comments and blank lines skipped, and a line that isn't a `*-keyring` package name skipped with a warning) with Arch's, before the system upgrade, so a key rotation in the platform's own repository never fails that upgrade's signature checks. On Apple Silicon, omarchy-mac names `asahi-alarm-keyring` there. + +#### Display hints (`displays.conf`) + +`omarchy-hw-display` picks the built-in panel's backlight by name (`gmux_backlight`, then `amdgpu_bl*`, `intel_backlight`, `acpi_video*`, else the first one listed, never a T2 Touch Bar's `appletb_backlight`), and `omarchy-brightness-display` probes every external monitor over DDC. Where a platform's names or display driver differ, its platform package says so in `/usr/share/omarchy-platform/displays.conf`; Omarchy ships none, and without it both behave as above. One directive per line, whitespace-separated; blank lines, whole-line `#` comments, unknown directives, and lines with a wrong word count or a name that is `.`, `..` or has a `/` are ignored: + +- `backlight-skip `: a `/sys/class/backlight` name (a shell glob) that never drives the built-in panel, such as a Touch Bar's. It is never picked, not even when a later directive or the built-in order names it. +- `backlight-prefer `: the built-in panel's backlight, a literal name, tried after `gmux_backlight` and before the GPU and ACPI ones, in file order. +- `ddc-require-connector-ddc`: the display driver registers no DDC channel, so a probe would only walk unrelated I2C buses. An external monitor (other than an Apple Studio or XDR Display, which `omarchy-brightness-display-apple` drives with asdcontrol) is probed only when its DRM connector has a `ddc` node (`/sys/class/drm/card*-/ddc`); otherwise it gets no DDC or backlight control, and the built-in panel is never dimmed in its place. + +No environment variable moves the file: these commands also run under `sudo` and from the brightness keys. Their tests (`test/shell.d/hw-display-test.sh`, `test/shell.d/brightness-display-test.sh`) run a copy rewritten to read a fixture in its place. + +#### Audio hints (`audio.json`) + +The audio panel lists every output, input and playback stream PipeWire has, and the microphone widget counts every recording as the microphone in use (the shell's own level meters never count). A platform whose audio runs through its own processing (DSP filter graphs in front of raw devices, say) has nodes that are neither devices nor apps, and its platform package names them in `/usr/share/omarchy-platform/audio.json`; Omarchy ships none, and without it nothing is left out. + +```json +{ + "hidden": ["", "..."], + "replaced": [{ "node": "", "by": "" }] +} +``` + +A pattern is a JavaScript regular expression matched against a whole `node.name`. A `hidden` node is never listed as an output, an input or an app stream, unless it is the current default output or input (so the panel always shows what's in use), and never lights the microphone widget; a `replaced` node is left out only while a node matching `by` exists (a mono processed microphone behind a stereo copy of it, say). Missing or malformed JSON means no hints, and an invalid pattern or entry is skipped. The shell reads the file at that fixed path, which no environment variable moves (`shell/Commons/AudioNodes.qml`), and `test/shell.d/audio-test.sh` covers the parsing. + +A virtual source (an `Audio/Source/Virtual`, such as EasyEffects' or a platform's microphone mapping) needs no hint: Quickshell leaves it untyped, so the panel and widget set its volume and mute through `wpctl` (`shell/Commons/UntypedInput.qml`, one `pactl subscribe` for the whole shell), shows no level meter for it (Quickshell's peak monitor takes typed nodes only), and the panel lists it because PulseAudio does (`omarchy-audio-sink-availability sources`). + ### Why `etc-overrides/` exists Some files under `/etc/` (`.bashrc` in `/etc/skel`, `nsswitch.conf`, @@ -279,6 +327,9 @@ and/or a working user systemd instance: Done here, not at finalize, because the user manager isn't reachable from the ISO chroot; `ConditionPath*` in the unit files keeps services inert when they don't apply. +- `omarchy-lifecycle-dispatch setup-user` — the platform's own user setup, + now with the session up (a no-op where the platform registers none; see + [lifecycle-dispatch.md](lifecycle-dispatch.md)). - `install/user/first-run/gnome-theme.sh`, `install/user/first-run/gtk-primary-paste.sh` — GNOME/GTK settings that need the dconf daemon. @@ -319,10 +370,26 @@ finalization. It sources: Logging goes to `/var/log/omarchy-install.log` via `install/helpers/logging.sh`. +Platform-specific setup asks `omarchy-hw-platform`, which prints `x86`, `aarch64` or `aarch64-apple` (the naming standard is in AGENTS.md under Platforms). It reads the vendor prefix of each token in the device tree's root `compatible` (`apple,` or `qcom,`, from `/proc/device-tree` or `/sys/firmware/devicetree/base`) and the CPU architecture, and fails when they contradict each other. A Snapdragon laptop's `qcom,` tree is `aarch64`. A manifest written by an older image builder may still say `apple-silicon`, `generic-aarch64` or `generic`, which read as `aarch64-apple`, `aarch64` and `x86`. + +An image built away from the machine it will run on names its target in a root-owned manifest, `/var/lib/omarchy/image/target` (`format=1`, `platform=`, unknown keys ignored). While the root is being built rather than booted, the detector answers from the manifest and never reads the build host's device tree. The root counts as built when it shows it: no `/run/systemd/system`, PID 1's root is another one (a chroot), or PID 1 is not systemd (a PID namespace). A booted system always answers from its hardware, even with a manifest left behind, so no unit that asks the detector may use `PrivatePIDs=`. As root the detector restarts in an empty environment and ignores the fixture variables its tests use. + The package lists the ISO pacstraps live at `install/omarchy-base.packages` and `install/omarchy-other.packages`; the ISO builder also reads them when constructing its offline mirror. +A platform's default package set is the base list, then its architecture's additions, then its own: `install/omarchy-aarch64.packages` on every aarch64 platform, then `install/omarchy-.packages` when that platform has one (`install/omarchy-aarch64-apple.packages` on Apple Silicon). x86 installs the base list alone. `omarchy-pkg-defaults [platform]` prints the composed set, for the running machine by default (via `omarchy-hw-platform`, so an image build gets its target's set), and `omarchy-reinstall-pkgs` installs it. + +## Platform display hints (`/usr/share/omarchy-platform/displays.conf`) + +`omarchy-hw-display` picks the built-in panel's backlight by name (`gmux_backlight`, then `amdgpu_bl*`, `intel_backlight`, `acpi_video*`, else the first one listed, never a T2 Touch Bar's `appletb_backlight`), and `omarchy-brightness-display` probes every external monitor over DDC. Where a platform's names or display driver differ, its platform package says so in `/usr/share/omarchy-platform/displays.conf`; Omarchy ships none, and without it both behave as above. One directive per line, whitespace-separated; blank lines, whole-line `#` comments, unknown directives, and lines with a wrong word count or a name that is `.`, `..` or has a `/` are ignored: + +- `backlight-skip `: a `/sys/class/backlight` name (a shell glob) that never drives the built-in panel, such as a Touch Bar's. It is never picked, not even when a later directive or the built-in order names it. +- `backlight-prefer `: the built-in panel's backlight, a literal name, tried after `gmux_backlight` and before the GPU and ACPI ones, in file order. +- `ddc-require-connector-ddc`: the display driver registers no DDC channel, so a probe would only walk unrelated I2C buses. An external monitor (other than an Apple Studio or XDR Display, which `omarchy-brightness-display-apple` drives with asdcontrol) is probed only when its DRM connector has a `ddc` node (`/sys/class/drm/card*-/ddc`); otherwise it gets no DDC or backlight control, and the built-in panel is never dimmed in its place. + +No environment variable moves the file: these commands also run under `sudo` and from the brightness keys. Their tests (`test/shell.d/hw-display-test.sh`, `test/shell.d/brightness-display-test.sh`) run a copy rewritten to read a fixture in its place. + ## Explicit resync (`omarchy-reinstall-configs`) When an existing user wants to reset to shipped defaults: diff --git a/docs/lifecycle-dispatch.md b/docs/lifecycle-dispatch.md new file mode 100644 index 00000000..4e458644 --- /dev/null +++ b/docs/lifecycle-dispatch.md @@ -0,0 +1,164 @@ +# Lifecycle dispatch + +Omarchy owns the boot lifecycle flows: the owner wizard, account creation, LUKS discovery, retry journals, snapshots, the migration runner and the update flow. Some platforms boot through a chain those flows can't drive generically. Apple Silicon Macs boot m1n1 → U-Boot → Limine, keep the install key on an ext4 boot partition and name it on the kernel command line. For those platforms, the flows call a small fixed set of operations through `bin/omarchy-lifecycle-dispatch`, and a platform boot package implements them as root-owned entrypoints. System and user setup, and the installers of apps a platform needs to adjust, call a few more, which a platform's runtime package implements, so its own setup runs from Omarchy's without an inline platform branch. Every other platform keeps the generic path, and each dispatch call is a no-op there. + +## The command + +``` +omarchy-lifecycle-dispatch [arguments...] +omarchy-lifecycle-dispatch --resolve +``` + +The first form runs the operation. `--resolve` prints the entrypoint the operation would run, or nothing when the operation is a no-op on this machine. A caller uses it when a platform implementation replaces a generic step (see [Callers](#callers)). + +| Situation | Run | `--resolve` | +| --- | --- | --- | +| The platform registers no package (`x86` and `aarch64` today) | no-op, exit 0 | prints nothing, exit 0 | +| The entrypoint exists and passes the trust rules | execs it; its exit status is the result | prints its path | +| A required operation has no entrypoint, and the package is not installed | exit 3 (an entrypoint's own status could also be 3; with `--resolve` it is only this): `Error: on needs , which provides ; it is not installed` | same error | +| A required operation has no entrypoint, but the package is installed (its pacman record says so) | exit 1: `Error: on needs , which does not provide; update ` | same error | +| An optional operation has no entrypoint (every setup and app-install operation is optional, so none of them ever fails for a missing package) | no-op, exit 0 | prints nothing, exit 0 | +| A user operation (`setup-user`, `post-install`, `pre-remove`) is run as root on a platform that registers it (Apple Silicon), its entrypoint installed or not | exit 1: `Error: runs as the user, never as root` | same error | +| The entrypoint fails the trust rules | exit 1: `Error: refusing : ...` (optional operations too) | same error | +| `omarchy-hw-platform` can't settle the platform | exit 1 | exit 1 | +| No operation, or one outside the fixed set | exit 2 with usage | exit 2 | + +## Operations + +The set is fixed in the dispatcher; adding one is a change to Omarchy. Every operation runs as root except `setup-user`, `post-install` and `pre-remove`, which run as the user. The `provision-*` operations take no arguments and work on fixed paths: `/var/lib/omarchy/provisioning` holds the staged install key (`luks-key`) and the re-key journal (`luks-rekey.state`). `luks-slots` takes the slot numbers it records. Factory reset names the factory root it is about to activate, since that is not `/` yet, and hands `reset-commit` the throwaway key on standard input, never in arguments. + +| Operation | Called | Contract | Apple | Caller | +| --- | --- | --- | --- | --- | +| `provision-prepare` | Owner provisioning, at the start of every setup attempt, before the owner is asked anything | Succeeds when the platform can finish setup on this machine. On failure its stderr is shown on tty1 and logged, and the attempt fails into the usual retry screen. It must leave nothing a retry can't repeat. | required | `omarchy-provision-owner` | +| `provision-commit` | Owner provisioning, during the LUKS re-key: after the owner's key is added, before any other slot is retired | Removes every boot-time copy of the staged key and its unlock configuration from the platform's boot chain, and rebuilds the boot files so the next boot asks for the password. Idempotent. If it fails, it leaves or restores a boot chain that still unlocks unattended with the staged key, so the retry boots. | required | `omarchy-provision-owner` | +| `provision-verify` | Owner provisioning, whenever it asks whether the staged unlock remains: before the re-key, during it, and before setup drops `pending` | Read-only. Exits 0 only when the boot chain holds no staged key or unlock configuration. Any other status counts as "remains", so setup never finishes. | required | `omarchy-provision-owner` | +| `reset-prepare []` | Factory reset, once the factory root is cloned and scrubbed, before switching to it. The device is there only when the root is encrypted. | Stages the platform's boot state for the factory root: the unlock's command line, the rebuilt boot files, and encryption state reopened for the next owner. It keeps what `reset-rollback` restores, and writes no key. | required | `omarchy-system-factory-reset` | +| `reset-verify ` | Factory reset, right after `reset-prepare` and before the throwaway slot is added and the switch committed | Read-only. Proves the rebuilt boot files boot the factory root on this boot chain. A failure rolls the reset back. | required | `omarchy-system-factory-reset` | +| `reset-commit` (key on standard input) | Factory reset, once the factory root is the active root | Puts the throwaway key where the staged unlock reads it, so the first boot unlocks unattended until owner setup re-keys, and drops what `reset-rollback` would restore. A failure is logged, not fatal: that boot asks for the current disk password once. | required | `omarchy-system-factory-reset` | +| `reset-rollback` | Factory reset, when anything fails after `reset-prepare` started and before the factory root is active, `reset-prepare`'s own failure included | Restores the previous boot state. Nothing to restore is a success. | required | `omarchy-system-factory-reset` | +| `update-verify` | Update, after the last package step: the transaction, migrations, orphan removal and AUR packages | Read-only. Verifies the boot chain boots the updated system, whose new kernel may still wait for its reboot. A failure leaves the update unfinished: it exits non-zero and offers no reboot. | required | `omarchy-update-boot` (`omarchy update`) | +| `update-takeover ...` | Update, when an Omarchy package's upgrade stopped only on files no package owns, before they are moved aside for the retry | Read-only. Gets those paths. Exit 0 lets the takeover go ahead; any other status keeps every file where it is, and the update fails as it would have without the retry. A platform whose boot chain or image writes files it can't do without refuses those. | required | `omarchy-update-system-pkgs-when-conflicted` (`omarchy update`) | +| `luks-slots` | Owner provisioning, once the re-key keeps only the owner's slot, before it destroys the staged key; the disk password change, with `--owner` before anything changes on the system disk, then once the owner's new key is confirmed | `luks-slots owner= [recovery=]` records the root volume's kept slots wherever the platform's boot checks look for them. Owner setup passes an empty `recovery=`, which records none. Omarchy creates no recovery key; `recovery=` exists only for a slot an earlier Mac setup recorded, which stays when the argument is left out. `luks-slots --owner` changes nothing: it exits 0 printing the recorded owner slot when that slot is in the LUKS header; 4 with `No owner key slot is recorded` on stderr when there is no owner record at all (a Mac set up before its boot package recorded slots); and 1 with the reason on stderr for anything else: a record that isn't a slot number or names a slot the header lacks, an unreadable header, or a boot package too old to know `--owner`. Idempotent. It fails when a slot is not in the LUKS header, and the caller then retries. | required | `omarchy-provision-owner`, `omarchy-drive-password` | +| `setup-boot [image-first-boot]` | Hardware setup's last leaf (`install/hardware/platform-setup.sh`), before `setup-system`, on every install and rerun of `omarchy-apply-hardware`, and on an image's first boot with `image-first-boot` | The platform boot package's own boot setup (on a Mac, the GRUB console settings and the Limine activation). Idempotent. With `image-first-boot` it asks for the boot-file rebuild by creating `/var/lib/omarchy/image/boot-rebuild` where it would build one. A failure fails the leaf, and `setup-system` does not run. | required | `install/hardware/platform-setup.sh` | +| `setup-system [image-first-boot]` | Hardware setup, as its last leaf (`install/hardware/platform-setup.sh`), after `setup-boot`, on every install and every rerun of `omarchy-apply-hardware`. In an image build it is queued with the other leaves and runs on the machine's first boot, with `image-first-boot`. | The platform's machine-wide setup: services, configuration and drivers its runtime package owns. Idempotent. Needs no network with `image-first-boot` (that boot may be offline), and there it asks for a boot-file rebuild by creating `/var/lib/omarchy/image/boot-rebuild` rather than building one, since the deferred setup rebuilds once after its last step. A failure fails the leaf, and on an image's first boot the step stays queued for the next boot. | optional | `install/hardware/platform-setup.sh` | +| `setup-user` | Runs as the user being set up: at the end of user finalization (`install/user/platform-setup.sh`, from `install/user/all.sh`), and in first run once the session is up. Finalization inside an image build skips it; first run on the machine runs it. | The platform's per-user setup. Idempotent. Finalization may have no session (the ISO chroot, owner setup), so work that needs one (a user unit started now, a D-Bus call) is left for the first-run call, and its absence is not a failure. A failure fails finalization or the first-run step, so first run stays pending and runs it again at the next login. | optional | `install/user/platform-setup.sh`, `omarchy-provision-first-run` | +| `post-install ` | Runs as the user, from an installer once `` is installed and Omarchy's own setup of it is done: `chromium`, `chrome`, `edge`, `brave`, `brave-origin`, `firefox`, `zen` (`omarchy-install-browser`) and `steam` (`omarchy-install-gaming-steam`) | The platform's adjustments for that app, such as its browser flags or a launcher the app needs on this hardware (which may install a package through `sudo`). Idempotent. A failure fails the install. | optional | `omarchy-install-browser`, `omarchy-install-gaming-steam` | +| `pre-remove ` | Runs as the user, from a remover before ``'s packages go: `steam` (`omarchy-remove-gaming-steam`) | Undoes what `post-install ` added, packages that depend on the app first. Idempotent; nothing to undo is a success. A failure stops the removal before anything is removed. | optional | `omarchy-remove-gaming-steam` | + +Snapshot restore is not an operation: a Limine machine restores through `limine-snapper-restore`, and a platform boot package that checks a restore does so through limine-snapper-sync's own hooks. + +## Platform registration + +Registration is code in `bin/omarchy-lifecycle-dispatch`, not configuration. No file, environment variable or `PATH` entry decides what runs as root. + +| Platform | Implementation directory | Package | Required operations | +| --- | --- | --- | --- | +| `aarch64-apple` | `/usr/lib/omarchy/mac-boot` | `omarchy-mac-boot` | all its operations | +| `aarch64-apple`: `setup-system`, `setup-user`, `post-install`, `pre-remove` | `/usr/lib/omarchy/mac` | `omarchy-mac` | none | +| `x86`, `aarch64` | none | none | none: every operation is a no-op, and callers keep their generic path | + +The entrypoint for an operation is `/`. A registered platform's required operations must be shipped. Its optional operations may be left out, and then they are no-ops. + +On a Mac, owner provisioning and factory reset have no other path, so their operations are required: a Mac without `omarchy-mac-boot`'s entrypoints stops before the owner form or before the reset is confirmed, with the dispatcher's error naming the package, where the generic Limine path would leave the boot-partition key behind or rebuild a UKI the Mac does not boot. `luks-slots` is required because the Mac's boot checks prove the disk's key slots. `update-verify` is required because nothing else checks what an update left in a Mac's boot chain. `update-takeover` is required because a Mac's boot chain and image write files no package owns, so a Mac whose boot package can't vouch for a takeover keeps its files and the update stops at the conflict. `setup-boot` is required because the Mac's boot setup has no other path in hardware setup: a Mac whose `omarchy-mac-boot` lacks it stops the hardware leaf, asking for the update, where an optional operation would skip the Limine activation. System and user setup and the app-install hooks belong to the Mac's runtime package, `omarchy-mac`, not its boot package, and are optional: a Mac without `omarchy-mac` gets Omarchy's generic setup and installs. + +## Trust rules + +- The dispatcher runs as `bash -p`, so a root caller's `BASH_ENV` and exported functions run nothing in it, and it refuses an ordinary Bash launch. As root it uses a fixed `PATH`, runs the detector installed beside it with nothing in its environment but that `PATH` (the detector reads only the live device tree as root), and resolves only the fixed implementation directory. +- An entrypoint runs only if it is a regular executable file. Neither the file nor any directory up to `/` may be a symlink, and all of them must be owned by root and not writable by group or others. An entrypoint that fails these rules is refused, even for an optional operation. +- The entrypoint runs with an empty environment apart from `PATH=/usr/local/sbin:/usr/local/bin:/usr/bin`. It gets the caller's arguments, standard streams and working directory. Entrypoints use fixed paths, never environment variables. Anything that can also run them directly re-checks the platform itself. +- `setup-user`, `post-install` and `pre-remove` refuse root where the platform registers them, so they never run as anyone but their caller. Where it registers none they stay no-ops for root too, so an installer run with `sudo` still finishes there. Their entrypoints follow the same trust rules, and besides `PATH` they get only these, when the caller has them set: `HOME`, `USER`, `XDG_CONFIG_HOME` and `XDG_STATE_HOME` (where the user's configuration and setup markers live), `XDG_RUNTIME_DIR`, `DBUS_SESSION_BUS_ADDRESS`, `WAYLAND_DISPLAY` and `OMARCHY_PATH`. +- For unprivileged tests, `OMARCHY_LIFECYCLE_ROOT` (absolute) prefixes the implementation directory, and the detector's fixture roots apply. Inside that root, files the caller owns count as root's. Root ignores both. + +## Callers + +A dispatch point takes one of two shapes: + +- **A platform step with no generic counterpart:** `omarchy-lifecycle-dispatch || fail`. It is a no-op on platforms without a boot package. +- **A platform implementation that replaces a generic step:** `--resolve `. A path means dispatch the operation, an empty result means run the generic step, and a failure fails closed. + +Failing closed holds on every platform: where `omarchy-hw-platform` can't settle the platform (contradicting device-tree evidence, for one), first-boot setup and factory reset stop instead of guessing a boot path, and an update fails its verification without the reboot prompt. There is no check before an update's packages change: on a machine whose platform can't be told, the update installs its packages first, and it only then fails verification. A platform that needs an earlier refusal adds that operation with its first entrypoint. + +### Owner provisioning (`bin/omarchy-provision-owner`) + +- `platform_ready` runs `provision-prepare` and resolves `luks-slots` at the start of each setup attempt, before the keyboard and account forms. If either fails, the owner sees its error, the log records it, and the attempt ends in the retry or root-shell screen. +- The shared re-key (`install/provisioning/luks-rekey.sh`) asks the caller for two callbacks: `luks_auto_unlock_present` and `luks_auto_unlock_drop`. `unlock_owner` resolves `provision-commit` and `provision-verify` once per process. If both resolve, the platform owns the unlock: drop is `provision-commit`, and present is `provision-verify` failing. If neither resolves, the Limine UKI callbacks run unchanged (x86, Snapdragon, other aarch64). If only one resolves, or resolution fails, the unlock counts as present and can't be dropped, so setup never finishes. +- After a factory reset left Limine entries for another machine identity, `run_provisioning` starts the menu over from the template and runs `limine-update`, on every platform. +- The shared re-key calls the caller's `luks_record_slots` once it has verified that only the owner's slot remains and before it destroys the staged key; `omarchy-provision-owner` runs `luks-slots owner= recovery=` there, a no-op where nothing records them. +- Everything else stays as it is: the wizard, account and login, the journal, slot retirement, the proof that the staged key opens nothing, and cleanup. + +### Factory reset (`bin/omarchy-system-factory-reset`) + +- `reset_boot_owner` resolves the four reset operations before the reset is confirmed. If all resolve, the platform owns the factory root's boot chain; if none do, the generic path runs unchanged (x86, Snapdragon, other aarch64: throwaway slot, keyfile in the Limine UKI, `limine-update`, `verify_limine_hashes`). If only some resolve, or resolution fails (a Mac without `omarchy-mac-boot`'s entrypoints), the reset stops before anything changes. +- Where the platform owns it, the order is: authorise with the current passphrase and stage the throwaway in the factory root's `/var/lib/omarchy/provisioning/luks-key`; `reset-prepare`; `reset-verify`; add the throwaway slot; switch the subvolumes; `reset-commit` with the throwaway on standard input. The slot comes after verification, so a failed rebuild adds no credential, and the platform writes its boot-time key only after the switch, so a power loss before it leaves the previous root asking for its password, never unlocked unattended. +- Any failure before the switch runs `reset-rollback` (once `reset-prepare` started), then revokes the slot this attempt added (found by the throwaway key when the add was not confirmed) and deletes the clone: the previous root stays the one that boots, with its boot files and encryption state. Operation output goes to the reset log; a failure shows the operation's last line. +- Everything else stays as it is: the @factory clone, identity and account scrub, provisioning markers and units, LUKS discovery, the passphrase check, the throwaway slot, and the subvolume switch. + +### Update (`bin/omarchy-update`) + +- `omarchy-update-boot` runs `update-verify` after AUR packages, which the update runs last, so it is the last sudo-capable step; like AUR it follows third-party build code, so it authenticates through the no-update wrapper without a reusable timestamp. When it fails, the update still releases Stay Awake, then says the update is not finished and exits 1 without the reboot prompt. +- It resolves the operation as the user first and runs it with `sudo` only when it resolves to an entrypoint, so an update with nothing to run never asks for root. A failed resolution fails the step with the dispatcher's message, except one: `update-verify` on a machine without its platform's boot package at all (exit 3) warns that the boot files were not verified and lets the update finish. Such a machine predates the package and boots a chain it does not manage; once the package is installed, a failed verification blocks. A package too old to ship `update-verify` blocks, since the fix is one package update away. +- The update path rebuilds no boot file itself: package hooks do, and `update-verify` catches what they missed. +- When an Omarchy package's upgrade stops only on files no package owns, `omarchy-update-system-pkgs-when-conflicted` moves them aside and retries. Before it moves anything it resolves `update-takeover` as the user and, when it resolves, runs `sudo omarchy-lifecycle-dispatch update-takeover ...` (inside the update's authorization, which the move needs anyway). A refusal, or a failed resolution (a Mac without its boot package, or with one too old to ship the operation), keeps every file and fails the update as it did before the retry existed. Where nothing resolves, the takeover goes ahead with no extra step. + +### Hardware setup (`install/hardware/platform-setup.sh`) + +- The last leaf of `install/hardware/all.sh` runs `setup-boot`, then `setup-system`, so both build on every generic leaf. As a `run_logged` leaf it is queued in an image build like any other, and `omarchy-provision-hardware` runs it on the first boot with `OMARCHY_IMAGE_DEFERRED_HARDWARE=1`, which the leaf passes on as `image-first-boot` to both. The environment variable itself never reaches the entrypoints. + +### User setup (`install/user/platform-setup.sh`, `bin/omarchy-provision-first-run`) + +- The last leaf of `install/user/all.sh` runs `setup-user` at the end of finalization. While `/var/lib/omarchy/image/target` exists the root is still being built, where a normal user's detector would read the build host, so the leaf says so and skips it. +- First run runs `setup-user` again as its own step, after the user units are enabled, now that the session is up. Its failure, like finalization's, leaves first run pending for the next login. + +### App installs (`bin/omarchy-install-browser`, `bin/omarchy-install-gaming-steam`, `bin/omarchy-remove-gaming-steam`) + +- A browser install runs `post-install ` once the browser's package, policy directory, flags file and theme are in place, and before it says the browser is installed. Steam's install runs `post-install steam` after the 32-bit drivers and the package, and before the first launch, and Steam's removal runs `pre-remove steam` before its packages go, so a package the hook added that depends on Steam doesn't stop the removal. +- On a platform that registers none, each call is a no-op and the installer runs as before. + +### Disk password change (`bin/omarchy-drive-password`) + +- After the system disk's key changed and the new key is confirmed, `record_owner_slot` resolves `luks-slots` as the user and, when it resolves, runs `sudo omarchy-lifecycle-dispatch luks-slots owner=`, so no other platform sees an extra `sudo`. Until that succeeds the journal stays, and the next run finishes the change and records the slot. The new key can land in another slot (cryptsetup 2.8's `luksChangeKey` moves a LUKS1 key to the first free slot, and keeps a LUKS2 one in place), and the boot check would then find a slot the platform did not record. +- Before anything changes, where `luks-slots` resolves, `owner_slot_matches` runs `sudo omarchy-lifecycle-dispatch luks-slots --owner` and refuses a current password whose slot is not the owner's, so another key on the system disk, such as a recovery key an earlier Mac setup added, stays as it is and can't take the owner's place. Exit 4 (no owner slot recorded) is taken as a machine nobody recorded: the change goes on only when the current password opens the disk's one key slot, checked again before the key changes, and records the new slot as the owner's once it is confirmed; with other keys on the disk it stops, naming the slots and saying how to record them (`luks-slots owner= recovery=`) or remove the extra key. Any other failure stops the change with the boot package's reason. + +## Key slots + +Omarchy has no disk recovery key: the disk, the login user and root share one password, as on every install. The re-key keeps the owner's slot alone. + +- **The owner's password:** the owner's slot is the re-key's own step, so a retry may still choose a new password while the staged key opens the disk, even after the boot step: the re-key then retires the old password's slot and records the final slot with `luks-slots`. +- **Temporary key:** setup finishes only after the re-key proved the staged key opens nothing, the boot package's `provision-verify` found no boot-time copy, and `luks-slots` recorded the kept slot. + +## Platform files + +A platform's runtime package describes its hardware in files under the platform root, `/usr/share/omarchy-platform/`, without an operation and without a branch in Omarchy's code: display hints, camera cutouts, audio nodes, keyrings and key names (see [file-layout.md](file-layout.md#platform-root)). Only the one installed platform package owns the root, and Omarchy ships nothing in it. The root is fixed: no environment variable moves it, and a development checkout in `OMARCHY_PATH` doesn't replace it. A missing root or file means no platform additions. It changes no binds, settings or gestures: Omarchy behaves the same on every machine. + +- **Key names: `key-names`**, one ` ` per line, which the keybindings menu shows in place of the keysym (a MacBook's `XF86MonBrightnessUp` is its F2 key). Its tests run copies of the menu that read a fixture root (`platform_root_copy` in `base-test.sh`). + +## Apple Silicon + +`omarchy-mac-boot`, from omacom/omarchy-mac-pkgs, implements the Apple boot operations as small entrypoints around its boot modules and installs them in `/usr/lib/omarchy/mac-boot`. `omarchy-mac`, from the same repository, implements `setup-system`, `setup-user`, `post-install` and `pre-remove` in `/usr/lib/omarchy/mac`. Their documentation describes each one. Nothing Apple-specific lives in Omarchy beyond the registration above. + +## Snapdragon + +Snapdragon laptops are `aarch64` here: they boot Limine with unified kernel images, like x86, every operation is a no-op there, and provisioning uses the Limine UKI callbacks, so Dragon behaves exactly as before. + +## Tests + +- `test/shell.d/lifecycle-dispatch-test.sh` covers the dispatcher on every platform fixture: + - the setup and app-install operations: no-ops off Apple, `omarchy-mac`'s directory on Apple (never `omarchy-mac-boot`'s), no-ops without `omarchy-mac`, the user operations refused as root and given only their allowlisted environment + - no-ops on x86 and plain aarch64, even with Mac entrypoints on disk + - Apple with and without the boot package, and with one too old to ship an operation + - arguments, exit status and the cleared environment + - untrusted entrypoints + - usage errors, and an undetermined platform + - an ordinary Bash launch, and root ignoring fixture roots, `BASH_ENV` and exported functions +- `test/shell.d/luks-rekey-journal-test.sh` runs owner provisioning through the real dispatcher: + - the crash-and-resume matrix on x86 (Limine UKI path unchanged, no Mac entrypoint runs) and on Apple with a fake boot package (the owner's slot recorded), on a slot-table fake and on file-backed LUKS2 and LUKS1 volumes + - setup stopping before the owner form when the boot package is not ready, missing, or too old to ship the provisioning entrypoints + - the stale-entry refresh rebuilding through `limine-update`, behind the boot package's `provision-verify` on Apple + - the worker failing closed without the provisioning entrypoints, or when a boot package implements only one of `provision-commit` and `provision-verify` +- `test/shell.d/platform-setup-test.sh` runs the two setup leaves (the system one running `setup-boot`, then `setup-system`, and stopping when either fails or the boot package lacks `setup-boot`) and first run through the real dispatcher: each is the last of its setup, a no-op on x86, `image-first-boot` only on an image's first boot, the user leaf waiting for first run in an image build, and a failing entrypoint failing the leaf and keeping first run pending. `test/shell.d/image-deferred-hardware-test.sh` checks that an image build queues every hardware leaf, the system one included. +- `test/shell.d/first-run-test.sh` checks that a failed finalization keeps first run pending. +- `test/shell.d/platform-app-hooks-test.sh` runs every browser install and Steam's install and removal through the real dispatcher: on Apple each calls its hook once, after the package and flags (before removal for `pre-remove`), and fails with it; on x86 none runs, even with Mac entrypoints on disk. +- `test/shell.d/update-file-conflict-test.sh` checks the takeover: on x86 it goes ahead asking no one; on Apple it goes ahead only when the boot package's `update-takeover` vouches for exactly the files that move, and a refusal, a missing boot package or one too old keep every file. +- `test/shell.d/factory-reset-dispatch-test.sh` runs the reset through the real dispatcher: on x86 with Mac entrypoints on disk that must not run (generic path unchanged), and on Apple into a fake boot package, covering a finished reset, a failed verification and a failed switch that roll back, an unconfirmed throwaway slot found by its key and revoked, a failed commit after the switch, and a missing or partial package. +- `test/shell.d/update-boot-verify-test.sh` runs `omarchy update` through the real `omarchy-update-boot` and dispatcher inside the sudo boundary fixture: no-ops and no root on x86 and plain aarch64; on Apple, verify after AUR through the no-update wrapper, a failed verification that offers no reboot, and a Mac without the package or with one too old. +- `test/shell.d/drive-password-test.sh` checks that an Apple password change records the owner's new slot through `luks-slots`, including after an interruption, and that x86 never calls it; that `--owner` failing (1, or a slot that isn't a number) stops the change with the reason; and that with no owner slot recorded (4) a one-key disk changes and records its slot, also after an interruption, while a disk with another key, or one that gains a key during the prompts, is refused unchanged. diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index c834d879..8a6f2415 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -396,6 +396,16 @@ size-vertical = 28 # left/right bar width at base-size 12 Set `scale-with-font = false` to keep those bar sizes as fixed pixels. +A panel with a camera cutout (a notch) at its top keeps a top bar out of it: the bar is never shorter than the cutout, and its center section sits beside the right one. Omarchy knows no panel's cutout itself; the platform's own package describes them in `/usr/share/omarchy-platform/display-cutouts.json`, a fixed path no environment variable moves (see [file-layout.md](file-layout.md#platform-root)): + +```json +{ "panels": [ { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 } ] } +``` + +A panel matches a screen whose connector name starts with `connector` and whose mode, as `hyprctl monitors` reports it, is `width` x `height` physical pixels, at any scale; `top` is the physical rows the cutout covers. On such a panel, `[bar] notch-height` (logical pixels, not scaled with the font) sets the floor by hand. The bar reads the file when the shell starts and whenever it changes; a file installed where its directory didn't exist yet (the platform root itself, on a first install) is read at the next shell start. + +Until Hyprland reports a screen's mode, a top bar there waits to appear if a description names the screen's connector, unless the logical size times Qt's whole-number scale already matches a panel. It then appears at its floor rather than growing once windows are laid out, and never guesses a floor for a panel nothing describes. After two seconds without a mode it appears without a floor. Notifications clear the bar as thick as it is on their screen. A plugin's `shell.bar` gives the same through `barSizeFor(screenName)` and the `barSizes` map (screen name to thickness); its `barSize` stays the configured size, which the widgets are drawn at. + ## Custom bar modules If a full plugin is overkill, declare a one-off module inline in diff --git a/docs/update-process.md b/docs/update-process.md index 3c7804c8..de02c184 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -143,9 +143,11 @@ omarchy-update ├─ run the post-update hook, then update mise tools ├─ stop the keepalive and invalidate sudo, then update AUR packages with │ no-update authentication, and invalidate again + ├─ omarchy-update-boot, with no-update authentication, and invalidate again + │ └─ the platform's boot package proves the boot files boot the updated system ├─ omarchy-update-stay-awake stop │ └─ release the sleep inhibitor and restore shell idle state, if changed - └─ offer the unprivileged reboot prompt + └─ offer the unprivileged reboot prompt, only when the boot files were verified ``` Important behavior: @@ -166,6 +168,7 @@ Important behavior: `omarchy-migrate` after pacman finishes. - A failure should leave enough output in `/tmp/omarchy-update.log` and the terminal transcript to debug. +- The boot check is the [lifecycle dispatch](lifecycle-dispatch.md) operation `update-verify`, a no-op on platforms whose boot chain needs no handling of its own, x86 included: nothing runs and nothing asks for root. It is the last sudo-capable step, after AUR packages, so it also covers the initramfs rebuilds they trigger; since it follows third-party build code, it authenticates through the no-update wrapper like AUR does, which on a platform that implements it without passwordless sudo is one more prompt. When it fails, the update finishes its remaining steps, then exits non-zero without the reboot prompt: the update is not finished. A machine without its platform's boot package at all predates it: the update warns that its boot files were not verified and finishes. There is no check before the packages change: where `omarchy-hw-platform` can't tell the platform, the update installs its packages, and the update then fails verification, without the reboot prompt. ## Path 2: direct `sudo pacman -Syu` attempt @@ -274,6 +277,32 @@ Channel switching runs the `pre-refresh-pacman` hook once, during its refresh step: cold, behind the no-update wrapper, after the package config is re-synced and before the refresh transaction. It does not run if the switch fails earlier. +Every platform has its own pacman.conf and mirrorlist for each channel it +offers (x86_64 stable, rc and edge; aarch64 edge alone, below), and a channel +change or install finalization copies them into place whole, the same way on +every platform (`install/helpers/pacman.sh`); a channel change backs up +the old pair first. +x86_64's are `default/pacman/pacman-.conf` and +`mirrorlist-`; Snapdragon and other aarch64 machines use +`default/pacman/aarch64/`, Omarchy's repository ahead of Arch Linux ARM's, as +x86_64 puts it ahead of Arch's (migration 1791403252 reorders existing +machines the same way, and a refresh can then move a package Omarchy also +publishes to Omarchy's build, downgrading it if that build is older); Apple +Silicon uses `default/pacman/aarch64-apple/`, which puts Omarchy and Asahi ALARM +ahead of Arch Linux ARM. Omarchy publishes aarch64 packages on edge alone so +far, and the `omarchy` and `omarchy-settings` packages there for stable and rc +are the release line, which has no aarch64 support, so ARM platforms have edge +templates only: switching an ARM machine to stable or rc would replace its +runtime with one that cannot run it. Adding a stable or rc template once a +release supports aarch64 is what opens that channel. A channel change refuses a +channel without both files for the platform before anything changes. On aarch64, +`omarchy-channel-set` refuses to link a dev checkout without +`bin/omarchy-hw-platform` and `install/helpers/pacman.sh`, whose own refresh +would write the x86_64 templates. `omarchy-reinstall-pkgs` resets to the +platform's default channel (`omarchy_pacman_default_channel`: stable, or edge on +aarch64) and installs its default packages; install finalization does the same +when the install's channel has no templates for the platform. + There is no version file at runtime. `omarchy-version` derives the version from `pacman -Q` on whichever package is installed, or reports `dev ()` for a linked checkout, and `omarchy-version-channel` sniffs the mirrorlist and @@ -293,7 +322,7 @@ scripts. | `omarchy-update-status` | Hidden helper that refreshes or clears the shell update indicator after rechecking available updates. | **Keep internal/hidden.** Keeps shell status synchronization out of the main pipeline. | | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | | `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | -| `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | +| `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring (and Arch Linux ARM keyring on aarch64, where installed) are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | | `omarchy-update-system-pkgs` | Runs `omarchy-update-pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | | `omarchy-update-system-pkgs-when-conflicted` | Hidden conflict handler: quarantines unowned conflicting files under `/var/lib/omarchy/replaced`, retries the upgrade once, restores files the upgrade didn't claim, and hands package-vs-package conflicts to an interactive pacman run (never under `-y`). | **Keep internal/hidden.** Keeps conflict recovery out of the happy path. | | `omarchy-update-pkg-prune` | Trims the pacman cache to two versions per package (`paccache -rk2`) before the snapshot, keeping the offline downgrade path while capping snapshot growth. | **Keep internal/hidden.** | @@ -308,6 +337,7 @@ scripts. | `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | | `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal unless passed `-y`, as the update pipeline does; standalone noninteractive mode only reports. | **Keep for now.** Automates cleanup during updates and supports standalone review. | | `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | +| `omarchy-update-boot` | Hidden helper that runs the platform's `update-verify` lifecycle operation through `omarchy-lifecycle-dispatch`, with `sudo` only when the platform implements it. | **Keep internal/hidden.** Keeps platform boot checks out of the pipeline and stubbable in tests. | | `omarchy-update-restart` | Restarts components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. | | `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | | `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | diff --git a/etc/mkinitcpio.conf.d/00-omarchy-hooks.conf b/etc/mkinitcpio.conf.d/00-omarchy-hooks.conf new file mode 100644 index 00000000..dda5da9a --- /dev/null +++ b/etc/mkinitcpio.conf.d/00-omarchy-hooks.conf @@ -0,0 +1,37 @@ +# The platform's HOOKS baseline. mkinitcpio sources mkinitcpio.conf and then +# every drop-in here in name order, so the baseline sorts first and the drop-ins +# after it add their hooks to it instead of being overwritten by it. +# +# Apple Silicon boots a systemd initramfs, and its platform package adds the +# firmware and encryption hooks. Every other machine keeps the busybox line, as +# does one without the runtime's detector. A detector that cannot place the +# machine stops the build rather than let it produce an image for the wrong +# platform. +_omarchy_platform="" +if _omarchy_detector=$(command -v omarchy-hw-platform); then + _omarchy_platform=$("$_omarchy_detector") || return 1 +fi +# omarchy-settings can update before the omarchy runtime whose detector still +# says apple-silicon. +[[ $_omarchy_platform != "apple-silicon" ]] || _omarchy_platform=aarch64-apple + +# A Mac whose mkinitcpio.conf carries the asahi hook, or the busybox encrypt +# hook that unlocks it through cryptdevice= (sd-encrypt cannot parse that), boots +# the initramfs its platform set up without the Apple boot package, as a legacy +# install does. Its line stays as it is. The asahi hook also marks such a root +# off a Mac, as in a chroot or a VM. +if [[ " ${HOOKS[*]:-} " == *" asahi "* ]] || + [[ $_omarchy_platform == "aarch64-apple" && " ${HOOKS[*]:-} " == *" encrypt "* ]]; then + _omarchy_platform=platform-owned +fi + +case $_omarchy_platform in + platform-owned) ;; + aarch64-apple) + HOOKS=(base systemd plymouth autodetect microcode modconf kms keyboard sd-vconsole block filesystems fsck) + ;; + *) + HOOKS=(base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck btrfs-overlayfs) + ;; +esac +unset _omarchy_platform _omarchy_detector diff --git a/etc/mkinitcpio.conf.d/omarchy_hooks.conf b/etc/mkinitcpio.conf.d/omarchy_hooks.conf index 68408b00..0dfcb8e9 100644 --- a/etc/mkinitcpio.conf.d/omarchy_hooks.conf +++ b/etc/mkinitcpio.conf.d/omarchy_hooks.conf @@ -1,4 +1,5 @@ -HOOKS=(base udev plymouth keyboard autodetect microcode modconf kms keymap consolefont block encrypt filesystems fsck btrfs-overlayfs) +# Adjusts the baseline HOOKS from 00-omarchy-hooks.conf. It stays apart from +# the baseline because it reads what the hardware drop-ins sorting before it set. # The proprietary NVIDIA driver does early KMS itself: nvidia.conf (written by # install/hardware/nvidia.sh, sourced before this file) early-loads nvidia_drm diff --git a/etc/mkinitcpio.conf.d/thunderbolt_module.conf b/etc/mkinitcpio.conf.d/thunderbolt_module.conf index 9518f8b1..2d308269 100644 --- a/etc/mkinitcpio.conf.d/thunderbolt_module.conf +++ b/etc/mkinitcpio.conf.d/thunderbolt_module.conf @@ -1 +1,7 @@ -MODULES+=(thunderbolt) +# Early-load thunderbolt where the kernel builds it as a module. The optional +# form, thunderbolt?, is not enough: a systemd initramfs copies MODULES as +# written into modules-load.d, where "thunderbolt?" names no module, and a +# kernel without the module would still list it there. +if [[ $(modinfo -k "${KERNELVERSION:-$(uname -r)}" -F filename thunderbolt 2>/dev/null) == /* ]]; then + MODULES+=(thunderbolt) +fi diff --git a/install/config/all.sh b/install/config/all.sh index 09b23293..bc327e6e 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -6,6 +6,7 @@ run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" run_logged "$OMARCHY_INSTALL/config/ssh-command-path.sh" run_logged "$OMARCHY_INSTALL/config/ssh-keepalive.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" +run_logged "$OMARCHY_INSTALL/config/locale.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" run_logged "$OMARCHY_INSTALL/config/usb-authorization.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" diff --git a/install/config/locale.sh b/install/config/locale.sh new file mode 100644 index 00000000..e70713fb --- /dev/null +++ b/install/config/locale.sh @@ -0,0 +1,50 @@ +# An image built from a distribution's root tarball rather than the ISO (Arch +# Linux ARM's ships LANG=C) never went through the ISO's locale step, so it +# runs non-UTF-8: byte-wise sorting, ASCII-only \u escapes, and any tool that +# reads the locale for its encoding. +# Root always writes the real files; the overrides are for unprivileged tests. +locale_conf=/etc/locale.conf +locale_gen=/etc/locale.gen +if (( EUID != 0 )); then + locale_conf=${OMARCHY_LOCALE_CONF:-$locale_conf} + locale_gen=${OMARCHY_LOCALE_GEN:-$locale_gen} +fi + +# Repair only the stock state -- an unset LANG, or the bare C/POSIX the image +# ships. Any named locale is somebody's choice, C.UTF-8 included, so leave it. +# A machine with no locale.conf at all reads as unset, not as a failure: +# under pipefail the missing file would otherwise abort the installer. +current=$(sed -n 's/^LANG=//p' "$locale_conf" 2>/dev/null | tail -1 | tr -d '"') || current="" + +case ${current:-C} in + C | POSIX) ;; + *) + echo "Leaving the locale as $current" + return 0 2>/dev/null || exit 0 + ;; +esac + +echo "Setting up locale (en_US.UTF-8)..." + +if ! locale -a 2>/dev/null | grep -qi "en_US.utf-\?8"; then + if grep -q '^#en_US.UTF-8' "$locale_gen" 2>/dev/null; then + sed -i 's/^#en_US.UTF-8/en_US.UTF-8/' "$locale_gen" + elif ! grep -q '^en_US.UTF-8' "$locale_gen" 2>/dev/null; then + echo "en_US.UTF-8 UTF-8" >>"$locale_gen" + fi + + locale-gen >/dev/null 2>&1 +fi + +# Only LANG changes; LC_* lines somebody set stay. +if grep -q '^LANG=' "$locale_conf" 2>/dev/null; then + sed -i 's/^LANG=.*/LANG=en_US.UTF-8/' "$locale_conf" +else + echo "LANG=en_US.UTF-8" >>"$locale_conf" +fi + +# The session that ran this keeps its inherited LANG; everything after it here +# should see the new one. +export LANG=en_US.UTF-8 + +echo "Locale set to en_US.UTF-8" diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 92f3fdea..4f24cdcb 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -45,3 +45,5 @@ run_logged "$OMARCHY_INSTALL/hardware/fix-yt6801-ethernet-adapter.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-tuxedo-backlight.sh" run_logged "$OMARCHY_INSTALL/hardware/speaker-tuning.sh" run_logged "$OMARCHY_INSTALL/hardware/pacman.sh" + +run_logged "$OMARCHY_INSTALL/hardware/platform-setup.sh" diff --git a/install/hardware/apple/fix-brcmfmac-supplicant.sh b/install/hardware/apple/fix-brcmfmac-supplicant.sh index 33238c9c..b679790f 100644 --- a/install/hardware/apple/fix-brcmfmac-supplicant.sh +++ b/install/hardware/apple/fix-brcmfmac-supplicant.sh @@ -18,6 +18,14 @@ # machines including the T2-less iMac19,1 and iMac19,2, and BCM4377/4378/4387 # from the T2 era on. The BCM4360 in 2013-2015 Macs is deliberately absent: it # runs the out-of-tree wl driver, which never reads a brcmfmac option. +# +# Apple Silicon is excluded even though its BCM4387 is in that list: there the +# firmware supplicant is what works, and disabling it makes firmware commands +# time out and breaks scanning entirely. +if omarchy-hw-aarch64-apple; then + return 0 +fi + sys_vendor="$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null || true)" if lspci -nn | grep "106b:180[12]" >/dev/null || diff --git a/install/hardware/apple/fix-spi-keyboard.sh b/install/hardware/apple/fix-spi-keyboard.sh index 330081b7..59d4b12f 100644 --- a/install/hardware/apple/fix-spi-keyboard.sh +++ b/install/hardware/apple/fix-spi-keyboard.sh @@ -1,5 +1,7 @@ -# Detect MacBook models that need SPI keyboard modules -product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null)" +# Detect MacBook models that need SPI keyboard modules. +# Not every machine has a DMI product name, and under set -e an unguarded read +# of a missing one aborts the hardware pass. +product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null || true)" if [[ $product_name =~ MacBook[89],1|MacBook1[02],1|MacBookPro13,[123]|MacBookPro14,[123] ]]; then echo "Detected MacBook with SPI keyboard" diff --git a/install/hardware/bluetooth.sh b/install/hardware/bluetooth.sh index 40345cf9..8ba4f897 100644 --- a/install/hardware/bluetooth.sh +++ b/install/hardware/bluetooth.sh @@ -1,5 +1,15 @@ systemctl enable bluetooth.service +# An image's first boot runs this after the adapter has brought up (or queued) +# bluetooth.target, and a target never starts a unit enabled after its start +# job was made, so Bluetooth would stay off until the next boot. Only there +# (omarchy-provision-hardware sets the marker): an install or a rerun of +# hardware setup leaves the service as it finds it. Without Bluetooth support +# the unit's own condition skips it. +if [[ ${OMARCHY_IMAGE_DEFERRED_HARDWARE:-} == "1" ]]; then + systemctl start --no-block bluetooth.service || true +fi + # AutoEnable stays at its stock default on purpose. It was set to false here to # persist the power state, which it never did: BlueZ has no such behaviour, so # all it bought was Bluetooth coming up off on every boot. omarchy-bluetooth-power diff --git a/install/hardware/fix-fkeys.sh b/install/hardware/fix-fkeys.sh index ae7fee52..f1edee26 100644 --- a/install/hardware/fix-fkeys.sh +++ b/install/hardware/fix-fkeys.sh @@ -1,5 +1,7 @@ -# Ensure that F-keys on Apple-like keyboards (such as Lofree Flow84) are always F-keys -if [[ ! -f /etc/modprobe.d/hid_apple.conf ]]; then - sudo mkdir -p /etc/modprobe.d - echo "options hid_apple fnmode=2" | sudo tee /etc/modprobe.d/hid_apple.conf >/dev/null +# Ensure that F-keys on Apple-like keyboards (such as Lofree Flow84) are always F-keys. +# Apple Silicon is left alone: the omarchy-mac package owns its keyboard mode. +conf="${OMARCHY_HID_APPLE_CONF:-/etc/modprobe.d/hid_apple.conf}" +if [[ ! -f $conf ]] && ! omarchy-hw-aarch64-apple; then + sudo mkdir -p "$(dirname "$conf")" + echo "options hid_apple fnmode=2" | sudo tee "$conf" >/dev/null fi diff --git a/install/hardware/platform-setup.sh b/install/hardware/platform-setup.sh new file mode 100644 index 00000000..946df9ea --- /dev/null +++ b/install/hardware/platform-setup.sh @@ -0,0 +1,13 @@ +# The platform packages' own setup, last so it builds on every other leaf +# (omarchy-lifecycle-dispatch setup-boot and setup-system, see +# docs/lifecycle-dispatch.md): the boot package's first, then the runtime +# package's. A no-op where the platform registers none. An image's first boot +# says so: there the network may be down, and a boot-file rebuild is asked for +# rather than built, since the deferred setup rebuilds once after its last step. +if [[ ${OMARCHY_IMAGE_DEFERRED_HARDWARE:-} == "1" ]]; then + omarchy-lifecycle-dispatch setup-boot image-first-boot + omarchy-lifecycle-dispatch setup-system image-first-boot +else + omarchy-lifecycle-dispatch setup-boot + omarchy-lifecycle-dispatch setup-system +fi diff --git a/install/helpers/image-target.sh b/install/helpers/image-target.sh new file mode 100644 index 00000000..5dc5882d --- /dev/null +++ b/install/helpers/image-target.sh @@ -0,0 +1,164 @@ +# Image builds and the hardware setup they defer to the machine's first boot. +# Sourced by omarchy-apply-hardware and omarchy-provision-hardware; no shebang. +# +# An image is built away from the machine it will run on, so hardware setup +# during the build would describe the builder, not the target. The builder +# declares the build in a root-owned manifest before it runs +# omarchy-apply-system in the image root: +# +# /var/lib/omarchy/image/target +# format=1 +# platform=x86|aarch64|aarch64-apple +# +# While it exists, omarchy-apply-hardware queues each hardware leaf in +# /var/lib/omarchy/image/deferred-steps instead of running it, and arms +# omarchy-provision-hardware.service. On the machine's first boot that service, +# or the platform's own first boot, retires the manifest (to target.booted) and +# runs the queue on the real hardware. Unknown manifest keys are ignored so the +# format can grow. +# +# An image carries no pacman master key of its own, or every machine flashed +# from it would share one: where install finalization would make the keyring +# (install/post-install/pacman.sh), a build requests it in +# /var/lib/omarchy/image/pacman-keyring instead, and the first boot makes it +# before any deferred step runs. +# +# The manifest is also the only source of the image's platform: until that boot, +# omarchy-hw-platform reports its platform instead of the build host's, so the +# initramfs, services and packages the build sets up are the target's. It reads +# the manifest by the same rules as omarchy_image_read_manifest below. +# +# Root always uses the fixed paths. Only a non-root test may move them under +# OMARCHY_IMAGE_ROOT, so no environment variable can make a live system defer, +# skip or replay its hardware setup. + +omarchy_image_init() { + if (( EUID == 0 )); then + omarchy_image_root="" + elif [[ -n ${OMARCHY_IMAGE_ROOT:-} ]]; then + omarchy_image_root=$OMARCHY_IMAGE_ROOT + else + echo "Error: image setup state is root-owned; run as root" >&2 + return 1 + fi + + omarchy_image_dir=$omarchy_image_root/var/lib/omarchy/image + omarchy_image_manifest=$omarchy_image_dir/target + omarchy_image_queue=$omarchy_image_dir/deferred-steps + omarchy_image_initramfs_baseline=$omarchy_image_dir/initramfs-inputs + omarchy_image_boot_rebuild=$omarchy_image_dir/boot-rebuild + omarchy_image_keyring_request=$omarchy_image_dir/pacman-keyring + omarchy_image_unit=omarchy-provision-hardware.service + omarchy_image_systemd_dir=$omarchy_image_root/etc/systemd/system +} + +# Owned by whoever runs this (root on a live system), not a symlink, and not +# writable by anyone else. +omarchy_image_trusted() { + local path=$1 owner mode + + [[ -e $path && ! -L $path ]] || return 1 + read -r owner mode < <(stat -c '%u %a' -- "$path") || return 1 + (( owner == EUID && (8#$mode & 8#022) == 0 )) +} + +omarchy_image_manifest_present() { + [[ -e $omarchy_image_manifest || -L $omarchy_image_manifest ]] +} + +# Sets omarchy_image_platform, or fails without guessing whether this is a build. +omarchy_image_read_manifest() { + local line key value format="" platform="" + + if [[ ! -d $omarchy_image_dir || ! -f $omarchy_image_manifest ]] || ! omarchy_image_trusted "$omarchy_image_dir" || + ! omarchy_image_trusted "$omarchy_image_manifest"; then + echo "Error: $omarchy_image_manifest is not a root-owned regular file" >&2 + return 1 + fi + + while IFS= read -r line || [[ -n $line ]]; do + [[ -n $line && $line != \#* ]] || continue + if [[ $line != *=* ]]; then + echo "Error: $omarchy_image_manifest is malformed: $line" >&2 + return 1 + fi + key=${line%%=*} + value=${line#*=} + case $key in + format) format=$value ;; + platform) platform=$value ;; + esac + done <"$omarchy_image_manifest" + + if [[ $format != "1" ]]; then + echo "Error: $omarchy_image_manifest is not format=1" >&2 + return 1 + fi + # Builders written before the platform names settled say apple-silicon, + # generic-aarch64 or generic (as omarchy-hw-platform reads them too). + case $platform in + apple-silicon) platform=aarch64-apple ;; + generic-aarch64) platform=aarch64 ;; + generic) platform=x86 ;; + esac + case $platform in + x86 | aarch64 | aarch64-apple) ;; + *) + echo "Error: $omarchy_image_manifest names no known platform: ${platform:-none}" >&2 + return 1 + ;; + esac + + omarchy_image_platform=$platform +} + +# Holds the image state still for the rest of the caller, so hardware setup and +# a first-boot run never overlap or decide from a manifest the other retires. +# A machine that was never an image has no state and nothing to lock. +omarchy_image_lock() { + [[ -e $omarchy_image_dir || -L $omarchy_image_dir ]] || return 0 + if [[ ! -d $omarchy_image_dir ]] || ! omarchy_image_trusted "$omarchy_image_dir"; then + echo "Error: $omarchy_image_dir is not a root-owned directory" >&2 + return 1 + fi + exec {omarchy_image_lock_fd}>>"$omarchy_image_dir/lock" || return 1 + flock "$omarchy_image_lock_fd" +} + +omarchy_image_write_queue() { + local tmp + + tmp=$(mktemp "$omarchy_image_dir/.deferred-steps.XXXXXX") || return 1 + if (( $# )); then + printf '%s\n' "$@" >"$tmp" || { rm -f "$tmp"; return 1; } + fi + chmod 0644 "$tmp" && mv -f "$tmp" "$omarchy_image_queue" +} + +# Queue every leaf install/hardware/all.sh would run, in its order, running +# none of them, then arm the first-boot service. Rerunning rewrites the same queue. +omarchy_image_defer_hardware() { + local listing + local -a steps=() + + listing=$( + run_logged() { + [[ $1 == "$OMARCHY_INSTALL"/hardware/*.sh && $1 != *..* ]] || { + echo "Error: cannot defer a step outside the hardware setup: $1" >&2 + exit 1 + } + printf 'install/%s\n' "${1#"$OMARCHY_INSTALL"/}" + } + source "$OMARCHY_INSTALL/hardware/all.sh" + ) || return 1 + + [[ -z $listing ]] || mapfile -t steps <<<"$listing" + omarchy_image_write_queue "${steps[@]}" || return 1 + + install -d -m 0755 "$omarchy_image_systemd_dir/multi-user.target.wants" && + install -m 0644 "$OMARCHY_INSTALL/provisioning/$omarchy_image_unit" "$omarchy_image_systemd_dir/$omarchy_image_unit" && + ln -sfn "/etc/systemd/system/$omarchy_image_unit" "$omarchy_image_systemd_dir/multi-user.target.wants/$omarchy_image_unit" || + return 1 + + echo "Image build for $omarchy_image_platform: deferred ${#steps[@]} hardware steps to first boot" +} diff --git a/install/helpers/pacman.sh b/install/helpers/pacman.sh new file mode 100644 index 00000000..1825e15a --- /dev/null +++ b/install/helpers/pacman.sh @@ -0,0 +1,32 @@ +# Pacman repository templates. Every platform has a pacman.conf and mirrorlist +# for each channel it offers, copied into place whole, as x86's always were: +# x86's in default/pacman, each ARM platform's in a directory named for it. +# Sourcing this file only defines functions; it never changes the system. + +# The directory holding 's pacman-.conf and +# mirrorlist-. A channel without both files isn't offered there. +omarchy_pacman_templates() { + case ${1:-} in + x86) echo "$OMARCHY_PATH/default/pacman" ;; + aarch64 | aarch64-apple) echo "$OMARCHY_PATH/default/pacman/$1" ;; + *) + echo "Error: Unknown platform '${1:-}'." >&2 + return 1 + ;; + esac +} + +# The channel a machine takes when none is named: stable, except on aarch64. +# Omarchy publishes aarch64 packages on edge alone so far, and the stable and rc +# packages there are the release line, which has no aarch64 support, so ARM +# platforms have edge templates only until a release does. +omarchy_pacman_default_channel() { + case ${1:-} in + x86) echo stable ;; + aarch64 | aarch64-apple) echo edge ;; + *) + echo "Error: Unknown platform '${1:-}'." >&2 + return 1 + ;; + esac +} diff --git a/install/omarchy-aarch64-apple.packages b/install/omarchy-aarch64-apple.packages new file mode 100644 index 00000000..13a7ad66 --- /dev/null +++ b/install/omarchy-aarch64-apple.packages @@ -0,0 +1,26 @@ +# Apple Silicon additions, composed after omarchy-base.packages and +# omarchy-aarch64.packages (omarchy-pkg-defaults aarch64-apple); never on Intel +# Macs or other aarch64 machines. The kernel and boot chain come with the +# Mac's image, as the kernel and bootloader do from the ISO on x86_64. What a +# Mac cannot work without (its speaker stack, audio server and GPU driver) +# comes as omarchy-mac's dependencies, so a new Mac driver needs no change +# here; the rest are defaults an owner may remove. + +# The Mac's runtime and boot packages (omacom/omarchy-mac-pkgs). +omarchy-mac +omarchy-mac-boot + +# Picks the startup volume (macOS or Omarchy) from Linux. +asahi-bless + +# Hardware video decode. +avd-fw +libva-v4l2_request-avd + +# Widevine for DRM streaming. +widevine + +# Screen recording: gpu-screen-recorder finds no display device to capture on +# Apple Silicon, whose display and GPU are separate DRM devices, so recording +# falls back to wf-recorder (omarchy-capture-screenrecording). +wf-recorder diff --git a/install/omarchy-aarch64.packages b/install/omarchy-aarch64.packages new file mode 100644 index 00000000..fa7dcab5 --- /dev/null +++ b/install/omarchy-aarch64.packages @@ -0,0 +1,4 @@ +# Additions for every aarch64 platform, composed after omarchy-base.packages +# (omarchy-pkg-defaults). omarchy-settings configures swap on zram, and the +# x86_64 installer provides zram-generator for it; aarch64 gets it here. +zram-generator diff --git a/install/omarchy-x86_64-only.packages b/install/omarchy-x86_64-only.packages new file mode 100644 index 00000000..231c4c9b --- /dev/null +++ b/install/omarchy-x86_64-only.packages @@ -0,0 +1,4 @@ +# Base packages with no aarch64 build in any repository Omarchy configures. +# omarchy-pkg-defaults leaves them out of every aarch64 platform's set. +gliff +superwhisper-bin diff --git a/install/post-install/pacman.sh b/install/post-install/pacman.sh index da84975b..4964fe97 100644 --- a/install/post-install/pacman.sh +++ b/install/post-install/pacman.sh @@ -1,7 +1,39 @@ # Configure pacman after package installation completes. Offline target package -# installs use the live ISO's offline pacman.conf until this final restore. -cp -f "$OMARCHY_PATH/default/pacman/pacman-${OMARCHY_MIRROR:-stable}.conf" /etc/pacman.conf -cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-${OMARCHY_MIRROR:-stable}" /etc/pacman.d/mirrorlist +# installs use the live ISO's offline pacman.conf until this final restore, +# which copies the platform's channel templates (install/helpers/pacman.sh). A +# Mac's image brings its keyrings, as the ISO does on x86; every other aarch64 +# platform installs Arch Linux ARM's before its repositories replace the +# offline ones, and trust every installed keyring before the first signed sync. +source "$OMARCHY_PATH/install/helpers/pacman.sh" +source "$OMARCHY_PATH/install/helpers/image-target.sh" +platform=$(omarchy-hw-platform) +templates=$(omarchy_pacman_templates "$platform") +alarm_keyring=0 +if [[ $platform == aarch64* && $platform != "aarch64-apple" ]]; then + alarm_keyring=1 +fi + +if (( alarm_keyring )); then + omarchy-pkg-add archlinuxarm-keyring +fi + +# An install built for a channel this platform has no templates for (stable or +# rc on aarch64) gets the platform's default channel instead. +channel=${OMARCHY_MIRROR:-} +[[ -n $channel && -f $templates/pacman-$channel.conf ]] || channel=$(omarchy_pacman_default_channel "$platform") +cp -f "$templates/pacman-$channel.conf" /etc/pacman.conf +cp -f "$templates/mirrorlist-$channel" /etc/pacman.d/mirrorlist + +# An image build leaves the keyring to each machine's first boot, so no two +# share a master key (install/helpers/image-target.sh). +if (( alarm_keyring )); then + if omarchy_image_init && omarchy_image_manifest_present; then + install -m 0644 /dev/null "$omarchy_image_keyring_request" + else + pacman-key --init + pacman-key --populate + fi +fi # Wait for CUPS to own the file, the way omarchy-settings does, so pacman does # not turn the override into a .pacnew during ISO package installation. diff --git a/install/provisioning/luks-rekey.sh b/install/provisioning/luks-rekey.sh new file mode 100644 index 00000000..ab7194fc --- /dev/null +++ b/install/provisioning/luks-rekey.sh @@ -0,0 +1,290 @@ +# Resumable re-key of the root LUKS volume from the staged install key to the +# owner's password. Sourcing performs no setup. +# +# Caller contract: +# - Set PROVISIONING_DIR (holds the staged luks-key), REKEY_STATE, LOG_FILE and +# password. Secrets reach cryptsetup through key files and process +# substitution, never argv, and the entry points turn tracing off. +# - Define luks_boot_layout, printing a fingerprint of the keyboard layout the +# boot files ask for the disk password in (the same value while it is +# unchanged, and never the layout's name). +# - Define log_step and say, plus the platform's boot-time unlock: +# luks_auto_unlock_present succeeds while any boot-time copy of the staged +# key or its unlock configuration remains; luks_auto_unlock_drop removes +# them and rebuilds the boot files, restoring the unlock before it fails. +# luks_record_slots records the kept slot wherever the platform's +# boot checks look for it, and succeeds where nothing does. +# +# The journal (REKEY_STATE) records only the phase, slot numbers and the layout +# fingerprint, never key material. Phases advance staged → owner → boot → done, each written durably +# after its step, so an attempt interrupted anywhere resumes from the last one; +# the retire and record steps before done are idempotent. A retry may still +# choose a new password while the staged key opens the disk, even after the +# boot step, so the kept slot is recorded only once it is final. The +# caller removes the journal with the rest of its provisioning state, so until +# then a retry can only use the password the disk holds. + +# cryptsetup open tries enrolled tokens (TPM2, FIDO2, keyring) before the key +# and reports a token's slot whatever key it was given. Restricting it to a +# token type no token has leaves only the key to decide. +luks_key_slot() { + local out + out=$(LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file "$1" "$2" 2>&1) || return 0 + grep -o 'Key slot [0-9]* unlocked' <<<"$out" | grep -o '[0-9]*' | head -1 || true +} + +luks_slot_for() { + local - + set +x + luks_key_slot <(printf '%s' "$1") "$2" +} + +# LUKS2 lists keyslots under "Keyslots:", and a luks2-keyring token under +# "Tokens:" looks the same, so read only the keyslot section. +luks_dump_slots() { + cryptsetup luksDump "$1" | awk ' + /^[^ \t]/ { keyslots = ($0 == "Keyslots:") } + keyslots && /^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 } + /^Key Slot [0-9]+: ENABLED/ { sub(":", "", $3); print $3 }' +} + +rekey_state_get() { + [[ -f $REKEY_STATE ]] || return 1 + awk -F= -v k="$1" '$1 == k { print $2; exit }' "$REKEY_STATE" +} + +# Set key/value pairs, keeping every other recorded key. +rekey_state_put() { + local line key tmp + local -A updates=() + local -a lines=() + + while (( $# >= 2 )); do + updates[$1]=$2 + shift 2 + done + + if [[ -f $REKEY_STATE ]]; then + while IFS= read -r line || [[ -n $line ]]; do + if [[ -n $line && -z ${updates[${line%%=*}]+set} ]]; then + lines+=("$line") + fi + done <"$REKEY_STATE" + fi + for key in "${!updates[@]}"; do + lines+=("$key=${updates[$key]}") + done + + tmp=$(mktemp "$REKEY_STATE.XXXXXX") || return 1 + if printf '%s\n' "${lines[@]}" >"$tmp" && chmod 600 "$tmp" && sync "$tmp" && mv -f "$tmp" "$REKEY_STATE"; then + sync "$(dirname "$REKEY_STATE")" + return + fi + rm -f "$tmp" + return 1 +} + +staged_key_slot() { + [[ -f $PROVISIONING_DIR/luks-key ]] || return 0 + luks_key_slot "$PROVISIONING_DIR/luks-key" "$1" +} + +# The staged key or its boot-time auto-unlock is still on this machine. +luks_staged_unlock_remains() { + [[ -e $PROVISIONING_DIR/luks-key ]] || luks_auto_unlock_present +} + +# Re-key work is due while the staged unlock remains or the journal is open. +luks_rekey_pending() { + [[ -e $REKEY_STATE ]] || luks_staged_unlock_remains +} + +# Whether an interrupted re-key can finish with $password. Until the staged key +# is retired it can add any password; afterwards only one the disk holds. +luks_rekey_accepts_password() { + local - + set +x + local device=$1 + + [[ -f $REKEY_STATE ]] || return 0 + [[ -n $(luks_slot_for "$password" "$device") || -n $(staged_key_slot "$device") ]] +} + +# Record the slot $password opens, adding it with the staged key when none does. +# A retry after a crash between the add and the journal write finds the slot +# instead of adding a duplicate, and a retry with a new password adds its own +# while the staged key still works; the retire step removes the stale one. +luks_rekey_owner() { + local device=$1 phase=$2 owner + + owner=$(luks_slot_for "$password" "$device") + if [[ -z $owner ]]; then + if [[ -z $(staged_key_slot "$device") ]]; then + log_step "the password opens no LUKS slot and the staged key no longer unlocks $device" + say --foreground 1 "Use the disk password chosen earlier in setup." + return 1 + fi + if ! cryptsetup luksAddKey --key-file "$PROVISIONING_DIR/luks-key" "$device" <(printf '%s' "$password"); then + log_step "could not add the owner's key to $device" + say --foreground 1 "Could not add your password to the disk; will retry." + return 1 + fi + owner=$(luks_slot_for "$password" "$device") + if [[ -z $owner ]]; then + log_step "could not identify the owner's LUKS slot after adding it" + say --foreground 1 "Could not confirm the LUKS re-key; will retry." + return 1 + fi + fi + + if [[ $owner == "$(rekey_state_get staged_slot || true)" ]]; then + log_step "the owner's password is the staged install key; refusing to keep it" + say --foreground 1 "Choose a disk password different from the temporary install key." + return 1 + fi + + if [[ $phase == "staged" ]]; then + rekey_state_put owner_slot "$owner" phase owner + else + rekey_state_put owner_slot "$owner" + fi +} + +luks_rekey_retire() { + local device=$1 owner slot slots + + owner=$(rekey_state_get owner_slot || true) + if [[ -z $owner ]]; then + log_step "no owner slot is recorded; refusing to retire LUKS slots" + return 1 + fi + + if ! slots=$(luks_dump_slots "$device"); then + log_step "luksDump failed while retiring slots; keeping the staged key for retry" + say --foreground 1 "Could not enumerate LUKS slots; will retry." + return 1 + fi + for slot in $slots; do + [[ $slot == "$owner" ]] && continue + if ! cryptsetup luksKillSlot -q --key-file <(printf '%s' "$password") "$device" "$slot"; then + log_step "failed to kill LUKS slot $slot; keeping the staged key for retry" + say --foreground 1 "Could not remove the throwaway LUKS key; will retry." + return 1 + fi + done +} + +# The staged key must open nothing before it is destroyed: only the owner slot +# remains and no boot-time copy or unlock configuration is left behind. +luks_rekey_verify() { + local device=$1 owner slots + + owner=$(rekey_state_get owner_slot || true) + if ! slots=$(luks_dump_slots "$device") || [[ -z $owner || $slots != "$owner" ]]; then + log_step "LUKS slots other than the owner's remain on $device" + return 1 + fi + if [[ -n $(staged_key_slot "$device") ]]; then + log_step "the staged LUKS key still unlocks $device" + return 1 + fi + if luks_auto_unlock_present; then + log_step "the boot-time auto-unlock is still configured" + return 1 + fi +} + +# The boot files carry the console keyboard layout the disk password is typed +# in at boot, so rebuild them again when the owner chose another layout since +# they were built, or when an older journal recorded none. The phase stays. +luks_rekey_boot_layout() { + local layout=$1 + + [[ $(rekey_state_get boot_layout || true) != "$layout" ]] || return 0 + log_step "the boot files were not built with the current keyboard layout; rebuilding them" + if ! luks_auto_unlock_drop; then + say --foreground 1 "Could not rebuild the boot files for your keyboard layout; will retry." + return 1 + fi + sync + rekey_state_put boot_layout "$layout" +} + +# Order: record the staged slot, add the owner's key, rebuild boot without the +# auto-unlock (keeping the staged slot as the fallback while that can fail), +# retire every other slot, then verify, record the kept slot for the platform, +# destroy the staged key and record done. +# Failing is loud: silently keeping the staged key would leave the disk +# effectively unencrypted. +luks_rekey() { + local - + set +x + local device=$1 phase staged layout + + if ! layout=$(luks_boot_layout) || [[ -z $layout ]]; then + log_step "could not fingerprint the keyboard layout for the boot files" + say --foreground 1 "Could not read the keyboard layout for the boot files; will retry." + return 1 + fi + + phase=$(rekey_state_get phase || true) + case $phase in + "") + staged=$(staged_key_slot "$device") + if [[ -z $staged ]]; then + log_step "the staged LUKS key is missing or does not unlock $device" + say --foreground 1 "The staged LUKS key no longer unlocks $device." + return 1 + fi + rekey_state_put staged_slot "$staged" phase staged || return 1 + phase=staged + ;; + staged | owner | boot | done) ;; + *) + log_step "unknown LUKS re-key phase '$phase' in $REKEY_STATE" + say --foreground 1 "The LUKS re-key journal is unreadable." + return 1 + ;; + esac + + if [[ $phase != "done" ]]; then + luks_rekey_owner "$device" "$phase" || return 1 + [[ $phase == "staged" ]] && phase=owner + + if [[ $phase == "owner" ]] || luks_auto_unlock_present; then + if ! luks_auto_unlock_drop; then + say --foreground 1 "Could not rebuild the boot files without the install key; will retry." + return 1 + fi + sync + rekey_state_put phase boot boot_layout "$layout" || return 1 + else + luks_rekey_boot_layout "$layout" || return 1 + fi + + luks_rekey_retire "$device" || return 1 + else + if [[ $(luks_slot_for "$password" "$device") != "$(rekey_state_get owner_slot || true)" ]]; then + log_step "the password does not open the owner slot the finished re-key recorded on $device" + say --foreground 1 "Use the disk password chosen earlier in setup." + return 1 + fi + luks_rekey_boot_layout "$layout" || return 1 + fi + + if ! luks_rekey_verify "$device"; then + say --foreground 1 "Could not confirm the temporary install key was removed; will retry." + return 1 + fi + if ! luks_record_slots "$(rekey_state_get owner_slot)"; then + log_step "the platform could not record the kept LUKS slot" + say --foreground 1 "Could not record the disk's key slot for the boot checks; will retry." + return 1 + fi + + if [[ -e $PROVISIONING_DIR/luks-key ]]; then + shred -u "$PROVISIONING_DIR/luks-key" 2>/dev/null || rm -f "$PROVISIONING_DIR/luks-key" || return 1 + fi + rm -f "$REKEY_STATE".?????? + [[ $phase == "done" ]] || rekey_state_put phase done +} diff --git a/install/provisioning/omarchy-provision-hardware.service b/install/provisioning/omarchy-provision-hardware.service new file mode 100644 index 00000000..a6638c8d --- /dev/null +++ b/install/provisioning/omarchy-provision-hardware.service @@ -0,0 +1,21 @@ +# Runs the hardware setup an image build deferred, on the machine it was built +# for, before owner setup and the login screen. Armed with its queue by +# omarchy-apply-hardware during the build (install/helpers/image-target.sh); +# retried on every boot until the queue is empty and any keyring an image build +# asked for exists, then it disables itself (the unit file stays; with neither +# file left it no longer runs). +[Unit] +Description=Omarchy first-boot hardware setup deferred from the image build +ConditionPathExists=|/var/lib/omarchy/image/deferred-steps +ConditionPathExists=|/var/lib/omarchy/image/pacman-keyring +Before=omarchy-provision-owner.service display-manager.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-provision-hardware +TimeoutStartSec=infinity +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/install/user/all.sh b/install/user/all.sh index d81c3e75..a9d15021 100644 --- a/install/user/all.sh +++ b/install/user/all.sh @@ -13,3 +13,6 @@ run_logged "$OMARCHY_INSTALL/user/hardware/vm-no-animations.sh" run_logged "$OMARCHY_INSTALL/user/default-keyring.sh" run_logged "$OMARCHY_INSTALL/user/mise.sh" +run_logged "$OMARCHY_INSTALL/user/dictation-default.sh" + +run_logged "$OMARCHY_INSTALL/user/platform-setup.sh" diff --git a/install/user/dictation-default.sh b/install/user/dictation-default.sh new file mode 100644 index 00000000..3a8f15c0 --- /dev/null +++ b/install/user/dictation-default.sh @@ -0,0 +1,8 @@ +# The settings package presets Superwhisper as the dictation backend, and it has +# no aarch64 build. A new user there starts with no backend selected and picks +# one in Setup > Defaults > Dictation. Any other selection is left alone. +dictation_default="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" +if ! omarchy-hw-x86 && omarchy-cmd-missing superwhisper && + [[ -f $dictation_default && $(<"$dictation_default") == "superwhisper" ]]; then + rm -f "$dictation_default" +fi diff --git a/install/user/platform-setup.sh b/install/user/platform-setup.sh new file mode 100644 index 00000000..a83f3b01 --- /dev/null +++ b/install/user/platform-setup.sh @@ -0,0 +1,10 @@ +# The platform package's own setup for this user, last so it builds on every +# other leaf (omarchy-lifecycle-dispatch setup-user, see +# docs/lifecycle-dispatch.md). A no-op where the platform registers none. In an +# image build the detector would read the build host, so it waits for first run +# on the machine, which runs it again. +if [[ -e ${OMARCHY_IMAGE_ROOT:-}/var/lib/omarchy/image/target ]]; then + echo "Image build: the platform's user setup waits for first run on the machine" +else + omarchy-lifecycle-dispatch setup-user +fi diff --git a/manual/48-security.md b/manual/48-security.md index f600e7a0..36e76830 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -33,7 +33,7 @@ With Secure Boot enabled, changing USB boot protection requires access to your s ## Changing your passwords -You have two passwords on an encrypted install: the one that unlocks the drive at boot, and the one you log in and `sudo` with. Both can be changed under _Update > Password_ in the Omarchy menu — _Drive Encryption_ for the first, _User_ for the second. Changing the drive password asks for the current one first, so have it handy. +You have two passwords on an encrypted install: the one that unlocks the drive at boot, and the one you log in and `sudo` with. Setup makes them the same. Both can be changed under _Update > Password_ in the Omarchy menu — _Drive Encryption_ for the first, _User_ for the second. Changing the drive password asks for the current one first, so have it handy. If a change is interrupted, run _Drive Encryption_ again: it asks for the password that unlocks the drive now and finishes the change. ## Passing on a machine you've already used diff --git a/migrations/1786391100.sh b/migrations/1786391100.sh index b67ecf82..6d66d58a 100644 --- a/migrations/1786391100.sh +++ b/migrations/1786391100.sh @@ -1,5 +1,12 @@ echo "Run the WPA handshake in software on Macs with Broadcom Wi-Fi" +# This quirk targets Intel Macs. Apple Silicon uses brcmfmac too and reports +# "Apple Inc." with a listed part (BCM4387 is 14e4:4433), but disabling its +# firmware supplicant makes firmware commands time out and breaks scanning +# entirely. A fresh install marks this done only for its first user, so an +# account added later still runs it. +omarchy-hw-aarch64-apple && exit 0 + # The install-time quirk only reaches machines set up after it shipped, and it # never covered Macs without a T2 at all, so an existing install on one still # cannot join a WPA2/WPA3 transition-mode network. See diff --git a/migrations/1786605598.sh b/migrations/1786605598.sh index ce9e9c0a..5ed1d817 100644 --- a/migrations/1786605598.sh +++ b/migrations/1786605598.sh @@ -11,6 +11,7 @@ echo "Rebuild the initramfs so NVIDIA-only systems shed nouveau's unused GSP fir # update as a .pacnew) keeps kms and correctly skips the rebuild. hooks_conf="${OMARCHY_MKINITCPIO_HOOKS_CONF:-/etc/mkinitcpio.conf.d/omarchy_hooks.conf}" +baseline_conf="${hooks_conf%/*}/00-omarchy-hooks.conf" nvidia_conf="${OMARCHY_MKINITCPIO_NVIDIA_CONF:-/etc/mkinitcpio.conf.d/nvidia.conf}" rebuild_marker="${OMARCHY_KMS_REBUILD_MARKER:-/var/lib/omarchy/migrations/1786605598}" @@ -22,9 +23,9 @@ omarchy-cmd-present limine-mkinitcpio || exit 0 # marker still retries an interrupted rebuild. [[ ! -e $rebuild_marker ]] || exit 0 -# Source the drop-ins in mkinitcpio's order (nvidia.conf sorts first) and read -# the HOOKS they produce. Skip conservatively if evaluation fails. -hooks=$(bash -c 'source "$1" && source "$2" && echo " ${HOOKS[*]} "' -- "$nvidia_conf" "$hooks_conf") || exit 0 +# Include the baseline when installed, then the hardware and filter drop-ins. +# Skip conservatively if evaluation fails. +hooks=$(bash -c 'if [[ -f $1 ]]; then source "$1" || exit 1; fi; source "$2" && source "$3" && echo " ${HOOKS[*]} "' -- "$baseline_conf" "$nvidia_conf" "$hooks_conf") || exit 0 [[ $hooks != *" kms "* ]] || exit 0 diff --git a/migrations/1791542175.sh b/migrations/1791542175.sh index 40bc4ba4..9984bda6 100644 --- a/migrations/1791542175.sh +++ b/migrations/1791542175.sh @@ -1,3 +1,6 @@ echo "Install Gliff, the Hyprland remote desktop over SSH" -omarchy-pkg-add gliff +# Gliff has no aarch64 build (install/omarchy-x86_64-only.packages). +if omarchy-hw-x86; then + omarchy-pkg-add gliff +fi diff --git a/shell/Commons/AudioNodes.qml b/shell/Commons/AudioNodes.qml new file mode 100644 index 00000000..448cfe55 --- /dev/null +++ b/shell/Commons/AudioNodes.qml @@ -0,0 +1,41 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import "AudioNodesModel.js" as Model + +// Which PipeWire nodes the audio panel and the microphone widget leave out: +// the shell's own level meters, and the nodes a platform's audio processing +// adds, which its platform package names in the fixed platform root (see +// Model.parsePlatformAudio). No environment variable moves that file, and most +// machines have none, so nothing of theirs is left out. +Singleton { + id: root + + readonly property var hints: Model.parsePlatformAudio(hintsFile.missing ? "" : hintsFile.text()) + + // Whether the platform's hints hide a node, given the names of every node + // now in the graph (for a "replaced" node). + function platformHides(name, nodeNames) { + return Model.platformHidesNode(name, hints, nodeNames) + } + + function isShellLevelMeter(name) { + return Model.isShellLevelMeter(name) + } + + FileView { + id: hintsFile + + // A file that went away hints nothing, whatever text() still holds. + property bool missing: false + + path: "/usr/share/omarchy-platform/audio.json" + blockLoading: true + watchChanges: true + printErrors: false + onLoaded: missing = false + onLoadFailed: missing = true + onFileChanged: reload() + } +} diff --git a/shell/Commons/AudioNodesModel.js b/shell/Commons/AudioNodesModel.js new file mode 100644 index 00000000..c139e48e --- /dev/null +++ b/shell/Commons/AudioNodesModel.js @@ -0,0 +1,96 @@ +// Audio node rules the bar and the audio panel share. Plain functions, so the +// tests load this file in Node. + +// The level meter the shell runs itself: Quickshell's peak monitor stream, +// named after the shell or after its stream depending on the PipeWire version. +// It is not an app using the input. +function isShellLevelMeter(name) { + var value = String(name || "") + return value === "quickshell" || value === "quickshell-peak-monitor" +} + +function wholeNamePattern(pattern) { + if (typeof pattern !== "string" || pattern === "") return null + try { + return new RegExp("^(?:" + pattern + ")$") + } catch (e) { + return null + } +} + +// A platform's audio hints, which its own package ships in the platform root +// (/usr/share/omarchy-platform/audio.json, read by AudioNodes.qml; Omarchy +// ships none): nodes of its audio processing that are neither devices nor apps. +// +// { "hidden": ["", ...], +// "replaced": [{ "node": "", "by": "" }, ...] } +// +// A pattern is a regular expression matched against a whole node.name. A +// hidden node is never listed as an output, an input or an app stream, and +// never counts as a recording; a replaced node is hidden while a node matching +// "by" exists. Missing or malformed text means no hints, and an invalid pattern +// or entry is skipped. +function parsePlatformAudio(text) { + var hints = { hidden: [], replaced: [] } + var data = null + try { + data = JSON.parse(String(text || "")) + } catch (e) { + return hints + } + if (!data || typeof data !== "object" || Array.isArray(data)) return hints + + var hidden = Array.isArray(data.hidden) ? data.hidden : [] + for (var i = 0; i < hidden.length; i++) { + var pattern = wholeNamePattern(hidden[i]) + if (pattern) hints.hidden.push(pattern) + } + + var replaced = Array.isArray(data.replaced) ? data.replaced : [] + for (var j = 0; j < replaced.length; j++) { + var entry = replaced[j] + if (!entry || typeof entry !== "object") continue + var node = wholeNamePattern(entry.node) + var by = wholeNamePattern(entry.by) + if (node && by) hints.replaced.push({ node: node, by: by }) + } + return hints +} + +// Whether the platform's hints hide a node, given the names of every node now +// in the graph (for "replaced"). +function platformHidesNode(name, hints, nodeNames) { + var value = String(name || "") + if (value === "" || !hints) return false + var hidden = hints.hidden || [] + for (var i = 0; i < hidden.length; i++) + if (hidden[i].test(value)) return true + + var replaced = hints.replaced || [] + var names = Array.isArray(nodeNames) ? nodeNames : [] + for (var j = 0; j < replaced.length; j++) { + if (!replaced[j].node.test(value)) continue + for (var k = 0; k < names.length; k++) { + var other = String(names[k] || "") + if (other !== value && replaced[j].by.test(other)) return true + } + } + return false +} + +// `wpctl get-volume ` prints "Volume: 0.50", with " [MUTED]" when muted, on +// the same cubic scale as PwNodeAudio.volume. +function parseWpctlVolume(text) { + var match = /^Volume:\s+([0-9]+(?:\.[0-9]+)?)(\s+\[MUTED\])?$/.exec(String(text || "").trim()) + if (!match) return null + return { volume: parseFloat(match[1]), muted: !!match[2] } +} + +if (typeof module !== "undefined") { + module.exports = { + isShellLevelMeter: isShellLevelMeter, + parsePlatformAudio: parsePlatformAudio, + platformHidesNode: platformHidesNode, + parseWpctlVolume: parseWpctlVolume + } +} diff --git a/shell/Commons/Style.qml b/shell/Commons/Style.qml index 87849c18..81792792 100644 --- a/shell/Commons/Style.qml +++ b/shell/Commons/Style.qml @@ -343,6 +343,13 @@ QtObject { readonly property QtObject bar: QtObject { readonly property int sizeHorizontal: root.barToken("size-horizontal", 26) readonly property int sizeVertical: root.barToken("size-vertical", 28) + // Calibrated notch cutout height in logical px, for panels whose real + // cutout differs from the derived default (see BarModel.notchHeight). + // 0 means derive it. Physical alignment, so it never scales with font. + readonly property int notchHeight: { + var v = Number(root.barOverrides["notch-height"]) + return (isFinite(v) && v > 0) ? Math.round(v) : 0 + } readonly property int iconSlot: root.barToken("icon-slot", 27) readonly property int iconCanvas: root.barToken("icon-canvas", 16) readonly property int iconFont: root.barToken("icon-font", 13) @@ -417,7 +424,7 @@ QtObject { } else if (section === "bar") { if (key === "scale-with-font") { nextBarScaleWithFont = boolToken(raw, nextBarScaleWithFont) - } else if (key === "size-horizontal" || key === "size-vertical") { + } else if (key === "size-horizontal" || key === "size-vertical" || key === "notch-height") { var b = parseInt(raw, 10) if (isFinite(b)) barOut[key] = b } diff --git a/shell/Commons/UntypedInput.qml b/shell/Commons/UntypedInput.qml new file mode 100644 index 00000000..f7dfd936 --- /dev/null +++ b/shell/Commons/UntypedInput.qml @@ -0,0 +1,152 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Pipewire +import "AudioNodesModel.js" as Model + +// Volume and mute of the default input when Quickshell gives it no +// PwNode.audio: Quickshell types only exact media classes, so a virtual source +// (an Audio/Source/Virtual, such as EasyEffects' or a platform's microphone +// mapping) has no volume or mute there. They go through wpctl instead, once for +// the whole shell. Reads and writes name the node by id, a reply for a node no +// longer tracked is dropped, and writes run one at a time with only the latest +// volume kept, so a slider drag cannot land on another input later. +Singleton { + id: root + + readonly property var source: Pipewire.defaultAudioSource + // The default input is untyped, so this drives it. + readonly property bool active: !!source && !source.audio + // Every handler reads nodeId itself: a property bound to it could still hold + // its old value when onNodeIdChanged runs. + readonly property int nodeId: active ? source.id : -1 + + property bool known: false + property real volume: 0 + property bool muted: false + + property int generation: 0 + property int readGeneration: -1 + property bool readAgain: false + property var pendingVolume: null + property var pendingMute: null + + // pactl subscribe is started again when it ends, unless it keeps ending at + // once (no pipewire-pulse), which would only respawn it forever. + property real subscribedAt: 0 + property int quickExits: 0 + + onNodeIdChanged: reset() + Component.onCompleted: reset() + + function reset() { + generation++ + known = false + pendingVolume = null + pendingMute = null + quickExits = 0 + events.running = nodeId >= 0 + refresh() + } + + function refresh() { + if (nodeId < 0) return + if (reader.running || writer.running) { + readAgain = true + return + } + readGeneration = generation + reader.command = ["wpctl", "get-volume", String(nodeId)] + reader.running = true + } + + function setVolume(value) { + if (nodeId < 0) return + volume = Math.max(0, Math.min(1, value)) + pendingVolume = [String(nodeId), volume.toFixed(2)] + flush() + } + + function setMuted(value) { + if (nodeId < 0 || !known) return + muted = value + pendingMute = [String(nodeId), value ? "1" : "0"] + flush() + } + + function flush() { + if (reader.running || writer.running) return true + var args = null + if (pendingMute !== null) { + args = ["wpctl", "set-mute"].concat(pendingMute) + pendingMute = null + } else if (pendingVolume !== null) { + args = ["wpctl", "set-volume"].concat(pendingVolume) + pendingVolume = null + } + if (!args) return false + writer.command = args + writer.running = true + return true + } + + function settle() { + if (flush()) return + if (readAgain) { + readAgain = false + refresh() + } + } + + Process { + id: reader + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var level = Model.parseWpctlVolume(text) + if (!level || root.nodeId < 0 || root.readGeneration !== root.generation) return + if (root.pendingVolume !== null || root.pendingMute !== null || writer.running) return + root.volume = level.volume + root.muted = level.muted + root.known = true + } + } + onExited: root.settle() + } + + Process { + id: writer + onExited: { + root.readAgain = true + root.settle() + } + } + + // The volume keys, the mute key and other apps change the node too. pactl + // translates its events, so they are read in the C locale. + Process { + id: events + command: ["pactl", "subscribe"] + environment: ({ LC_ALL: "C" }) + onRunningChanged: if (running) root.subscribedAt = Date.now() + onExited: { + root.quickExits = Date.now() - root.subscribedAt < 1000 ? root.quickExits + 1 : 0 + if (root.nodeId >= 0 && root.quickExits < 5) resubscribe.start() + } + stdout: SplitParser { + onRead: function(line) { + if (String(line).indexOf(" on source #") !== -1) root.refresh() + } + } + } + + Timer { + id: resubscribe + interval: 2000 + onTriggered: if (root.nodeId >= 0 && !events.running) { + events.running = true + root.refresh() + } + } +} diff --git a/shell/Commons/qmldir b/shell/Commons/qmldir index 1c9101c1..660f5901 100644 --- a/shell/Commons/qmldir +++ b/shell/Commons/qmldir @@ -1,7 +1,9 @@ module qs.Commons +singleton AudioNodes 1.0 AudioNodes.qml singleton Border 1.0 Border.qml singleton Color 1.0 Color.qml singleton IpcRegistry 1.0 IpcRegistry.qml singleton Style 1.0 Style.qml +singleton UntypedInput 1.0 UntypedInput.qml singleton Util 1.0 Util.qml ShellIpc 1.0 ShellIpc.qml diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 84680cb5..392a7c44 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -569,6 +569,61 @@ Item { readonly property bool vertical: position === "left" || position === "right" readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal + // Display cutouts (a camera notch) the platform's own package describes in + // the fixed platform root, which no environment variable moves. Most machines + // have none. It is read as the shell starts (blockLoading), so a bar surface + // knows before it maps whether its screen may have one. See + // BarModel.parseCutouts. + readonly property var displayCutouts: displayCutoutsFile.missing ? [] : BarModel.parseCutouts(displayCutoutsFile.text()) + FileView { + id: displayCutoutsFile + + // A file that went away describes nothing, whatever text() still holds. + property bool missing: false + + path: "/usr/share/omarchy-platform/display-cutouts.json" + blockLoading: true + watchChanges: true + printErrors: false + onLoaded: missing = false + onLoadFailed: missing = true + onFileChanged: reload() + } + + // The physical mode Hyprland reports for a screen, as hyprctl monitors does. + // Qt's whole-number devicePixelRatio can't rebuild it at a fractional scale. + function panelModeFor(screen) { + var monitor = screen ? Hyprland.monitorFor(screen) : null + return monitor ? ({ + width: monitor.width, + height: monitor.height, + transform: monitor.lastIpcObject ? monitor.lastIpcObject.transform : 0 + }) : null + } + + // How thick the bar is on a screen. A top bar shorter than a panel's camera + // cutout leaves a sliver of every window peeking out beside the camera, so + // the cutout is this panel's minimum sensible top-bar height. An + // intentionally taller bar still wins. + function thicknessFor(screen) { + if (root.vertical || !screen) return root.barSize + return Math.max(root.barSize, BarModel.notchFloor(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, root.panelModeFor(screen), Style.bar.notchHeight)) + } + + // Each screen's bar thickness, by screen name: barSize, or more where a notch + // floor raises a top bar. Toasts and plugins clear the bar by it; barSize + // stays the configured size the widgets are drawn at. + readonly property var screenBarSizes: { + var sizes = ({}) + var screens = Quickshell.screens + for (var i = 0; i < screens.length; i++) sizes[screens[i].name] = root.thicknessFor(screens[i]) + return sizes + } + + function barSizeFor(screenName) { + return BarModel.barSizeFor(root.screenBarSizes, screenName, root.barSize) + } + function normalizePosition(value) { return BarModel.normalizePosition(value) } @@ -1253,7 +1308,12 @@ Item { // reveal has to rebuild them — new surface, re-shaped glyphs, re-uploaded // textures — which measures ~150ms against ~20ms to tear down. Parking // keeps the surface alive, so showing is only a margin change. - visible: !remapGuard.remapping + // + // A top bar that can't tell its notch floor yet waits for Hyprland's mode + // before it maps (see BarModel.cutoutPending), so it maps at its floor + // instead of growing once the windows are laid out. The wait ends once, for + // good: when the mode settles it, or after two seconds without one. + visible: !remapGuard.remapping && (cutoutWaitOver || !cutoutPending) exclusionMode: root.barHidden ? ExclusionMode.Ignore : ExclusionMode.Auto ScreenMoveRemap { @@ -1261,11 +1321,13 @@ Item { window: barWindow } + // Parked by its full thickness, which a notch floor may make more than + // barSize, so no strip of it stays on screen. margins { - top: root.barHidden && root.position === "top" ? -root.barSize : 0 - bottom: root.barHidden && root.position === "bottom" ? -root.barSize : 0 - left: root.barHidden && root.position === "left" ? -root.barSize : 0 - right: root.barHidden && root.position === "right" ? -root.barSize : 0 + top: root.barHidden && root.position === "top" ? -barWindow.thickness : 0 + bottom: root.barHidden && root.position === "bottom" ? -barWindow.thickness : 0 + left: root.barHidden && root.position === "left" ? -barWindow.thickness : 0 + right: root.barHidden && root.position === "right" ? -barWindow.thickness : 0 } anchors { @@ -1275,8 +1337,26 @@ Item { right: root.position === "right" || !root.vertical } - implicitWidth: root.vertical ? root.barSize : 0 - implicitHeight: root.vertical ? 0 : root.barSize + readonly property var panelMode: root.panelModeFor(screen) + + readonly property bool centerBesideRight: BarModel.centerBesideRight(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, panelMode) + + readonly property bool cutoutPending: BarModel.cutoutPending(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, panelMode) + property bool cutoutWaitOver: false + onCutoutPendingChanged: if (!cutoutPending) cutoutWaitOver = true + Component.onCompleted: if (!cutoutPending) cutoutWaitOver = true + + Timer { + interval: 2000 + running: barWindow.cutoutPending && !barWindow.cutoutWaitOver + onTriggered: barWindow.cutoutWaitOver = true + } + + // The same thickness the bar publishes for its screen (screenBarSizes). + readonly property int thickness: root.thicknessFor(screen) + + implicitWidth: root.vertical ? thickness : 0 + implicitHeight: root.vertical ? 0 : thickness color: root.transparent ? "transparent" : root.background surfaceFormat.opaque: false WlrLayershell.namespace: "omarchy-bar" @@ -1367,7 +1447,10 @@ Item { Item { anchors.fill: parent - CenterModules { anchors.fill: parent } + CenterModules { + anchors.fill: parent + entries: barWindow.centerBesideRight ? [] : root.layoutEntries("center") + } LeftModules { anchors.left: parent.left @@ -1376,10 +1459,22 @@ Item { } RightModules { + id: rightModules anchors.right: parent.right anchors.rightMargin: Style.space(8) anchors.verticalCenter: parent.verticalCenter } + + // Keeps the center region, so settings pushes, drag and drop, and panel + // routing still address it. The gap lets a drop at the seam land in + // the section the pointer is nearer to. + ModuleList { + entries: barWindow.centerBesideRight ? root.layoutEntries("center") : [] + region: "center" + anchors.right: rightModules.left + anchors.rightMargin: Style.space(4) + anchors.verticalCenter: parent.verticalCenter + } } } @@ -1529,8 +1624,7 @@ Item { } } - function findCenterAnchorEntry() { - var entries = root.layoutEntries("center") + function findCenterAnchorEntry(entries) { var idx = root.entryIndex(entries, root.centerAnchor) return idx === -1 ? null : entries[idx] } @@ -1550,7 +1644,7 @@ Item { property var entries: root.layoutEntries("center") readonly property bool hasAnchor: root.entryIndex(entries, root.centerAnchor) !== -1 - readonly property var anchorEntry: root.findCenterAnchorEntry() + readonly property var anchorEntry: root.findCenterAnchorEntry(entries) Loader { anchors.fill: parent diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js index 36900e5b..b9ce90e8 100644 --- a/shell/plugins/bar/BarModel.js +++ b/shell/plugins/bar/BarModel.js @@ -208,9 +208,122 @@ function nearestDropTarget(candidates, point, vertical) { return best } +// Display cutouts (a camera notch at the top of a laptop panel) are described +// by the platform's own package, in /usr/share/omarchy-platform/display-cutouts.json: +// +// { "panels": [ { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 } ] } +// +// A panel matches a screen whose connector name starts with `connector` and +// whose mode is width x height physical pixels; `top` is how many physical rows +// at its top the cutout covers. Anything malformed is dropped. +function parseCutouts(text) { + var parsed + try { + parsed = JSON.parse(String(text || "")) + } catch (error) { + return [] + } + var panels = parsed && Array.isArray(parsed.panels) ? parsed.panels : [] + var cutouts = [] + for (var i = 0; i < panels.length; i++) { + var panel = panels[i] || {} + var width = Number(panel.width) + var height = Number(panel.height) + var top = Number(panel.top) + if (typeof panel.connector !== "string" || panel.connector === "") continue + if (!(width > 0) || !(height > 0) || !(top > 0) || top >= height) continue + cutouts.push({ connector: panel.connector, width: width, height: height, top: top }) + } + return cutouts +} + +// The cutout at the top of this screen, in logical pixels, or 0. `mode` is the +// output's physical mode as Hyprland reports it ({ width, height, transform }). +// Qt's devicePixelRatio is a whole number even at a fractional scale, so the +// logical size times it rebuilds the mode only at whole scales; that stands in +// just until Hyprland's answer arrives. A mode Hyprland reports that matches no +// panel has no cutout. +function cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) { + var name = String(screenName || "") + var logical = Number(logicalWidth) + var logicalTall = Number(logicalHeight) + if (!(logical > 0) || !(logicalTall > 0)) return 0 + var width = Number(mode && mode.width) + var height = Number(mode && mode.height) + if (width > 0 && height > 0) { + // Turned a quarter or upside down, the cutout is on another edge. + var transform = Number(mode.transform) || 0 + if (transform !== 0 && transform !== 4) return 0 + if ((width > height) !== (logical > logicalTall)) return 0 + } else { + var scale = Number(devicePixelRatio) > 0 ? Number(devicePixelRatio) : 1 + width = Math.round(logical * scale) + height = Math.round(logicalTall * scale) + } + var list = Array.isArray(cutouts) ? cutouts : [] + for (var i = 0; i < list.length; i++) { + var panel = list[i] + if (name.indexOf(panel.connector) !== 0) continue + // Logical sizes are rounded, so a rebuilt mode can be a couple of pixels off. + if (Math.abs(width - panel.width) <= 4 && Math.abs(height - panel.height) <= 4) + return Math.ceil(panel.top * logical / width) + } + return 0 +} + +// The height a top bar on this screen must reach to cover its cutout, or a +// calibrated [bar] notch-height in its place. A screen without a cutout, or a +// bar on another edge, has no floor, so a calibration never reaches an +// external monitor. +function notchFloor(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode, calibrated) { + if (position !== "top") return 0 + var top = cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) + if (!(top > 0)) return 0 + return Number(calibrated) > 0 ? Math.round(Number(calibrated)) : top +} + +// A cutout covers the middle of a top bar, so that bar draws its center +// section beside the right one. +function centerBesideRight(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) { + return position === "top" && cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) > 0 +} + +// Whether a top bar can't tell its floor yet: Hyprland hasn't reported the +// screen's mode, the logical size times Qt's ratio matched no panel (as at a +// fractional scale), and a described panel's connector matches the screen, so +// the mode may still bring a cutout. Such a bar waits for the mode before it +// maps rather than guess a floor. A screen no entry's connector matches never +// waits. +function cutoutPending(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) { + if (position !== "top") return false + if (Number(mode && mode.width) > 0 && Number(mode && mode.height) > 0) return false + var name = String(screenName || "") + var list = Array.isArray(cutouts) ? cutouts : [] + var described = false + for (var i = 0; i < list.length; i++) { + if (name.indexOf(list[i].connector) === 0) described = true + } + return described && !(cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) > 0) +} + +// The bar's thickness on the screen with this name, from the sizes a bar +// publishes by screen name, or `fallback` (its configured size) for a screen +// it has none for. +function barSizeFor(sizes, screenName, fallback) { + var name = String(screenName || "") + var size = sizes && Object.prototype.hasOwnProperty.call(sizes, name) ? Number(sizes[name]) : 0 + return size > 0 ? size : fallback +} + if (typeof module !== "undefined") { module.exports = { + barSizeFor: barSizeFor, + centerBesideRight: centerBesideRight, + cutoutPending: cutoutPending, + cutoutTop: cutoutTop, isDrawnSlot: isDrawnSlot, + notchFloor: notchFloor, + parseCutouts: parseCutouts, pickDrawnSlot: pickDrawnSlot, pickPanelSlot: pickPanelSlot, nearestDropTarget: nearestDropTarget, diff --git a/shell/plugins/bar/indicators/ScreenRecording.qml b/shell/plugins/bar/indicators/ScreenRecording.qml index 271e1c39..59b4cab2 100644 --- a/shell/plugins/bar/indicators/ScreenRecording.qml +++ b/shell/plugins/bar/indicators/ScreenRecording.qml @@ -15,7 +15,7 @@ BarIndicator { function refresh() { if (!root.bar || statusProc.running) return - statusProc.command = ["pgrep", "--quiet", "-f", "^gpu-screen-recorder"] + statusProc.command = ["omarchy-capture-screenrecording", "--status"] statusProc.running = true } diff --git a/shell/plugins/bar/widgets/Microphone.qml b/shell/plugins/bar/widgets/Microphone.qml index 4f1bf211..10b0e1f9 100644 --- a/shell/plugins/bar/widgets/Microphone.qml +++ b/shell/plugins/bar/widgets/Microphone.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import Quickshell.Services.Pipewire import qs.Ui +import qs.Commons BarWidget { id: root @@ -9,15 +10,28 @@ BarWidget { readonly property var source: Pipewire.defaultAudioSource - readonly property bool muted: source && source.audio ? source.audio.muted : true - readonly property real volume: source && source.audio ? source.audio.volume : 0 + // A virtual source is untyped in Quickshell, so it has no PwNode.audio; its + // volume and mute go through wpctl (UntypedInput). + readonly property bool viaWpctl: UntypedInput.active + readonly property bool muted: viaWpctl ? (!UntypedInput.known || UntypedInput.muted) : (source && source.audio ? source.audio.muted : true) + readonly property real volume: viaWpctl ? UntypedInput.volume : (source && source.audio ? source.audio.volume : 0) readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : [] + readonly property var nodeNames: { + var names = [] + for (var i = 0; i < nodes.length; i++) + if (nodes[i] && nodes[i].name) names.push(String(nodes[i].name)) + return names + } readonly property var activeStreams: { var list = [] for (var i = 0; i < nodes.length; i++) { var node = nodes[i] - if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node) + if (!node || !node.isStream || node.isSink !== false || node.audio?.muted) continue + // The shell's own level meters, and a platform's audio processing that + // records all the time, are not an app using the microphone. + if (AudioNodes.isShellLevelMeter(node.name) || AudioNodes.platformHides(node.name, nodeNames)) continue + list.push(node) } return list } @@ -29,7 +43,10 @@ BarWidget { implicitHeight: button.implicitHeight function toggleMute() { - if (source && source.audio) source.audio.muted = !source.audio.muted + if (viaWpctl) { + if (UntypedInput.known) UntypedInput.setMuted(!UntypedInput.muted) + } + else if (source && source.audio) source.audio.muted = !source.audio.muted } PwObjectTracker { objects: root.source ? [root.source] : [] } @@ -46,8 +63,12 @@ BarWidget { else root.toggleMute() } onWheelMoved: function(delta) { - if (!root.source || !root.source.audio) return var step = 0.05 + if (root.viaWpctl) { + if (UntypedInput.known) UntypedInput.setVolume(root.volume + (delta > 0 ? step : -step)) + return + } + if (!root.source || !root.source.audio) return root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step))) } } diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index b152dc06..6ba2b6ab 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -412,6 +412,19 @@ function popupExpired(entry, duration, now) { return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime } +// How far a toast on a screen keeps from a top or right bar: the bar's +// thickness on that screen (a notch floor can make a top bar thicker than its +// configured size on one screen), plus the gap. A bar without per-screen +// sizes counts its configured size; a hidden or missing one, the default size. +function barClearance(bar, screenName, defaultBarSize, gapsOut) { + var size = Number(defaultBarSize) + if (bar && !bar.barHidden) { + size = Number(typeof bar.barSizeFor === "function" ? bar.barSizeFor(screenName) : bar.barSize) + size = isFinite(size) ? Math.max(0, size) : 0 + } + return size + Number(gapsOut) +} + function popupPlacement(barPosition, barClearance, gapsOut) { var position = String(barPosition || "top") var clearance = Number(barClearance) @@ -492,6 +505,7 @@ if (typeof module !== "undefined") { serializePopup: serializePopup, parsePopupFiles: parsePopupFiles, popupExpired: popupExpired, + barClearance: barClearance, popupPlacement: popupPlacement } } diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index cba13cbe..a239b19f 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -45,11 +45,15 @@ Item { // pull notification popups away from the expected top-right location. // Falls back to the bar's default size (26 horizontal / 28 vertical) when // shell.bar isn't reachable so the popup never lands on top of the bar. + // Each screen clears the bar as thick as it is there: a notch floor can make + // a top bar taller than its configured size on one screen only. readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top" readonly property bool barVertical: barPosition === "left" || barPosition === "right" readonly property int defaultBarSize: barVertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal - readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize - readonly property int barClearance: liveBarSize + Style.gapsOut + + function barClearanceFor(screenName) { + return NotificationLogic.barClearance(shell ? shell.bar : null, screenName, defaultBarSize, Style.gapsOut) + } // Live Notification objects by originalId, kept OUT of the ListModels: a // QObject stored in a model role becomes a dangling C++ pointer when the @@ -1009,7 +1013,7 @@ Item { color: "transparent" readonly property var popupPlacement: NotificationLogic.popupPlacement( - service.barPosition, service.barClearance, Style.gapsOut) + service.barPosition, service.barClearanceFor(modelData ? modelData.name : ""), Style.gapsOut) // Full-screen, fixed-size surface (like the OSD overlay). Adding or // removing a toast changes only the content inside; the Wayland surface diff --git a/shell/plugins/panels/audio/Model.js b/shell/plugins/panels/audio/Model.js index 03b2f8aa..32a006c2 100644 --- a/shell/plugins/panels/audio/Model.js +++ b/shell/plugins/panels/audio/Model.js @@ -256,6 +256,16 @@ function streamRepresentsPlayer(node, player, players, streams) { return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel) } +// Quickshell types only exact media classes, so a virtual source (an +// Audio/Source/Virtual, such as EasyEffects' or a platform's microphone +// mapping) is a node with no PwNode.audio. PulseAudio lists it as a source, so +// the source availability listing names it; a non-stream node it names is one. +function isUntypedSource(node, sourceAvailability) { + if (!node || node.audio || node.isStream || node.isSink) return false + var name = String(node.name || "") + return name !== "" && !!sourceAvailability && Object.prototype.hasOwnProperty.call(sourceAvailability, name) +} + if (typeof module !== "undefined") { module.exports = { isPlaybackStream: isPlaybackStream, @@ -281,6 +291,7 @@ if (typeof module !== "undefined") { matchingMprisStreamLabel: matchingMprisStreamLabel, unmatchedMprisStreamLabel: unmatchedMprisStreamLabel, streamLabel: streamLabel, - streamRepresentsPlayer: streamRepresentsPlayer + streamRepresentsPlayer: streamRepresentsPlayer, + isUntypedSource: isUntypedSource } } diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index 7f336a31..46cb39a8 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -21,11 +21,25 @@ Panel { readonly property var mediaService: bar?.shell?.firstPartyServiceFor("omarchy.media") readonly property var activeMediaPlayer: mediaService ? mediaService.activePlayer : null + // Nodes of a platform's audio processing, which are neither devices nor apps, + // stay out of every list (AudioNodes.platformHides). Most machines have none. + readonly property var nodeNames: { + var names = [] + for (var i = 0; i < nodes.length; i++) + if (nodes[i] && nodes[i].name) names.push(String(nodes[i].name)) + return names + } + + function platformHides(node) { + return !!node && AudioNodes.platformHides(node.name, nodeNames) + } + readonly property var candidateSinks: { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] - if (n && n.isSink && !n.isStream) list.push(n) + if (!n || !n.isSink || n.isStream || platformHides(n)) continue + list.push(n) } return list } @@ -34,11 +48,11 @@ Panel { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] - if (n && !n.isSink && !n.isStream && isAudioSource(n)) { - var name = n.name || "" - if (name === "quickshell") continue - list.push(n) - } + if (!n || n.isSink || n.isStream) continue + if (!isAudioSource(n) && !Model.isUntypedSource(n, sourceAvailability)) continue + var name = n.name || "" + if (name === "quickshell" || platformHides(n)) continue + list.push(n) } return list } @@ -51,6 +65,7 @@ Panel { // A tuning's output is a playback stream too, but it is the processing // itself rather than an application, so it does not belong in the list. if (String(n.name || "").indexOf("omarchy_speaker_tuning") === 0) continue + if (platformHides(n)) continue list.push(n) } return list @@ -58,6 +73,7 @@ Panel { property var sinkAvailability: ({}) property bool sinkAvailabilityLoaded: false + property var sourceAvailability: ({}) // Identify true playback streams without reading node.properties here: // PwNode.properties is invalid until the node is bound, and reading it while @@ -148,8 +164,15 @@ Panel { readonly property real outputVolume: volumeSink && volumeSink.audio ? volumeSink.audio.volume : 0 readonly property bool outputMuted: volumeSink && volumeSink.audio ? volumeSink.audio.muted : false - readonly property real inputVolume: source && source.audio ? source.audio.volume : 0 - readonly property bool inputMuted: source && source.audio ? source.audio.muted : false + // A virtual source is untyped in Quickshell (see Model.isUntypedSource), so + // its volume and mute go through wpctl (UntypedInput), and the panel shows no + // level meter for it: Quickshell's peak monitor takes typed nodes only. + readonly property bool inputViaWpctl: UntypedInput.active + readonly property bool inputLevelKnown: !inputViaWpctl || UntypedInput.known + readonly property real inputVolume: inputViaWpctl ? UntypedInput.volume : (source && source.audio ? source.audio.volume : 0) + readonly property bool inputMuted: inputViaWpctl ? UntypedInput.muted : (source && source.audio ? source.audio.muted : false) + readonly property var inputPeakNode: inputViaWpctl ? null : source + readonly property bool inputLevelShown: !!inputPeakNode onRawAudioSinksChanged: if (rawAudioSinks.length > 0) cachedAudioSinks = rawAudioSinks onRawAudioSourcesChanged: if (rawAudioSources.length > 0) cachedAudioSources = rawAudioSources @@ -175,7 +198,7 @@ Panel { // would otherwise report "input unmuted" forever, leaving the hero switch // able to mute but never to unmute. readonly property bool hasOutput: !!(volumeSink && volumeSink.audio) - readonly property bool hasInput: !!(source && source.audio) + readonly property bool hasInput: !!(source && source.audio) || (inputViaWpctl && UntypedInput.known) readonly property bool anyAudible: (hasOutput && !outputMuted) || (hasInput && !inputMuted) readonly property string toggleHint: anyAudible ? "Mute" : "Unmute" @@ -427,7 +450,7 @@ Panel { } function inputIcon() { - if (!source || !source.audio) return "󰍭" + if (!hasInput) return "󰍭" return inputMuted ? "󰍭" : "󰍬" } @@ -454,6 +477,10 @@ Panel { } function setInputVolume(v) { + if (inputViaWpctl) { + UntypedInput.setVolume(v) + return + } if (!source || !source.audio) return source.audio.volume = Math.max(0, Math.min(1, v)) } @@ -463,7 +490,10 @@ Panel { } function toggleInputMute() { - if (source && source.audio) source.audio.muted = !source.audio.muted + if (inputViaWpctl) { + if (UntypedInput.known) UntypedInput.setMuted(!UntypedInput.muted) + } + else if (source && source.audio) source.audio.muted = !source.audio.muted } // The hero switch is the whole panel's on/off, so it carries both channels @@ -472,7 +502,8 @@ Panel { function toggleAllMuted() { var mute = anyAudible if (hasOutput) volumeSink.audio.muted = mute - if (hasInput) source.audio.muted = mute + if (hasInput && inputViaWpctl) UntypedInput.setMuted(mute) + else if (hasInput) source.audio.muted = mute } function setDefaultSink(node) { @@ -489,7 +520,8 @@ Panel { function setDefaultSource(node) { if (!node) return - Pipewire.preferredDefaultAudioSource = node + // Quickshell refuses an untyped node; the command below still selects it. + if (node.audio) Pipewire.preferredDefaultAudioSource = node if (node.id !== undefined && node.name) { Quickshell.execDetached([ "omarchy-audio-input-set-default", @@ -597,8 +629,8 @@ Panel { PwNodePeakMonitor { id: inputPeakMonitor - node: root.source - enabled: root.opened && !!root.source + node: root.inputPeakNode + enabled: root.opened && !!root.inputPeakNode } Process { @@ -610,6 +642,15 @@ Panel { } } + Process { + id: sourceAvailabilityProc + command: ["omarchy-audio-sink-availability", "sources"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.sourceAvailability = Model.parseSinkAvailability(text) + } + } + Process { id: volumeSinkProc command: ["omarchy-audio-output-sink"] @@ -624,7 +665,10 @@ Panel { running: root.opened repeat: true triggeredOnStart: true - onTriggered: if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true + onTriggered: { + if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true + if (!sourceAvailabilityProc.running) sourceAvailabilityProc.running = true + } } // Runs whether or not the panel is open: the bar shows and scrolls the output @@ -908,7 +952,7 @@ Panel { Text { id: microphonePercent textFormat: Text.PlainText - text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" + text: root.inputLevelKnown ? Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" : "–" color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily font.pixelSize: Style.font.caption @@ -932,9 +976,13 @@ Panel { Column { id: inputControls - anchors.fill: parent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top anchors.leftMargin: Style.space(6) anchors.rightMargin: Style.space(6) + // Alone, the slider centres in the row the way the output slider does. + anchors.topMargin: root.inputLevelShown ? 0 : Style.spacing.controlGap / 2 spacing: Style.space(5) PanelSlider { @@ -953,6 +1001,7 @@ Panel { } Rectangle { + visible: root.inputLevelShown width: parent.width height: Math.max(Style.space(5), Style.spacing.xs) color: Util.alpha(root.bar.foreground, 0.18) diff --git a/shell/services/PluginBarStateApi.qml b/shell/services/PluginBarStateApi.qml index db802b77..82b22718 100644 --- a/shell/services/PluginBarStateApi.qml +++ b/shell/services/PluginBarStateApi.qml @@ -6,7 +6,18 @@ QtObject { required property string ownerPluginId property bool barHidden: false + // The configured size. A notch floor can make a top bar thicker on one + // screen; barSizes holds each screen's thickness by screen name. property int barSize: 0 + property var barSizes: ({}) property string fontFamily: "" property string position: "top" + + // The bar's thickness on the screen with this name, to clear the bar by; + // barSize for a screen the bar reports none for. + function barSizeFor(screenName) { + var name = String(screenName || "") + var size = barSizes && Object.prototype.hasOwnProperty.call(barSizes, name) ? Number(barSizes[name]) : 0 + return size > 0 ? size : barSize + } } diff --git a/shell/shell.qml b/shell/shell.qml index b89831ed..458ad1de 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -439,12 +439,26 @@ ShellRoot { return api } + // A copy of the active bar's per-screen thickness (screen name -> number), + // so a plugin holds no reference into the bar. A bar that reports none + // gives an empty map. + function detachedBarSizes(sizes) { + var copy = ({}) + if (!Util.isPlainObject(sizes)) return copy + for (var name in sizes) { + var size = Number(sizes[name]) + if (size > 0) copy[name] = size + } + return copy + } + function pluginBarStateFor(cacheKey, pluginId) { if (_pluginBarStateApis[cacheKey]) return _pluginBarStateApis[cacheKey] var api = pluginBarStateApiComponent.createObject(null, { ownerPluginId: pluginId }) if (!api) return null api.barHidden = Qt.binding(function() { return shell.bar ? shell.bar.barHidden === true : false }) api.barSize = Qt.binding(function() { return shell.bar ? Math.max(0, shell.bar.barSize || 0) : 0 }) + api.barSizes = Qt.binding(function() { return shell.bar ? shell.detachedBarSizes(shell.bar.screenBarSizes) : ({}) }) api.fontFamily = Qt.binding(function() { return shell.bar ? String(shell.bar.fontFamily || "") : "" }) api.position = Qt.binding(function() { return shell.bar ? String(shell.bar.position || "top") : "top" }) var next = ({}) diff --git a/test/shell.d/apple-silicon-quirks-test.sh b/test/shell.d/apple-silicon-quirks-test.sh new file mode 100644 index 00000000..7ec697f6 --- /dev/null +++ b/test/shell.d/apple-silicon-quirks-test.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# PC and Intel Mac quirks that must leave Apple Silicon alone, run on both sides +# of the detector. + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +calls="$test_tmp/calls.log" +conf="$test_tmp/modprobe.d/hid_apple.conf" +mkdir -p "$stub_bin" + +cat >"$stub_bin/omarchy-hw-aarch64-apple" <<'SH' +#!/bin/bash +[[ ${APPLE_SILICON:-0} == "1" ]] +SH + +cat >"$stub_bin/uname" <<'SH' +#!/bin/bash +if [[ $1 == "-m" ]]; then + echo "${UNAME_M:-x86_64}" +else + exec /usr/bin/uname "$@" +fi +SH + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >>"$TEST_LOG" +"$@" +SH + +chmod +x "$stub_bin"/* + +run_fkeys() { + APPLE_SILICON="$1" OMARCHY_HID_APPLE_CONF="$conf" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \ + bash -eE -o pipefail -c 'source "$1"' _ "$ROOT/install/hardware/fix-fkeys.sh" +} + +run_fkeys 0 +[[ $(<"$conf") == "options hid_apple fnmode=2" ]] || fail "a PC keeps F-keys first on Apple-like keyboards" "$(cat "$conf")" +pass "a PC keeps F-keys first on Apple-like keyboards" + +printf 'options hid_apple fnmode=0\n' >"$conf" +run_fkeys 0 +[[ $(<"$conf") == "options hid_apple fnmode=0" ]] || fail "an existing hid_apple.conf is left alone" +pass "an existing hid_apple.conf is left alone" + +rm -rf "$test_tmp/modprobe.d" +: >"$calls" +run_fkeys 1 +[[ ! -e $conf && ! -s $calls ]] || fail "Apple Silicon keeps its own keyboard mode" "$(cat "$calls")" +pass "Apple Silicon keeps its own keyboard mode" + +# The Windows guest is x86_64, so every other CPU refuses it before it touches +# anything, Apple Silicon or not. +for machine in aarch64 armv7l; do + output=$(UNAME_M=$machine APPLE_SILICON=0 PATH="$stub_bin:$ROOT/bin:$PATH" bash "$ROOT/bin/omarchy-windows-vm" install 2>&1) && + fail "Windows VM install fails on $machine" + [[ $output == *"needs an x86_64 machine"* ]] || fail "Windows VM says why it refuses on $machine" "$output" +done +pass "Windows VM refuses every CPU but x86_64" + +# The firmware boot entry and hibernation are refused or skipped on Apple +# Silicon before they touch anything. +output=$(APPLE_SILICON=1 PATH="$stub_bin:$PATH" bash "$ROOT/bin/omarchy-setup-direct-boot" 2>&1) && + fail "direct boot fails on Apple Silicon" +[[ $output == *"not supported on Apple Silicon"* ]] || fail "direct boot says why it refuses" "$output" +output=$(APPLE_SILICON=1 PATH="$stub_bin:$PATH" bash "$ROOT/bin/omarchy-hibernation-setup" --force 2>&1) || + fail "hibernation setup skips Apple Silicon without failing" "$output" +[[ $output == "Skipping hibernation setup (not supported on Apple Silicon)" ]] || + fail "hibernation setup stops before its own checks on Apple Silicon" "$output" +pass "direct boot and hibernation step aside on Apple Silicon" + +# On x86_64 the Windows VM goes on to its own commands, as before. +output=$(UNAME_M=x86_64 PATH="$stub_bin:$ROOT/bin:$PATH" bash "$ROOT/bin/omarchy-windows-vm" help 2>&1) || true +[[ $output != *"x86_64 machine"* && $output == *"install"* ]] || fail "Windows VM runs as before on x86_64" "$output" +pass "Windows VM runs as before on x86_64" + +run_node_test <<'JS' +const fs = require('fs') +const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') +const items = menu.parseMenuJsonc(fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8')) +const when = id => (items.find(entry => entry.id === id) || {}).when +assertEqual(when('install.windows'), 'omarchy-hw-x86', 'install.windows is offered on x86_64 only') +assertEqual(when('setup.direct-boot'), '! omarchy-hw-aarch64-apple', 'setup.direct-boot is hidden on Apple Silicon only') +JS +pass "the menu offers Windows on x86_64 only and hides Direct Boot on Apple Silicon only" diff --git a/test/shell.d/audio-input-set-default-test.sh b/test/shell.d/audio-input-set-default-test.sh new file mode 100644 index 00000000..5d9d5ab5 --- /dev/null +++ b/test/shell.d/audio-input-set-default-test.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" + +cat >"$work/bin/wpctl" <<'SH' +#!/bin/bash +printf 'wpctl %s\n' "$*" >>"$CALLS" +SH +cat >"$work/bin/pactl" <<'SH' +#!/bin/bash +printf 'pactl %s\n' "$*" >>"$CALLS" +if [[ $* == "list source-outputs" ]]; then + [[ ${LC_ALL:-} == C ]] || echo "pactl read outside the C locale" >>"$CALLS" + cat "$OUTPUTS" +fi +exit 0 +SH +chmod +x "$work/bin/wpctl" "$work/bin/pactl" + +moved() { + grep '^pactl move-source-output ' "$CALLS" | awk '{ print $3 }' | tr '\n' ' ' +} + +export CALLS="$work/calls" OUTPUTS="$work/outputs" + +# A filter chain's capture half (joined to its source by node.link-group) +# records its device all the time. Choosing an input moves apps, never it. +cat >"$OUTPUTS" <<'OUT' +Source Output #67 + Driver: PipeWire + Properties: + node.name = "filter_capture.mic" + node.group = "filter-chain-100-18" + node.link-group = "filter-chain-100-18" + media.name = "Microphone filter" +Source Output #90 + Driver: PipeWire + Properties: + application.name = "Firefox" + node.name = "Firefox" +Source Output #91 + Properties: + node.name = "native-recorder" + media.name = "node.link-group = in a title" +OUT +: >"$CALLS" +PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-input-set-default" 113 virtual_mic +grep -qx 'wpctl set-default 113' "$CALLS" || fail 'input set-default selects the node' +grep -qx 'pactl set-default-source virtual_mic' "$CALLS" || fail 'input set-default names the default source' +! grep -q 'outside the C locale' "$CALLS" || fail 'input set-default reads the recordings in the C locale' +[[ $(moved) == "90 91 " ]] || fail 'input set-default moves apps but not a filter'"'"'s own capture' "$(moved)" +pass 'input set-default moves apps but not a filter'"'"'s own capture' + +# Without filters every recording moves, as before, whatever its properties. +cat >"$OUTPUTS" <<'OUT' +Source Output #12 + Properties: + application.name = "OBS" +Source Output #13 + Properties: + node.name = "native-recorder" +Source Output #14 + Properties: + node.name = "filter_capture.mic-copy" + node.group = "pipewire.dummy" +OUT +: >"$CALLS" +PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-input-set-default" 43 alsa_input.pci-0000_00_1f.3.analog-stereo +[[ $(moved) == "12 13 14 " ]] || fail 'input set-default still moves every other recording' "$(moved)" +pass 'input set-default still moves every other recording' diff --git a/test/shell.d/audio-source-availability-test.sh b/test/shell.d/audio-source-availability-test.sh new file mode 100644 index 00000000..04a3e173 --- /dev/null +++ b/test/shell.d/audio-source-availability-test.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" + +cat >"$work/bin/pactl" <<'SH' +#!/bin/bash +printf '%s %s\n' "${LC_ALL:-unset}" "$*" >>"$CALLS" +case $* in + "list sources") cat "$SOURCES" ;; + "list sinks") cat "$SINKS" ;; +esac +SH +cat >"$work/bin/omarchy-audio-tuning" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$work/bin/pactl" "$work/bin/omarchy-audio-tuning" +export CALLS="$work/calls" SOURCES="$work/sources" SINKS="$work/sinks" + +# A headset jack input with nothing plugged in, a virtual source (no ports) +# and a filter's source (one port of unknown availability). +cat >"$SOURCES" <<'OUT' +Source #1074 + Name: alsa_input.pci-0000_00_1f.3.analog-stereo + Ports: + [In] analog-input-headset-mic: Headset Microphone (type: Headset, priority: 300, availability group: Headset Mic, not available) + Active Port: [In] analog-input-headset-mic + Formats: + pcm +Source #126 + Name: easyeffects_source + Formats: + pcm +Source #1102 + Name: effect_output.mic-filter + Ports: + [In] Mic: Microphone (type: Mic, priority: 100, availability unknown) + Active Port: [In] Mic +OUT +cat >"$SINKS" <<'OUT' +Sink #57 + Name: alsa_output.pci-0000_00_1f.3.analog-stereo + Ports: + [Out] analog-output-headphones: Headphones (type: Headphones, priority: 300, availability group: Headphone, not available) + Active Port: [Out] analog-output-headphones +OUT + +: >"$CALLS" +expected=$'alsa_input.pci-0000_00_1f.3.analog-stereo\t0\neasyeffects_source\t1\neffect_output.mic-filter\t1' +actual=$(PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-sink-availability" sources) +[[ $actual == "$expected" ]] || fail 'source availability reports an empty jack input unavailable' "$actual" +grep -qx 'C list sources' "$CALLS" || fail 'source availability reads pactl in the C locale' "$(cat "$CALLS")" +pass 'source availability reports an empty jack input unavailable' + +: >"$CALLS" +actual=$(LC_ALL=de_DE.UTF-8 PATH="$work/bin:$PATH" "$ROOT/bin/omarchy-audio-sink-availability") +[[ $actual == $'alsa_output.pci-0000_00_1f.3.analog-stereo\t0' ]] || fail 'sink availability reports an empty headphone jack unavailable' "$actual" +grep -qx 'C list sinks' "$CALLS" || fail 'sink availability reads pactl in the C locale whatever the caller'"'"'s' "$(cat "$CALLS")" +pass 'sink availability reports an empty headphone jack unavailable, in the C locale' diff --git a/test/shell.d/audio-test.sh b/test/shell.d/audio-test.sh index c0c962cb..a0e20321 100644 --- a/test/shell.d/audio-test.sh +++ b/test/shell.d/audio-test.sh @@ -57,4 +57,72 @@ assertEqual(audio.matchingMprisStreamLabel('Chromium', players), 'Chromium', 'au assertEqual(audio.unmatchedMprisStreamLabel('audio-src', players, streams), 'Spotify', 'audio uses unmatched MPRIS player for generic streams') assertEqual(audio.streamLabel(streams[1], players, streams), 'Spotify', 'audio labels generic streams from MPRIS') assert(audio.streamRepresentsPlayer(streams[1], players[0], players, streams), 'audio links generic streams to active player') + +// A virtual source is untyped in Quickshell (no audio); PulseAudio's source +// listing is what names it a source. +const availability = { virtual_mic: true, 'alsa_input.usb': false } +assert(audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: false }, availability), 'audio finds an untyped source PulseAudio lists') +assert(audio.isUntypedSource({ name: 'unplugged_virtual', isSink: false, isStream: false }, { unplugged_virtual: false }), 'an untyped source PulseAudio lists as unavailable is still a source') +assert(!audio.isUntypedSource({ name: 'v4l2_input.camera', isSink: false, isStream: false }, availability), 'audio skips an untyped node PulseAudio does not list') +assert(!audio.isUntypedSource({ name: 'virtual_mic', audio: {}, isSink: false, isStream: false }, availability), 'a typed source is not untyped') +assert(!audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: true }, availability), 'a stream is never an untyped source') +assert(!audio.isUntypedSource({ name: '', isSink: false, isStream: false }, { '': true }), 'a nameless node is never an untyped source') +assert(!audio.isUntypedSource({ name: 'virtual_mic', isSink: false, isStream: false }, {}), 'nothing is untyped before the listing') + +const nodes = requireFromRoot('shell/Commons/AudioNodesModel.js') + +for (const name of ['quickshell', 'quickshell-peak-monitor']) + assert(nodes.isShellLevelMeter(name), 'audio knows the shell meter ' + name) +for (const name of ['Firefox', 'quickshell-other', '', undefined]) + assert(!nodes.isShellLevelMeter(name), 'audio counts ' + name + ' as a recording') + +// Platform hints: none by default, whole-name patterns, and "replaced" only +// while its replacement exists. +const none = nodes.parsePlatformAudio('') +assertDeepEqual([none.hidden.length, none.replaced.length], [0, 0], 'audio has no platform hints without the file') +for (const text of ['not json', '[]', 'null', '{"hidden": "x"}', '{"replaced": [1, {"node": "a"}, {"node": "(", "by": "b"}]}']) { + const parsed = nodes.parsePlatformAudio(text) + assertEqual(parsed.hidden.length + parsed.replaced.length, 0, 'audio ignores platform hints ' + text) +} +const hints = nodes.parsePlatformAudio(JSON.stringify({ + hidden: ['dsp_capture\\.[a-z]+', 'raw_speakers', '(', 7], + replaced: [{ node: 'dsp_mic\\.[0-9]+', by: 'stereo_mic' }] +})) +assertEqual(hints.hidden.length, 2, 'audio skips invalid hidden patterns') +const names = ['dsp_capture.mic', 'dsp_mic.1', 'stereo_mic', 'raw_speakers'] +assert(nodes.platformHidesNode('dsp_capture.mic', hints, names), 'platform hints hide a matching node') +assert(nodes.platformHidesNode('raw_speakers', hints, []), 'platform hints hide a literal name') +assert(!nodes.platformHidesNode('raw_speakers.monitor', hints, []), 'platform patterns match whole names only') +assert(!nodes.platformHidesNode('xdsp_capture.mic', hints, []), 'platform patterns are anchored at the start') +assert(nodes.platformHidesNode('dsp_mic.1', hints, names), 'platform hints hide a replaced node while its replacement exists') +assert(!nodes.platformHidesNode('dsp_mic.1', hints, ['dsp_mic.1']), 'platform hints keep a replaced node without its replacement') +assert(!nodes.platformHidesNode('stereo_mic', hints, names), 'platform hints keep the replacement') +for (const name of ['Firefox', 'alsa_input.pci-0000_00_1f.3.analog-stereo', '', undefined]) + assert(!nodes.platformHidesNode(name, hints, names), 'platform hints keep ' + name) +assert(!nodes.platformHidesNode('dsp_capture.mic', none, names), 'no platform hints hide nothing') + +// An untyped source's level is read from wpctl. +assertDeepEqual(nodes.parseWpctlVolume('Volume: 0.50\n'), { volume: 0.5, muted: false }, 'audio reads a wpctl volume') +assertDeepEqual(nodes.parseWpctlVolume('Volume: 1.00 [MUTED]'), { volume: 1, muted: true }, 'audio reads a muted wpctl volume') +for (const text of ['', 'Translate ID error: 404', 'Volume: loud', 'Volume: 0.50 [muted]']) + assertEqual(nodes.parseWpctlVolume(text), null, 'audio rejects wpctl output ' + JSON.stringify(text)) + +// Source wiring (no Quickshell here): the hints come from the fixed platform +// root, and the audio code names no platform or hardware of its own. +const fs = require('fs') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') +const audioNodes = read('shell/Commons/AudioNodes.qml') +assert(/path: "\/usr\/share\/omarchy-platform\/audio\.json"/.test(audioNodes) && !/Quickshell\.env|OMARCHY_/.test(audioNodes), + 'audio reads the platform hints from the fixed platform root') +assert(/onLoadFailed: missing = true/.test(audioNodes), 'a removed hints file hides nothing') +const sources = ['shell/plugins/panels/audio/Panel.qml', 'shell/plugins/panels/audio/Model.js', 'shell/Commons/AudioNodes.qml', + 'shell/Commons/AudioNodesModel.js', 'shell/Commons/UntypedInput.qml', 'shell/plugins/bar/widgets/Microphone.qml', + 'bin/omarchy-audio-input-set-default', 'bin/omarchy-audio-sink-availability'] +for (const file of sources) + assert(!/apple|asahi|macbook|j[0-9]{3}|omarchy-hw-|platform-sound/i.test(read(file)), file + ' names no platform') + +const panel = read('shell/plugins/panels/audio/Panel.qml') +assert(/inputPeakNode: inputViaWpctl \? null : source/.test(panel) && /inputLevelShown: !!inputPeakNode/.test(panel) && + /visible: root\.inputLevelShown[^}]*inputPeakMonitor\.peak/.test(panel), + 'the input level bar is hidden when the input is driven through wpctl') JS diff --git a/test/shell.d/bar-notch-test.sh b/test/shell.d/bar-notch-test.sh new file mode 100644 index 00000000..fab5b1b0 --- /dev/null +++ b/test/shell.d/bar-notch-test.sh @@ -0,0 +1,235 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const bar = requireFromRoot('shell/plugins/bar/BarModel.js') +const barSource = fs.readFileSync(root + '/shell/plugins/bar/Bar.qml', 'utf8') +const styleSource = fs.readFileSync(root + '/shell/Commons/Style.qml', 'utf8') +const shellJson = fs.readFileSync(root + '/config/omarchy/shell.json', 'utf8') + +// A platform package's cutout description, as it would ship it. +const cutouts = bar.parseCutouts(JSON.stringify({ panels: [ + { connector: 'eDP', width: 3024, height: 1964, top: 64 }, + { connector: 'eDP', width: 2560, height: 1664, top: 56 } +] })) +assertEqual(cutouts.length, 2, 'a well-formed description yields its panels') + +// Malformed descriptions and entries are dropped, never guessed at. +for (const text of ['', 'not json', '[]', '{}', '{"panels": {}}']) + assertDeepEqual(bar.parseCutouts(text), [], `an unusable description (${JSON.stringify(text)}) yields no cutouts`) +assertDeepEqual(bar.parseCutouts(JSON.stringify({ panels: [ + { width: 3024, height: 1964, top: 64 }, + { connector: '', width: 3024, height: 1964, top: 64 }, + { connector: 'eDP', width: 0, height: 1964, top: 64 }, + { connector: 'eDP', width: 3024, height: 1964, top: 0 }, + { connector: 'eDP', width: 3024, height: 1964, top: 1964 }, + null +] })), [], 'entries without a connector, a size or a sensible cutout are dropped') + +// A cutout is described in physical rows; the bar works in logical pixels at +// any scale. Hyprland reports each output's physical mode, as hyprctl monitors +// does; Qt's devicePixelRatio is a whole number even at a fractional scale. +const mode = (width, height, transform = 0) => ({ width, height, transform }) +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1964)), 32, 'a 64-row cutout is 32 px at scale 2') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 3024, 1964, 1, mode(3024, 1964)), 64, 'scale 1 uses the rows as they are') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1280, 832, 2, mode(2560, 1664)), 28, 'each described panel gets its own cutout') + +// A 16" MacBook Pro panel at scale 1.33: Qt says 2, Hyprland says 3456x2234. +const pro16 = [{ connector: 'eDP', width: 3456, height: 2234, top: 64 }] +assertEqual(bar.cutoutTop(pro16, 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), 48, 'a fractional scale matches its panel by the mode Hyprland reports') +assertEqual(bar.notchFloor(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234), 0), 48, 'a top bar at a fractional scale floors at the cutout') +assertEqual(bar.centerBesideRight(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), true, 'a top bar at a fractional scale moves the center section') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1890, 1228, 2, mode(3024, 1964)), 40, 'scale 1.6 matches too') + +// Until Hyprland answers, the logical size times Qt's ratio stands in for the +// mode, which holds at whole scales only. +for (const pending of [null, undefined, {}, mode(0, 0)]) + assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, pending), 32, `a whole scale matches before Hyprland reports the mode (${JSON.stringify(pending)})`) +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 3024, 1964, 0, null), 64, 'a missing scale reads as 1') +assertEqual(bar.cutoutTop(pro16, 'eDP-1', 2592, 1676, 2, null), 0, 'a fractional scale finds nothing on Qt\'s ratio alone') + +// A mode Hyprland reports is the answer: one no panel describes has no cutout, +// whatever the logical size times the ratio would say. +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1890)), 0, 'the same panel with its cutout rows hidden has none') +assertEqual(bar.cutoutTop(cutouts, 'DP-1', 1512, 982, 2, mode(3024, 1964)), 0, 'another connector with the same mode has none') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1728, 1117, 2, mode(3456, 2234)), 0, 'a panel nobody described has none') +assertEqual(bar.cutoutTop([], 'eDP-1', 1512, 982, 2, mode(3024, 1964)), 0, 'with no description no panel has a cutout') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 0, 0, 2, mode(3024, 1964)), 0, 'degenerate screen sizes have none') + +// Turned a quarter or upside down, the cutout is on another edge; mirrored, +// it is still at the top. +for (const transform of [1, 2, 3, 5, 6, 7]) + assertEqual(bar.cutoutTop(cutouts, 'eDP-1', transform % 2 ? 982 : 1512, transform % 2 ? 1512 : 982, 2, mode(3024, 1964, transform)), 0, `a panel with transform ${transform} has no cutout at its top`) +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 1512, 982, 2, mode(3024, 1964, 4)), 32, 'a mirrored panel keeps its cutout at the top') +assertEqual(bar.cutoutTop(cutouts, 'eDP-1', 982, 1512, 2, mode(3024, 1964)), 0, 'a portrait screen on a landscape mode is turned, whatever its transform says') + +// Only a top bar on a described panel has a floor; a calibrated [bar] +// notch-height replaces the described value there and nowhere else. +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 0), 32, 'a top bar floors at the cutout') +for (const position of ['bottom', 'left', 'right']) + assertEqual(bar.notchFloor(cutouts, position, 'eDP-1', 1512, 982, 2, mode(3024, 1964), 0), 0, `a ${position} bar has no floor`) +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 36), 36, 'a calibrated notch-height overrides the described floor') +assertEqual(bar.notchFloor(cutouts, 'top', 'DP-1', 2560, 1440, 1, mode(2560, 1440), 36), 0, 'a calibrated notch-height never floors an external monitor') +assertEqual(bar.notchFloor([], 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 36), 0, 'a calibrated notch-height never floors a panel without a cutout') +assertEqual(bar.notchFloor(cutouts, 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964), 'x'), 32, 'an unreadable calibration falls back to the described floor') + +// Source wiring only (no Quickshell here): the bar reads the description from +// the fixed platform root, which no environment variable moves, not from a +// detector. +assert( + /path: "\/usr\/share\/omarchy-platform\/display-cutouts\.json"/.test(barSource) && + /readonly property var displayCutouts: displayCutoutsFile\.missing \? \[\] : BarModel\.parseCutouts\(displayCutoutsFile\.text\(\)\)/.test(barSource), + 'bar reads the platform package\'s cutout description from the platform root' +) +assert( + /id: displayCutoutsFile[\s\S]{0,400}blockLoading: true[\s\S]{0,200}onLoadFailed: missing = true/.test(barSource), + 'bar reads the description as the shell starts, before its surfaces map, and a removed file describes nothing' +) +assert(!/_PACKAGED_PATH|packagedPath/.test(barSource), 'no environment variable moves the cutout description') +assert(!/omarchy-hw-/.test(barSource), 'bar asks no hardware detector') +assert( + /BarModel\.notchFloor\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, root\.panelModeFor\(screen\), Style\.bar\.notchHeight\)/.test(barSource), + 'bar derives the floor from its own screen geometry, its mode and the calibration' +) +assert( + /function panelModeFor\(screen\) \{\s*var monitor = screen \? Hyprland\.monitorFor\(screen\) : null\s*return monitor \? \(\{\s*width: monitor\.width,\s*height: monitor\.height,\s*transform: monitor\.lastIpcObject \? monitor\.lastIpcObject\.transform : 0\s*\}\) : null/.test(barSource) && + /readonly property var panelMode: root\.panelModeFor\(screen\)/.test(barSource), + 'bar takes its screen\'s physical mode from Hyprland' +) +assert( + /function thicknessFor\(screen\) \{\s*if \(root\.vertical \|\| !screen\) return root\.barSize\s*return Math\.max\(root\.barSize, BarModel\.notchFloor\(/.test(barSource) && + /readonly property int thickness: root\.thicknessFor\(screen\)/.test(barSource) && + /implicitHeight: root\.vertical \? 0 : thickness/.test(barSource), + 'bar height is floored at the cutout, never shrunk to it' +) +// A hidden bar parks by that same thickness (bar-test.sh), so a floored top +// bar leaves no strip of itself on screen. + +// The calibration must not scale with the font: it describes physical pixels +// beside the camera. +assert( + /notchHeight:[\s\S]{0,240}barOverrides\["notch-height"\]/.test(styleSource) && + !/barToken\("notch-height"/.test(styleSource), + 'notch-height is read raw, not through the font-scaled bar tokens' +) + +// A top bar on a described panel draws its center section beside the right +// one; everything else keeps the configured layout. +for (const scale of [1, 2]) + assertEqual(bar.centerBesideRight(cutouts, 'top', 'eDP-1', 3024 / scale, 1964 / scale, scale, mode(3024, 1964)), true, `a described panel at scale ${scale} moves the center section`) +assertEqual(bar.centerBesideRight(cutouts, 'top', 'USB-2', 3440, 1440, 1, mode(3440, 1440)), false, 'an external monitor keeps the center section') +assertEqual(bar.centerBesideRight([], 'top', 'eDP-1', 1512, 982, 2, mode(3024, 1964)), false, 'a machine without a description keeps the center section') +for (const position of ['bottom', 'left', 'right']) + assertEqual(bar.centerBesideRight(cutouts, position, 'eDP-1', 1512, 982, 2, mode(3024, 1964)), false, `a ${position} bar keeps the center section`) + +// The move is per screen and draw-time only: the user's layout is read as +// configured, and the center entries keep their order and their own region. +assert( + /centerBesideRight: BarModel\.centerBesideRight\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, panelMode\)/.test(barSource), + 'each bar surface decides the move from its own screen' +) +assert( + /CenterModules \{\s*anchors\.fill: parent\s*entries: barWindow\.centerBesideRight \? \[\] : root\.layoutEntries\("center"\)\s*\}/.test(barSource), + 'a moved center section draws nothing in the middle of the bar' +) +assert( + /ModuleList \{\s*entries: barWindow\.centerBesideRight \? root\.layoutEntries\("center"\) : \[\]\s*region: "center"\s*anchors\.right: rightModules\.left\s*anchors\.rightMargin: Style\.space\(\d+\)/.test(barSource), + 'the center entries sit just left of the right section in their own region' +) + +// With no gap the last center slot and the first right slot would share an +// edge, and a drop there would always land in whichever registered first. +const seam = [ + { slot: 'right-first', x: 104, width: 20 }, + { slot: 'center-last', x: 80, width: 20 } +] +assertDeepEqual(bar.nearestDropTarget(seam, { x: 101 }, false), { slot: 'center-last', after: true }, 'a drop at the seam nearer the center section lands after its last entry') +assertDeepEqual(bar.nearestDropTarget(seam, { x: 103 }, false), { slot: 'right-first', after: false }, 'a drop at the seam nearer the right section lands before its first entry') +assert( + /anchorEntry: root\.findCenterAnchorEntry\(entries\)/.test(barSource), + 'an emptied center section does not keep a hidden copy of its anchor' +) + +// Until Hyprland reports the mode, a top bar waits to map where the mode may +// still bring a cutout: a described connector whose whole-scale rebuild +// matched nothing, as at a fractional scale. It never guesses a floor there. +assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, null), true, 'a fractional scale on a described connector waits for the mode') +for (const pending of [undefined, {}, mode(0, 0)]) + assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, pending), true, `no mode yet (${JSON.stringify(pending)}) still waits`) +assertEqual(bar.notchFloor(pro16, 'top', 'eDP-1', 2592, 1676, 2, null, 0), 0, 'a waiting bar has no guessed floor') +assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 2592, 1676, 2, mode(3456, 2234)), false, 'the mode ends the wait') +assertEqual(bar.cutoutPending(pro16, 'top', 'eDP-1', 1728, 1117, 2, mode(3456, 2160)), false, 'a mode no panel describes ends the wait too') +assertEqual(bar.cutoutPending(cutouts, 'top', 'eDP-1', 1512, 982, 2, null), false, 'a whole scale that matches needs no wait') +assertEqual(bar.cutoutPending(cutouts, 'top', 'DP-1', 2560, 1440, 1, null), false, 'a connector no entry names never waits') +assertEqual(bar.cutoutPending([], 'top', 'eDP-1', 2592, 1676, 2, null), false, 'without a description nothing waits') +for (const position of ['bottom', 'left', 'right']) + assertEqual(bar.cutoutPending(pro16, position, 'eDP-1', 2592, 1676, 2, null), false, `a ${position} bar never waits`) +assert( + /readonly property bool cutoutPending: BarModel\.cutoutPending\(root\.displayCutouts, root\.position, screen\.name, screen\.width, screen\.height, screen\.devicePixelRatio, panelMode\)/.test(barSource) && + /visible: !remapGuard\.remapping && \(cutoutWaitOver \|\| !cutoutPending\)/.test(barSource), + 'a bar surface maps only once its floor is known, and still remaps on a screen move' +) +assert( + /property bool cutoutWaitOver: false\s*onCutoutPendingChanged: if \(!cutoutPending\) cutoutWaitOver = true\s*Component\.onCompleted: if \(!cutoutPending\) cutoutWaitOver = true/.test(barSource) && + /Timer \{\s*interval: 2000\s*running: barWindow\.cutoutPending && !barWindow\.cutoutWaitOver\s*onTriggered: barWindow\.cutoutWaitOver = true\s*\}/.test(barSource), + 'the wait ends once, for good: when the mode settles it or after two seconds' +) + +// Toasts and plugins clear the bar as thick as it is on their screen. The bar +// publishes each screen's thickness by name from the same calculation its +// surfaces use; barSize stays the configured size the widgets are drawn at. +const sizes = { 'eDP-1': 40, 'DP-1': 26 } +assertEqual(bar.barSizeFor(sizes, 'eDP-1', 26), 40, 'a notch screen reports its floored thickness') +assertEqual(bar.barSizeFor(sizes, 'DP-1', 26), 26, 'another screen reports its own') +assertEqual(bar.barSizeFor(sizes, 'HDMI-A-1', 26), 26, 'a screen without a surface falls back to the configured size') +assertEqual(bar.barSizeFor({}, 'eDP-1', 26), 26, 'no published sizes fall back to the configured size') +assertEqual(bar.barSizeFor(sizes, 'constructor', 26), 26, 'inherited object keys are no screen') +assert( + /readonly property var screenBarSizes: \{\s*var sizes = \(\{\}\)\s*var screens = Quickshell\.screens\s*for \(var i = 0; i < screens\.length; i\+\+\) sizes\[screens\[i\]\.name\] = root\.thicknessFor\(screens\[i\]\)\s*return sizes\s*\}/.test(barSource) && + /function barSizeFor\(screenName\) \{\s*return BarModel\.barSizeFor\(root\.screenBarSizes, screenName, root\.barSize\)\s*\}/.test(barSource), + 'bar publishes every screen\'s thickness from the same calculation its surfaces use' +) +assert( + /readonly property int barSize: vertical \? Style\.bar\.sizeVertical : Style\.bar\.sizeHorizontal/.test(barSource), + 'barSize stays the configured size' +) + +// The plugin facade copies the sizes, holding no reference into the bar, and +// keeps barSize the configured size. +const shellSource = fs.readFileSync(root + '/shell/shell.qml', 'utf8') +const stateApiSource = fs.readFileSync(root + '/shell/services/PluginBarStateApi.qml', 'utf8') +const extract = (source, name) => { + const match = source.match(new RegExp(`function ${name}\\((\\w+)\\) \\{([\\s\\S]*?)\\n \\}\\n`)) + assert(match, `${name} is defined`) + return match +} +const detached = extract(shellSource, 'detachedBarSizes') +const Util = { isPlainObject: value => value !== null && typeof value === 'object' && !Array.isArray(value) } +const detachedBarSizes = new Function('Util', detached[1], detached[2]) +const live = { 'eDP-1': 40, 'DP-1': 26, broken: 'x', none: 0 } +const copy = detachedBarSizes.call(null, Util, live) +assertDeepEqual(copy, { 'eDP-1': 40, 'DP-1': 26 }, 'the plugin facade copies each screen\'s thickness') +assert(copy !== live, 'the copy is detached from the bar\'s map') +assertDeepEqual(detachedBarSizes.call(null, Util, undefined), {}, 'a bar without per-screen sizes gives an empty map') +assert( + /api\.barSize = Qt\.binding\(function\(\) \{ return shell\.bar \? Math\.max\(0, shell\.bar\.barSize \|\| 0\) : 0 \}\)/.test(shellSource) && + /api\.barSizes = Qt\.binding\(function\(\) \{ return shell\.bar \? shell\.detachedBarSizes\(shell\.bar\.screenBarSizes\) : \(\{\}\) \}\)/.test(shellSource), + 'plugins get the configured barSize and every screen\'s thickness' +) +const apiFor = extract(stateApiSource, 'barSizeFor') +const stateBarSizeFor = new Function('barSizes', 'barSize', apiFor[1], apiFor[2]) +assertEqual(stateBarSizeFor(copy, 26, 'eDP-1'), 40, 'a plugin reads a notch screen\'s thickness') +assertEqual(stateBarSizeFor(copy, 26, 'HDMI-A-1'), 26, 'a plugin falls back to barSize for a screen with none') +assertEqual(stateBarSizeFor({}, 26, 'eDP-1'), 26, 'a plugin under a bar without per-screen sizes gets barSize') + +const parsed = JSON.parse(shellJson) +assertEqual(parsed.bar.centerAnchor, 'omarchy.clock', 'shipped bar layout still anchors on the clock') +assert( + JSON.stringify(parsed.bar.layout.center).indexOf('omarchy.indicators') >= 0, + 'shipped bar layout still keeps the default center widgets' +) +JS diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index bc2b945f..79b7f9a4 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -81,8 +81,8 @@ assert( ) for (const edge of ['top', 'bottom', 'left', 'right']) { assert( - new RegExp(`${edge}: root\\.barHidden && root\\.position === "${edge}" \\? -root\\.barSize : 0`).test(barSource), - `a hidden bar parks past the ${edge} edge` + new RegExp(`${edge}: root\\.barHidden && root\\.position === "${edge}" \\? -barWindow\\.thickness : 0`).test(barSource), + `a hidden bar parks past the ${edge} edge by its whole thickness` ) } diff --git a/test/shell.d/base-test.sh b/test/shell.d/base-test.sh index 476b8f83..7deda36d 100644 --- a/test/shell.d/base-test.sh +++ b/test/shell.d/base-test.sh @@ -89,6 +89,58 @@ require_compositor() { exit 0 } +# Fake the hardware identity omarchy-hw-platform reads: a device tree under +# $1/proc and a uname in $1/bin reporting the platform's CPU. Run the code under +# test with OMARCHY_PROC_ROOT="$1/proc" and PATH="$1/bin:$ROOT/bin:...", so the +# real detector and its wrappers answer from the fixture. +fake_platform() { + local dir="$1" platform="$2" machine=aarch64 + local -a compatible=() + + case $platform in + aarch64-apple) compatible=(apple,j416c apple,t6021 apple,arm-platform) ;; + aarch64) compatible=(raspberrypi,5-model-b brcm,bcm2712) ;; + x86) machine=x86_64 ;; + *) fail "fake_platform knows the platform $platform" ;; + esac + + rm -rf "$dir/proc" + mkdir -p "$dir/proc" "$dir/bin" + if (( ${#compatible[@]} > 0 )); then + mkdir -p "$dir/proc/device-tree" + printf '%s\0' "${compatible[@]}" >"$dir/proc/device-tree/compatible" + fi + + cat >"$dir/bin/uname" <"$copy" && + chmod +x "$copy" || fail "copy $script for a fixture platform root" + grep -qF -- "$fixture" "$copy" && ! grep -qF /usr/share/omarchy-platform "$copy" || + fail "$(basename -- "$script") reads the platform root from the fixture" +} + run_node_test() { require_command node diff --git a/test/shell.d/battery-present-test.sh b/test/shell.d/battery-present-test.sh new file mode 100755 index 00000000..2ec66f96 --- /dev/null +++ b/test/shell.d/battery-present-test.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/macsmc-ac" "$test_tmp/CMB0" +printf 'Mains\n' >"$test_tmp/macsmc-ac/type" +printf 'Battery\n' >"$test_tmp/CMB0/type" +printf '1\n' >"$test_tmp/CMB0/present" + +OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present" || fail "battery presence accepts non-BAT native paths" +pass "battery presence accepts non-BAT native paths" + +printf '0\n' >"$test_tmp/CMB0/present" +if OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present"; then + fail "battery presence rejects an absent battery" +fi +pass "battery presence rejects an absent battery" + +# A wireless mouse registers a Battery of its own, scoped Device; it must not +# make a desktop look like a laptop. +printf '1\n' >"$test_tmp/CMB0/present" +printf 'System\n' >"$test_tmp/CMB0/scope" +mkdir -p "$test_tmp/hidpp_battery_0" +printf 'Battery\n' >"$test_tmp/hidpp_battery_0/type" +printf '1\n' >"$test_tmp/hidpp_battery_0/present" +printf 'Device\n' >"$test_tmp/hidpp_battery_0/scope" +OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present" || fail "battery presence accepts a System-scoped battery beside a peripheral" +pass "battery presence accepts a System-scoped battery beside a peripheral" + +rm -r "$test_tmp/CMB0" +if OMARCHY_POWER_SUPPLY_PATH="$test_tmp" "$ROOT/bin/omarchy-battery-present"; then + fail "battery presence ignores a peripheral's Device-scoped battery" +fi +pass "battery presence ignores a peripheral's Device-scoped battery" diff --git a/test/shell.d/battery-status-test.sh b/test/shell.d/battery-status-test.sh index 88a7fa12..8478cdb8 100644 --- a/test/shell.d/battery-status-test.sh +++ b/test/shell.d/battery-status-test.sh @@ -8,20 +8,40 @@ tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT mkdir -p "$tmp_dir/bin" -mkdir -p "$tmp_dir/power/BAT0" -printf '900000\n' >"$tmp_dir/power/BAT0/current_now" -printf '12000000\n' >"$tmp_dir/power/BAT0/voltage_now" +# Apple Silicon: a battery not called BAT*, power_now signed by direction, and +# the thresholds and cycle count only under the native path. +mkdir -p "$tmp_dir/power/macsmc-battery" +printf '%s\n' '-10800000' >"$tmp_dir/power/macsmc-battery/power_now" +printf '75\n' >"$tmp_dir/power/macsmc-battery/charge_control_start_threshold" +printf '80\n' >"$tmp_dir/power/macsmc-battery/charge_control_end_threshold" +printf '212\n' >"$tmp_dir/power/macsmc-battery/cycle_count" cat >"$tmp_dir/bin/upower" <<'STUB' #!/bin/bash +# A wireless mouse enumerates first, as a battery_ device that is not a power +# supply; the machine's own battery follows it. if [[ $1 == "-e" ]]; then - echo "/org/freedesktop/UPower/devices/battery_BAT0" + echo "/org/freedesktop/UPower/devices/line_power_macsmc_ac" + echo "/org/freedesktop/UPower/devices/battery_hidpp_battery_0" + echo "/org/freedesktop/UPower/devices/battery_${OMARCHY_TEST_NATIVE_PATH//-/_}" + exit 0 +fi + +if [[ $1 == "-i" && $2 == */battery_hidpp_battery_0 ]]; then + cat <<'INFO' + native-path: hidpp_battery_0 + model: Wireless Mouse + power supply: no + state: discharging + percentage: 5% +INFO exit 0 fi if [[ $1 == "-i" ]]; then cat <<'INFO' - native-path: BAT0 + native-path: macsmc-battery + power supply: yes state: discharging energy: 28.3 Wh energy-full: 56.7 Wh @@ -36,13 +56,69 @@ exit 1 STUB chmod +x "$tmp_dir/bin/upower" -shell_output=$(OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) +shell_output=$(OMARCHY_TEST_NATIVE_PATH=macsmc-battery OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) -grep -Fx $'percentage\t51%' <<<"$shell_output" >/dev/null || fail "battery status reports percentage" +grep -Fx $'percentage\t51%' <<<"$shell_output" >/dev/null || fail "battery status reports the machine's battery, not the mouse's" grep -Fx $'state\tdischarging' <<<"$shell_output" >/dev/null || fail "battery status reports state" grep -Fx $'rate\t10.8W' <<<"$shell_output" >/dev/null || fail "battery status reports live sysfs power rate" grep -Fx $'size\t56Wh' <<<"$shell_output" >/dev/null || fail "battery status reports full capacity" grep -Fx $'time\t2h 30m' <<<"$shell_output" >/dev/null || fail "battery status reports remaining time" +grep -Fx $'cycles\t212' <<<"$shell_output" >/dev/null || fail "battery status reports native-path cycle count" +grep -Fx $'threshold\t75-80%' <<<"$shell_output" >/dev/null || fail "battery status reports native-path charge thresholds" + +# The BAT* name the script used to key on is one of many; CMB0 is another. +mkdir -p "$tmp_dir/power/CMB0" +printf '7300000\n' >"$tmp_dir/power/CMB0/power_now" +sed -i 's/native-path: macsmc-battery/native-path: CMB0/' "$tmp_dir/bin/upower" +generic_output=$(OMARCHY_TEST_NATIVE_PATH=CMB0 OMARCHY_POWER_SUPPLY_PATH="$tmp_dir/power" PATH="$tmp_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) +grep -Fx $'rate\t7.3W' <<<"$generic_output" >/dev/null || fail "battery status accepts arbitrary UPower battery paths" +pass "battery status supports Apple Silicon and arbitrary native battery paths" + +# An 80% hold threshold doesn't trip while the battery is still below it. +hold_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir" "$hold_dir"' EXIT + +mkdir -p "$hold_dir/bin" "$hold_dir/power/BAT0" "$hold_dir/power/ac" +printf 'Mains\n' >"$hold_dir/power/ac/type" +printf '1\n' >"$hold_dir/power/ac/online" +printf '80\n' >"$hold_dir/power/BAT0/charge_control_end_threshold" +cat >"$hold_dir/bin/upower" <<'STUB' +#!/bin/bash + +if [[ $1 == "-e" ]]; then + echo "/org/freedesktop/UPower/devices/battery_BAT0" + exit 0 +fi + +if [[ $1 == "-i" ]]; then + cat <<'INFO' + native-path: BAT0 + power supply: yes + state: charging + energy-full: 69.6 Wh + energy-rate: 0.1 W + time to full: 0.2 hours + percentage: 79.5% + charge-end-threshold: 80% +INFO + exit 0 +fi + +exit 1 +STUB +chmod +x "$hold_dir/bin/upower" + +hold_output=$(OMARCHY_POWER_SUPPLY_PATH="$hold_dir/power" PATH="$hold_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) + +grep -Fx $'state\tcharging' <<<"$hold_output" >/dev/null || fail "a hold below its threshold is still charging" +pass "battery status doesn't trip a hold early" + +# Once the battery reaches the threshold, idle charging is holding. +sed -i 's/percentage: 79.5%/percentage: 80.0%/' "$hold_dir/bin/upower" +held_output=$(OMARCHY_POWER_SUPPLY_PATH="$hold_dir/power" PATH="$hold_dir/bin:$PATH" "$ROOT/bin/omarchy-battery-status" --shell) +grep -Fx $'percentage\t80%' <<<"$held_output" >/dev/null || fail "threshold percentage still displays as 80%" +grep -Fx $'state\tholding' <<<"$held_output" >/dev/null || fail "idle charging at the threshold is holding" +pass "battery status reports holding once the raw percentage reaches the threshold" if matches=$(rg -n 'omarchy-battery-(capacity|remaining|remaining-time)' "$ROOT/bin" "$ROOT/test" "$ROOT/shell" "$ROOT/docs"); then fail "battery status owns capacity and remaining calculations" "$matches" diff --git a/test/shell.d/brave-widevine-test.sh b/test/shell.d/brave-widevine-test.sh new file mode 100644 index 00000000..ca9750c1 --- /dev/null +++ b/test/shell.d/brave-widevine-test.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +stub_bin="$test_tmp/bin" +omarchy_path="$test_tmp/omarchy" +opt_path="$test_tmp/opt" +cdm="$opt_path/WidevineCdm/chromium" +calls="$test_tmp/calls.log" +mkdir -p "$stub_bin" "$test_tmp/home" "$omarchy_path/install/helpers" "$omarchy_path/config" + +# The real policy helper writes under /etc without sudo when run as root, so +# stand it in; this test covers only the CDM link. Its as_root goes through the +# sudo stub, which logs every link, whatever the test's EUID. +printf 'browser_policy_setup_dir() { :; }\nas_root() { sudo "$@"; }\n' >"$omarchy_path/install/helpers/browser-policy.sh" +cp "$ROOT/config/chromium-flags.conf" "$omarchy_path/config/" + +for command in omarchy-pkg-add omarchy-pkg-aur-add omarchy-install-chromium-copy-url omarchy-install-chromium-ytdlp omarchy-theme-set-browser omarchy-lifecycle-dispatch; do + printf '#!/bin/bash\nexit 0\n' >"$stub_bin/$command" +done +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo:%s\n' "$*" >>"$TEST_LOG" +"$@" +SH +chmod +x "$stub_bin"/* + +reset_opt() { + rm -rf "$opt_path" + mkdir -p "$opt_path" + local dir + for dir in "$@"; do + mkdir -p "$opt_path/$dir" + done +} + +run() { + : >"$calls" + HOME="$test_tmp/home" OMARCHY_PATH="$omarchy_path" OMARCHY_OPT_PATH="$opt_path" \ + TEST_LOG="$calls" PATH="$stub_bin:$PATH" "$@" >/dev/null +} + +install_browser() { + run bash "$ROOT/bin/omarchy-install-browser" "$1" +} + +links_made() { + grep -c '^sudo:ln ' "$calls" || true +} + +assert_cdm_linked() { + [[ -L $opt_path/$1/WidevineCdm && $(readlink "$opt_path/$1/WidevineCdm") == "$cdm" ]] || + fail "$2" "$(ls -la "$opt_path/$1" 2>&1)" +} + +for browser in brave brave-origin; do + install_dir=$browser-bin + + reset_opt WidevineCdm/chromium "$install_dir" + install_browser "$browser" + assert_cdm_linked "$install_dir" "$browser install links the system Widevine CDM into $install_dir" + install_browser "$browser" + (( $(links_made) == 0 )) || fail "$browser reinstall keeps the existing CDM link" "$(<"$calls")" + assert_cdm_linked "$install_dir" "$browser reinstall keeps the existing CDM link" + pass "$browser install links the system Widevine CDM into $install_dir once" + + reset_opt "$install_dir" + install_browser "$browser" + [[ ! -e $opt_path/$install_dir/WidevineCdm && ! -L $opt_path/$install_dir/WidevineCdm ]] || + fail "$browser install links nothing without the widevine package" + pass "$browser install links nothing without the widevine package" + + reset_opt WidevineCdm/chromium "$install_dir" own-cdm + ln -s "$opt_path/own-cdm" "$opt_path/$install_dir/WidevineCdm" + install_browser "$browser" + [[ $(readlink "$opt_path/$install_dir/WidevineCdm") == "$opt_path/own-cdm" ]] || + fail "$browser install keeps a CDM the browser already has" + pass "$browser install keeps a CDM the browser already has" +done + +reset_opt WidevineCdm/chromium +install_browser chromium +(( $(links_made) == 0 )) || fail "Chromium install leaves Widevine to the widevine package" "$(<"$calls")" +pass "Chromium install leaves Widevine to the widevine package" diff --git a/test/shell.d/brcmfmac-supplicant-test.sh b/test/shell.d/brcmfmac-supplicant-test.sh index 7e476d73..b02af983 100755 --- a/test/shell.d/brcmfmac-supplicant-test.sh +++ b/test/shell.d/brcmfmac-supplicant-test.sh @@ -9,6 +9,11 @@ fix_t2="$ROOT/install/hardware/apple/fix-t2.sh" all="$ROOT/install/hardware/all.sh" migration="$ROOT/migrations/1786391100.sh" +# Apple Silicon's BCM4387 is in the ID list, but there the firmware supplicant +# is the one that works, so the leaf and the migration step aside: a fresh +# install marks the migration done only for its first user. +grep -Fq 'omarchy-hw-aarch64-apple && exit 0' "$migration" || fail "Broadcom migration excludes Apple Silicon" + grep -q 'apple/fix-brcmfmac-supplicant.sh' "$all" || fail "the brcmfmac quirk runs during hardware setup" @@ -61,13 +66,19 @@ printf '\t%s' "$@" >>"$TEST_LOG" printf '\n' >>"$TEST_LOG" SH +cat >"$stub_bin/omarchy-hw-aarch64-apple" <<'SH' +#!/bin/bash + +(( ${APPLE_SILICON:-0} == 1 )) +SH + chmod +x "$stub_bin"/* # The leaf reads the vendor from an absolute path, so point it at a fixture by # running with a fake root on PATH-independent state. pipefail is on, so a # grep -q gate would go silent here the way #6608 did. run_leaf() { - local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" + local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" apple_silicon="${4:-0}" rm -rf "$test_tmp/etc" mkdir -p "$test_tmp/etc" printf '%s' "$vendor" >"$test_tmp/dmi/sys_vendor" @@ -78,7 +89,7 @@ run_leaf() { -e "s|/etc/modprobe.d|$test_tmp/etc/modprobe.d|g" \ "$leaf" >"$script" - WIFI_ID="$wifi_id" T2_HARDWARE="$t2" PATH="$stub_bin:$PATH" \ + WIFI_ID="$wifi_id" T2_HARDWARE="$t2" APPLE_SILICON="$apple_silicon" PATH="$stub_bin:$PATH" \ bash -eE -o pipefail -c 'source "$1"' bash "$script" /dev/null +[[ ! -f $conf ]] || fail "Apple Silicon is excluded from the Intel Mac quirk" +pass "Apple Silicon is excluded from the Intel Mac quirk" + # Older Macs report the vendor differently. run_leaf "Apple Computer, Inc." 43ba 0 >/dev/null [[ -f $conf ]] || fail "the older Apple vendor string is recognized" @@ -120,11 +135,11 @@ pass "a Mac with no wireless device is left alone" # Installs that predate the quirk never ran the leaf, so the migration has to # reach them. It runs as the user under pipefail, the context #6608 was about. run_migration() { - local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" + local vendor="$1" wifi_id="${2:-}" t2="${3:-0}" apple_silicon="${4:-0}" printf '%s' "$vendor" >"$test_tmp/dmi/sys_vendor" : >"$calls" - WIFI_ID="$wifi_id" T2_HARDWARE="$t2" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \ + WIFI_ID="$wifi_id" T2_HARDWARE="$t2" APPLE_SILICON="$apple_silicon" PATH="$stub_bin:$PATH" TEST_LOG="$calls" \ OMARCHY_BRCMFMAC_DMI_VENDOR="$test_tmp/dmi/sys_vendor" \ OMARCHY_BRCMFMAC_CONF="$conf" \ bash -euo pipefail "$migration" >/dev/null @@ -148,6 +163,12 @@ run_migration "Apple Inc." 4488 1 [[ ! -s $calls ]] || fail "a repaired install is left untouched" "$(cat "$calls")" pass "the migration is idempotent" +rm -rf "$test_tmp/etc" +run_migration "Apple Inc." 4433 0 1 +[[ ! -e $conf ]] || fail "the migration leaves Apple Silicon Wi-Fi alone" +[[ ! -s $calls ]] || fail "the migration escalates nothing on Apple Silicon" "$(cat "$calls")" +pass "the migration leaves Apple Silicon Wi-Fi alone" + # The machine this was written for, with no T2 to fall back on. rm -rf "$test_tmp/etc" run_migration "Apple Inc." 43ba 0 diff --git a/test/shell.d/brightness-display-test.sh b/test/shell.d/brightness-display-test.sh index 3e41c511..4399f437 100755 --- a/test/shell.d/brightness-display-test.sh +++ b/test/shell.d/brightness-display-test.sh @@ -53,9 +53,19 @@ SH chmod +x "$mock_bin"/* +# A copy of the command reads its platform file from a fixture root (none +# unless a case writes one, whatever the machine running the suite has +# installed) and its DRM connectors from a fixture directory. +platform_root="$test_tmp/platform" +mkdir -p "$platform_root" "$test_tmp/copy" +brightness_display="$test_tmp/copy/omarchy-brightness-display" +platform_root_copy "$ROOT/bin/omarchy-brightness-display" "$brightness_display" "$platform_root" +sed -i "s|/sys/class/drm|$test_tmp/drm|g" "$brightness_display" +grep -qF "$test_tmp/drm/card" "$brightness_display" || fail "omarchy-brightness-display reads DRM connectors from the fixture" + run_brightness() { - CALL_LOG="$call_log" XDG_RUNTIME_DIR="$runtime_dir" PATH="$mock_bin:$ROOT/bin:$PATH" \ - "$ROOT/bin/omarchy-brightness-display" "$@" + CALL_LOG="$call_log" XDG_RUNTIME_DIR="$runtime_dir" \ + PATH="$mock_bin:$ROOT/bin:$PATH" "$brightness_display" "$@" } brightness=$(run_brightness --monitor DP-1) @@ -144,3 +154,43 @@ if PATH="$mock_bin:$PATH" "$ROOT/bin/omarchy-hyprland-monitor-focused-apple"; th fail "focused non-Apple display is not detected as Apple" fi pass "named Apple display is detected independently of focus" + +# A platform whose display driver has no DDC channel says so: an external +# monitor whose connector has no ddc node is not probed and never dims the +# built-in panel in its place. +mkdir -p "$test_tmp/drm/card2-USB-1" "$test_tmp/drm/card2-eDP-1" +# The last line needs no newline. +printf '%s\n%s' '# no DDC channel on the display driver' 'ddc-require-connector-ddc' >"$platform_root/displays.conf" +: >"$call_log" +if run_brightness --monitor USB-1 >/dev/null 2>&1; then + fail "an external monitor without a ddc node has no brightness backend" +fi +if run_brightness --no-osd --monitor USB-1 +5% >/dev/null 2>&1; then + fail "setting an external monitor without a ddc node fails" +fi +printf 'ddc-require-connector-ddc\r\n' >"$platform_root/displays.conf" +if run_brightness --monitor USB-1 >/dev/null 2>&1; then + fail "a displays.conf saved with CRLF line ends reads the same" +fi +printf '%s\n%s' '# no DDC channel on the display driver' 'ddc-require-connector-ddc' >"$platform_root/displays.conf" +[[ ! -s $call_log ]] || fail "an external monitor without a ddc node probes nothing" "$(<"$call_log")" +brightness=$(run_brightness --monitor eDP-1) +(( brightness == 40 )) || fail "the built-in panel still uses the kernel backlight" "actual: $brightness" +pass "a platform without DDC channels skips DDC for a connector without one" + +rm -f "$runtime_dir/omarchy-brightness-display-ddc/"* +: >"$test_tmp/drm/card2-USB-1/ddc" +brightness=$(DDC_CONNECTOR=USB-1 run_brightness --monitor USB-1) +(( brightness == 50 )) || fail "an external monitor with a ddc node uses DDC" "actual: $brightness" +pass "a platform without DDC channels still uses DDC where a connector has one" + +# Without the directive, every external monitor is probed, ddc node or not. +rm -f "$runtime_dir/omarchy-brightness-display-ddc/"* "$test_tmp/drm/card2-USB-1/ddc" +for conf in "" "ddc-require-connector-ddc extra" "ddc-future"; do + printf '%s\n' "$conf" >"$platform_root/displays.conf" + rm -f "$runtime_dir/omarchy-brightness-display-ddc/"* + brightness=$(DDC_CONNECTOR=USB-1 run_brightness --monitor USB-1) + (( brightness == 50 )) || fail "an external monitor is probed without the directive" "conf: $conf, actual: $brightness" +done +rm -f "$platform_root/displays.conf" +pass "every external monitor is probed without the directive" diff --git a/test/shell.d/channel-sudo-boundary-test.sh b/test/shell.d/channel-sudo-boundary-test.sh index f98934c2..8ca9ab7e 100755 --- a/test/shell.d/channel-sudo-boundary-test.sh +++ b/test/shell.d/channel-sudo-boundary-test.sh @@ -69,6 +69,51 @@ for channel in stable rc edge dev; do pass "$channel starts cold, authorizes the switch per command, runs the refresh hook cold, hands off to one update authorization and exits cold" done +# Every aarch64 platform switches to edge and dev, copying its own templates. +for platform in aarch64 aarch64-apple; do + case $platform in + aarch64-apple) templates=default/pacman/aarch64-apple ;; + *) templates=default/pacman/aarch64 ;; + esac + for channel in edge dev; do + # dev refreshes from the checkout it links, on edge. + pacman_channel=$channel root=$SUDO_TEST_ROOT + [[ $channel != "dev" ]] || pacman_channel=edge root=$SUDO_TEST_HOME/omarchy + reset_boundary + SUDO_TEST_PLATFORM=$platform run_channel "$channel" || fail "$platform $channel failed" "$(<"$boundary_tmp/output")" + assert_scoped_channel "$platform $channel" + grep -Fqx "step:cp -f $root/$templates/pacman-$pacman_channel.conf /etc/pacman.conf" "$SUDO_TEST_LOG" || + fail "$platform $channel copies its own template" "$(<"$SUDO_TEST_LOG")" + done +done +pass "aarch64 platforms switch to edge and dev through their own templates" + +# stable and rc would install the release line, which has no aarch64 support: +# an aarch64 machine refuses them before anything changes. +for platform in aarch64 aarch64-apple; do + for channel in stable rc; do + reset_boundary + if SUDO_TEST_PLATFORM=$platform run_channel "$channel"; then fail "$platform accepted $channel"; fi + if grep -Eq '^step:|^sudo -N ' "$SUDO_TEST_LOG"; then fail "$platform $channel was refused before any change" "$(<"$SUDO_TEST_LOG")"; fi + grep -q "Omarchy has no $channel channel for $platform" "$boundary_tmp/output" || fail "$platform $channel: the refusal says why" "$(<"$boundary_tmp/output")" + assert_boundary_cold "$platform $channel" + done +done +pass "aarch64 platforms refuse stable and rc before any change" + +# A channel the platform has no template for stops before anything, the dev +# confirmation included. +mv "$SUDO_TEST_ROOT/default/pacman/aarch64-apple/pacman-edge.conf" "$boundary_tmp/saved-template" +for channel in edge dev; do + reset_boundary + if SUDO_TEST_PLATFORM=aarch64-apple run_channel "$channel"; then fail "a channel without a template was accepted ($channel)"; fi + if grep -Eq '^step:|^sudo -N ' "$SUDO_TEST_LOG"; then fail "$channel was refused before any change" "$(<"$SUDO_TEST_LOG")"; fi + grep -q "Omarchy has no edge channel for aarch64-apple" "$boundary_tmp/output" || fail "the refusal says why" "$(<"$boundary_tmp/output")" + assert_boundary_cold "missing template $channel" +done +mv "$boundary_tmp/saved-template" "$SUDO_TEST_ROOT/default/pacman/aarch64-apple/pacman-edge.conf" +pass "a channel without a template for the platform is refused before any change" + reset_boundary wrapper="$SUDO_TEST_HOME/omarchy/default/omarchy/sudo-no-update/sudo" mv "$wrapper" "$boundary_tmp/saved-wrapper" @@ -81,6 +126,40 @@ assert_boundary_cold "stale checkout" mv "$boundary_tmp/saved-wrapper" "$wrapper" pass "a stale dev checkout is rejected before linking or privileged work" +# On aarch64 the checkout's own refresh keeps the machine's repositories only +# once it tells platforms apart: a checkout without that is refused the same way. +checkout="$SUDO_TEST_HOME/omarchy" +for required in bin/omarchy-hw-platform install/helpers/pacman.sh; do + for platform in aarch64; do + reset_boundary + mv "$checkout/$required" "$boundary_tmp/saved-required" + if SUDO_TEST_PLATFORM=$platform run_channel dev; then fail "$platform: a dev checkout without $required was accepted"; fi + mv "$boundary_tmp/saved-required" "$checkout/$required" + if grep -Eq '^step:omarchy-(dev-link|state)|^step:pacman|^sudo -N ' "$SUDO_TEST_LOG"; then + fail "$platform: a dev checkout without $required changed the system before rejection" "$(<"$SUDO_TEST_LOG")" + fi + grep -q "Update the checkout before switching to dev; on $platform it needs $required" "$boundary_tmp/output" || + fail "$platform: the rejection names $required" "$(<"$boundary_tmp/output")" + assert_boundary_cold "$platform checkout without $required" + done +done +pass "on aarch64 a dev checkout that can't keep the machine's repositories is rejected before linking" + +# The checkout's own refresh copies its own templates, so one without them for +# this platform is refused before linking. +template=$checkout/default/pacman/aarch64/mirrorlist-edge +mv "$template" "$boundary_tmp/saved-template" +reset_boundary +if SUDO_TEST_PLATFORM=aarch64 run_channel dev; then fail "a dev checkout without its templates was accepted"; fi +mv "$boundary_tmp/saved-template" "$template" +if grep -Eq '^step:omarchy-(dev-link|state)|^step:pacman|^sudo -N ' "$SUDO_TEST_LOG"; then + fail "a dev checkout without its templates changed the system before rejection" "$(<"$SUDO_TEST_LOG")" +fi +grep -q "Update the checkout before switching to dev; it has no edge templates for aarch64" "$boundary_tmp/output" || + fail "the rejection names the missing templates" "$(<"$boundary_tmp/output")" +assert_boundary_cold "checkout without templates" +pass "a dev checkout without its templates for the platform is rejected before linking" + reset_boundary OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")" assert_scoped_channel "dev to stable" diff --git a/test/shell.d/default-apps-test.sh b/test/shell.d/default-apps-test.sh index d4aaa26a..b450c299 100755 --- a/test/shell.d/default-apps-test.sh +++ b/test/shell.d/default-apps-test.sh @@ -125,6 +125,9 @@ for setup_command in \ ln -s omarchy-test-setup-call "$mock_bin/$setup_command" done +# The real browser installer below calls the platform's post-install hook; on a +# Mac with the platform package installed that would run, so answer it here. +printf '#!/bin/bash\nexit 0\n' >"$mock_bin/omarchy-lifecycle-dispatch" chmod +x "$mock_bin"/* export HOME="$test_home" diff --git a/test/shell.d/desktop-entry-launch-test.sh b/test/shell.d/desktop-entry-launch-test.sh index c6014b10..16fe5a05 100644 --- a/test/shell.d/desktop-entry-launch-test.sh +++ b/test/shell.d/desktop-entry-launch-test.sh @@ -22,7 +22,7 @@ cat >"$mock_bin/omarchy-font-set" <<'SH' printf 'font:%s\n' "$*" >>"$OMARCHY_TEST_LOG" SH -for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32; do +for command in omarchy-pkg-aur-add omarchy-install-emacs omazed omarchy-theme-set-vscode omarchy-install-gaming-gpu-lib32 omarchy-lifecycle-dispatch; do cat >"$mock_bin/$command" <<'SH' #!/bin/bash exit 0 diff --git a/test/shell.d/dictation-test.sh b/test/shell.d/dictation-test.sh index f8793310..d70f1b90 100644 --- a/test/shell.d/dictation-test.sh +++ b/test/shell.d/dictation-test.sh @@ -212,11 +212,15 @@ pass "selection updates are atomic for concurrent readers" lua <<'LUA' local root = os.getenv("ROOT") -local configured, bindings = false, {} +local selected, installed, bindings = nil, true, {} +io.popen = function(command) + assert(command == "omarchy-default-dictation 2>/dev/null") + return { read = function() return selected end, close = function() end } +end o = { - shell_succeeds = function(command) - assert(command == "omarchy-default-dictation") - return configured + cmd_missing = function(command) + assert(command == selected) + return not installed end, bind = function(...) table.insert(bindings, {...}) end, bind_hold = function(keys, _, press, _, release, options) @@ -226,9 +230,9 @@ o = { } dofile(root .. "/default/hypr/bindings/dictation.lua") assert(#bindings == 0, "unconfigured dictation leaves application keys available") -configured = true +selected = "some-backend" dofile(root .. "/default/hypr/bindings/dictation.lua") -assert(#bindings == 5, "any configured backend receives the same bindings") +assert(#bindings == 5, "any installed backend receives the same bindings") local function binds(keys, command) for _, binding in ipairs(bindings) do if binding[1] == keys and binding[2] == command then return true end @@ -238,8 +242,11 @@ for _, keys in ipairs({ "F9", "ALT + Alt_R" }) do assert(binds(keys, "omarchy-dictation start") and binds(keys, "omarchy-dictation stop"), keys .. " starts dictation on press and stops it on release") end +bindings, installed = {}, false +dofile(root .. "/default/hypr/bindings/dictation.lua") +assert(#bindings == 0, "a selected backend that is not installed leaves application keys available") LUA -pass "dictation shortcuts require a selection without limiting backend names" +pass "dictation shortcuts require an installed selection without limiting backend names" # Fresh users receive the backend choice through the settings package's skel. cp "$ROOT/config/omarchy/defaults/dictation" "$config" @@ -247,6 +254,26 @@ cp "$ROOT/config/omarchy/defaults/dictation" "$config" printf '%s\n' voxtype > "$config" pass "fresh users get Superwhisper as their default backend" +# On aarch64, where Superwhisper has no build, user setup drops that preset; an +# owner's own selection, and every x86_64 preset, stay. +leaf_bin="$test_tmp/leaf-bin" +mkdir -p "$leaf_bin" +printf '#!/bin/bash\n[[ ${TEST_ARCH:-aarch64} == x86_64 ]]\n' >"$leaf_bin/omarchy-hw-x86" +printf '#!/bin/bash\n[[ -z ${TEST_HAS_SUPERWHISPER:-} ]]\n' >"$leaf_bin/omarchy-cmd-missing" +chmod +x "$leaf_bin"/* +run_leaf() { env "$@" PATH="$leaf_bin:$PATH" bash -c 'source "$1"' bash "$ROOT/install/user/dictation-default.sh"; } +cp "$ROOT/config/omarchy/defaults/dictation" "$config" +run_leaf +[[ ! -e $config ]] || fail "aarch64 user setup drops the Superwhisper preset" +printf '%s\n' voxtype >"$config" +run_leaf +[[ $(<"$config") == "voxtype" ]] || fail "aarch64 user setup keeps a selection someone made" +cp "$ROOT/config/omarchy/defaults/dictation" "$config" +run_leaf TEST_ARCH=x86_64 +[[ $(<"$config") == "superwhisper" ]] || fail "x86_64 keeps the Superwhisper preset" +printf '%s\n' voxtype >"$config" +pass "aarch64 new users start with no dictation backend instead of an unbuildable one" + lua <<'LUA' local root = os.getenv("ROOT") package.path = root .. "/?.lua;" .. package.path diff --git a/test/shell.d/drive-password-test.sh b/test/shell.d/drive-password-test.sh index 7cd32b17..7479a598 100644 --- a/test/shell.d/drive-password-test.sh +++ b/test/shell.d/drive-password-test.sh @@ -4,46 +4,828 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -tmp_dir=$(mktemp -d) -trap 'rm -r "$tmp_dir"' EXIT +# omarchy-drive-password against fake drives. findmnt and lsblk describe a +# system drive under / and a data drive, and blkid has the empty cache a user +# sees until root runs blkid in that boot; cryptsetup is either a slot-table fake +# or the real binary on file-backed volumes; a chpasswd fake shows the login and +# root passwords never change. Each cryptsetup call is a crash point, so a run +# can be killed after every step and rerun, like a power loss. The runs repeat on an Apple fixture, +# where a fake boot package records the owner's slot through the real +# omarchy-lifecycle-dispatch; on the x86 fixture that is a no-op. -cat >"$tmp_dir/blkid" <<'EOF' -#!/bin/bash -echo /dev/test-luks -EOF - -cat >"$tmp_dir/gum" <<'EOF' -#!/bin/bash -head -n 1 "$TEST_INPUTS" -sed -i '1d' "$TEST_INPUTS" -EOF - -cat >"$tmp_dir/sudo" <<'EOF' -#!/bin/bash -printf '%s\n' "$@" >"$TEST_ARGS" -cat >"$TEST_STDIN" -EOF - -chmod +x "$tmp_dir/blkid" "$tmp_dir/gum" "$tmp_dir/sudo" -export PATH="$tmp_dir:$ROOT/bin:$PATH" -export TEST_ARGS="$tmp_dir/args" TEST_INPUTS="$tmp_dir/inputs" TEST_STDIN="$tmp_dir/stdin" - -printf '\n' >"$TEST_INPUTS" -if "$ROOT/bin/omarchy-drive-password" >/dev/null; then - fail "drive password rejects an empty passphrase" +# Root's dispatcher ignores the fixtures and sees this machine. +if (( EUID == 0 )) && [[ $("$ROOT/bin/omarchy-hw-platform") == "aarch64-apple" ]]; then + skip "running as root on Apple Silicon, where dispatch ignores fixtures; skipping" + exit 0 fi -[[ ! -e $TEST_ARGS ]] || fail "drive password does not run cryptsetup for an empty passphrase" -printf 'secret123\n*\n' >"$TEST_INPUTS" -if "$ROOT/bin/omarchy-drive-password" >/dev/null; then - fail "drive password rejects a mismatched confirmation" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +old_password=old-password +new_password='new pass:word' +recovery_key=recovery-passphrase +data_password=data-password +journal=$tmp/state/omarchy/drive-password.state + +mkdir -p "$tmp/bin" "$tmp/real" "$tmp/dev" + +cat >"$tmp/crash.sh" <<'SH' +crash_point() { + local count + count=$(( $(cat "$TEST_TMP/steps") + 1 )) + echo "$count" >"$TEST_TMP/steps" + printf '%s %s\n' "$count" "$1" >>"$TEST_TMP/trace" + if (( count == ${CRASH_AT:-0} )); then + kill -9 "$TEST_PID" + # An orphan outlives the script for a moment, like a cryptsetup child. + if [[ -n ${CRASH_ORPHAN:-} ]]; then sleep 1; else kill -9 $$; fi + fi +} +SH + +cat >"$tmp/bin/blkid" <<'SH' +#!/bin/bash +exit 2 +SH + +cat >"$tmp/bin/findmnt" <<'SH' +#!/bin/bash +[[ $* == "-no SOURCE /" && -z ${TEST_FINDMNT_FAIL:-} ]] || exit 1 +echo '/dev/mapper/root[/@]' +SH + +cat >"$tmp/bin/lsblk" <<'SH' +#!/bin/bash +if [[ $1 == "-dno" && $2 == "UUID" ]]; then + cat "$3.uuid" + exit fi -[[ ! -e $TEST_ARGS ]] || fail "drive password does not run cryptsetup for a mismatched confirmation" +if [[ $* == "-nrpo NAME,FSTYPE" ]]; then + echo "/dev/fake-disk " + while IFS= read -r drive; do + printf '%s crypto_LUKS\n/dev/mapper/%s btrfs\n' "$drive" "${drive##*/}" + done <"$TEST_TMP/drives" + exit +fi +[[ $* == "-nsrpo NAME,TYPE,FSTYPE /dev/mapper/root" ]] || exit 98 +cat "$TEST_TMP/root-ancestry" +SH -printf 'new password\nnew password\n' >"$TEST_INPUTS" -"$ROOT/bin/omarchy-drive-password" >/dev/null +cat >"$tmp/bin/omarchy-drive-select" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >"$TEST_TMP/offered" +cat "$TEST_TMP/select" +SH -[[ $(<"$TEST_STDIN") == "new password" ]] || fail "drive password passes the validated passphrase without a newline" -grep -F 'cryptsetup luksChangeKey' "$TEST_ARGS" >/dev/null || fail "drive password changes the LUKS key" -grep -Fx /dev/test-luks "$TEST_ARGS" >/dev/null || fail "drive password targets the selected drive" -pass "drive password rejects empty and mismatched passphrases and passes validated input to cryptsetup" +cat >"$tmp/bin/gum" <<'SH' +#!/bin/bash +[[ $1 == "input" ]] || exit 97 +printf '%s\n' "$*" >>"$TEST_TMP/prompts" +[[ -s $TEST_TMP/inputs ]] || exit 130 +# Another key enrolled while the owner types the new password. +if [[ -n ${TEST_ENROLL_AT_PROMPT:-} && $* == *"$TEST_ENROLL_AT_PROMPT"* ]]; then + printf '3\tlater-key\n' >>"$TEST_ENROLL_DEVICE.slots" +fi +head -n 1 "$TEST_TMP/inputs" +sed -i '1d' "$TEST_TMP/inputs" +SH + +# Runs as the caller, closing every descriptor above stderr as sudo does. +cat >"$tmp/bin/sudo" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_TMP/sudo-calls" +for fd in /proc/$$/fd/*; do + fd=${fd##*/} + (( fd > 2 && fd != 255 )) && eval "exec $fd>&-" 2>/dev/null +done +exec "$@" +SH + +cat >"$tmp/bin/chpasswd" <<'SH' +#!/bin/bash +source "$TEST_TMP/crash.sh" +IFS= read -r line +name=${line%%:*} +{ + grep -v "^$name " "$TEST_TMP/accounts" || true + printf '%s\t%s\n' "$name" "${line#*:}" +} >"$TEST_TMP/accounts.next" +mv -f "$TEST_TMP/accounts.next" "$TEST_TMP/accounts" +SH + +# A LUKS1-style volume as a table of "slotpassphrase" lines next to the +# device: luksChangeKey adds the new key to a free slot, then removes the old, +# and luksKillSlot needs a key that opens another slot, as cryptsetup does. +cat >"$tmp/bin/cryptsetup" <<'SH' +#!/bin/bash +source "$TEST_TMP/crash.sh" + +slot_of() { + local slot key + while IFS=$'\t' read -r slot key; do + [[ $key == "$1" && $slot != "${2:-}" ]] && { echo "$slot"; return; } + done <"$slots" +} + +op=$1 +shift +key_file="" token_type="" key_slot="" positional=() +while (( $# )); do + case $1 in + --key-file) key_file=$2; shift 2 ;; + --token-type) token_type=$2; shift 2 ;; + --key-slot) key_slot=$2; shift 2 ;; + --pbkdf | --iter-time) shift 2 ;; + -*) shift ;; + *) positional+=("$1"); shift ;; + esac +done +slots=${positional[0]}.slots +[[ -f $slots ]] || { echo "Device ${positional[0]} does not exist or access denied."; exit 4; } +if [[ $key_file == "-" ]]; then key=$(cat); elif [[ -n $key_file ]]; then key=$(cat "$key_file"); fi + +[[ $op != "isLuks" ]] || exit 1 +if [[ $op == "luksDump" ]]; then + echo "Keyslots:" + cut -f1 "$slots" | sed 's/^/ /; s/$/: luks2/' + [[ -z ${TEST_TOKEN_SLOT:-} ]] || printf 'Tokens:\n 0: luks2-keyring\n\tKeyslot: %s\n' "$TEST_TOKEN_SLOT" + printf 'Digests:\n 0: pbkdf2\n' + exit 0 +fi + +crash_point "cryptsetup $op" +[[ $op != "open" || -z ${TEST_OPEN_FAIL:-} ]] || exit 1 +# An enrolled token unlocks its slot whatever key is given, unless the allowed +# token types exclude it, as with cryptsetup. +if [[ $op == "open" && -z $token_type && -n ${TEST_TOKEN_SLOT:-} ]]; then + echo "Key slot $TEST_TOKEN_SLOT unlocked." + exit 0 +fi +target="" +[[ $op == "luksKillSlot" ]] && target=${positional[1]} +if [[ $op == "open" && -n $key_slot ]]; then + grep -qxF "$key_slot"$'\t'"$key" "$slots" || { echo "No key available with this passphrase."; exit 2; } + echo "Key slot $key_slot unlocked." + exit 0 +fi +slot=$(slot_of "$key" "$target") +[[ -n $slot ]] || { echo "No key available with this passphrase."; exit 2; } + +case $op in + open) echo "Key slot $slot unlocked." ;; + luksChangeKey) + [[ -z ${TEST_CHANGE_FAIL:-} ]] || exit 1 + for (( next = 0; next < 32; next++ )); do + cut -f1 "$slots" | grep -qx "$next" || break + done + printf '%s\t%s\n' "$next" "$(cat "${positional[1]}")" >>"$slots" + crash_point "new key added" + [[ -z ${TEST_CHANGE_PARTIAL:-} ]] || exit 137 + awk -F'\t' -v s="$slot" '$1 != s' "$slots" >"$slots.next" + mv -f "$slots.next" "$slots" + crash_point "old key removed" + ;; + luksKillSlot) + [[ -z ${TEST_KILL_FAIL:-} ]] || exit 1 + awk -F'\t' -v s="$target" '$1 != s' "$slots" >"$slots.next" + mv -f "$slots.next" "$slots" + crash_point "slot $target killed" + ;; + *) exit 1 ;; +esac +SH + +# The real cryptsetup, keeping the command's KDF but at a fast cost, counting +# the same crash points. +cat >"$tmp/real/cryptsetup" <<'SH' +#!/bin/bash +source "$TEST_TMP/crash.sh" +args=() +while (( $# )); do + case $1 in + --iter-time) shift 2 ;; + *) args+=("$1"); shift ;; + esac +done +if [[ ${args[0]} == "luksChangeKey" ]]; then + if [[ " ${args[*]} " == *" argon2id "* ]]; then + args+=(--pbkdf-force-iterations 4 --pbkdf-memory 32768) + else + args+=(--pbkdf-force-iterations 1000) + fi +fi +[[ ${args[0]} == "luksDump" || ${args[0]} == "isLuks" ]] || crash_point "cryptsetup ${args[0]}" +[[ ${args[0]} != "open" || -z ${TEST_OPEN_FAIL:-} ]] || exit 1 +status=0 +"$REAL_CRYPTSETUP" "${args[@]}" || status=$? +if (( status == 0 )) && [[ ${args[0]} == "luksChangeKey" || ${args[0]} == "luksKillSlot" ]]; then + crash_point "${args[0]} done" +fi +exit "$status" +SH + +chmod +x "$tmp"/bin/* "$tmp/real/cryptsetup" + +# The boot package's luks-slots, run by dispatch with an empty environment: it +# records its arguments and is a crash point like the rest. +fake_platform "$tmp/x86" x86 +fake_platform "$tmp/apple" aarch64-apple +mkdir -p "$tmp/lifecycle/usr/lib/omarchy/mac-boot" +cat >"$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" <&2; exit 1; } + [[ ! -e $tmp/owner-query-garbage ]] || { echo "slot zero"; exit 0; } + [[ ! -e $tmp/owner-slot ]] || { cat "$tmp/owner-slot"; exit 0; } + [[ ! -e $tmp/owner-unrecorded ]] || { echo "No owner key slot is recorded." >&2; exit 4; } + echo 0 + exit 0 +fi +[[ ! -e $tmp/record-fail ]] || exit 1 +printf '%s\n' "\$*" >>"$tmp/slot-record" +[[ \$1 != owner=* ]] || echo "\${1#owner=}" >"$tmp/owner-slot" +count=\$(( \$(cat "$tmp/steps") + 1 )) +echo "\$count" >"$tmp/steps" +printf '%s slots recorded\n' "\$count" >>"$tmp/trace" +if (( count == \$(cat "$tmp/crash-at") )); then + kill -9 "\$PPID" + kill -9 \$\$ +fi +SH +chmod 755 "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" +chmod -R go-w "$tmp/lifecycle" + +export TEST_TMP=$tmp OMARCHY_PATH=$ROOT XDG_STATE_HOME=$tmp/state SUDO_USER=owner OMARCHY_LIFECYCLE_ROOT=$tmp/lifecycle +base_path=$PATH +platform=x86 + +# The command's PATH for this backend on this platform fixture. +use() { + backend=$1 platform=$2 + export OMARCHY_PROC_ROOT=$tmp/$platform/proc + if [[ $backend == "fake" ]]; then + export PATH="$tmp/$platform/bin:$tmp/bin:$ROOT/bin:$base_path" + else + export PATH="$tmp/$platform/bin:$tmp/real:$tmp/bin:$ROOT/bin:$base_path" + fi +} +data=$tmp/dev/data + +# The slot the key opens, or nothing. +opens() { + local device=$1 key=$2 slot k + if [[ $backend == "fake" ]]; then + while IFS=$'\t' read -r slot k; do + [[ $k == "$key" ]] && { echo "$slot"; return; } + done <"$device.slots" + else + LC_ALL=C "$REAL_CRYPTSETUP" open --test-passphrase --verbose --key-file <(printf '%s' "$key") "$device" 2>&1 | + grep -o 'Key slot [0-9]* unlocked' | grep -o '[0-9]*' || true + fi +} + +account() { + awk -F'\t' -v n="$1" '$1 == n { print $2 }' "$tmp/accounts" +} + +volume() { + local device=$1 key=$2 extra=${3:-} + echo "uuid-$(basename "$device")" >"$device.uuid" + if [[ $backend == "fake" ]]; then + : >"$device" + printf '0\t%s\n' "$key" >"$device.slots" + [[ -z $extra ]] || printf '1\t%s\n' "$extra" >>"$device.slots" + else + rm -f "$device" + truncate -s 32M "$device" + "$REAL_CRYPTSETUP" luksFormat -q --type "$backend" --pbkdf pbkdf2 --pbkdf-force-iterations 1000 "$device" <(printf '%s' "$key") + if [[ -n $extra ]]; then + "$REAL_CRYPTSETUP" luksAddKey --pbkdf pbkdf2 --pbkdf-force-iterations 1000 --key-file <(printf '%s' "$key") "$device" <(printf '%s' "$extra") + fi + fi +} + +# / on the system drive, which also holds a recovery key; a data drive beside it. +fixture() { + rm -rf "$tmp/state" "$tmp/output" "$tmp/trace" "$tmp/offered" "$tmp"/dev/* "$tmp/slot-record" "$tmp/record-fail" "$tmp/owner-slot" "$tmp/owner-query-fail" "$tmp/owner-query-garbage" "$tmp/owner-unrecorded" + unset TEST_ENROLL_AT_PROMPT TEST_ENROLL_DEVICE TEST_OPEN_FAIL TEST_CHANGE_FAIL TEST_CHANGE_PARTIAL TEST_KILL_FAIL TEST_FINDMNT_FAIL TEST_TOKEN_SLOT CRASH_ORPHAN + system=$tmp/dev/system + recovery=${1-$recovery_key} + volume "$system" "$old_password" "$recovery" + volume "$data" "$data_password" + printf 'owner\t%s\nroot\t%s\n' "$old_password" "$old_password" >"$tmp/accounts" + printf '%s\n%s\n' "$system" "$data" >"$tmp/drives" + printf '/dev/mapper/root crypt btrfs\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry" + echo "$system" >"$tmp/select" + : >"$tmp/prompts" + : >"$tmp/sudo-calls" +} + +# One run of the command: an optional crash step, then answers for its prompts. +attempt() { + local crash_at=$1 + shift + if (( $# )); then printf '%s\n' "$@" >"$tmp/inputs"; else : >"$tmp/inputs"; fi + echo 0 >"$tmp/steps" + echo "$crash_at" >"$tmp/crash-at" + : >"$tmp/trace" + { + CRASH_AT=$crash_at bash -c 'TEST_PID=$$ exec bash ${TEST_TRACE:+-x} "$0"' "$ROOT/bin/omarchy-drive-password" + } >>"$tmp/output" 2>&1 +} + +consistent() { + local context=$1 password=$2 other=$old_password + [[ $password == "$old_password" ]] && other=$new_password + untouched_accounts "$context" + [[ -n $(opens "$system" "$password") ]] || fail "$backend: $context: the disk opens with that password" "$(cat "$tmp/output")" + [[ -z $(opens "$system" "$other") ]] || fail "$backend: $context: the other password no longer opens the disk" "$(cat "$tmp/trace" "$tmp/output")" + [[ -z $recovery || -n $(opens "$system" "$recovery") ]] || fail "$backend: $context: the recovery key still opens the disk" + [[ ! -e $journal ]] || fail "$backend: $context: the journal is gone" "$(cat "$journal")" + recorded "$context" "$password" +} + +# On Apple the boot package holds the slot the owner's password opens: recorded +# whenever the key moved. x86 records nothing and runs no extra sudo. +recorded() { + local context=$1 password=$2 slot + if [[ $platform == "apple" ]]; then + slot=$(opens "$system" "$password") + if [[ -s $tmp/slot-record ]]; then + [[ $(tail -n 1 "$tmp/slot-record") == "owner=$slot" ]] || + fail "$backend: apple: $context: the owner's slot is recorded" "$(cat "$tmp/slot-record")" + else + [[ $slot == "0" ]] || fail "$backend: apple: $context: a key that moved is recorded" "$(cat "$tmp/trace")" + fi + else + [[ ! -e $tmp/slot-record ]] && ! grep -q 'lifecycle-dispatch' "$tmp/sudo-calls" || + fail "$backend: x86: $context: no slot is recorded and no dispatch runs under sudo" + fi +} + +# The disk password never changes the login or root password. +untouched_accounts() { + [[ $(account owner) == "$old_password" && $(account root) == "$old_password" ]] && ! grep -q 'chpasswd' "$tmp/sudo-calls" || + fail "$backend: $1: the login and root passwords stay as they were" "$(cat "$tmp/accounts" "$tmp/sudo-calls")" +} + +said() { + grep -Fq "$1" "$tmp/output" || fail "$backend: the command says: $1" "$(cat "$tmp/output")" +} + +no_secrets() { + local file + for file in "$journal" "$tmp/sudo-calls" "$tmp/prompts"; do + [[ -e $file ]] || continue + ! grep -Fq -e "$old_password" -e "$new_password" -e "$recovery_key" -e "$data_password" "$file" || + fail "$backend: $1: $(basename "$file") holds no password" "$(cat "$file")" + done +} + +# Between runs the disk opens with the old or the new password, and the +# accounts never change. +recoverable() { + local context=$1 + [[ -n $(opens "$system" "$old_password") || -n $(opens "$system" "$new_password") ]] || + fail "$backend: $context: the disk opens with the old or the new password" "$(cat "$tmp/trace")" + untouched_accounts "$context" + [[ ! -e $journal || $(stat -c %a "$journal") == "600" ]] || fail "$backend: $context: the journal is private" + no_secrets "$context" +} + +backends=(fake) +if REAL_CRYPTSETUP=$(command -v cryptsetup); then + export REAL_CRYPTSETUP + backends+=(luks2 luks1) +else + skip "cryptsetup is not installed; skipping the file-backed volume runs" +fi + +matrix=() +for backend in "${backends[@]}"; do + matrix+=("$backend x86" "$backend apple") +done + +for run_spec in "${matrix[@]}"; do + use $run_spec + + fixture + attempt 0 "$old_password" "$new_password" "$new_password" || fail "$backend: changing the system disk password succeeds" "$(cat "$tmp/output")" + consistent "uninterrupted" "$new_password" + [[ $(opens "$data" "$data_password") == "0" ]] || fail "$backend: the data drive is untouched" + ! grep -Fq 'login password' "$tmp/output" || fail "$backend: the command does not mention the login password" "$(cat "$tmp/output")" + no_secrets "uninterrupted" + total_steps=$(cat "$tmp/steps") + (( total_steps >= 4 )) || fail "$backend: every change is a crash point" "$(cat "$tmp/trace")" + pass "$backend: the system disk key changes and verifies, and the login and root passwords stay as they were" + + fixture + if attempt 0 "not-the-password" "$new_password" "$new_password"; then fail "$backend: a wrong current password fails the command"; fi + said "That password does not open $system." + ! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "$backend: a wrong current password changes nothing" + [[ -n $(opens "$system" "$old_password") && $(account owner) == "$old_password" ]] || fail "$backend: a wrong current password leaves the disk and accounts" + pass "$backend: a wrong current password is refused before anything changes" + + fixture "" + attempt 0 "$old_password" "$new_password" "$new_password" || fail "$backend: a single-slot system disk changes" "$(cat "$tmp/output")" + consistent "single slot" "$new_password" + pass "$backend: a disk with one key slot keeps it through the change" + + fixture + echo "$data" >"$tmp/select" + attempt 0 "$data_password" "$new_password" "$new_password" || fail "$backend: changing a data drive password succeeds" "$(cat "$tmp/output")" + [[ -n $(opens "$data" "$new_password") && -z $(opens "$data" "$data_password") ]] || fail "$backend: the data drive takes the new password" + untouched_accounts "data drive" + [[ -n $(opens "$system" "$old_password") ]] || fail "$backend: a data drive change leaves the system disk alone" + [[ ! -e $journal ]] || fail "$backend: a data drive change keeps no journal" + pass "$backend: changing a non-root encrypted drive's password leaves the system disk alone" + + # Killed after each step, the owner runs the command again and answers with a + # password that opens the disk now: the new one, or the old one while it still + # works. Each rerun is itself killed after each of its steps until one finishes. + for (( step = 1; step <= total_steps; step++ )); do + for answer in "$new_password" "$old_password"; do + fixture + if attempt "$step" "$old_password" "$new_password" "$new_password"; then fail "$backend: the run is killed at step $step"; fi + point=$(sed -n "${step}p" "$tmp/trace") + recoverable "killed after '$point'" + if [[ ! -e $journal ]]; then + if [[ -n $(opens "$system" "$new_password") ]]; then + consistent "killed after '$point'" "$new_password" + else + consistent "killed after '$point'" "$old_password" + fi + continue + fi + [[ -n $(opens "$system" "$answer") ]] || continue + if grep -qx 'phase=record' "$journal" && [[ $answer == "$old_password" ]]; then + if attempt 0 "$answer"; then fail "$backend: after '$point' a confirmed change refuses the old password"; fi + [[ -e $journal ]] || fail "$backend: after '$point' a refused rerun keeps the journal" + answer=$new_password + fi + for (( again = 1; ; again++ )); do + status=0 + attempt "$again" "$answer" || status=$? + (( status == 0 )) && break + (( status == 137 )) || fail "$backend: the rerun after '$point' finishes" "$(cat "$tmp/output")" + recoverable "rerun after '$point' killed at step $again" + done + consistent "rerun with $answer after '$point'" "$answer" + no_secrets "rerun after '$point'" + done + done + pass "$backend $platform: killed after each of $total_steps steps, and each rerun after each of its own, the disk ends on one password and the accounts never change" +done + +use fake x86 + +fixture +printf '%s\n%s\n%s\n' "$system" "$data" "$data" >"$tmp/drives" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a fresh boot finds the system disk without a blkid cache" "$(cat "$tmp/output")" +[[ $(cat "$tmp/offered") == "$system"$'\n'"$data" ]] || fail "each LUKS drive lsblk names is offered once" "$(cat "$tmp/offered")" +consistent "fresh boot" "$new_password" +pass "before root has run blkid, every LUKS drive is offered once and the system disk changes" + +fixture +printf '5\t%s\n' tpm-sealed-key >>"$system.slots" +export TEST_TOKEN_SLOT=5 +if attempt 0 "not-the-password" "$new_password" "$new_password"; then fail "an enrolled token does not stand in for the current password"; fi +said "That password does not open $system." +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a disk with an enrolled token changes its password" "$(cat "$tmp/output")" +consistent "beside an enrolled token" "$new_password" +[[ $(opens "$system" tpm-sealed-key) == "5" ]] || fail "the token's slot is left alone" +pass "an enrolled token never answers for a password, and its slot is left alone" + +fixture +export TEST_CHANGE_FAIL=1 +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed luksChangeKey fails the command"; fi +[[ -n $(opens "$system" "$old_password") && ! -e $journal ]] || fail "a failed luksChangeKey leaves the disk and no journal" +said "The disk password did not change." +pass "a failed LUKS change leaves the disk on its old password" + +fixture +export TEST_CHANGE_PARTIAL=1 +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a luksChangeKey that dies after adding the new key is finished" "$(cat "$tmp/output")" +consistent "luksChangeKey died after adding the new key" "$new_password" +pass "a luksChangeKey that dies between keys has its old key retired" + +fixture +export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1 +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed retirement fails the command"; fi +[[ -e $journal ]] || fail "a failed retirement keeps the journal" +unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL +if attempt 0 "$recovery_key"; then fail "an unconfirmed change refuses the recovery key"; fi +said "That password does not finish the change" +[[ -e $journal && -n $(opens "$system" "$recovery_key") ]] || fail "refusing the recovery key changes nothing" +attempt 0 "$new_password" || fail "the rerun retires the old key and finishes" "$(cat "$tmp/output")" +consistent "rerun after a failed retirement" "$new_password" +pass "before the change is confirmed a rerun refuses the recovery key and retires the old key" + +fixture +export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1 +attempt 0 "$old_password" "$new_password" "$new_password" || true +unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL +attempt 0 "$old_password" || fail "the rerun with the old password rolls back" "$(cat "$tmp/output")" +consistent "rollback with the old password" "$old_password" +pass "a rerun with the old password removes the half-added new key" + +# On Apple the slot record follows the confirmed change: a crash there leaves +# the confirmed phase. +use fake apple +fixture +attempt 0 "$old_password" "$new_password" "$new_password" +record_step=$(grep -n 'slots recorded' "$tmp/trace" | cut -d: -f1) +fixture +attempt "$record_step" "$old_password" "$new_password" "$new_password" || true +grep -qx 'phase=record' "$journal" || fail "a crash at the slot record leaves the confirmed phase" "$(cat "$journal" "$tmp/trace")" +if attempt 0 "$recovery_key"; then fail "a confirmed change refuses the recovery key"; fi +said "That password does not finish the change" +[[ -e $journal ]] || fail "refusing the recovery key keeps the journal" +if attempt 0 "not-the-password"; then fail "a rerun refuses a password that opens nothing"; fi +for suffix in "" .slots .uuid; do + mv "$system$suffix" "$tmp/dev/renumbered$suffix" +done +system=$tmp/dev/renumbered +printf '/dev/mapper/root crypt btrfs\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry" +attempt 0 "$new_password" || fail "the confirmed change finishes on a renumbered disk" "$(cat "$tmp/output")" +consistent "rerun after a confirmed change" "$new_password" +pass "apple: once the new key is confirmed only its slot finishes the change, on the disk found by UUID" + +fixture +export TEST_OPEN_FAIL=1 +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a disk that cannot be checked fails the command"; fi +said "Could not check the password against $system." +! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a disk that cannot be checked is not changed" +pass "the current password is checked before anything changes" + +fixture +attempt "$record_step" "$old_password" "$new_password" "$new_password" || true +export TEST_FINDMNT_FAIL=1 +if attempt 0 "$new_password"; then fail "a run that cannot find / fails"; fi +said "Could not tell which drive holds the system." +[[ -e $journal ]] || fail "a run that cannot find / keeps the journal" +unset TEST_FINDMNT_FAIL +attempt 0 "$new_password" || fail "the rerun finishes once / is found" "$(cat "$tmp/output")" +consistent "rerun after a failed root lookup" "$new_password" +pass "apple: a failed root lookup changes nothing and keeps the journal" + +fixture +attempt "$record_step" "$old_password" "$new_password" "$new_password" || true +sed -i 's/^phase=record$/phase=accounts/' "$journal" +grep -qx 'phase=accounts' "$journal" || fail "the journal is rewritten as an earlier version wrote it" "$(cat "$journal")" +if attempt 0 "$old_password"; then fail "an earlier version's confirmed change refuses the old password"; fi +attempt 0 "$new_password" || fail "an earlier version's confirmed change finishes" "$(cat "$tmp/output")" +consistent "journal from an earlier version" "$new_password" +pass "apple: a confirmed change journaled by an earlier version records its slot and leaves the accounts alone" +use fake x86 + +fixture +attempt 0 "$old_password" "$new_password" "$new_password" +change_step=$(grep -n 'cryptsetup luksChangeKey' "$tmp/trace" | cut -d: -f1) +fixture +export CRASH_ORPHAN=1 +attempt "$change_step" "$old_password" "$new_password" "$new_password" || true +unset CRASH_ORPHAN +if attempt 0 "$old_password"; then fail "a rerun waits for a luksChangeKey the killed run left behind"; fi +attempt 0 "$new_password" || fail "the rerun finishes after the orphaned change" "$(cat "$tmp/output")" +consistent "orphaned luksChangeKey" "$new_password" +pass "a luksChangeKey that outlives its run finishes before the rerun reads the disk" + +fixture +printf '/dev/mapper/root crypt btrfs\n%s part \n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a crypt layer without a named LUKS partition fails"; fi +said "Could not tell which drive holds the system." +! grep -q 'luksChangeKey' "$tmp/sudo-calls" || fail "an unresolved crypt layer changes nothing" +pass "a crypt layer whose LUKS partition cannot be named fails closed" + +fixture +mkdir -p "${journal%/*}" +printf 'uuid=uuid-system\nold_slot=0\nslots=\nphase=luks\n' >"$journal" +if attempt 0 "$recovery_key"; then fail "a journal without slots refuses to finish"; fi +said "is unreadable" +[[ -e $journal && -n $(opens "$system" "$old_password") ]] || fail "an unreadable journal changes nothing" +pass "a journal without its slot list is refused rather than trusted" + +fixture +export TEST_CHANGE_PARTIAL=1 TEST_KILL_FAIL=1 +attempt 0 "$old_password" "$new_password" "$new_password" || true +unset TEST_CHANGE_PARTIAL TEST_KILL_FAIL +printf '3\tlater-key\n' >>"$system.slots" +if attempt 0 "$new_password"; then fail "a disk that gained two slots refuses to finish"; fi +said "changed since the interrupted change" +[[ -e $journal && -n $(opens "$system" "later-key") && -n $(opens "$system" "$old_password") ]] || fail "a disk that gained two slots is left alone" +pass "a rerun never removes a slot enrolled after the interrupted change" + +fixture +mkdir -p "${journal%/*}" +exec {held}>"${journal%/*}/drive-password.lock" +flock -n "$held" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a second run waits for the first"; fi +exec {held}>&- +grep -Fq 'Another drive password change is running.' "$tmp/output" && ! grep -q 'luksChangeKey' "$tmp/sudo-calls" || + fail "a second run changes nothing while another holds the lock" +pass "two runs never interleave" + +fixture +export TEST_TRACE=1 +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a traced run succeeds" "$(cat "$tmp/output")" +unset TEST_TRACE +! grep -Fq -e "$old_password" -e "$new_password" "$tmp/output" || fail "tracing never shows a password" "$(cat "$tmp/output")" +pass "bash -x does not trace passwords" + +fixture +printf '/dev/mapper/root lvm btrfs\n/dev/mapper/cryptlvm crypt LVM2_member\n%s part crypto_LUKS\n/dev/fake-disk disk \n' "$system" >"$tmp/root-ancestry" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "an LVM-on-LUKS system disk changes" "$(cat "$tmp/output")" +consistent "LVM on LUKS" "$new_password" +pass "the system disk is found beneath an LVM volume group" + +fixture +printf '/dev/mapper/root part ext4\n/dev/fake-disk disk \n' >"$tmp/root-ancestry" +echo "$data" >"$tmp/drives" +attempt 0 "$data_password" "$new_password" "$new_password" || fail "an unencrypted root still changes a data drive" "$(cat "$tmp/output")" +[[ -n $(opens "$data" "$new_password") ]] || fail "an unencrypted root still changes the data drive" +untouched_accounts "unencrypted root" +pass "with / unencrypted, every encrypted drive changes alone" + +fixture +mkdir -p "${journal%/*}" +printf 'uuid=uuid-elsewhere\nphase=record\nslot=0\n' >"$journal" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a journal for another disk does not block a change" "$(cat "$tmp/output")" +consistent "stale journal" "$new_password" +pass "a journal for a disk that is not / is dropped" + +fixture +if attempt 0 "$old_password" ""; then fail "an empty password is refused"; fi +said "Password cannot be empty." +if attempt 0 "$old_password" "secret123" "*"; then fail "a mismatched confirmation is refused"; fi +said "Passwords do not match." +if attempt 0 "$old_password" "$old_password" "$old_password"; then fail "the current password is refused as the new one"; fi +said "The new password is the current one." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || + fail "refused passwords change nothing" +pass "drive password rejects empty, mismatched and unchanged passphrases before changing anything" + +# A change interrupted part way is finished from the one slot the password +# opened, so a system disk whose password is in two slots is refused before +# anything changes: the other copy would outlive the change. +use fake x86 +fixture "$old_password" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a password in two slots is refused"; fi +said "That password also opens key slot 1 on $system" +said "The system disk password did not change." +! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a password in two slots changes nothing" +[[ $(opens "$system" "$old_password") == "0" ]] && grep -qxF $'1\t'"$old_password" "$system.slots" || fail "both copies are left as they were" +pass "a system disk whose password opens two key slots is refused before anything changes" + +# A change journaled by an earlier version did not check the old password for +# other slots. With other slots on the disk a copy of it may have outlived the +# change, and no password entered now can show whose slot is whose, so it is +# left for the owner to settle by hand, in every phase. With one slot it settles. +for legacy_phase in luks record accounts; do + fixture "" + printf '0\t%s\n2\t%s\n' "$new_password" "$old_password" >"$system.slots" + mkdir -p "${journal%/*}" + printf 'uuid=uuid-system\nold_slot=0\nslots=0,2\nslot=0\nphase=%s\n' "$legacy_phase" >"$journal" + if attempt 0 "$new_password" "$old_password"; then fail "an earlier version's $legacy_phase change on a disk with other slots is not settled"; fi + said "may still hold a copy of the old password" + said "Settle it by hand" + ! grep -q 'luksKillSlot\|luksChangeKey' "$tmp/sudo-calls" && [[ -e $journal && -n $(opens "$system" "$old_password") ]] || + fail "an earlier version's $legacy_phase change is left as it was" "$(cat "$tmp/sudo-calls")" +done +fixture "" +printf '0\t%s\n' "$new_password" >"$system.slots" +mkdir -p "${journal%/*}" +printf 'uuid=uuid-system\nold_slot=0\nslots=0\nphase=luks\n' >"$journal" +attempt 0 "$new_password" || fail "an earlier version's change on a one-slot disk settles" "$(cat "$tmp/output")" +[[ ! -e $journal && $(opens "$system" "$new_password") == "0" ]] || fail "the one-slot change is settled" +pass "an earlier version's interrupted change is left to the owner when other slots remain, and settles on a one-slot disk" + +# The new key's slot is how the change is followed, so a new password that +# already opens a slot (another key on the disk) is refused. +fixture +if attempt 0 "$old_password" "$recovery_key" "$recovery_key"; then fail "a new password that opens a slot already is refused"; fi +said "That password already opens a key slot on $system. Choose another." +! grep -q 'luksChangeKey' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "an enrolled new password changes nothing" +echo "$data" >"$tmp/select" +volume "$data" "$data_password" "$new_password" +if attempt 0 "$data_password" "$new_password" "$new_password"; then fail "a data drive refuses a new password it already has"; fi +said "That password already opens a key slot on $data. Choose another." +pass "a new password that already opens a key slot is refused, on the system disk and data drives" + +# Only the owner's slot changes the system disk. A second key an earlier setup +# added (a recovery key) is refused before anything changes, and so is a change +# the boot package cannot vouch for. +use fake apple +fixture +if attempt 0 "$recovery_key" "$new_password" "$new_password"; then fail "apple: a key in another slot is refused"; fi +said "That password opens another key slot on the system disk" +said "The system disk password did not change." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: refusing another slot's key changes nothing" +[[ -n $(opens "$system" "$recovery_key") ]] || fail "apple: the other key still opens the disk" +consistent "another slot's key refused" "$old_password" +touch "$tmp/owner-query-fail" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: a boot package that cannot name the owner's slot stops the change"; fi +said "Could not read which key slot holds the system disk password: The LUKS header has no key in the recorded owner slot 0." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "apple: an unanswered owner query changes nothing" +consistent "owner query failed" "$old_password" +rm "$tmp/owner-query-fail" +touch "$tmp/owner-query-garbage" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: an owner slot that is not a number stops the change"; fi +said "named 'slot zero', which is not a key slot" +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "apple: a garbled owner slot changes nothing" +rm "$tmp/owner-query-garbage" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: the owner's password changes the disk" "$(cat "$tmp/output")" +consistent "owner's password after refusals" "$new_password" +pass "apple: only the owner's slot changes the system disk; another slot's key and an unanswered query change nothing" + +# A Mac set up before its boot package recorded slots has no owner slot (4). +# Its disk's only key is the owner's: the change goes through and records the +# new slot. With another key beside it the owner's can't be told, so the change +# is refused, saying how to settle it: record both, or remove the extra key. +use fake apple +fixture "" +touch "$tmp/owner-unrecorded" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: an unrecorded single-key disk changes its password" "$(cat "$tmp/output")" +[[ $(cat "$tmp/slot-record") == "owner=$(opens "$system" "$new_password")" ]] || fail "apple: the owner's new slot is recorded" "$(cat "$tmp/slot-record")" +consistent "no owner slot recorded, one key" "$new_password" +step=$(grep -n 'cryptsetup' "$tmp/trace" | tail -1 | cut -d: -f1) +fixture "" +touch "$tmp/owner-unrecorded" +if attempt "$step" "$old_password" "$new_password" "$new_password"; then fail "apple: the unrecorded change is killed before the slot record"; fi +[[ -e $journal && ! -e $tmp/slot-record ]] || fail "apple: a change killed before the slot record keeps its journal" "$(cat "$tmp/trace")" +attempt 0 "$new_password" || fail "apple: the rerun finishes an unrecorded change" "$(cat "$tmp/output")" +[[ $(cat "$tmp/slot-record") == "owner=$(opens "$system" "$new_password")" ]] || fail "apple: the rerun records the owner's new slot" "$(cat "$tmp/slot-record")" +consistent "no owner slot recorded, interrupted" "$new_password" +pass "apple: with no owner slot recorded, the disk's only key changes and its new slot is recorded, after an interruption too" + +fixture +touch "$tmp/owner-unrecorded" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: an unrecorded disk with two keys refuses the change"; fi +said "has keys in slots 0,1, so which one is yours can't be told" +said "record both: sudo omarchy-lifecycle-dispatch luks-slots owner=0 recovery=1" +said "sudo cryptsetup luksKillSlot $system " +said "The system disk password did not change." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: an unrecorded two-key disk changes nothing" +"$ROOT/bin/omarchy-lifecycle-dispatch" luks-slots owner=0 recovery=1 +: >"$tmp/slot-record" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "apple: once both slots are recorded the change goes through" "$(cat "$tmp/output")" +consistent "slots recorded as the refusal said" "$new_password" +pass "apple: with no owner slot recorded, a disk with another key refuses the change and says how to record or remove it" + +fixture "" +touch "$tmp/owner-unrecorded" +export TEST_ENROLL_AT_PROMPT="Confirm new encryption password" TEST_ENROLL_DEVICE=$system +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "apple: a key enrolled during the prompts stops an unrecorded change"; fi +said "changed while you entered the new password" +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal && ! -e $tmp/slot-record ]] || fail "apple: a key enrolled during the prompts changes nothing" +pass "apple: an unrecorded disk that gains a key while the owner types is left alone" + +# A slot the boot package could not record keeps the journal: the rerun records it. +use fake apple +fixture +touch "$tmp/record-fail" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "a failed record fails the command"; fi +said "Could not record the new key slot for the boot checks." +[[ -e $journal && ! -e $tmp/slot-record ]] || fail "a failed record keeps the journal" +rm "$tmp/record-fail" +attempt 0 "$new_password" || fail "the rerun records the slot" "$(cat "$tmp/output")" +[[ -s $tmp/slot-record ]] || fail "the rerun records the moved key's slot" +consistent "rerun after a failed record" "$new_password" +pass "apple: the change finishes only once the boot package recorded the owner's new slot" + +# An installed boot package older than luks-slots could not record the slot, so +# the system disk does not change; a data drive still does. +fixture +mv "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" "$tmp/luks-slots.off" +mkdir -p "$tmp/lifecycle/var/lib/pacman/local/omarchy-mac-boot-20260925-2" +if attempt 0 "$old_password" "$new_password" "$new_password"; then fail "an Apple boot package without luks-slots fails the command"; fi +said "which omarchy-mac-boot 20260925-2 does not provide; update omarchy-mac-boot" +said "The system disk password did not change." +! grep -q 'luksChangeKey\|chpasswd' "$tmp/sudo-calls" && [[ ! -e $journal ]] || fail "a boot package without luks-slots changes nothing" +consistent "boot package without luks-slots" "$old_password" +echo "$data" >"$tmp/select" +attempt 0 "$data_password" "$new_password" "$new_password" || fail "a data drive changes without luks-slots" "$(cat "$tmp/output")" +pass "apple: a boot package without luks-slots stops a system disk change before it starts, naming the package" + +# A Mac without its boot package at all predates it: nothing checks its slots, +# so the change goes through and records nothing. +fixture +rm -r "$tmp/lifecycle/var/lib/pacman" +attempt 0 "$old_password" "$new_password" "$new_password" || fail "a Mac without the boot package changes its disk password" "$(cat "$tmp/output")" +# Checked as on x86: no slot recorded and no dispatch under sudo. +platform=x86 +consistent "no boot package" "$new_password" +platform=apple +! grep -q 'Error:' "$tmp/output" || fail "without the boot package nothing is reported" "$(cat "$tmp/output")" +mv "$tmp/luks-slots.off" "$tmp/lifecycle/usr/lib/omarchy/mac-boot/luks-slots" +pass "apple: a Mac without its boot package changes its disk password and records no slot" + +fixture +echo "$data" >"$tmp/select" +attempt 0 "$data_password" "$new_password" "$new_password" || fail "apple: a data drive changes" "$(cat "$tmp/output")" +[[ ! -e $tmp/slot-record && -n $(opens "$data" "$new_password") ]] || fail "apple: a data drive records no slot" +pass "apple: only the system disk's slot is recorded" diff --git a/test/shell.d/factory-reset-dispatch-test.sh b/test/shell.d/factory-reset-dispatch-test.sh new file mode 100644 index 00000000..bf35d4a8 --- /dev/null +++ b/test/shell.d/factory-reset-dispatch-test.sh @@ -0,0 +1,339 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Factory reset's boot step through the real omarchy-lifecycle-dispatch. On an +# x86 fixture the generic Limine UKI path runs and the boot-package entrypoints +# on disk never do. On an Apple fixture a fake boot package owns the factory +# root's boot chain: it prepares and verifies it before the throwaway slot is +# added, gets the key on standard input after the switch, and rolls back when +# the reset fails before the switch. Subvolumes are directories, and +# cryptsetup is a slot-table fake. +require_platform_fixtures "factory reset through lifecycle dispatch" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +fake_platform "$tmp/x86" x86 +fake_platform "$tmp/apple" aarch64-apple + +# Load the production functions without self-elevation or the reset entrypoint. +awk ' + /^[a-z_]+\(\) \{/ { copying = 1 } + copying { print } + /^}/ { copying = 0 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$tmp/functions" + +# The fake boot package: each entrypoint records its call, reset-commit its +# standard input, and reset-verify fails on request. +lifecycle=$tmp/lifecycle +mac_boot=$lifecycle/usr/lib/omarchy/mac-boot +mkdir -p "$mac_boot" +for operation in reset-prepare reset-verify reset-commit reset-rollback; do + cat >"$mac_boot/$operation" <>"$tmp/calls" +[[ $operation != reset-commit ]] || cat >"$tmp/committed-key" +[[ ! -e $tmp/$operation-fail ]] || { echo "$operation: the fixture refuses" >&2; exit 1; } +SH +done +chmod 755 "$mac_boot"/* +chmod -R go-w "$lifecycle" + +cat >"$tmp/reset" <<'SH' +#!/bin/bash +set -euo pipefail +source "$TMP/functions" +TOP_MNT=$TMP/top +NEXT_NAME=@omarchy-reset-next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE=$TMP/reset.log +DISPATCH=${TEST_DISPATCH:-$ROOT/bin/omarchy-lifecycle-dispatch} +RESET_BOOT="" +RESET_BOOT_ERROR="" +swap_done=0 + +gum() { + if [[ $1 == "input" ]]; then + printf '%s\n' "$CURRENT" + elif [[ $1 == "style" ]]; then + printf '%s\n' "${!#}" >>"$TMP/screen" + fi +} +encrypted_install() { [[ -n $DEVICE ]]; } +luks_device() { echo "$DEVICE"; } +scrub_factory_accounts() { :; } +sanitize_factory_baseline() { :; } +install_provisioning_units() { :; } +systemd-id128() { printf '%032d\n' 7; } +sync() { :; } +mountpoint() { return 0; } +mount() { echo "mount $*" >>"$TMP/calls"; } +umount() { :; } +chroot() { echo "chroot ${*:2}" >>"$TMP/calls"; } +reset_limine_config() { echo "limine menu reset" >>"$TMP/calls"; } +verify_limine_hashes() { :; } + +btrfs() { + case "$1 $2" in + "subvolume snapshot") cp -a "$3" "$4" ;; + "subvolume delete") rm -rf "${!#}" ;; + "subvolume show") return 1 ;; + esac +} + +mv() { + if [[ -e $TMP/switch-fail && $1 == "$TOP_MNT/@" ]]; then + echo "mv: the fixture refuses the switch" >&2 + return 1 + fi + if [[ -e $TMP/activate-fail && $1 == "$TOP_MNT/$NEXT_NAME" ]]; then + echo "mv: the fixture refuses the activation" >&2 + return 1 + fi + if [[ -e $TMP/restore-fail && $1 == "$TOP_MNT"/@omarchy-old-* ]]; then + echo "mv: the fixture refuses the restore" >&2 + return 1 + fi + command mv "$@" +} + +# " " per line. +cryptsetup() { + local op=$1 key_file="" slot material next + local -a args=() + shift + while (( $# )); do + case $1 in + --key-file) key_file=$2; shift 2 ;; + --token-type) shift 2 ;; + -*) shift ;; + *) args+=("$1"); shift ;; + esac + done + echo "cryptsetup $op" >>"$TMP/calls" + case $op in + open) + material=$(cat "$key_file") + if [[ -e $TMP/lookup-fail-once && $material != "$CURRENT" ]]; then + rm "$TMP/lookup-fail-once" + return 2 + fi + slot=$(awk -v m="$material" '$2 == m { print $1; exit }' "$TMP/slots") + [[ -n $slot ]] || return 2 + echo "Key slot $slot unlocked." + ;; + luksAddKey) + material=$(cat "$key_file") + awk -v m="$material" '$2 == m { found = 1 } END { exit !found }' "$TMP/slots" || return 2 + for (( next = 0; next < 32; next++ )); do + awk '{ print $1 }' "$TMP/slots" | grep -qx "$next" || break + done + printf '%s %s\n' "$next" "$(cat "${args[1]}")" >>"$TMP/slots" + ;; + luksDump) + echo "Keyslots:" + awk '{ printf " %s: luks2\n", $1 }' "$TMP/slots" + ;; + luksKillSlot) + slot=${args[1]} + awk -v s="$slot" '$1 != s' "$TMP/slots" >"$TMP/slots.next" + command mv -f "$TMP/slots.next" "$TMP/slots" + ;; + *) return 1 ;; + esac +} + +case $MODE in + owner) reset_boot_owner && echo "$RESET_BOOT" ;; + reset) + trap cleanup EXIT + stage_full_reset + ;; +esac +SH + +current_password=owner-password +device=$tmp/luks-device + +# A top-level Btrfs volume: the running root and a factory baseline whose ESP +# the generic path mounts from its fstab. +fixture() { + rm -rf "$tmp/top" "$tmp/calls" "$tmp/screen" "$tmp/reset.log" "$tmp/committed-key" "$tmp"/*-fail + mkdir -p "$tmp/top/@" "$tmp/top/@factory/etc" "$tmp/top/@factory/usr/bin" \ + "$tmp/top/@factory/usr/share/omarchy/install/provisioning" "$tmp/top/@factory/boot" + touch "$tmp/top/@/old-system" "$tmp/top/@factory/factory-system" "$tmp/esp" \ + "$tmp/top/@factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" + printf '#!/bin/bash\n' >"$tmp/top/@factory/usr/bin/omarchy-provision-owner" + chmod +x "$tmp/top/@factory/usr/bin/omarchy-provision-owner" + printf '%s /boot vfat defaults 0 2\n' "$tmp/esp" >"$tmp/top/@factory/etc/fstab" + : >"$device" + printf '0 %s\n' "$current_password" >"$tmp/slots" + : >"$tmp/reset.log" +} + +run() { + local mode=$1 platform=$2 + TMP=$tmp ROOT=$ROOT MODE=$mode CURRENT=$current_password DEVICE=${DEVICE-$device} TEST_DISPATCH=${TEST_DISPATCH:-} \ + OMARCHY_PROC_ROOT="$tmp/$platform/proc" OMARCHY_LIFECYCLE_ROOT=$lifecycle PATH="$tmp/$platform/bin:$PATH" \ + bash "$tmp/reset" >"$tmp/out" 2>&1 +} + +activated() { + [[ -e $tmp/top/@/factory-system && -f $tmp/top/@/var/lib/omarchy/provisioning/pending ]] && + compgen -G "$tmp/top/@omarchy-old-*/old-system" >/dev/null +} + +untouched() { + [[ -e $tmp/top/@/old-system && ! -e $tmp/top/@omarchy-reset-next ]] && [[ $(cat "$tmp/slots") == "0 $current_password" ]] +} + +# x86: the generic Limine UKI path, with boot-package entrypoints on disk that +# would record themselves if they ran. +fixture +run owner x86 && [[ $(cat "$tmp/out") == "generic" ]] || fail "x86: the reset boot step is generic" "$(cat "$tmp/out")" +run reset x86 || fail "x86: an encrypted reset stages" "$(cat "$tmp/out" "$tmp/reset.log")" +activated || fail "x86: the factory root is activated" +grep -qx 'chroot /usr/bin/limine-update' "$tmp/calls" || fail "x86: limine-update rebuilds the factory root's UKI" "$(cat "$tmp/calls")" +[[ -f $tmp/top/@/etc/omarchy/provisioning.key && -f $tmp/top/@/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf && + -f $tmp/top/@/etc/mkinitcpio.conf.d/99-omarchy-provisioning-key.conf ]] || fail "x86: the UKI carries the reset's auto-unlock" +[[ $(awk 'NR == 2 { print $2 }' "$tmp/slots") == "$(cat "$tmp/top/@/var/lib/omarchy/provisioning/luks-key")" ]] || + fail "x86: the throwaway slot opens with the staged key" "$(cat "$tmp/slots")" +! grep -q '^reset-' "$tmp/calls" || fail "x86: no boot-package entrypoint runs" "$(cat "$tmp/calls")" +pass "x86: dispatch is a no-op and the generic Limine UKI path resets the machine" + +# A failed activation on the generic path puts the previous root back and +# revokes the throwaway slot. The ESP already holds the factory root's UKI, so +# the screen says to rebuild the boot files rather than claiming nothing changed. +fixture +touch "$tmp/activate-fail" +if run reset x86; then fail "x86: a failed activation fails the reset"; fi +untouched || fail "x86: a failed activation puts the previous root back and revokes the throwaway slot" "$(ls "$tmp/top"; cat "$tmp/slots")" +! grep -q '^reset-' "$tmp/calls" || fail "x86: a failed activation runs no boot-package entrypoint" "$(cat "$tmp/calls")" +grep -q "run 'sudo limine-update' before rebooting" "$tmp/screen" || + fail "x86: a failed activation says the boot files need rebuilding" "$(cat "$tmp/screen")" +pass "x86: a failed activation puts the previous root back, revokes the throwaway slot and says to rebuild the boot files" + +# Apple: the boot package prepares and verifies the factory root before the +# throwaway slot exists, and gets the key only once the factory root is active. +fixture +run owner apple && [[ $(cat "$tmp/out") == "platform" ]] || fail "apple: the boot package owns the reset boot step" "$(cat "$tmp/out")" +run reset apple || fail "apple: an encrypted reset stages" "$(cat "$tmp/out" "$tmp/reset.log")" +activated || fail "apple: the factory root is activated" +expected="reset-prepare $tmp/top/@omarchy-reset-next $device +reset-verify $tmp/top/@omarchy-reset-next +cryptsetup luksAddKey +reset-commit" +[[ $(grep -v '^cryptsetup \(open\|luksDump\)' "$tmp/calls") == "$expected" ]] || + fail "apple: prepare, verify, the throwaway slot, then commit" "$(cat "$tmp/calls")" +staged=$(cat "$tmp/top/@/var/lib/omarchy/provisioning/luks-key") +[[ -n $staged && $(cat "$tmp/committed-key") == "$staged" ]] || fail "apple: reset-commit gets the staged key on standard input" +[[ $(awk 'NR == 2 { print $2 }' "$tmp/slots") == "$staged" ]] || fail "apple: the throwaway slot opens with the staged key" "$(cat "$tmp/slots")" +[[ ! -e $tmp/top/@/etc/omarchy/provisioning.key && ! -e $tmp/top/@/etc/limine-entry-tool.d/99-omarchy-provisioning-unlock.conf ]] || + fail "apple: no Limine UKI unlock is written" +! grep -q 'limine' "$tmp/calls" || fail "apple: the Limine UKI path never runs" "$(cat "$tmp/calls")" +! grep -Fq "$staged" "$tmp/reset.log" "$tmp/calls" "$tmp/out" || fail "apple: the throwaway key is never logged" +pass "apple: the boot package prepares and verifies the factory root before the throwaway slot, and gets the key after the switch" + +# A failed verification adds no credential and rolls the boot state back; the +# previous root keeps booting. +fixture +touch "$tmp/reset-verify-fail" +if run reset apple; then fail "apple: a failed verification fails the reset"; fi +[[ $(grep '^reset-' "$tmp/calls" | cut -d' ' -f1) == $'reset-prepare\nreset-verify\nreset-rollback' ]] || + fail "apple: a failed verification rolls back" "$(cat "$tmp/calls")" +! grep -q 'luksAddKey' "$tmp/calls" || fail "apple: a failed verification adds no slot" +untouched || fail "apple: a failed verification leaves the previous root and its slots" "$(cat "$tmp/slots")" +grep -q 'reset-verify: the fixture refuses' "$tmp/screen" || fail "apple: the verification's reason is shown" "$(cat "$tmp/screen")" +pass "apple: a failed verification rolls back, adds no slot and keeps the previous root" + +# A failure after the slot was added and before the switch revokes it too. +fixture +touch "$tmp/switch-fail" +if run reset apple; then fail "apple: a failed switch fails the reset"; fi +grep -q 'luksAddKey' "$tmp/calls" && grep -qx 'reset-rollback' "$tmp/calls" && ! grep -q '^reset-commit' "$tmp/calls" || + fail "apple: a failed switch rolls back without committing" "$(cat "$tmp/calls")" +untouched || fail "apple: a failed switch revokes the throwaway slot and keeps the previous root" "$(cat "$tmp/slots")" +pass "apple: a failure before the switch revokes the slot it added and rolls the boot state back" + +# The old root already moved aside when the factory root cannot take @: the old +# root goes back, and the reset is undone as if it failed before the switch. +fixture +touch "$tmp/activate-fail" +if run reset apple; then fail "apple: a failed activation fails the reset"; fi +grep -qx 'reset-rollback' "$tmp/calls" && ! grep -q '^reset-commit' "$tmp/calls" || + fail "apple: a failed activation rolls back without committing" "$(cat "$tmp/calls")" +untouched || fail "apple: a failed activation puts the previous root back at @ and revokes the throwaway slot" "$(ls "$tmp/top"; cat "$tmp/slots")" +! compgen -G "$tmp/top/@omarchy-old-*" >/dev/null || fail "apple: a failed activation leaves no renamed root behind" "$(ls "$tmp/top")" +grep -q 'the current root is back at @' "$tmp/screen" || fail "apple: a failed activation says the current root is back" "$(cat "$tmp/screen")" +pass "apple: a failed activation puts the previous root back, rolls back and revokes the slot" + +# When the old root cannot go back either, neither root is at @: both survive +# for recovery, and the screen names the rename that restores the machine. +fixture +touch "$tmp/activate-fail" "$tmp/restore-fail" +if run reset apple; then fail "apple: a failed activation and restore fails the reset"; fi +old=$(compgen -G "$tmp/top/@omarchy-old-*") || fail "apple: the previous root survives under its moved-aside name" "$(ls "$tmp/top")" +[[ -e $old/old-system && -e $tmp/top/@omarchy-reset-next/factory-system && ! -e $tmp/top/@ ]] || + fail "apple: a failed restore keeps both roots" "$(ls "$tmp/top")" +grep -qx 'reset-rollback' "$tmp/calls" && [[ $(cat "$tmp/slots") == "0 $current_password" ]] || + fail "apple: a failed restore still rolls the boot state back and revokes the slot" "$(cat "$tmp/calls" "$tmp/slots")" +grep -q "rename ${old##*/} to @" "$tmp/screen" || fail "apple: a failed restore names the recovery rename" "$(cat "$tmp/screen")" +pass "apple: a failed activation and restore keeps both roots and names the recovery rename" + +# A slot added but not confirmed is found by its key and revoked all the same. +fixture +touch "$tmp/lookup-fail-once" +if run reset apple; then fail "apple: an unconfirmed throwaway slot fails the reset"; fi +grep -qx 'reset-rollback' "$tmp/calls" && grep -q 'luksKillSlot' "$tmp/calls" || fail "apple: an unconfirmed slot is rolled back and killed" "$(cat "$tmp/calls")" +untouched || fail "apple: an unconfirmed throwaway slot is revoked" "$(cat "$tmp/slots")" +pass "apple: a throwaway slot the reset could not confirm is found by its key and revoked" + +# Once the factory root is active nothing rolls back: a failed commit is logged +# and costs the first boot one password prompt. +fixture +touch "$tmp/reset-commit-fail" +run reset apple || fail "apple: a failed commit still completes the reset" "$(cat "$tmp/out")" +activated && ! grep -qx 'reset-rollback' "$tmp/calls" || fail "apple: a failed commit keeps the factory root active" "$(cat "$tmp/calls")" +grep -q 'reset-commit: the fixture refuses' "$tmp/reset.log" || fail "apple: a failed commit is logged" "$(cat "$tmp/reset.log")" +[[ $(wc -l <"$tmp/slots") == 2 ]] || fail "apple: a failed commit keeps the throwaway slot" +pass "apple: a failed commit after the switch is logged and the reset stands" + +# An unencrypted Mac still has its boot files rebuilt and verified. +fixture +DEVICE="" run reset apple || fail "apple: an unencrypted reset stages" "$(cat "$tmp/out")" +[[ $(grep '^reset-' "$tmp/calls") == "reset-prepare $tmp/top/@omarchy-reset-next"$'\n'"reset-verify $tmp/top/@omarchy-reset-next"$'\n'"reset-commit" ]] || + fail "apple: an unencrypted reset names no LUKS device" "$(cat "$tmp/calls")" +! grep -q '^cryptsetup' "$tmp/calls" || fail "apple: an unencrypted reset touches no key slot" +pass "apple: an unencrypted reset rebuilds and verifies the boot files without a key" + +# Without the boot package, or with one that lacks a reset operation, the +# reset stops before anything is asked or changed, naming the package. +fixture +mv "$mac_boot" "$tmp/mac-boot.off" +if run owner apple; then fail "apple without the boot package: the reset refuses"; fi +grep -q 'reset-prepare on aarch64-apple needs omarchy-mac-boot' "$tmp/out" || fail "apple without the boot package: the package is named" "$(cat "$tmp/out")" +mkdir -p "$mac_boot" +cp -p "$tmp/mac-boot.off/reset-prepare" "$tmp/mac-boot.off/reset-verify" "$mac_boot/" +chmod -R go-w "$lifecycle" +run owner x86 && [[ $(cat "$tmp/out") == "generic" ]] || fail "x86: a partial boot package on disk is ignored" "$(cat "$tmp/out")" +if run owner apple; then fail "apple with part of the boot package: the reset refuses"; fi +grep -q 'reset-commit on aarch64-apple needs omarchy-mac-boot' "$tmp/out" || fail "apple with part of the boot package: the missing operation is named" "$(cat "$tmp/out")" +rm -rf "$mac_boot" +mv "$tmp/mac-boot.off" "$mac_boot" +pass "apple: without the boot package's reset operations, or with only some of them, the reset refuses naming the package" + +# A registration that left the reset operations optional and got only some of +# them would mix two boot paths: the reset refuses that too. +mkdir -p "$tmp/half" +cat >"$tmp/half/omarchy-lifecycle-dispatch" </dev/null) || continue + [[ $_omarchy_mac_hid_path == /* ]] || continue + MODULES+=("$_omarchy_mac_hid_module") +done +unset _omarchy_mac_hid_module _omarchy_mac_hid_path + +# MTP trackpad firmware (apple/tpmtfw-.bin) is not a MODULES entry. +# omarchy-vendorfw-initrd.service unpacks ESP vendorfw/firmware.cpio onto +# /lib/firmware/vendor (symlink to /vendorfw) before cryptsetup-pre.target. +# Do not FILES+= under /lib/firmware/vendor, and do not pre-create +# /vendorfw/apple in the image: that skipped ESP extraction. diff --git a/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/93-omarchy-mac-plymouth.conf b/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/93-omarchy-mac-plymouth.conf new file mode 100644 index 00000000..75cbbb50 --- /dev/null +++ b/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/93-omarchy-mac-plymouth.conf @@ -0,0 +1,18 @@ +# Plymouth draws the disk password prompt and the boot splash, as on x86 +# Omarchy. It goes right after systemd so its initrd units order correctly, +# only when the hook is installed, and never twice. +if [[ -f /usr/lib/initcpio/install/plymouth && " ${HOOKS[*]} " != *" plymouth "* ]]; then + _omarchy_mac_plymouth_hooks=() + _omarchy_mac_plymouth_added=false + for _omarchy_mac_plymouth_hook in "${HOOKS[@]}"; do + _omarchy_mac_plymouth_hooks+=("$_omarchy_mac_plymouth_hook") + if [[ $_omarchy_mac_plymouth_hook == systemd && $_omarchy_mac_plymouth_added == false ]]; then + _omarchy_mac_plymouth_hooks+=(plymouth) + _omarchy_mac_plymouth_added=true + fi + done + if [[ $_omarchy_mac_plymouth_added == true ]]; then + HOOKS=("${_omarchy_mac_plymouth_hooks[@]}") + fi + unset _omarchy_mac_plymouth_hooks _omarchy_mac_plymouth_added _omarchy_mac_plymouth_hook +fi diff --git a/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/94-omarchy-mac-vconsole.conf b/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/94-omarchy-mac-vconsole.conf new file mode 100644 index 00000000..9547df77 --- /dev/null +++ b/test/shell.d/fixtures/omarchy-mac-boot-mkinitcpio/94-omarchy-mac-vconsole.conf @@ -0,0 +1,46 @@ +# The disk passphrase prompt types with the owner's keyboard layout, as on +# x86 Omarchy: sd-vconsole loads KEYMAP on the console (systemd-ask-password) +# and /etc/vconsole.conf gives Plymouth its XKBLAYOUT. The aarch64 +# omarchy-settings drops upstream's omarchy_hooks.conf, so this drop-in +# carries its guard: a layout that does not type Latin letters stays out of +# the initramfs, because a Latin passphrase would be untypeable in it +# (upstream #6229). The prompt then uses the kernel's US map, which is what +# such a passphrase was typed with. +# +# A systemd HOOKS line gets sd-vconsole exactly once, after keyboard (or +# after systemd without one); keymap and consolefont are its busybox +# counterparts and never belong on such a line. A busybox line (a Mac +# unlocked by cryptdevice=, which 91 leaves alone) keeps its hooks; it only +# gets the file for Plymouth, as upstream does. +# No vconsole.conf is the kernel's US map: sd-vconsole stays, nothing to bundle. +_omarchy_mac_vconsole_latin=true +if [[ -f /etc/vconsole.conf ]]; then + _omarchy_mac_vconsole_layout=$(unset XKBLAYOUT; . /etc/vconsole.conf 2>/dev/null; printf '%s' "${XKBLAYOUT:-}") + case ${_omarchy_mac_vconsole_layout%%,*} in + af | am | ara | bd | bg | by | et | ge | gr | il | in | iq | ir | kg | kh | kz | la | lk | mk | mm | mn | mv | np | rs | ru | sy | th | tj | ua) + _omarchy_mac_vconsole_latin=false ;; + esac +fi + +if [[ " ${HOOKS[*]} " == *" systemd "* ]]; then + _omarchy_mac_vconsole_hooks=() + _omarchy_mac_vconsole_anchor=systemd + [[ " ${HOOKS[*]} " != *" keyboard "* ]] || _omarchy_mac_vconsole_anchor=keyboard + for _omarchy_mac_vconsole_hook in "${HOOKS[@]}"; do + case $_omarchy_mac_vconsole_hook in + sd-vconsole | keymap | consolefont) continue ;; + esac + _omarchy_mac_vconsole_hooks+=("$_omarchy_mac_vconsole_hook") + if [[ $_omarchy_mac_vconsole_hook == "$_omarchy_mac_vconsole_anchor" && $_omarchy_mac_vconsole_latin == true ]]; then + _omarchy_mac_vconsole_hooks+=(sd-vconsole) + _omarchy_mac_vconsole_anchor= + fi + done + HOOKS=("${_omarchy_mac_vconsole_hooks[@]}") +fi + +if [[ $_omarchy_mac_vconsole_latin == true && -f /etc/vconsole.conf ]]; then + FILES+=(/etc/vconsole.conf) +fi +unset _omarchy_mac_vconsole_latin _omarchy_mac_vconsole_layout _omarchy_mac_vconsole_hooks \ + _omarchy_mac_vconsole_anchor _omarchy_mac_vconsole_hook diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index 1fb123d0..83824530 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -32,6 +32,17 @@ PY copy_boundary_file bin/omarchy-security-functions copy_boundary_file bin/omarchy-update-pacman copy_boundary_file default/omarchy/sudo-no-update/sudo +copy_boundary_file install/helpers/pacman.sh +# Channel changes copy the platform's templates; the copy itself is a stand-in. +cp -r "$ROOT/default/pacman" "$SUDO_TEST_ROOT/default/" + +# Channel changes ask for the platform. x86 unless a test says otherwise, so no +# test reads the host's hardware. +cat >"$SUDO_TEST_ROOT/bin/omarchy-hw-platform" <<'STUB' +#!/bin/bash +echo "${SUDO_TEST_PLATFORM:-x86}" +STUB +chmod +x "$SUDO_TEST_ROOT/bin/omarchy-hw-platform" cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB' #!/bin/bash @@ -132,7 +143,7 @@ case "$step" in esac STUB chmod +x "$SUDO_TEST_ROOT/bin/test-step" -for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do +for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-boot omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman systemd-run cp yay; do ln -s test-step "$SUDO_TEST_ROOT/bin/$step" done ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman" @@ -140,7 +151,7 @@ ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman" reset_boundary() { : >"$SUDO_TEST_LOG" /usr/bin/rm -f "$SUDO_TEST_CACHE" - unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP + unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP SUDO_TEST_PLATFORM } assert_boundary_cold() { [[ ! -e $SUDO_TEST_CACHE ]] || fail "$1 left cached authorization" diff --git a/test/shell.d/hw-display-test.sh b/test/shell.d/hw-display-test.sh index e4028831..5e9bb139 100755 --- a/test/shell.d/hw-display-test.sh +++ b/test/shell.d/hw-display-test.sh @@ -17,8 +17,18 @@ write_backlights() { done } +# A copy of the command reads its platform file from a fixture root: none +# unless a case writes one, whatever the machine running the suite has installed. +platform_root="$tmp_dir/platform" +mkdir -p "$platform_root" "$tmp_dir/bin" +platform_root_copy "$ROOT/bin/omarchy-hw-display" "$tmp_dir/bin/omarchy-hw-display" "$platform_root" + +write_displays_conf() { + printf '%s\n' "$@" >"$platform_root/displays.conf" +} + hw_display() { - OMARCHY_BACKLIGHT_PATH="$tmp_dir/backlight" "$ROOT/bin/omarchy-hw-display" + OMARCHY_BACKLIGHT_PATH="$tmp_dir/backlight" "$tmp_dir/bin/omarchy-hw-display" } write_backlights intel_backlight @@ -48,9 +58,9 @@ pass "a T2 Mac uses gmux instead of the Touch Bar" write_backlights appletb_backlight if hw_display >/dev/null 2>&1; then - fail "the Touch Bar is never used as the display backlight" + fail "the T2 Touch Bar is never used as the display backlight" fi -pass "the Touch Bar is never used as the display backlight" +pass "the T2 Touch Bar is never used as the display backlight" write_backlights acpi_video0 amdgpu_bl0 appletb_backlight gmux_backlight intel_backlight device=$(hw_display) @@ -62,3 +72,71 @@ if hw_display >/dev/null 2>&1; then fail "no backlight device reports failure" fi pass "no backlight device reports failure" + +# An empty platform file changes nothing. +: >"$platform_root/displays.conf" +write_backlights acpi_video0 amdgpu_bl1 +device=$(hw_display) +[[ $device == "amdgpu_bl1" ]] || fail "an empty platform file keeps the candidate order" "actual: $device" +write_backlights nvidia_wmi_ec_backlight +device=$(hw_display) +[[ $device == "nvidia_wmi_ec_backlight" ]] || fail "an empty platform file keeps the fallback" "actual: $device" +pass "an empty platform file changes nothing" + +write_displays_conf '# accessory panels' 'backlight-skip accessory-pipe' 'backlight-skip *.dsi.0' 'backlight-prefer panel-bl' +write_backlights 1000.dsi.0 accessory-pipe +if hw_display >/dev/null 2>&1; then + fail "a skipped backlight is never the fallback" +fi +write_backlights 1000.dsi.0 accessory-pipe zz-other +device=$(hw_display) +[[ $device == "zz-other" ]] || fail "the fallback passes over skipped backlights" "actual: $device" +pass "a platform's skipped backlights are never the fallback" + +write_backlights 1000.dsi.0 amdgpu_bl0 panel-bl +device=$(hw_display) +[[ $device == "panel-bl" ]] || fail "a preferred backlight beats the GPU backlights" "actual: $device" +printf 'backlight-prefer panel-bl\r\n' >"$platform_root/displays.conf" +device=$(hw_display) +[[ $device == "panel-bl" ]] || fail "a file saved with CRLF line ends reads the same" "actual: $device" +write_displays_conf '# accessory panels' 'backlight-skip accessory-pipe' 'backlight-skip *.dsi.0' 'backlight-prefer panel-bl' +write_backlights gmux_backlight panel-bl +device=$(hw_display) +[[ $device == "gmux_backlight" ]] || fail "gmux still beats a preferred backlight" "actual: $device" +pass "a platform's preferred backlight follows gmux and beats the GPU backlights" + +write_displays_conf 'backlight-prefer second-bl' 'backlight-prefer first-bl' +write_backlights first-bl second-bl +device=$(hw_display) +[[ $device == "second-bl" ]] || fail "preferences are tried in file order" "actual: $device" +pass "preferences are tried in file order" + +write_displays_conf 'backlight-skip intel_backlight' 'backlight-prefer panel-bl' 'backlight-skip panel-bl' +write_backlights acpi_video0 intel_backlight panel-bl +device=$(hw_display) +[[ $device == "acpi_video0" ]] || fail "a skip beats preferred and built-in candidates" "actual: $device" +write_displays_conf 'backlight-skip gmux_backlight' +write_backlights gmux_backlight intel_backlight +device=$(hw_display) +[[ $device == "intel_backlight" ]] || fail "a skip beats gmux" "actual: $device" +pass "a skip beats preferred and built-in candidates" + +write_displays_conf 'backlight-prefer panel-*' 'backlight-prefer .' 'backlight-prefer ..' +write_backlights amdgpu_bl0 panel-bl +device=$(hw_display) +[[ $device == "amdgpu_bl0" ]] || fail "a preference is a literal name" "actual: $device" +pass "a preference is a literal name" + +# Only whole directives count: an unknown one, a path, an extra word or a +# trailing comment leaves the line out; the last line needs no newline. +printf '%s\n' 'backlight-future panel-bl' 'backlight-prefer ../panel-bl' 'backlight-prefer panel-bl extra' \ + 'backlight-skip acpi_video0 # comment' >"$platform_root/displays.conf" +printf 'backlight-prefer last-bl' >>"$platform_root/displays.conf" +write_backlights acpi_video0 last-bl panel-bl +device=$(hw_display) +[[ $device == "last-bl" ]] || fail "malformed directives are ignored and the last line is read" "actual: $device" +write_displays_conf 'backlight-skip acpi_video0 # comment' +write_backlights acpi_video0 +device=$(hw_display) +[[ $device == "acpi_video0" ]] || fail "a directive with a trailing comment is ignored" "actual: $device" +pass "malformed directives are ignored and the last line is read" diff --git a/test/shell.d/hw-laptop-test.sh b/test/shell.d/hw-laptop-test.sh new file mode 100755 index 00000000..9b4781d9 --- /dev/null +++ b/test/shell.d/hw-laptop-test.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +acpi_lid="$test_tmp/acpi/button/lid" +input_class="$test_tmp/input" +dmi_chassis="$test_tmp/chassis_type" +mkdir -p "$stub_bin" "$acpi_lid/LID0" "$input_class" + +busctl_log="$test_tmp/busctl.log" +cat >"$stub_bin/busctl" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$BUSCTL_LOG" +[[ ${OMARCHY_TEST_LID_STATE:-} != "fail" ]] || exit 1 +printf '%s\n' "${OMARCHY_TEST_LID_STATE:-}" +SH +chmod +x "$stub_bin/busctl" + +run_laptop() { + OMARCHY_DMI_CHASSIS_TYPE_PATH="$dmi_chassis" \ + OMARCHY_ACPI_LID_PATH="$acpi_lid" \ + OMARCHY_INPUT_CLASS_PATH="$input_class" \ + PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-hw-laptop" +} + +run_lid_closed() { + OMARCHY_ACPI_LID_PATH="$acpi_lid" \ + OMARCHY_INPUT_CLASS_PATH="$input_class" \ + BUSCTL_LOG="$busctl_log" \ + PATH="$stub_bin:$PATH" \ + OMARCHY_TEST_LID_STATE="$1" \ + "$ROOT/bin/omarchy-hw-laptop-closed" +} + +# Input devices as sysfs shows them: a name and a switch capability bitmap +# whose last word holds SW_LID in bit 0. +input_device() { + mkdir -p "$input_class/$1/capabilities" + printf '%s\n' "$2" >"$input_class/$1/name" + printf '%s\n' "$3" >"$input_class/$1/capabilities/sw" +} + +# No ACPI lid, no lid switch, desktop chassis: a Mac mini, a Studio or a tower. +: >"$acpi_lid/LID0/state" +rm -f "$acpi_lid/LID0/state" +printf '3\n' >"$dmi_chassis" +input_device input0 "Power Button" 0 +input_device input1 "Apple SMC power/lid events" 0 +input_device input2 "MacBook Pro J416 Headphone Jack" 14 +if run_laptop; then + fail "a desktop without a lid is not classified as a laptop" +fi +pass "a desktop without a lid is not classified as a laptop, even with the SMC's power device" + +# Apple Silicon MacBook: no ACPI lid, but the SMC reports a lid switch. +input_device input1 "Apple SMC power/lid events" 1 +run_laptop || fail "an Apple SMC lid switch is classified as a laptop" +pass "an Apple SMC lid switch is classified as a laptop" + +input_device input1 "Lid Switch" "0 1" +run_laptop || fail "a lid switch in the low word of a longer bitmap is classified as a laptop" +pass "a lid switch in the low word of a longer bitmap is classified as a laptop" + +input_device input1 "Lid Switch" "1 0" +if run_laptop; then + fail "a switch bit in a higher word is not a lid" +fi +pass "a switch bit in a higher word is not a lid" + +# x86 laptop via DMI when ACPI and evdev lids are absent. +input_device input1 "Power Button" 0 +printf '9\n' >"$dmi_chassis" +run_laptop || fail "DMI laptop chassis is recognized when ACPI is absent" +pass "DMI laptop chassis is recognized when ACPI is absent" + +printf '3\n' >"$dmi_chassis" +if run_laptop; then + fail "a desktop DMI chassis is not classified as a laptop" +fi +pass "a desktop DMI chassis is not classified as a laptop" + +printf 'closed\n' >"$acpi_lid/LID0/state" +run_laptop || fail "an ACPI lid is classified as a laptop" +pass "an ACPI lid is classified as a laptop" + +# An ACPI lid answers for itself, as it always has, without asking logind over +# D-Bus: this runs in sudo's PAM stack where fingerprint login is set up. +: >"$busctl_log" +run_lid_closed 'b false' || + fail "an ACPI lid reports itself closed" +pass "an ACPI lid reports itself closed" + +printf 'open\n' >"$acpi_lid/LID0/state" +if run_lid_closed 'b true'; then + fail "an ACPI lid reports itself open" +fi +pass "an ACPI lid reports itself open" +[[ ! -s $busctl_log ]] || fail "an ACPI lid leaves logind unasked" "$(cat "$busctl_log")" +pass "an ACPI lid leaves logind unasked" + +# Without an ACPI lid, logind answers only for a lid switch it can track: it +# says "open" of a machine it tracks no lid on. +rm -f "$acpi_lid/LID0/state" +input_device input1 "Power Button" 0 +if run_lid_closed 'b true'; then + fail "a machine without a lid is never closed" +fi +[[ ! -s $busctl_log ]] || fail "a machine without a lid leaves logind unasked" "$(cat "$busctl_log")" +pass "a machine without a lid is never closed and leaves logind unasked" + +input_device input1 "Apple SMC power/lid events" 1 +run_lid_closed 'b true' || + fail "logind recognizes a closed lid switch" +pass "logind recognizes a closed lid switch" +[[ $(head -1 "$busctl_log") == "--timeout=2 "*" LidClosed" ]] || + fail "logind gets a short timeout, not busctl's 25 s" "$(cat "$busctl_log")" +pass "logind gets a short timeout, not busctl's 25 s" + +if run_lid_closed 'b false'; then + fail "logind recognizes an open lid switch" +fi +pass "logind recognizes an open lid switch" + +input_device input1 "Lid Switch" "0 1" +run_lid_closed 'b true' || + fail "a lid switch in the low word of a longer bitmap is asked about" +pass "a lid switch in the low word of a longer bitmap is asked about" + +for answer in '' 'not-a-property' 'fail'; do + if run_lid_closed "$answer"; then + fail "a logind that does not answer leaves the lid open" "$answer" + fi +done +pass "a logind that does not answer leaves the lid open" diff --git a/test/shell.d/hw-platform-test.sh b/test/shell.d/hw-platform-test.sh new file mode 100644 index 00000000..9b476213 --- /dev/null +++ b/test/shell.d/hw-platform-test.sh @@ -0,0 +1,203 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +detector="$ROOT/bin/omarchy-hw-platform" +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +for platform in aarch64-apple aarch64 x86; do + fake_platform "$test_tmp/$platform" "$platform" +done + +# A privileged caller reads the live device tree and the system uname, never +# the fixture roots or PATH its environment names. Run it as root when the suite +# is root and as namespaced root otherwise. +root_runner=() +if (( EUID != 0 )); then + root_runner=(unshare --user --map-root-user) +fi +if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then + live=$("${root_runner[@]}" "$detector") || fail "root detects the live platform" + [[ $live =~ ^(aarch64-apple|aarch64|x86)$ ]] || fail "root detects the live platform" "live: $live" + for platform in aarch64-apple aarch64 x86; do + fixture="$test_tmp/$platform" + if [[ -f $fixture/proc/device-tree/compatible ]]; then + mkdir -p "$fixture/sys/firmware/devicetree/base" + cp "$fixture/proc/device-tree/compatible" "$fixture/sys/firmware/devicetree/base/compatible" + fi + overridden=$(OMARCHY_PROC_ROOT="$fixture/proc" OMARCHY_SYS_ROOT="$fixture/sys" PATH="$fixture/bin:$ROOT/bin:$PATH" \ + "${root_runner[@]}" "$detector") || fail "root detects the live platform with a $platform fixture in its environment" + [[ $overridden == "$live" ]] || + fail "root ignores a $platform fixture in its environment" "live: $live +with fixture: $overridden" + done + pass "root ignores fixture roots and PATH when detecting the platform" +else + skip "no unprivileged user namespace; skipping the root override probe" +fi + +# -p in the shebang is what keeps exported functions and BASH_ENV out, so an +# ordinary Bash launch with a decoy -p argument is refused before it reads anything. +for command in omarchy-hw-platform omarchy-hw-aarch64-apple omarchy-hw-apple-silicon; do + if /usr/bin/bash "$ROOT/bin/$command" -p >/dev/null 2>"$test_tmp/error"; then + fail "$command refuses an ordinary Bash launch" + fi + grep -Fq "Refusing an unsafe Bash startup" "$test_tmp/error" || fail "$command explains the refusal" "$(cat "$test_tmp/error")" +done +pass "the detector and the Apple predicate refuse an ordinary Bash launch with a decoy -p" + +require_platform_fixtures "the platform fixtures" + +# The three platforms every caller is written against. +for platform in aarch64-apple aarch64 x86; do + fixture="$test_tmp/$platform" + actual=$(OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:$ROOT/bin:$PATH" "$detector") || + fail "the $platform fixture is detected" + [[ $actual == "$platform" ]] || fail "the $platform fixture is detected" "actual: $actual" + + apple_status=0 + OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:$ROOT/bin:$PATH" "$ROOT/bin/omarchy-hw-aarch64-apple" || apple_status=$? + if [[ $platform == "aarch64-apple" ]]; then + (( apple_status == 0 )) || fail "the Apple predicate accepts the Apple fixture" + else + (( apple_status != 0 )) || fail "the Apple predicate rejects the $platform fixture" + fi + pass "the $platform fixture is detected and the Apple predicate agrees" +done + +# Systemd and the shell run the predicate by absolute path with whatever PATH +# they have; it must use the detector shipped beside it. +fixture="$test_tmp/aarch64-apple" +OMARCHY_PROC_ROOT="$fixture/proc" PATH="$fixture/bin:/usr/bin:/bin" "$ROOT/bin/omarchy-hw-aarch64-apple" || + fail "the Apple predicate finds its detector without Omarchy on PATH" +pass "the Apple predicate finds its detector without Omarchy on PATH" + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" +cat >"$stub_bin/uname" <<'SH' +#!/bin/bash +[[ ${1:-} == -m ]] && { printf '%s\n' "${TEST_ARCH:-aarch64}"; exit 0; } +exec /usr/bin/uname "$@" +SH +chmod +x "$stub_bin/uname" + +# $1 names a case directory holding proc/ and sys/ roots; $2 is the CPU. +detect() { + local case_dir="$test_tmp/cases/$1" + TEST_ARCH="${2:-aarch64}" OMARCHY_PROC_ROOT="$case_dir/proc" OMARCHY_SYS_ROOT="$case_dir/sys" \ + PATH="$stub_bin:$ROOT/bin:$PATH" "$detector" 2>"$test_tmp/error" +} + +# $1 case, $2 proc|sys, then the compatible tokens. +write_tree() { + local case_dir="$test_tmp/cases/$1" tree="$2" file + shift 2 + mkdir -p "$case_dir/proc" "$case_dir/sys" + if [[ $tree == "proc" ]]; then + file="$case_dir/proc/device-tree/compatible" + else + file="$case_dir/sys/firmware/devicetree/base/compatible" + fi + mkdir -p "$(dirname "$file")" + printf '%s\0' "$@" >"$file" +} + +expect() { + local name="$1" machine="$2" expected="$3" description="$4" actual + actual=$(detect "$name" "$machine") || fail "$description" "$(cat "$test_tmp/error")" + [[ $actual == "$expected" ]] || fail "$description" "expected: $expected +actual: $actual" +} + +expect_contradiction() { + local name="$1" machine="$2" description="$3" + if detect "$name" "$machine" >/dev/null; then + fail "$description" + fi + grep -Fq "contradictory platform identity" "$test_tmp/error" || fail "$description explains itself" "$(cat "$test_tmp/error")" +} + +# Real boards: the M1 Pro and M2 Max test Macs, an M1 Mac mini, and the +# Snapdragon X laptops Dragon supports. +write_tree m1-pro proc apple,j314s apple,t6000 apple,arm-platform +write_tree m2-max proc apple,j416c apple,t6021 apple,arm-platform +write_tree m1-mini proc apple,j274 apple,t8103 apple,arm-platform +write_tree yoga-slim7x proc lenovo,yoga-slim7x qcom,x1e80100 +write_tree xps13-9345 proc dell,xps13-9345 qcom,x1e80100 +write_tree t14s proc lenovo,thinkpad-t14s qcom,x1e78100 qcom,x1e80100 +for board in m1-pro m2-max m1-mini; do + expect "$board" aarch64 aarch64-apple "the $board device tree is Apple Silicon" +done +for board in yoga-slim7x xps13-9345 t14s; do + expect "$board" aarch64 aarch64 "the $board device tree is plain aarch64, never Apple Silicon" +done +pass "real Apple and Snapdragon device trees are recognised" + +write_tree qemu-virt proc linux,dummy-virt +write_tree raspberry-pi proc raspberrypi,5-model-b brcm,bcm2712 +mkdir -p "$test_tmp/cases/acpi/proc" "$test_tmp/cases/acpi/sys" +expect qemu-virt aarch64 aarch64 "a QEMU virt board is plain aarch64" +expect raspberry-pi aarch64 aarch64 "a Raspberry Pi is plain aarch64" +expect acpi aarch64 aarch64 "an aarch64 machine without a device tree is plain aarch64" +expect acpi x86_64 x86 "an x86 machine without a device tree is x86" +pass "unknown aarch64 and x86 machines need no family" + +# Only a token's vendor prefix identifies the board; the old detector matched +# "apple," anywhere in the file. +write_tree substring proc pineapple,board acmeqcom,soc vendor,apple,x vendor,qcom,y +expect substring aarch64 aarch64 "vendor names inside other tokens do not identify the board" +pass "only a token's vendor prefix identifies the board" + +# /proc/device-tree is a link into sysfs; read sysfs when it is missing. +write_tree sysfs-qualcomm sys lenovo,yoga-slim7x qcom,x1e80100 +write_tree sysfs-apple sys apple,j314s apple,t6000 apple,arm-platform +expect sysfs-qualcomm aarch64 aarch64 "sysfs reads a Snapdragon tree without /proc/device-tree" +expect sysfs-apple aarch64 aarch64-apple "sysfs identifies Apple Silicon without /proc/device-tree" +write_tree agree proc apple,j314s apple,t6000 apple,arm-platform +write_tree agree sys apple,j314s apple,t6000 apple,arm-platform +expect agree aarch64 aarch64-apple "matching proc and sysfs trees agree" +pass "sysfs is the fallback for the device tree" + +write_tree both-vendors proc apple,j314s qcom,x1e80100 +expect_contradiction both-vendors aarch64 "a device tree naming Apple and Qualcomm fails" +write_tree sources-disagree proc apple,j314s apple,t6000 apple,arm-platform +write_tree sources-disagree sys lenovo,yoga-slim7x qcom,x1e80100 +expect_contradiction sources-disagree aarch64 "proc and sysfs naming different vendors fails" +write_tree vendor-vs-none proc apple,j314s apple,t6000 apple,arm-platform +write_tree vendor-vs-none sys linux,dummy-virt +expect_contradiction vendor-vs-none aarch64 "proc naming Apple while sysfs names nobody fails" +expect_contradiction m1-pro x86_64 "an Apple device tree on an x86 CPU fails" +expect_contradiction yoga-slim7x x86_64 "a Qualcomm device tree on an x86 CPU fails" +if TEST_ARCH=x86_64 OMARCHY_PROC_ROOT="$test_tmp/cases/m1-pro/proc" PATH="$stub_bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-hw-aarch64-apple" 2>/dev/null; then + fail "the Apple predicate fails closed on contradictory identity" +fi +pass "contradictory identity fails with an explanation" + +failing_uname="$test_tmp/failing-uname" +mkdir -p "$failing_uname" +printf '#!/bin/bash\nexit 1\n' >"$failing_uname/uname" +chmod +x "$failing_uname/uname" +if OMARCHY_PROC_ROOT="$test_tmp/cases/acpi/proc" PATH="$failing_uname:$PATH" "$detector" 2>"$test_tmp/error"; then + fail "an unreadable CPU architecture fails instead of guessing generic" +fi +grep -Fq "cannot read the CPU architecture" "$test_tmp/error" || fail "an unreadable CPU architecture explains itself" "$(cat "$test_tmp/error")" +pass "an unreadable CPU architecture fails instead of guessing generic" + +# The architecture predicates answer from the CPU alone: aarch64 covers every +# ARM family, Apple Silicon included, and x86 none of them. +for platform in x86 aarch64 aarch64-apple; do + fake_platform "$test_tmp/arch-$platform" "$platform" + arch_path="$test_tmp/arch-$platform/bin:$ROOT/bin:$PATH" + if [[ $platform == "x86" ]]; then + PATH=$arch_path omarchy-hw-x86 || fail "x86: omarchy-hw-x86 accepts it" + ! PATH=$arch_path omarchy-hw-aarch64 || fail "x86: omarchy-hw-aarch64 rejects it" + else + PATH=$arch_path omarchy-hw-aarch64 || fail "$platform: omarchy-hw-aarch64 accepts it" + ! PATH=$arch_path omarchy-hw-x86 || fail "$platform: omarchy-hw-x86 rejects it" + fi +done +pass "omarchy-hw-x86 and omarchy-hw-aarch64 split every platform by its CPU" diff --git a/test/shell.d/hw-touchpad-test.sh b/test/shell.d/hw-touchpad-test.sh new file mode 100755 index 00000000..27a86ed9 --- /dev/null +++ b/test/shell.d/hw-touchpad-test.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +touchpad="$ROOT/bin/omarchy-hw-touchpad" + +require_command jq + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +input_class="$test_tmp/input" +mkdir -p "$stub_bin" + +# Hyprland's mice, in its order, from a space-separated MICE. +cat >"$stub_bin/hyprctl" <<'SH' +#!/bin/bash + +jq -n --arg names "${MICE:-}" \ + '{mice: [$names | split(" ")[] | select(. != "") | {name: .}], keyboards: []}' +SH + +# udev's properties for an event device, kept beside it in the fixture. +cat >"$stub_bin/udevadm" <<'SH' +#!/bin/bash + +cat "${!#}/udev" 2>/dev/null +SH + +chmod +x "$stub_bin"/* + +# An event device as sysfs shows it: its input device's name, and what udev +# made of it. +event_device() { + mkdir -p "$input_class/$1/device" + printf '%s\n' "$2" >"$input_class/$1/device/name" + printf '%s\n' "$3" >"$input_class/$1/udev" +} + +detected() { + MICE="$1" OMARCHY_INPUT_CLASS_PATH="$input_class" PATH="$stub_bin:$PATH" bash "$touchpad" +} + +# Apple Silicon's trackpad is named after its MTP HID interface, so neither +# word is in its name; udev still calls it a touchpad. +event_device event1 "Apple MTP keyboard" "ID_INPUT_KEYBOARD=1" +event_device event2 "Apple MTP multi-touch" "ID_INPUT_TOUCHPAD=1" +[[ $(detected "apple-mtp-multi-touch") == "apple-mtp-multi-touch" ]] || + fail "the Apple Silicon trackpad is detected" "$(detected "apple-mtp-multi-touch")" +pass "the Apple Silicon trackpad is detected" + +# omarchy-toggle-input-device passes the name straight to hl.device, so a name +# that does not match leaves the menu entry hidden and the toggle dead. +for name in "elan-touchpad" "apple-magic-trackpad-2"; do + [[ $(detected "$name") == "$name" ]] || fail "the touchpads that already worked still do" "$(detected "$name")" +done +pass "the touchpads that already worked still do" + +# Some touchscreens' mouse interfaces are called multi-touch as well. A named +# touchpad wins over one listed first, and without one it takes udev calling +# the device a touchpad, not its name. +rm -rf "$input_class" +event_device event5 "ELAN9008:00 04F3:2C82 Multi-Touch" "ID_INPUT_TOUCHSCREEN=1" +event_device event7 "SYNA2BA6:00 06CB:CEF5 Touchpad" "ID_INPUT_TOUCHPAD=1" +[[ $(detected "elan9008:00-04f3:2c82-multi-touch syna2ba6:00-06cb:cef5-touchpad") == "syna2ba6:00-06cb:cef5-touchpad" ]] || + fail "a touchscreen listed first does not take the touchpad's place" "$(detected "elan9008:00-04f3:2c82-multi-touch syna2ba6:00-06cb:cef5-touchpad")" +[[ -z $(detected "elan9008:00-04f3:2c82-multi-touch") ]] || + fail "a multi-touch touchscreen alone is no touchpad" "$(detected "elan9008:00-04f3:2c82-multi-touch")" +pass "a multi-touch touchscreen is never taken for the touchpad" + +# Hyprland turns commas into dashes as well as spaces. +event_device event9 "Acme, Inc. Precision Pad" "ID_INPUT_TOUCHPAD=1" +[[ $(detected "acme--inc.-precision-pad") == "acme--inc.-precision-pad" ]] || + fail "a touchpad named with a comma is found by Hyprland's name for it" "$(detected "acme--inc.-precision-pad")" +pass "a touchpad named with a comma is found by Hyprland's name for it" + +# Hyprland adds -1, -2 and so on to a name another device already took, as when +# the MTP interface exposes the trackpad twice; that name is the one to toggle, +# and an unrelated name that merely starts the same is not. +event_device event11 "Apple MTP multi-touch" "ID_INPUT_TOUCHPAD=1" +[[ $(detected "apple-mtp-multi-touch-1") == "apple-mtp-multi-touch-1" ]] || + fail "a touchpad under Hyprland's numbered name is found" "$(detected "apple-mtp-multi-touch-1")" +[[ -z $(detected "apple-mtp-multi-touch-extra") ]] || + fail "a name that only starts with the touchpad's is not taken for it" "$(detected "apple-mtp-multi-touch-extra")" +pass "a touchpad under Hyprland's numbered name for a duplicate is found" + +# udev's touchpad has to be one of Hyprland's mice for its name to toggle. +[[ -z $(detected "logitech-mx-master-3") ]] || + fail "a udev touchpad Hyprland does not list is not reported" "$(detected "logitech-mx-master-3")" +pass "an ordinary mouse is not mistaken for a touchpad" + +[[ -z $(detected "") ]] || + fail "no touchpad answers with an empty detection" "$(detected "")" +pass "no touchpad answers with an empty detection" diff --git a/test/shell.d/hyprland-binding-conflicts-test.sh b/test/shell.d/hyprland-binding-conflicts-test.sh index 90af5845..f18b7538 100755 --- a/test/shell.d/hyprland-binding-conflicts-test.sh +++ b/test/shell.d/hyprland-binding-conflicts-test.sh @@ -151,7 +151,9 @@ cat > "$stub_bin/omarchy-default-dictation" <<'SH' #!/bin/bash echo voxtype SH -chmod +x "$stub_bin/omarchy-default-dictation" +# The dictation keys are bound only once the selected backend is installed. +printf '#!/bin/bash\nexit 0\n' > "$stub_bin/voxtype" +chmod +x "$stub_bin/omarchy-default-dictation" "$stub_bin/voxtype" bindings=$(PATH="$stub_bin:$PATH" list_bindings "$home") [[ -n $bindings ]] || fail "default bindings load for the conflict check" diff --git a/test/shell.d/hyprland-default-config-test.sh b/test/shell.d/hyprland-default-config-test.sh index 0e12178a..3fe76dbd 100644 --- a/test/shell.d/hyprland-default-config-test.sh +++ b/test/shell.d/hyprland-default-config-test.sh @@ -158,6 +158,10 @@ echo future-backend SH chmod +x "$missing_bin/omarchy-default-dictation" dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home") +if grep -Fq 'dictation' <<<"$dictation_output"; then fail "a selected backend that is not installed leaves application shortcuts available"; fi +printf '#!/bin/bash\nexit 0\n' > "$missing_bin/future-backend" +chmod +x "$missing_bin/future-backend" +dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home") for binding in \ $'SUPER + CTRL + X\tToggle dictation' \ $'F9\tStart dictation (push-to-talk)' \ @@ -166,7 +170,7 @@ for binding in \ $'ALT + Alt_R\tStop dictation (push-to-talk)'; do grep -Fq "$binding" <<<"$dictation_output" || fail "dictation bindings load for any selected backend" "$binding" done -pass "dictation bindings require a selection without hardcoding backends" +pass "dictation bindings require an installed selection without hardcoding backends" # Lazydocker is optional on new installs, while existing installs keep the hotkey. lazydocker_bin="$tmpdir/lazydocker-bin" diff --git a/test/shell.d/image-deferred-hardware-test.sh b/test/shell.d/image-deferred-hardware-test.sh new file mode 100644 index 00000000..741ef4d3 --- /dev/null +++ b/test/shell.d/image-deferred-hardware-test.sh @@ -0,0 +1,665 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Root never reads the fixture root, so as root the build and first boot below +# would act on this machine. A skip is a pass. +if (( EUID == 0 )); then + skip "running as root, where image setup ignores fixture roots; skipping" + exit 0 +fi + +umask 022 +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +unit_name=omarchy-provision-hardware.service +unit_source="$ROOT/install/provisioning/$unit_name" + +# omarchy-apply-hardware refuses anyone but root, and root never reads the +# fixture root. Run a copy whose only change is that check, so a normal user +# can build an image into a fixture root. +apply_hardware="$test_tmp/omarchy-apply-hardware" +sed 's/^if (( EUID != 0 )); then$/if false; then/' "$ROOT/bin/omarchy-apply-hardware" >"$apply_hardware" +chmod +x "$apply_hardware" +grep -q '^if false; then$' "$apply_hardware" || fail "the test copy of omarchy-apply-hardware drops only its root check" + +# Commands a hardware leaf would reach for. Any call means a leaf ran. +stub_bin="$test_tmp/stub-bin" +mkdir -p "$stub_bin" +for command in sudo pacman pacman-key systemctl lspci omarchy-pkg-add modinfo; do + cat >"$stub_bin/$command" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >>"$LEAF_CALLS" +exit 1 +SH +done +stub_rebuild() { + cat >"$stub_bin/$1" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >>"$REBUILDS" +SH + chmod +x "$stub_bin/$1" +} +stub_rebuild mkinitcpio +# Only the stubs count as present, so a machine with Limine never runs its own +# limine-mkinitcpio here. +cat >"$stub_bin/omarchy-cmd-present" <"$fixture/install/hardware/all.sh" <<'SH' +run_logged "$OMARCHY_INSTALL/hardware/a.sh" +run_logged "$OMARCHY_INSTALL/hardware/apple/b.sh" +run_logged "$OMARCHY_INSTALL/hardware/c.sh" +SH +cat >"$fixture/install/hardware/a.sh" <<'SH' +printf 'a user=%s path=%s\n' "${OMARCHY_INSTALL_USER-unset}" "$OMARCHY_PATH" >>"$RUNS" +mkdir -p "$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d" +echo 'MODULES+=(a)' >"$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d/a.conf" +SH +cat >"$fixture/install/hardware/apple/b.sh" <<'SH' +mkdir -p "$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d" +echo 'MODULES+=(b)' >"$OMARCHY_IMAGE_ROOT/etc/mkinitcpio.conf.d/b.conf" +[[ ! -e $FAIL_B ]] || return 1 +echo b >>"$RUNS" +SH +cat >"$fixture/install/hardware/c.sh" <<'SH' +echo c >>"$RUNS" +[[ ! -e $REQUEST_REBUILD ]] || touch "$OMARCHY_IMAGE_BOOT_REBUILD" +SH +export RUNS="$test_tmp/runs" FAIL_B="$test_tmp/fail-b" REQUEST_REBUILD="$test_tmp/request-rebuild" + +new_root() { + local root="$test_tmp/root-$1" + rm -rf "$root" + mkdir -p "$root/var/log" + printf '%s\n' "$root" +} + +write_manifest() { + local root=$1 body=${2:-$'format=1\nplatform=aarch64-apple\n'} + mkdir -p "$root/var/lib/omarchy/image" + chmod 0755 "$root/var/lib/omarchy/image" + printf '%s' "$body" >"$root/var/lib/omarchy/image/target" + chmod 0644 "$root/var/lib/omarchy/image/target" +} + +build() { + local root=$1 omarchy=${2:-$fixture} + OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$omarchy" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \ + PATH="$base_path" "$apply_hardware" --defer-provisioning +} + +first_boot() { + local root=$1 + OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \ + PATH="$base_path" "$ROOT/bin/omarchy-provision-hardware" +} + +reset_logs() { + rm -f "$RUNS" "$LEAF_CALLS" "$REBUILDS" "$FAIL_B" +} + +queue_of() { + cat "$1/var/lib/omarchy/image/deferred-steps" +} + +# --- The build --------------------------------------------------------------- + +# The real hardware setup: every leaf is queued in order and none of them runs. +reset_logs +root=$(new_root real) +write_manifest "$root" +output=$(build "$root" "$ROOT") || fail "an image build defers the real hardware setup" "$output" +expected=$(sed -n 's|^run_logged "\$OMARCHY_INSTALL/\(hardware/[^"]*\)"$|install/\1|p' "$ROOT/install/hardware/all.sh") +[[ -n $expected && $(queue_of "$root") == "$expected" ]] || + fail "an image build queues every hardware leaf in order" "expected: +$expected +queued: +$(queue_of "$root")" +[[ ! -e $LEAF_CALLS ]] || fail "an image build runs no hardware leaf" "$(cat "$LEAF_CALLS")" +[[ $output == *"Image build for aarch64-apple: deferred $(wc -l <<<"$expected") hardware steps to first boot"* ]] || + fail "an image build reports what it deferred" "$output" +pass "an image build queues every hardware leaf in order and runs none of them" + +[[ $(<"$unit_source") == "$(<"$root/etc/systemd/system/$unit_name")" ]] || + fail "an image build installs the first-boot hardware service" +[[ $(readlink "$root/etc/systemd/system/multi-user.target.wants/$unit_name") == "/etc/systemd/system/$unit_name" ]] || + fail "an image build enables the first-boot hardware service" +[[ -f $root/var/lib/omarchy/image/target && ! -e $root/var/lib/omarchy/image/target.booted ]] || + fail "an image build leaves its manifest for the first boot" +[[ $(stat -c '%a' "$root/var/lib/omarchy/image/deferred-steps") == "644" ]] || + fail "the queue is readable and only its owner writes it" +pass "an image build arms the first-boot hardware service" + +before=$(queue_of "$root") +build "$root" "$ROOT" >/dev/null || fail "an image build can run hardware setup again" +[[ $(queue_of "$root") == "$before" ]] || fail "running hardware setup again in the build queues the same steps" +pass "running hardware setup again in the build queues the same steps" + +# Without a manifest nothing is deferred: the leaves run as they always did. +reset_logs +root=$(new_root live) +build "$root" >/dev/null || fail "hardware setup without a manifest runs" +[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' ]] || fail "hardware setup without a manifest runs every leaf" "$(cat "$RUNS")" +[[ ! -e $root/var/lib/omarchy/image && ! -e $root/etc/systemd/system/$unit_name ]] || + fail "hardware setup without a manifest queues and arms nothing" +pass "hardware setup without a manifest runs every leaf and defers nothing" + +# Hardware setup that has to wait for a first boot in progress decides only +# once it has the lock: by then the manifest is retired and the leaves run here. +reset_logs +root=$(new_root race) +write_manifest "$root" +( + exec {lock}>>"$root/var/lib/omarchy/image/lock" + flock "$lock" + touch "$test_tmp/locked" + until [[ -e $test_tmp/release || ! -d $test_tmp ]]; do sleep 0.1; done + mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/target.booted" +) & +first_boot_pid=$! +until [[ -e $test_tmp/locked ]]; do sleep 0.1; done +build "$root" >/dev/null & +setup_pid=$! +sleep 1 +kill -0 "$setup_pid" 2>/dev/null || fail "hardware setup waits while a first boot holds the lock" +touch "$test_tmp/release" +wait "$first_boot_pid" +wait "$setup_pid" || fail "hardware setup runs after waiting for a first boot" +[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || + fail "hardware setup that waited for a first boot runs its leaves instead of queueing them" +pass "hardware setup waits for a first boot in progress and then runs on the machine" + +# A manifest that is not unambiguously root's own stops hardware setup outright. +check_refused() { + local description=$1 root=$2 + reset_logs + if build "$root" >/dev/null 2>&1; then + fail "hardware setup refuses $description" + fi + [[ ! -e $RUNS && ! -e $root/var/lib/omarchy/image/deferred-steps && ! -e $root/etc/systemd/system/$unit_name ]] || + fail "hardware setup refuses $description without running, queueing or arming anything" + pass "hardware setup refuses $description" +} + +root=$(new_root format) +write_manifest "$root" $'format=2\nplatform=aarch64-apple\n' +check_refused "a manifest of an unknown format" "$root" + +root=$(new_root platform) +write_manifest "$root" $'format=1\nplatform=apple\n' +check_refused "a manifest naming an unknown platform" "$root" + +root=$(new_root malformed) +write_manifest "$root" $'format=1\napple-silicon\n' +check_refused "a malformed manifest" "$root" + +root=$(new_root writable) +write_manifest "$root" +chmod 0666 "$root/var/lib/omarchy/image/target" +check_refused "a manifest others can write" "$root" + +root=$(new_root writable-dir) +write_manifest "$root" +chmod 0777 "$root/var/lib/omarchy/image" +check_refused "a manifest in a directory others can write" "$root" + +root=$(new_root symlink) +write_manifest "$root" +mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/real-target" +ln -s real-target "$root/var/lib/omarchy/image/target" +check_refused "a symlinked manifest" "$root" + +# --- The first boot ---------------------------------------------------------- + +reset_logs +root=$(new_root fresh) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +[[ $(queue_of "$root") == $'install/hardware/a.sh\ninstall/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] || + fail "the fixture image queues its three leaves" "$(queue_of "$root")" + +output=$(first_boot "$root") || fail "the first boot finishes the deferred hardware setup" "$output" +[[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' ]] || + fail "the first boot runs each deferred leaf once, in order, with no install user" "$(cat "$RUNS")" +[[ $output == *"First boot of an image built for aarch64-apple, on aarch64-apple hardware"* ]] || + fail "the first boot reports the image target and the live platform" "$output" +[[ ! -e $root/var/lib/omarchy/image/target && -f $root/var/lib/omarchy/image/target.booted ]] || + fail "the first boot retires the build manifest" +[[ ! -e $root/var/lib/omarchy/image/deferred-steps ]] || fail "the first boot empties the queue" +[[ ! -L $root/etc/systemd/system/multi-user.target.wants/$unit_name && -f $root/etc/systemd/system/$unit_name ]] || + fail "the first boot disables its service and keeps the unit, so a start job already queued is skipped rather than failed" +[[ $(cat "$REBUILDS") == "mkinitcpio -P" ]] || + fail "the first boot rebuilds the initramfs once after a leaf changed it" "$(cat "$REBUILDS" 2>/dev/null)" +[[ ! -e $root/var/lib/omarchy/image/initramfs-inputs ]] || fail "the first boot clears the rebuild it owed" +grep -q 'Completed: .*/install/hardware/c.sh' "$root/var/log/omarchy-install.log" || + fail "the first boot logs each step to the install log" +pass "the first boot runs the deferred hardware setup with no command from the owner" + +rm -f "$RUNS" "$REBUILDS" +output=$(first_boot "$root") || fail "running the first-boot hardware setup again succeeds" +[[ -z $output && ! -e $RUNS && ! -e $REBUILDS ]] || + fail "running the first-boot hardware setup again does nothing" "$output" +pass "running the first-boot hardware setup again does nothing" + +# The live platform is asked once the manifest is retired, so it is the +# hardware's even where the detector cannot tell a booted root (this fixture +# runs no systemd): an Apple Silicon image booted in a VM reports plain aarch64. +fake_platform "$test_tmp/vm" aarch64 +reset_logs +root=$(new_root vm) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds for a VM boot" +output=$(OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/vm/proc" \ + PATH="$test_tmp/vm/bin:$stub_bin:$ROOT/bin:/usr/local/bin:/usr/bin:/bin" "$ROOT/bin/omarchy-provision-hardware") || + fail "the first boot on other hardware finishes" "$output" +[[ $output == *"First boot of an image built for aarch64-apple, on aarch64 hardware"* ]] || + fail "the first boot reports the hardware it runs on, not the image target" "$output" +pass "the first boot reports the hardware it runs on, not the image target" + +# A failed step stays queued with everything after it; the next boot resumes there. +reset_logs +root=$(new_root resume) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +# A repository without a sync database, which the image never has: the failed +# step is not retried after a fetch. +mkdir -p "$root/etc" +printf '[options]\nArchitecture = auto\n\n[core]\nServer = https://mirror.invalid/$arch/$repo\n' >"$root/etc/pacman.conf" +touch "$FAIL_B" +status=0 +output=$(first_boot "$root" 2>&1) || status=$? +(( status == 75 )) || fail "a failed step exits 75, so a platform first boot can tell it from a refusal" "status $status: $output" +[[ $output == *"Deferred hardware step failed: install/hardware/apple/b.sh"* ]] || + fail "the first boot names the failed step" "$output" +[[ $(cat "$RUNS") == "a user= path=$fixture" ]] || fail "the first boot stops at the failed step" "$(cat "$RUNS")" +[[ $(queue_of "$root") == $'install/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] || + fail "the failed step and those after it stay queued" "$(queue_of "$root")" +[[ -f $root/var/lib/omarchy/image/target.booted && ! -e $root/var/lib/omarchy/image/target ]] || + fail "the machine stops being an image build once its first boot starts" +[[ -L $root/etc/systemd/system/multi-user.target.wants/$unit_name && -e $root/var/lib/omarchy/image/initramfs-inputs && ! -e $REBUILDS ]] || + fail "the service stays armed and the initramfs rebuild stays owed until the queue is empty" +[[ ! -e $LEAF_CALLS ]] || fail "a failed step fetches no package databases" "$(cat "$LEAF_CALLS")" +pass "a failed step stays queued with the steps after it" + +rm -f "$FAIL_B" "$RUNS" +stub_rebuild limine-mkinitcpio +output=$(first_boot "$root") || fail "the next boot finishes the deferred hardware setup" "$output" +[[ $(cat "$RUNS") == $'b\nc' ]] || fail "the next boot resumes at the failed step" "$(cat "$RUNS")" +[[ $(cat "$REBUILDS") == "limine-mkinitcpio " ]] || + fail "the next boot rebuilds the initramfs the earlier boot owed, through Limine when present" "$(cat "$REBUILDS" 2>/dev/null)" +[[ ! -e $root/var/lib/omarchy/image/deferred-steps && ! -L $root/etc/systemd/system/multi-user.target.wants/$unit_name ]] || + fail "the next boot disarms the service" +pass "the next boot resumes at the failed step and finishes" + +# The step that changed the initramfs inputs is the one that failed: its rerun +# changes nothing more, and the rebuild is still owed. +reset_logs +rm -f "$stub_bin/limine-mkinitcpio" +root=$(new_root rebuild) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/apple/b.sh install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" +touch "$FAIL_B" +first_boot "$root" >/dev/null 2>&1 && fail "the first boot reports the failed step" +rm -f "$FAIL_B" +first_boot "$root" >/dev/null || fail "the next boot finishes the deferred hardware setup" +[[ $(cat "$RUNS") == $'b\nc' && $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] || + fail "a step that changed the initramfs and then failed still gets it rebuilt" "$(cat "$REBUILDS" 2>/dev/null)" +pass "a step that changed the initramfs and then failed still gets it rebuilt" + +# A step that only adds to the kernel command line Limine's entry tool builds +# (as intel/fred.sh does) gets the rebuild too. +reset_logs +root=$(new_root cmdline) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +cat >"$fixture/install/hardware/cmdline.sh" <<'SH' +mkdir -p "$OMARCHY_IMAGE_ROOT/etc/limine-entry-tool.d" +echo 'KERNEL_CMDLINE[default]+=" example=on"' >"$OMARCHY_IMAGE_ROOT/etc/limine-entry-tool.d/example.conf" +SH +printf '%s\n' install/hardware/cmdline.sh >"$root/var/lib/omarchy/image/deferred-steps" +first_boot "$root" >/dev/null || fail "a step that changes the kernel command line finishes" +[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] || + fail "a step that only changed Limine's kernel command line gets the rebuild" "$(cat "$REBUILDS" 2>/dev/null)" +rm -f "$fixture/install/hardware/cmdline.sh" +pass "a step that only changed Limine's kernel command line gets the rebuild" + +# Module options reach the initramfs through mkinitcpio's modconf hook, so a +# step that only writes /etc/modprobe.d (as nvidia.sh does) gets the rebuild too. +reset_logs +root=$(new_root modprobe) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +cat >"$fixture/install/hardware/modprobe.sh" <<'SH' +mkdir -p "$OMARCHY_IMAGE_ROOT/etc/modprobe.d" +echo 'options example flag=1' >"$OMARCHY_IMAGE_ROOT/etc/modprobe.d/example.conf" +SH +printf '%s\n' install/hardware/modprobe.sh >"$root/var/lib/omarchy/image/deferred-steps" +first_boot "$root" >/dev/null || fail "a step that changes module options finishes" +[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" ]] || + fail "a step that only changed module options gets the rebuild" "$(cat "$REBUILDS" 2>/dev/null)" +rm -f "$fixture/install/hardware/modprobe.sh" +pass "a step that only changed module options gets the rebuild" + +# --- The pacman keyring ------------------------------------------------------- + +# Install finalization makes the pacman keyring on plain aarch64. +# An image build asks for it instead, so every machine flashed from the image +# makes its own master key on its first boot, before any step installs a package. +keyring_bin="$test_tmp/keyring-bin" +mkdir -p "$keyring_bin" "$fixture/install/post-install" +# Finalization's repositories are pacman-templates-test.sh's; here they land in +# the root under test, never the host's /etc. +sed 's|/etc/pacman|$OMARCHY_IMAGE_ROOT/etc/pacman|g' "$ROOT/install/post-install/pacman.sh" >"$fixture/install/post-install/pacman.sh" +cp "$ROOT/install/helpers/pacman.sh" "$fixture/install/helpers/pacman.sh" +mkdir -p "$fixture/default" +cp -r "$ROOT/default/pacman" "$fixture/default/" +: >"$fixture/install/hardware/pacman.sh" +for command in pacman-key omarchy-pkg-add; do + cat >"$keyring_bin/$command" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >>"$KEYRING" +[[ ${0##*/} != "pacman-key" || ! -e $KEYRING_FAIL ]] +SH +done +printf '#!/bin/bash\necho "$PLATFORM"\n' >"$keyring_bin/omarchy-hw-platform" +chmod +x "$keyring_bin"/* +export KEYRING="$test_tmp/keyring" KEYRING_FAIL="$test_tmp/keyring-fail" +request=var/lib/omarchy/image/pacman-keyring + +finalize() { + mkdir -p "$1/etc/pacman.d" + OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$fixture" OMARCHY_INSTALL="$fixture/install" PLATFORM=$2 \ + PATH="$keyring_bin:$base_path" bash -e -c 'source "$1"' bash "$fixture/install/post-install/pacman.sh" +} + +keyring_boot() { + OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \ + PATH="$keyring_bin:$base_path" "$ROOT/bin/omarchy-provision-hardware" +} + +for platform in aarch64; do + rm -f "$KEYRING" + root=$(new_root "install-$platform") + finalize "$root" "$platform" || fail "$platform: install finalization succeeds" + [[ $(cat "$KEYRING") == $'omarchy-pkg-add archlinuxarm-keyring\npacman-key --init\npacman-key --populate' ]] || + fail "$platform: an install makes its pacman keyring in finalization, as before" "$(cat "$KEYRING")" + [[ ! -e $root/$request ]] || fail "$platform: an install requests no first-boot keyring" + + rm -f "$KEYRING" + root=$(new_root "image-$platform") + write_manifest "$root" $'format=1\nplatform='"$platform"$'\n' + build "$root" >/dev/null || fail "$platform: the fixture image builds" + finalize "$root" "$platform" || fail "$platform: image finalization succeeds" + [[ $(cat "$KEYRING") == "omarchy-pkg-add archlinuxarm-keyring" ]] || + fail "$platform: an image build makes no pacman keyring" "$(cat "$KEYRING")" + [[ -f $root/$request ]] || fail "$platform: an image build asks its first boot for the keyring" +done +for platform in aarch64-apple x86; do + rm -f "$KEYRING" + root=$(new_root "image-$platform") + write_manifest "$root" $'format=1\nplatform='"$platform"$'\n' + build "$root" >/dev/null || fail "$platform: the fixture image builds" + finalize "$root" "$platform" || fail "$platform: image finalization succeeds" + finalize "$(new_root "install-$platform")" "$platform" || fail "$platform: install finalization succeeds" + [[ ! -e $KEYRING && ! -e $root/$request ]] || fail "$platform: finalization leaves the keyring alone" "$(cat "$KEYRING" 2>/dev/null)" +done +pass "an image build on plain aarch64 leaves the pacman keyring to the first boot" + +reset_logs +rm -f "$KEYRING" +root=$(new_root image-aarch64-apple) +write_manifest "$root" +build "$root" >/dev/null || fail "the Mac fixture image builds" +keyring_boot "$root" >/dev/null || fail "a Mac image's first boot finishes" +[[ ! -e $KEYRING ]] || fail "a first boot nobody asked for a keyring leaves the keyring alone" "$(cat "$KEYRING")" +pass "a first boot nobody asked for a keyring leaves the keyring alone" + +reset_logs +root=$test_tmp/root-image-aarch64 +mkdir -p "$root/etc/pacman.d/gnupg" +touch "$root/etc/pacman.d/gnupg/build-master-key" "$KEYRING_FAIL" +status=0 +output=$(KEYRING=$RUNS keyring_boot "$root" 2>&1) || status=$? +(( status == 75 )) || fail "a keyring that can't be made exits 75" "status $status: $output" +[[ $output == *"could not create the pacman keyring"* ]] || fail "the first boot says the keyring failed" "$output" +[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init" ]] || + fail "no step runs before the keyring exists" "$(cat "$RUNS")" +[[ -f $root/$request && $(queue_of "$root") == $'install/hardware/a.sh\ninstall/hardware/apple/b.sh\ninstall/hardware/c.sh' ]] || + fail "the keyring request and the whole queue stay for the next boot" +pass "a keyring that can't be made stays requested and holds back every step" + +rm -f "$KEYRING_FAIL" "$RUNS" +mkdir -p "$root/etc/pacman.d/gnupg" +touch "$root/etc/pacman.d/gnupg/build-master-key" +output=$(KEYRING=$RUNS keyring_boot "$root") || fail "the next boot makes the keyring and finishes" "$output" +[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init"$'\n'"pacman-key --gpgdir $root/etc/pacman.d/gnupg --populate"$'\na user= path='"$fixture"$'\nb\nc' ]] || + fail "the first boot makes the keyring, then runs the steps" "$(cat "$RUNS")" +[[ ! -e $root/etc/pacman.d/gnupg/build-master-key ]] || fail "the first boot replaces a keyring the build left" +[[ ! -e $root/$request && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || fail "the first boot clears the keyring request" +pass "the first boot replaces any build keyring with the machine's own before the steps run" + +# A keyring request with nothing queued (a build whose hardware setup queued no +# step) still gets its keyring. +reset_logs +rm -f "$RUNS" +root=$test_tmp/root-keyring-only +mkdir -p "$root/var/lib/omarchy/image" +install -m 0644 /dev/null "$root/$request" +output=$(KEYRING=$RUNS keyring_boot "$root") || fail "a first boot with only a keyring request finishes" "$output" +[[ $(cat "$RUNS") == "pacman-key --gpgdir $root/etc/pacman.d/gnupg --init"$'\n'"pacman-key --gpgdir $root/etc/pacman.d/gnupg --populate" && ! -e $root/$request ]] || + fail "a keyring request is honoured with no queued step" "$(cat "$RUNS" 2>/dev/null)" +grep -qx 'ConditionPathExists=|/var/lib/omarchy/image/pacman-keyring' "$ROOT/install/provisioning/omarchy-provision-hardware.service" || + fail "the first-boot unit also starts for a keyring request alone" +pass "a keyring request with no queued step still gets the machine's own keyring" + +# A leaf a later Omarchy no longer ships is dropped; a queue entry outside the +# hardware setup stops the run before anything runs. +reset_logs +root=$(new_root gone) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/removed.sh install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" +output=$(first_boot "$root") || fail "a step no longer shipped does not block the rest" "$output" +[[ $output == *"Skipping deferred hardware step install/hardware/removed.sh"* && $(cat "$RUNS") == "c" ]] || + fail "a step no longer shipped is skipped and the rest run" "$output" +pass "a deferred step Omarchy no longer ships is skipped" + +# A step that would build the boot image itself asks for the rebuild after the +# last step instead: one rebuild, even when no +# initramfs input changed, and the request outlives a run that stopped early. +reset_logs +root=$(new_root request) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" +first_boot "$root" >/dev/null || fail "a step that asks for nothing finishes" +[[ ! -e $REBUILDS ]] || fail "no rebuild without a changed input or a request" "$(cat "$REBUILDS")" +reset_logs +root=$(new_root request) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" +touch "$REQUEST_REBUILD" +first_boot "$root" >/dev/null || fail "a step that asks for the rebuild finishes" +rm -f "$REQUEST_REBUILD" +[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" && ! -e $root/var/lib/omarchy/image/boot-rebuild ]] || + fail "the request alone, with no changed input, gets one rebuild and is then cleared" "$(cat "$REBUILDS" 2>/dev/null)" +reset_logs +root=$(new_root request) +write_manifest "$root" +build "$root" >/dev/null || fail "the fixture image builds" +printf '%s\n' install/hardware/c.sh install/hardware/apple/b.sh >"$root/var/lib/omarchy/image/deferred-steps" +rm -f "$root/etc/mkinitcpio.conf.d/b.conf" +touch "$REQUEST_REBUILD" "$FAIL_B" +first_boot "$root" >/dev/null 2>&1 && fail "the first boot reports the failed step" +[[ -f $root/var/lib/omarchy/image/boot-rebuild && ! -e $REBUILDS ]] || + fail "a requested rebuild waits for the queue to empty, and stays requested" +rm -f "$FAIL_B" "$REQUEST_REBUILD" +first_boot "$root" >/dev/null || fail "the next boot finishes the deferred hardware setup" +[[ $(cat "$REBUILDS" 2>/dev/null) == "mkinitcpio -P" && ! -e $root/var/lib/omarchy/image/boot-rebuild ]] || + fail "the requested rebuild runs once after the last step and is then cleared" "$(cat "$REBUILDS" 2>/dev/null)" +pass "a step can ask for the one rebuild after the last step" + +for entry in /etc/passwd install/hardware/../../bin/x.sh install/login/sddm.sh; do + reset_logs + root=$(new_root unsafe) + write_manifest "$root" + build "$root" >/dev/null || fail "the fixture image builds" + printf '%s\n' "$entry" install/hardware/c.sh >"$root/var/lib/omarchy/image/deferred-steps" + status=0 + first_boot "$root" >/dev/null 2>&1 || status=$? + (( status == 1 )) || fail "the first boot refuses the queue entry $entry with status 1, not a step failure's 75" "status $status" + [[ ! -e $RUNS && $(head -n 1 "$root/var/lib/omarchy/image/deferred-steps") == "$entry" ]] || + fail "the first boot refuses the queue entry $entry before running anything" +done +pass "the first boot runs only hardware setup leaves from its queue" + +reset_logs +root=$(new_root nothing) +output=$(first_boot "$root") || fail "the first-boot hardware setup succeeds with nothing deferred" +[[ -z $output && ! -e $root/var/lib/omarchy ]] || fail "the first-boot hardware setup does nothing with nothing deferred" "$output" +pass "with nothing deferred the first-boot hardware setup does nothing" + +if env -u OMARCHY_IMAGE_ROOT OMARCHY_PATH="$fixture" PATH="$base_path" "$ROOT/bin/omarchy-provision-hardware" >/dev/null 2>&1; then + fail "the first-boot hardware setup refuses a normal user" +fi +pass "the first-boot hardware setup refuses a normal user" + +# The service fires on the queue this helper writes and hands the machine to +# owner setup and the login screen only after it. +grep -qx 'ConditionPathExists=|/var/lib/omarchy/image/deferred-steps' "$unit_source" && + grep -qx 'ExecStart=/usr/bin/omarchy-provision-hardware' "$unit_source" && + grep -qx 'Before=omarchy-provision-owner.service display-manager.service' "$unit_source" && + grep -qx 'WantedBy=multi-user.target' "$unit_source" || + fail "the first-boot hardware service runs on the queue before owner setup and the login screen" +pass "the first-boot hardware service runs on the queue before owner setup and the login screen" + +# --- Bluetooth on the first boot --------------------------------------------- + +# The adapter brings up bluetooth.target before the first boot runs the +# Bluetooth leaf, and a target never starts a unit enabled after its start job +# was made, so that boot has to start bluetooth.service itself. Nothing else +# does: an install or a rerun of hardware setup leaves the service as it finds it. +bt_fixture="$test_tmp/omarchy-bluetooth" +mkdir -p "$bt_fixture/install/helpers" "$bt_fixture/install/provisioning" "$bt_fixture/install/hardware" +cp "$ROOT/install/helpers/logging.sh" "$ROOT/install/helpers/image-target.sh" "$bt_fixture/install/helpers/" +cp "$unit_source" "$bt_fixture/install/provisioning/" +cp "$ROOT/install/hardware/bluetooth.sh" "$bt_fixture/install/hardware/" +cp "$fixture/install/hardware/c.sh" "$bt_fixture/install/hardware/" +printf 'run_logged "$OMARCHY_INSTALL/hardware/%s"\n' bluetooth.sh c.sh >"$bt_fixture/install/hardware/all.sh" + +bt_bin="$test_tmp/bt-bin" +mkdir -p "$bt_bin" +cat >"$bt_bin/systemctl" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$SYSTEMCTL_CALLS" +case $* in + "start --no-block bluetooth.service") [[ ! -e $BT_START_REFUSED ]] ;; + *) exit 0 ;; +esac +SH +chmod +x "$bt_bin/systemctl" +export SYSTEMCTL_CALLS="$test_tmp/systemctl-calls" BT_START_REFUSED="$test_tmp/bt-start-refused" +bt_path="$bt_bin:$base_path" + +bt_image() { + local root + root=$(new_root "bt-$1") + write_manifest "$root" + OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$bt_fixture" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \ + PATH="$bt_path" "$apply_hardware" --defer-provisioning >/dev/null || fail "the Bluetooth fixture image builds" + printf '%s\n' "$root" +} + +bt_first_boot() { + rm -f "$SYSTEMCTL_CALLS" "$RUNS" + OMARCHY_IMAGE_ROOT="$1" OMARCHY_PATH="$bt_fixture" OMARCHY_PROC_ROOT="$test_tmp/hw/proc" \ + PATH="$bt_path" "$ROOT/bin/omarchy-provision-hardware" >/dev/null 2>&1 +} + +started=$'enable bluetooth.service\nstart --no-block bluetooth.service' + +reset_logs +rm -f "$BT_START_REFUSED" "$SYSTEMCTL_CALLS" +root=$(bt_image started) +[[ ! -e $SYSTEMCTL_CALLS ]] || fail "an image build touches no Bluetooth unit" "$(cat "$SYSTEMCTL_CALLS")" +bt_first_boot "$root" || fail "the first boot finishes the Bluetooth leaf" +[[ $(<"$SYSTEMCTL_CALLS") == "$started" ]] || + fail "the first boot enables Bluetooth and starts it without waiting for the job" "$(<"$SYSTEMCTL_CALLS")" +[[ $(cat "$RUNS") == c && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || + fail "the first boot goes on past the Bluetooth leaf" +pass "the first boot enables Bluetooth and starts it in the same session" + +touch "$BT_START_REFUSED" +root=$(bt_image refused) +bt_first_boot "$root" || fail "a refused Bluetooth start does not fail the first boot" +[[ $(<"$SYSTEMCTL_CALLS") == "$started" && $(cat "$RUNS") == c && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || + fail "a refused Bluetooth start leaves the step done and the queue going" "$(<"$SYSTEMCTL_CALLS")" +pass "a refused Bluetooth start does not fail the first boot" +rm -f "$BT_START_REFUSED" + +# A later boot that retries the queue is still the machine's own first setup. +root=$(bt_image retry) +mv "$root/var/lib/omarchy/image/target" "$root/var/lib/omarchy/image/target.booted" +bt_first_boot "$root" || fail "a retried queue finishes" +[[ $(<"$SYSTEMCTL_CALLS") == "$started" ]] || + fail "a retried queue starts Bluetooth like the first run" "$(<"$SYSTEMCTL_CALLS")" +pass "a retried queue starts Bluetooth like the first run" + +# Hardware setup outside the first boot, even with the marker inherited. +rm -f "$SYSTEMCTL_CALLS" "$RUNS" +root=$(new_root bt-live) +OMARCHY_IMAGE_DEFERRED_HARDWARE=1 OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$bt_fixture" \ + OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" PATH="$bt_path" "$apply_hardware" --defer-provisioning >/dev/null || + fail "hardware setup without a manifest runs the Bluetooth leaf" +[[ $(<"$SYSTEMCTL_CALLS") == "enable bluetooth.service" && $(cat "$RUNS") == c ]] || + fail "hardware setup outside the first boot only enables Bluetooth" "$(<"$SYSTEMCTL_CALLS")" +pass "hardware setup outside the first boot only enables Bluetooth, as an install always has" + +# --- Root ignores the fixture root ------------------------------------------ + +# As namespaced root the fixture root in the environment is ignored: the +# fixture image is not built into, and hardware setup runs as on any live system. +if [[ -e /var/lib/omarchy/image ]]; then + pass "this machine keeps image state of its own; skipping the root override probe" +elif unshare --user --map-root-user true 2>/dev/null; then + root_runner=(unshare --user --map-root-user) + + reset_logs + root=$(new_root root-build) + write_manifest "$root" + OMARCHY_IMAGE_ROOT="$root" OMARCHY_PATH="$fixture" OMARCHY_INSTALL_LOG_FILE="$root/var/log/omarchy-install.log" \ + PATH="$base_path" "${root_runner[@]}" "$ROOT/bin/omarchy-apply-hardware" --defer-provisioning >/dev/null || + fail "root runs hardware setup with a fixture root in its environment" + [[ $(cat "$RUNS") == $'a user= path='"$fixture"$'\nb\nc' && ! -e $root/var/lib/omarchy/image/deferred-steps ]] || + fail "root ignores a fixture manifest named by its environment" + + # Both commands take every path from the helper, which as root answers with + # the live ones whatever the environment says. + resolved=$(OMARCHY_IMAGE_ROOT="$root" "${root_runner[@]}" bash -c \ + 'source "$1"; omarchy_image_init && printf "%s\n" "$omarchy_image_manifest" "$omarchy_image_queue" "$omarchy_image_systemd_dir"' \ + bash "$ROOT/install/helpers/image-target.sh") || fail "root resolves the image paths" + [[ $resolved == $'/var/lib/omarchy/image/target\n/var/lib/omarchy/image/deferred-steps\n/etc/systemd/system' ]] || + fail "root resolves the live image paths whatever its environment names" "$resolved" + pass "root ignores fixture roots in its environment" +else + pass "no unprivileged user namespace; skipping the root override probe" +fi diff --git a/test/shell.d/image-target-platform-test.sh b/test/shell.d/image-target-platform-test.sh new file mode 100644 index 00000000..3b8b4092 --- /dev/null +++ b/test/shell.d/image-target-platform-test.sh @@ -0,0 +1,442 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# omarchy-hw-platform answers with the image-target manifest while a root is +# being built, and with the hardware once it boots. Each world below is what one +# detector run sees: image/ is the root, proc/ its /proc (the host's device tree +# and PID 1), bin/ a uname for the CPU it runs on. + +detector="$ROOT/bin/omarchy-hw-platform" +umask 022 +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +apple_manifest=$'format=1\nplatform=aarch64-apple\n' + +# $1 world, $2 CPU, then the host's device-tree tokens (none: no device tree). +world() { + local dir="$test_tmp/worlds/$1" cpu=$2 + shift 2 + rm -rf "$dir" + mkdir -p "$dir/image" "$dir/proc" "$dir/bin" "$dir/host" + if (( $# )); then + mkdir -p "$dir/proc/device-tree" + printf '%s\0' "$@" >"$dir/proc/device-tree/compatible" + fi + cat >"$dir/bin/uname" <