Omahub
← All plugins
Z

Blackwall

by zds

Timed session lock with no way out. Pick a duration on the bar; the Blackwall holds the session until it expires.

Security review

No obvious issues detected

Deterministic scan — not a security guarantee

None
Risk level
None
Analyzed commit
7a2e6d2
Scanned
1 month ago

No potentially dangerous behavior detected in the analyzed commit.

Automated analysis only — not a security guarantee.

AI advisory review

No obvious issues detected

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

Low
AI risk level
Low
Recommendation
install
Model
~deepseek/deepseek-v4-flash-latest
Analyzed commit
7a2e6d2
Reviewed
1 month ago

Blackwall is a well-documented timed session lock with no unlock path, which is its intended purpose. The code is clean, uses defensive file handling (O_NOFOLLOW, ownership/mode checks, bounded reads/writes), and has no network access, privilege escalation, or hidden persistence. The only risk is the potential for user lockout, but that is by design and clearly documented.

  • The lock can be engaged for up to 12 hours and, if 'persist across reboot' is enabled, resumes after a reboot, which could surprise users who forget they engaged it.
  • There is no unlock method other than waiting for the timer or using the documented TTY recovery path; accidental engagement of a long lock could be disruptive.
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/zdsdeveloper/blackwall --enable
System #bar #system #security

Blackwall

Blackwall lock screen

A timed session lock with no way out. Pick a duration from the bar; the session locks and stays locked until the timer runs out.

There is no password prompt, no cancel button, and no unlock IPC method. That is the point.

Install

omarchy plugin add https://github.com/zdsdeveloper/blackwall.git
omarchy plugin enable zds.blackwall

omarchy plugin add clones into ~/.config/omarchy/plugins/zds.blackwall/ (named by the manifest id) and leaves the plugin disabled so you can read the code before running it. Plugins run unsandboxed inside omarchy-shell; that pause is the point, so take it.

Enabling puts the button in the bar's right section. Move it with:

omarchy bar move zds.blackwall --section center

Optional: give it a soundtrack

No audio ships with the plugin, so the lock is silent out of the box. Drop any audio file into sounds/ and it loops while locked — the filename does not matter:

cp ~/Downloads/whatever.mp3 ~/.config/omarchy/plugins/zds.blackwall/sounds/

Recommended: "Cyberpunk 2077 — Standing in front of the Blackwall (Ambience)" by Anendale, which is what this was built against. Source your own copy; it is not redistributed here. See sounds/README.md for formats, pointing at a file elsewhere, and volume.

Remove

omarchy plugin remove zds.blackwall

That takes the widget out of the bar and deletes the checkout. Two things live outside the plugin directory and are left behind on purpose — remove them by hand if you want no trace:

rm ~/.config/omarchy/zds.blackwall.json              # the persistence toggle
rm -rf ~/.local/state/omarchy/blackwall              # live lock state

Removing the plugin while a lock is engaged does not unlock the session. The compositor holds the lock, not the plugin. Let the timer run out first, or use the recovery path below.

Requirements

  • Omarchy 4.0+ — the Quickshell shell, WlSessionLock, and the omarchy plugin commands. It will not load on the older waybar-based shell.
  • A monospace font with block-drawing glyphs (▀ ▄ █ ░ ▒ ▓). Any Nerd Font has them; Omarchy's default JetBrainsMono Nerd Font is fine.
  • qt6-multimedia (optional) — only for the ambience. Without it, or with no audio file in sounds/, the lock is simply silent.
  • qt6-shadertools (only to develop) — supplies qsb for rebuilding glitch.frag.qsb. Not needed to run the plugin; the compiled shader ships in the repo.
  • python3 — runs bin/blackwall-file-guard, which is how the two state files are read and written (see State files). Already present on every Omarchy system: omarchy depends on uwsm, which depends on python. Nothing is installed with pip; the guard uses only the standard library.

No network access, no privilege escalation, no systemd units. The only external processes it starts are mkdir/chmod for its state directory, cat /proc/sys/kernel/random/boot_id, a bash glob to find an audio file, and python3 bin/blackwall-file-guard to touch its own two files.

