Omahub
← All plugins
S

Live Wallpaper

by Sumiran Dahal

Desktop background renderer with a per-theme live treatment: parallax, web glints, motes and a comic-print shader pass.

Security review

Review recommended · 1 finding

Deterministic scan — not a security guarantee

Low
Risk level
Low
Analyzed commit
1338f6a
Scanned
1 month ago

Flagged patterns appear only in documentation files (README / docs) — descriptive examples, not executable code.

  • Docs sudo README.md:34

    Command runs with sudo, elevating the process beyond the plugin environment.

    sudo or pkexec is required. No package manager is invoked.

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

Language-model assessment · ~deepseek/deepseek-v4-flash-latest — advisory only

None
AI risk level
None
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
1338f6a
Reviewed
1 month ago

The deterministic scan's only finding is a false positive from the README: the line actually says 'No sudo or pkexec is required', and no executable path elevates privileges. The plugin's QML and shell scripts are transparent, bounded against malicious spec values, and make only disclosed user-level changes (a PATH CLI and menu rows). Its update check detects signed tags but never runs remote code or installs updates without an explicit user-approved `omarchy plugin update` in a visible terminal.

How this check works

This review combines the deterministic scan (the rule-based results above) with an independent look at the plugin's code by a language model. The model reads a trimmed sample of the repository's files, the manifest, and the README, then gives a plain-language risk level and a recommendation: install (no notable danger), review (look closer first), or avoid (clearly dangerous).

It runs on the same analyzed commit as the deterministic scan and is strictly advisory — it is not a security guarantee and never blocks a plugin by itself. A human moderator still reviews plugins before they are listed.

AI advisory only — automated analysis, not a security guarantee.

Install
$ omarchy plugin add https://github.com/sumdahl/omarchy-plugin-livewallpaper --enable
Appearance #bar #quickshell #power-management

Live Wallpaper

A drop-in replacement for Omarchy's omarchy.background service that adds a per-theme live treatment to the desktop.

The lock screen picks up the same treatment behind its blur if you also install the companion io.github.sumdahl.lock plugin. That part is optional — without it the desktop treatment works exactly the same and the lock screen stays stock.

Every theme is live by default. Install the plugin and all of them animate, each in its own palette — a theme has to ship nothing at all. Themes that want a specific look declare it; users who want something else override it; anyone who wants a static wallpaper opts out.

Requirements

Everything here ships with Omarchy Quattro — the list is for auditability, not because anything needs installing.

Qt/Quickshell modules (from quickshell and qt6-*, already required by the Omarchy shell): Quickshell, Quickshell.Io, Quickshell.Hyprland, Quickshell.Wayland, Quickshell.Services.UPower (battery state), QtQuick.Particles (motes), QtQuick.Effects, QtQuick.Shapes, and QtMultimedia — used only when a theme's spec names a video.

Commands: bash, git (update checking only), jq, and the coreutils head, timeout, install, readlink, grep, sed, awk.

Omarchy commands: omarchy-shell, omarchy-notification-send, omarchy-launch-floating-terminal-with-presentation, and — only when you accept an update — omarchy plugin update.

No sudo or pkexec is required. No package manager is invoked.

Install

omarchy plugin add https://github.com/sumdahl/omarchy-plugin-livewallpaper.git --enable

That is the whole thing. The plugin finishes its own setup the first time it mounts: it puts omarchy-live on your PATH and merges the Style > Live Wallpaper rows into ~/.config/omarchy/extensions/omarchy-menu.jsonc, then tells you once that it did. Both steps are necessary because omarchy plugin add does not run a plugin's install.sh, and Omarchy's menu reads exactly one user file — a plugin cannot contribute rows to it from its own directory. Skipping them is what made this plugin look broken on a machine other than the one it was written on.

The merge is textual and additive: it creates the file if missing, never touches keys it does not own, preserves the comments in a JSONC file, and is a no-op on a second run. omarchy-live menu-uninstall removes only its own rows. The setup also refreshes ~/.local/bin/omarchy-live whenever it differs from the plugin's copy, so omarchy plugin update cannot leave a stale command talking to a newer service.

Cloning by hand instead? ./install.sh does the same work up front, and omarchy restart shell mounts it.

Nothing about the menu is required — the bar icon works on its own.

Removal

