omarchy Documentation

Beautiful, Modern & Opinionated Linux

44,817 stars Shell Shell 1 file · ~66,135 tokens
RAW Doc

Technical Documentation: basecamp/omarchy

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (README.md)

Omarchy

Omarchy is a beautiful, fun & agentic Linux distribution by DHH.

Read more at omarchy.org.

The Omarchy Manual

The manual lives in manual/, which is its authoritative source.

The Basics

The Applications

Configuration

The Rest

License

Omarchy is released under the MIT License.


Audio Tuning (`docs/audio-tuning.md`)

Speaker tunings

Laptop speakers ship voiced by the vendor's Windows DSP layer, which Linux does
not get. A tuning restores that as a PipeWire filter-chain in front of the
internal speaker sink: a declarative graph hosted by a small PipeWire client, with
no GUI app and no binary blob.

text
default/audio/tunings/<vendor>-<model>/
├── tuning.conf        # description, match, provenance, measurements
└── filter-chain.conf  # the graph, with @SPEAKER_SINK@ substituted on install

on renders the graph into ~/.config/pipewire/omarchy-speaker-tuning.conf.d/
(plus the shared host config omarchy-speaker-tuning.conf, from
default/audio/filter-chain-host.conf) and runs it as its own PipeWire client via
omarchy-speaker-tuning.service, rather than
loading it into the audio daemon. The daemon only reads its own config at startup,
so a daemon-loaded tuning could only be switched by restarting PipeWire — which
drops every PulseAudio client's connection, and applications that do not reconnect
(Spotify) then have to be restarted by hand. Hosting it separately makes switching
a start/stop of one small process, and contains failure: a malformed tuning breaks
only that service instead of stopping PipeWire from starting at all.

Tunings apply automatically: install/hardware/speaker-tuning.sh installs the
LV2 dependency and install/user/first-run/audio-tuning.sh applies the tuning,
both gated on the match. Machines without a matching tuning are untouched.

Switching it on happens at first-run, not at finalize-user time, because finalize-user
also runs in the ISO chroot where there is no audio server: the sink a tuning has
to target does not exist there, so nothing could be written — and nothing would
retry, since the finalizer marks all shipped migrations complete on a fresh
install. Matching itself deliberately does not consult the audio graph, so the
LV2 dependency is still installed in the chroot.

bash
omarchy audio tuning on        # install the matching tuning
omarchy audio tuning off       # remove it, back to raw speakers
omarchy audio tuning status    # installed? in use? what matches?

match and fronted-sink are also accepted; they exist for the install hooks
and the sink-listing scripts rather than for daily use.

on verifies its own work: it waits for the tuning sink to appear and confirms
its output linked to the expected physical sink, and uninstalls everything again
on either failure — so a broken tuning cannot be left half-applied. It also
switches the default sink to the tuning and moves existing app streams onto it
(off reverses both). When everything is already installed and linked, on is
a no-op; pass --force when iterating on a tuning in place.

Adding a tuning

Add a directory with a tuning.conf and a filter-chain.conf. No new command is
needed: matching is data. A tuning declares how to recognise its hardware, plus the
sink_pattern its graph targets:

Key Matches on Notes
match_sku DMI product SKU, whole value Most precise. Vendors key speaker firmware on it, so it identifies the hardware rather than a marketing name
match_dmi DMI product name or family, substring Convenient, but a short string widens fast — product_family here is Dell Laptops
match_command Any predicate you name For hardware needing a sharper test than either

match_sku and match_dmi are lists, so one tuning can cover several models it has
been validated on:

bash
match_sku=("0DB9" "0DBA")   # XPS 14 and XPS 16

Only the first defined key is consulted, in the order match_command,
match_sku, match_dmi — keys below a defined one are ignored, and a tuning
defining none never matches.

sink_pattern is required whichever method you use, since the graph's target sink
is substituted from it. The graph must also keep two fixed properties: the sink
node is named omarchy_speaker_tuning (the install checks for that exact name,
so renaming it silently fails verification), and its output stream sets
node.dont-fallback alongside node.dont-move, so WirePlumber cannot link it
somewhere else when the target sink is absent at host start.

Gate narrowly and widen as models are validated; a tuning aimed at the wrong
drivers can sound worse than none and can stress them. When a tuning covers a model
it was not measured on, say so in tuning.conf — the provenance fields are there to
keep that distinction visible rather than implied.

Two hard requirements:

  • End in a limiter. Peaks must stay under 0 dBFS with headroom.
  • Do not boost what the drivers cannot deliver. The XPS 14 tuning
    deliberately cuts 40 Hz by around 18 dB. Excursion down there buys nothing
    and costs distortion.

Building one

Measuring a laptop and fitting a filter-chain to it is a separate job with its own
tools, in omarchy-audio-tuner.
It is not installed by default — almost nobody authoring a tuning, and it needs
python, ffmpeg and mpv.

bash
omarchy pkg add omarchy-audio-tuner

Its README is the walkthrough, and covers both cases: copying a reference that
already sounds right (how the XPS 14 tuning was made, no microphone needed), and
designing from scratch, which needs a calibrated measurement mic and a target
curve that measurement alone cannot give you.

What a tuning must report

A tuning is not reviewable on "sounds better to me". Record these, measured, in
tuning.conf:

Field What it is
magnitude_rms_db Deviation from the reference or target it was fitted to
bass_group_delay_swing_ms Max minus min group delay, 30–300 Hz
limiter_headroom_db Worst-case peak against the limiter threshold
dynamic_range_delta_lu LRA change against the reference

Measure electrically by capturing the physical speaker sink's monitor, which sits
upstream of the volume control, so results are independent of listening level.

How this fits the audio graph

Two things about the surrounding system are worth knowing, because both caused
real bugs:

  • Volume lives downstream of the tuning. A tuning is a virtual sink that
    becomes the default output, and changing its volume would alter the level
    going into the processing — moving the display while the speakers stay put, and
    changing the tone of anything with a compressor or limiter in it.
    omarchy-audio-output-sink is the single definition of "which sink does this
    output's volume really use": it resolves a sink through any DSP sink to the
    physical one, and with no argument resolves the current default output. The
    volume keys, the output switcher's OSD and the audio panel all use it, so they
    cannot disagree. Resolving the current default rather than "whatever a tuning
    fronts" is what keeps it correct when headphones or HDMI are selected while a
    tuning still exists.
  • The fronted sink is hidden. The tuning and the physical speakers both exist
    in the graph, and selecting the physical one would only bypass the tuning. So
    omarchy-audio-sink-availability reports it unavailable and
    omarchy-audio-output-switch skips it, leaving one speaker entry in the panel.
    A WirePlumber smart filter would remove the need for both — it leaves the real
    device as the default output — but on PipeWire 1.6.8 / WirePlumber 0.5.15 the
    graph loads and links correctly as a smart filter and then passes audio through
    unprocessed. Revisit when that is understood.
  • EasyEffects cannot coexist with a tuning. It moves any stream that follows
    the default sink to its own sink, so it would grab audio back from a
    filter-chain. on refuses while it is running rather than installing a graph
    that would be bypassed.
  • The tuning's own output must not be moved. A filter-chain's output is a
    playback stream like any other, so anything rerouting "all streams" to a newly
    selected output would drag the processing with it — onto headphones, or into the
    tuning's own sink, which is a cycle. The tuning sets node.dont-move, and
    omarchy-audio-output-set-default moves only streams that carry an
    application.name.

Cli Router (`docs/cli-router.md`)

The Omarchy CLI router

bin/omarchy maps spaced commands onto the flat bin/omarchy-* namespace:
omarchy theme set foo becomes exec bin/omarchy-theme-set foo. There is no
registry to maintain — every executable bin/omarchy-* file is a command, and
its filename is its default route. Metadata comments in the file header refine
how it presents and routes; the keys are documented in
agents/skills/command-metadata.md.
This document covers what that guide does not: how resolution and dispatch
actually work.

How a binary becomes routes

The stem after omarchy- splits at the first hyphen: omarchy-theme-set gets
group theme and name set, with remaining hyphens becoming spaces
(omarchy-hw-asus-rog → group hw, name asus rog). A single-segment stem
(omarchy-update) is the root command of its own group, with an empty name.

Every command registers two routes: the canonical route omarchy <group> <name> after metadata overrides, and the filename route with all hyphens
turned to spaces. When metadata moves nothing, they are the same route. When it
does, both keep working — # omarchy:name=gaming xbox-cloud on
omarchy-install-gaming-xbox-cloud keeps the hyphen inside the name, so
omarchy install gaming xbox-cloud is canonical while the filename route
omarchy install gaming xbox cloud still resolves. An explicitly empty # omarchy:name= makes a command the root of its group: omarchy-menu-share sets
group=share and an empty name, so its canonical route is omarchy share
while omarchy menu share remains as the filename route. Alias routes register
the same way but are flagged, so listings show them as aliases rather than
commands.

