Files
omarchycn/shell
1e7bb66556 Recover a session lock stranded by a dead shell (#6692)
* Detect a compositor session lock through one helper

omarchy-restart-shell decided whether the session was locked by looking for
"LOCK" anywhere in the hyprctl monitors payload. That works, but not for the
reason the code reads like: Hyprland reports no lock state of its own, and the
string comes from solitaryBlockedBy, the list of reasons a monitor cannot hand
a client the whole screen. An active ext-session-lock is one of those reasons.

A substring match over the whole payload also answers yes to a workspace or a
monitor description that merely spells LOCK, and locking a desktop nobody asked
to lock is the worst way to be wrong. Match the reason list itself, and put it
behind a helper now that a second caller needs the same answer.

That second caller needs a third answer too, because the reason list is not
always readable. Hyprland stops at the first reason on a monitor with no
workspace yet — one just coming back — and returns before it ever looks at the
lock, so a missing LOCK there means nothing was asked rather than nothing was
found. Neither that nor an unreachable compositor is an unlocked session, and
locks strand precisely while outputs are coming and going, so both exit 2.
Callers that only branch on success are unaffected.

The test fixture claimed the string came from a workspace name, so it was
encoding the wrong model of the compositor. It now returns what Hyprland
actually returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Retake a session lock stranded by a dead shell

ext-session-lock keeps the session locked when its client goes away — that is
the point of the protocol, so a crashing lock screen cannot expose the desktop.
The cost is that a shell which dies while locked leaves the compositor locked
with nothing left to authenticate against: Hyprland's failsafe, which takes a
TTY or another machine to clear.

Nothing carried the lock across a restart. Quickshell relaunches itself after a
crash and omarchy-restart-shell can be run by hand, but both bring back a shell
holding no lock, so the failsafe stayed up. A fresh shell never holds a lock, so
a session already locked as the lock service starts can only be that orphan:
take it back and let the user type their way out.

Asking once is not enough. These deaths happen while outputs are going away,
and the replacement shell comes up inside that same window, where there is
nothing to read a lock off. So the question is asked until the answer means
something: on a short timer while the session settles, and again when a screen
comes back, since a display asleep for hours outlasts any timer worth running
and returns through a state the compositor cannot answer for either. Once an
answer does arrive the search ends, so the timer stops and later screen changes
cost nothing.

Three ways this could lock a desktop nobody asked to lock, all closed. A lock
this shell took itself is not an orphan, including one taken while the question
was in flight — omarchy-restart-shell re-locks a fresh shell, and the answer
cannot tell whose lock it found. Recovery runs once and clears the flag, so
nothing lingers to fire after an unlock. And PAM landing late reopens the
question rather than answering it: clearing the failsafe from a TTY is the
documented way out, so a yes from before there was anything to do about it may
be stale by the time it can be acted on.

The check has to live here rather than in the launcher. Quickshell's crash
handler re-execs in place, keeping the same pid, so a supervising process never
sees the restarts that recovery matters most for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Relaunch the shell when it dies without a signal

Quickshell restarts itself after a crash, but only from its signal handlers:
SIGSEGV, SIGABRT, SIGFPE, SIGILL, SIGBUS, SIGTRAP. Qt does not always leave
that way. When the Wayland connection fails, QWaylandDisplay::checkWaylandError
calls _exit() directly, which raises no signal at all — so the crash handler
never runs, no report lands in ~/.cache/quickshell/crashes, and the desktop is
left with no bar and no explanation.

That is how #6684 ends: the lock path meets a screen with no valid Wayland
output, declines to create a lock surface for it, and the connection dies with
EINVAL. Supervise the launcher so those deaths come back.

A clean exit is deliberate — omarchy-restart-shell stops the shell over IPC and
starts its own replacement — and a signal to the supervisor means the session is
going away, so neither relaunches. Neither does a shell that outlived its
compositor, though that takes more than one unanswered query to conclude: the
shell dies while outputs are being reconfigured, which is also when a busy
compositor can miss one without being gone. A shell that cannot stay up gives
up after five tries in a minute rather than spinning.

Signals need care now that a launcher stands between the session and the shell.
Bash defers a trap until a foreground command returns, so the shell runs as a
job and the supervisor waits on it. Stopping the launcher used to stop the shell
with it, back when this script exec'd Quickshell, so the signal is passed on
rather than leaving a desktop nobody is watching. One arriving during the
backoff sleep only reaches the trap afterwards, so the flag is read again at the
top of the loop: a shutdown racing a crash would otherwise get one more
Quickshell on its way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:29:35 +02:00
..
2026-08-09 12:32:53 +02:00

Omarchy shell

omarchy-shell is a single long-running Quickshell instance that hosts the Omarchy desktop. Hyprland autostart launches one shell per graphical session; everything else — the bar, background switcher, panels, and overlays — runs inside the shell as a plugin.

Hosting everything inside one shell means:

  • shared services and singletons live once, not once per process
  • summoning a panel is an IPC call into a process that is already running, not a fresh quickshell -p ... cold start
  • third-party plugins can be loaded from disk without changing any source code in Omarchy itself

The runtime layout:

shell/
  shell.qml              entry point (ShellRoot)
  services/
    PluginRegistry.qml   discovers, validates plugins, looks up enabled state in shell.json
    BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p)
  plugins/
    bar/                 first-party plugins (see plugins/README.md)
    image-picker/
    menu/
    notifications/
    panels/
      audio/
      bluetooth/
      monitor/
      network/
      power/
      weather/
    agents/
    services/
      battery/
      idle/
    osd/
    polkit/