omarchy-live menu-uninstall       # takes back only its own menu rows
omarchy plugin disable io.github.sumdahl.livewallpaper
rm -rf ~/.config/omarchy/plugins/io.github.sumdahl.livewallpaper
rm -f  ~/.local/bin/omarchy-live
rm -rf ~/.config/omarchy/live ~/.config/omarchy/live.json   # your overrides
omarchy restart shell

Disabling restores Omarchy's built-in omarchy.background, so the desktop goes back to a stock static wallpaper. Themes keep their live/live.json — it is inert without this plugin and costs nothing to leave in place. Remove the bar icon with omarchy bar remove io.github.sumdahl.livewallpaper if it lingers.

Bar widget

The control panel

An icon on the bar, active while the treatment is actually rendering:

  • click — panel with the controls below
  • middle click — pause or resume, no panel
  • tooltip — current theme and whether the scene is running, parked behind a fullscreen window, or reduced on battery

The panel adjusts loop length, zoom, halftone, grain, vignette, bloom, motes and glints; toggles the treatment and the battery notice; and resets to the theme default by dropping your override. Sliders commit on release, so a drag is one write rather than sixty. Values are read from the resolved spec the service publishes, so the panel follows changes made from the menu, from a terminal, or by switching theme.

Spec layers

Four sources, merged shallow per section — a layer naming motion owns that whole section, and sections it omits fall through. Later wins:

# Source Purpose
1 built-in defaults Baseline: parallax and motes, no halftone, colours auto
2 ~/.config/omarchy/live.json Your preference for every theme
3 <theme>/live/live.json What the theme's author intended
4 ~/.config/omarchy/live/<theme>.json Your override for one theme

"auto" colours resolve against the live palette, so ink always matches the desktop. Halftone is off in the baseline on purpose: the ben-day comic pass is a Spider-Verse conceit and looks wrong on a photographic or minimal theme, so only a theme that asks for it gets it.

A malformed layer is warned about and skipped — the layer below shows instead of a black wallpaper.

Opting out: "enabled": false at the top level of any layer. In layer 2 it disables the plugin everywhere, in layer 3 a theme author declares "this theme should not animate", and in layer 4 you overrule either:

omarchy-live toggle           # persistent, this theme — what the menu row runs
omarchy-live disable nord     # persistent, naming a theme
omarchy-live off              # this session only, every theme

toggle and on/off are deliberately different switches. The menu tick is a setting, so it writes the persistent one and survives a reboot; on/off are the transient escape hatch. omarchy-live is-on reports the two combined — the same value the renderer gates on — so the tick and the screen cannot disagree.

Where edits land

tune, preset and edit always write to layer 4, ~/.config/omarchy/live/<theme>.json — never into the theme itself. So tuning works identically on a stock theme under /usr/share/omarchy/, on a theme cloned from git, and on one you wrote yourself, and none of them are modified by it. omarchy-live path prints the file that would be used.

That also makes reset meaningful: omarchy-live reset drops your override and the theme's own look returns.

Editing a theme's own spec is an authoring act, and needs saying so:

omarchy-live edit theme      # opens <theme>/live/live.json itself

An earlier version guessed at this — it wrote into a theme's own spec whenever one existed and was writable. That quietly dirtied git checkouts on every slider drag, and left reset with nothing to reset to, since the "default" was the file being edited.

omarchy-live install <theme> materialises an editable spec inside a theme directory. For a stock theme it creates an overlay — a user theme directory holding nothing but live/. omarchy-theme-set copies the stock theme into staging first and lays the user one over the top, so the overlay adds a spec without forking wallpapers or colours.

What it renders

Composited in this order, then put through one shader pass so the whole frame reads as a single printed cel rather than layered sprites:

Layer What it does
Parallax Slow zoom and elliptical drift over the wallpaper. Every term is an integer harmonic of one 0..2π phase, so the loop closes with matching position and velocity — no seam at the wrap.
Web glints Thin angled light streaks sweeping across, at randomised intervals. Reads as light travelling along a strand.
Motes GPU particles drifting down — dust, snow, city light.
Comic print Fragment shader: rotated ben-day dot screens in the theme's two ink colours, chromatic aberration on the spider-sense pulse, an accent rim, highlight bloom, animated grain, vignette.

The dot screens are weighted to the midtones, not to everything dark, so a large flat night sky stays clean instead of turning into a screen door.

Updates

The plugin checks for a newer release every six hours and shows a dot on its bar icon when one exists. Clicking opens a terminal running Omarchy's own omarchy plugin update, which prints the full diff and waits for you to agree.

