Shipping met reality tonight, and reality won three rounds before we did. mpvpaper's -f forks it into the background, which a supervisor reads as instant death — the respawn loop repainted the desktop black once a second. The reaper's own kills fired onExited like crashes, so the supervisor ate its young until deliberate deaths got marked. And the video's path shared wallpaperPath with the still pipeline, whose transactional persistence clobbered it — it now lives under its own videoWallpaperPath key, read by the lock screen and the doctor too. Restore is the service's own now, reactive and once per session: a one-shot timer raced the async preference load and availability probe at cold start and silently lost. A video-wallpaper IPC target drives start/stop/pause from a terminal and from the contract sweep to come. Verified live: one player per output, same PID across restarts, VAAPI engaged, 0.2% CPU steady for 1440p30 h264, pause from the bar pill. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
230 lines
12 KiB
Bash
Executable File
230 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Video wallpapers.
|
|
#
|
|
# A looping video behind every window is the single most expensive thing a
|
|
# desktop can draw, and none of the cost is visible: Hyprland rebuilds the
|
|
# full-screen blur chain on every wallpaper frame while any blur-enabled layer
|
|
# exists, and an occluded wallpaper keeps compositing unless solitary mode
|
|
# holds -- which one toast breaks. So Panama owns the pause policy rather than
|
|
# trusting the compositor with it, and this pins that policy in place.
|
|
#
|
|
# What must hold:
|
|
#
|
|
# 1. The video pauses for a game, on battery when asked to, and on demand
|
|
# from the bar. Each reason is independent; the video plays only when
|
|
# none of them holds.
|
|
# 2. mpvpaper is told to decode on the GPU, stay muted, loop, and open an
|
|
# IPC socket. A silent software-decode fallback would burn a core
|
|
# forever with nothing on screen to explain it.
|
|
# 3. hyprpaper's service is stopped while a video plays and started again
|
|
# afterwards. Both claim the background layer and stacking within a layer
|
|
# is creation order -- a race with no winner worth having.
|
|
# 4. The picker cannot be flooded, and cannot be handed a file mpvpaper will
|
|
# not play.
|
|
# 5. The lock screen gets a still frame, because hyprlock cannot play motion.
|
|
#
|
|
# Static only: starting a real mpvpaper would take over the live desktop's
|
|
# background.
|
|
|
|
set -euo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
shell_dir="$repo_dir/config/dot/quickshell"
|
|
service="$shell_dir/services/VideoWallpaper.qml"
|
|
wallpaper="$shell_dir/services/Wallpaper.qml"
|
|
helper="$shell_dir/scripts/panama-video-wallpaper"
|
|
indicator="$shell_dir/modules/bar/WallpaperIndicator.qml"
|
|
bar="$shell_dir/modules/bar/Bar.qml"
|
|
picker="$shell_dir/modules/settings/WallpaperPicker.qml"
|
|
appearance="$shell_dir/modules/settings/AppearancePage.qml"
|
|
schema="$shell_dir/config/PreferenceSchema.qml"
|
|
lock="$shell_dir/scripts/panama-lock"
|
|
packages="$repo_dir/setup/packages/hyprland-packages"
|
|
|
|
fail() {
|
|
printf 'video wallpaper contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
for file in "$service" "$wallpaper" "$helper" "$indicator" "$bar" "$picker" \
|
|
"$appearance" "$schema" "$lock" "$packages"; do
|
|
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
|
done
|
|
[[ -x "$helper" ]] || fail 'panama-video-wallpaper is not executable'
|
|
bash -n "$helper" || fail 'panama-video-wallpaper does not parse'
|
|
|
|
# ── 1. Three independent pause reasons ──────────────────────────────────────
|
|
python3 - "$service" <<'PY' || fail 'the pause policy drifted'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
stripped = re.sub(r"//.*", "", text)
|
|
|
|
reasons = {
|
|
"gamePaused": "FocusModes.gameRunning",
|
|
"batteryPaused": 'DesktopPreferences.get("videoWallpaperPauseOnBattery")',
|
|
}
|
|
for prop, source in reasons.items():
|
|
block = re.search(r'property bool ' + prop + r':(.*?)\n\s*(readonly )?property', stripped, re.S)
|
|
if not block or source not in block.group(1):
|
|
raise SystemExit(f"{prop} no longer follows {source}")
|
|
|
|
battery = re.search(r'property bool batteryPaused:(.*?)\n\s*(readonly )?property', stripped, re.S)
|
|
for needle in ("Battery.available", "!Battery.acOnline"):
|
|
if needle not in battery.group(1):
|
|
raise SystemExit(f"the battery pause reason no longer checks {needle}")
|
|
|
|
if not re.search(r'property bool manuallyPaused: false', stripped):
|
|
raise SystemExit("the bar pill's manual pause is gone")
|
|
|
|
combined = re.search(r'property bool paused:(.*?)\n', stripped)
|
|
if not combined:
|
|
raise SystemExit("there is no combined paused state")
|
|
for reason in ("root.manuallyPaused", "root.gamePaused", "root.batteryPaused"):
|
|
if reason not in combined.group(1):
|
|
raise SystemExit(f"the combined paused state ignores {reason}")
|
|
|
|
# The pause reaches mpv over its JSON IPC socket rather than by killing and
|
|
# respawning the player, which would restart the video from the first frame.
|
|
if 'JSON.stringify({ command: ["set_property", "pause", root.paused] })' not in stripped:
|
|
raise SystemExit("pausing no longer goes over mpv's JSON IPC")
|
|
if 'onPausedChanged: pauseSync.restart()' not in stripped:
|
|
raise SystemExit("a change of pause state does not push to the player")
|
|
PY
|
|
|
|
# ── 2. What mpvpaper is actually told ───────────────────────────────────────
|
|
for option in 'hwdec=vaapi' 'no-audio' 'loop-file=inf' 'input-ipc-server='; do
|
|
rg -Fq "$option" "$service" || fail "the mpvpaper invocation is missing $option"
|
|
done
|
|
rg -Fq '"mpvpaper", "-o",' "$service" \
|
|
|| fail 'the mpvpaper invocation is no longer a fixed argv'
|
|
# -f is banned outright: it forks mpvpaper into the background, which reads to
|
|
# the supervisor as an instant death — the respawn loop it caused flashed the
|
|
# desktop black once a second until the flag was removed.
|
|
if rg -Fq '"mpvpaper", "-f"' "$service"; then
|
|
fail 'mpvpaper is launched with -f again; the supervisor needs it foreground'
|
|
fi
|
|
# Restore is the service's own, reactive and once per session — a one-shot
|
|
# timer raced the async preference load and the availability probe at cold
|
|
# start and silently lost.
|
|
for needle in 'function tryRestore' 'restoreConsumed' 'videoWallpaperPath'; do
|
|
rg -Fq "$needle" "$service" || fail "startup restore lost its $needle"
|
|
done
|
|
rg -Fq 'command -v mpvpaper' "$service" \
|
|
|| fail 'the shell never checks whether mpvpaper is installed'
|
|
rg -Fq 'mpvpaper' "$packages" \
|
|
|| fail 'mpvpaper is not in the package list, so a fresh machine has no player'
|
|
|
|
# A dead player while a video is meant to be active is a crash -- mpvpaper has
|
|
# a known hotplug segfault -- so the whole set respawns rather than being
|
|
# reasoned about per output.
|
|
rg -Fq 'playerRespawn.restart();' "$service" \
|
|
|| fail 'a crashed player is not respawned'
|
|
rg -Fq 'onOutputsChanged: if (root.active) playerRespawn.restart()' "$service" \
|
|
|| fail 'a display hotplug does not respawn the players'
|
|
|
|
# ── 3. The hyprpaper handover ───────────────────────────────────────────────
|
|
rg -Fq '["systemctl", "--user", "stop", "hyprpaper.service"]' "$service" \
|
|
|| fail 'starting a video does not stop hyprpaper, so the two race for the layer'
|
|
rg -Fq '["systemctl", "--user", "start", "hyprpaper.service"]' "$service" \
|
|
|| fail 'stopping a video does not bring hyprpaper back'
|
|
rg -Fq 'Wallpaper.refreshActive()' "$service" \
|
|
|| fail 'the still wallpaper is not reapplied once hyprpaper returns'
|
|
|
|
# Wallpaper.qml routes a video away from the hyprpaper transaction, which would
|
|
# only fail validation, and gives hyprpaper a beat to come back on the way out.
|
|
for needle in 'VideoWallpaper.isVideo(path)' 'VideoWallpaper.isVideo(root.configured)' \
|
|
'pendingStillPolicy' 'VideoWallpaper.start(' 'VideoWallpaper.stop()'; do
|
|
rg -Fq "$needle" "$wallpaper" || fail "Wallpaper.qml lost its video routing: $needle"
|
|
done
|
|
|
|
# ── 4. Discovery is bounded and typed ───────────────────────────────────────
|
|
rg -Fq 'NR <= 60' "$helper" \
|
|
|| fail 'the video scan is unbounded, so a dumping-ground folder floods the picker'
|
|
rg -Fq -- '-maxdepth 2' "$helper" \
|
|
|| fail 'the video scan is no longer bounded in depth'
|
|
rg -Fq "\\( -iname '*.mp4' -o -iname '*.mkv' -o -iname '*.webm' \\)" "$helper" \
|
|
|| fail 'the video scan accepts extensions mpvpaper may not play'
|
|
rg -Fq '/\.(mp4|mkv|webm)$/i' "$service" \
|
|
|| fail 'the shell and the helper disagree about what counts as a video'
|
|
rg -Fq 'VideoWallpaper.isVideo(' "$picker" \
|
|
|| fail 'the picker does not distinguish video tiles from image tiles'
|
|
|
|
# ── 5. The lock screen gets a still ─────────────────────────────────────────
|
|
rg -Fq 'video-wallpaper-frame.png' "$service" \
|
|
|| fail 'no still frame is cached for the lock screen'
|
|
rg -Fq 'video-wallpaper-frame.png' "$lock" \
|
|
|| fail 'the lock screen does not use the cached still frame'
|
|
rg -Fq 'ffmpeg -hide_banner' "$helper" \
|
|
|| fail 'the frame grab no longer runs ffmpeg quietly'
|
|
rg -Fq '\.(mp4|mkv|webm)$' "$lock" \
|
|
|| fail 'panama-lock does not recognise a video wallpaper'
|
|
|
|
# ── The bar pill ────────────────────────────────────────────────────────────
|
|
rg -Fq 'visible: VideoWallpaper.active' "$indicator" \
|
|
|| fail 'the bar pill is not conditional on a video actually playing'
|
|
rg -Fq 'onActivated: VideoWallpaper.togglePause()' "$indicator" \
|
|
|| fail 'clicking the bar pill does not pause the video'
|
|
rg -Fq 'Accessible.name: VideoWallpaper.paused' "$indicator" \
|
|
|| fail 'the bar pill has no spoken name, so it is a glyph and nothing else'
|
|
rg -Fq 'Paused for game' "$indicator" \
|
|
|| fail 'the pill does not say why the video paused itself'
|
|
python3 - "$bar" <<'PY' || fail 'the wallpaper pill is not in the bar'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
if 'WallpaperIndicator {' not in text:
|
|
raise SystemExit("Bar.qml never instantiates WallpaperIndicator")
|
|
# It belongs in the right-hand status row, beside the other conditional pills.
|
|
row = re.search(r'// ── Right ─+\n\s*Row \{(.*?)\n \}', text, re.S)
|
|
if not row or 'WallpaperIndicator {' not in row.group(1):
|
|
raise SystemExit("WallpaperIndicator is not in the bar's right-hand row")
|
|
PY
|
|
if rg -n 'NumberAnimation|ColorAnimation|SequentialAnimation|ParallelAnimation|loops:[[:space:]]*Animation\.Infinite' \
|
|
"$indicator"; then
|
|
fail 'the wallpaper pill animates continuously beside a video that already costs frames'
|
|
fi
|
|
|
|
# ── Schema and settings surface ─────────────────────────────────────────────
|
|
python3 - "$schema" <<'PY' || fail 'the video wallpaper schema entries are missing or malformed'
|
|
import re
|
|
import sys
|
|
|
|
text = open(sys.argv[1], encoding="utf-8").read()
|
|
expected = {
|
|
"videoWallpaperDir": ("string", '"Videos/Wallpapers"', "wallpaper"),
|
|
"videoWallpaperPauseOnBattery": ("bool", "true", "wallpaper"),
|
|
}
|
|
for key, (kind, default, group) in expected.items():
|
|
block = re.search(r"\{\s*\n\s*key:\s*\"" + key + r"\".*?\n\s{8}\}", text, re.S)
|
|
if not block:
|
|
raise SystemExit(f"missing {key}")
|
|
body = block.group(0)
|
|
if not re.search(rf'type:\s*"{kind}"', body):
|
|
raise SystemExit(f"{key} has wrong type")
|
|
if not re.search(rf'def:\s*{re.escape(default)}', body):
|
|
raise SystemExit(f"{key} has wrong default")
|
|
if not re.search(rf'group:\s*"{group}"', body):
|
|
raise SystemExit(f"{key} has wrong group")
|
|
if 'internal: true' in body:
|
|
raise SystemExit(f"{key} is marked internal, so nobody can find it")
|
|
PY
|
|
|
|
for needle in 'title: "Video playback"' 'setting: "videoWallpaperDir"' \
|
|
'setting: "videoWallpaperPauseOnBattery"' 'VideoWallpaper.rescan()'; do
|
|
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
|
|
done
|
|
# Honest about what it cannot do, rather than silently showing a black lock
|
|
# screen when the wallpaper is a video.
|
|
rg -Fq 'Still frame' "$appearance" \
|
|
|| fail 'the settings page does not say the lock screen uses a still frame'
|
|
|
|
# ── The doctor knows about it ───────────────────────────────────────────────
|
|
rg -Fq 'input.video-wallpaper' "$shell_dir/scripts/panama-doctor" \
|
|
|| fail 'panama-doctor has no video wallpaper check'
|
|
|
|
printf 'video wallpaper contract: PASS\n'
|