# bg-pasticcio — internals Working notes for anyone (human or agent) changing this plugin. Install and first-run instructions live in [README.md](README.md); everything below is how it actually works and what must not be broken. ## Layout | Path | What it is | | --- | --- | | `manifest.json` | Omarchy plugin manifest: id `ssimo.bg-pasticcio`, kinds `service` + `bar-widget`, entry points. | | `Service.qml` | The `service`: headless singleton, owns the schedule, the config watch, and the `bgpasticcio` IPC target. | | `BarPanel.qml` | The `bar-widget`: the icon by the clock and its popup. Stateless. | | `bin/bg-pasticcio` | Bash worker. Does everything that can fail: network, disk, applying the wallpaper, writing config. | | `config.env.example` | Reference copy of the config file. Editing it changes nothing. | No build step, no runtime beyond bash and `omarchy-shell`. ## Architecture **One worker, one owner of state.** A bar widget is instantiated once per monitor, so `BarPanel.qml` keeps no state: it reaches the service with `bar?.shell?.serviceFor("ssimo.bg-pasticcio")`, calls its functions and binds to `service.workerStatus`. Every copy of the panel therefore agrees with every other, and N monitors still means one worker process. **The QML decides *when*, the bash does *what*.** Anything that can fail lives in `bin/bg-pasticcio`. `Service.qml` only schedules it and reacts to the exit code. ### Service.qml - `config.env` is read through `configReader`, a `Process` running `head -c 65536`, on a 30 s timer and after every command that writes the file. The cap is the point: `config.env` is hand-editable, this service outlives the session, and a `FileView` would pull whatever it found into the shell whole, every 30 s. Reading past the cap fires a one-shot `console.warn` (measured on `data.byteLength`, not on the string — one em dash and a full read looks short of the cap). A read requested while one is in flight sets `configReloadPending` rather than being dropped: the read after `enable` is what starts the schedule. - Polled rather than watched, deliberately. A watch has to keep the file loaded to notice it change — the read just capped — and misses a file replaced by rename anyway, which is what `sed -i`, most editors, and the worker's own `set-config` all do. Only two keys are parsed here — `BG_ENABLED` and `BG_INTERVAL_MINUTES`; the worker is the source of truth for the rest. - `cycleTimer` — `running: root.rotationOn`, so an installed-but-untouched plugin has **no schedule at all**. Deliberately without `triggeredOnStart`: Qt honours that flag only on a Timer's very first start, so it cannot be relied on to fire every time the switch is flipped on. - `onRotationOnChanged` is the one place the switch is acted on, and it calls `runNow()` — that is what makes turning it on (panel, `e`, IPC, or a hand edit of `config.env`) change the wallpaper immediately rather than at the next tick. If the worker is busy it retries in 5 s, because `runWorker` drops what it declines without a trace. - `retryTimer` — cross-tick backoff after a non-zero exit: 1, 2, 4, 8 … minutes, capped at the interval. On top of the worker's own per-request backoff. - Three separate `Process` objects on purpose: `worker` (images/wallpaper), `statusProc` (`status`), `configProc` (`set-config`). Editing a setting or refreshing the panel must not queue behind a download. - Status polling runs at 5 s only while `uiWatchers > 0`; panels call `watch()`/`unwatch()` on open/close, so nothing polls when nobody is looking. - `setConfig` starts `configProc` with the key only, then writes the value plus a newline on `onStarted` and clears the property that held it. A value carrying a newline is refused before the worker is started: the worker reads one line, and would otherwise save a pasted value cut in half. - `IpcHandler` target `bgpasticcio` exposes `next`, `rotate`, `like`, `dislike`, `enable`, `disable`, `restore`, `status`. Each returns `"running"` or `"busy"`. ### Trap: `next` means two different things | Call | Effect | | --- | --- | | `omarchy-shell bgpasticcio next` | Service `runNow()` → worker **`run`** — fetch fresh from the network, restart the clock. | | `bin/bg-pasticcio next` | Worker `cmd_next` — rotate to the next image in `liked/`, no network. | In the worker, `next` and `rotate` are the same command. Over IPC they are not. ### BarPanel.qml Left-click opens the panel, right-click fetches a fresh image, middle-click steps through `liked/`. Keys inside the panel, via `PanelKeyCatcher` (suppressed while a text field has focus): `e` toggle, `f` keep, `d`/`x` discard, `n` fresh image. | Control | Writes / calls | | --- | --- | | **Change my background** | `setEnabled()` → worker `enable`/`disable` (not `set-config`: only the worker takes the lock that makes restoring safe). | | **Restore my wallpaper** | `restore`. Shown only while `canRestore`. | | **Keep** / **Not this** / **Next** | `like` / `dislike` / `runNow`. | | **Endpoint** | `set-config BG_ENDPOINT`. Enter saves, Escape reverts. | | **Change every** | `set-config BG_INTERVAL_MINUTES`. | Buttons grey out when the plugin is off, when the worker is busy, or when the wallpaper on screen did not come from this plugin (`currentIsOurs`) — a theme background or the setup notice is not something to rate. ## Worker command surface `bin/bg-pasticcio `; default is `run`. | Command | Lock | Does | | --- | --- | --- | | `run` | yes | Fetch JSON, download, verify, apply. Falls back to a kept image on any failure. | | `next` / `rotate` | yes | Next image in `liked/`. No network. No-op when `liked/` is empty. | | `like` | yes | Move the current image into `liked/`, re-apply at the new path. The only way an image survives. | | `dislike` | yes | Delete it, blocklist hash + source URL, fetch a replacement. | | `enable` / `disable` | yes | Flip `BG_ENABLED`; `disable` also restores the original wallpaper. | | `restore` | yes | Put the pre-first-change wallpaper back, leave the switch alone. | | `set-config KEY [VALUE]` | its own | Only writer of `config.env`. Never touches the images. Without `VALUE` the value is read as one line on stdin — how the panel sends it. Refused, rather than read, when stdin is a terminal, so a forgotten argument cannot silently blank the endpoint. | | `status` | **no** | JSON: config, kept/blocked counts, current image, last result. | | `interval` | **no** | Prints `BG_INTERVAL_MINUTES`. | `status` and `set-config` never take `$STATE_DIR/lock`, so the panel stays responsive during a download. Everything else serialises through it, and a timer tick and a manual IPC call cannot race. `set-config` does take a second, much shorter lock of its own, `$CONFIG_DIR/.config-lock`, because it reads the whole file and moves a new one over it: `enable` writing from inside the worker's lock while the panel saves an interval from outside it used to throw one of the two changes away. Order is always worker lock first, config lock second, so the pair cannot deadlock. Five seconds and it gives up rather than making the panel wait. ### Run path `cmd_run` → `fetch_fresh_image` → `curl` the endpoint → `jq .url` → `download_image` → `file` check on the bytes → store under a content hash in `images/` → `write_meta` sidecar → `apply_background`. `main` then calls `discard_unkept`, which deletes everything in `images/` that is not the file on screen. When the fetch fails — unreachable endpoint, or an endpoint that only offers blocked images — `rotate_liked` shows the next image in `liked/` instead. With `liked/` empty there is no fallback at all: the wallpaper is left alone, the run is recorded as failed, and the service's backoff retries. Applying always goes through `omarchy-theme-bg-set`, so Omarchy's `~/.local/state/omarchy/current/background` symlink stays authoritative and the change is instant. Before the *first* background it ever applies, the worker records what was already in place — both resolved file and raw symlink target — in `original-background`. ## Config `~/.config/bg-pasticcio/config.env`, written with defaults on first run. | Key | Default | Meaning | | --- | --- | --- | | `BG_ENABLED` | `0` | Master switch. Every wallpaper-touching command refuses while `0`. | | `BG_ENDPOINT` | `https://bg.ssimo.dev` | JSON endpoint. Empty = rotate `liked/` only. | | `BG_INTERVAL_MINUTES` | `60` | Rotation period. | | `BG_TIMEOUT_SECONDS` | `20` | Per-request timeout. | | `BG_MAX_RETRIES` | `4` | Attempts per network call. | | `BG_RETRY_BASE_SECONDS` | `2` | Backoff base: 2 s, 4 s, 8 s … | | `BG_BLOCKED_RETRIES` | `3` | Re-asks when the endpoint keeps returning a blocked image. | `BG_ENABLED` and `BG_INTERVAL_MINUTES` apply live (≤30 s, immediately when the panel saves). The rest are read by the worker on its next run. `BG_ENABLED=0` set by hand only stops rotation; it does **not** restore the wallpaper. `disable` does both. ## On disk | Path | Purpose | | --- | --- | | `~/.config/bg-pasticcio/config.env` | settings. 0600, in a 0700 directory: it holds `BG_ENDPOINT` | | `~/.local/share/bg-pasticcio/images/` | at most one file: the downloaded image on screen | | `~/.local/share/bg-pasticcio/liked/` | kept images; the only durable collection, never deleted from | | `~/.local/state/bg-pasticcio/original-background` | wallpaper from before the first change | | `~/.local/state/bg-pasticcio/blocklist` | `` + source URL per discarded image. 0600 | | `~/.local/state/bg-pasticcio/last` | tab-separated epoch, result, message — feeds `status` | | `~/.local/state/bg-pasticcio/lock` | `flock` target for image/wallpaper commands | | `~/.config/bg-pasticcio/.config-lock` | `flock` target for `set-config` only | | `~/.local/state/bg-pasticcio/setup-required.png` | generated "configure me" background | | `~/.local/state/bg-pasticcio/bg-pasticcio.log` | one line per run; truncated to 200 lines past 256 KB | | `.download.`, `.curl-body.`, `.curl-err.`, `*.tmp.` | scratch. Removed by the worker's `EXIT`/`INT`/`TERM` trap; a run killed outright leaves them, so `sweep_stale_temp_files` deletes any older than an hour, under the lock | | `.meta` | JSON sidecar: the sanitized endpoint answer for that image. 0600 | | `.src` | legacy sidecar, URL only; still read, upgraded to `.meta` when seen again | | `~/.local/state/omarchy/current/background` | Omarchy's symlink to the active image | Sidecars follow the image on `like` (`images/` → `liked/`). ## Endpoint contract Only `url` is required: ```json { "url": "https://example.org/photo.jpg", "title": "California's Central Valley", "creator": "Mark Miller", "license": "cc0", "licenseUrl": "https://creativecommons.org/publicdomain/zero/1.0/deed.en", "source": "wikimedia", "sourceUrl": "https://commons.wikimedia.org/w/index.php?curid=2374537" } ``` `sanitize_meta` treats the answer as a stranger's text that ends up on a desktop and in a shell command line: - unknown keys dropped, every string capped at 200 characters; - `licenseUrl` / `sourceUrl` accepted only as `http(s)` URLs built from a character set that cannot break out of a single-quoted shell word — the apostrophe is deliberately excluded, which is what makes opening one in a browser safe; - anything failing a check is dropped, and the panel simply omits it; - text fields render as plain text, never markup. Downloads: `curl` pinned to `http`/`https` (so an endpoint cannot bounce the fetch into `file://`), stopped at 64 MB — 256 KB for the JSON answer, which is read into a shell variable and so gets a ceiling of its own rather than the wallpaper's. Downloaded bytes are verified with `file` — an HTML error page served with a `200` never becomes a wallpaper. An endpoint URL can carry a token, so it is kept out of everywhere it would otherwise end up in the clear: - **Not in the log.** URLs are stripped from logged network errors, and no message that refuses a URL prints it. - **Not in `argv`.** `/proc//cmdline` is readable by every local user unless `/proc` was mounted with `hidepid`, so neither process that handles the endpoint puts it there. `curl` is given the URL on stdin through its config-file syntax (`-K -`); `--resolve` and the rest stay in `argv`, since they give away a host and never a secret. `set-config` takes the key as an argument and the value as a line on stdin, written by `configProc.onStarted` — the newline is what ends the worker's read, so stdin never has to be closed and the write cannot race the close. Typed by hand, `set-config KEY VALUE` still works and is still `argv`; that is the caller's choice to make. - **Not world-readable on disk.** `config.env` is 0600 and its directory 0700, set on creation and re-applied on every run so a file written before this existed is narrowed too. Same for `blocklist` and the `.meta` sidecars, which hold image URLs that can be presigned. The images themselves stay 0644. `-K -` quotes with `"` and `\`, so `url_is_safe` gates every URL before curl is handed it — `BG_ENDPOINT` (which `config_value_valid` only checks when the panel writes it, not when the file is edited by hand), the image URL the endpoint chose, and every redirect target. The character set is the union of the two already in use, and it cannot express either quoting character. Where the fetch may point is checked too, because the feed picks the image URL: `url_is_public` resolves the host and refuses loopback, link-local, private, CGNAT and multicast addresses, so a feed cannot have this machine fetch from a service only this machine can reach. `-L` is not used — it would connect to whatever the previous hop named before that check could run — so `curl_with_backoff` walks redirects itself (at most `MAX_REDIRECTS`) and vets every hop. curl is then pinned to the addresses that passed, with `--resolve` built by `resolve_pin_for` from `RESOLVED_ADDRS`, so it never looks the name up a second time: a name whose owner controls the DNS answer cannot show a public address to the check and a loopback one to the fetch. Only `BG_ENDPOINT` itself is exempt on its first hop, since the user configured it and a local feed of their own is legitimate; nothing it points at afterwards is. ## Invariants — do not break these 1. **Nothing happens until `BG_ENABLED` is on.** No request, no schedule, no wallpaper change on install. `require_enabled` guards every mutating command; `cycleTimer` does not run. 2. **The original wallpaper is recorded before the first apply** and restorable for as long as the file exists. 3. **The worker is the only writer of `config.env`** — nothing else needs to know how to quote a shell value safely. 4. **No `set -e` in the worker.** Deliberate: every failure is handled and downgraded to offline rotation. A wallpaper that stops changing is the bug being avoided. 5. **The panel stores no state.** Anything remembered belongs in the service or in `status`. 6. **Only image/wallpaper commands take `$STATE_DIR/lock`.** `status` takes no lock at all; `set-config` takes only its own, so neither can end up waiting on a download. 7. **`status` is the single read model** for the UI: add a field there rather than shelling out from QML. 8. **Paths are canonical before they are compared.** `CONFIG_DIR`, `DATA_DIR`, `STATE_DIR` and `OMARCHY_STATE` go through `canonical_path` at the top of the worker, because everything they build is matched against the background symlink after `readlink -f` resolved it. Leave one unresolved and a symlinked `~/.local/share` makes every image of ours read as foreign: nothing can be kept, `discard_unkept` deletes the wallpaper on screen, and the background written down as "the one from before" is one of ours. 9. **At most one downloaded image exists at a time.** `images/` holds the file on screen and nothing else; `discard_unkept` in `main` enforces it after every locked command. `liked/` is the only durable collection, and nothing in the worker deletes from it except an explicit `dislike`. ## Debugging ```sh tail -f ~/.local/state/bg-pasticcio/bg-pasticcio.log bin/bg-pasticcio status | jq . bin/bg-pasticcio run # logs to stderr too when attached to a tty omarchy-restart-shell # after ANY QML edit ``` The shell caches compiled QML per file URL, so `rescanPlugins` can hand back the previous version — restart the shell while developing. Common failures, all visible in the log: | Message | Meaning | | --- | --- | | `missing required command(s)` | Install what it names; checked once per run rather than failing mid-pipeline. | | `endpoint response has no usable .url field` | Endpoint must return a top-level `url`. Check with `curl -s "$BG_ENDPOINT" \| jq .`. | | `downloaded content is not a supported image` | Not JPEG/PNG/WebP/GIF/BMP — usually an error page or a login redirect. | | `another run is in progress, skipping` | `flock` did its job. Not an error. | | Black "configure an endpoint" background | `BG_ENDPOINT` blank and `liked/` empty. Rendered with ImageMagick at the largest monitor's size (`hyprctl`), in `fc-match sans-serif`. Without `magick` the worker logs and leaves the background alone. | | `config.env is larger than 65536 bytes` | Only the first 64 KiB is read; keys past it are ignored. The file grew — the worker's own is ~1.2 KB. | | Buttons greyed out | Plugin off, worker busy, or the wallpaper did not come from this plugin. | | Background never changes offline | Nothing kept yet — offline rotation only has `liked/` to work with. | ## Uninstall notes `disable` (or the toggle) first, then `omarchy plugin remove ssimo.bg-pasticcio`. Kept images, config and log are left on purpose; `rm -rf ~/.config/bg-pasticcio ~/.local/share/bg-pasticcio ~/.local/state/bg-pasticcio` clears them. If the active wallpaper still came from this plugin, pick another background first (`omarchy theme bg next`) or the desktop is left pointing at a file that no longer exists.