Converge omarchy-mac and omarchy-mx-mac into upstream Omarchy (#14431)

* 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-<name>'),
<name> 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 "<keysym> <name>" 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 <app> 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 <app> 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 <path>... 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-<platform>.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 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* 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 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* 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 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* 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 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* 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 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@openai.com>
Co-Authored-By: Codex Medium <noreply@openai.com>

* 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 <noreply@openai.com>
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 <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* 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 <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* 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 <noreply@openai.com>

* 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 <noreply@openai.com>

Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* 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 <noreply@openai.com>

Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* 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 <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* 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-<family>
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 <maralc@gmail.com>
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 <scottajones@gmail.com>
Co-authored-by: Marcelo Alcantara <marcelo.alcantara@bp.com>
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 <ryan@heyoodle.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
170 files changed
+13030 -366

No files matched your search

+24
View File
@@ -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-<family>` (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-<family>`) - 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-<platform>.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/<platform>/` for each ARM platform.
- Hardware setup: `install/hardware/<vendor-or-family>/`, 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
+2
View File
@@ -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.
+15 -1
View File
@@ -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
+9 -1
View File
@@ -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
+17 -4
View File
@@ -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
+10 -4
View File
@@ -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
+27 -6
View File
@@ -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"
+28 -1
View File
@@ -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
+307 -48
View File
@@ -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=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording]
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--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"
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# omarchy:summary=Check or signal the current user's recording processes
# omarchy:args=[--pid <pid>] [--signal INT|KILL]
# omarchy:hidden=true
usage() {
echo "Usage: omarchy-capture-screenrecording-process [--pid <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
+31 -3
View File
@@ -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
+419 -19
View File
@@ -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 <slot>"
else
echo "Remove the keys you don't use: sudo cryptsetup luksKillSlot $root_drive <slot>"
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 <slot> $root_drive) and remove each one it opens (sudo cryptsetup luksKillSlot $root_drive <slot>). 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=<slot> recovery=<slot>, 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) </dev/tty' bash "$drive_to_change"
else
if [[ -z $drive ]]; then
echo "No drive selected."
return 0
fi
if [[ -n $root_drive && $(readlink -f "$drive") == "$root_drive" ]]; then
system_drive=true
drive=$root_drive
fi
current=$(gum input --password --header "Current encryption password") || return 1
old_slot=$(drive_key_slot "$drive" "$current") || status=$?
if (( status == 2 )); then
echo "That password does not open $drive."
return 1
elif (( status != 0 )); then
echo "Could not check the password against $drive."
return 1
fi
if [[ $system_drive == "true" ]]; then
owner_slot_matches "$old_slot" || { echo "The system disk password did not change."; return 1; }
# An interrupted change is finished from the one slot the password opened;
# another copy of it would outlive the change.
others=$(other_key_slots "$drive" "$current" "$old_slot") || { echo "Could not check the key slots of $drive."; return 1; }
if [[ -n $others ]]; then
echo "That password also opens key slot $others on $drive. Remove the extra copy first (sudo cryptsetup luksKillSlot $drive <slot>), 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=<slot> recovery=<slot>, 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
+6
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Detect whether the CPU is aarch64 (every ARM machine, Apple Silicon included).
[[ $(uname -m) == "aarch64" ]]
+17
View File
@@ -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" ]]
+15
View File
@@ -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"
+40 -6
View File
@@ -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
+26 -2
View File
@@ -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
+40 -4
View File
@@ -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
+202
View File
@@ -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-<family>, 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,<model>" from Asahi's m1n1 on every
# Apple Silicon Mac, "qcom,<soc>" 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
+26 -1
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Detect whether the CPU is x86_64.
[[ $(uname -m) == "x86_64" ]]
+1
View File
@@ -10,6 +10,7 @@ sync_clamshell() {
(
flock -n 9 || exit 0
omarchy-hyprland-monitor-clamshell
omarchy-system-lid-inhibit
) 9>"$LOCK"
}
+22
View File
@@ -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"
;;
*)
+4 -1
View File
@@ -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
+1
View File
@@ -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..."
+200
View File
@@ -0,0 +1,200 @@
#!/bin/bash -p
# omarchy:summary=Run a fixed lifecycle operation through the platform's own packages
# omarchy:args=[--resolve] <operation> [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] <operation> [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" "$@"
+25 -2
View File
@@ -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
# "<keysym> <name>" 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 }'
}
+53
View File
@@ -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
+4
View File
@@ -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" \
+162
View File
@@ -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; } </dev/null >>"$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 </dev/null >>"$OMARCHY_INSTALL_LOG_FILE" 2>&1
else
mkinitcpio -P </dev/null >>"$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"
+255 -90
View File
@@ -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"
+16 -4
View File
@@ -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
+8 -6
View File
@@ -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[@]}"
+1
View File
@@ -5,6 +5,7 @@
set -e
omarchy-lifecycle-dispatch pre-remove steam
omarchy-pkg-drop steam
rm -rf \
+8
View File
@@ -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
@@ -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"
+165 -6
View File
@@ -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
+3 -2
View File
@@ -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
+100
View File
@@ -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
+15 -1
View File
@@ -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
+39
View File
@@ -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
+25 -1
View File
@@ -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.
@@ -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%/*}"
+5
View File
@@ -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[@]}"
+19 -1
View File
@@ -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"
+6
View File
@@ -4,6 +4,12 @@
# omarchy:args=<install|remove|launch|stop|status> [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
+9 -1
View File
@@ -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
+3
View File
@@ -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")
+11 -11
View File
@@ -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"},
@@ -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
@@ -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
+3
View File
@@ -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
+36
View File
@@ -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
+27
View File
@@ -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.
+68 -1
View File
@@ -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=<omarchy-hw-platform value>`), 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 "<keysym> <name>" 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 <glob>`: 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 <name>`: 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*-<connector>/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": ["<pattern>", "..."],
"replaced": [{ "node": "<pattern>", "by": "<pattern>" }]
}
```
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=<omarchy-hw-platform value>`, 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-<platform>.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 <glob>`: 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 <name>`: 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*-<connector>/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:
+164
View File
@@ -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 <operation> [arguments...]
omarchy-lifecycle-dispatch --resolve <operation>
```
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: <operation> on <platform> needs <package>, which provides <path>; 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: <operation> on <platform> needs <path>, which <package> <version> does not provide; update <package>` | 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: <operation> runs as the user, never as root` | same error |
| The entrypoint fails the trust rules | exit 1: `Error: refusing <path>: ...` (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-root> [<luks-device>]` | 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-root>` | 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 <path>...` | 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=<slot> [recovery=<slot>]` 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 <app>` | Runs as the user, from an installer once `<app>` 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 <app>` | Runs as the user, from a remover before `<app>`'s packages go: `steam` (`omarchy-remove-gaming-steam`) | Undoes what `post-install <app>` 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 `<implementation directory>/<operation>`. 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 <operation> || fail`. It is a no-op on platforms without a boot package.
- **A platform implementation that replaces a generic step:** `--resolve <operation>`. 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=<slot> 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 <path>...` (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 <browser>` 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=<slot>`, 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=<slot> recovery=<slot>`) 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 `<keysym> <name>` 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.
+10
View File
@@ -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
+32 -2
View File
@@ -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-<channel>.conf` and
`mirrorlist-<channel>`; 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 (<hash>)` 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. |
@@ -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
+2 -1
View File
@@ -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
@@ -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
+1
View File
@@ -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"
+50
View File
@@ -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"
+2
View File
@@ -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"
@@ -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 ||
+4 -2
View File
@@ -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"
+10
View File
@@ -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
+6 -4
View File
@@ -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
+13
View File
@@ -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
+164
View File
@@ -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"
}
+32
View File
@@ -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 <platform>'s pacman-<channel>.conf and
# mirrorlist-<channel>. 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
}
+26
View File
@@ -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
+4
View File
@@ -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
+4
View File
@@ -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
+35 -3
View File
@@ -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.
+290
View File
@@ -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 <owner> 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
}
@@ -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
+3
View File
@@ -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"
+8
View File
@@ -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
+10
View File
@@ -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
+1 -1
View File
@@ -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
+7
View File
@@ -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
+4 -3
View File
@@ -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
+4 -1
View File
@@ -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
+41
View File
@@ -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()
}
}
+96
View File
@@ -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": ["<pattern>", ...],
// "replaced": [{ "node": "<pattern>", "by": "<pattern>" }, ...] }
//
// 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 <id>` 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
}
}
+8 -1
View File
@@ -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
}
+152
View File
@@ -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()
}
}
}
+2
View File
@@ -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
+105 -11
View File
@@ -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
+113
View File
@@ -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,
@@ -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
}
+26 -5
View File
@@ -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)))
}
}
@@ -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
}
}
+7 -3
View File
@@ -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
+12 -1
View File
@@ -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
}
}
+67 -18
View File
@@ -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)
+11
View File
@@ -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
}
}
+14
View File
@@ -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 = ({})
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more