Files

File What it is
manifest.json Plugin manifest — one bar-widget, one service
BarWidget.qml Bar button, duration menu, persistence toggle
Service.qml Owns the session lock, the countdown, and both files
GuardedFile.qml Read/write access to one file, via the guard below
bin/blackwall-file-guard Bounded, no-follow reads and writes (see below)
tests/test-file-guard.sh Exercises the guard against symlinks, FIFOs, and the rest
BlackwallLockView.qml What the lock surface paints, and the ambience
GhostFaces.qml The apparition layer for the release sequence
Faces.js The faces, as shaded block text
GlitchBackground.qml Static field behind the wall (wraps the shader)
glitch.frag Shader source
glitch.frag.qsb Compiled shader — what Qt actually loads
Logo.js The wall itself, as text
logo.txt Source the logo was generated from
Model.js Duration parsing, formatting, config, and the ripple math
sounds/ Drop audio here to give the lock a soundtrack (optional)

Two files outside this directory:

Path What it is
~/.config/omarchy/zds.blackwall.json User config (the toggle)
~/.local/state/omarchy/blackwall/deadline Live lock state (mode 0600)

Everything under ~/.config/omarchy/plugins/ hot-reloads on save. If a change does not take, omarchy-shell shell rescanPlugins forces a reload — but note that the QML engine caches a type that failed to compile, so after fixing a syntax error you need omarchy restart shell, not a rescan.

State files

Both of Blackwall's files live at paths that are entirely predictable, and one of them decides whether a lock is still owed. Qt's FileView is not a safe way to open either: it has no size ceiling, so a read lands whole in the shell's heap; it has no type check, so a FIFO left at the path blocks the open; and its atomic write resolves a symlink and writes through to the target.

So neither file is touched through FileView. Everything goes through bin/blackwall-file-guard, which validates an already-open descriptor rather than a path:

open(O_NOFOLLOW | O_NONBLOCK) -> fstat(fd) -> read(fd, <= 64 KiB)

O_NOFOLLOW makes a planted symlink fail at open(). O_NONBLOCK is what stops a FIFO from hanging the open itself — the one check that cannot be made after the fact. Because the type, owner, mode and size are checked against the descriptor and not the path, there is no window in which the file can be swapped underneath the check. Writes go to a fresh 0600 temp file in the same directory and are renamed into place; rename(2) replaces the name rather than writing through a link.

A file is refused if it is not a regular file, is not owned by you, is writable by other users, is larger than 64 KiB, or sits in a directory other users can write to. The state directory is created 0700.

The two files get deliberately different treatment:

  • deadline is Blackwall's own, in a directory nothing else uses. Symlinks are refused. A write may take the name back from something planted there — refusing forever would quietly stop the deadline persisting, which would turn restarting the shell into a way out of a lock.
  • zds.blackwall.json is yours. Symlinks are allowed, so keeping it in a dotfiles repo and linking it into place works; the link has to resolve inside $HOME and the target still faces every check above. Nothing there is ever replaced or deleted. If the path is refused, Blackwall runs on defaults in memory and will not write to it at all — blackwall status reports configWritable: false, and the settings commands say so.

A refused read never stalls startup: it resolves as "no saved lock", exactly as an empty file would, and every guard run has a 5-second watchdog behind it.

All of the above is covered by tests/test-file-guard.sh, which plants each kind of entry at a real path and checks what the guard does with it:

./tests/test-file-guard.sh

How it works

The service owns a Quickshell WlSessionLock — the same ext-session-lock primitive omarchy.lock uses. While it holds the lock the compositor routes all keyboard and pointer input to the lock surface and nothing else, so input is blocked at the compositor, not by a window that merely covers the screen.

It is a service rather than part of the bar widget because bar widgets are instantiated once per monitor and a session lock has to be a singleton. The widget reaches the service through shell.serviceFor("zds.blackwall"), and falls back to omarchy-shell blackwall engage <seconds> if that lookup fails.