The plugin discovery path is documented in plugins/README.md.

Plugin manifest

Every plugin ships a manifest.json describing what it is and how the shell should load it. Minimal example:

{
  "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" }
    ]
  }
}

Supported kinds:

Kind What it is
bar-widget A component that the active bar can drop into a section
panel A persistent or summoned floating window (e.g. OSD)
overlay A fullscreen overlay (e.g. background switcher)
menu A summoned menu surface
service A headless singleton, no UI
bar A full bar option that can replace the built-in omarchy.bar

Only one bar plugin is active at a time. Missing or invalid selections fall back to the built-in omarchy.bar, so users always have a safe path home. Panels, overlays, and menus are loaded when summoned. Plugins that need to outlive a single summon can set keepLoaded: true (e.g. the image picker keeps its overlay window mounted between summons). First-party services are loaded at startup.

The full schema lives in 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/<id>/ (named by the manifest id); updating is a fast-forward pull of that checkout.

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

⚠️ Plugins run as unsandboxed code inside omarchy-shell. Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. Only add repos whose code you are willing to run.

Each command is interactive when run bare in a terminal (gum pickers, confirmation, a diff to review) and fully non-interactive when given arguments. Pass --yes to skip every prompt — this is the path for scripts and AI agents:

omarchy plugin add https://github.com/acme/omarchy-weather.git --enable --yes
omarchy plugin update --yes

The installer never runs plugin code, install hooks, or sudo — it only clones files, validates the manifest, and toggles enabled state over shell IPC. Since an installed plugin is a plain git checkout, anything beyond add/update (pinning a ref, switching branches) is ordinary git in the plugin directory.

Installing by hand

You can still drop a plugin in without git:

  1. Put it in ~/.config/omarchy/plugins/<plugin-id>/ with a manifest.json plus the QML referenced from its entryPoints.
  2. omarchy-shell shell rescanPlugins.
  3. omarchy plugin enable <id>. Bar widgets start in barWidget.defaultSection, or in the center when it is omitted, and can be moved with omarchy bar move; a full bar replaces the one in use.

The lower-level IPC equivalents remain available via omarchy-shell shell rescanPlugins, omarchy-shell shell enablePlugin <id> '{}', and omarchy-shell shell listPlugins. The omarchy plugin commands wrap those calls. omarchy bar move and omarchy bar set edit the persisted widget layout in shell.json.

To hack on a built-in plugin safely, clone it into user config instead of editing the built-in source. The complete plugin directory is copied, including every declared kind and local dependency. A built-in id such as omarchy.clock becomes <username>.clock (e.g. dhh.clock), with My Clock as its display name. The username prefix keeps shared clones from colliding with each other or with other plugin authors.

omarchy plugin clone omarchy.clock

Cloning switches from the built-in to the new personal plugin, preserving an existing bar widget's position and settings. Setup > Plugins > Clone provides the interactive picker, then opens the new <username>.* directory in $EDITOR. Existing shortcuts and shell IPC calls made to the built-in id are routed to the enabled clone, so cloning does not require changing its callers. Removing an active clone switches back to its built-in source. Saving a file anywhere under ~/.config/omarchy/plugins/ reloads plugin code automatically; omarchy-shell shell rescanPlugins remains available to force a reload.

First-party plugins under shell/plugins/ are discovered the same way and load by default. Disabling a non-widget records it in disabledPlugins[]; disabling a widget removes it from the bar layout while leaving its component available to add again. A full bar has no off state and is replaced by enabling another.

IPC contract

The shell exposes a single shell IPC target plus whatever extra targets individual plugins register (e.g. the bar's bar target for refresh hooks, the image picker's image-selector target). omarchy-menu uses the shell target to summon the first-party omarchy.menu plugin instead of running a separate Quickshell instance.