It detects; it never installs. The check transfers git refs and a tag object and nothing else — no code from the remote runs during a check, and nothing is written to the checkout. Only a release that was deliberately tagged and signed is ever offered, and the signing key must match the one your installation trusted the first time it verified a release.

omarchy-live check-updates   # look now
omarchy-live update          # review a release and apply it

Nothing here installs anything without showing you what changed first. The reasoning, the threat model, and what this explicitly does not protect against are in SECURITY.md.

Power behaviour

The scene parks itself — animations stopped, shader layer and its offscreen buffer dropped entirely — when:

  • a fullscreen window covers the wallpaper (resumes within ~1s of leaving it)
  • the screen is locked — detected from whichever lock plugin is mounted, so this works on its own; the companion plugin also pushes the signal over IPC when installed
  • the treatment is switched off

Measured cost of the full treatment on Intel Iris Xe at 1920x1080: +3.6 percentage points of one core over the stock static background.

On battery the scene parks itself, and comes back on its own when you plug in. power.onBattery chooses what unplugging does:

value on battery
"off" (default) scene parks completely
"low" drops to parallax only — no glints, motes or shader pass
"ignore" full effect, as if plugged in

This is a derived state, not a switch that gets flipped: nothing is stored, so reconnecting the mains restores exactly what you had with no click and nothing to get stuck in. power.batterySaver: true from an older spec is still read as "low", and false as "ignore".

Battery state comes from UPower, the same source stock Omarchy uses. An earlier version read /sys/class/power_supply/AC/online directly, which was wrong on any laptop that calls that node ADP1, ACAD or AC0 — and wrong even where the path was right, because sysfs does not deliver the change notifications the watcher was waiting on.

On unplugging, a notification says the scene paused itself and will resume on AC — once per discharge, re-armed when the mains return, never at startup, and never when the treatment was already off or power.onBattery is "ignore". Silence it with power.notifyOnBattery: false in any layer.

Control

omarchy-live status              # current state as JSON, including which layers apply
omarchy-live toggle              # on/off, persistent — what the menu row runs
omarchy-live off | on            # this session only, every theme
omarchy-live disable <theme>     # persistent, one theme
omarchy-live speed 1.5           # motion multiplier, 0.1-8 (1.0 = built-in pace)
omarchy-live tune print.halftone 0.4
omarchy-live preset bold         # subtle | balanced | bold
omarchy-live reset               # drop your override for this theme
omarchy-live path                # print the file an edit would go to
omarchy-live edit                # open that file

When an edit lands in a theme's own spec it is mirrored into the staged copy under ~/.local/state/ too — writing only there would look right until the next omarchy theme set rebuilt the directory and silently discarded the change.

live.json

Every key is optional; the values below are the built-in defaults.

{
  "enabled": true,                                      // false opts this layer out
  "motion": { "period": 36, "speed": 1.0, "zoom": 0.05, "driftX": 0.014, "driftY": 0.009 },
  "print":  { "halftone": 0.0, "dotScale": 150, "grain": 0.022,
              "vignette": 0.30, "bloom": 0.08 },
  "pulse":  { "everyMin": 24, "everyMax": 55, "attack": 200, "hold": 90,
              "release": 800, "aberration": 0.004 },
  "glints": { "count": 2, "everyMin": 12, "everyMax": 30, "length": 0.45,
              "thickness": 1.6, "speed": 1100 },
  "motes":  { "rate": 14, "life": 14000, "size": 2.6, "drift": 16, "fall": 18 },
  "colors": { "accent": "auto", "secondary": "auto" },  // "auto" = theme palette
  "lock":   { "pulse": 0.5 },                           // 0 disables the lock rim
  "power":  { "onBattery": "off", "notifyOnBattery": true },
  "video":  null                                        // path, relative to live/
}

period is in seconds; every other duration is in milliseconds. speed multiplies the loop rather than restating it — the effective loop is period / speed — so a preference for livelier motion survives a theme that sets its own period.

Every numeric here is clamped to a sane range on the way in (SpecBounds.js), and the mote population is bounded as a whole rather than per key, so no spec file can turn the wallpaper into a denial of service.

Video wallpapers

Set video to a file next to live.json and it plays looped through QtMultimedia instead of the parallax still, with the shader pass still applied on top. If the file is missing or the codec is unavailable the still image shows instead — the plate underneath is already loaded and correct for the theme.

The parallax path is usually the better choice: it stays crisp at any resolution because it samples the 4K source directly, loops with no seam, and costs far less than continuous video decode.