Three things keep the wall standing:

  • Wall-clock deadline. The countdown is deadline - Date.now(), not an accumulated tick count, so a suspend, a resume, or a late timer callback cannot stretch or shorten the lock.
  • Idle suppression. With no input arriving, the idle monitor keeps counting and would fire omarchy-system-lock on top of us at idle.lock. The service holds the idle cycle off for the duration, without touching the stay-awake state file or its bar indicator.
  • Re-assertion. If the compositor hands the session lock to something else while time remains, the service takes it back (bounded to 20 attempts) and tells omarchy.lock to stand down.

The reconnection sequence

The countdown reaching zero does not unlock anything. It starts a ~4.6s sequence — the wall opening — and only the end of that hands the session back.

Fraction Phase What happens
0.00 – 0.16 breach BREACH DETECTED flickers at ~7Hz, glitch and ripple spike
0.16 – 0.70 press Faces come up against the wall, block meter fills
0.70 – 0.88 surge WALL FAILING, everything peaks, the wall goes white-hot
0.88 – 1.00 shatter Rows part and fade, then a white seal shuts to a point

Every curve — glitch intensity, ripple amplitude, bleaching, face pressure, shatter travel, the meter, the audio fade — is a pure function of one releaseProgress value in Model.js. Nothing has its own timeline, so nothing can drift out of step with anything else.

Service.tick runs at 5Hz normally and 16ms while releasing: the countdown only ever renders whole seconds, but the sequence derives its motion from the same clock, and at 200ms the shatter steps visibly. One clock, sped up, rather than a second timeline that could drift from the authoritative one.

It always opens

Two things guarantee the session comes back:

  • The watchdog. releaseWatchdog fires at RELEASE_MS + 2000 and unlocks regardless of what the progress clock is doing. A ceremony that can hang is a lock that never opens. This path is tested by shortening the watchdog until it wins the race; it logs released: watchdog.
  • The state file is cleared when the sequence starts, not when it ends. The timer is genuinely over at that point, so a crash mid-ceremony comes back as an unlocked session, never as a resumed lock.

While the sequence runs the lock is still held — holding is engaged || releasing, and every guard that keeps the wall up asks that rather than engaged, or the wall would drop the instant the timer hit zero and the sequence would play to an empty room.

The faces

Faces.js holds three shaded block faces in the same character vocabulary as the logo — a soft ░ halo falling off to a solid ██ core, with eyes and mouth carved out to empty. Negative space is what makes them read as faces at this size; the halo is what keeps them reading as apparitions rather than stickers. They are generated from ellipse falloff by scratchpad/faces.py, so every row is the same width. Hand-edit freely, but keep the rows rectangular.

GhostFaces.qml runs a pool of five independent apparition slots. Each picks a face, a position, and a scale, swells out of nothing, holds, and sinks back. Nothing is synchronised between slots, so the layer never pulses in unison.

One trap worth knowing about: the timings live on the slot, not on the animation. A ScriptAction that rewrites the durations of the SequentialAnimation containing it restarts that group, which re-fires the script, which restarts it again — an infinite synchronous loop that hangs the QML engine at component creation with no error message at all. Animation durations are only ever written while the animation is stopped; all the per-cycle jitter lives on a Timer interval, which is safe to rewrite at any point.

Persist Across Reboot

The menu carries one setting, stored in ~/.config/omarchy/zds.blackwall.json:

{
  "version": 1,
  "persistAcrossReboot": true,
  "soundPath": ""
}

(soundPath is the ambience override — see sounds/README.md. Empty means auto-discovery.)

The file is created with defaults on first run, is watched, so hand-edits take effect without a restart, and is also reachable over IPC.

ON — a lock outlives a reboot and resumes for whatever time is left.

OFF — the lock is session-only. A reboot clears it.

Note what OFF does not mean: inside a single boot the lock always comes back, however the shell went down. Restarting or crashing the shell is not an escape hatch either way — only a reboot is, and only with the toggle off.

Telling those two cases apart is why the state file carries a boot id:

{ "version": 1, "deadline": 1787530843043, "bootId": "7aa0960c-…" }

On resume the stored id is compared against /proc/sys/kernel/random/boot_id. Same id means the shell restarted; a different id (or a state file old enough to have no id at all) means the machine rebooted. Only the second case consults the toggle.

