diff --git a/wl-svg-bg/README.md b/wl-svg-bg/README.md new file mode 100644 index 00000000..d8c2c250 --- /dev/null +++ b/wl-svg-bg/README.md @@ -0,0 +1,84 @@ +# Animated SVG Wallpaper + +Renders a CSS-animated SVG as your wallpaper, **with the animation actually +running**. Ordinary wallpaper tools rasterize an SVG to a single frozen frame; +this one hands the file to a WebKit view on a `wlr-layer-shell` background +surface, so `@keyframes` play live behind your desktop. + +While it is active the plugin asks noctalia to release its own Background +surface on the affected outputs, so the two never draw over each other. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `theta/wl-svg-bg` | +| Entries | Service: `takeover` | + +## Requirements + +- **`wl-svg-bg`** on `PATH` — the renderer itself, a separate program that this + plugin only drives. It is a single Python script from + [M4jor-Tom/wl-svg-bg.py](https://github.com/M4jor-Tom/wl-svg-bg.py) and needs + PyGObject with the GTK 4, WebKitGTK 6.0 and gtk4-layer-shell typelibs. On + Arch that is `python-gobject gtk4 webkitgtk-6.0 gtk4-layer-shell`; a Nix flake + and a home-manager module are in the repo. +- **`systemd`** — supplies `systemctl` and `systemd-run`, which start and stop + the renderer's user unit. +- A compositor with **`wlr-layer-shell`**: niri, sway, Hyprland, labwc, and the + rest of the wlroots family. This does not work on GNOME or KDE, which do not + implement that protocol. + +## Usage + +1. Install `wl-svg-bg` and enable this plugin. +2. Open the plugin's settings and set **Animated SVG** to a CSS-animated `.svg`. + One ships with the plugin at `examples/aurora.svg` if you do not have one — + animated SVGs are rare, so start there. +3. The wallpaper starts immediately. noctalia stops drawing its own on every + connected output, and monitors plugged in later are picked up automatically. + +**To turn it off, clear the Animated SVG field.** That is the off-switch: it +stops the renderer and gives the outputs back to noctalia. Disabling the plugin +also stops the renderer, but the outputs stay released until noctalia restarts, +because the host discards side effects issued during teardown — so clearing the +field is the clean path. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `svg` | `file` | *(empty)* | Path to the CSS-animated SVG to render. Empty means the plugin claims nothing and starts nothing, which is also how you switch it off. | + +## Notes + +**Cost.** This runs a WebKit view per monitor, continuously, for as long as the +wallpaper is up. That is more expensive than a static wallpaper and it does not +pause when the screen is locked, occluded, or on battery — the compositor gives +a background surface no visibility signal to react to. Budget for it on a +laptop, and prefer SVGs that animate slowly and over large soft shapes: +`examples/aurora.svg` is built that way on purpose. A file full of fast +per-element transforms will cost far more. + +**If the renderer dies**, its unit is restarted automatically +(`Restart=on-failure`). While it is down the outputs stay released, so the +screen shows the bare compositor background rather than your old wallpaper. If +it cannot start at all, the plugin hands every output back to noctalia and +notifies you, so a broken setup fails to your normal wallpaper instead of to +black. + +**Processes spawned.** `systemctl --user start|restart|stop wl-svg-bg.service`, +and — only when no `wl-svg-bg.service` has been declared, which is every install +that does not use the project's home-manager module — `systemd-run --user` to +create a transient unit of the same name. The plugin never writes a unit file +and never runs `daemon-reload`. + +**Files written.** One: `~/.local/state/wl-svg-bg/env`, holding +`WL_SVG_BG_SVG=`. The renderer reads the SVG path from +there rather than from a command line, so paths containing spaces survive. The +plugin compares this file's contents before writing, and only restarts the +renderer when the path actually changed — which is what keeps a plain +`systemctl --user restart noctalia` from reloading your wallpaper. + +**Network.** None. Nothing is fetched, and the rendered SVG is loaded from a +local `file://` URL. diff --git a/wl-svg-bg/examples/aurora.svg b/wl-svg-bg/examples/aurora.svg new file mode 100644 index 00000000..1a578390 --- /dev/null +++ b/wl-svg-bg/examples/aurora.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/wl-svg-bg/plugin.toml b/wl-svg-bg/plugin.toml new file mode 100644 index 00000000..d8c1056e --- /dev/null +++ b/wl-svg-bg/plugin.toml @@ -0,0 +1,28 @@ +id = "theta/wl-svg-bg" +name = "Animated SVG Wallpaper" +version = "0.1.0" +author = "theta" +license = "MIT" +# Catalog descriptions are capped at 120 characters (validate-plugins.py). +description = "Renders a CSS-animated SVG as the wallpaper with its animation running, via WebKit on a layer-shell surface." +# Exactly 17, not a floor picked for breadth: below it `onEnable` and the +# Disable/Uninstall exit reasons never fire (plugin_manager.cpp:593,609), which +# silently breaks the off-switch and the teardown path. +plugin_api = 17 +icon = "image" +tags = ["wallpaper", "animation", "service"] +# Everything this plugin spawns. `wl-svg-bg` is the renderer itself; systemd +# supplies both systemctl and systemd-run, which manage its unit. +dependencies = ["wl-svg-bg", "systemd"] + +[[service]] +id = "takeover" +entry = "service.luau" + +[[setting]] +key = "svg" +type = "file" +label_key = "settings.svg.label" +description_key = "settings.svg.description" +extensions = ["svg"] +default = "" diff --git a/wl-svg-bg/service.luau b/wl-svg-bg/service.luau new file mode 100644 index 00000000..8e11e15a --- /dev/null +++ b/wl-svg-bg/service.luau @@ -0,0 +1,210 @@ +-- Coordinates wl-svg-bg, an external WebKitGTK layer-shell renderer, with +-- noctalia: yields noctalia's Background surface on the outputs the renderer +-- owns, and drives the renderer's systemd user unit. +-- +-- The renderer cannot live in here: it needs WebKit to run the SVG's CSS +-- animation live, which a sandboxed Luau plugin cannot host. + +local BIN = "wl-svg-bg" +local UNIT = BIN .. ".service" +local ENV_DIR = "~/.local/state/wl-svg-bg" +local ENV_FILE = ENV_DIR .. "/env" +local TITLE = "Animated SVG wallpaper" + +-- Connectors we have told noctalia to yield, so onOutputsChanged only claims +-- what is new. noctalia's own managed set is runtime-only and clears on restart, +-- which is why the script body re-claims rather than relying on onEnable. +local owned = {} + +local function claimOutputs() + for _, output in ipairs(noctalia.outputs()) do + if not owned[output.name] then + noctalia.setWallpaperEnabled(output.name, false) + owned[output.name] = true + end + end +end + +local function releaseOutputs() + for name in pairs(owned) do + noctalia.setWallpaperEnabled(name, true) + end + owned = {} +end + +-- Start the renderer without a declared unit, which is every install that is +-- not home-manager's: nothing else on Arch or Fedora writes wl-svg-bg.service, +-- and without this branch the plugin can only ever fail there. +-- +-- A transient unit rather than a written one: `systemd-run` spawns and forgets, +-- where generating ~/.config/systemd/user/ + daemon-reload would have the plugin +-- installing units behind the user's back. --unit takes UNIT itself, so the +-- transient unit and the declarative one cannot drift apart and `stop` reaches +-- either; --collect frees the name again once the renderer exits, so the next +-- start can reclaim it. +-- +-- The SVG path travels in EnvironmentFile, never on the command line, so a +-- wallpaper under "~/Pictures/My Wallpapers/" survives whatever splitting the +-- host applies to the command string. wallpaper.py reads WL_SVG_BG_SVG when +-- --svg is absent, which is what makes that possible. +local function runTransient(cause) + if not noctalia.commandExists(BIN) then + noctalia.notifyError(TITLE, BIN .. " is not installed, or not on PATH. " + .. "Install it, then pick the SVG again.") + releaseOutputs() + return + end + -- Stop first: a transient unit cannot be restarted in place, and the name + -- has to be free before systemd-run can claim it. Its result is deliberately + -- ignored — "not running" is the expected case here, not an error. + noctalia.runAsync("systemctl --user stop " .. UNIT, function() + -- Restart=on-failure mirrors the declarative unit. Without it a renderer + -- crash is terminal, and since the outputs are already yielded that + -- means a black screen until the user touches the settings again. + noctalia.runAsync("systemd-run --user --collect --unit=" .. UNIT + .. " -p Restart=on-failure" + .. " -p EnvironmentFile=" .. noctalia.expandPath(ENV_FILE) + .. " " .. BIN, function(result) + if result.exitCode ~= 0 then + -- Report the declarative unit's failure too. This path is also + -- taken when a unit exists but is genuinely broken, and naming + -- only the fallback's error would bury the real cause. + noctalia.notifyError(TITLE, "Could not start the renderer (" + .. tostring(result.exitCode) .. "): " .. tostring(result.stderr) + .. " — the declared unit first failed with: " .. cause) + releaseOutputs() + end + end) + end) +end + +-- `detached` runs without a result callback, for use during teardown: onExit +-- side effects are discarded, but runAsync spawns immediately. +-- +-- The outputs are claimed before the unit is asked to run, so a start that +-- fails outright — a deleted SVG, or no renderer installed at all — would +-- otherwise leave noctalia's Background surface torn down with nothing drawing +-- in its place: bare compositor, forever. Failing to a static wallpaper beats +-- failing to black, so every path that cannot start hands the outputs back. +-- A failed "stop" is not a reason to re-claim. + +local function systemctl(verb, detached) + local command = "systemctl --user " .. verb .. " " .. UNIT + if detached then + noctalia.runAsync(command) + return + end + if not noctalia.commandExists("systemctl") then + noctalia.notifyError(TITLE, "systemctl not found; cannot manage " .. UNIT) + if verb ~= "stop" then + releaseOutputs() + end + return + end + noctalia.runAsync(command, function(result) + if result.exitCode == 0 then + return + end + -- A failed start is not yet a failure: on any non-Nix install it just + -- means no unit was ever declared. Try a transient one before handing + -- the outputs back, and stay quiet until that fails too — notifying here + -- would fire on every healthy Arch session. + if verb ~= "stop" then + runTransient(tostring(result.exitCode) .. " " .. tostring(result.stderr)) + return + end + noctalia.notifyError(TITLE, "systemctl " .. verb .. " failed (" + .. tostring(result.exitCode) .. "): " .. tostring(result.stderr)) + end) +end + +-- Publish the chosen SVG where the unit's EnvironmentFile picks it up, and +-- report whether that changed anything. +-- +-- The comparison is what makes a declarative SVG change take effect without a +-- relogin. EnvironmentFile always beats Environment, so once this file exists +-- the unit's declarative `Environment=` is shadowed for good; a rebuild that +-- changes the SVG restarts noctalia and the renderer in unspecified order, and +-- if the renderer goes first it re-reads the *old* file. Only a restart fixes +-- that, and only a restart when the content actually differs keeps a plain +-- noctalia restart from reloading the wallpaper — the property systemd was +-- chosen for over runStream. +-- +-- A missing or unreadable file is not a differing file: on first boot nothing is +-- running yet, and `start` is what is wanted. +local function writeEnv(svg) + local body = "WL_SVG_BG_SVG=" .. svg .. "\n" + local previous = noctalia.readFile(ENV_FILE) + local changed = type(previous) == "string" and previous ~= "" and previous ~= body + + noctalia.mkdirAll(ENV_DIR) + local ok, err = noctalia.writeFile(ENV_FILE, body) + if not ok then + noctalia.notifyError(TITLE, "Cannot write " .. ENV_FILE .. ": " .. tostring(err)) + return false + end + return true, changed +end + +-- Clearing the SVG is the supported off-switch: it releases the outputs and +-- stops the renderer. +local function activate() + local svg = noctalia.getConfig("svg") + if svg == nil or svg == "" then + if next(owned) ~= nil then + releaseOutputs() + systemctl("stop") + else + -- Naming the bundled file matters: almost nobody owns an animated + -- SVG, so "choose one" alone sends a first-time user to an empty + -- file dialog and an uninstall. + noctalia.notifyError(TITLE, "No SVG selected. Choose one in the plugin " + .. "settings — this plugin ships an example, see its README.") + end + return + end + local ok, changed = writeEnv(svg) + if not ok then + return + end + claimOutputs() + systemctl(changed and "restart" or "start") +end + +activate() + +function onEnable() + activate() +end + +function onOutputsChanged() + -- Keyed on the SVG, not on `owned`. An empty `owned` is also what a load with + -- every output asleep looks like — lid shut on an external-only setup, or + -- noctalia restarting mid-reconfiguration — and gating on it there would + -- disarm this hook for the session: the renderer self-heals via its own + -- hotplug resync while noctalia never yields, which is the two-surface + -- overlap this plugin exists to remove. + local svg = noctalia.getConfig("svg") + if svg ~= nil and svg ~= "" then + claimOutputs() + end +end + +function onConfigChanged() + activate() +end + +function onExit(_, reason) + -- "reload" is a script edit and "shutdown" is the session ending; the + -- renderer should survive both. + -- + -- Deliberately no releaseOutputs() here. setWallpaperEnabled queues a side + -- effect, and the host's Stop path discards onExit's side effects entirely + -- (script_runtime.cpp:527-537), so it would be dead code. Disabling the + -- plugin therefore leaves the outputs yielded until noctalia restarts, which + -- clears the runtime-only managed set. Clearing the SVG setting is the clean + -- off-switch. + if reason == "disable" or reason == "uninstall" then + systemctl("stop", true) + end +end diff --git a/wl-svg-bg/thumbnail.webp b/wl-svg-bg/thumbnail.webp new file mode 100644 index 00000000..169e5de4 Binary files /dev/null and b/wl-svg-bg/thumbnail.webp differ diff --git a/wl-svg-bg/translations/en.json b/wl-svg-bg/translations/en.json new file mode 100644 index 00000000..27a50b76 --- /dev/null +++ b/wl-svg-bg/translations/en.json @@ -0,0 +1,8 @@ +{ + "settings": { + "svg": { + "label": "Animated SVG", + "description": "The CSS-animated SVG to render as the wallpaper. An example ships with this plugin at examples/aurora.svg. Clearing this field is the off-switch: it stops the renderer and hands the wallpaper back to noctalia." + } + } +}