Projects / FF00 Video Wallpaper Manager / Technical appendix
0.1.0-alpha.1 / source-grounded design record
Why FF00 Video Wallpaper Manager is built this way
This appendix explains the application from first principles. It is written for someone who can use a desktop but does not yet know X11, and for someone comfortable hand-building a kernel who wants exact boundaries instead of “the framework handles it.”
The compact description is this: FF00-vwm creates one real X11 desktop window
per assigned output, embeds one external mpv process into each window,
remembers the requested assignment in a small TOML file, and records enough
Linux process identity to stop only the renderer processes it created. The GTK
window is a control panel. It is not the wallpaper and does not need to remain
open.
This is not a claim that every choice is universally correct. Each is made inside a declared boundary: Linux x86_64, X11, local files, one desktop user, and an alpha whose primary tested window manager is i3. Trade-offs and gaps are stated rather than hidden behind a feature list.
How to read the code: excerpts preserve the released behavior but may omit imports and repetitive error conversion. The tagged repository is the source of truth.
1. Problem and product boundary
A still-image wallpaper can be painted once. A video wallpaper requires a decoder that continuously presents frames in something the window manager treats as background. With several displays, the X11 desktop is one root coordinate space containing multiple output rectangles. Some rectangles can begin at negative coordinates.
The original manual experiment used xwinwrap, which creates a window and
substitutes its numeric identifier into another command. This explains the
early --wid=WID failure: WID is not an mpv keyword. It is a placeholder that
only some wrapper invocation is expected to replace. If it reaches mpv
literally, mpv correctly says it is not an integer.
FF00-vwm owns the small X11 window-creation part itself. The alpha boundary is deliberately narrow:
included excluded for now
------------------------------- --------------------------------
one local file per XRandR output URLs, downloaders, playlists
fit/fill/stretch/center audio, scheduling, interaction
silent looping playback automatic hot-plug monitoring
manual refresh Wayland compositor protocols
opt-in X11-login restore a media library or custom file manager
owned-process stop broad mpv process managementScope is a correctness tool. Each source type, protocol, and lifecycle rule adds new failure states. The alpha establishes one inspectable path first.
2. Process model
The GUI is not the wallpaper owner. The application re-enters its own hidden
__renderer subcommand once per active output, and each renderer supervises one
mpv child:
ff00-vwm GUI or `ff00-vwm apply`
+-- ff00-vwm __renderer --output DP-1 ...
| +-- mpv --wid=<window-id> ... video-a.mp4
+-- ff00-vwm __renderer --output HDMI-1 ...
+-- mpv --wid=<window-id> ... video-b.mp4Using the same executable avoids a second helper package while preserving a real process boundary. Closing or restarting GTK does not kill healthy wallpapers. One renderer per output costs several processes, but makes ownership, replacement, and failure isolation straightforward. A monolithic GUI would turn a configuration-window crash into an all-screen playback failure.
3. Why X11 first
X11 and Wayland are not interchangeable modes. X11 lets a client create a root-child window, choose root coordinates, and publish window-manager hints. Wayland deliberately prevents arbitrary global placement; wallpaper behavior comes from compositor-specific facilities such as layer-shell protocols.
Pretending one abstraction already covers both would hide who controls placement and layering. The alpha therefore targets X11 honestly. A future Wayland backend must name and test its supported compositor protocols rather than opening a normal XWayland window at the wrong layer.
4. Wallpaper-level windows without xwinwrap
The renderer creates an X11 INPUT_OUTPUT child of the root at the output’s
current rectangle. override_redirect asks the window manager not to manage it
as an ordinary client.
let attributes = CreateWindowAux::new()
.background_pixel(screen.black_pixel)
.border_pixel(screen.black_pixel)
.override_redirect(1)
.event_mask(EventMask::STRUCTURE_NOTIFY);
connection.create_window(
COPY_DEPTH_FROM_PARENT, window, root,
x as i16, y as i16, width as u16, height as u16,
0, WindowClass::INPUT_OUTPUT, COPY_FROM_PARENT, &attributes,
)?;The renderer also sets _NET_WM_WINDOW_TYPE_DESKTOP, sticky,
skip-taskbar, and skip-pager state, maps the window, and requests stacking below
normal windows. These overlapping signals are intentional: override-redirect
gives predictable placement on i3, while EWMH properties explain the role to
cooperating X11 software.
No hint forces identical behavior from every window manager. That is why the project names i3 as primary tested WM rather than claiming every X11 environment. The initial black pixel prevents uninitialized content flashing before mpv produces its first frame.
5. Direct XRandR discovery and signed geometry
The application uses RandR through x11rb; it does not parse xrandr terminal
output. Protocol data is typed, not localized, and preserves signed positions.
let reply = connection.randr_get_monitors(root, true)?.reply()?;
let monitor = Monitor {
name: atom_name(&connection, monitor.name)?,
width: u32::from(monitor.width),
height: u32::from(monitor.height),
x: i32::from(monitor.x),
y: i32::from(monitor.y),
primary: monitor.primary,
};A monitor left of the origin can begin at x = -1920; a monitor above another
can have negative y. Unsigned conversion would silently place the window
elsewhere. Geometry is checked against X11’s 16-bit request fields before it is
cast, so out-of-range values fail rather than wrap.
Assignments use output names such as DP-1. Coordinates are never persisted;
they are refreshed before every apply. This separates intent (“video A belongs
to DP-1”) from observation (“DP-1 currently starts at -1920,0”). A disconnected
assignment is retained and skipped, because undocking should not erase intent.
Output names can change with drivers or docks; EDID-based identity is possible
future work, not silently claimed today.
6. Why mpv is external
The project is not a decoder. mpv already contains mature codec, hardware decode, color, and presentation machinery. Using the executable keeps this native boundary visible and lets distributions deliver codec security updates. Embedding libmpv could reduce process count, but would enlarge the unsafe/native API surface and couple decoder lifecycle to the Rust process.
The renderer passes a concrete X11 number:
mpv --wid=4194305 --loop-file=inf --no-audio ... -- /path/video.mp4One mpv per output uses more memory than custom multi-output compositing, but a broken file on HDMI-1 cannot inherently tear down DP-1. For a handful of desktop outputs, isolation wins over a custom multiplexer.
7. Argument and path safety
User-selected paths never enter a constructed shell command. std::process::Command
passes an argument vector directly to process creation:
Command::new("mpv")
.arg(format!("--wid={window}"))
.args(common_mpv_arguments())
.args(mode_arguments(mode))
.arg("--")
.arg(&video_path)
.spawn()?;Spaces, Unicode, quotes, $(), semicolons, and other shell characters remain
filename content rather than executable syntax. -- ends mpv option parsing,
so a filename beginning with - cannot become an option. The path must resolve
to a regular file before a running renderer is replaced.
This does not make an arbitrary media file harmless; mpv and its codecs still parse it. It means FF00-vwm does not add shell injection on top of that parser risk.
8. Four explicit scaling modes
| Mode | mpv policy | Visible result |
|---|---|---|
| Fit | aspect kept, panscan 0, scaling allowed | Whole frame visible; bars may remain. |
| Fill | aspect kept, panscan 1, scaling allowed | Output covered; overflow cropped. |
| Stretch | aspect not kept, scaling allowed | Exact rectangle; image may distort. |
| Center | aspect kept, panscan 0, unscaled | Native size when possible, centered. |
match mode {
Fit => &["--keepaspect=yes", "--panscan=0", "--video-unscaled=no"],
Fill => &["--keepaspect=yes", "--panscan=1", "--video-unscaled=no"],
Stretch => &["--keepaspect=no", "--video-unscaled=no"],
Center => &["--keepaspect=yes", "--panscan=0", "--video-unscaled=yes"],
}Fill is default because wallpaper usually prioritizes covering the output. The choice stays explicit and reversible. Audio, OSC, and input bindings are disabled because a desktop background must not unexpectedly speak after login or act like an interactive player. Looping is explicit.
9. Detachment, readiness, and supervision
A successful spawn proves only that a process started. The parent waits for a
bounded readiness handshake. The renderer creates its X11 window, starts mpv,
waits 300 ms to catch immediate failure, and writes the window ID to an
output-specific ready file under the user runtime directory. The parent polls
for no more than five seconds.
parent renderer mpv
| spawn process group | |
|-------------------------->| create X11 window |
| | spawn --wid=<id> |
| |------------------------>|
| wait <= 5 seconds | verify still running |
|<--------------------------| write ready file |
| record ownership | supervise child |The file is a rendezvous, not durable state, and is removed after use. A socket could carry richer diagnostics, but the file keeps protocol v1 small and easy to inspect. Detached stdout/stderr means early renderer errors are summarized; structured diagnostic transport is a future improvement.
10. Process ownership and stopping
pkill mpv would be unacceptable: a user may be watching an unrelated video.
A PID alone is also weak because Linux reuses PIDs. Each renderer starts as a
new process-group leader, with its mpv child in the same group. Runtime state
records protocol version, PID, process-group ID, kernel start time, executable,
output, video, mode, and geometry.
Before signaling, three facts must still match:
recorded protocol == current renderer protocol
/proc/<pid>/stat start time == recorded start time
/proc/<pid>/exe == recorded canonical executableOnly then does the app send SIGTERM to the group. It waits up to two seconds.
If the renderer still exists and passes ownership validation again, SIGKILL
is used.
if !record_is_owned(record) { return Ok(()); }
killpg(Pid::from_raw(record.process_group), Signal::SIGTERM)?;
wait_up_to_two_seconds();
if record_is_owned(record) {
killpg(Pid::from_raw(record.process_group), Signal::SIGKILL)?;
}Repeated validation before the hard kill matters because time passed while
waiting. Stale records are pruned instead of trusted. The verified test that an
unrelated mpv --idle survives ff00-vwm stop is therefore a core safety test.
This mechanism relies on Linux /proc and is deliberately Linux-specific.
11. Persistent TOML configuration
User intent lives in the XDG configuration directory:
version = 1
restore_on_login = false
[outputs.DP-1]
video = "/home/user/Videos/ambient loop.mp4"
mode = "fill"TOML is appropriate for small, inspectable state that a person may read or
repair. A BTreeMap gives deterministic output ordering. Schema version 1 is
validated; guessing at an unknown future format could apply the wrong intent.
A legacy pre-public-name path is migrated once so early users are not abandoned.
Writes use a temporary file in the destination directory. Data is written,
sync_all is requested, and the temporary file is atomically persisted. A
crash during serialization should leave the previous complete file instead of
half a document. Coordinates are absent because topology is refreshed from
XRandR.
12. Volatile runtime JSON
Ownership state lives in $XDG_RUNTIME_DIR/ff00-vwm/state.json, not beside
configuration. A runtime directory is per user and normally per login; old PIDs
should not survive reboot as authority. JSON is machine-written but convenient
for diagnosis. It uses the same atomic replacement pattern.
The file is neither a lock nor proof a process exists. Every load is followed by identity revalidation. Its content can reveal local video and output names to other processes already running as the same user; FF00-vwm is not a same-user sandbox.
13. GTK4 as a thin interface
GTK4 supplies native widgets, keyboard behavior, accessibility semantics, a scrolling monitor list, and desktop integration. One card represents each connected output. The crucial rule is what GTK does not do: callbacks do not construct shell commands, parse XRandR prose, signal arbitrary PIDs, or manually edit TOML. They update typed configuration and invoke core functions.
Rc<GuiState> and RefCell fit GTK’s single main-thread callback model. An
async runtime or general state framework would add machinery without solving a
current concurrency problem. Automatic RandR monitoring may justify channels
later.
The Crimsonvariable CSS and locally attributed fonts provide identity without replacing GTK’s actual control semantics with a custom canvas UI.
14. Delegating file browsing
The application uses FileChooserNative instead of implementing a file manager:
let chooser = FileChooserNative::builder()
.title("Choose a video")
.action(FileChooserAction::Open)
.accept_label("Choose")
.cancel_label("Cancel")
.build();
video_filter.add_mime_type("video/*");An All Files fallback exists because MIME databases can mislabel valid media. Choosing a file changes the draft assignment; Apply starts playback. That extra step prevents an accidental chooser click from immediately replacing a screen.
A custom browser would badly duplicate mounts, bookmarks, removable devices, permissions, search, keyboard navigation, and accessibility. Delegation is the correct boundary, not merely less code.
15. Per-output failure isolation
Saved assignments are applied independently. Disconnected outputs are skipped. A missing file or renderer error is collected for its output while the loop continues, and runtime state is saved after each attempt. Apply All can therefore partially succeed and return a combined error. That is preferable to blanking every display, but a future UI should show structured per-output results.
Replacement currently stops the previous renderer before the new renderer is ready. This avoids two desktop windows racing for the same rectangle, but a broken replacement can temporarily leave that output blank. A future transactional swap could verify a new renderer before retiring the old one.
There is also an alpha-level UI mismatch: the Apply button inside one monitor
card calls the shared save_and_apply path, which runs apply_saved across all
saved and currently connected assignments. The status message names the card’s
output, but other configured outputs may also be restarted. The core still
isolates errors per output; the button scope should either become genuinely
single-output or be labeled clearly in a later release.
16. Opt-in login restoration
Installation never enables autostart. The switch writes an XDG autostart entry
that invokes the same binary with apply:
[Desktop Entry]
Type=Application
Name=FF00-vwm
Exec="/absolute/path/to/ff00-vwm" apply
Terminal=false
X-VideoWallpaperManager-Owned=trueThe ownership marker prevents overwrite or deletion of an unrelated file with
the same name. Disable refuses an unmarked file. The executable path is escaped
for desktop-entry Exec syntax, which is not shell syntax. Real login restore
still depends on an XDG-compatible X11 session, so it remains a named alpha
acceptance item rather than a universal guarantee.
17. CLI and error surface
No argument opens GTK. Public subcommands expose the same core:
ff00-vwm apply
ff00-vwm refresh
ff00-vwm status
ff00-vwm stopApply and refresh currently rediscover topology and apply saved intent. Both names preserve user meaning and room for future event handling. Status reports mpv compatibility, configuration, login restoration, connected outputs, assignments, and active renderer records. The hidden renderer subcommand is an internal protocol, not a stable scripting API.
Exit-code families separate dependency, display, configuration, and runtime/renderer failures, so automation need not scrape prose.
18. Dependency policy
Rust dependencies are locked. Wildcard versions, unknown Git sources, and
unapproved licences are denied. cargo audit checks advisories; cargo deny
checks advisory, licence, duplicate-version, and source policy.
mpv below 0.36.0 is rejected. Versions through the highest tested 0.41.0 are reported supported. Newer versions are allowed but labeled “newer than tested.” Rejecting every future version would cause needless breakage; calling it tested would be dishonest.
No runtime updater exists. Acquisition belongs to Nix, Cargo during a source build, or the distribution package manager—not a wallpaper process.
19. Nix and native packaging
The locked Nix flake is the reference path. It builds Rust, installs the desktop
entry and icon, and wraps the result so tested mpv is on PATH. This pins the
environment without copying mpv into the source tree.
The x86_64 native archive uses Debian 12 as its baseline and installs to a
user-controlled prefix, usually ~/.local. It does not bundle mpv, GTK, or X11
libraries. Those projects have separate security lifecycles; bundling them
would make a small alpha responsible for stale codec and ABI updates. The trade
is explicit: native installation is conventional but less portable; Nix is the
recommended reproducible route.
Release tar creation sorts names, uses the commit timestamp, numeric ownership, and timestamp-free gzip output. SHA-256 detects corruption or substitution when the checksum comes from a trusted channel; it is not an identity signature.
20. Installer and uninstaller safety
The installer accepts an absolute prefix and refuses /, the home directory
itself, and newline-bearing paths. Every archive member is checked before copies
begin. Missing mpv produces a warning rather than invoking a guessed privileged
package manager.
The uninstaller reads an exact installation manifest. Every path must be beneath the resolved prefix before deletion. Only recorded files are removed; empty parent directories are then attempted. User configuration and selected videos are not manifest members and are not deleted.
21. Privacy and threat model
There is no telemetry, account, catalogue, downloader, or network service. The
app reads selected files, X11 display state, its XDG configuration, its own
runtime metadata, and limited /proc identity for processes it created.
X11 itself is not a strong isolation boundary: clients in one session can often observe or interfere with one another. FF00-vwm does not claim to repair that. Its narrower promises are meaningful: no shell-evaluated path, no broad process kill, no overwrite of unowned autostart files, and no hidden download.
22. Licence and contribution boundary
The public stream uses AGPL-3.0-or-later. Dependency notices and licence
policy ship with source and artifacts. The project is independent of mpv, GTK,
Rust, X.Org, i3, NixOS, and Linux distributions.
Contributions require a separately executed copyright-assignment agreement. That preserves centralized dual-licensing authority while the public stream is guaranteed to remain within its stated copyleft boundary. A contributor may accept or reject that trade; publishing it before work is submitted is the transparent requirement. This explanation is not legal advice; the repository’s operative documents control.
23. Evidence, tests, and honest gaps
The alpha passed formatting, strict Clippy, 17 Rust tests, release builds, RustSec, cargo-deny policy, Nix flake checks, ShellCheck, actionlint, XML and privacy scans, clean Debian installation, exact uninstall, and archive checksum verification. A manual isolation test proved unrelated mpv survives Stop.
Unit tests cover signed geometry, TOML round-trips and schema rejection, mpv
version parsing, mode arguments, runtime filename sanitization, /proc start
time, invalid video rejection, runtime-state round-trips, and autostart quoting.
The public checklist deliberately retained incomplete manual evidence at publication: a separately recorded single-monitor case, every scaling mode’s visual review, close/reopen persistence, disconnected-output behavior, a fresh real-login restoration test, and the complete presentation of missing-file and missing-dependency errors. Unchecked means evidence was incomplete, not that a feature is known broken. That distinction is why the version is alpha.
24. Rejected alternatives
Keep xwinwrap: rejected as a mandatory layer because forks and placeholder syntax vary, while the required X11 window behavior is small and testable.
Use only a shell script: useful as a prototype, but durable quoting, per-output partial failure, process identity, persistence, and UI errors become a fragile second program encoded in shell conventions.
Let the GUI own playback: rejected because closing configuration would stop wallpaper and a GUI crash would become an all-screen event.
Kill mpv by name: rejected because a program name is not ownership.
Parse xrandr: rejected because human output is not a stable protocol and
direct RandR already supplies typed signed geometry.
Build a file manager: rejected as duplication of complex desktop behavior unrelated to wallpaper management.
Bundle everything: rejected in the native archive because it transfers codec and GUI-library maintenance to the alpha; Nix provides a pinned route.
Call XWayland a Wayland backend: rejected because a normal compatibility window is not a compositor wallpaper protocol and recreates the wrong layer.
25. What should change next
The best next work is closing the manual acceptance matrix, adding structured per-output diagnostics, and making replacement transactional. RandR event monitoring can then improve dock behavior without changing saved intent.
Wayland should arrive only as an explicit backend with named compositor tests. Shared concepts—assignment, scaling intent, persistence, and owned renderer lifecycle—can remain, but placement must be protocol-native. Other valuable work includes EDID-assisted identity with privacy analysis, machine-readable status, keyboard/accessibility review, and signed release provenance.
Network media, downloaders, audio, playlists, and scheduling should remain separate product decisions rather than accumulating merely because mpv supports them.
Closing principle
read current RandR outputs
-> combine topology with saved user intent
-> create one desktop-level X11 window per assignment
-> start one silent looping mpv child with a concrete window id
-> wait for bounded readiness
-> record validated Linux process identity
-> stop only that owned process group when askedThere is no cloud service, privileged daemon, shell-evaluated media path, or hidden catalogue. The remaining complexity is real desktop complexity: coordinates, WM conventions, PID reuse, codec lifecycles, session startup, and packaging. Trust improves when these mechanisms remain visible.