Restarting the shell while locked

omarchy restart shell refuses while a Blackwall lock is up — Omarchy guards against restarting a live lock client and stranding the session behind Hyprland's failsafe. That is the desired behaviour here, so it is left alone.

If the shell dies anyway, the next one resumes the lock and tells omarchy.lock to stand down — otherwise its stranded-lock recovery would put a password prompt in front of an unexpired Blackwall, which would be the escape hatch this plugin exists to not have.

Recovery

This needs a TTY, and it is deliberately not reachable from the locked session:

  1. Ctrl+Alt+F2, log in.
  2. rm ~/.local/state/omarchy/blackwall/deadline
  3. pkill -f 'quickshell.*omarchy/shell'

The process is quickshell -n -p /usr/share/omarchy/shell, so a pattern of quickshell -p will not match it.

Hyprland keeps the screen locked when a lock client dies, so on the next shell start omarchy.lock picks up the stranded lock and offers its normal password prompt. Skipping step 2 means Blackwall resumes instead.

Commands

omarchy-shell blackwall status         # JSON: engaged, remaining, deadline
omarchy-shell blackwall remaining      # MM:SS
omarchy-shell blackwall engage 600     # lock for 600 seconds
omarchy-shell blackwall persist            # true / false
omarchy-shell blackwall setPersist false   # flip the toggle

engage clamps to 30 seconds minimum and 12 hours maximum, and is refused while a lock is already up. There is no release. setPersist is safe to call while locked — it decides what happens at the next boot, so it is not an unlock path.

Tuning the look

Everything visual lives in BlackwallLockView.qml:

  • rippleStrength (default 0.14) — sideways travel per slice, as a fraction of the font size. The block art only survives displacement well under one character cell; much past 0.2 and the rows shear into mush.
  • sliceCount — one band per character row. Slicing finer puts the shear line through the middle of the glyphs.
  • The phase animation (2600ms) drives the travelling wave; the breath sequence (1900ms in, 2500ms out) drives the swell.

Preset durations and the 30-minute warning threshold are PRESET_MINUTES and WARN_MINUTES in Model.js. The custom field accepts 1 to 720 minutes (12 hours, MAX_SECONDS); anything longer is clamped rather than refused.

The confirmation screen always spells out the real duration — "The session locks for 8 hours" — so read that line before engaging. It is the last point at which a mistyped number is still recoverable.

The glitch field

glitch.frag is GLSL; Qt 6 will not accept it as an inline string the way Qt 5 did, so it has to be baked. After any edit, rebuild it or nothing changes:

qsb --qt6 -o glitch.frag.qsb glitch.frag   # /usr/lib/qt6/bin/qsb

Uniforms are all scalar floats so std140 packs them consecutively with no alignment traps. intensity is driven from the lock view and breathes with the wall. If the shader ever fails to load the effect hides itself and the surface falls back to flat black — a broken background must never be the reason the Blackwall does not come up.

The sequence

Phase boundaries, durations, and every curve are at the bottom of Model.js. RELEASE_MS sets the total length; the watchdog tracks it automatically. Face count, opacity, and colour are at the top of GhostFaces.qml.

The ambience

Whatever audio is in sounds/, looped at volume 0.3 (audioVolume in BlackwallLockView.qml), fading out across the shatter so it is not cut off mid-note when the session hands back. See sounds/README.md.

Resolution order, done at startup and again whenever the config changes: an explicit soundPath in the config file, then the first audio file in sounds/ regardless of name, then nothing. When nothing resolves, soundSource stays empty, no player is built, and the lock is silent — that is the entire missing-file handling.

On a multi-monitor setup one lock surface exists per output, all running the same view. Exactly one claims the audio via Service.claimAudio(), or the ambience plays once per monitor slightly out of phase.

License

GPL-3.0-or-later. See LICENSE.

Copyright (C) 2026 Zamil Suarez.

No audio is distributed with this plugin. The recommended track — "Cyberpunk 2077 — Standing in front of the Blackwall (Ambience)" by Anendale — is named in the docs but not included, because it is not mine to license. sounds/ is gitignored: whatever you put there is yours and stays local.