Two routes claiming different binaries is a collision: the first registration
wins, dispatch is unaffected, and the conflict is recorded for omarchy commands --check to report. Hidden commands (# omarchy:hidden=true) still
register and dispatch normally — hiding only removes them from listings, which
is how install-time plumbing like omarchy apply hardware stays callable
without being browsable.

Metadata is read from the comment header only: the first 80 lines, stopping at
the first non-comment line, so metadata-shaped comments after code never take
effect. Malformed omarchy: lines and unknown keys are ignored rather than
fatal — a typo degrades a command to its filename route instead of breaking the
router. The first plain comment line doubles as a fallback summary, and a
command with no comments at all still gets a generated one, though --check
demands the explicit form (below).

Dispatch

Resolution is longest-prefix: the router tries the full argument list as a
route, then drops trailing words until something matches. Whatever it drops is
passed to the binary as arguments. This runs in two passes.

The fast path joins argument prefixes with hyphens and checks for an
executable file: omarchy theme set foo probes omarchy-theme-set-foo, then
omarchy-theme-set, which exists — resolved without reading a single metadata
header. This exists because plain dispatch is the hot path: parsing the
headers of several hundred binaries on every invocation is measurable
latency (omarchy dev benchmark cli tracks it), and a filename probe is a few
stat calls. Metadata loads lazily, only for the resolved command when help is
needed.

When no filename matches — metadata-moved routes like omarchy share, and aliases like omarchy screenshot — the router first loads only the commands that could own a route under the first word: those whose filename starts with it, and those whose header names it as their group or in an alias. If that resolves a route that runs its command, it dispatches from there, about as fast as a filename route. Help output, a route that needs arguments it wasn't given, and anything still unresolved fall back to loading all metadata and resolving against the full route table, with the same longest-prefix rule, since help lists related commands from the whole table.

Both paths intercept --help/-h anywhere in the leftover arguments, not
just the first one. Resolution can succeed with unresolved words still ahead of
the flag — omarchy update aur --help resolves update with leftovers aur --help — and checking only the first leftover once let that invocation start a
real update. A -- ends the scan: everything after it belongs to the command,
so omarchy foo run -- --help forwards the flag. --json alongside --help
switches the help output to the command's JSON record; --json alone is just
an argument for the command.

Bare invocations are also guarded. If a command declares required arguments
(its args metadata, minus [bracketed] optional parts, is non-empty) and
none were given, the router shows help instead of executing — omarchy theme set prints usage rather than running an interactive setter. A bare group name
with child commands shows the group help.

Dispatch is exec: the router process is replaced, the binary sees only the
leftover arguments, and the exit code is the binary's own. The router itself
exits 127 for unknown routes or missing binaries.

When nothing resolves, the router tries a prefix listing — omarchy hw asus
prints every command whose usage starts with that prefix — and otherwise
errors with a "did you mean" suggestion (a known route extending the first
word) and a pointer to omarchy commands --all.

Groups and the top-level listing

Group help is synthesized from metadata, not written anywhere. A command
belongs to a group when either its metadata group or its filename group
matches, listed under the route that fits the group being viewed: omarchy menu --help shows omarchy-menu-share as omarchy menu share, while its
canonical omarchy share stands alone. On the fast path, group help loads
only that group's filename-prefixed binaries rather than everything.

The top-level omarchy listing is driven entirely by the hand-curated
GROUP_DESCRIPTIONS table in bin/omarchy, which also titles each group's
help. An entry there advertises the group even when every command in it is
hidden — which is exactly why apply and provision have none: they route,
but a listing entry would put install-time plumbing back in front of users
(see the Command Naming section of AGENTS.md). Adding a browsable command
group means adding its GROUP_DESCRIPTIONS entry; adding hidden plumbing
means deliberately not doing so.

Introspection

omarchy commands prints every non-hidden command with its summary, plus an
alias table. --all includes hidden commands, --markdown emits a table, and
--json emits full records: route, binary, group, name, summary, flags, args,
examples, aliases, filename_route, and routes (the union of everything
that resolves to the binary). Per-command JSON comes from omarchy <route> --help --json.

omarchy commands --check is the metadata lint, run by test/cli. It fails
on:

  • route collisions between binaries
  • a missing explicit # omarchy:summary= — a plain-comment fallback renders
    in help but does not satisfy the check
  • invalid boolean metadata: hidden and requires-sudo must be true or
    omitted, never false
  • a registered command whose binary is missing or not executable

When debugging a routing surprise, omarchy <route> --help shows the resolved
binary and, when it differs, the filename route; omarchy commands --all --json shows every route the router knows.


Elsewhen (`docs/elsewhen.md`)

Elsewhen

A world clock for the Omarchy shell: a globe in the bar that opens a panel of clocks, one row per city, with a spinnable globe behind it. It lives in shell/plugins/panels/elsewhen/ under the plugin id omarchy.elsewhen, and file names below are relative to that directory. Its checks live in test/shell.d/elsewhen/ and run as part of ./test/shell through test/shell.d/elsewhen-test.sh.

Everything it needs is already on an Omarchy install: date and timedatectl for zone offsets, and curl for weather. Weather is the only thing that touches the network: Open-Meteo geocoding and forecasts, without an API key. The globe's coastlines are Natural Earth 110m (public domain), shipped as world.json. Settings live inline on the widget's shell.json entry; see Settings.

Why it shells out to `date`

Qt's QML engine has no Intl, so JavaScript cannot be asked for the time in an arbitrary IANA zone. Instead a single date probe reports each zone's current UTC offset and abbreviation, and the rows tick locally against those offsets. Offsets only move at a DST boundary, so the probe re-runs whenever the panel opens and every five minutes while it stays open.

Reordering

Grab the body of a row and drag it up or down. The row lifts and follows the pointer while the ones it passes step aside by exactly one row; the new order is written to shell.json on release.

Three things make it feel solid:

The pointer is measured in the Column, not in the row. The grab area sits inside the row, and the row is translated as it is dragged - so reading mouse.y directly is a feedback loop: the local frame slides out from under a stationary pointer, the offset chases itself, and the row stutters. mapToItem into the (never-moving) Column gives a stable reading.

The target slot has hysteresis. Recomputing it freely means a pointer resting near a boundary flips between two slots on sub-pixel movement. The target only changes once the pointer is 0.6 of a row past the current slot, so jitter at a midpoint holds steady.

The drop is animated. On release the row glides the remaining distance into its slot and the reorder commits when it arrives, rather than the row vanishing from under the pointer. Rows making room ease aside too - but the dragged row itself is excluded from that easing, or it lags the cursor.

The list itself is not touched while the pointer moves - rows are displaced with a Translate transform, which is purely visual and leaves the Column's layout alone. Reordering the model mid-drag would replace the array the Repeater is built from, rebuild every delegate, and drop the gesture half-way through.

The grab area stops above the daylight strip, so vertical reordering never competes with the strip's horizontal scrub, and it is declared before the remove button so that keeps its taps. A few pixels of slack are required before a drag arms, so a click is never a reorder.

The first run

A fresh install has no configuration and asks for none. The first time the panel opens it writes its own starting list: the city you are in, plus four well-known destinations spread round the clock from it - five rows, so it looks like a world clock immediately rather than an empty box with an "add a city" button.

The four are chosen relative to home. A fixed list would hand someone in Paris a second Paris, and would give a reader in Tokyo a spread that is really a spread around California. From Los Angeles the list comes out as New York, London, Dubai, Tokyo; from Copenhagen as Delhi, Tokyo, Los Angeles, New York.

Candidates are taken in order of how well known they are, and one is kept only if it is at least three hours from home and from every city already picked - so the gap does the spreading rather than a rule about spacing. Spacing four cities evenly round the dial and taking whoever is nearest each mark gives a tidier spread but a stranger list: from Los Angeles it produces Sao Paulo, Cairo, Bangkok and Auckland, which is even but reads like a lottery. If somewhere has too little of the world far enough away - the Pacific, mostly - the three-hour gap relaxes rather than returning a short list. The result is sorted eastward, so the list walks round the world.

It costs one process: the same date probe that reads the local zone also prices the candidate cities on that first run, and never again. The local zone is emitted twice in that probe - once as the LOCAL marker and once as an ordinary row - so home's own offset is available like any other city's, which the picker needs and cannot ask for in advance.

Everything is written to shell.json as a normal list, so the first thing anyone can do is delete or reorder it. test/shell.d/elsewhen-model-test.sh runs the whole thing from fifteen different home cities, including UTC, Kathmandu's forty-five-minute offset, and the Pacific.

Adding and removing cities

+ Add a city opens an inline search over the system's zone list. Clicking a result adds it; hovering a row reveals a × in its top-right corner to remove it. The last row cannot be removed. Changes are written straight back to this widget's entry in ~/.config/omarchy/shell.json, so they survive a restart.

The search is driven from the keyboard: type, walk the results with the up and down arrows, Return adds the highlighted one, Escape closes. The selection starts on the first match, so the common case - type three letters, press Return - never needs an arrow at all, and it wraps at both ends because the list is six long and entirely on screen. A changed query puts the selection back on the first row: the list under it has been replaced, and Return should not add a city nobody looked at.

Both lists name the zone and say where it is: Asia/Kathmandu UTC+5:45. The name alone does not settle it - half a dozen entries share America/Los_Angeles, and Asia/Kolkata tells you nothing about India being half an hour off the hour until it says UTC+5:30. The offset is absolute rather than relative to you, unlike the +9h on a tracked row: a city you have not added yet has no relationship to you yet.

Search results are not covered by the probe that feeds the rows - that one only knows the cities you track - so the offsets come from a second date call over whatever the list is currently showing, at most six zones, coalesced while one is in flight. The answers are merged into what is already known rather than replacing it, so the column does not blank on every keystroke.

The globe's own jump box works the same way - arrows, Return, Escape, selection on the first match, wrapping over five results. Its results grow upward out of the field rather than down from it, but the first match is still at the top of them, so Down moves down the screen and down the list at once.

A zone is not a city. Six of the picker's entries share America/Los_Angeles; the name is the only thing telling Oakland from Las Vegas from Los Angeles. Committing a choice passes the label alongside the zone, so picking Oakland puts "Oakland" on the list rather than "Los Angeles".

The × is placed from the corner rather than centered in its target, so how tucked-in it reads does not depend on how big the target is. The target itself is anchored flush into the corner and is larger than the mark, because a corner is the easiest place on a row to hit.

The search is rendered inline rather than in a dropdown popup: the shared SearchableDropdown only ever opens downward, and this panel hangs off a vertical bar low on the screen, so its list would run off the bottom edge.

Inline, the list grows the panel instead - but only up to the card's cap, the screen's height or space(680), whichever is smaller. Past that the content is clipped to the card and scrolls, and while the search is open the panel follows the results down so the last one is not left clipped against the bottom edge.

Scrolling is the wheel only. The rows own the pointer - drag-to-reorder and the scrub strip are MouseAreas with preventStealing - so an interactive Flickable would be fighting them for every gesture.

City aliases

The tz database ships one representative city per zone, so most places people actually search for are missing — there is no Miami, only America/New_York. CITY_ALIASES in Model.js adds extra search entries pointing at the zone that governs them. Adding a city is a one-line change; the only rule is that the zone must be the one that place actually observes, DST rules included.

Two cities may share a zone (Miami and Boca Raton are both America/New_York), so a row is identified by its label and zone together, and removal is by position.

The daylight strip

Each row carries a 24-hour bar, midnight to midnight in that city's own clock, with the daylight lit and a marker at now. Read down the column and you can see who is awake: markers inside the lit band are in daylight, markers out at the dark ends are not. Rows are also filled by phase, lightest at midday and darkest at night.

The lit band is the city's real day, from the coordinates the weather lookup already geocodes. Reykjavik's four-hour December day and Auckland's long January one are different shapes, and that difference is most of what a daylight bar is worth looking at. A city the geocoder has not placed yet gets a fixed 06:00-18:00 band until its coordinates arrive.

The marker turns gold while it is inside the lit band and stays white outside it. That is decided geometrically, from the same span the band draws, not from the phase name - so the sun can never be painted sitting in the dark half of its own bar. The gold is a literal color rather than a theme role, because several themes map the palette name yellow to something that isn't yellow.

Sun.js computes the day, built on the globe's own subsolarPoint rather than a second copy of the astronomy. Declination comes straight off that function and the equation of time is recovered from it - subsolarPoint builds its meridian as lon = -15 * (utcHours - 12) - eot, so running that relation backwards hands the correction back with nothing new to keep in step. Local solar noon is found by iterating: the subsolar meridian sweeps west at a steady 15 degrees an hour, so the gap between where the sun is and where you want it converts straight into a time correction, and three passes put the residual under a second.

test/shell.d/elsewhen-sun-test.sh checks it against Open-Meteo's published sunrise and sunset for thirteen cities - both hemispheres, both solstices, the equator, and Kashgar, which runs on Beijing time and sees the sun rise at 08:23 by the clock. The reference rows are held in the test verbatim so it stays offline.

It is inside a minute at mid-latitudes, and about four minutes at Nuuk and Anadyr, both a little past 64 degrees north. That is the shared low-precision solar position doing what it says: it is good to a fraction of a degree, and near the poles the sun crosses the horizon at such a shallow angle that a fraction of a degree is minutes of time. The two high-latitude cities are in the test with the tolerance they actually need rather than left out.

A bar can be lit at both ends, and that is not a drawing fault. Reykjavik on the June solstice sets four minutes after midnight, so the first four minutes of the same day are lit too - by the sun that rose the morning before. The day is drawn where it falls and again a day either side, and only the parts that land on the bar survive.

The arrows do not wrap with it. A sunset at 00:04 belongs to the next bar along, and a mark pinned to this one's edge would claim the sun set at midnight.

Above the Arctic circle there is no sunrise to plot, and that is a real answer rather than an error: kind comes back as midnightSun or polarNight, the band is the whole bar or none of it, and no arrows are drawn. Longyearbyen in June and in December are both in the test.

The arrows

An up arrow just before the band and a down arrow just after it, shown while the pointer is on that row and outside the lit part rather than on its edge - a mark sitting on the boundary reads as part of the band and gets lost in it. The band already says how long the day is; the arrows say which end is which. They keep off the resting state: five rows each carrying two arrows all the time is a lot of furniture for something looked up rarely.

They are tucked in close to the band. The tap target is finger-sized and the glyph sits in the middle of it, so a box placed flush against the crossing would leave the arrow half a box away. A nudge closes most of that without letting the glyph touch the band.

Sunrise sits two pixels high and sunset two pixels low. The glyphs are the same height and the same shape reversed, which is the pair the eye is worst at telling apart at this size. The offset gives a second, coarser cue that needs no reading: the one above the line is the one going up.

Click one and it prints Sunrise 6:16 AM in a small dark chip above the bar - named, not just numbered, because a bare time floating over a row that already has a clock on it has to be worked out from which arrow was clicked. One at a time, and it clears when the pointer leaves the row.

Above and not below, because below is where the pointer is. The chip floats over the date line, which is why it has a ground of its own, and its colors are inverted in every theme the way a tooltip is. The arrows carry z: 1 so the chip paints over the row's time block, which is declared after them.

They are declared after the strip's scrub MouseArea. That one covers the whole bar to drag time, and an earlier sibling would never see a press - the same later-sibling rule the remove button relies on.

An arrow hides while the marker is standing on it. Two things drawn at the same point read as a printing fault, and the arrow is the one that can be spared: it marks a boundary that is not going anywhere, while the marker is the only thing on the bar that says when now is. It follows nowMarker.x and not the row's progress, so the arrow comes back exactly as the sun clears it - the marker's motion is eased, and progress is where it is going, not where it is. Hidden rather than faded, because invisible is also untappable: a click on the sun should scrub time the way it does everywhere else on the bar. If the marker covers an arrow whose time is open, the chip closes with it.

Clicking the marker itself does not name the moon's phase: the marker is also the scrub handle, so aiming at a ten-pixel dot to read a label would nudge the whole list off the hour on every miss. Shift-clicking the moon runs the phase animation instead, which is a deliberate gesture rather than one you land on by aiming badly.

Clicking anywhere else on the row puts the chip away. A tooltip that can only be dismissed by hitting the same few pixels that opened it is a trap.

A click on an arrow stops at the arrow: the scrub bar and the row's reorder grab underneath record no press at all, so the scrub bar needs no guard against the arrows drawn on top of it. That is measured rather than reasoned - see Testing what the pointer does.

A press on the row body does still reach the grab, which dismisses, so the two decisions can meet on one click. Neither rule reads the live value; both are answered from what was showing when the press began, which is the same number whichever runs first. The two rules are Model.chipAfterTap and Model.chipAfterRelease, and test/shell.d/elsewhen-model-test.sh plays a click through both delivery orders across every case: opening, closing, swapping one chip for another, and a release that must not undo a chip opened during the same press.

Weather

Each row shows the current temperature beside the city name, and one glyph beside it: sun, partly cloudy, cloud, rain, or snow. The glyph sits with the temperature because the two are the same fact about being outside.

Open-Meteo reports WMO present-weather codes - nearly a hundred of them, separating drizzle from freezing drizzle from rain showers. Model.weatherKind collapses them to those five, which is all a row has space for; fog joins cloud rather than getting a symbol nobody could read at this size. The code travels with the temperature in the same reading, so the two cannot disagree.

The sun is white-balance-sunny, not weather-sunny. The obvious one is a hollow ring inside a six-point burst, which at eleven pixels reads as a snowflake - sitting directly beside an actual snowflake. The one in use is a solid disc with short rays, which cannot be mistaken for anything else in the set.

Number(null) is 0, and 0 is the WMO code for clear sky - so a missing reading would quietly render a sun. Absent values are rejected before the conversion; test/shell.d/elsewhen-model-test.sh covers it.

The panel fetches it with curl, in two steps: one geocoding request per city it has no coordinates for yet, then one batched forecast for every city. Both results live in memory as facts, keyed label|zone. Geocodes last the session (a city does not move) and weather twenty minutes (the resolution the source offers), so opening the panel again soon does no network at all. Nothing here is fatal - a failed fetch keeps the previous value, and failing that the row simply renders without it.

Cities are geocoded by their label, not their zone, which matters for aliases: Miami and Boca Raton share America/New_York but resolve to their own Florida coordinates and report genuinely different temperatures. Where a label cannot be geocoded, the zone's representative coordinates from /usr/share/zoneinfo/zone1970.tab are the fallback; after a network failure that fallback is used for now and the geocode retried on the next refresh.

The hour's greeting

Point at a row and its date line turns into what people there would actually be saying to each other at that moment: Tokyo at midday says こんにちは, at eight in the evening こんばんは, at three in the morning おやすみ. The pronunciation follows it in grey, because a greeting you cannot say out loud is only a decoration.

The number is the one thing about a time zone that does not travel. "18:40" is the same symbol in every city on the list and says nothing about what 18:40 is there. The greeting is the hour as the people in it experience it.

The table is three levels deep - a zone belongs to a country, a country is greeted in a language, and a handful of cities override their country - and it is baked into Greetings.js and never fetched. Greetings do not change, and nothing the panel draws on hover should need the network.

Two decisions inside it are judgement calls, and are meant to be:

It picks the language you would hear, not the one on the paperwork. Brussels is French, Dublin is Irish, Hong Kong is Cantonese rather than Mandarin, Nairobi is Swahili. An official-languages list would have made several of these duller and one or two of them wrong.

It does not invent an hourly split where a language has none. Burmese greets you with မင်္ဂလာပါ at any hour and Thai barely moves, so those tables are one and two bands long. A short table is a fact about the language, not a gap in the data.

The boundaries are where the language moves rather than where a clock does, and the same language disagrees with itself: Spanish in Madrid is still saying buenas tardes at 20:00, while Spanish in Lima gave it up an hour earlier. Vienna gets Grüß Gott and Zurich Grüezi where Berlin gets Guten Tag. Indonesian has five bands where English has four - siang and sore split an afternoon English keeps whole.

The panel's own monospace family carries none of these scripts. Fontconfig substitutes per character, so a Japanese greeting is set in whatever the system has for Japanese and only the Latin ones stay monospaced; Arabic sits right-to-left with its pronunciation in a separate item beside it, which is what keeps the two from being reordered into each other.

Every zone the picker offers is covered. The picker offers every zone timedatectl list-timezones returns - 598 of them, not just the 74 in cities.json - so the table is built from the system's own zone.tab. English is checked at the source rather than in the result: a zone may only be greeted in English because some country asked for English, and a zone missing from the table is a failure rather than a silent fall-through to English.

The overrides earn their place by being few. Honolulu is Hawaiian though the United States is English, Montreal is French though Canada is not, and that is nearly the whole list - the country is the right answer almost everywhere.

test/shell.d/elsewhen-greetings-test.sh checks everything around the words - that every zone the picker can offer maps to a language, that no hour of any day falls into a gap, that adjacent bands actually differ, and that every non-Latin greeting carries a pronunciation while no Latin one does. The words themselves are the one thing here with no independent reference on this machine, and they want a speaker's eye rather than a test.

The legacy aliases

The zone table matches each zone's compiled zoneinfo file against the ones in zone.tab. That is exact for real zones and wrong for the legacy aliases, because an alias shares its rules with whatever zone the tz database linked it to - chosen for keeping identical time, not for being anywhere near it. Iceland keeps Abidjan's clock all year, so it would match Burkina Faso and say Bonjour; NZ would match Antarctica, Asia/Rangoon the Cocos Islands, Africa/Asmera Djibouti.

Following the tz link table instead does not help: that returns the country of the canonical zone, which for Iceland is Côte d'Ivoire and for Pacific/Truk is Papua New Guinea. There is no rule available here, only places, so the fifteen are named by hand in ALIAS_COUNTRY and countryFor reads that first.

Two that look like mistakes and are not: Antarctica/South_Pole really is in Antarctica and Pacific/Ponape really is in Micronesia, whatever zones they share their rules with. Europe/Simferopol stays Ukrainian, which is a choice rather than a lookup.

The test checks each alias against the canonical zone for the same place - Iceland against Atlantic/Reykjavik, Pacific/Truk against Pacific/Chuuk - which is an answer that does not come from the table being tested.

Time scrubber

Drag any row's daylight strip and every clock moves together, so you can ask "if I propose 3pm, what am I doing to Auckland?" and see the answer rather than compute it. The header shows the shifted time in the accent color with the offset from now (+3h), so a scrubbed clock can never be mistaken for the real one. Release and it holds for a couple of seconds - long enough to read - then returns to the present. Closing the panel also returns it.

The drag maps absolutely: the pointer's position across the strip is a local time-of-day for that city, measured against the unscrubbed present, so a drag cannot accumulate drift. It resolves to the nearest occurrence of that time, so dragging slightly left means an hour ago, never twenty-three hours on.

The rows are modelled on the zone list rather than on the computed clock rows. The clock rows are a binding on the scrubbable, ticking time, so using them as the model would rebuild every delegate on every tick and destroy the MouseArea mid-gesture, turning every drag into a single click.

test/shell.d/elsewhen-model-test.sh covers the scrub arithmetic, including scrub direction and the short way round midnight.

Globe mode

Tapping the globe in the header does not swap one view for another: the globe grows out of the little circle and shoves the rows aside, and the panel opens out around it. Tapping the cross that takes its place in the circle reverses the whole thing.

One number, zoom, runs from 0 (the list) to 1 (the globe). Everything is a function of it - the stage's height, each row's displacement, the globe's scale and position, the cross in the header, the globe's own footer and search bar - so no part of the transition can fall out of step with any other. The animation is on zoom alone: 800ms OutQuart opening, which spends its speed early and then settles, and a brisker 500ms InOutCubic coming back.

Hold Shift while clicking to run the whole thing at a third speed - the gesture macOS uses for slow-motion window animations, and free here because every Hyprland binding is SUPER-prefixed. Because one number drives everything, slowing that number slows the rows, the fades and the globe's chrome with it.

The duration and easing are set before globeMode is flipped, never derived from it. Deriving them from globeMode inside the animation is a race - it is the very property whose change starts the animation - and losing that race means an opening transition runs with the closing duration. Every path into globe mode goes through setGlobeMode for that reason.

The globe is scaled about the center of its own disc, not the center of its box, so it grows from where it is drawn; the translation then carries that center between the little circle and the middle of the stage. The two globes trade places with a quick crossfade while they are still the same size and in the same spot, so there is never a moment with two of them on screen.

Rows are shoved in sequence rather than together - each waits its turn, then covers the rest of the distance - so it reads as something arriving from above rather than the list simply leaving. They tilt, shrink, and are thrown a long way sideways in alternating directions.

They do not fade at all: they fall clear off the bottom of the panel. The distance is measured from the list at rest plus a margin, so even the topmost row - which has the whole list below it to fall past - is gone by the end. The fall is squared against the zoom so it accelerates rather than easing out, the way a dropped thing does.

Everything involved is opaque. The globe's ocean and the row cards mix their tones against the panel background and return a color with no alpha, rather than painting low-alpha foreground over it. That looks identical at rest and correct in motion: the globe arrives as a solid object in front of the list rather than as a tinted pane the rows show through.

The stage is not clipped, because at the start of the flight the globe is still up in the header above it and clipping would cut it in half exactly when it is meant to look like the little circle. The rows are clipped instead, by a separate item, so they slide out of the panel rather than piling up past its edge. The globe is loaded on hover, not on click, so reading its two data files never lands in the middle of the animation.

What it shows

A spinnable orthographic globe: coastlines, a graticule, the day/night terminator, and a major city for every time zone. Drag to spin (it keeps going and eases to a stop), drag vertically to tilt, and tap a city to read its local time in the footer. Tapping the hero globe again returns to the list, and closing the panel returns to it too - the list stays the way in.

City names are drawn in two tones: light over the sea, dark over the land. They are painted twice - once light over everything, then again dark through a clip of the continents - so a name straddling a coastline comes out dark on its land half and light on its sea half, and every part of it sits against something it contrasts with. An outline cannot do this; it only fattens the letters and dulls both halves. Tracked and home cities keep their distinction through weight and their dot rings rather than label color.

Names are placed to the left of their dot when placing them to the right would run off the panel, so nothing is truncated at the edge.

On each row's daylight strip the marker is gold by day and the moon by night - not a plain pale dot, but tonight's actual phase, with the unlit part bitten out of it. Sun and moon are the same size: they are the same marker in the same place meaning the same thing, so only their content differs. The whole disc is always drawn faintly underneath, so the marker never disappears at new moon.

Shift-click a moon to walk it through a full lunation and back - the moon is nearly always somewhere unremarkable, so without this there is no way to see that the marker really is drawing a phase. It runs about five seconds, tweening between eight stops, then drops straight back to the real phase (the tween is disabled for that last step, or it would run the month backwards on the way).

It is one element carrying two facts rather than a new thing on the row, which is the only reason it earns its place. The phase follows the scrubber too, so dragging time walks the moon through its month.

GlobeModel.moonPhase is the mean synodic month against a known new moon - enough to draw a phase, not enough to predict an eclipse. It drifts from the true lunation by up to about half a day, which is under 6% of illumination and sub-pixel on a marker this size. test/shell.d/elsewhen-globe-test.sh pins it against five eclipses, which are the one thing that fixes a lunation to a wall clock: a solar eclipse can only happen at new moon and a lunar eclipse only at full. The drawn shape is checked by rasterising it and counting lit pixels against the illumination formula.

City dots on the globe use the same rule as the list: gold in daylight, pale at night.

The city you are in is always on the globe, always labelled, and drawn the way the panel globe draws it - the dot in the accent color, a dark edge so it does not vanish into the land, and a halo. It is merged in ahead of everything else, so a built-in or a tracked row of the same name cannot shadow it.

Cities the list is tracking get an accent ring around the dot, and first claim on a label slot so they are always named. Matching is by city name, not zone - tracking Miami does not light up New York, because they are different cities that happen to share America/New_York. A tracked city that is not one of the globe's own built-ins is merged in using the coordinates the weather script already geocoded, so a city in the list can never be missing from the globe.

Dots are thinned in screen space before anything is drawn: candidates are offered in priority order and one is kept only if it clears the others by a minimum distance, so a dense region like western Europe shows a few legible cities instead of a smear of overlapping dots. Tracked cities and the current selection are exempt and always survive. The survivors change as the globe turns or the panel resizes, since the test is in pixels rather than degrees.

Labels are then placed greedily over the survivors with collision avoidance, so names appear and disappear as the globe turns rather than piling up.

A city's label is part of its click target. A two-pixel dot is a hard thing to hit, so label boxes are tested first - before dots - because a label sits beside its own dot and would otherwise lose the proximity test to a neighbouring city.

A moving globe is drawn with less in it. A full paint measures about 14ms - already over a 120Hz frame at 8.3ms - and every frame of movement repaints the whole canvas, because the projection changes, whether the globe is riding the zoom, being dragged, or coasting after a throw.

Two things dominate that paint: the city names, which cost a layout pass and two passes of text, one of them through a clip built from every coastline ring; and the graticule, 17 stroked polylines. Both are dropped while the globe is in motion and come back the moment it settles, which roughly halves the cost. Nothing is lost that could be read - names on a turning globe are a smear, and mid-zoom the whole thing is a few dozen pixels across.

Below half size the coastline is drawn at half its vertices as well. That one is tied to how big the globe is being drawn rather than to whether it is transitioning, because the zoom's ease-out spends its slow tail near full size, where the missing islands would be visible and would then snap back in.

Measured over the open transition, with smoothMotion off and on: average paint 11.6ms to 7ms, and the worst gap between frames 105ms to 45ms. Set smoothMotion to false to draw everything, always.

Everything drawn scales with the shell. Stroke widths and marker radii go through GlobeModel.scalePx rather than being pixel literals, because the globe's radius and its labels already follow the shell's base font size, and literal widths would read as proportionally thinner as that size grows. The small globe derives its widths from its own radius, which the large globe cannot do, its radius being hundreds of pixels rather than tens. Widths are floored at one pixel, below which a stroke stops reading as a thin line and starts dropping out of the raster.

Nothing here touches the network at runtime. The coastlines are Natural Earth 110m, simplified with Douglas-Peucker to 68 rings and 1337 points (14 KB), and the cities were geocoded once at build time - both are plain data files in the plugin. Zone offsets come from the same date probe the list uses.

test/shell.d/elsewhen-globe-test.sh covers the projection and solar maths, including a check of the terminator against Open-Meteo's is_day for every city.

One selection, two views

The list and the globe are two views of the same choice, so they hold it together. Clicking a row and then opening the globe lands on that city rather than resetting to home, and picking a city on the globe moves the list's focus so the header and the small globe are already on it when the globe closes.

Picking a city on the globe also brings it round to face you, as opening the globe does for home and the jump box does for what it finds - a city chosen near the limb would otherwise sit where the projection is most foreshortened. Only a hit turns the globe: a tap on open ocean clears the selection and leaves the view alone.

The pick is pickAt(x, y) on the globe's root rather than a body inside the MouseArea, so the same code the pointer runs can be driven from a test or over IPC. This desktop cannot inject a pointer at all, and a copy of the logic in a test would be free to drift from the one the mouse actually reaches.

A globe city the list does not track is the ordinary case - the globe draws every zone's main city, the list holds the handful you chose - and it leaves the list where it was. There is no row to focus, and sending the list home would move it somewhere nobody asked to go. Clicking empty ocean is the same story from the other side: it clears the globe's selection, but the globe's "no city" and the list's "home" are different states and forwarding one as the other would be a lie.

The list's own focus is held the same way. focusKey is the city's label|zone and the row number is derived from it, because zones is a binding too - replaced wholesale on a reorder or a removal - so a stored index would silently come to mean a different city. Deriving the index means a reorder carries the focus with the city and removing the focused city drops it back to home.

The crossing is made on label|zone, not on an index. The two views index different things - a row is an index into the settings list, the globe's selection is an index into its own catalogue of everything it draws - and that catalogue is a binding, rebuilt whenever the home row, a tracked city's coordinates or a session city lands. A key survives the rebuild. test/shell.d/elsewhen-model-test.sh covers the crossing, including the untracked city and the case where both fields have to agree.

Jumping to a city

The bar at the bottom searches the whole zone catalogue - every IANA city plus the aliases - and turns the globe to whatever is picked, centering it by setting the spin to the city's longitude and viewLat to its latitude, taking the short way round.

A city already on the globe is flown to immediately. One that is not is added for this session only: the panel geocodes it, and the globe flies there once the coordinates arrive. Nothing is written to shell.json, so there is no saved list to delete from, order, or migrate. Want it again, type it again.

Results are drawn over the globe rather than growing the panel, so the globe does not resize under the pointer while a search is being typed.

Turning it off

Set globeEnabled to false on the widget's shell.json entry. The hero stops being a button and the globe's Loader never activates; nothing else changes.

Two lines: the city with its time, and under it the zone with its offset. Opening the globe selects home, so the footer is never empty under a marker sitting plainly on a city.

A mark sits against the city's name, the same one the rows carry on their strips: a lit dot by day, tonight's moon by night. It is judged by this globe's own daylight, so it agrees with the dot drawn on that same city an inch above it.

It is sized off the name it stands next to rather than set in pixels, and it stands on the same baseline, so it occupies exactly the band the capital does - never above the cap, never below the letters. tightBoundingRect on a capital M gives the ink of the glyph; the mark is one pixel under that, which rasterises to the same 14 device pixels, because a circle's antialiased edge reads a pixel wider than a glyph's stem. Any smaller and the moon reads as a bullet point; a crescent needs room to be a crescent.

It is not placed with anchors.baseline: inside a Row, anchoring to a sibling whose own position depends on the Row's height is a loop, and QML settles it by dropping the dot onto the line below. A Row leaves y alone, so both items start at its top and the dot's underside is placed on the baseline directly - Text.baselineOffset is the ascent of its first line, the same number the glyph itself is drawn from.

The mark and the name are their own Row inside the line, so the gap between them can be tighter than the gaps after them - the mark belongs to the name, not to the row of facts.

There is no "daylight" or "night" word after the time: the city markers and the terminator already say it in the picture. The line cannot fit on one row - "Johannesburg Africa/Johannesburg UTC+2 7:50 PM" runs off the end of the panel - so it is two caption lines, which the footer's reserved height already holds.

The parts of that line are anchored to a named footer id rather than reached through parent.parent, because QML resolves a chain that falls a step short to undefined in silence.

The hero globe

The globe in the header is drawn, not a glyph, by MiniGlobe.qml. A glyph cannot spin: rotating a flat image about the vertical axis squashes it to a line and flips it, which reads as a coin. A sphere keeps its circular outline and moves only its surface across it - so the disc is constant and the graticule and coastlines are re-projected as the spin advances, using the same orthographic projection as globe mode and the same world.json.

Landmasses are filled rather than outlined, and only rings above a size threshold are drawn: at icon size an outline is a scribble and an island is a speck of dirt on the lens. The rim is stroked last so nothing spills over it.

Both globes fill their continents from the same clipping code in GlobeModel.js. Neither strokes a coastline over the fill: the fill's own edge is the coastline, and a second pass over every ring is the expensive half. Measured on the large globe, per repaint: outlines only 8.1ms, fill plus outline 13.4ms, fill alone 9.1ms - so the filled look costs about 1ms over outlines, against a 16.7ms frame budget.

Filling means labels and city dots cross light land as often as dark ocean, so labels are outlined and every dot carries a dark edge.

Clipping a coastline to the visible hemisphere has to produce one polygon per ring. Keeping each visible run and closing it makes self-intersecting shapes whose area jumps whenever a run splits, and that is visible: continents morph and pulse at the limb, worst as the spin slows. Sutherland-Hodgman against the hemisphere keeps the ring whole, and walking the limb between an exit and the next entry (rather than cutting straight across) makes the silhouette continuous. Measured over a full rotation, the worst area change per quarter-degree of spin goes from 356 px^2 (split runs) to 278 (whole ring, chords) to 3.5 (whole ring, limb arcs) on a 2463 px^2 disc. test/shell.d/elsewhen-globe-test.sh holds it there.

The globe leans by GlobeModel.AXIAL_TILT - 23.44 degrees, the real obliquity, and the same constant the subsolar calculation uses.

It is drawn at full strength and oversized rather than sitting at text weight: as the centerpiece a dimmed thin globe just reads as washed out.

A marker shows the city you are in, in the accent color. It carries a dark edge: the marker can be nearly the same lightness as the filled continents, and without one the dot dissolves into whichever landmass it is sitting on.

The globe opens on the city you are in. It flies there as the panel zooms out, so the two motions - growing out of the header and turning round to home - land together rather than one after the other. If your coordinates have not arrived yet, which happens on a cold geocode cache, the request is held and runs the moment they do.

Clicking a city row turns the globe to it and marks it - so a tap on Tokyo swings the globe round and drops the marker on Japan. It takes the shortest way round rather than always turning forward: Los Angeles to Tokyo is 102 degrees west, not 258 east.

While the globe is showing somewhere else, clicking the header line brings it home. The whole line is the target, not just the name, and it is only live while the globe is away, so it is never a dead click target. Reopening the panel also returns it home.

The opening spin lands on home - the animation runs from homeLon - 1080 to homeLon, three whole turns that finish with your own meridian facing you. At rest a Binding holds the globe there, standing down while the animation is writing the property. Until the weather script has geocoded your city the marker is hidden and it rests on Greenwich.

Easing.OutQuart over 1250ms puts most of the rotation in the first third and lets the rest coast out, which is what a globe flicked by hand does rather than a motor driving it at a constant rate.

Where "here" is

The header reads "It's 10:28 AM here in Los Angeles." rather than a bare "here", which names your own city without spending a row on it. The zone comes from the same date probe the rows use - one extra LOCAL|<zone> line, from timedatectl - so it costs no additional process and follows a time-zone change on the next refresh.

The city name is the zone's last segment, and the tz database names zones after a representative city: someone in Boca Raton would read "here in New York". Tap your city on the globe to fix that: a city on this machine's zone becomes homeCity, and the header and home pin follow it. Tapping the zone's own city clears the choice, and a city on another zone leaves it alone, since the time beside the name is still this machine's.

Two offsets, one line

A row's offset reads +2h - how far that city is from you - until you click it, and then every row reads UTC-5 instead. Both are the same fact from different ends: the relative offset answers "how far ahead are they", the absolute one answers "where is this place", and the click is cheaper than printing both and doubling the width of the line.

It is one setting for the whole list rather than one per row. A column where each row had picked its own units would be unreadable, and the point of a column is that it can be read down. The choice is stored, so it survives a restart.

The tap target sits on the offset itself, and works because it is declared late: the drag handle covers the whole body of the row, and later siblings win the tap - the same rule the remove button relies on.

The globe's footer reads the same setting and offers the same click, so the offset under the globe is never in different units from the offset in the list you just came from, and flipping it in either place flips it in both. There the target is the zone name as well as the number: on your own home city the relative offset is blank, and a control that disappears on one city out of the list is not a control.

The globe does not own the setting. It publishes offsetModeToggleRequested and the panel flips it; the new mode arrives back down the same binding as every other property, so there is only one place the mode can live.

Units and notation, on a click

The temperature and the time are both controls. Click any temperature to swap the whole list between Celsius and Fahrenheit; click any time to swap it between 12- and 24-hour. Both work the way the offset does - one setting for every row, changed where you are already reading rather than in a settings pane, and written straight to shell.json so it survives a restart.

One setting for all rows, not one per row, for the same reason the offsets move together: every clock here exists to be read against the others.

The starting notation follows the machine. With hour24 unset, the locale's own short time format decides: Qt.locale().timeFormat gives h:mm Ap for en_US and en_AU, HH:mm for en_GB, de_DE, fr_FR and zh_CN, H:mm for ja_JP and H.mm for fi_FI. The test is the AM/PM designator rather than the case of the hour letter - h means 1-12 and H means 0-23, which is the same answer, but a locale may spell a 24-hour clock with either while a designator only ever belongs to a 12-hour one. Quoted literal text is stripped first, because some locales write the separator as H'h'mm.

Those patterns were read off the running shell, and the ones that matter are in test/shell.d/elsewhen-model-test.sh verbatim - including the detail that Qt spells the designator Ap and puts U+202F in front of it, not a space.

The starting unit follows the machine too. With units unset, Qt.locale().measurementSystem decides: the US system means Fahrenheit and everything else means Celsius. en_US reports ImperialUSSystem, en_GB reports ImperialUKSystem and de_DE and ja_JP report MetricSystem, so the rule gives Britain Celsius, which is what Britain uses for weather whatever else it measures in miles.

The rule is Qt's CLDR data and it is not a survey of thermometers: Liberia, which does use Fahrenheit day to day, reports as metric. That is what the click is for. An explicit C or F always wins over the automatic answer, so one click is the whole escape hatch, and "" puts it back on the system's units. Model.resolveUnits holds those rules and test/shell.d/elsewhen-model-test.sh covers them, including the junk values a hand-edited shell.json can produce.

Testing what the pointer does

No pointer can be injected into the running shell, but qmltestrunner synthesises real mouse events into an offscreen window, and the structures worth checking are small enough to rebuild in a test:

bash
QT_QPA_PLATFORM=offscreen /usr/lib/qt6/bin/qmltestrunner -input test/shell.d/elsewhen/qml

Use that full path. /usr/bin/qmltestrunner is the Qt5 binary; it exits 0 having run nothing and printed nothing, which looks exactly like success.

test/shell.d/elsewhen/qml/tst_arrows.qml settles whether a click on an item in front also reaches a MouseArea behind it. It does not.

Keys

Key What it does
Space opens the globe, and closes it again
+ opens the city search, or the globe's jump box when the globe is up
a opens the city search (list only)
j opens the globe's jump box (globe only)
r re-probes the zones and refetches the weather
Esc closes the search, then leaves the globe, then closes the panel
arrows, Return walk and pick a search result

The key catcher reports Space as "activate" and reports Return as a return and then an activate, so Return marks itself on the way past and the activate behind it stands down - otherwise Return would work the globe too.

Escape unwinds one layer per press rather than closing outright, so the way out of the globe is the same key as the way out of everything else. The globe's search hands the keyboard back when it closes: a hidden item keeps its focus, so otherwise the field would go on swallowing keys and the second Escape would go nowhere.

Settings

Inline on the widget's shell.json entry:

Key Meaning
zones Label|IANA name, comma separated; blank seeds on first run
hour24 true or false; unset (the default) follows the system's time format. Click any row's time to flip it
offsetMode home for the offset from you (default), utc for the absolute one
units F or C; blank (the default) follows the system's measurement units. Click any temperature to flip it
globeEnabled false to remove the globe entry point (default on)
homeCity your city for the header (blank = from the system zone); set by tapping a city on this zone on the globe
smoothMotion drop labels and detail while the globe moves (default true)

IPC

bash
omarchy-shell omarchy.elsewhen toggle
omarchy-shell omarchy.elsewhen globe                            # switch between list and globe
omarchy-shell omarchy.elsewhen times                            # JSON, one entry per row
omarchy-shell omarchy.elsewhen add America/New_York Miami
omarchy-shell omarchy.elsewhen remove America/New_York
omarchy-shell omarchy.elsewhen refresh                          # re-probe offsets

File Layout (`docs/file-layout.md`)

File layout

How omarchy/ is organized and where everything ends up on an installed
system.

Mental model

Two Arch packages are built from this one repo (PKGBUILDs live in the
separate omarchy-pkgs repository, under pkgbuilds/):

  • omarchy — runtime binaries (bin/, including bin/omarchy-dev-*),
    install/finalize scripts (install/), migrations, themes, and the
    Quickshell desktop (shell/). Depends on omarchy-settings.
  • omarchy-settings — everything that has to be on the target before
    the omarchy package installs (specifically before useradd -m and the
    limine bootloader install): all /etc/skel/**, /etc/ drop-ins,
    package-owned system files under /usr/share and /usr/lib, fonts,
    plymouth theme, sddm theme, branding, plus the limine/snapper configs
    (mkinitcpio hooks, limine-entry-tool drop-ins, snapper template, the
    default/limine/ and default/snapper/ trees, and the boot/snapshot
    story end-to-end). Also ships the three debug binaries
    (omarchy-debug, omarchy-debug-idle, omarchy-upload-log) needed by
    the live ISO env.

Two other packages live in omarchy-pkgs but stand alone:
omarchy-keyring (GPG keys for pacman) and omarchy-nvim (the Neovim
setup; independently seeds /etc/skel).

Some trees ship in neither package and exist only in the repo: manual/
(user manual chapters), agents/skills/ (contributor task guides), docs/,
test/, and plans/.

Three layers populate $HOME:

  1. Seed — omarchy-settings ships static defaults to /etc/skel/.
    Arch's useradd -m copies that tree into a new user's $HOME at user
    creation. This is the only mechanism that touches a brand-new user's home
    for these files.
  2. Finalize — omarchy-provision-user (routed as omarchy finalize user) runs once per user and handles the things /etc/skel can't do
    because they need $HOME expansion, the live $OMARCHY_PATH, or runtime
    detection of system state.
  3. Resync — omarchy-reinstall-configs is the explicit, destructive
    command for an existing user to clobber their configs back to shipped
    defaults.

/etc/skel only fires at user creation. Existing users picking up new
defaults must use the resync command.

Deferred-provisioning installs (omarchy-apply-system --defer-provisioning)
create no user at all: the ISO leaves /var/lib/omarchy/provisioning/pending
behind, which arms omarchy-provision-owner.service (shipped from
install/provisioning/, alongside the factory-reset finish unit and
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.

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,
shell layout, plugins, and themed template overrides.

Build-time map (repo → installed paths)

text
/* Detailed source-code truncated for AI context efficiency. */

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.

text
/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.

omarchy-update-keyring refreshes each installed package keyrings names (# comments and blank lines skipped, and a line that isn't a *-keyring package name skipped with a warning) along with Arch's, before the system upgrade, installing any newer version the repository has, 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,
security/faillock.conf, cups/cups-browsed.conf, plymouth/plymouthd.conf)
are owned by upstream Arch packages, so we can't install over them via pacman
without a file conflict. Instead their sources (under etc/ in the repo;
.bashrc from default/bashrc) ship at
/usr/share/omarchy/etc-overrides/ and the omarchy-settings post_install
/ post_upgrade scriptlet cp -f's them into place.

Tradeoff: user edits to those files get clobbered on every omarchy-settings
upgrade. This is documented in the PKGBUILD.

Locate indexing

default/systemd/system/plocate-updatedb.service.d/10-omarchy.conf ships through omarchy-settings to /usr/lib/systemd/system/plocate-updatedb.service.d/10-omarchy.conf. It replaces the existing service's ExecStart with updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots, keeping Btrfs subvolume mounts searchable and excluding Snapper snapshots. The upstream service retains its timer, resource limits, and sandbox; Omarchy's existing AC-power condition still applies.

/etc/updatedb.conf remains owned by plocate and is never rewritten by Omarchy. The command-line options override bind-mount pruning and add to the administrator's existing path exclusions. Installer and AUR package refreshes pass the same options directly because installation may run without systemd and an explicitly requested refresh should work on battery.

Arch's systemd package hook reloads units when the vendor drop-in is installed or upgraded. The settings package containing the drop-in must ship alongside the runtime package that removes the old configuration helper and migration. Pacman removes those retired files; no new state migration is needed. A running indexer finishes with its original options, and subsequent service starts use the drop-in. For an immediate local test after installing the packages, restart plocate-updatedb.service while connected to AC power.

Env bootstrap (`default/bash/env-bootstrap`)

Single source of truth for OMARCHY_PATH and dev-link-aware PATH. It:

  • Sources /etc/omarchy.conf (written by omarchy-dev-link, reset to the
    package path by omarchy-dev-unlink) if present; otherwise forces
    OMARCHY_PATH=/usr/share/omarchy so a stale inherited value can't survive
    an omarchy-dev-unlink.
  • Prepends $OMARCHY_PATH/bin to PATH only when OMARCHY_PATH is
    not /usr/share/omarchy. On a production install the binaries are
    already on PATH as /usr/bin/omarchy-* via the omarchy package.
  • Prepends mise's command-wrappers/bin so subscription account dispatch runs before inherited tool binaries, then appends ~/.local/share/mise/shims and ~/.local/bin so login shells and the uwsm session find mise-managed tools. The existing mise tool shims also dispatch these wrappers for SSH commands that run no shell setup; the PAM path needs no additional entry. etc/mise/conf.d/omarchy-agent-accounts.toml declares the Claude, Codex, and Grok wrappers; mise reshim builds them during user setup and migration. Each invocation resolves the selected account through omarchy-agent-account-exec, while an explicit provider home takes precedence. Credentials and running sessions remain in their original account homes.

Sourced by every entry point that needs the env set:

text
/etc/profile.d/omarchy.sh                      (system login shells)
/etc/skel/.bashrc                              (interactive shells)
/usr/share/uwsm/env.d/10-omarchy               (Hyprland session via uwsm)
/usr/share/omarchy/default/bash/envs           (SSH / non-login bash)

Idempotent — safe to source more than once in the same shell.

PATH covers everything the user runs, but not sudo, which resolves command
names against secure_path from /etc/sudoers. So omarchy-dev-link also
writes /etc/sudoers.d/omarchy-dev-path:

text
Defaults secure_path="<checkout>/bin:/usr/local/sbin:/usr/local/bin:/usr/bin"

Without it, sudo omarchy-* fails for a command the package has not shipped
yet and silently runs the packaged copy of one it has. The drop-in is validated
with visudo -c before install and removed by omarchy-dev-unlink; unlike
/etc/omarchy.conf, it takes effect without a reboot.

Factory reset is an exception: it always self-elevates through /usr/bin/omarchy-system-factory-reset and refuses a checkout copy that differs from the installed command, including when invoked with sudo. Install the matching package before resetting so a newer checkout cannot silently hand off to older account-scrubbing code.

Runtime finalization (`omarchy-provision-user`)

Runs once per user. It does not copy ~/.config/**, ~/.bashrc,
flags.lua, or the nautilus extensions — /etc/skel already seeded those.
It only does the things /etc/skel can't:

  • Skill symlinks into ~/.agents/skills/<name>, ~/.claude/skills/<name>, ~/.codex/skills/<name>, ~/.pi/agent/skills/<name>, ~/.gemini/config/skills/<name> (Antigravity), ~/.hermes/skills/<name>, and each existing ~/.hermes/profiles/*/skills/<name> → $OMARCHY_PATH/default/agents/skills/<name>, looping over every skill directory there (currently omarchy and diagnose-crash) so new skills need no edit. Symlinks (not copies) so omarchy dev link against a dev checkout repoints them correctly. Hermes profile dirs are only linked when they already exist — provision does not create Hermes profiles.
  • xdg-user-dirs-update (Templates/Public/Desktop folded back into $HOME)
    and ~/.config/gtk-3.0/bookmarks (needs $HOME expansion).
  • Hyprland's package-owned default input reads XKBLAYOUT / XKBVARIANT
    from /etc/vconsole.conf; no per-user Hyprland config rewrite is needed.
  • xdg-settings set default-web-browser chromium.desktop and
    xdg-mime default HEY.desktop x-scheme-handler/mailto (XDG-aware paths).
  • omarchy-refresh-applications (composes generated .desktop launchers).
  • Sources install/user/all.sh — theme, chromium, git, xcompose, mise,
    keyring, per-user hardware quirks (asus mic/mixer, framework f13 audio, …).
  • On --first-install, marks every shipped user migration as already applied
    for the freshly-created user.

Idempotency marker: ~/.local/state/omarchy/done/finalize-user, managed
by omarchy-done.

The ISO calls it as omarchy-provision-user --force --first-install in the
target chroot as the install user, after omarchy-apply-system has finished
the root-side work. omarchy-provision-owner makes the same call (with
OMARCHY_SETUP_CONTEXT=provision-owner) when it creates the user during
deferred first-boot provisioning.

Migrations (`omarchy-migrate`)

See migrations.md for the full migration model, authoring
guidelines, and troubleshooting notes.

Omarchy migrations live in migrations/*.sh and run per-user through
omarchy-migrate. Completion state lives in
~/.local/state/omarchy/migrations/, so every user gets a chance to run every
migration. Migrations run as the user; privileged work should invoke the
appropriate helper or privilege prompt. Migrations must be idempotent;
machine-wide repairs should no-op when another user already applied them.

Each graphical user has omarchy-migrate-notify.service, started once per login
through WantedBy=graphical-session.target and ordered after that target so
notification actions can safely launch through UWSM. The omarchy-pkgs
PKGBUILD has shipped omarchy-update-user-notify.service as a symlink onto
it, so users enabled under the old unit name keep working before they reach
migration 1785095882.
It runs omarchy-migrate-notify as
that user, which checks omarchy-migrate --pending. If this user has missing
migration state, it shows a notification that opens a terminal for
omarchy-migrate. The notifier never runs migrations in the background.

Login is the only trigger. Nothing watches the packaged migration directory: a
watcher cannot tell a bypassed pacman -Syu from the package transaction inside
a normal omarchy update, so it notified about migrations that omarchy-migrate
was already applying in the visible update terminal.

omarchy-migrate waits for any active pacman transaction to finish, then runs
pending migrations. It does not need --force; migrations happen when state
files are missing. omarchy update runs omarchy-migrate after the package
transaction in the already-visible update terminal, then runs
omarchy-hook post-update.

First-run (`omarchy-provision-first-run`)

Runs once on first interactive login, after the user manager is live. It
first runs omarchy-provision-user || true so finalize catches up if it
never ran, then handles the steps that need a running graphical session
and/or a working user systemd instance:

  • omarchy-hook-install post-update for the two shipped hooks
    (setup-fingerprint.hook, setup-agent.hook).
  • install/user/first-run/enable-user-units.sh — daemon-reload, then
    systemctl --user enable --now the shipped user units (bt-agent,
    omarchy-sleep-lock, omarchy-recover-internal-monitor,
    omarchy-migrate-notify.service, omarchy-fcitx5.service,
    omarchy-crash-watch.service) so they run in the first session too.
    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).
  • install/user/first-run/gnome-theme.sh,
    install/user/first-run/gtk-primary-paste.sh — GNOME/GTK settings that
    need the dconf daemon.
  • install/user/first-run/audio-tuning.sh — apply speaker tuning.
  • install/user/first-run/welcome.sh — keybindings toast that greets the
    first login and opens the cheatsheet when clicked. The caller runs
    omarchy-notification-wait once before this and the Wi-Fi step, so both
    toasts land on a live notification server.
  • install/user/first-run/wifi.sh — Wi-Fi/update toasts (waits detached on
    nm-online so the update prompt only lands once there is a connection).

The entire sequence has one idempotency marker:
~/.local/state/omarchy/done/first-run-user, managed by omarchy-done.
Completed users exit before any first-run step. On failure the marker is not
written and the sequence retries next login.

Completion markers live under ~/.local/state/omarchy/done/. Use
omarchy-done check <name> to check one and omarchy-done mark <name> to record it.
Use omarchy-done ensure <name> as a conditional when the guarded work should
run only once; it records completion before returning success.
The Quattro upgrade completes graphical first-run for upgraded users and moves
the legacy finalization marker from ~/.local/state/omarchy/ into done/.

Root-side install orchestration

omarchy-apply-system (root, in chroot) runs target-side setup at ISO
finalization. It sources:

  • install/config/all.sh — theme links, lockout limits, lockscreen PAM,
    powerprofilesctl shebang fix, SSH command path and keepalive, docker setup,
    Snapper retention, locate index tuning, service enablement, firewall.
  • install/hardware/all.sh via omarchy-apply-hardware — vendor- and
    device-specific kernel modules, udev rules, microcode, wireless regdom,
    ASUS / Framework / Intel / Apple / Lenovo quirks.
  • install/login/all.sh — SDDM theme/session config.
  • install/post-install/all.sh — final pacman/udev/localdb passes.

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-.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:

text
~/  ←  cp -af /etc/skel/.

Replaying /etc/skel over $HOME is exactly what useradd -m does for a
brand-new user, so this one copy resyncs .bashrc, .config/**,
.local/share/applications/, the nautilus-python extensions, hypr toggles,
branding files, and the shipped migration markers in a single pass.

Then it runs omarchy-refresh-limine, omarchy-refresh-plymouth, and the
nvim refresh. Destructive: existing user files copied from /etc/skel are
clobbered without backup. Fastfetch is package-owned at
/etc/fastfetch/config.jsonc; delete ~/.config/fastfetch/config.jsonc to
return to the packaged default.

Quick reference: where does X live?

Goal Touch
Default file at ~/.config/foo/ config/foo/
/etc/ drop-in we own outright etc/
/etc/ file owned by an upstream package etc/ (see etc/security/faillock.conf), then add to etc-overrides in omarchy-settings PKGBUILD + scriptlet
Package-owned system file (e.g. systemd user service in /usr/lib) default/, then add the install -Dm644 line in omarchy-settings PKGBUILD
Per-user file that's static but lives outside ~/.config default/, then add install -Dm644 ... $pkgdir/etc/skel/... in omarchy-settings PKGBUILD
Runtime tweak that needs $HOME or live system state extend omarchy-provision-user, or add a per-user leaf under install/user/ and wire into install/user/all.sh
One-time root-side setup step install/config/*.sh or install/hardware/*.sh, wire into install/config/all.sh or install/hardware/all.sh
One-time fix for existing installs migrations/.sh
Package-owned path something else may already write Prefer a path nothing else writes, such as a vendor drop-in under /usr/lib. Otherwise the --overwrite entry in bin/omarchy-update-system-pkgs has to ship a release before the file
User-facing omarchy-* command bin/omarchy-<group>-<verb> — see GROUP_DESCRIPTIONS in bin/omarchy
New stock theme themes/<name>/ (+ matching templates under default/themed/ if they need theme colors)
User-installed theme ~/.config/omarchy/themes/<name>/
Generated current theme/background state ~/.local/state/omarchy/current/

Kitty defaults and user overrides

Kitty loads /etc/xdg/kitty/kitty.conf before ~/.config/kitty/kitty.conf. The omarchy-settings package owns the system file; the user template contains only the active theme include and commented examples for personal overrides. Keeping the theme include in the user file lets users remove it without changing the packaged defaults. Individual inherited keybindings can be unmapped with an empty map <shortcut> directive, or all inherited bindings can be cleared with clear_all_shortcuts yes.

The system default uses allow_remote_control socket-only so Omarchy can query the active terminal directory over its Unix socket while Kitty rejects remote-control requests arriving through terminal output. Changing this setting requires restarting Kitty. The migration refreshes the exact previous stock config with a backup; customized configs retain their settings and ordering, with only explicit unrestricted yes, y, or true remote-control settings commented out.


I18n (`docs/i18n.md`)

Omarchy in 50 languages

Omarchy ships in the same 50 languages as omarchy.org: English plus 49 translations, chosen under Setup > Language in the menu. English is the default and the fallback for every string a translation has not covered yet. Picking a language translates the Omarchy menu, the Quickshell desktop, and Omarchy's own notifications, and sets the session's LANG to match, so other applications follow as they are opened: LANG goes to ~/.config/uwsm/env.d/50-omarchy-language, which uwsm exports at every login, and into the running session's systemd user environment, which every app launched through uwsm app starts from. Apps already open keep the language they started with until restarted, and the system locale in /etc/locale.conf (the console, the login screen) is left alone. The Omarchy interface itself switches immediately, without a shell restart, because the shell watches the language file.

Where things live

  • default/i18n/locales.json — the 50-language registry: code, native name, English name, BCP 47 format locale, glibc locale, and text direction where it is not left-to-right. The roster mirrors src/i18n/locales.json on the omarchy-site fifty-languages branch (56 site editions collapse to 50 languages because the regional English editions share English text).
  • default/i18n/en/menu.json, shell.json, cli.json — the English source catalogs. Every other language mirrors their keys exactly in default/i18n/<code>/.
  • manual/*.md — the English manual. Translations live in manual/<code>/ under the same file names.
  • ~/.config/omarchy/language — the user's choice, one registry code. A missing or unknown code means English.
  • bin/omarchy-language — shows or sets the language and the session's LANG. With no argument it prints the current code; list prints the picker rows; omarchy language da switches. Only generating a locale that isn't generated yet needs root, as one elevated script with a single password prompt: sudo in a terminal, pkexec when picked from the menu. Switching between generated locales prompts for nothing. The choice is persisted only after that succeeds, so a cancelled prompt leaves everything as it was.
  • default/i18n/<code>/keybindings.json — the descriptions in the keybindings menu (Super+K), keyed by their English text. The English catalog is generated from the bind calls in default/hypr/bindings/*.lua by scripts/i18n-sync.py (a description built with .. becomes a %1 pattern such as Switch to workspace %1), and omarchy-menu-keybindings swaps each row's description for the current language's as it shows the menu, after ordering the rows in English. A description a user wrote shows as written.
  • bin/omarchy-i18n — prints one cli.json string in the current language: omarchy-i18n battery.lowBody 12. Catalog entries carry %1-style tokens, substituted from the extra arguments, so translations may reorder them freely.
  • shell/Commons/I18n.qml — the shell singleton. QML reads I18n.t("common.search") (or Commons.I18n.t(...) with a qualified import), with .arg(...) filling %1 holes. It layers the current language's shell.json over the English one and re-renders live when the language file changes. It also exposes formatLocale from the registry. Shell code that prints day or month names formats with Qt.locale(I18n.formatLocale) rather than Qt.formatDate or a bare Qt.locale(), which follow the system LANG and so stay in the old language until the next login. Qt's MMMM is the genitive month (right in "14 октября", wrong in a heading), so a month on its own uses standaloneMonthName, and date patterns that vary by language (clock.dayMonthFormat, clock.monthYear) live in the catalog.
  • shell/plugins/menu/MenuModel.js — applyTranslations retitles the shipped menu rows from menu.json before the user's menu extension merges on top, so user rows and retitles always win. The English label moves into the row's search-only terms, which keeps English search working in every language without making English words routable menu commands. The injected root row ("Go") has no JSONC entry, so its label comes from the catalog's root entry.
  • test/shell.d/i18n-test.sh — holds the whole arrangement honest: registry shape, English menu catalog freshness against omarchy-menu.jsonc, exact key and placeholder parity for all 49 languages, every catalog key referenced and every referenced key present, complete per-language manuals, and omarchy-language / omarchy-i18n behavior against stubbed locale files.

Adding or changing a string

English changes first, translations follow. For a menu label, edit default/omarchy/omarchy-menu.jsonc and mirror the change in default/i18n/en/menu.json (the test fails until they agree). For a shell string, add the key to default/i18n/en/shell.json and read it with I18n.t(...); plain-JS models take an optional tr(key, english) argument defaulting to the English text, which the Node tests assert. For a notification or other user-facing CLI string, add the key to default/i18n/en/cli.json and print it with omarchy-i18n. Terminal output, usage text, and diagnostics stay English, as does the developer gallery; plugin manifest names and descriptions are not translated yet.

A few habits keep a translated string correct. Keep each sentence whole in one key with %1-style placeholders, never glued from translated pieces, since other languages reorder words; where English branches on a count, use a …One key for exactly one beside the general key. Translate at the point of display: a value that is stored, compared, or answered over IPC stays English, and a line that must keep an English literal carries an // i18n: ignore comment so the extractor passes it. Keep I18n.t() inside bindings so a language switch re-renders it; text assembled once when something happens keeps the old language until rebuilt. A QML Text whose text becomes an I18n.t() binding needs an explicit textFormat (qml-text-format-test.sh). Day and month names come from Qt.locale(I18n.formatLocale) (standaloneMonthName for a month on its own), never Qt.formatDate or a bare Qt.locale(); in bin/, run date with LC_TIME="$(omarchy-language locale)". Date patterns that vary by language, like clock.dayMonthFormat, live in the catalog as Qt patterns.

Third-party shell plugins ship their own catalogs and read them through Commons.Translations (shell/Commons/Translations.qml), and Omarchy-style apps follow the language with a small JSON-backed QTranslator; the omarchy skill's plugins.md and the omarchy-app skill describe both for the agents that build them.

Until a translation catches up, the missing key simply renders in English. The site keeps its manual in English for now (manual: false in its registry); the translated manuals in this repo are ahead of it and can be picked up there later.

Keeping translations in sync

Individual pull requests only ever add the English side: the string in code plus its English catalog entry (or the English manual chapter). The i18n-check workflow validates structure on pull requests — catalogs well-formed, placeholders intact, English menu catalog fresh, and no user-facing English literals embedded directly in bin/ or shell/ — but deliberately does not require translations. The extractor flags any QML property whose name ends like display text (text, tooltipText, activeTooltipText, leftLabel, placeholderText, …), every literal on such a line, and bare string-list items such as hero phrases; in bin/, literals on omarchy-notification-send and menu prompt lines. A handful of intentional literals (brand names and fixed phrases) live in default/i18n/extract-allowlist.json; for one line rather than one string everywhere, use the // i18n: ignore marker.

The Sync translations workflow does the converging, modeled on omarchy-site's translate workflow. It runs on pushes to quattro that touch English sources, daily, and on demand. scripts/i18n-sync.py computes what is pending from default/i18n/translation-manifest.json, which records the hash of the English source every catalog key and manual chapter was translated from; a unit is pending when its translation is missing or its English changed since. The workflow first extracts any English that was embedded directly in code — on a credential-less runner that only produces a patch, which another credential-less job applies and validates (the checks load the extracted code, so nothing there may hold a token), and a trusted job path-checks and publishes without running any repository code — then runs one credential-less Muse runner per pending language, collects and validates their patches under per-language path allowlists without credentials, updates the manifest, and lands everything through a final trusted job, again only git and gh, as a single pull request from the i18n-sync branch with auto-merge enabled, so it merges itself once i18n-check passes. The one exception: when extraction changed executable code under bin/ or shell/, auto-merge is turned off, even if an earlier run enabled it, and the pull request waits for a human review. Failed languages simply stay pending and are retried on the next run; English is never blocked. The same script works locally: python3 scripts/i18n-sync.py pending shows the queue and translate --locale da fills one language.

Repository settings this needs: the MUSE_API_KEY Actions secret (optional MUSE_MODEL variable), an I18N_SYNC_TOKEN secret holding a fine-grained PAT with contents and pull-requests write on this repository (pull requests created with the default GITHUB_TOKEN do not trigger workflow runs, so their required check would never report; without the PAT the sync pull request still opens but needs a human nudge), "Allow auto-merge" enabled, and i18n-check as a required status check on quattro so auto-merge actually waits for validation.

Translator brief

Translate like the interface was written in your language: short, natural, and in the register of consumer desktop software. Menu labels are one or two words where the language allows it. Keep the following exactly as they are: product and brand names (Omarchy, Hyprland, Neovim, Chromium, application names, agent names, font names), technical tokens (DNS, DHCP, AUR, TUI, VM, QR, GPU, SSH, PATH), commands, file paths, URLs, keyboard key names (Super, Alt, Ctrl, Home, F9), and placeholders (%1, %2, … in both catalogs) — a translation must carry the same placeholders as its English source, though it may reorder them. Clock-format preview labels translate the weekday and month words only; times and digits stay. ALL-CAPS section headers stay capitalized where that is natural for the script. In the manual, preserve Markdown structure, headings, links, code blocks, commands, and file names verbatim, translate link text and prose, and keep the author's voice — the manual is opinionated on purpose.

Regional notes: pt is European Portuguese as written in Portugal (estar a fazer, equipa, ecrã, ficheiro, utilizador); pt-BR is Brazilian Portuguese. es-MX is Mexican Spanish. zh-CN is Simplified Chinese and zh-TW Traditional Chinese in Taiwan usage. no is Norwegian Bokmål. sr is Serbian in Cyrillic. The stock noto-fonts and noto-fonts-cjk packages already cover every script in the roster, so no language needs extra fonts.

A plural entry is an object of CLDR categories rather than a string: English writes {"one": "%1 device", "other": "%1 devices"}, and each translation supplies exactly the categories its language's counts reach — Russian one, few, many, other; Arabic all six; Japanese only other — always keeping every placeholder in other, and dropping the count from a form only where the language says the number with a word.

Plurals, numbers, and direction

Plural rules live twice, in shell/Commons/Plural.js for the shell and default/i18n/plural.jq for omarchy-i18n, and i18n-test.sh checks the two agree for every language. The sync script asks plural.jq which categories a language needs, so translations come back complete. In QML, I18n.tn("key", count) picks the form (.arg() still fills the holes); in bash, omarchy-i18n --count "$n" key "$n"; a plain-JS model takes an optional tn the way it takes tr. A string that only abbreviates a unit (%1h, %1 min) is not a plural.

Numbers follow the language too: the registry's decimal and zeroDigit (taken from Qt, so they match the dates it writes) give Danish 13,1 and Persian ۱۳٫۵. Shell code formats displayed numbers with I18n.formatNumber(value, decimals) (shell/Commons/Numbers.js), never toFixed or toLocaleString; omarchy-i18n localizes any argument that is a plain number, so commands pass numbers as their own arguments with the unit in the catalog string, and omarchy-i18n --number writes one number for text outside a catalog. Numbers are not grouped, so a year or a port never grows a separator.

Arabic, Hebrew, Persian, and Urdu are marked "direction": "rtl" in the registry, and I18n.rightToLeft mirrors every panel, menu, notification, and dialog with Qt's LayoutMirroring (set in KeyboardPanel, PopupCard, OverlayWindow, and the standalone windows); the bar keeps the sections the user arranged. Anchors, rows, layouts, and text alignment flip on their own. Anything placed by explicit x arithmetic follows LayoutMirroring.enabled itself, as PanelSlider does; glyphs that mean back or forward swap with I18n.rightToLeft; and Left/Right key handling goes through Util.readingKey(event), so Left moves forward along a right-to-left row.


Input Methods (`docs/input-methods.md`)

Input methods

The install keyboard picker selects a console keymap plus an optional Fcitx engine. install/provisioning/setup-form.sh is shared by the ISO configurator and deferred first-boot owner setup. Its rows are label|console keymap|input method|desktop XKB override; the last two fields are optional, so ordinary keyboard choices keep their existing meaning.

The ISO carries the extra fields in omarchy_install.input_method and omarchy_install.input_xkb_layout. Both installation paths persist them in /etc/omarchy/input-method as INPUT_METHOD and XKB_LAYOUT. This file is parsed as data, never sourced. Korean and Lao have a real US console keymap and a kr or la desktop override (Lao is non-Latin, so US still leads on the desktop); Japanese uses jp106, or US when the US-keyboard choice is selected. Console and disk-unlock input remain ordinary keymaps.

install/user/input-method.sh calls omarchy-input-method configure --seed during user finalization, before graphical login. The standard engines are in the base package set so deferred provisioning and factory resets do not need network access to configure typing. default/input-methods/presets.json is the engine catalog used by the post-install selector.

default/input-methods/configure.py owns the shared configuration behavior. Offline seeding replaces only a missing profile or Fcitx's untouched single keyboard-us guess. The group layout matches Hyprland's effective leading layout and variant; non-Latin layouts retain the US-leading behavior used for desktop bindings. The selected composition engine is the group's DefaultIM, with ActiveByDefault=False so initial typing remains Latin. Existing multilingual profiles and explicitly chosen keyboard layouts are preserved.

omarchy setup input <engine> installs the selected engine's packages, then appends it through the same path as the Input Methods picker. Selecting an engine whose package is installed but which the running Fcitx has not discovered (one an update installed, say) restarts the supervised Fcitx unit once and waits for it; an engine whose package is missing is refused with the package name. Changes go through InputMethodGroupInfo, SetInputMethodGroupInfo, and Save, keeping the group name, layout, existing methods in their switching order, and their layout overrides. There is no parallel unsupervised daemon. A per-user D-Bus activation file delegates org.fcitx.Fcitx5 to omarchy-fcitx5.service.

Setup > Typing > Keyboard Layouts and Input Methods use the native menu's multi-select mode, with current entries checked and changes applied immediately. default/input-methods/typing_setup.py uses the installer’s keyboard choices, converted to desktop layouts through systemd’s keymap table, and reads the current input group. Input selection replaces only the non-keyboard group items and retains selected engines' layout overrides; deselection does not uninstall packages. The standard composition engines are already shipped in the base package set. Closing a picker keeps changes already applied. Failed updates leave its checked state unchanged.

Keyboard selections are persisted as data in ~/.config/omarchy/keyboard-layouts; default.hypr.keyboard.selected() reads them before falling back to installation defaults. Personal Hyprland overrides still take precedence. A save reloads and validates the compositor, aligns the Fcitx group's primary keyboard with the leading layout/variant, and restores the previous configuration and group on failure. Non-Latin selections keep the installer's Latin-leading rule for desktop shortcuts. System console, disk-unlock, and greeter layouts are unchanged.

The bar reads Fcitx through one long-running reader, default/input-methods/indicator.py, started by shell/Commons/InputMethodState.qml. Fcitx signals neither a method switch nor a configuration change over D-Bus (Controller1's InputMethodGroupsChanged stays silent for both), so the reader reads once at startup and again when the org.fcitx.Fcitx5 bus name changes owner or the profile changes. The profile is $FCITX_CONFIG_HOME/profile, else $XDG_CONFIG_HOME/fcitx5/profile, located from the environment of the process that owns the bus name each time it appears, so a service drop-in that moves it is followed. The file watch follows saves that rename a new profile into place; a dotfiles directory relinked while Fcitx runs is picked up at Fcitx's next start. It polls every 500 ms only while there is something to switch between: more than one method in the current group, a lone composition engine (which still toggles and steps aside in password fields), or more than one group. A read that fails while the service is running is retried every 2 s. A single-method setup costs no wakeups.

Shortcut defaults reserve Ctrl+Space for Tmux/Herdr, Super+Space for the menu, Super+Shift+Space for the bar, and Super+grave for the console. Hyprland owns Super+I to cycle all selected compositor layouts followed by the configured Fcitx input methods, independently of Fcitx switching keys. With keyboard layouts alone, the same shortcut cycles the compositor layouts, keeping matching devices in sync even without the bar widget. The widget is enabled in the default right-hand bar layout and appears only when more than one input method or keyboard layout is configured. Fcitx handles Zenkaku/Hankaku and Hangul; Henkan activates and Muhenkan deactivates. Ctrl+Space is never a Fcitx toggle: configure --defaults removes it from any TriggerKeys list, keeping the user's other keys, and writes Omarchy's list where none exists, since Fcitx's built-in default includes it. Other existing lists, including empty ones, are preserved, and stock generated lists are repaired only while the user still has the single-keyboard default profile. A terminal Ctrl+Space returns to direct input before the following key, with a 300 ms timeout and no D-Bus activation. Wayland and XCB addons do not override the compositor's XKB settings.

The migration installs the standard engines and applies conservative defaults. It aligns an untouched Fcitx keyboard profile with the install layout, using the live controller when Fcitx is running. It leaves existing multilingual profiles and working trigger keys alone. Atomic file writes follow configuration symlinks, preserve file modes, and roll back on a failed seed or migration so a retry can complete. Migration bus errors defer profile alignment to the next login and do not restart input. If a pre-update daemon has not discovered a new engine, the migration aligns only its keyboard and prints the setup command.

Input selection does not change the system locale, repository region, desktop language, or Latin interface font. A new language setup seeds a CJK fallback preference once; additional languages do not overwrite it.

Focused checks live in test/shell.d/input-method-test.sh, setup-form-test.sh, hyprland-keyboard-layout-test.sh, and hyprland-jis-bindings-test.sh. test/acceptance.d/input-methods-test.sh exercises real composition in a disposable installed VM and restores the original user configuration. The ISO repository's keyboard tests cover transport and target persistence. Browser input must be verified against the shipped browser versions; the language selector is not a place to guess flags from older guides.

Default key values are checked against Fcitx global configuration.


Lifecycle Dispatch (`docs/lifecycle-dispatch.md`)

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

text
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).

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 needs , which provides ; it is not installed same error
A required operation has no entrypoint, but the package is installed (its pacman record says so) exit 1: Error: <operation> on needs , which <version> does not provide; update same error
An optional operation has no entrypoint (every setup and app-install operation is optional, so none of them ever fails for a missing package) no-op, exit 0 prints nothing, exit 0
A user operation (setup-user, post-install, pre-remove) is run as root on a platform that registers it (Apple Silicon), its entrypoint installed or not exit 1: Error: <operation> runs as the user, never as root same error
The entrypoint fails the trust rules exit 1: Error: refusing : ... (optional operations too) same error
omarchy-hw-platform can't settle the platform exit 1 exit 1
No operation, or one outside the fixed set exit 2 with usage exit 2

Operations

The set is fixed in the dispatcher; adding one is a change to Omarchy. Every operation runs as root except setup-user, post-install and pre-remove, which run as the user. The provision-* operations take no arguments and work on fixed paths: /var/lib/omarchy/provisioning holds the staged install key (luks-key) and the re-key journal (luks-rekey.state). luks-slots takes the slot numbers it records. Factory reset names the factory root it is about to activate, since that is not / yet, and hands reset-commit the throwaway key on standard input, never in arguments.

Operation Called Contract Apple Caller
provision-prepare Owner provisioning, at the start of every setup attempt, before the owner is asked anything Succeeds when the platform can finish setup on this machine. On failure its stderr is shown on tty1 and logged, and the attempt fails into the usual retry screen. It must leave nothing a retry can't repeat. required omarchy-provision-owner
provision-commit Owner provisioning, during the LUKS re-key: after the owner's key is added, before any other slot is retired Removes every boot-time copy of the staged key and its unlock configuration from the platform's boot chain, and rebuilds the boot files so the next boot asks for the password. Idempotent. If it fails, it leaves or restores a boot chain that still unlocks unattended with the staged key, so the retry boots. required omarchy-provision-owner
provision-verify Owner provisioning, whenever it asks whether the staged unlock remains: before the re-key, during it, and before setup drops pending Read-only. Exits 0 only when the boot chain holds no staged key or unlock configuration. Any other status counts as "remains", so setup never finishes. required omarchy-provision-owner
reset-prepare <factory-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 ... 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 /<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 ... (inside the update's authorization, which the move needs anyway). A refusal, or a failed resolution (a Mac without its boot package, or with one too old to ship the operation), keeps every file and fails the update as it did before the retry existed. Where nothing resolves, the takeover goes ahead with no extra step.

Hardware setup (`install/hardware/platform-setup.sh`)

  • The last leaf of install/hardware/all.sh runs setup-boot, then setup-system, so both build on every generic leaf. As a run_logged leaf it is queued in an image build like any other, and omarchy-provision-hardware runs it on the first boot with OMARCHY_IMAGE_DEFERRED_HARDWARE=1, which the leaf passes on as image-first-boot to both. The environment variable itself never reaches the entrypoints.

User setup (`install/user/platform-setup.sh`, `bin/omarchy-provision-first-run`)

  • The last leaf of install/user/all.sh runs setup-user at the end of finalization. While /var/lib/omarchy/image/target exists the root is still being built, where a normal user's detector would read the build host, so the leaf says so and skips it.
  • First run runs setup-user again as its own step, after the user units are enabled, now that the session is up. Its failure, like finalization's, leaves first run pending for the next login.

App installs (`bin/omarchy-install-browser`, `bin/omarchy-install-gaming-steam`, `bin/omarchy-remove-gaming-steam`)

  • A browser install runs post-install once the browser's package, policy directory, flags file and theme are in place, and before it says the browser is installed. Steam's install runs post-install steam after the 32-bit drivers and the package, and before the first launch, and Steam's removal runs pre-remove steam before its packages go, so a package the hook added that depends on Steam doesn't stop the removal.
  • On a platform that registers none, each call is a no-op and the installer runs as before.

Disk password change (`bin/omarchy-drive-password`)

  • After the system disk's key changed and the new key is confirmed, record_owner_slot resolves luks-slots as the user and, when it resolves, runs sudo omarchy-lifecycle-dispatch luks-slots owner=<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). 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.

The Omarchy menu

The menu is the omarchy.menu plugin of the Quickshell desktop, with its
content defined as data in default/omarchy/omarchy-menu.jsonc (read at
runtime from $OMARCHY_PATH) and overlaid by the user's
~/.config/omarchy/extensions/omarchy-menu.jsonc. The shell parses both files
at startup and watches them for changes, so the keybind → IPC → visible path
never shells out to parse anything, and edits to either file take effect
without restarting the shell. Rendering and behavior live in
shell/plugins/menu/Menu.qml; the pure logic lives in
shell/plugins/menu/MenuModel.js, which is plain JavaScript that Node can
also load — the shell tests in test/shell.d/menu-test.sh and
menu-guards-test.sh exercise it directly.

JSONC here means JSON plus comments and trailing commas, stripped by the
parser rather than a real JSONC grammar: only whole-line // comments are
removed, so an inline trailing comment breaks the parse. A file that fails to
parse contributes no entries — a broken user extension silently drops every
user entry while the shipped menu keeps working.

Entry schema

Entries are object keys. The dotted id is the tree: trigger.share.file is a
child of trigger.share, and an id with no dot sits on the root menu. There
is no separate parent field to keep in sync — where an entry appears follows
from what it is called (an explicit parent is accepted but nothing shipped
uses one).

Kind is inferred rather than declared: an entry with action is an action,
one with target is a link to another submenu, and anything else is a
submenu. The fields:

Field Meaning
icon Glyph in the icon column (usually Nerd Font)
iconFont Font family for the glyph when it differs from the menu font — how the private omarchy font's brand glyphs render
label Visible row title; defaults to the id
title Header text when the submenu is open; defaults to label. Lets a row read "Browser" under Defaults while the open menu says "Default Browser"
action Shell command to run, detached, when selected
target Existing submenu id to open; makes the row a link
provider Runtime row source for this submenu (see Providers)
aliases Alternate omarchy menu summon <name> routes; also searchable
description Subtitle shown while searching, and extra search text matched by whole word
when / checked / disabled Shell conditions (see Guards)

Do not add aliases to new entries. They are reserved for established
alternate names users already type (power-menu, settings), kept for
compatibility — see AGENTS.md. Search does not need them: labels, the last
id segment, and descriptions are all searchable.

Load and merge

mergeMenuSources overlays user entries on the defaults per key: reusing a
shipped id replaces only the fields you declare, so an extension can retitle
or re-icon a row without re-declaring its action, and an overridden entry
keeps its original position in the list. New ids append. A root entry is
injected if neither file declares one.

The sample extension at config/omarchy/extensions/omarchy-menu.jsonc
(refreshed into ~/.config/) documents the format in its header and ships
only comments, so the default state adds nothing.

Guards

when, checked, and disabled are bash conditions. The shell never
evaluates them on the open path: all guards in the menu are batched into a
single bash process per (re)load and per open, reporting :<w|c|d>:<0|1>
lines. The menu opens immediately on the previous evaluation's answers, so
the batch's runtime is exactly how long a row can contradict the state it
describes — which is why the batch works hard to be fast:

  • Package and command presence (omarchy-pkg-present and friends) are
    answered in-process from one pacman -Q snapshot instead of a fork per
    row. The snapshot resolves provides too, so gvim answers for vim. It is
    stored in $XDG_RUNTIME_DIR/omarchy-menu-packages, stamped with the
    pacman local database directory (under pacman-conf DBPath), and rebuilt by the first batch after any
    install, removal, or upgrade moves that stamp; a build that overlapped a
    transaction (db.lck) or whose queries failed is used once and never
    stored.
  • Commands that several rows read a value from — every Defaults > Browser row
    compares against $(omarchy-default-browser) — run once, with the captured
    answer substituted into each expression. The reader list is
    GUARD_READERS in MenuModel.js; a new $(omarchy-...) reader used by
    more than one row must be added there, and menu-guards-test.sh fails the
    build if it is not. The readers all start at once, before the package
    snapshot is built.
  • The guards themselves are dealt into eight background subshells that run
    side by side, so the batch takes about as long as its slowest probe rather
    than the sum of them. Result lines therefore arrive in any order, and a
    guard must not depend on anything another guard set.

The three guards differ in what failure means:

  • when hides the row when it fails. A submenu whose visible descendants
    are all hidden disappears with them (provider-backed submenus stay, since
    their rows load on demand).
  • checked appends ✓ when it succeeds — the "this is the current choice"
    marker on defaults, DNS, channel rows.
  • disabled keeps the row listed but dims it, marks it ✓, and makes it
    unselectable: cursor, pointer, and Enter all step over it, and search omits
    it. The Install submenus use it so software already on the machine reads as
    installed rather than vanishing from the list it was installed from — the
    list stays a catalog of what Omarchy can install. Since a dimmed row means
    "you already have this", it earns the same ✓ as checked does elsewhere.

Install rows should therefore carry disabled: with the presence check, not
when:; Remove rows are the opposite, hiding via when: what is not there
to remove. menu-test.sh enforces the Install side of this convention.

Providers

A submenu with provider: "name" gets its rows at runtime instead of from
JSONC. The names are defined by the shell, not the menu file — an extension
can point a submenu at an existing provider but cannot declare a new one:

  • apps is QML-native: rows come from the shared AppLibrary (desktop
    entries), carrying image icons, launch feedback, and uninstall support like
    the launcher. App rows are searchable by their desktop Keywords but never
    routable, so an installed app cannot capture a menu route (htop ships
    Keywords=system;..., and SUPER+ESCAPE must still open the system menu).
  • fonts, power-profiles, and languages are bash one-liners in the providers map in
    Menu.qml. The contract is one tab-delimited line per row:
    label\tvalue\tcurrent, with an optional fourth field that becomes a search-only
    term for the row (the languages provider passes each language's English name, so
    searching "danish" finds the row labeled "Dansk", without making "danish" a routable command). The row whose value equals current gets the ✓
    icon, and selection runs the spec's actionFor(value). Row ids are
    <menuId>.<slugify(value)>, with a - appended on collision so two values
    that slugify alike cannot silently drop a row.

A provider marked volatile re-runs every time its submenu is entered — a
font installed since the shell started shows up without a restart — but not
on search keystrokes, which would restart the same enumeration per key.

swapProviderRows in MenuModel.js merges the results: rows carry the id of
the submenu that produced them, so a provider that runs again drops its
previous batch without disturbing static children declared in JSONC. Both it
and the app merge return fresh item maps for the caller to assign in one go —
writing into a map held by a QML var property occasionally loses the write,
which used to duplicate launcher rows. Never mutate root.items in place.

Adding a provider means adding an entry to the providers map in Menu.qml
(script, icon, actionFor, optionally volatile) and pointing a submenu at
it with provider:.

Driving the menu from the CLI

bin/omarchy-menu is a thin wrapper over the standard plugin IPC surface:

bash
omarchy menu                    # toggle the root menu
omarchy menu toggle system      # open at a route, or close if already open
omarchy menu summon style.theme # always open (no close-if-visible)
omarchy menu close
omarchy menu refresh            # re-parse the JSONC files
omarchy menu ping

A route is an item id or a declared alias, case-insensitive, with
underscores normalized to dashes. An exact id beats any alias; empty input,
go, and menu mean root; an unknown string falls through as a literal id
so a misspelling still attempts to open that id. Summoning a route that
resolves to an action — an alias for a leaf, like screenrecord-stop — runs
the action directly instead of opening an action with no children, and a
link is followed to its target. The default Hyprland bindings in
default/hypr/bindings/utilities.lua all go through this surface
(SUPER+SPACE toggles root, SUPER+ESCAPE the system menu, and so on).

Select and input modes

The same plugin doubles as the system's dmenu. omarchy-menu-select and
omarchy-menu-input summon it with a mode: select or mode: input
payload, then block on a tempfile handshake: the shell writes the selection
to selectionFile and touches doneFile, and cancellation (empty
selection) exits 1. A select option is label, glyph\tlabel, or
glyph\tlabel\tsubtext — the glyph shows but never returns, the subtext
renders under the label, filters with it, and comes back as
label\tsubtext so callers with same-named rows get a stable key. This is
how the pickers behind menu actions (omarchy-menu-plugin,
omarchy-menu-timezone, ...) present lists without owning any UI.

Pass -- --multiple to omarchy-menu-select for a multi-select request. Repeat --selected <value> to preselect returned values (labels plus any subtext, without icons). Clicking or pressing Enter toggles an option without closing the menu; Apply returns a JSON array in option order, including [] when all choices are deselected. Cancellation still exits 1. Selections survive filtering.

With --on-change <JSON command array>, each toggle runs that command with the proposed selection JSON appended as its final argument, and no Apply row is shown. The checkbox changes only after the command succeeds; a failed command retains the previous selection. Changes persist when the menu closes. The typing selectors use this mode.

For immediate multi-select requests, --change-key <key> identifies the setting being changed across picker openings. The menu keeps queued selections for that key when reopened, so a new toggle cannot discard a change still being saved. Without a key, the command array identifies the setting.


Notifications (`docs/notifications.md`)

Notifications

The shell is the notification daemon: shell/plugins/notifications/Service.qml
hosts a Quickshell NotificationServer that claims org.freedesktop.Notifications
on the session bus. There is no dunst or mako — anything that speaks the
freedesktop notification protocol (notify-send, libnotify apps, Chromium web
apps) lands in the shell, which renders it as a toast card stacked in the
top-right corner. The pure decision logic lives in NotificationLogic.js,
which is also loadable from Node so test/shell.d/ can exercise it without
a compositor.

The end-user view (hotkey notices for time, battery, weather) is in
manual/10-notices.md; this document is the system shape behind it.

Toast lifecycle

A toast lives on screen for at least 5s (low), 8s (normal), or forever
(critical), stretched up to 30s if the sender asked for a longer
expire_timeout. Hovering pauses the countdown, and a content update restarts
it — new text deserves a full look. Left-click invokes the default action,
right-click or the hover-revealed close button dismisses.

Every on-screen popup is mirrored to its own file under
~/.local/state/omarchy/notifications/ (one JSON line per file, named
<timestamp>-.json), so live toasts survive the shell restart that
omarchy-update performs. When a toast leaves the screen — expiry, dismissal,
or click — its file moves into notifications/history/, trimmed to the newest
ten. That directory is the history: showHistory replays exactly what has
been moved in there. Referenced avatars/images are copied into
notifications/images/, because senders delete their originals on close.

replaces_id updates never produce a second notification signal: the server
writes new content onto the object the service already holds, so the service
watches the object's property-change signals and rewrites the row and its file
in place, under the popup's original file identity. Restored rows carry ids
from a dead server generation (ids restart from 1 each shell process), so
they are keyed by timestamp+id and never matched against live objects — a
fresh notification reusing an old id must not dismiss or replace them.

Silencing

Do-not-disturb is a single boolean, persisted as the dnd key in
~/.local/state/omarchy/notifications.json and toggled via shell IPC
(omarchy-shell notifications toggleDnd / setDnd / dndState).
omarchy-toggle-notification-silencing wraps the toggle and refreshes the
bar's omarchy.indicators widget, whose Dnd indicator binds directly to the
service's doNotDisturb property.

Two kinds of notification punch through DND, chosen to be intentional and
rare:

  • app_name = omarchy-action — Omarchy's own user-action confirmation
    toasts ("Theme changed"). The user just did something; their feedback shows.
  • urgency critical and app_name = notify-send — bare-CLI emergency
    alerts. Critical alone is not enough, because chat apps abuse it to force
    visibility; they set app_name to their brand, which fails this rule.

A silenced notification that anyone might look back at is written straight
into history — "what did I miss while silenced" is what history is for.
Ephemeral ones (the freedesktop transient hint, or an app_name of
notify-send/omarchy-action) are dropped entirely.

The sender contract

bin/omarchy-notification-send is the one way Omarchy code sends
notifications — never raw notify-send. It calls
org.freedesktop.Notifications.Notify directly over the session bus (via
busctl --user), so each value is one typed D-Bus parameter and there is no
argv layer that could reinterpret a relayed headline as an option or a hint. Its
flags map onto that call:

Flag Becomes Meaning
-g / --glyph hint omarchy-glyph Nerd Font glyph for the icon slot when no image icon resolves
--exec [args…] hint omarchy-exec-argv the click command; consumes the rest of the line, so it comes last. Each word is a discrete argument the shell runs without re-parsing (see below)
--image hint image-path the standard freedesktop image hint
-i / --icon app_icon themed icon name for the toast
--app-name app_name defaults to omarchy-action
-u / --urgency hint urgency (byte) low/normal/critical; defaults to low
-t / --expire-time expire_timeout milliseconds on screen; server default otherwise

Unknown flags are a hard error, not a silent pass-through: --exec is the only
door to a click command, and there is no generic option pass-through to smuggle
one through.

The defaults are the point: an unadorned omarchy-notification-send "Done"
is a low-urgency user-action toast that pops through DND and is treated as
ephemeral noise when silenced.

The click command is deliberately not a libnotify action. An action keeps the
sender blocked waiting for ActionInvoked, and dies unanswered whenever the
shell restarts underneath it — the installer toasts restart the shell as their
first act. Carrying the command as a hint means the shell executes the click
itself (detached, so the command outlives the shell process) from the copy it
keeps with the popup, which the persistence files preserve: a restored toast
clicks through exactly like a live one, and oneshot senders can exit
immediately. For third-party clients the click falls back to the libnotify
default action while the sender is alive, then to focusing the sender's
window by class via omarchy-hyprland-focus-app — chat apps rarely register
an action and just expect click-to-jump.

Click commands are argv, never shell strings

--exec consumes the rest of the line as the click command:

bash
omarchy-notification-send "Download complete" "$title" --exec mpv -- "$file"

The caller's shell has already split those words into discrete arguments, and a
quoted argument ("$file") stays one argument even with spaces. On the shell
side they are run through Util.execArgv, which invokes bash -lc 'exec "$@"'
with the arguments as positional parameters — never interpolated into the
script text. bash expands "$@" without re-tokenizing or re-evaluating it, so a
value carrying data an attacker controls — a downloaded video's title, a
received filename, a crashed process's name — is only ever a single argument and
can never be reparsed as a command. The login shell keeps the PATH and session
environment that GUI click targets (the screenshot editor, mpv, xdg-open) expect.

The critical rule: the splitting must happen at the call site, not inside the
tool.
Passing a single quoted string (--exec "mpv $title") and letting the
tool whitespace-split it would hand argument boundaries to whoever controls the
string's content — a title with a space could inject an extra option or program.
So --exec refuses a lone quoted-string argument and points at the unquoted
form. There is no "take a command string and sanitize it" path; that is the
escaping trap (string-concatenated SQL) the yt-dlp title RCE exploited.

The shell fails closed on a malformed argv hint (it must be a JSON array of
strings whose program is present and not a leading-dash option) rather than
running anything it can't validate. A caller can still deliberately name a shell
as the program (--exec sh -c …), but that runs code because the developer
wrote it, not because attacker data became a command — a reviewable red flag
(greppable as --exec sh/--exec bash), not an injection. Insulating against a
native same-user process is out of scope: it already runs with your privileges
and needs no notification to execute code. What is fully closed is untrusted
content — web notifications can't set the exec hint at all, and any relayed
title/filename is confined to inert argument data.

The sender keeps that last part true structurally rather than leaving it to each
caller. Because it calls Notify directly, the headline and description are
typed string parameters — a relayed value like --hint=… or -rf is the
summary or body, never an option or a hint, and there is no argv/option layer
(no notify-send) left to reinterpret it. --exec is the only thing that can
build the omarchy-exec-argv hint. (The leading -- on the busctl call is a
belt for busctl's own getopt, which would otherwise read a dash-leading value
as a busctl option; the summary/body themselves are never parsed as options.)

Helper commands

  • omarchy-notification-wait [timeout] — polls until the shell answers IPC
    and has claimed the bus name. Anything sending near session start or a
    shell restart uses it, or the toast is sent into the void.
  • omarchy-notification-dismiss — dismiss by summary substring,
    used by the first-run toasts once their action has been clicked.
  • omarchy-notification-time / -battery — the hotkey notices: one-line
    low-urgency glyph toasts wrapping date and omarchy-battery-status.
  • omarchy-notification-weather — despite the name, not a sender: it toggles
    the omarchy.weather shell panel.

Keybindings live in default/hypr/bindings/utilities.lua: Super+comma
variants map to the IPC methods dismissOne, dismissAll, invokeLast,
showHistory, and the silencing toggle.

How subsystems plug in

Everything goes through the same sender contract, so the pieces are small:

  • Low battery — omarchy-battery-low sends a critical toast and runs the
    battery-low hook.
  • Crash capture — omarchy-crash-watch follows the systemd-coredump
    journal stream and announces each crashed program (deduped per minute) as a
    critical toast whose click runs omarchy-agent-crash (via --exec, so a
    hostile process name stays a discrete argument). It waits for the
    server first: a shell crash takes the notification server down with it, and
    that crash is the one most worth reporting.
  • Pending migrations — omarchy-migrate-notify (from its user service
    after graphical-session.target) waits for the server, then sends a
    critical toast whose click opens a terminal running omarchy-migrate,
    falling back to printing in the terminal if the hand-off fails.

Reminders

Reminders ride on notifications rather than being their own daemon.
bin/omarchy-reminder <minutes> [message] creates a transient systemd user
timer via systemd-run --user --collect --on-active=<minutes>m under the
unit name omarchy-reminder-<minutes>m-<epoch>; the timer's payload sends the
reminder toast, deletes its message file, and refreshes the bar indicator.
Custom messages are stashed in $XDG_RUNTIME_DIR/omarchy-reminders/.message
since a unit name cannot carry arbitrary text. --collect means fired timers
leave nothing behind.

The state therefore lives entirely in systemd: show and clear enumerate
systemctl --user list-timers "omarchy-reminder-*.timer" — show as a
summary toast, show --json as the JSON the bar's Reminder indicator polls
(refreshed by the same omarchy-shell -q omarchy.indicators refresh call the
timers and mutations make). omarchy-reminder -i summons the
omarchy.reminders overlay (shell/plugins/reminders/ReminderFlow.qml), a
two-step minutes/message prompt that shells back out to omarchy-reminder to
do the setting.


Omarchy Shell (`docs/omarchy-shell.md`)

omarchy-shell

A single long-running Quickshell instance
that hosts the Omarchy desktop. The bar, panels, overlays, menus, and
services all run inside as plugins. Hyprland autostart launches the
shell via omarchy-launch-shell; restart it with omarchy-restart-shell.
IPC is the canonical way for CLIs to talk to a running shell —
omarchy-shell forwards a call and fails when the shell is not running
(-q makes it quiet best-effort; OMARCHY_SHELL_IPC_TIMEOUT bounds the
wait).

Plugin manifest

json
{
  "schemaVersion": 1,
  "id": "my.org.cool-clock",
  "name": "Cool clock",
  "version": "1.0.0",
  "author": "You",
  "description": "A clock that does cool things",
  "kinds": ["bar-widget"],
  "entryPoints": { "barWidget": "Widget.qml" },
  "barWidget": {
    "displayName": "Cool clock",
    "category": "Time",
    "allowMultiple": false,
    "defaultSection": "left",
    "defaults": { "format": "HH:mm" },
    "schema": [
      { "key": "format", "type": "string", "label": "Format" }
    ]
  }
}

kinds (a manifest may declare more than one):

Kind What it is
bar-widget Component the active bar drops into a section
bar Full bar option that can replace omarchy.bar
panel Floating window (e.g. OSD)
overlay Fullscreen overlay (e.g. background picker)
menu Summoned menu surface
service Headless singleton, no UI

Only one full bar option is active at a time. The built-in omarchy.bar is
used when bar.id is omitted or when a selected third-party bar cannot load.
Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key keepLoaded: true to survive between summons, and to keep a service mounted across plugin hot-reload (so omarchy.lock is not destroyed while Hyprland still holds the session lock). The kept service instance is not replaced, so changes to its code take effect on a shell restart. First-party services are loaded at startup.

Entry points are QML Items. Panel, overlay, and menu entry points expose open(payloadJson) and close() for summon/hide; on load the host injects omarchyPath, shell, manifest, and the registries (pluginRegistry / barWidgetRegistry) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry/configuration snapshots can be changed only locally without mutating host state. The facades are API boundaries, not same-process QML sandboxes: a visual widget shares the host bar's scene and can walk its parent hierarchy to ordinary host objects. Sensitive state must not rely on the facade alone for isolation.

A third-party replacement bar can render registered widget components, but widgets it hosts receive a service-less entry facade. Allowing the bar to manufacture an own-service facade for an arbitrary widget would also let it retrieve that plugin's live service object. Service-backed third-party widgets therefore retain their full integration only under the trusted built-in bar; a replacement bar may still provide their target-scoped lifecycle and settings operations.

Full schema: shell/services/PluginRegistry.qml.

Installing a third-party plugin

A plugin is a git repo with a manifest.json at its root. Adding one
clones it straight into ~/.config/omarchy/plugins//; updating is a
fast-forward pull:

bash
omarchy plugin add https://github.com/acme/omarchy-weather.git
omarchy plugin update                # fetches, shows a diff, fast-forwards
omarchy plugin remove acme.weather

Setup › Plugins offers Enable, Disable, Add, Clone, and Remove. Enable and
Disable include built-ins as well as installed plugins. Clone is limited to
built-ins, while Remove is limited to installed plugins since a built-in has
no checkout to delete. Add, Clone, and Remove open a terminal so their warning,
editor, confirmation, and output stay visible.

Cloning omarchy.clock, for example, creates and switches to
~/.config/omarchy/plugins/.clock/ (e.g. dhh.clock), names it
My Clock, and preserves the built-in IPC identity so existing shortcuts keep
working. The username prefix keeps a shared clone from colliding with anyone
else's. Saving files in any installed plugin reloads its code automatically,
and removing an active clone switches back to its built-in source.

For a bar widget, on and off means its place in the bar. Everything else is
loaded by default when it is built in, so shell.json records only the
deviation: a third-party plugin you added under plugins[], a built-in you
switched off under disabledPlugins[]. A full bar has no off state: enabling
one replaces the active bar, and it is therefore never offered under Disable.
Bar widgets may set barWidget.defaultSection to left, center, tray, or right;
widgets that omit it default to center.

Plugins run as unsandboxed code inside omarchy-shell. Adding warns you before cloning, plugins land disabled so you can review the code before omarchy plugin enable, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add --yes to skip every prompt (the path for scripts and agents). The scoped interfaces remove direct access to authentication services and avoid handing generic cross-plugin service factories to replacement bars, but visual plugins can still traverse ordinary objects in their shared QML scene. Plugin code also has the same user-level file and process access as the shell.

You can still install by hand: drop a plugin into
~/.config/omarchy/plugins//, run omarchy-shell shell rescanPlugins, then
omarchy plugin enable . A bar widget starts in its declared default
section; enabling a full bar replaces the one in use. omarchy bar drives the
bar from the CLI — use | reset | defaults | position | background | put | move | set | get, with placement flags such as --section and --index.
The lower-level IPC methods remain available through omarchy-shell shell ....

Elsewhen

Elsewhen (omarchy.elsewhen), the world clock, is a first-party plugin in shell/plugins/panels/elsewhen/; elsewhen.md covers how it works. It shipped as the separate elsewhen package under the id omacom.elsewhen until it moved in. New installs place it immediately after the clock, before Weather; the placement migration uses omarchy bar put omarchy.elsewhen --after omarchy.clock, which preserves an existing placement and uses Elsewhen's normal right-side placement if the clock is absent. With no shell to ask, as in an update from a TTY, it skips the placement rather than failing the update. A later migration renames existing omacom.elsewhen entries in shell.json, keeping their settings, and removes the retired package and any dev-checkout link to its /usr/share/omarchy path, leaving links and checkouts the user made alone.

IPC

The shell exposes a shell target (the host also registers
image-selector) plus targets registered by individual plugins, named
for the plugin rather than for where it appears: background, osd,
media, notifications, and per-widget targets such as omarchy.clock
or omarchy.power. There is no bar target.

Method Effect
ping health check
summon load + open a plugin
hide close a previously-summoned
toggle summon if closed, hide if open
togglePanelAt <section> toggle the panel at a bar position
call <method> <arg> call an already-loaded plugin
rescanPlugins re-walk plugin dirs and hot-reload plugin code
reloadConfig reload shell.json
applyTheme <colorsB64> <shellB64> push theme colors + shell.toml
toggleBarTransparency cycle the bar background from clear to glassy to opaque
cycleInput <next|back> switch to the next or previous input method or layout (Super+I, Super+Shift+I); not running without the input reader
setPluginEnabled <"true"|…> flip enabled bit (ok / unknown)
enablePlugin enable and place in one mutation
putBarWidget place a widget only where absent (omarchy bar put)
moveBarWidget move a configured widget
setBarWidget <key> <valueJson> <selectorJson> set an inline widget option
listPlugins JSON of every discovered plugin
listShellConfig effective shell.json as JSON
debugBarGeometry bar geometry dump for debugging

setPluginEnabled takes a string; only literal "true" enables. Methods
answer on stdout with exit 0 — ok on success, unknown or an error
string on a miss.

shell.json

json
{
  "version": 1,
  "idle": {
    "screensaver": 150,
    "lock": 300
  },
  "bar": {
    "id": "omarchy.bar",
    "position": "top",
    "background": "opaque",
    "centerAnchor": "omarchy.clock",
    "layout": {
      "left":   [ { "id": "omarchy.menu" } ],
      "center": [ { "id": "omarchy.clock", "format": "HH:mm" } ],
      "right":  [ { "id": "omarchy.audio" } ]
    }
  },
  "plugins": [
    { "id": "community.weather-extra" }
  ]
}

Rules:

  1. The active bar option is bar.id. Omit it or set it to omarchy.bar for
    the built-in bar; set it to a plugin whose manifest declares kind: "bar"
    to replace the full bar.
  2. Every plugin instance is one entry — bar.layout.<section> for
    bar widgets, plugins[] for everything else.
    The sections are left, center, tray and right. tray is
    drawn inside the tray's drawer, after the app icons, and shows only while
    omarchy.tray is on the bar; it cannot hold the tray itself.
  3. Settings are inline on the entry. No config: sub-object, no
    merge layers.
  4. Built-in bar widget ids are namespaced (omarchy.clock, omarchy.audio, …).
  5. Third-party enabled ⇔ present; for full bar options that means bar.id.
    First-party non-bar plugins are enabled unless listed in disabledPlugins[].
  6. barWidget.allowMultiple: true in the manifest permits multiple instances.
  7. idle.screensaver and idle.lock are seconds since user idle began.
  8. version: 1 is required.

config/omarchy/shell.json describes the fresh-install state. When no
user shell.json exists, defaults are used verbatim. Once the user
customizes, shell.json is canonical — there is no deep-merge.

shell.json is shell configuration; theme tokens live in shell.toml
(next section). Both are current — they answer different questions. A
machine-level ~/.config/omarchy/shell.toml is watched live by the
shell and its keys win over the active theme's shell.toml, so
overrides like omarchy display text size survive theme switches.

Theme tokens

Shell and plugin QML must qualify the palette singleton because Qt 6.12 introduces its own Color type. Import qs.Commons with an alias and use Commons.Color for palette properties and signal connections:

qml
import QtQuick
import qs.Commons
import qs.Commons as Commons

Rectangle {
  color: Commons.Color.background
}

The unqualified import can remain for Style, Util, and Border. Existing third-party plugins that use bare Color must make the same change; fixing the host shell does not change a plugin's import scope.

See theming.md for the full theme/template workflow,
including generated *.tpl files, gradient helpers, and shell border syntax.

Themes ship colors in themes/<name>/colors.toml and surface roles +
sizing in themes/<name>/shell.toml. Defaults are generated from
default/themed/shell.toml.tpl; a theme may also drop a hand-written
shell.toml next to its colors.toml to replace the generated file,
or override a single section with shell.<section>.toml, merged in by
omarchy-theme-set-templates (see theming.md).

colors.toml uses foreground and background for the foundational
text/background palette, exposed to QML as Commons.Color.foreground and
Commons.Color.background.

The shell exposes these tokens to QML via three singletons in
qs.Commons:

  • Color — palette (foreground, background, accent, urgent)
    and per-surface roles (Commons.Color.bar.*, Commons.Color.popups.*,
    Commons.Color.tooltip.*, Commons.Color.notifications.*, Commons.Color.menu.*,
    Commons.Color.polkit.*, Commons.Color.lock.*, Commons.Color.imagePicker.*). Clipboard
    and emojis share Commons.Color.menu.*; the [launcher] section is consumed
    by the launcher outside shell QML.
  • Style — structural tokens (cornerRadius), shared interactive
    state tokens/helpers, spacing (Style.spacing.* / Style.space(px)),
    the type scale (Style.font.*), and bar dimensions
    (Style.bar.sizeHorizontal / Style.bar.sizeVertical).
  • Border — border-spec helpers for QML surfaces. Use with
    BorderSurface from qs.Ui when a border should honor shell theme
    gradients or per-side widths. Commons.Color.<section>.border is only the
    flat-color fallback for code that cannot render a real border.

Interactive states

[controls] standardizes reusable control chrome (buttons, dropdowns,
tab strips, etc.) around four states: normal, hover-cursor, focus,
and selected. State colors and border tokens accept palette roles
(foreground, accent, urgent, background) or hex strings; border
values may also be gradients. Fill alpha applies to the state color;
border alpha applies to the state's border token.

Surfaces like [menu], [launcher], and [image-picker] define
their own selected-* tokens and do not inherit from [controls].
[controls] only governs the shared button/dropdown chrome.

State Color token Fill alpha Border token Border width Border alpha
Normal idle chrome normal-color normal-fill-alpha normal-border normal-border-width normal-border-alpha
Hover / keyboard cursor hover-cursor-color hover-cursor-fill-alpha hover-cursor-border hover-cursor-border-width hover-cursor-border-alpha
Qt activeFocus focus-color focus-fill-alpha focus-border focus-border-width focus-border-alpha
Persistent selected/current selected-color selected-fill-alpha selected-border selected-border-width selected-border-alpha

The template ships focus-* adjacent to hover-cursor-* with the same
values so mouse hover, keyboard cursor, and tab focus read identically.
Themes that want focus to stand out override the focus-* keys.

Border widths are the theme-level on/off switches for state borders; set
a width to 0 to keep the fill while removing that state border. The
default keeps selected borders off globally (selected-border-width = 0); explicitly bordered controls keep their normal border when selected.

toml
[controls]
# Accent-tinted cursor/focus, foreground-tinted selected state.
hover-cursor-color = "accent"
focus-color        = "accent"
selected-color     = "foreground"
focus-border       = "rgba(33ccffee) rgba(00ff99ee) 45deg"

# Keep selected fills but remove selected-state borders.
selected-border-width = 0

Momentary fills use pressed-fill-alpha for button press feedback and
selection-fill-alpha for text selection. Themes may also provide
pressed-color or selection-color; they fall back to hover-cursor and
foreground respectively.

The section was previously named [style]. Hand-written theme
shell.toml files using the old name still apply — the parser accepts
both [controls] and [style].

Spacing

[spacing] scale multiplies the shell's shared margins, gaps, padding,
control sizes, and panel dimensions. The default is 1.0; values above
1.0 create more breathing room while values below 1.0 make controls
dense. By default scale-with-font = true, so increasing [font] base-size also scales buttons, popup widths, row heights, and panel
padding proportionally.

toml
[spacing]
scale = 1.15
scale-with-font = true  # grow controls and panels with [font] base-size

QML components should prefer semantic tokens where possible:

Token Default use
Style.spacing.controlPaddingX / controlPaddingY Button and tooltip padding
Style.spacing.inputPaddingY Text-field vertical padding
Style.spacing.controlHeight / popupRowHeight Dropdown and number-field row heights
Style.spacing.dropdownWidth / searchableDropdownWidth / numberFieldWidth Default field widths
Style.spacing.searchablePopupMinHeight Minimum searchable dropdown popup height
Style.spacing.controlGap Gap between icon and label inside controls
Style.spacing.labelGap Label-to-control and compact list gaps
Style.spacing.rowGap / rowPaddingX Form rows and list row content
Style.spacing.panelGap / panelPadding Panel section spacing and interior padding
Style.spacing.popupPadding Popout interior padding

Popout placement deliberately follows Hyprland's general:gaps_out
(Style.gapsOut) so panels align with tiled windows. Use a theme's
hyprland.lua to change that outer alignment; [spacing] controls the
interior breathing room.

For one-off proportional constants, use Style.space(px) to preserve the
old default at scale 1.0 and base-size = 12 while still responding to
the theme scale and font scale. Use Style.spaceReal(px) only for
fractional geometry that should not be rounded, such as bar widget text
margins. Themes can override any semantic token directly in [spacing],
e.g.:

toml
[spacing]
scale = 1.0
panel-padding = 22
row-gap = 10

Typography

[font] base-size is the rem root for the scale. Every
Style.font.<token> derives from it via a fixed multiplier, so
bumping base-size rescales the whole shell proportionally:

Token Multiplier Default
Style.font.caption 0.833 10
Style.font.bodySmall 0.917 11
Style.font.body 1.0 12
Style.font.subtitle 1.083 13
Style.font.title 1.167 14
Style.font.heading 1.333 16
Style.font.display 2.0 24
Style.font.displayLarge 2.333 28
Style.font.iconSmall bodySmall 11
Style.font.icon title 14
Style.font.iconLarge 1.5 18

A theme can either scale everything by tweaking base-size:

toml
[font]
base-size = 13   # roomier

…or pin individual tokens for stylistic emphasis without affecting
the rest of the scale:

toml
[font]
base-size = 12
heading       = 20
display-large = 36

Recognized override keys: base-size, caption, body-small,
body, subtitle, title, heading, display, display-large,
icon-small, icon, icon-large.

base-size has no upper clamp; the shell only floors it at 1px to
avoid nonsensical zero/negative sizes. Per-token overrides aren't clamped
either. The shell font family is the fontconfig monospace alias —
themes don't set it, the user does via omarchy font set <name>.

Bar size

[bar] size-horizontal / size-vertical set the cross-axis dimension
of top/bottom and left/right bars respectively, measured at the default
12px font base. By default scale-with-font = true, so increasing
[font] base-size also increases the bar's cross-axis size:

toml
[bar]
scale-with-font = true
size-horizontal = 26   # top/bottom bar height at base-size 12
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):

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
bar.layout.<section>:

json
{ "id": "vpn", "type": "command", "exec": "~/.config/omarchy/bar/scripts/vpn-status",
  "interval": 5, "tooltip": "VPN", "onClick": "nm-connection-editor" }

Output is plain text or Waybar-style JSON ({ "text": ..., "tooltip": ..., "class": ... }).

For a custom QML widget:

json
{ "id": "gpu", "type": "qml" }

Then ~/.config/omarchy/bar/modules/gpu.qml (or set source to point
elsewhere). The module is an Item and receives bar, moduleName,
settings properties. bar exposes foreground / background /
urgent / fontFamily / position / vertical / barSize, plus
run(cmd), showTooltip(t, s) / hideTooltip(t),
requestPopout(o) / releasePopout(o). To shell-quote arguments for
run, use Util.shellQuote(v) from qs.Commons.


Passwordless Sudo (`docs/passwordless-sudo.md`)

Passwordless sudo

omarchy-sudo-passwordless publishes a timed or permanent grant for the numeric UID authenticated by sudo. Its user interface inspects sudo policy without prompting and authenticates only the enable action when access is off, without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on /run/lock/omarchy-sudo-passwordless.lock.

The menu bar's PasswordlessSudo indicator runs --active when the shell starts and whenever omarchy.indicators is refreshed, and shows the passwordless sudo icon in red while access is active. The probe normally reads sudo's noninteractive long policy listing for the installed __status action and checks its !authenticate tag. If listing requires authentication, it falls back to a noninteractive __status call that ignores cached credentials. Neither path prompts or updates the credential cache. Interactive setup likewise falls back to noninteractive status and, when authentication is unavailable, leaves validation to the single confirmed enable action. Clicking the active indicator runs --disable noninteractively without opening a terminal, so a grant expiring between display and click cannot start the enable flow. The indicator never polls: every enable or disable attempt refreshes it directly, failed ones included since a rolled-back renewal can remove the previous grant, and enabling a timed grant also starts a transient user timer on the same wall-clock deadline that refreshes it two seconds later, so expiry during suspend shows on resume. The root expiry timer cannot reach the user's shell. If that refresh is missed, the indicator stays red past expiry, and clicking it reports access is already disabled and clears it.

Grant lifecycle

The sudoers rule is the only grant record. A timed grant contains the resolved account name and a UTC NOTAFTER deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with visudo, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.

An internal status result is 0 for an active, validated grant and 3 for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface offers a duration picker after result 3. Result 0 toggles access off when no argument is given, or changes its duration when an argument is given. Permanent access always requires confirmation, including when replacing an active timed grant. It must not turn an inspection failure into a claim that no grant exists.

Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone.

Legacy cleanup uses a root-owned machine marker under /var/lib/omarchy/migrations/, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up. The generated filename prefix is reserved: boot cleanup and the package hook already remove everything under it, and the old writer could emit a rule whose body differs from its filename, so the migration moves any other file found there into a fresh root-only directory under /var/lib/omarchy/sudoers-quarantine/, as policy with the original name stored beside it, rather than leaving it live or deleting its content.

Permanent grants live under /etc/sudoers.d/99-omarchy-permanent-nopasswd-, outside the temporary cleanup namespace. They have no NOTAFTER deadline or timer. Status and disable handle both forms; switching duration publishes the replacement before removing the prior policy under the same lock. Old expiry callbacks leave permanent grants intact. If an untrappable interruption leaves both rules, a pair of trusted generated rules for the same account remains inspectable and revocable. Permanent policy continues to govern until an explicit disable or completed duration change; callbacks never remove it merely because the timed companion has expired. Mismatched accounts, symlinks, and administrator-modified companion rules remain errors. Permanent policy survives reboot and package upgrade or removal; disable it before uninstalling the command or downgrading to a version without permanent-grant support, or remove its sudoers file as an administrator afterward.

Package ownership

The packaging companion must put the publication/expiry command, omarchy-security-functions, omarchy-nopasswd-sudo.conf, and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files.

Before settings removal or upgrade, the installed ALPM PreTransaction hook invokes the fixed __package-removing action, acquires the same grant lock, sets /run/omarchy-sudo-passwordless-package-removing and revokes existing temporary policy. The marker prevents a waiting publisher from creating a new timed grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses AbortOnFail because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New timed grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another timed grant.

The runtime marker need not survive reboot: pre-removal revokes the old timed grants before package files disappear, and a new timed grant independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch.

Validation

The two passwordless-sudo test suites share a private filesystem and command fixture. They cover caller validation, the public prompt boundary, atomic publication, renewal failures, expiry, old callbacks, machine migration, and the source/package lock. Supply OMARCHY_PKGS_PATH as either a repository root or its pkgbuilds directory. The package-scriptlet checks skip without a checkout, and with one that predates the settings packages' cleanup scriptlets (omacom/omarchy-pkgs#944). An optional OMARCHY_TEST_SUDOERS path to sudo's upstream testsudoers executable evaluates the generated policy before and after its deadline without root or changing host policy.

These local tests do not establish release readiness. The simplified candidate needs fresh installed-package, suspend/resume, boot-cleanup, and package-removal validation in a disposable VM. The shared security library and its interface are unchanged for downstream PRs.


Testing (`docs/testing.md`)

Testing

How the non-graphical test suites are organized: what each runner owns, the
protocol test files speak, and the conventions that keep them runnable on any
machine — including headless CI sandboxes with no compositor. The graphical
acceptance suite is a separate thing that drives a live session in a disposable
VM; see agents/skills/acceptance-tests.md.

Suite map

./test/all runs both suites below and keeps going when one fails, so a single
failure cannot hide the other suite behind it. It reports the failed suites at
the end and exits non-zero.

  • ./test/cli — one big script, one suite. It owns the CLI router: help
    and group rendering, route resolution, aliases, hidden commands, and the
    guarantee that a trailing --help never executes the target. It also owns
    the metadata lint — every omarchy-* executable under bin/ is checked for a
    # omarchy:summary= header and against removed or redundant fields — plus
    the theme pipeline: template rendering (omarchy-theme-set-templates,
    omarchy-theme-color, omarchy-theme-osc), the theme sync commands
    (tmux, GNOME, VS Code, Pi, Claude) run against stub binaries and a fake
    $HOME.
  • ./test/shell — runs every test/shell.d/*-test.sh (except
    base-test.sh itself). Each file is an independent suite covering one area:
    a shell plugin, a bin/ command, a config invariant, or a still-live migration. This is where new tests go.
  • Acceptance — everything that needs a real desktop doing real things.
    Deliberately excluded from ./test/all; it runs in a VM, not the
    development session.

A new shell test only needs the right name: drop <area>-test.sh into
test/shell.d/ and ./test/shell picks it up automatically. Shared fixtures
live under test/shell.d/fixtures/.

The base-test.sh contract

Every shell test starts the same way:

bash
#!/bin/bash

set -euo pipefail

source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"

base-test.sh refuses to be executed directly — it is a library. It discovers
the repo root from its own location and exports it as ROOT, so tests
reference files as $ROOT/bin/... and never depend on the caller's working
directory or an installed Omarchy.

Assertions are TAP-flavored and blunt:

  • pass "description" prints ok - description.
  • skip "description" prints ok - description # SKIP. Include why the check could not run. Like pass, it returns normally; use exit 0 afterwards only when the rest of the file cannot run either.
  • fail "description" [detail] prints the optional detail and
    not ok - description to stderr, then exits the file. There is no
    counting or continuing within a file: the first failed assertion ends it,
    which keeps later assertions from reporting against state the failure
    already invalidated.
  • require_command <cmd> fails the file when a needed tool is absent.

The runner compensates for that early exit: ./test/shell continues past a failing file and summarizes the failures at the end. Aborting the whole run at the first bad file once let a single packaging failure mask 114 of 134 files. Failure granularity is therefore per file inside a run, per assertion inside a file.

The runner also lists files with skipped checks, including files that ran some checks or later failed. Skips do not fail a run. When no files fail, a run with skips is reported as completed without failures rather than having passed every check.

Compositor-dependent tests

Some tests launch Quickshell or query Hyprland, but the suite must stay green on headless machines. require_compositor "description" handles this: when no compositor answers it calls skip with the reason and exits 0, and otherwise returns so the file proceeds. The skip marker lets the runner distinguish unavailable runtime coverage from checks that passed.

The probe is more than an environment check, because WAYLAND_DISPLAY only
proves the variable was inherited. Sandboxes pass the environment through
while blocking $XDG_RUNTIME_DIR, so Quickshell clears a bare variable check
and then aborts inside QGuiApplication — a core dump per launch where a skip
belonged. So compositor_reachable checks the socket actually exists, then
asks Hyprland itself (hyprctl -j monitors, retried, and only when
HYPRLAND_INSTANCE_SIGNATURE makes it askable), since a compositor that died
mid-session leaves its socket behind. When the compositor is reachable,
require_compositor also sets ulimit -c 0: Quickshell leaves through
qFatal() if its connection drops mid-run, and the test should fail without
writing a core dump as debris.

Gate only what needs gating — put require_compositor in files whose runtime
half needs a live session, and keep static analysis of the same area in code
that runs unconditionally before or beside it.

Performance guards

A handful of tests fail when a fast path gets slow again. Each prefers a count that is the same on every machine over a wall-clock time. Where it has to time something it takes the fastest of several runs, and either compares two paths timed in the same run or scales its budget with process_speed_percent from base-test.sh: how long 100 fork/execs take here against the 25ms they take on the laptop the budgets were set on. A machine more than 8x slower skips the timing (require_comparable_speed) rather than guess.

  • test/shell.d/shell-idle-spawn-test.sh starts a second shell on the default config, waits for its startup work to go quiet, then fails if it starts any process over 30 idle seconds, naming each command line. A preloaded shim (fixtures/idle-shell/exec-log.c, built with cc at test time) logs every exec, fork and spawn the shell and its descendants make, so a probe that lives for a millisecond is caught, and nothing outside the shell's process tree can be. A command that legitimately runs while idle is allowed with allow in that file, only while its reason holds and at most as often as the reason implies. The test watches Hyprland, UPower and NetworkManager events meanwhile; a spawn that coincides with one is given a second window before it fails, and the events are printed with it. The test shell gets stubs for everything that would act on the session (signals, power profiles, bash -l). Compositor-gated, and skipped without a C compiler.
  • test/cli times omarchy screenshot against help for it on a copy of the real command table, and fails unless the alias dispatch costs under a quarter of the full load (13ms against 190ms today).
  • test/shell.d/menu-guard-budget-test.sh runs the shipped menu's guard batch on this machine's real hardware: the batch within 200ms (about 50ms today) and each guard alone within 100ms (the slowest is about 30ms), scaled to the machine, listing the slowest guards when either fails. Skipped without $XDG_RUNTIME_DIR, which holds the package snapshot the menu relies on.
  • test/shell.d/theme-set-budget-test.sh runs the whole omarchy-theme-set in a scratch home where every app is a stub given its real cost (a running Hunk, VS Code's Electron boot, three tmux sessions). It fails when either theme's switch takes over 500ms scaled to the machine (about 80ms today), starts more than a handful of tmux clients, starts VS Code to list its extensions, or waits for Hunk's settle nudge.

Override a budget through the environment variable named at the top of each file when measuring something slower on purpose.

Companion checkouts

Some checks hold Omarchy's scripts against the packages in the separate omarchy-pkgs repository (or the installer in omarchy-iso). find_omarchy_pkgs sets OMARCHY_PKGS_ROOT to that checkout's pkgbuilds/ directory. When OMARCHY_PKGS_PATH is set, only it (the checkout or its pkgbuilds/) is tried, and an override that doesn't resolve fails the test, so a typo never turns package checks into skips. Without the override, the usual places beside this checkout and ~/Work/omacom are tried; finding none returns 1, and the test skips only the checks that need it, the same way a missing compositor is handled, so the suite stays green on a machine with just this repository. Point OMARCHY_PKGS_PATH at the checkout when running from a worktree or an unusual location.

Unit-testing shell JavaScript from bash

The Quickshell plugins keep their logic in plain .js modules
(shell/plugins/menu/MenuModel.js, bar/BarModel.js, ...) that end in a
guarded if (typeof module !== "undefined") module.exports = {...} block. QML
imports them directly and ignores the guard; Node loads them as CommonJS. That
dual citizenship is what makes the shell's model logic unit-testable without a
compositor.

run_node_test is the bridge: it prepends a JS prelude to a heredoc and pipes
the result into node. The prelude mirrors the bash assertion protocol
(pass, fail, assert, assertEqual, assertDeepEqual — same
ok/not ok lines, same exit-on-first-failure) and provides root (from the
exported ROOT), path, and requireFromRoot(relativePath):

bash
run_node_test <<'JS'
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')

const parsed = menu.parseMenuJsonc('{ "items": { "root": { "label": "Go" }, }, }')
assertEqual(parsed.length, 1, 'menu parses JSONC with trailing commas')
JS

Roughly a quarter of the shell test files use this to test parsing, merging,
and layout logic as pure functions, reserving compositor-gated tests for what
only a live session can prove.

Conventions worth copying

  • Redirect background output. Send background fixtures' stdout to a log or /dev/null and clean up the processes on exit. An inherited output pipe can hold the runner open after the test exits.
  • Stub the world, run the real code. Tests build a scratch bin/ of stub
    executables (sudo, tmux, gsettings, helper commands) that log their
    arguments to a file, prepend it to PATH, and then run the real script
    under test. Assertions grep the call log and the files the script wrote.
  • Login shells drop the stubs. A bash -l rereads /etc/profile, which resets OMARCHY_PATH from /etc/omarchy.conf and puts that checkout's bin/ ahead of the stub directory, so a command run through one reaches the real commands again. Commands that start bash -lc (omarchy-theme-set runs its app retints that way) need a bash stub that drops the -l.
  • Fake $HOME, real $OMARCHY_PATH. Anything touching user state runs
    with HOME pointed at a mktemp -d directory (cleaned up via
    trap ... EXIT) and OMARCHY_PATH="$ROOT", so tests exercise the checkout
    without touching the developer's machine.
  • Migrations run directly. A migration test builds the legacy state in a
    fake $HOME, runs bash -euo pipefail "$ROOT/migrations/<ts>.sh", and
    asserts the resulting state — including running it twice to prove
    idempotence, and once against non-legacy state to prove it leaves user
    customization alone. Keep that test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair. Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of omarchy-migrate, the login notifier, and omarchy-upgrade-to-quattro stay.
  • Assert the invariant, not the snapshot. Config tests pin the property a
    test is named for (this widget stays adjacent to that one) rather than whole
    structures, so unrelated churn does not fail them.

Theming (`docs/theming.md`)

Omarchy theming

Omarchy themes live under themes/<name>/ in the source tree (installed at
/usr/share/omarchy/themes/<name>/), with optional user themes under
~/.config/omarchy/themes/<name>/. A theme normally starts with a
colors.toml; Omarchy generates the active theme files from
default/themed/*.tpl when omarchy-theme-set <name> runs.

Beyond colors.toml and hand-written config overrides, a first-party theme can ship backgrounds/ (users overlay their own via ~/.config/omarchy/backgrounds/<name>/; the active image is the ~/.local/state/omarchy/current/background symlink), preview.png and preview-unlock.png for the theme switcher, icons.theme, keyboard.rgb, unlock.png, and a light.mode marker file.

A theme can pair a still background with a silent intro video using matching filenames: backgrounds/0-winding-road.webp and backgrounds/intros/0-winding-road.mp4. The video plays automatically at login and when switching to that theme with this background selected. No registration, checksum file or manual setting is needed. Supported video extensions are mp4, m4v, mov, webm, mkv and avi. Intro videos stay inside the nested intros/ directory so the background picker does not list them as looping wallpapers. An image selected from another directory looks for its intro in that directory's own intros/, so a same-named custom image cannot inherit a theme's video.

Aim for a video between five and seven seconds long that opens on its scene setup and ends on the matching still. The shell provides the opening crossfade, so an added fade from black is unnecessary. OWE owns decoding, mute and the handoff back to that image. The theme picker prepares the highlighted theme's remembered intro offscreen, paused at its opening frame. OWE keeps one prepared clip and an idle renderer for up to one minute, then releases them. A theme switch also starts preparation while its templates render; OWE compares the selected video with the prepared clip before resuming it. Older OWE versions use the ordinary startup path. Hyprland and the SDDM greeter use a black compositor background. On a fresh login, the persistent shell host keeps an opaque black cover above the bar and wallpaper until the bar has loaded and OWE has a moving video frame, then fades both in together over 420 ms. Hyprland initializes with a transparent cursor so its visibility polling cannot expose a pointer in the first frames. The temporary Xcursor theme stays private to the compositor; applications inherit the normal cursor environment. The opening fade restores the selected cursor and settings. Shell restarts and a fifteen-second compositor timeout recover a cursor left hidden by an interrupted startup. With intros disabled or unavailable, it waits for the still to be ready instead. A ten-second deadline releases the cover if a missing wallpaper or failed plugin never becomes ready. Ordinary shell restarts preload the selected still alongside the bar without mapping that login cover. Login intros use the Hyprland instance signature, so logging out and back in plays the intro again. The launcher consumes the current session before waiting up to five seconds for OWE. Shell restarts and a daemon starting after that window cannot play a delayed intro. Theme switches synchronize OWE with the selected image and use its first-frame start mode. An interrupted video supplies its currently playing frame as the outgoing snapshot. Rapid selections wait for the previous opening fade to finish before capturing it. Theme replacement and playback wait for the outgoing cover to decode and reach the compositor on every screen. A persistent layer holds that outgoing wallpaper until OWE hides the shell background and reports a ready video with an advancing playback position and no outgoing still transition, then crossfades it over 420 ms while applying the new bar palette. The shell retains its background service and decoded wallpaper while it is hidden, so handing the still back does not recreate them. App retints wait until the opening crossfade completes, with a three-second bound, so they do not compete with its first frames. The compositor reload waits until playback finishes because reloading Hyprland pauses video presentation. A superseded intro skips that reload. Failed playback releases the pending palette, and a newer theme invalidates the older handoff. Refreshing a theme, headless setup, and cycling backgrounds within the same theme do not trigger intros. Appearance > Animations suppresses playback along with the other animations. The omarchy theme bg intro toggle command can toggle intros separately.

A theme installed from a git repo is held to a much shorter list; see What an installed theme may not ship.

Theme activation flow

omarchy-theme-set <name> builds a clean staging directory at
~/.local/state/omarchy/current/next-theme:

  1. Copy the first-party theme from themes/<name>/.
  2. Overlay ~/.config/omarchy/themes/<name>/, in full when the user wrote it and filtered when it came from a git repo, naming anything it dropped on stderr.
  3. If needed, generate colors.toml from alacritty.toml.
  4. Run omarchy-theme-set-templates to render templates into the staging
    theme.
  5. Move the staging theme into ~/.local/state/omarchy/current/theme, write
    ~/.local/state/omarchy/current/theme.name, and notify the running shell.

Template rendering only happens when the staged theme has colors.toml.
Existing files are never overwritten by a template, so a hand-written
themes/<name>/shell.toml or hyprland.lua wins over
default/themed/shell.toml.tpl or hyprland.lua.tpl.

User templates in ~/.config/omarchy/themed/*.tpl are processed before the
built-in templates. If a user template has the same output filename as a
built-in template, the built-in output is skipped.

After activation, omarchy-theme-set retints what is on screen first: the terminals and the programs inside them (visible_theme_commands), plus the Hyprland reload when no background intro is playing (an intro reloads after playback). Those run in parallel and the command waits for them. The rest of the apps — GTK, the browser policy, editors, agents, the keyboard backlight (other_theme_commands) — then retint in parallel, followed by the theme-set hook (~/.config/omarchy/hooks/theme-set*, theme name in $1). During an intro they wait for its reveal first.

A switch from the theme picker, the menu or a script does not wait for that second group: the command returns and the group finishes in the background, writing its output to ~/.local/state/omarchy/theme-set.log and naming any retint that failed in a notification. Run in a terminal, or with OMARCHY_THEME_WAIT=1, it waits for everything and reports failures on stderr, so a browser policy update can ask for a sudo password there. A systemd service or timer that sets the theme should set OMARCHY_THEME_WAIT=1 as well: when a oneshot unit finishes, systemd stops whatever is left in its cgroup, and the background group with it. A failed retint never changes the exit status. Batches from quick successive switches run one at a time, and a batch whose theme was replaced before it got its turn skips the retints (its hook still runs), so an older theme's slow write can't land after a newer one. Making a new app follow theme changes means adding its restart/retint command to one of the two lists in bin/omarchy-theme-set. Runs serialize on a flock, so scripted theme changes queue instead of racing.

Last, it starts omarchy-theme-set-herdr-machines detached. That command sets the same theme on every enabled herdr machine list target that runs Omarchy, over SSH inside the remote's live Hyprland session, and logs each machine's result to ~/.local/state/omarchy/theme-set-herdr-machines.log. Sync is off by default. The herdr-theme-sync toggle turns it on, and a machine only sends and accepts themes while it is on. A mirrored change carries OMARCHY_THEME_SYNC_FROM, so the receiving machine never sends it on.

What an installed theme may not ship

themes/<name>/ in this repo is Omarchy's own code and is trusted. So is a theme the user wrote by hand in ~/.config/omarchy/themes/<name>/: it is their machine and their file, and both stage in full.

omarchy theme install is different. It clones a stranger's git repo straight into that same directory, so the contents are whatever the theme author pushed. omarchy-theme-set tells the two apart the way omarchy-theme-extras already does — a .git directory means it was cloned, while a plain directory or a symlink to a working copy is the user's own — and from a cloned one it drops only what can run code:

  • any *.lua — Hyprland requires a theme's hyprland.lua and gum_env.lua at login, and Neovim loads its neovim.lua at startup
  • alacritty.toml, foot.ini, ghostty.conf, kitty.conf — each names the program the terminal launches
  • vscode.json — names the extension omarchy-theme-set-vscode installs, and a VS Code extension is arbitrary JavaScript

Symlinks are dropped with them, at any depth; in a cloned theme they point wherever the theme author chose. Everything a cloned theme ships that is colour is kept, including files Omarchy would otherwise have generated — btop.theme, chromium.theme, helix.toml, shell.toml, icons.theme, keyboard.rgb and the rest — so a theme can still say exactly how it wants each app to look. What is dropped gets generated from default/themed/*.tpl instead, and is named on stderr.

A denylist is only right while it is maintained. Adding a template for another terminal, or for another editor that loads Lua, means adding it to INSTALLED_THEME_DENIED in bin/omarchy-theme-set; test/shell.d/theme-staging-test.sh fails on any default/themed/*.tpl whose output is recorded as neither code nor colour, so a new template cannot be added without that decision being made.

A theme predating colors.toml is not left without a palette: its alacritty.toml is read through omarchy-theme-colors-from-alacritty into a scratch directory and only the resulting colors.toml is staged, so the colors survive and the terminal config does not.

The restriction lives in omarchy-theme-set rather than in omarchy-theme-install on purpose. Filtering at staging also covers themes installed before the rule existed and files a theme gains later through omarchy theme update.

What this does not cover: a theme distributed as an archive rather than a git repo, extracted into ~/.config/omarchy/themes/ by hand, is indistinguishable from one the user wrote and stages in full. omarchy theme install only takes git URLs, so the supported path is always filtered, but the check is a statement about where a theme came from and not a sandbox.

`colors.toml`

colors.toml provides the palette keys used by templates. Keys are grouped
semantic-first: accent/selection/muted, then the backgrounds, then the
foregrounds, then the named colors:

toml
mode = "dark"

accent = "#7aa2f7"
selection = "#292e42"
muted = "#414868"

background = "#1a1b26"
dark_background = "#13141c"
darker_background = "#0e0e14"
lighter_background = "#24283b"

foreground = "#a9b1d6"
dark_foreground = "#565f89"
light_foreground = "#b4bee6"
bright_foreground = "#c0caf5"

red = "#f7768e"
blue = "#7aa2f7"

Any key can be referenced from a template with {{ key }}. The foundational
shell palette is loaded from:

  • foreground — primary readable text color
  • background — primary background color
  • accent — preferred when present; otherwise some places fall back to
    color4
  • muted — de-emphasized elements (comments, placeholders, dividers); also
    serves as ANSI color8
  • red / color1 — populate the shell's urgent role; there is no urgent
    palette key (one defined in colors.toml is ignored)

Themes and user templates using the legacy short names remain supported.
Canonical names take precedence when both forms are defined, and resolved
canonical values are also exposed through their legacy names:

Canonical Legacy
background bg
dark_background dark_bg
darker_background darker_bg
lighter_background lighter_bg
foreground fg
dark_foreground dark_fg
light_foreground light_fg
bright_foreground bright_fg

The neutral ramp is centered on background -> bright_foreground. Dark themes
should read from darkest to lightest; light themes should read from lightest to
darkest. Terminal and editor cursors use bright_foreground; there is no
separate cursor palette key. selection is the text-selection background stop
in that ramp; Omarchy derives selection_background = selection and
selection_foreground = bright_foreground. Use
omarchy dev theme-preview [theme] to inspect that ramp, including
dark_background, darker_background, and a selected-text sample.

Template placeholders

Templates are plain files ending in .tpl. omarchy-theme-set-templates
replaces placeholders with values from colors.toml.

Color placeholders

For a color key such as accent = "#7aa2f7":

Placeholder Output
{{ accent }} #7aa2f7
{{ accent_strip }} 7aa2f7
{{ accent_rgb }} 122,162,247

Color mixing

mix, mix_strip, and mix_rgb blend two hex colors by a fraction or
percentage:

text
{{ mix background foreground 15% }}
{{ mix_strip background accent 0.35 }}
{{ mix_rgb color0 color7 50 }}

Gradient helpers

Some theme keys can be either a solid color or a Hyprland-style gradient:

toml
hyprland_active_border = "rgba(33ccffee) rgba(00ff99ee) 45deg"

Gradient helper placeholders understand those values:

Helper Use Example output
{{ hypr_gradient hyprland_active_border accent }} Hyprland Lua config { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, angle = 45 }
{{ shell_gradient hyprland_active_border accent }} shell border tokens rgba(33ccffee) rgba(00ff99ee) 45deg
{{ gradient_start hyprland_active_border accent }} flat-color-only consumers #33ccff

The second argument is a fallback. For example,
{{ shell_gradient hyprland_active_border accent }} means: use
hyprland_active_border if the theme defines it; otherwise use accent.
The helper does not choose the first color unless you use gradient_start.

`shell.toml`

shell.toml contains shell surface roles, control states, spacing, typography,
and bar sizing. The default generated file comes from
default/themed/shell.toml.tpl.

Themes can override the entire generated file by shipping shell.toml, or just
one section by shipping shell.<section>.toml. For example,
shell.lock.toml replaces only the [lock] section after the default
shell.toml has been generated:

toml
text        = "#ffffff"
placeholder = "#ffffff"
border      = "#ffffff"

The filename decides the target section, so the [lock] header is optional.

The running shell reads shell.toml into two QML singletons:

  • Color for palette and surface roles like Commons.Color.menu.border.
  • Style for controls, spacing, font scale, corner radius, and bar sizing.

Borders

Shell border tokens accept either a solid color or a gradient in the same key:

toml
[notifications]
border = "#7aa2f7"

or:

toml
[notifications]
border = "rgba(33ccffee) rgba(00ff99ee) 45deg"

Do not add a separate border-gradient key for new themes. The parser still
accepts border-gradient and *-border-gradient for compatibility with older
configs, but the canonical form is the border key itself.

Border alphas apply to solid borders and to every gradient stop:

toml
[notifications]
border       = "rgba(33ccffee) rgba(00ff99ee) 45deg"
border-alpha = 0.8

If a color stop already includes alpha, the stop alpha and border-alpha are
combined.

Border widths

Border widths accept CSS-style lists:

toml
border-width = 2          # all sides
border-width = "2 4"      # top/bottom, right/left
border-width = "2 4 6"    # top, right/left, bottom
border-width = "2 4 6 8"  # top, right, bottom, left

Per-side keys override the list:

toml
[notifications]
border-width = 2
border-width-left = 6

That gives notifications a 2px border on the top, right, and bottom, and a 6px
left edge.

State-specific borders follow the same pattern. A selected menu row can use a
different width from the card border:

toml
[menu]
selected-border = "accent"
selected-border-width = "1 1 1 4"

For state-specific surfaces such as lock and polkit, the token name prefixes
the width key:

toml
[lock]
border-active = "rgba(33ccffee) rgba(00ff99ee) 45deg"
border-active-width-left = 6

Control borders

[controls] governs shared controls such as buttons, dropdowns, text fields,
toggles, and cursor rows. Each state has a fill color, optional border value,
border width, and border alpha:

toml
[controls]
normal-color        = "#a9b1d6"
normal-border       = "#a9b1d6"
normal-border-width = 1
normal-border-alpha = 0.4

hover-cursor-color        = "#a9b1d6"
hover-cursor-border       = "#a9b1d6"
hover-cursor-border-width = 1
hover-cursor-border-alpha = 0.25

The *-border keys can also be gradients:

toml
[controls]
focus-border = "rgba(33ccffee) rgba(00ff99ee) 45deg"
focus-border-width = "2 2 2 4"

Set a border width to 0 to keep the fill but remove that state border.

Surface sections

Common shell sections include:

  • [bar]
  • [controls]
  • [popups]
  • [tooltip]
  • [notifications]
  • [launcher]
  • [menu]
  • [polkit]
  • [lock]
  • [image-picker]
  • [spacing]
  • [font]

Clipboard and emojis inherit menu tokens. Popups are used by bar flyouts,
dropdowns, OSD, and popup cards.

QML border API

Plugin and shell QML should use BorderSurface for theme-aware borders:

qml
import qs.Commons
import qs.Commons as Commons
import qs.Ui

BorderSurface {
  color: Commons.Color.popups.background
  borderSpec: Border.surfaceSpec("popups", "border", Commons.Color.popups.border, 2)
  padding: Style.spacing.popupPadding

  Item {
    anchors.fill: parent
    anchors.topMargin: parent.contentTopInset
    anchors.rightMargin: parent.contentRightInset
    anchors.bottomMargin: parent.contentBottomInset
    anchors.leftMargin: parent.contentLeftInset
  }
}

Use Border.surfaceSpec(section, token, fallbackColor, fallbackWidth, alphaKey)
for shell theme tokens (the optional alphaKey names the alpha token, e.g.
"border-alpha"), Border.controlSpec(state, foreground, accent, urgent) for
shared controls, and Border.flat(color, width) for a deliberate local border
that should not be overridden by the active theme. Commons.Color.<section>.border is the
flat first-stop color for consumers that cannot render full border specs.

Hyprland templates

Hyprland theme output is generated from default/themed/hyprland.lua.tpl.
Use hypr_gradient for border values because Hyprland's Lua config wants a
Lua string for solid colors and a Lua table for gradients:

lua
local active_border_color = {{ hypr_gradient hyprland_active_border accent }}

For a solid fallback this renders:

lua
local active_border_color = "#7aa2f7"

For a gradient it renders:

lua
local active_border_color = { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, angle = 45 }

Adding or overriding theme files

  • Add palette values to themes/<name>/colors.toml.
  • Hand-written overrides work everywhere except a .lua, a terminal config or a vscode.json in a theme cloned from a git repo; see What an installed theme may not ship.
  • Prefer generated files when the theme can be expressed with templates.
  • Add a hand-written file in themes/<name>/ only when that theme needs to
    override the generated output entirely.
  • Add a new built-in template under default/themed/<file>.tpl when every
    theme should generate that file.
  • Add a user-wide template under ~/.config/omarchy/themed/<file>.tpl when a
    local customization should apply across themes.

When changing templates or theme helpers, run focused tests such as:

bash
./test/cli
./test/shell

Update Process (`docs/update-process.md`)

Omarchy update process

This document describes the intended update behavior now that Omarchy is
package-backed. It covers the blessed update path plus what happens when a user attempts to
bypass it:

  1. omarchy update — the blessed interactive Omarchy update flow.
  2. sudo pacman -Syu — guarded by Omarchy and aborted with instructions unless
    the user explicitly bypasses the guard.

The design goal is:

  • omarchy update owns the visible update pipeline: package transaction,
    migrations, post-update hooks, update-state refresh, and restart checks.
  • Migrations run per-user after pacman finishes, because they may need $HOME,
    DBus/session state, a graphical session, sudo, or user interaction.
  • Users who bypass omarchy update are nudged back by the pacman guard; if they
    explicitly bypass it, their session is notified when migrations are pending.

State and coordination files

Path Owner Purpose
${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.lock user Prevent overlapping update runs. Owned by omarchy-update-lock; compatibility wrappers inherit/respect it.
${XDG_RUNTIME_DIR}/omarchy-update-stay-awake/ user Private mode-0700 inhibitor coordination state. If no runtime directory is available, the helper uses the validated mode-0700 /tmp/omarchy-$UID/ fallback.
/tmp/omarchy-update.log user Transcript of omarchy update, used by omarchy-update-analyze-logs.
~/.local/state/omarchy/current/ user Generated active theme, selected theme name, and current background symlink.
~/.local/state/omarchy/migrations/ user Per-user migration markers.
~/.local/state/omarchy/reboot-required user Optional reboot marker checked by omarchy-update-restart.
~/.local/state/omarchy/restart-*-required user Optional service/app restart markers checked by omarchy-update-restart. The shell needs no marker: it is restarted unconditionally after every update.

Migration layout

See migrations.md for the full migration model, authoring
guidelines, and troubleshooting notes.

Migrations live in:

text
migrations/*.sh

They run as the current user through:

bash
omarchy-migrate

Completion state is per-user:

text
~/.local/state/omarchy/migrations/<migration filename>

Every user gets a chance to run every migration. Migrations run as the user;
privileged work should invoke the appropriate helper or privilege prompt.
Migrations must be idempotent; if one user already applied a machine-wide repair,
the migration should no-op for other users.

When invoked by the update, migrations share its single sudo authorization. The standalone migration runner has its own security changes in the migration-boundary PR; this update change does not establish that standalone boundary. Historical migrations remain strictly ordered.

For watchers and diagnostics, omarchy-migrate --pending prints pending
migration names and exits 0 when any are pending. When no migrations are
pending, it prints nothing and exits non-zero.

Raw pacman guard

The omarchy package installs an ALPM pre-transaction hook alongside its guard
binary:

text
/usr/share/libalpm/hooks/00-omarchy-update-guard.hook
/usr/bin/omarchy-update-pacman-guard

It triggers on package upgrades and runs:

bash
omarchy-update-pacman-guard

The guard detects direct pacman system-upgrade commands like pacman -Syu or
pacman --sync --refresh --sysupgrade. If the upgrade was not launched by an
Omarchy update command, the hook exits non-zero with AbortOnFail, which stops
the transaction before packages are changed.

omarchy-update-system-pkgs, omarchy-refresh-pacman, omarchy-reinstall-pkgs,
and omarchy-channel-set run pacman through the hidden omarchy-update-pacman
helper (the v4 upgrader sets OMARCHY_UPDATE_PACMAN=1 directly):

bash
sudo env OMARCHY_UPDATE_PACMAN=1 systemd-run --scope --quiet --collect pacman ...

so the guard allows Omarchy-owned update flows. The systemd-run --scope
wrapper registers the transaction as a PID 1 scope: upgrading systemd reexecs
the system and user managers mid-transaction, and a pacman left inside a
user-session scope can be SIGKILLed by that reexec. On unbooted systems (such
as the installer chroot) the helper runs pacman directly. A user can intentionally bypass
the guard with:

bash
sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman -Syu

The guard does not start omarchy update itself because pacman is already in a
transaction setup path; it only aborts with instructions.

The omarchy package also installs ALPM hooks for omarchy-settings /
omarchy-settings-dev installs and upgrades. The pre-transaction hook runs
omarchy-hyprland-reload-guard pause to disable live Hyprland config reloads
while /usr/share/omarchy/default/hypr/** is replaced. The post-transaction
hook runs omarchy-hyprland-reload-guard resume, forces one hyprctl reload,
and restores the session's previous misc.disable_autoreload and
debug.suppress_errors values.

Path 1: `omarchy update`

High-level flow:

text
/* Detailed source-code truncated for AI context efficiency. */

Important behavior:

  • Protected update entrypoints require the session's canonical OMARCHY_PATH to match their own checkout or the packaged /usr/bin entrypoint before selecting commands or the sudo wrapper. This preserves intentionally trusted development checkouts while rejecting a command paired with a different source root. System phases use a fixed command search path; user PATH is restored for hooks and mise.
  • Mixed-trust update entrypoints start Bash in privileged mode, discard BASH_ENV, ENV, and exported-function records before launching helpers, and reject an ordinary bash path/to/command invocation. Run them as executables (normally through the omarchy CLI); /usr/bin/bash -p path/to/command is the explicit interpreter form. This keeps shell startup injection from replacing the no-update sudo boundary.
  • In dev-link mode, omarchy update fast-forwards the active checkout from its configured upstream before changing system packages or running migrations.
  • The update asks for the sudo password once, right after confirmation. It first invalidates any existing timestamp so that prompt always belongs to this update, then a background keepalive refreshes the timestamp every minute so long downloads, migrations, hooks, and mise never outlast it. Everything except AUR shares that one authorization: package prune, snapshot, stay-awake, keyring, system packages, migrations, orphan removal, service restarts, the post-update hook, and mise. Stay-awake sees OMARCHY_UPDATE_SUDO_SESSION=1, uses that authorization non-interactively whatever its stdin is, and leaves it to the update instead of revoking it. The authorization runs a command rather than sudo -v, so passwordless sudo still needs no prompt. Standalone commands that keep their own cold boundary, such as omarchy-refresh-pacman, still revoke if a post-update hook calls them.
  • AUR builds run third-party PKGBUILD code, so they run last and never see the update's authorization. The update stops the keepalive, invalidates the timestamp, and runs yay with the no-update wrapper as its sudo command and its credential loop disabled; an AUR install prompts per command without publishing a reusable timestamp. The timestamp is invalidated again afterwards and on every exit.
  • This lifecycle controls authorization created by the protected workflow. sudo -N prevents cache updates but can use an existing valid credential, and sudo -k revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow.
  • Sleep inhibition authenticates before detaching, drops the held command back to the caller, and closes both update lock descriptors before the persistent process starts. Cleanup accepts only caller-owned, mode-0600, single-link state and revalidates the recorded PID, process start time, owner, and random token immediately before every signal.
  • Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and checks after each package transaction that the wrapper still exists before any further privileged step, since a transaction can replace the running tree with a release that predates it; when it is gone, or the destination otherwise lacks it, the switch stops after the package switch with instructions to run that release's update from a fresh session rather than letting a bare sudo or an updater that authenticates without --no-update publish a timestamp. Failed and interrupted channel switches revoke on exit.
  • -y exports OMARCHY_UPDATE_UNATTENDED=1 and suppresses Omarchy confirmation prompts. Conflict handoff reports and skips instead of blocking. Orphan packages are automatically removed during every update. Privileged commands still require the one sudo authorization, and AUR installs can prompt separately.
  • The free-space requirement uses a 10 GiB threshold and stops the update before
    confirmation when it is not met. If free space cannot be determined, the
    check is silently skipped. Set OMARCHY_UPDATE_FORCE=1 to bypass the check.
  • omarchy update checks/runs migrations in the same visible terminal via
    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 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

High-level flow:

text
sudo pacman -Syu
  ├─ pre-transaction guard aborts and tells the user to run omarchy update
  └─ if explicitly bypassed, upgrades omarchy and related packages
  └─ at that user's next login
       ├─ graphical-session.target starts
       ├─ omarchy-migrate-notify.service starts after it
       ├─ omarchy-migrate-notify checks omarchy-migrate --pending
       ├─ if this user has missing migration state, show notification
       └─ click opens terminal: omarchy-migrate

Login is deliberately the only trigger. A watcher on the packaged migration
directory cannot distinguish a bypassed pacman -Syu from the package
transaction inside a normal omarchy update, so it fired notifications for
migrations that omarchy-migrate was about to apply in the visible update
terminal. The retired unit was omarchy-update-user-notify.path.

Retiring that watcher through a migration cannot come in time for the update
that retires it: pacman writes the migration directory, the watcher fires, and
only then does omarchy-migrate reach the migration that stops it. So the
notifier also refuses to run while omarchy update holds its
$XDG_RUNTIME_DIR/omarchy-update.lock, which covers the stale watcher and any
trigger added later — during an update, every pending migration is by
definition already being applied a step away. It checks again after waiting for
the notification server, since that wait is long enough for an update to start
underneath it.

The notifier reads only its own user's runtime directory, never the /tmp path
omarchy-update falls back to when XDG_RUNTIME_DIR is unset. A shared lock
file belongs to whoever created it first, so honouring it would let one user
silence another user's notification. Missing an update and showing a redundant
toast is the better failure.

Suppression is why omarchy-update-stay-awake starts its sleep inhibitor with
the lock descriptor closed. That inhibitor outlives the step that starts it, so
an update killed before cleanup would otherwise leave it holding the flock
indefinitely — blocking later updates and, now that the notifier reads the same
lock, silencing migration notifications at every login.

Fallbacks:

  • omarchy-provision-first-run enables omarchy-migrate-notify.service, which also
    covers users created after install: their per-user migration markers are
    missing, so their first login prompts them to run every shipped migration.
  • The package ships omarchy-update-user-notify.service as a symlink onto
    omarchy-migrate-notify.service. Users set up before the rename hold an
    absolute graphical-session.target.wants symlink to the old path, and the
    migration that repoints it only runs for users who run an update — the
    opposite of who the notifier is for. The alias can be dropped once installs
    have run migration 1785095882.
  • The notifier is ordered after graphical-session.target, so an action that
    launches through uwsm-app cannot block the target that gates UWSM's app
    daemon.
  • The notifier waits for a live notification server before sending, because
    graphical-session.target can be reached before the shell claims
    org.freedesktop.Notifications.
  • The notifier is only a prompt. It does not run migrations in the background.
  • A session that is already open when another user updates is not re-checked;
    it picks the migrations up at its next login, or whenever that user runs
    omarchy-migrate or omarchy update.
  • Direct pacman updates do not run omarchy-hook post-update unless the user
    explicitly runs that hook; without a package-update marker, the only pending
    state we can derive is missing per-user migration markers.

Shell update indicator

The bar widget omarchy.system-update runs:

bash
omarchy-update-available

omarchy-update-available checks the active Omarchy sources for updates:

  • new upstream commits for the active dev-linked checkout
  • omarchy-dev, when installed
  • otherwise omarchy, when installed

The dev check fetches the checkout's configured upstream before comparing it
with HEAD. A failed fetch is quiet and falls back to the existing remote-
tracking state.

Exit codes:

  • 0 — Omarchy updates are available; stdout is the update list.
  • non-zero — no Omarchy updates are available; stdout says Omarchy is up to date.

The widget runs this check on shell startup and every six hours. Clicking the
update icon launches omarchy-update in a floating terminal.

Channels and versions

Updates install whatever the active channel points at. omarchy-channel-set <stable|rc|edge|dev> switches channels: the three package channels select
which pacman repo the mirrorlist points at (and swap between the omarchy and
omarchy-dev packages through a guard-allowed pacman run), while dev links
the runtime to a git checkout via the dev-link mechanism, after which
omarchy update fast-forwards that checkout instead of upgrading a package.
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
pacman.conf to answer which channel is active.

This inventory is intentionally opinionated. Some commands are useful as stable
leaf commands; others exist mostly because the old update flow accreted small
scripts.

Binary Current purpose Keep? / Question
omarchy-update Public user command. Adds transcript logging, confirmation, snapshot, and restart checks around the locked, sleep-inhibited update pipeline. Keep. This is the blessed entry point and orchestrates the update pipeline.
omarchy-update-lock Hidden command wrapper that holds the per-user update lock while its child runs. Keep internal/hidden. Isolates update concurrency and lock descriptor handling.
omarchy-update-stay-awake Hidden helper that starts or stops update-owned sleep and idle inhibition, restoring only the state it changed. Keep internal/hidden. Keeps inhibitor ownership and cleanup together.
omarchy-update-status Hidden compatibility helper that clears the shell update indicator. omarchy-update clears it directly now; an update started by an older omarchy-update still calls this after its upgrade. Temporary. Keep only for updates started before the change.
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 (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.
omarchy-update-requires-free-space Aborts the update below a 10 GiB free-space threshold on /; silently skipped when free space cannot be determined; OMARCHY_UPDATE_FORCE=1 bypasses. Keep internal/hidden.
omarchy-migrate Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports --pending. Keep. This replaces the discarded omarchy-update-user-finalize name and no longer needs --force.
omarchy-update-pacman-guard ALPM pre-transaction guard that aborts direct pacman -Syu style upgrades unless Omarchy set OMARCHY_UPDATE_PACMAN=1 or the user explicitly set OMARCHY_ALLOW_DIRECT_PACMAN=1. Keep internal/hidden. This is what nudges users back to omarchy update.
omarchy-update-pacman Hidden helper that runs a guard-approved pacman transaction as a PID 1 scope (systemd-run --scope) so a mid-transaction systemd reexec cannot kill it; runs pacman directly when not booted under systemd. Keep internal/hidden. Single place that owns how Omarchy invokes pacman for system mutation.
omarchy-migrate-notify Internal login-time notification helper. Uses omarchy-migrate --pending and shows a notification only when this user has pending migrations. Keep internal/hidden. Clear name now that the public command is omarchy-migrate.
omarchy-update-user-notify Hidden compatibility wrapper for omarchy-migrate-notify. Temporary. Keep only for old callers.
omarchy-update-available Update checker for the shell widget. The update itself clears the widget instead of rechecking. Keep. Could eventually be renamed omarchy-update-check, but current name matches widget semantics.
omarchy-update-aur-pkgs Updates AUR packages with yay -Sua if foreign packages exist and AUR is reachable. Question. Omarchy is package-backed now, but users may still install AUR packages. Keep for now.
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.

Closed decisions

  1. Migrations run per-user from the update pipeline

    • omarchy update runs omarchy-migrate after pacman finishes.
    • Package-time migration runners do not apply migrations inside pacman.
    • Every user has per-user migration markers, and migrations must be
      idempotent when they repair machine-wide state.
  2. Migration notification naming

    • The real helper is omarchy-migrate-notify, started by
      omarchy-migrate-notify.service.
    • omarchy-update-user-notify remains only as a hidden compatibility wrapper.
  3. Update pipeline ownership

    • omarchy-update owns the full update pipeline now.
  4. Mise remains in the blessed update path

    • omarchy-update-mise intentionally runs as part of omarchy update.
  5. Orphan cleanup stays in the update path for now

    • It is prompt-only and never removes packages noninteractively.
  6. Direct pacman user follow-up is based on actual migration state

    • Direct sudo pacman -Syu no longer uses a fake user-update marker.
    • User notifications are shown only when omarchy-migrate --pending finds
      missing per-user migration state.

Remaining concerns

  1. Pacman guard scope

    • The guard detects direct pacman sysupgrade invocations and allows Omarchy
      commands that set OMARCHY_UPDATE_PACMAN=1.
    • We may regret blocking some legitimate package-manager frontends or
      maintenance flows. Keep an eye on what should be allowed versus redirected
      to omarchy update.
  2. Pacnew/pacsave handling is still missing

    • Package-backed Omarchy should warn about or help process .pacnew and
      .pacsave files after updates.

--- METRICS ---