How it replaces the built-in

manifest.json declares omarchy.clonedFrom: "omarchy.background", so enabling this plugin moves the built-in into disabledPlugins[] and exactly one background renderer is ever mounted.

The stock contract is preserved verbatim: the background IPC target that omarchy-theme-bg-set and omarchy-theme-set call into, the masked wipe between wallpapers, and the double-click selectors. The live scene fades out for the duration of a wipe so theme switching still gets the stock reveal instead of a crossfade between two animating scenes.

Live controls sit on a separate livewallpaper IPC target so that contract is never touched.

Hardening

Not a behaviour change, added after the marketplace security baseline flagged it. Worth carrying forward on a rebase rather than dropping as noise.

Nothing is read without a ceiling. Every file this plugin reads is writable local state: a theme's live.json, the global and per-theme overrides, the resolved spec in the runtime directory, the current theme name, the AC sysfs node. FileView reads whatever it is pointed at, in full, into a process that lives for the whole session, and it has no size limit of its own — so a file that has grown to hundreds of megabytes at one of those paths took the shell's memory with it, whether it got that way through corruption or on purpose.

GuardedFile.qml splits the job. FileView keeps the half it is safe at, watching: preload: false stops it reading the file at all while watchChanges still delivers the notifications live edits depend on. The content comes from head -c, which stops at the ceiling inside the read itself rather than measuring the file and then trusting the measurement — nothing above the ceiling is ever allocated, and there is no window between the check and the read for the file to grow in. A file over the ceiling is named in the log and then treated exactly as a missing one, so the layer falls through, which is the same degradation a malformed file already gets.

The ceiling is 256 KiB for the spec layers, 4 KiB for the theme name and the AC node. The write-only FileView that publishes the resolved spec carries preload: false too — it never reads that file, but without it a file already sitting at that path would be pulled in for no reason at all.

Subprocess output is bounded the same way. The bar's omarchy-shell livewallpaper status call and the readlink that resolves the current background both pipe through head -c before StdioCollector sees them.

Nothing is read without a deadline either. A read that never finishes is as bad as one that never stops growing: it holds the single reader, so every later reload queues behind it and that file stops updating for the rest of the session. A FIFO is the easy way to arrange that — opening one with no writer on the other end blocks forever.

So the read tests for a regular file before it opens anything. [ -f ] answers from a stat rather than an open, so a FIFO, a character device or a directory at one of these paths is rejected without ever being opened. timeout covers what the test cannot: a path that turns into a FIFO in the moment between the test and the read, and a regular file that is simply slow to serve — one on a network mount that has gone away. It runs the read in its own process group and signals the group, so nothing is left behind. Either failure lands as absence, the same as a missing file. The two StdioCollector calls carry the same deadline.

Spec values are clamped, not just the bytes that carry them. A ceiling on how much of a file is read says nothing about what those bytes ask for. glints.count becomes a Repeater.model, motes.rate an emitter's particles per second, motes.life how long each is retained, and the pulse and glint values become animation durations and timer intervals. A file well under a kilobyte can therefore ask for a million scene items, and the cost lands in a compositor-adjacent process that stays up for the whole session.

SpecBounds.js carries a range for every numeric the spec can set, applied both at the merge — so the status output, the bar sliders and the published resolved spec all see the same bounded values — and again in the renderer's cfg(), which is the point every one of those values passes through and works for a spec that arrived from somewhere other than this plugin's own merge. The ranges sit far wider than anything a real theme wants: they are the point past which a value stops being a look and starts being a denial of service. Out-of-range clamps to the nearest end rather than dropping the layer, so a typo still renders close to what was asked for; a non-number is not a value at all and falls back to the consumer's own default.

One bound is not a single key. The standing mote population is emission rate times lifetime, so a spec can put both of those inside their own ranges and still ask for tens of thousands of live particles — Qt caps an ImageParticle at 16383 and warns about it every frame past that. Emission is throttled against a population budget, which the built-in baseline sits at roughly 7% of.

Rebuilding the shader

shaders/livefx.frag.qsb is committed. After editing the .frag:

/usr/lib/qt6/bin/qsb --glsl "100 es,120,150" --hlsl 50 --msl 12 \
  -o shaders/livefx.frag.qsb shaders/livefx.frag
omarchy restart shell

Service plugins are remounted on shell restart, not by rescanPlugins — editing one and only rescanning leaves the old code running.