Method Returns Effect
ping ok health check
summon <id> <payloadJson> ok / unknown load + open a panel/overlay plugin
hide <id> close a previously-summoned plugin
toggle <id> <payloadJson> summon if closed, hide if open
call <id> <method> <arg> string call a method on an already-loaded plugin
rescanPlugins re-walk plugin dirs and hot-reload plugin code
reloadConfig ok reload ~/.config/omarchy/shell.json
setPluginEnabled <id> <enabled> ok / unknown flip the persisted enabled bit (see note)
listPlugins JSON every discovered plugin, sorted by name

Direct invocation:

quickshell ipc -p $OMARCHY_PATH/shell call shell ping

Hyprland autostart launches the shell directly with quickshell -p $OMARCHY_PATH/shell. Use omarchy-restart-shell to stop every running instance of that config and launch one fresh shell process.

A convenience wrapper, omarchy-shell, forwards IPC calls to the running shell. It does not start the shell.

omarchy-shell shell ping
omarchy-shell shell toggle omarchy.menu '{"menu":"root"}'
omarchy-shell shell listPlugins
omarchy-shell shell rescanPlugins

Note on setPluginEnabled: the enabled argument is a string. Only the literal "true" enables the plugin; every other value (including "True", "1", "yes", or omitted) disables it. This keeps the IPC surface type-stable across QML's string-only IPC arguments.

Persisted state

There is one user config file. Everything that distinguishes your customization from the shipped defaults lives in it.

Path Owner Purpose
~/.config/omarchy/shell.json the shell full layout + per-entry settings + enabled plugin list
~/.config/omarchy/plugins/<id>/ user drop-in third-party plugin source files

The config/omarchy/shell.json default config describes the fresh-install state. When the user has no shell.json, the shell uses the defaults verbatim. Once the user customizes anything, shell.json becomes the authoritative file — we do not deep-merge defaults back in.

shell.json shape

{
  "version": 1,
  "idle": {
    "screensaver": 150,
    "lock": 300
  },
  "bar": {
    "id": "omarchy.bar",
    "position": "top",
    "transparent": false,
    "centerAnchor": "omarchy.clock",
    "layout": {
      "left":   [ { "id": "omarchy.menu" }, { "id": "omarchy.workspaces" } ],
      "center": [ { "id": "omarchy.clock", "format": "HH:mm" } ],
      "right": [
        { "id": "omarchy.audio" }
      ]
    }
  },
  "plugins": []
}

Storage rules

  1. The active bar option is bar.id. Omit it or set it to omarchy.bar to use the built-in bar. Set it to another plugin id whose manifest declares kind: "bar" to replace the full bar.
  2. Every plugin instance is one entry. Either in bar.layout.<section> for bar widgets, or in plugins[] for panels, overlays, services, menus, and anything else non-bar.
  3. Settings are inline on the entry. No config: sub-object, no separate per-plugin settings file, no merge layers. The fields on each entry are the values the plugin sees.
  4. Built-in widget ids are namespaced. Use ids such as omarchy.clock, omarchy.audio, and omarchy.network. The migration rewrites older ids like Clock and AudioPanel forward.
  5. Third-party enabled ⇔ present. A third-party plugin is enabled iff its id appears somewhere in shell.json. For full bar options, that means bar.id; for bar widgets, plugin enable/disable adds/removes layout entries; other plugin kinds are enabled the same way. First-party non-bar plugins are enabled unless listed in disabledPlugins[].
  6. Multiple instances are allowed when a manifest sets allowMultiple: true. Each instance is independent — e.g. two clock widgets in different timezones are just two {"id":"omarchy.clock", "timezone": ...} entries with their own values.
  7. Idle timings are top-level. idle.screensaver and idle.lock are seconds since user idle began, so the default lock fires at 300s even if the 150s screensaver starts first.
  8. version: 1 is required at the top level. The shell will fall back to defaults rather than load an unknown version.

Implementation history

Built up in phases on this branch:

  • Phase 1 — omarchy-shell phase 1: host the existing bar in a single shell
  • Phase 2 — omarchy-shell phase 2: plugin registry and bar widget registry
  • Phase 3 — omarchy-shell phase 3: fold bar-settings into the shell as a panel plugin
  • Phase 4 — omarchy-shell phase 4: absorb background-switcher as a plugin
  • Phase 5 — omarchy-shell phase 5: docs, cleanup, and migration crumbs
  • Phase 6 — omarchy-shell phase 6: reviewer cleanup (path traversal, collision, races)
  • Phase 7 — omarchy-shell phase 7: replace socket with IpcHandler, rename to image-picker
  • Phase 8a — omarchy-shell phase 8a: unified shell.json with inline plugin settings

Shared services and Pipewire/UPower/Hyprland consolidation are explicitly out of scope here and deferred to a follow-up after a review pass.