From 42930dc7f902e2a5526b876b616a74551404435a Mon Sep 17 00:00:00 2001 From: sramkrishna Date: Fri, 25 Sep 2026 05:44:24 +0000 Subject: [PATCH 1/6] feat(thumbnailer): add opt-in ujust toggle for the ffmpeg thumbnailer daemon The rootless ffmpeg video thumbnailer ships enabled for every graphical session through its user preset. That is the wrong default for constrained systems, metered connections (the container image is 111 MB), and hosts that already have a working hardware thumbnailer. Add two recipes to shared.just: - toggle-ffmpeg-thumbnailer: `enable` / `disable` / `cancel` ACTION plus an interactive gum prompt. Disable stops the units and then masks all three (daemon and both container quadlets); enable unmasks, reloads, and starts them again. - status-ffmpeg-thumbnailer: read-only report of unit state, container, daemon socket, and recent journal activity. Mask, not disable. The daemon is pulled in by a static graphical-session.target.wants symlink shipped in /usr/lib, which `systemctl --user disable` cannot remove, and the container units are quadlet-generated so they carry no install state for systemctl to edit. Both recipes probe for the daemon unit before touching anything, so images that do not ship the thumbnailer (Aurora) report "not installed" instead of masking units that do not exist. Tests: 28 new bats cases in tests/test_shared_just.bats stub systemctl, podman, journalctl, and gum, and run the extracted recipe bodies so the control flow is exercised rather than grepped. tests/test_shared_just.bats is now wired into unit-tests.yml, which never ran it before. Docs: new docs/skills/systemd-user-units.md records the mask-vs-disable rule for image-shipped systemd user units and quadlets; shell-scripts/references/bats-patterns.md gains the name-anchored recipe extraction pattern and the `{{ }}` interpolation gotcha. Hive-Run: projectbluefin/common#1229 Hive-Plan: ujust toggle for ffmpeg thumbnailer daemon (no plan artifact supplied) Hive-Spec: projectbluefin/common#1229 (no spec artifact supplied) Co-Authored-By: Claude Code Assisted-by: Claude Code (deepseek-v4.1-flash) via litellm Signed-off-by: sramkrishna --- docs/SKILL.md | 1 + docs/skills/index.json | 21 +- docs/skills/index.md | 3 +- .../shell-scripts/references/bats-patterns.md | 64 +++ docs/skills/systemd-user-units.md | 150 +++++++ .../usr/share/ublue-os/just/shared.just | 126 ++++++ tests/test_shared_just.bats | 382 +++++++++++++++++- 7 files changed, 738 insertions(+), 9 deletions(-) create mode 100644 docs/skills/systemd-user-units.md diff --git a/docs/SKILL.md b/docs/SKILL.md index 94ab41e45..e4eaf3b93 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -39,6 +39,7 @@ repository's local catalog with `docs/skills/hive.md` as the preflight guard. | Change a GNOME setting or dconf key | [`dconf-consistency.md`](skills/dconf-consistency.md) | | Work on Bazaar config or hooks | [`bazaar.md`](skills/bazaar.md) | | Edit `system_files/shared/` or `bluefin/` | [`submodule-boundary.md`](skills/submodule-boundary.md) | +| Add a systemd user unit or quadlet, or write a ujust toggle for one | [`systemd-user-units.md`](skills/systemd-user-units.md) | | Touch any image reference or registry path | [`image-registry.md`](skills/image-registry.md) | | Add an image variant or write `image-info.json` | [`image-identity.md`](skills/image-identity.md) | | Modify the `Containerfile` or add a binary | [`containerfile/SKILL.md`](skills/containerfile/SKILL.md) | diff --git a/docs/skills/index.json b/docs/skills/index.json index 7023f9428..a71b6c2eb 100644 --- a/docs/skills/index.json +++ b/docs/skills/index.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-09-26", + "generated_at": "2026-09-27", "schema_version": "1.0", "skills": [ { @@ -762,6 +762,25 @@ "last_updated": "2026-06-23", "doc_type": "reference" }, + { + "id": "systemd-user-units", + "name": "systemd-user-units", + "one_line_purpose": "Ship systemd user units and rootless quadlets from common, and toggle them per user.", + "entry_point": "docs/skills/systemd-user-units.md", + "category": "platform", + "status": "active", + "tags": [ + "systemd", + "quadlet", + "user-units", + "ujust", + "system_files" + ], + "description": "systemd user services, presets, and rootless quadlet containers shipped by common. Use when adding or editing a unit under system_files/shared/, writing a ujust enable/disable toggle for one, or debugging why a unit starts anyway.", + "version": "1.0", + "last_updated": "2026-09-25", + "doc_type": "reference" + }, { "id": "workflow-map", "name": "workflow-map", diff --git a/docs/skills/index.md b/docs/skills/index.md index a8077d069..c97bcf368 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -3,7 +3,7 @@ This file is a human-readable mirror of `index.json`. Both are generated by `scripts/generate_skill_index.py` — do not hand-edit either file. -Generated: 2026-09-26 · schema 1.0 · 45 skills +Generated: 2026-09-27 · schema 1.0 · 46 skills | id | category | status | one-line purpose | |---|---|---|---| @@ -50,5 +50,6 @@ Generated: 2026-09-26 · schema 1.0 · 45 skills | [skill-drift](skill-drift.md) | meta | deprecated | Record why the skill-drift CI check was retired and what replaced it. | | [skill-improvement](skill-improvement.md) | meta | active | Capture durable agent learnings in maintained skill docs. | | [submodule-boundary](submodule-boundary.md) | meta | active | Decide where a system_files change belongs across variants. | +| [systemd-user-units](systemd-user-units.md) | platform | active | Ship systemd user units and rootless quadlets from common, and toggle them per user. | | [workflow-map](workflow-map.md) | ci-ops | active | Understand what each GitHub workflow in common does. | | [write-a-skill](write-a-skill.md) | meta | active | Author a new skill doc following front-matter and size rules. | diff --git a/docs/skills/shell-scripts/references/bats-patterns.md b/docs/skills/shell-scripts/references/bats-patterns.md index e1cf08ec5..b9ca5ed55 100644 --- a/docs/skills/shell-scripts/references/bats-patterns.md +++ b/docs/skills/shell-scripts/references/bats-patterns.md @@ -70,6 +70,70 @@ _extract_script() { } ``` +### Extract by recipe name, not just the first shebang + +A file with several recipes needs the name-anchored form. Match the recipe +header as a regex so parameterised recipes are covered too, and stop at the +first line that returns to column zero: + +```bash +_extract_recipe() { + awk -v name="$1" ' + $0 ~ ("^" name "([[:space:]].*)?:$") { in_recipe = 1; next } + in_recipe && $0 ~ /^[^[:space:]]/ { exit } + in_recipe && $0 ~ /^ / { sub(/^ /, ""); print } + ' "${JUSTFILE}" +} +``` + +`tests/test_shared_just.bats` and `tests/test_update_just.bats` both use this +shape; the trailing `([[:space:]].*)?` is what makes +`toggle-ffmpeg-thumbnailer ACTION="prompt":` extractable. + +### Substitute `{{ ... }}` before running the body + +The extracted text is not yet valid bash — `just` replaces `{{ NAME }}` before +the shell runs, so a test that executes the raw body runs a literal +`{{ NAME }}` and fails in a way that looks like a recipe bug. Pipe the +extraction through `sed`, mirroring the recipe's own default: + +```bash +_extract_toggle_recipe() { + _extract_recipe toggle-ffmpeg-thumbnailer \ + | sed 's/{{ ACTION }}/${ACTION:-prompt}/g' +} +``` + +### Never write `{{` in a recipe body + +`just` interpolates recipe bodies, shebang recipes included, so a literal +`{{` aborts parsing of the whole file. This bites when a recipe shells out to +something that takes its own templating syntax: + +``` +error: unknown start of token '.' + ——▶ util.just:3:22 + │ podman ps --format '{{.Status}}' +``` + +Avoid the argument (drop `--format`, or use JSON plus `jq`), or escape the +opening braces as `{{{{` — `{{{{.Status}}` reaches the shell as `{{.Status}}`. +The closing braces need no escaping. + +### Assert the full command line + +Stub the binary to append `"$*"` to a call log, then assert the whole logical +call with a fixed-string line match rather than a substring, so a test cannot +pass on a partial match: + +```bash +run grep -Fqx "systemctl --user mask ffmpeg-thumbnailer-daemon.service" <<< "$(_calls)" +[ "${status}" -eq 0 ] +``` + +For ordering guarantees (stop before mask, prompt before mutation), compare +`grep -n` line numbers from the same log. + Then run: `bash "${extracted_script}"` with mocked PATH binaries. ## Pitfall: literal `*` in bats grep assertions diff --git a/docs/skills/systemd-user-units.md b/docs/skills/systemd-user-units.md new file mode 100644 index 000000000..810270937 --- /dev/null +++ b/docs/skills/systemd-user-units.md @@ -0,0 +1,150 @@ +--- +name: systemd-user-units +version: "1.0" +last_updated: "2026-09-25" +id: systemd-user-units +one_line_purpose: Ship systemd user units and rootless quadlets from common, and toggle them per user. +entry_point: docs/skills/systemd-user-units.md +category: platform +mcp_compliance_level: partial +optimization_status: draft +status: active +dependencies: [] +tags: [systemd, quadlet, user-units, ujust, system_files] +description: >- + systemd user services, presets, and rootless quadlet containers shipped by + common. Use when adding or editing a unit under system_files/shared/, writing + a ujust enable/disable toggle for one, or debugging why a unit starts anyway. +metadata: + type: reference +--- + +# systemd user units and quadlets — projectbluefin/common + +Covers the units this repo ships in `system_files/shared/`, how they get +enabled on the image, and the only reliable per-user way to switch them off. + +## When to Use + +- Adding or editing anything under `system_files/shared/usr/lib/systemd/user/`, + `usr/lib/systemd/user-preset/`, or `usr/share/containers/systemd/users/` +- Writing a `ujust` recipe that enables, disables, or reports on such a unit + (see `toggle-ffmpeg-thumbnailer` / `status-ffmpeg-thumbnailer` in + [`shared.just`](../../system_files/shared/usr/share/ublue-os/just/shared.just)) +- A user reports a unit still starting after they disabled it + +## Where units live + +| Path | What it is | +|---|---| +| `usr/lib/systemd/user/*.service` | Static user units. Image content, read-only at runtime. | +| `usr/lib/systemd/user-preset/*.preset` | `enable`/`disable` defaults applied when the unit is first seen (image install / first user creation). | +| `usr/lib/systemd/user/.wants/` | Static pull-in symlink. Part of the image, not of the enable state. | +| `usr/share/containers/systemd/users/*.container` | Rootless quadlets. The quadlet generator turns `foo.container` into `foo.service` at user-manager start. | + +Quadlet naming is the one thing to get right when writing `After=` lines or a +toggle: the generated unit is `.service`, so +`ffmpeg-thumbnailer-nvidia.container` is `ffmpeg-thumbnailer-nvidia.service`, +regardless of the `ContainerName=` inside the file. Do not assume a `.container` +file can be masked by its own name. + +## Enabling: presets, not `systemctl enable` + +Downstream image repos do **not** run `systemctl --global enable`. Units are +enabled by shipping a preset line, e.g. +`usr/lib/systemd/user-preset/01-ffmpeg-thumbnailer.preset`: + +``` +enable ffmpeg-thumbnailer-daemon.service +``` + +Two consequences for anything you write in `common`: + +- A unit that must start for every user needs either the preset **or** a static + `wants/` symlink in the image — a `WantedBy=` line alone is inert until + something enables the unit. +- Because enablement is image state, a per-user toggle cannot undo it with + `systemctl --user disable`. See below. + +## Disabling per user: mask, not disable + +`systemctl --user disable ` only removes symlinks the enable verb created. +It does **not** touch a static `usr/lib/systemd/user/.wants/` +symlink, and for quadlet-generated units it has nothing to act on at all — the +generated `.service` has no install state systemctl can edit, so only the +generator decides whether it exists and what pulls it in. + +`systemctl --user mask ` writes a symlink to `/dev/null` into the user's +own unit directory (no `sudo`) and outranks the image and generator locations, +so systemd refuses to load the unit no matter what pulls it in. That is why the +thumbnailer toggle masks all three units — the daemon *and* both container +quadlets — instead of stopping them: + +```bash +systemctl --user stop ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service +systemctl --user mask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service +# later, to restore image behaviour: +systemctl --user unmask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service +``` + +Re-enabling also needs `systemctl --user daemon-reload`, and starting the +container quadlets explicitly if thumbnails must work before the next login. +A mask is durable user state: it survives image updates until `unmask` (or +deleting the symlink) removes it. + +Stop before mask, in that order: the daemon unlinks its socket on `SIGTERM`, so +masking a live unit can leave a stale socket path behind. + +## Writing the toggle recipe + +Recipe bodies are bash, so test them by extracting the body and stubbing +`systemctl` on `PATH` (see +[`shell-scripts/references/bats-patterns.md`](shell-scripts/references/bats-patterns.md)). +Two things to carry into the recipe: + +- Resolve every unit through one array so stop/mask/start cannot drift apart, + and probe for the unit (`systemctl --user cat `) before mutating + anything — `shared.just` is also consumed by images that do not ship the + thumbnailer, where the recipe must report "not installed" instead of + masking a unit that does not exist. +- Never write `{{` in the body. `just` interpolates it before bash ever sees + the script, and a `podman ps --format '{{.Status}}'` style argument fails to + parse the entire recipe. Write the literal as `{{{{` if one is unavoidable. + +## Red Flags + +- A toggle that calls `systemctl --user disable` on a unit shipped in + `usr/lib/systemd/user/` — the static `wants/` symlink keeps starting it +- Masking a quadlet's `.container` filename instead of the generated `.service` +- Assuming `systemctl --user cat .service` fails merely because the + file is in `usr/share/containers/systemd/` — the generator makes the unit + loadable at user-manager start, and `cat` is the right existence probe +- A recipe body containing `{{ ... }}` +- Documenting enablement as `systemctl --global enable`; presets own it + +## Verification + +Re-derive each fact on a live system rather than trusting this page: + +```bash +# Real unit search path and its order for the user manager: +systemd-analyze --user unit-paths + +# Enable state: "masked" is the state this skill's toggles produce. +systemctl --user is-enabled ffmpeg-thumbnailer-daemon.service + +# Where a mask landed, and what it shadows: +ls -l "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/" + +# What actually pulls a unit in (preset, static wants, or generator): +systemctl --user show -p LoadState -p UnitFileState -p WantedBy -p RequiredBy \ + ffmpeg-thumbnailer-daemon.service +grep -rn "ffmpeg-thumbnailer" system_files/shared/usr/lib/systemd/ \ + system_files/shared/usr/share/containers/systemd/ +``` + +## See Also + +- [`shell-scripts/SKILL.md`](shell-scripts/SKILL.md) — testing recipe bodies and shell scripts +- [`submodule-boundary.md`](submodule-boundary.md) — which `system_files/` tree a unit belongs in +- [`dconf-consistency.md`](dconf-consistency.md) — the same override-vs-lock pattern for desktop settings diff --git a/system_files/shared/usr/share/ublue-os/just/shared.just b/system_files/shared/usr/share/ublue-os/just/shared.just index 24b462e74..c848f77e8 100644 --- a/system_files/shared/usr/share/ublue-os/just/shared.just +++ b/system_files/shared/usr/share/ublue-os/just/shared.just @@ -21,3 +21,129 @@ powerwash: exit 0 fi sudo bootc install reset --experimental + +# Toggle the rootless ffmpeg video thumbnailer (container quadlets + host daemon) +# Pass `enable`, `disable`, or `cancel` (case-insensitive) as ACTION, or run +# without arguments for the interactive prompt. +[group('System')] +toggle-ffmpeg-thumbnailer ACTION="prompt": + #!/usr/bin/bash + ACTION_VALUE="{{ ACTION }}" + + DAEMON_UNIT="ffmpeg-thumbnailer-daemon.service" + # Order matters: the daemon is stopped first so it unlinks its socket. + THUMBNAILER_UNITS=( + ffmpeg-thumbnailer-daemon.service + ffmpeg-thumbnailer.service + ffmpeg-thumbnailer-nvidia.service + ) + + if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1; then + echo "The ffmpeg video thumbnailer is not installed on this system." + exit 0 + fi + + _is_masked() { + [[ "$(systemctl --user is-enabled "$1" 2>/dev/null)" == "masked" ]] + } + + case "${ACTION_VALUE,,}" in + enable) + SELECTED_OPTION="Enable" + ;; + disable) + SELECTED_OPTION="Disable" + ;; + cancel) + exit 0 + ;; + *) + CURRENT_STATE="enabled" + if _is_masked "${DAEMON_UNIT}"; then + CURRENT_STATE="disabled" + fi + echo "The ffmpeg video thumbnailer is currently ${CURRENT_STATE}" + SELECTED_OPTION="$(gum choose --header="Toggle the ffmpeg video thumbnailer?" "Enable" "Disable" "Cancel")" + if [[ "${SELECTED_OPTION}" != "Enable" && "${SELECTED_OPTION}" != "Disable" ]]; then + echo "No changes made." + exit 0 + fi + ;; + esac + + if [[ "${SELECTED_OPTION}" == "Disable" ]]; then + systemctl --user stop "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true + # Mask, not disable: the daemon is also pulled in by a static + # graphical-session.target.wants symlink shipped in /usr/lib, which + # `disable` cannot remove. Only a mask overrides it. + # The per-user mask also blocks the container quadlets, so the 111 MB + # image is never pulled again while the toggle is off. + if systemctl --user mask "${THUMBNAILER_UNITS[@]}"; then + echo "The ffmpeg video thumbnailer has been disabled" + echo "Its container and daemon units are masked and stopped;" + echo "video thumbnails fall back to the in-sandbox thumbnailer." + else + echo "Failed to disable the ffmpeg video thumbnailer" >&2 + exit 1 + fi + else + if systemctl --user unmask "${THUMBNAILER_UNITS[@]}"; then + systemctl --user daemon-reload >/dev/null 2>&1 || true + systemctl --user enable --now "${DAEMON_UNIT}" >/dev/null 2>&1 || true + systemctl --user start "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true + echo "The ffmpeg video thumbnailer has been enabled" + echo "The container image is pulled on first use, so the first" + echo "thumbnail after enabling may fail while the pull completes." + else + echo "Failed to enable the ffmpeg video thumbnailer" >&2 + exit 1 + fi + fi + +# Inspect the rootless ffmpeg video thumbnailer: units, container, socket, and +# recent daemon activity. +[group('System')] +status-ffmpeg-thumbnailer: + #!/usr/bin/bash + DAEMON_UNIT="ffmpeg-thumbnailer-daemon.service" + THUMBNAILER_UNITS=( + ffmpeg-thumbnailer.service + ffmpeg-thumbnailer-nvidia.service + ffmpeg-thumbnailer-daemon.service + ) + CONTAINER_NAME="${FFT_CONTAINER:-ffmpeg-thumbnailer}" + SOCKET_PATH="${FFT_SOCKET:-${XDG_CACHE_HOME:-${HOME}/.cache}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer.sock}" + + echo "Units (enabled-state / active-state):" + for unit in "${THUMBNAILER_UNITS[@]}"; do + ENABLED_STATE="$(systemctl --user is-enabled "${unit}" 2>/dev/null || true)" + ACTIVE_STATE="$(systemctl --user is-active "${unit}" 2>/dev/null || true)" + printf ' %-38s %-10s %s\n' "${unit}" "${ENABLED_STATE:-unknown}" "${ACTIVE_STATE:-unknown}" + done + + echo "Container:" + if command -v podman >/dev/null 2>&1; then + if podman container exists "${CONTAINER_NAME}"; then + podman ps --all --filter "name=^${CONTAINER_NAME}$" + else + echo " ${CONTAINER_NAME}: not created (masked, stopped, or never started)" + fi + else + echo " podman is not installed" + fi + + echo "Daemon socket:" + if [[ -S "${SOCKET_PATH}" ]]; then + echo " ${SOCKET_PATH}: listening" + elif [[ -e "${SOCKET_PATH}" ]]; then + echo " ${SOCKET_PATH}: present but not a socket (stale)" + else + echo " ${SOCKET_PATH}: missing (daemon not running)" + fi + + echo "Recent daemon activity:" + if command -v journalctl >/dev/null 2>&1; then + journalctl --user -u "${DAEMON_UNIT}" -n 15 --no-pager 2>/dev/null || echo " no journal entries recorded" + else + echo " journalctl is not available" + fi diff --git a/tests/test_shared_just.bats b/tests/test_shared_just.bats index 5fa9b9472..1e18627e4 100644 --- a/tests/test_shared_just.bats +++ b/tests/test_shared_just.bats @@ -1,28 +1,39 @@ #!/usr/bin/env bats # Tests for system_files/shared/usr/share/ublue-os/just/shared.just # -# Covers the two recipes defined there: -# - powerwash (destructive factory reset, double-confirmation gated) -# - toggle-tpm2 (LUKS TPM2 auto-unlock toggle) +# Covers the recipes defined there: +# - powerwash (destructive factory reset, double-confirmation gated) +# - toggle-tpm2 (LUKS TPM2 auto-unlock toggle) +# - toggle-ffmpeg-thumbnailer (opt-in toggle for the ffmpeg thumbnailer daemon) +# - status-ffmpeg-thumbnailer (read-only inspection of the same units) # # The powerwash recipe body is a `#!/usr/bin/bash` shebang recipe with no just # interpolation, so it can be extracted verbatim and executed against stubbed # bctl/gum/sudo binaries. That exercises the real control flow instead of -# grepping the recipe text. +# grepping the recipe text. The same extraction approach is used for the +# thumbnailer recipes with every systemctl/podman/journalctl call stubbed. SHARED_JUST="${BATS_TEST_DIRNAME}/../system_files/shared/usr/share/ublue-os/just/shared.just" WORKDIR="" # Extract a shebang recipe body from shared.just and dedent it by four spaces. +# The recipe pattern also matches parameterised recipes (`name ARG="x":`). _extract_recipe() { awk -v name="$1" ' - $0 == name ":" { in_recipe = 1; next } + $0 ~ ("^" name "([[:space:]].*)?:$") { in_recipe = 1; next } in_recipe && $0 ~ /^[^[:space:]]/ { exit } in_recipe && $0 ~ /^ / { sub(/^ /, ""); print } in_recipe && $0 == "" { print } ' "${SHARED_JUST}" } +# Same extraction, but resolves the `{{ ACTION }}` just interpolation the way +# the just parser would so the body is runnable bash (see test_update_just.bats). +_extract_toggle_recipe() { + _extract_recipe toggle-ffmpeg-thumbnailer \ + | sed 's/{{ ACTION }}/${ACTION:-prompt}/g' +} + setup() { WORKDIR="$(mktemp -d)" mkdir -p "${WORKDIR}/bin" @@ -52,7 +63,68 @@ EOF printf 'bctl %s\n' "$*" >> "${CALLS}" EOF - chmod +x "${WORKDIR}/bin/gum" "${WORKDIR}/bin/sudo" "${WORKDIR}/bin/bctl" + # systemctl stub. MOCK_UNIT_PRESENT=0 makes `systemctl --user cat` fail, + # which is how the recipes detect that the thumbnailer is not installed. + cat > "${WORKDIR}/bin/systemctl" <<'EOF' +#!/usr/bin/bash +printf 'systemctl %s\n' "$*" >> "${CALLS}" +[[ "${1:-}" == "--user" ]] && shift +case "${1:-}" in + cat) + [[ "${MOCK_UNIT_PRESENT:-1}" == "1" ]] && exit 0 || exit 1 + ;; + is-enabled) + [[ "${MOCK_STATE_FAIL:-0}" == "1" ]] && exit 1 + if [[ "${MOCK_MASKED:-0}" == "1" ]]; then + printf 'masked\n' + exit 1 + fi + printf '%s\n' "${MOCK_ENABLED_STATE:-enabled}" + ;; + is-active) + [[ "${MOCK_STATE_FAIL:-0}" == "1" ]] && exit 1 + printf '%s\n' "${MOCK_ACTIVE_STATE:-inactive}" + ;; + mask) + exit "${MOCK_MASK_STATUS:-0}" + ;; + unmask) + exit "${MOCK_UNMASK_STATUS:-0}" + ;; +esac +exit 0 +EOF + + cat > "${WORKDIR}/bin/podman" <<'EOF' +#!/usr/bin/bash +printf 'podman %s\n' "$*" >> "${CALLS}" +if [[ "${1:-}" == "container" && "${2:-}" == "exists" ]]; then + exit "${MOCK_CONTAINER_EXISTS:-0}" +fi +if [[ "${1:-}" == "ps" ]]; then + printf 'CONTAINER ID IMAGE STATUS\n' + printf 'a1b2c3d4e5f6 jrottenberg/ffmpeg:9.0-alpine320 Up 2 hours\n' +fi +exit 0 +EOF + + cat > "${WORKDIR}/bin/journalctl" <<'EOF' +#!/usr/bin/bash +printf 'journalctl %s\n' "$*" >> "${CALLS}" +if [[ "${MOCK_JOURNAL_ENTRIES:-1}" == "1" ]]; then + printf 'Sep 25 12:00:00 host ffmpeg-thumbnailer-daemon: listening on /tmp/x.sock\n' + exit 0 +fi +exit 1 +EOF + + chmod +x "${WORKDIR}/bin/gum" "${WORKDIR}/bin/sudo" "${WORKDIR}/bin/bctl" \ + "${WORKDIR}/bin/systemctl" "${WORKDIR}/bin/podman" "${WORKDIR}/bin/journalctl" + + _extract_toggle_recipe > "${WORKDIR}/toggle-thumbnailer.sh" + chmod +x "${WORKDIR}/toggle-thumbnailer.sh" + _extract_recipe status-ffmpeg-thumbnailer > "${WORKDIR}/status-thumbnailer.sh" + chmod +x "${WORKDIR}/status-thumbnailer.sh" } teardown() { @@ -209,5 +281,301 @@ _calls() { } @test "shared.just: recipes are grouped under System" { - [ "$(grep -c "^\[group('System')\]" "${SHARED_JUST}")" -eq 2 ] + [ "$(grep -c "^\[group('System')\]" "${SHARED_JUST}")" -eq 4 ] +} + +# --- ffmpeg thumbnailer toggle ------------------------------------------------ + +# Run an extracted thumbnailer recipe against the stubs. Every argument after +# the script name is passed to `env -i` as a KEY=VALUE assignment, so each test +# states exactly which mock behaviour it needs. +_run_thumbnailer() { + local script="$1" + shift + run /usr/bin/env -i \ + PATH="${WORKDIR}/bin:/usr/bin:/bin" \ + HOME="${WORKDIR}/home" \ + CALLS="${WORKDIR}/calls.log" \ + GUM_ANSWERS="${WORKDIR}/gum-answers" \ + "$@" \ + /usr/bin/bash "${WORKDIR}/${script}" +} + +_SOCKET_REL="cache/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer.sock" + +_socket_path() { + printf '%s\n' "${WORKDIR}/${_SOCKET_REL}" +} + +_make_socket() { + mkdir -p "$(dirname "$(_socket_path)")" + python3 -c 'import socket, sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' \ + "$(_socket_path)" +} + +@test "toggle-ffmpeg-thumbnailer: recipe body is extractable and resolves ACTION" { + [ -s "${WORKDIR}/toggle-thumbnailer.sh" ] + run head -n1 "${WORKDIR}/toggle-thumbnailer.sh" + [ "${output}" = "#!/usr/bin/bash" ] + # The just-injected value must be substituted, not left as a literal + # {{ ACTION }} expression the shell cannot evaluate. + run grep -Fq 'ACTION_VALUE="${ACTION:-prompt}"' "${WORKDIR}/toggle-thumbnailer.sh" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: disable stops and masks all three units" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + result="${output}" + + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user stop ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user mask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + [[ "${result}" == *"has been disabled"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: disable stops the units before masking them" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + + stop_line="$(grep -n '^systemctl --user stop ' "${WORKDIR}/calls.log" | cut -d: -f1)" + mask_line="$(grep -n '^systemctl --user mask ' "${WORKDIR}/calls.log" | cut -d: -f1)" + [ -n "${stop_line}" ] + [ -n "${mask_line}" ] + [ "${stop_line}" -lt "${mask_line}" ] +} + +@test "toggle-ffmpeg-thumbnailer: disable never unmasks" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + + run grep -q "^systemctl --user unmask " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: enable unmasks, reloads, and starts daemon plus containers" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + result="${output}" + + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user unmask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user daemon-reload" <<< "$(_calls)" + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user enable --now ffmpeg-thumbnailer-daemon.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user start ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] + [[ "${result}" == *"has been enabled"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: enable never masks" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + + run grep -q "^systemctl --user mask " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: ACTION is case-insensitive" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=DISABLE + + [ "${status}" -eq 0 ] + run grep -Fqx "systemctl --user mask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: ACTION=cancel changes nothing" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=cancel + + [ "${status}" -eq 0 ] + run grep -Eq "^systemctl --user (mask|unmask|stop|start) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: the prompt reports the enabled state" { + _queue_answers "Cancel" + + _run_thumbnailer toggle-thumbnailer.sh + result="${output}" + + [ "${status}" -eq 0 ] + [[ "${result}" == *"currently enabled"* ]] + run grep -q "^gum choose " <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: the prompt reports the masked state" { + _queue_answers "Cancel" + + _run_thumbnailer toggle-thumbnailer.sh MOCK_MASKED=1 + result="${output}" + + [ "${status}" -eq 0 ] + [[ "${result}" == *"currently disabled"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: the prompt offers Cancel and Cancel is a no-op" { + _queue_answers "Cancel" + + _run_thumbnailer toggle-thumbnailer.sh + result="${output}" + + run grep -Fq "Cancel" <<< "$(grep '^gum choose ' "${WORKDIR}/calls.log")" + [ "${status}" -eq 0 ] + [[ "${result}" == *"No changes made."* ]] + run grep -Eq "^systemctl --user (mask|unmask|stop|start) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: an unrelated prompt answer changes nothing" { + _queue_answers "maybe" + + _run_thumbnailer toggle-thumbnailer.sh + + [ "${status}" -eq 0 ] + run grep -Eq "^systemctl --user (mask|unmask|stop|start) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: prompting then choosing Disable masks the units" { + _queue_answers "Disable" + + _run_thumbnailer toggle-thumbnailer.sh + + [ "${status}" -eq 0 ] + run grep -q "^systemctl --user mask " <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: prompting then choosing Enable unmasks the units" { + _queue_answers "Enable" + + _run_thumbnailer toggle-thumbnailer.sh MOCK_MASKED=1 + + [ "${status}" -eq 0 ] + run grep -q "^systemctl --user unmask " <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: a missing thumbnailer is reported, not masked" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable MOCK_UNIT_PRESENT=0 + result="${output}" + + [ "${status}" -eq 0 ] + [[ "${result}" == *"not installed"* ]] + run grep -Eq "^systemctl --user (mask|unmask|stop|start) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "toggle-ffmpeg-thumbnailer: a failed mask exits non-zero and explains why" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable MOCK_MASK_STATUS=1 + result="${output}" + + [ "${status}" -eq 1 ] + [[ "${result}" == *"Failed to disable"* ]] +} + +@test "toggle-ffmpeg-thumbnailer: a failed unmask exits non-zero and explains why" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable MOCK_UNMASK_STATUS=1 + result="${output}" + + [ "${status}" -eq 1 ] + [[ "${result}" == *"Failed to enable"* ]] +} + +# --- ffmpeg thumbnailer status ------------------------------------------------ + +@test "status-ffmpeg-thumbnailer: reports every unit's enabled and active state" { + _run_thumbnailer status-thumbnailer.sh \ + MOCK_MASKED=1 MOCK_ACTIVE_STATE=inactive XDG_CACHE_HOME="${WORKDIR}/cache" + result="${output}" + + [ "${status}" -eq 0 ] + for unit in ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service \ + ffmpeg-thumbnailer-daemon.service; do + [[ "${result}" == *"${unit}"* ]] + done + [[ "${result}" == *"masked"* ]] + [[ "${result}" == *"inactive"* ]] +} + +@test "status-ffmpeg-thumbnailer: never changes unit state" { + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + run grep -Eq "^systemctl --user (mask|unmask|stop|start|enable) " <<< "$(_calls)" + [ "${status}" -ne 0 ] +} + +@test "status-ffmpeg-thumbnailer: reports a listening daemon socket" { + _make_socket + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"listening"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports a missing daemon socket" { + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"daemon not running"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports a stale non-socket path" { + mkdir -p "$(dirname "$(_socket_path)")" + : > "$(_socket_path)" + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"present but not a socket"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports a running container" { + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"Up 2 hours"* ]] + run grep -q "^podman ps " <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "status-ffmpeg-thumbnailer: reports a container that was never created" { + _run_thumbnailer status-thumbnailer.sh MOCK_CONTAINER_EXISTS=1 XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"not created"* ]] +} + +@test "status-ffmpeg-thumbnailer: degrades when podman is absent" { + rm -f "${WORKDIR}/bin/podman" + + _run_thumbnailer status-thumbnailer.sh PATH="${WORKDIR}/bin" XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"podman is not installed"* ]] +} + +@test "status-ffmpeg-thumbnailer: shows recent daemon activity" { + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"listening on /tmp/x.sock"* ]] +} + +@test "status-ffmpeg-thumbnailer: says so when the journal has no entries" { + _run_thumbnailer status-thumbnailer.sh MOCK_JOURNAL_ENTRIES=0 XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"no journal entries recorded"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports unknown state instead of failing" { + rm -f "${WORKDIR}/bin/podman" "${WORKDIR}/bin/journalctl" + + _run_thumbnailer status-thumbnailer.sh PATH="${WORKDIR}/bin" MOCK_STATE_FAIL=1 XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"unknown"* ]] } From b56ccbfdb78b6e82914e8141cb9868fd789a7270 Mon Sep 17 00:00:00 2001 From: castrojo Date: Tue, 29 Sep 2026 00:34:30 -0400 Subject: [PATCH 2/6] fix(just): override the thumbnailer environment.d drop-in on disable Masking the units left /usr/lib/environment.d/10-ffmpeg-thumbnailer.conf in effect, so the shim stayed first in XDG_DATA_DIRS for video MIME types and thumbnails failed instead of falling back. Disable now writes a same-named user drop-in in ~/.config/environment.d (which overrides the system one); enable removes it. The message notes it applies at next login. Per hanthor's review. Assisted-by: Claude via omp Signed-off-by: castrojo --- .../shared/usr/share/ublue-os/just/shared.just | 8 +++++++- tests/test_shared_just.bats | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/system_files/shared/usr/share/ublue-os/just/shared.just b/system_files/shared/usr/share/ublue-os/just/shared.just index c848f77e8..59535d1f2 100644 --- a/system_files/shared/usr/share/ublue-os/just/shared.just +++ b/system_files/shared/usr/share/ublue-os/just/shared.just @@ -37,6 +37,9 @@ toggle-ffmpeg-thumbnailer ACTION="prompt": ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service ) + # A same-named user drop-in overrides /usr/lib/environment.d, which + # otherwise keeps the shim first in XDG_DATA_DIRS for video MIME types. + ENV_OVERRIDE="${XDG_CONFIG_HOME:-${HOME}/.config}/environment.d/10-ffmpeg-thumbnailer.conf" if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1; then echo "The ffmpeg video thumbnailer is not installed on this system." @@ -79,15 +82,18 @@ toggle-ffmpeg-thumbnailer ACTION="prompt": # The per-user mask also blocks the container quadlets, so the 111 MB # image is never pulled again while the toggle is off. if systemctl --user mask "${THUMBNAILER_UNITS[@]}"; then + mkdir -p "$(dirname "${ENV_OVERRIDE}")" + printf '# Written by ujust toggle-ffmpeg-thumbnailer: masks the system drop-in.\n' > "${ENV_OVERRIDE}" echo "The ffmpeg video thumbnailer has been disabled" echo "Its container and daemon units are masked and stopped;" - echo "video thumbnails fall back to the in-sandbox thumbnailer." + echo "after you log out and back in, video thumbnails fall back to the in-sandbox thumbnailer." else echo "Failed to disable the ffmpeg video thumbnailer" >&2 exit 1 fi else if systemctl --user unmask "${THUMBNAILER_UNITS[@]}"; then + rm -f "${ENV_OVERRIDE}" systemctl --user daemon-reload >/dev/null 2>&1 || true systemctl --user enable --now "${DAEMON_UNIT}" >/dev/null 2>&1 || true systemctl --user start "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true diff --git a/tests/test_shared_just.bats b/tests/test_shared_just.bats index 1e18627e4..22c9b8670 100644 --- a/tests/test_shared_just.bats +++ b/tests/test_shared_just.bats @@ -335,6 +335,20 @@ _make_socket() { [[ "${result}" == *"has been disabled"* ]] } +@test "toggle-ffmpeg-thumbnailer: disable overrides the environment.d drop-in and enable removes it" { + override="${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" + + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + [ "${status}" -eq 0 ] + [ -f "${override}" ] + run grep -q XDG_DATA_DIRS "${override}" + [ "${status}" -ne 0 ] + + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + [ "${status}" -eq 0 ] + [ ! -e "${override}" ] +} + @test "toggle-ffmpeg-thumbnailer: disable stops the units before masking them" { _run_thumbnailer toggle-thumbnailer.sh ACTION=disable From 891d6c968e4ae2fa5de5ae12e32e545b6fba1dab Mon Sep 17 00:00:00 2001 From: hive-scanner Date: Tue, 29 Sep 2026 11:08:28 -0400 Subject: [PATCH 3/6] fix(thumbnailer): align status socket path with the daemon and correct the unit docs `status-ffmpeg-thumbnailer` defaulted SOCKET_PATH to .../gstreamer-1.0/ffmpeg-thumbnailer.sock, but the daemon binds .../gstreamer-1.0/ffmpeg-thumbnailer/sock (GST_CACHE_DIR + "ffmpeg-thumbnailer" + "sock"), so the report always said "missing (daemon not running)". The bats helper mirrored the same wrong path, so the suite could not catch it. Fix both and pin the default with a regression test that greps the extracted recipe body. docs/skills/systemd-user-units.md described a usr/lib/systemd/user-preset/01-ffmpeg-thumbnailer.preset file and a static graphical-session.target.wants symlink. Neither is shipped: the daemon unit only carries [Install] WantedBy=graphical-session.target default.target and the container units are quadlet-generated with no [Install] at all. Rewrite the enable/disable rationale around what is actually in the tree. Mask is still the right verb, for the real reasons: the quadlet units have no install state to disable, and a plain disable on the daemon is not durable. Hive-Fix-Attempt: 1/6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/skills/systemd-user-units.md | 61 +++++++++++-------- .../usr/share/ublue-os/just/shared.just | 10 +-- tests/test_shared_just.bats | 13 +++- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/docs/skills/systemd-user-units.md b/docs/skills/systemd-user-units.md index 810270937..9f9665dd0 100644 --- a/docs/skills/systemd-user-units.md +++ b/docs/skills/systemd-user-units.md @@ -48,37 +48,49 @@ toggle: the generated unit is `.service`, so regardless of the `ContainerName=` inside the file. Do not assume a `.container` file can be masked by its own name. -## Enabling: presets, not `systemctl enable` - -Downstream image repos do **not** run `systemctl --global enable`. Units are -enabled by shipping a preset line, e.g. -`usr/lib/systemd/user-preset/01-ffmpeg-thumbnailer.preset`: - -``` -enable ffmpeg-thumbnailer-daemon.service -``` +## Enabling: `[Install]` + the quadlet generator + +Downstream image repos do **not** run `systemctl --global enable`. What starts +a unit here is one of three things, and it is worth checking which one before +writing a toggle: + +- An `[Install] WantedBy=` line plus something that acts on it — a shipped + `usr/lib/systemd/user-preset/*.preset` entry, or the user running + `systemctl --user enable`. `ffmpeg-thumbnailer-daemon.service` takes this + route: it ships `WantedBy=graphical-session.target default.target` and + `common` ships no preset for it, so the `enable` branch of the toggle is what + installs the wants symlink. +- A static `usr/lib/systemd/user/.wants/` symlink baked into the + image. `common` uses this for system timers (`timers.target.wants/`), not for + the thumbnailer. +- The quadlet generator. `ffmpeg-thumbnailer.container` and + `ffmpeg-thumbnailer-nvidia.container` carry no `[Install]` section at all; + the generator materialises `.service` at user-manager start and they + are started on demand or by another unit's dependency. Two consequences for anything you write in `common`: -- A unit that must start for every user needs either the preset **or** a static - `wants/` symlink in the image — a `WantedBy=` line alone is inert until - something enables the unit. -- Because enablement is image state, a per-user toggle cannot undo it with - `systemctl --user disable`. See below. +- A `WantedBy=` line alone is inert. If a unit must start for every user + without a ujust opt-in, it needs a preset line or a static `wants/` symlink. +- A per-user toggle cannot rely on `systemctl --user disable`, because two of + the three units have no install state at all. See below. ## Disabling per user: mask, not disable -`systemctl --user disable ` only removes symlinks the enable verb created. -It does **not** touch a static `usr/lib/systemd/user/.wants/` -symlink, and for quadlet-generated units it has nothing to act on at all — the +`systemctl --user disable ` only removes symlinks the enable verb +created. For the quadlet-generated units it has nothing to act on — the generated `.service` has no install state systemctl can edit, so only the -generator decides whether it exists and what pulls it in. +generator decides whether it exists and what pulls it in. For the daemon it +does work, but it is not durable: a preset added downstream, or a later +`enable`, reinstates the wants symlink, and `disable` does nothing to stop a +unit another unit already pulled in. `systemctl --user mask ` writes a symlink to `/dev/null` into the user's own unit directory (no `sudo`) and outranks the image and generator locations, -so systemd refuses to load the unit no matter what pulls it in. That is why the -thumbnailer toggle masks all three units — the daemon *and* both container -quadlets — instead of stopping them: +so systemd refuses to load the unit no matter what pulls it in. That is the +one mechanism that works uniformly for all three units, which is why the +thumbnailer toggle masks the daemon *and* both container quadlets instead of +stopping them: ```bash systemctl --user stop ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service @@ -113,14 +125,15 @@ Two things to carry into the recipe: ## Red Flags -- A toggle that calls `systemctl --user disable` on a unit shipped in - `usr/lib/systemd/user/` — the static `wants/` symlink keeps starting it +- A toggle that calls `systemctl --user disable` on a quadlet-generated + `.service` — there is no install state to remove, so nothing changes - Masking a quadlet's `.container` filename instead of the generated `.service` - Assuming `systemctl --user cat .service` fails merely because the file is in `usr/share/containers/systemd/` — the generator makes the unit loadable at user-manager start, and `cat` is the right existence probe - A recipe body containing `{{ ... }}` -- Documenting enablement as `systemctl --global enable`; presets own it +- Documenting enablement as `systemctl --global enable`; presets, static + `wants/` symlinks, or an explicit `systemctl --user enable` own it ## Verification diff --git a/system_files/shared/usr/share/ublue-os/just/shared.just b/system_files/shared/usr/share/ublue-os/just/shared.just index 59535d1f2..1d083fdbf 100644 --- a/system_files/shared/usr/share/ublue-os/just/shared.just +++ b/system_files/shared/usr/share/ublue-os/just/shared.just @@ -76,9 +76,10 @@ toggle-ffmpeg-thumbnailer ACTION="prompt": if [[ "${SELECTED_OPTION}" == "Disable" ]]; then systemctl --user stop "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true - # Mask, not disable: the daemon is also pulled in by a static - # graphical-session.target.wants symlink shipped in /usr/lib, which - # `disable` cannot remove. Only a mask overrides it. + # Mask, not disable: the container units are quadlet-generated, so they + # carry no install state `systemctl --user disable` can edit, and the + # daemon ships `WantedBy=graphical-session.target default.target`, which + # a preset or a re-enable can reinstate. Only a mask overrides both. # The per-user mask also blocks the container quadlets, so the 111 MB # image is never pulled again while the toggle is off. if systemctl --user mask "${THUMBNAILER_UNITS[@]}"; then @@ -118,7 +119,8 @@ status-ffmpeg-thumbnailer: ffmpeg-thumbnailer-daemon.service ) CONTAINER_NAME="${FFT_CONTAINER:-ffmpeg-thumbnailer}" - SOCKET_PATH="${FFT_SOCKET:-${XDG_CACHE_HOME:-${HOME}/.cache}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer.sock}" + # Must match SOCK_PATH in /usr/libexec/ffmpeg-thumbnailer/ffmpeg-thumbnailer-daemon. + SOCKET_PATH="${FFT_SOCKET:-${XDG_CACHE_HOME:-${HOME}/.cache}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock}" echo "Units (enabled-state / active-state):" for unit in "${THUMBNAILER_UNITS[@]}"; do diff --git a/tests/test_shared_just.bats b/tests/test_shared_just.bats index 22c9b8670..d832e4200 100644 --- a/tests/test_shared_just.bats +++ b/tests/test_shared_just.bats @@ -301,7 +301,7 @@ _run_thumbnailer() { /usr/bin/bash "${WORKDIR}/${script}" } -_SOCKET_REL="cache/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer.sock" +_SOCKET_REL="cache/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock" _socket_path() { printf '%s\n' "${WORKDIR}/${_SOCKET_REL}" @@ -536,6 +536,17 @@ _make_socket() { [[ "${output}" == *"daemon not running"* ]] } +@test "status-ffmpeg-thumbnailer: the default socket path matches the daemon's SOCK_PATH" { + # ffmpeg-thumbnailer-daemon binds + # ${XDG_CACHE_HOME}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock + # (GST_CACHE_DIR + "ffmpeg-thumbnailer" + "sock"). A drifting default here + # makes the report claim the daemon is down while it is serving. + run grep -Fq \ + 'SOCKET_PATH="${FFT_SOCKET:-${XDG_CACHE_HOME:-${HOME}/.cache}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock}"' \ + "${WORKDIR}/status-thumbnailer.sh" + [ "${status}" -eq 0 ] +} + @test "status-ffmpeg-thumbnailer: reports a stale non-socket path" { mkdir -p "$(dirname "$(_socket_path)")" : > "$(_socket_path)" From 74a40d5255cf70721c36d08ccc4ab18fc21d9902 Mon Sep 17 00:00:00 2001 From: "sec-check[bot]" Date: Tue, 29 Sep 2026 11:43:50 -0400 Subject: [PATCH 4/6] docs(thumbnailer): document and report the environment.d override The disable branch of `toggle-ffmpeg-thumbnailer` writes a comment-only `~/.config/environment.d/10-ffmpeg-thumbnailer.conf` to shadow the system drop-in and keep the shim out of `XDG_DATA_DIRS`. Nothing told the user about it: the skill doc covered only masking, and `status-ffmpeg-thumbnailer` never mentioned the file. A user who unmasked the units by hand got no video thumbnails and no pointer to the leftover override. - `status-ffmpeg-thumbnailer` now prints an "Environment override" section reporting whether the file is present, what it does, and how to remove it. Its path is derived the same way as the toggle's, and a test pins the two literals together so they cannot drift. - `docs/skills/systemd-user-units.md` gains a section on why masking alone is not enough for a session environment variable, the shadow-by-name mechanism, a matching red flag, and a `cat-config` verification command. Hive-Fix-Attempt: 2/6 Assisted-by: Claude Opus 5 via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/skills/systemd-user-units.md | 34 ++++++++++++++++ .../usr/share/ublue-os/just/shared.just | 12 ++++++ tests/test_shared_just.bats | 40 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/docs/skills/systemd-user-units.md b/docs/skills/systemd-user-units.md index 9f9665dd0..f5cfd1704 100644 --- a/docs/skills/systemd-user-units.md +++ b/docs/skills/systemd-user-units.md @@ -107,6 +107,34 @@ deleting the symlink) removes it. Stop before mask, in that order: the daemon unlinks its socket on `SIGTERM`, so masking a live unit can leave a stale socket path behind. +## Masking is not enough: the `environment.d` drop-in + +The thumbnailer also ships a system `environment.d` drop-in that puts its shim +directory first in `XDG_DATA_DIRS` for video MIME types. That variable is read +by the user session at login, not by a unit, so masking the units does nothing +to it: the shim `.thumbnailer` entries keep winning and video thumbnails keep +failing to the masked daemon. + +`environment.d` resolves same-named files by name across its search path, and +the user directory outranks `/usr/lib`, so writing an empty (comment-only) file +at the same name shadows the system drop-in completely: + +```bash +ENV_OVERRIDE="${XDG_CONFIG_HOME:-$HOME/.config}/environment.d/10-ffmpeg-thumbnailer.conf" +mkdir -p "$(dirname "${ENV_OVERRIDE}")" +printf '# masks the system drop-in\n' > "${ENV_OVERRIDE}" # disable +rm -f "${ENV_OVERRIDE}" # enable +``` + +Two consequences for any toggle that follows this pattern: + +- The override is durable user state, like a mask, and it takes effect at the + next login rather than immediately. +- Report it. A user who unmasks the units by hand, without the toggle, still + gets no thumbnails and has nothing pointing at the leftover file — + `status-ffmpeg-thumbnailer` prints whether it is present for exactly that + reason. + ## Writing the toggle recipe Recipe bodies are bash, so test them by extracting the body and stubbing @@ -132,6 +160,8 @@ Two things to carry into the recipe: file is in `usr/share/containers/systemd/` — the generator makes the unit loadable at user-manager start, and `cat` is the right existence probe - A recipe body containing `{{ ... }}` +- A toggle that masks units but leaves a session-level `environment.d` drop-in + in place, or a status recipe that never reports the override it wrote - Documenting enablement as `systemctl --global enable`; presets, static `wants/` symlinks, or an explicit `systemctl --user enable` own it @@ -149,6 +179,10 @@ systemctl --user is-enabled ffmpeg-thumbnailer-daemon.service # Where a mask landed, and what it shadows: ls -l "${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/" +# Whether a user environment.d override is shadowing the system drop-in: +ls -l "${XDG_CONFIG_HOME:-$HOME/.config}/environment.d/" +systemd-analyze --user cat-config environment.d + # What actually pulls a unit in (preset, static wants, or generator): systemctl --user show -p LoadState -p UnitFileState -p WantedBy -p RequiredBy \ ffmpeg-thumbnailer-daemon.service diff --git a/system_files/shared/usr/share/ublue-os/just/shared.just b/system_files/shared/usr/share/ublue-os/just/shared.just index 1d083fdbf..d7516d32c 100644 --- a/system_files/shared/usr/share/ublue-os/just/shared.just +++ b/system_files/shared/usr/share/ublue-os/just/shared.just @@ -121,6 +121,8 @@ status-ffmpeg-thumbnailer: CONTAINER_NAME="${FFT_CONTAINER:-ffmpeg-thumbnailer}" # Must match SOCK_PATH in /usr/libexec/ffmpeg-thumbnailer/ffmpeg-thumbnailer-daemon. SOCKET_PATH="${FFT_SOCKET:-${XDG_CACHE_HOME:-${HOME}/.cache}/gnome-desktop-thumbnailer/gstreamer-1.0/ffmpeg-thumbnailer/sock}" + # Must match ENV_OVERRIDE in toggle-ffmpeg-thumbnailer. + ENV_OVERRIDE="${XDG_CONFIG_HOME:-${HOME}/.config}/environment.d/10-ffmpeg-thumbnailer.conf" echo "Units (enabled-state / active-state):" for unit in "${THUMBNAILER_UNITS[@]}"; do @@ -140,6 +142,16 @@ status-ffmpeg-thumbnailer: echo " podman is not installed" fi + echo "Environment override:" + if [[ -e "${ENV_OVERRIDE}" ]]; then + echo " ${ENV_OVERRIDE}: present" + echo " It shadows the system environment.d drop-in, so the shim stays out of" + echo " XDG_DATA_DIRS and video thumbnails stay off even if the units are unmasked." + echo " Run 'ujust toggle-ffmpeg-thumbnailer enable' to remove it." + else + echo " ${ENV_OVERRIDE}: absent (system environment.d drop-in in effect)" + fi + echo "Daemon socket:" if [[ -S "${SOCKET_PATH}" ]]; then echo " ${SOCKET_PATH}: listening" diff --git a/tests/test_shared_just.bats b/tests/test_shared_just.bats index d832e4200..19e805408 100644 --- a/tests/test_shared_just.bats +++ b/tests/test_shared_just.bats @@ -582,6 +582,46 @@ _make_socket() { [[ "${output}" == *"podman is not installed"* ]] } +@test "status-ffmpeg-thumbnailer: reports the environment.d override when present" { + mkdir -p "${WORKDIR}/home/.config/environment.d" + printf '# stub\n' > "${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"10-ffmpeg-thumbnailer.conf: present"* ]] + [[ "${output}" == *"toggle-ffmpeg-thumbnailer enable"* ]] +} + +@test "status-ffmpeg-thumbnailer: reports an absent environment.d override" { + rm -f "${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [[ "${output}" == *"10-ffmpeg-thumbnailer.conf: absent"* ]] +} + +@test "status-ffmpeg-thumbnailer: the override path matches the toggle's ENV_OVERRIDE" { + # A drifting path here makes the report claim no override while the toggle's + # file is still shadowing the system drop-in. + override_line='ENV_OVERRIDE="${XDG_CONFIG_HOME:-${HOME}/.config}/environment.d/10-ffmpeg-thumbnailer.conf"' + run grep -Fq "${override_line}" "${WORKDIR}/status-thumbnailer.sh" + [ "${status}" -eq 0 ] + run grep -Fq "${override_line}" "${WORKDIR}/toggle-thumbnailer.sh" + [ "${status}" -eq 0 ] +} + +@test "status-ffmpeg-thumbnailer: never changes the environment.d override" { + mkdir -p "${WORKDIR}/home/.config/environment.d" + printf '# stub\n' > "${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" + + _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" + + [ "${status}" -eq 0 ] + [ -f "${WORKDIR}/home/.config/environment.d/10-ffmpeg-thumbnailer.conf" ] +} + @test "status-ffmpeg-thumbnailer: shows recent daemon activity" { _run_thumbnailer status-thumbnailer.sh XDG_CACHE_HOME="${WORKDIR}/cache" From 4851f33f9da0035999cf2a5c9cc5d4d0eb4b7827 Mon Sep 17 00:00:00 2001 From: hive-scanner Date: Tue, 29 Sep 2026 12:17:01 -0400 Subject: [PATCH 5/6] fix(thumbnailer): state the re-login caveat on enable and match status parity The enable branch of `toggle-ffmpeg-thumbnailer` removes the user `environment.d` override, but `environment.d` is only read at login. The old message said thumbnails worked immediately and blamed the container pull, so a disable -> relogin -> enable cycle left the user with active units, no shim in `XDG_DATA_DIRS`, no thumbnails, and a misleading explanation. The disable branch already stated the caveat; enable now does too. `status-ffmpeg-thumbnailer` also gains the toggle's `systemctl --user cat` probe and short-circuits with the same "not installed" line, instead of printing unknown/absent rows for a thumbnailer that is not on the system. Tests cover both branches' re-login wording and the status short-circuit; the skill doc records why the enable side is deferred too. Hive-Fix-Attempt: 3/6 Assisted-by: Claude Opus 5 via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/skills/systemd-user-units.md | 7 ++++-- .../usr/share/ublue-os/just/shared.just | 14 ++++++++++-- tests/test_shared_just.bats | 22 +++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/docs/skills/systemd-user-units.md b/docs/skills/systemd-user-units.md index f5cfd1704..6f7ff699c 100644 --- a/docs/skills/systemd-user-units.md +++ b/docs/skills/systemd-user-units.md @@ -129,11 +129,14 @@ rm -f "${ENV_OVERRIDE}" # enable Two consequences for any toggle that follows this pattern: - The override is durable user state, like a mask, and it takes effect at the - next login rather than immediately. + next login rather than immediately. Say so in *both* toggle branches: removing + the file on enable is as deferred as writing it on disable, so a user who + enables mid-session sees active units and still no thumbnails until re-login. - Report it. A user who unmasks the units by hand, without the toggle, still gets no thumbnails and has nothing pointing at the leftover file — `status-ffmpeg-thumbnailer` prints whether it is present for exactly that - reason. + reason. The status recipe also short-circuits on the same "is it installed?" + probe as the toggle, so the pair never disagrees about an absent thumbnailer. ## Writing the toggle recipe diff --git a/system_files/shared/usr/share/ublue-os/just/shared.just b/system_files/shared/usr/share/ublue-os/just/shared.just index d7516d32c..b7a516af0 100644 --- a/system_files/shared/usr/share/ublue-os/just/shared.just +++ b/system_files/shared/usr/share/ublue-os/just/shared.just @@ -99,8 +99,11 @@ toggle-ffmpeg-thumbnailer ACTION="prompt": systemctl --user enable --now "${DAEMON_UNIT}" >/dev/null 2>&1 || true systemctl --user start "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true echo "The ffmpeg video thumbnailer has been enabled" - echo "The container image is pulled on first use, so the first" - echo "thumbnail after enabling may fail while the pull completes." + echo "Its units are unmasked and started, but the shim only returns to" + echo "XDG_DATA_DIRS at the next login, so video thumbnails come back" + echo "after you log out and back in." + echo "The container image is also pulled on first use, so the first" + echo "thumbnail after logging back in may fail while the pull completes." else echo "Failed to enable the ffmpeg video thumbnailer" >&2 exit 1 @@ -124,6 +127,13 @@ status-ffmpeg-thumbnailer: # Must match ENV_OVERRIDE in toggle-ffmpeg-thumbnailer. ENV_OVERRIDE="${XDG_CONFIG_HOME:-${HOME}/.config}/environment.d/10-ffmpeg-thumbnailer.conf" + # Same probe as toggle-ffmpeg-thumbnailer, so both recipes agree on whether + # the thumbnailer exists instead of printing unknown/absent rows for it. + if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1; then + echo "The ffmpeg video thumbnailer is not installed on this system." + exit 0 + fi + echo "Units (enabled-state / active-state):" for unit in "${THUMBNAILER_UNITS[@]}"; do ENABLED_STATE="$(systemctl --user is-enabled "${unit}" 2>/dev/null || true)" diff --git a/tests/test_shared_just.bats b/tests/test_shared_just.bats index 19e805408..be6fd1fd1 100644 --- a/tests/test_shared_just.bats +++ b/tests/test_shared_just.bats @@ -382,6 +382,17 @@ _make_socket() { [[ "${result}" == *"has been enabled"* ]] } +@test "toggle-ffmpeg-thumbnailer: disable and enable both state the re-login caveat" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=disable + [ "${status}" -eq 0 ] + [[ "${output}" == *"log out and back in"* ]] + + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + [ "${status}" -eq 0 ] + [[ "${output}" == *"XDG_DATA_DIRS"* ]] + [[ "${output}" == *"log out and back in"* ]] +} + @test "toggle-ffmpeg-thumbnailer: enable never masks" { _run_thumbnailer toggle-thumbnailer.sh ACTION=enable @@ -498,6 +509,17 @@ _make_socket() { # --- ffmpeg thumbnailer status ------------------------------------------------ +@test "status-ffmpeg-thumbnailer: a missing thumbnailer is reported like the toggle does" { + _run_thumbnailer status-thumbnailer.sh \ + MOCK_UNIT_PRESENT=0 XDG_CACHE_HOME="${WORKDIR}/cache" + result="${output}" + + [ "${status}" -eq 0 ] + [[ "${result}" == *"not installed"* ]] + [[ "${result}" != *"Units (enabled-state"* ]] + [[ "${result}" != *"Environment override:"* ]] +} + @test "status-ffmpeg-thumbnailer: reports every unit's enabled and active state" { _run_thumbnailer status-thumbnailer.sh \ MOCK_MASKED=1 MOCK_ACTIVE_STATE=inactive XDG_CACHE_HOME="${WORKDIR}/cache" From 2a09d4bdee1d8f4ea4ebf2b0dbe875cc42aa203a Mon Sep 17 00:00:00 2001 From: "sec-check[bot]" Date: Tue, 29 Sep 2026 13:02:39 -0400 Subject: [PATCH 6/6] fix(thumbnailer): start the quadlets with --no-block on enable `ujust toggle-ffmpeg-thumbnailer enable` started the container quadlets with a blocking `systemctl --user start` and discarded its output. The quadlets carry TimeoutStartSec=900 and pull a 111 MB image on first start, so the recipe could sit silently for minutes. Start them (and the daemon) with --no-block and say that the pull now runs in the background, pointing at status-ffmpeg-thumbnailer to check on it. Also make the install probe survive a `systemctl --user cat` that fails on a masked unit by falling back to `is-enabled`, in both the toggle and status recipes, and correct two comments/doc claims: the unit array's order is cosmetic because stop/mask take all three units in one call (the daemon's After= is what sequences the stop), and nothing in the image pulls the container quadlets in, so this recipe is their only starter. Hive-Fix-Attempt: 4/6 Assisted-by: Claude Opus 5 via GitHub Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../shell-scripts/references/bats-patterns.md | 23 +++++++++ docs/skills/systemd-user-units.md | 17 +++++-- .../usr/share/ublue-os/just/shared.just | 27 +++++++---- tests/test_shared_just.bats | 47 +++++++++++++++++-- 4 files changed, 96 insertions(+), 18 deletions(-) diff --git a/docs/skills/shell-scripts/references/bats-patterns.md b/docs/skills/shell-scripts/references/bats-patterns.md index b9ca5ed55..4e9fa69b5 100644 --- a/docs/skills/shell-scripts/references/bats-patterns.md +++ b/docs/skills/shell-scripts/references/bats-patterns.md @@ -134,6 +134,29 @@ run grep -Fqx "systemctl --user mask ffmpeg-thumbnailer-daemon.service" <<< "$(_ For ordering guarantees (stop before mask, prompt before mutation), compare `grep -n` line numbers from the same log. +### Assert the flags a stub cannot feel + +A stub returns instantly, so it can never reproduce a blocking call. Anything +whose real cost is latency — `systemctl start` on a quadlet that pulls an +image, a network fetch, a `--wait` flag — has to be pinned as a flag assertion +instead. Assert over every matching call, not just the one you remember: + +```bash +run grep -E "^systemctl --user (start|enable --now)( |$)" <<< "$(_calls)" +[ "${status}" -eq 0 ] +while read -r line; do + [[ "${line}" == *" --no-block"* ]] +done <<< "${output}" +``` + +### Give each real failure mode its own stub knob + +One knob per mock behaviour, not one per unit state. `systemctl cat` and +`systemctl is-enabled` fail independently on a real system (a masked unit can +fail the first and still answer the second), so the stub needs +`MOCK_UNIT_PRESENT` *and* `MOCK_CAT_FAIL`. Collapsing them into one flag makes +the suite unable to express the case a fallback exists for. + Then run: `bash "${extracted_script}"` with mocked PATH binaries. ## Pitfall: literal `*` in bats grep assertions diff --git a/docs/skills/systemd-user-units.md b/docs/skills/systemd-user-units.md index 6f7ff699c..c12953eff 100644 --- a/docs/skills/systemd-user-units.md +++ b/docs/skills/systemd-user-units.md @@ -65,8 +65,10 @@ writing a toggle: the thumbnailer. - The quadlet generator. `ffmpeg-thumbnailer.container` and `ffmpeg-thumbnailer-nvidia.container` carry no `[Install]` section at all; - the generator materialises `.service` at user-manager start and they - are started on demand or by another unit's dependency. + the generator materialises `.service` at user-manager start, but + nothing in the image pulls those services in. The daemon only orders itself + `After=` them, which does not start them, so the sole starter today is the + `enable` branch of `ujust toggle-ffmpeg-thumbnailer`. Two consequences for anything you write in `common`: @@ -101,11 +103,16 @@ systemctl --user unmask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.ser Re-enabling also needs `systemctl --user daemon-reload`, and starting the container quadlets explicitly if thumbnails must work before the next login. -A mask is durable user state: it survives image updates until `unmask` (or -deleting the symlink) removes it. +Start them with `--no-block`: they carry `TimeoutStartSec=900` and pull a +111 MB image on first start, so a blocking `start` stalls the caller for +minutes. A mask is durable user state: it survives image updates until +`unmask` (or deleting the symlink) removes it. Stop before mask, in that order: the daemon unlinks its socket on `SIGTERM`, so -masking a live unit can leave a stale socket path behind. +masking a live unit can leave a stale socket path behind. A single +`systemctl --user stop a b c` is enough — argument order does not sequence +anything; the daemon's `After=` on the container units is what makes systemd +stop it first. ## Masking is not enough: the `environment.d` drop-in diff --git a/system_files/shared/usr/share/ublue-os/just/shared.just b/system_files/shared/usr/share/ublue-os/just/shared.just index b7a516af0..1f9c5d57e 100644 --- a/system_files/shared/usr/share/ublue-os/just/shared.just +++ b/system_files/shared/usr/share/ublue-os/just/shared.just @@ -31,7 +31,10 @@ toggle-ffmpeg-thumbnailer ACTION="prompt": ACTION_VALUE="{{ ACTION }}" DAEMON_UNIT="ffmpeg-thumbnailer-daemon.service" - # Order matters: the daemon is stopped first so it unlinks its socket. + # Array order is cosmetic: stop/mask/unmask take all three units in one + # call, where argument order does not affect sequencing. The daemon still + # gets SIGTERM before the containers go away because its unit orders itself + # with After= on them, which is what lets it unlink its socket. THUMBNAILER_UNITS=( ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service @@ -41,7 +44,8 @@ toggle-ffmpeg-thumbnailer ACTION="prompt": # otherwise keeps the shim first in XDG_DATA_DIRS for video MIME types. ENV_OVERRIDE="${XDG_CONFIG_HOME:-${HOME}/.config}/environment.d/10-ffmpeg-thumbnailer.conf" - if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1; then + if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1 \ + && [[ -z "$(systemctl --user is-enabled "${DAEMON_UNIT}" 2>/dev/null)" ]]; then echo "The ffmpeg video thumbnailer is not installed on this system." exit 0 fi @@ -96,14 +100,18 @@ toggle-ffmpeg-thumbnailer ACTION="prompt": if systemctl --user unmask "${THUMBNAILER_UNITS[@]}"; then rm -f "${ENV_OVERRIDE}" systemctl --user daemon-reload >/dev/null 2>&1 || true - systemctl --user enable --now "${DAEMON_UNIT}" >/dev/null 2>&1 || true - systemctl --user start "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true + systemctl --user enable --now --no-block "${DAEMON_UNIT}" >/dev/null 2>&1 || true + # --no-block: the container quadlets carry TimeoutStartSec=900 and + # pull a 111 MB image on their first start, so a blocking start + # would stall this recipe for minutes with its output discarded. + systemctl --user start --no-block "${THUMBNAILER_UNITS[@]}" >/dev/null 2>&1 || true echo "The ffmpeg video thumbnailer has been enabled" - echo "Its units are unmasked and started, but the shim only returns to" + echo "Its units are unmasked and starting, but the shim only returns to" echo "XDG_DATA_DIRS at the next login, so video thumbnails come back" echo "after you log out and back in." - echo "The container image is also pulled on first use, so the first" - echo "thumbnail after logging back in may fail while the pull completes." + echo "The container image is pulled in the background by that start, so" + echo "the first thumbnails after logging back in may fail until it lands." + echo "Run 'ujust status-ffmpeg-thumbnailer' to see whether it is up." else echo "Failed to enable the ffmpeg video thumbnailer" >&2 exit 1 @@ -129,7 +137,10 @@ status-ffmpeg-thumbnailer: # Same probe as toggle-ffmpeg-thumbnailer, so both recipes agree on whether # the thumbnailer exists instead of printing unknown/absent rows for it. - if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1; then + # The is-enabled fallback keeps a masked daemon counting as installed even + # if `systemctl --user cat` fails on it. + if ! systemctl --user cat "${DAEMON_UNIT}" >/dev/null 2>&1 \ + && [[ -z "$(systemctl --user is-enabled "${DAEMON_UNIT}" 2>/dev/null)" ]]; then echo "The ffmpeg video thumbnailer is not installed on this system." exit 0 fi diff --git a/tests/test_shared_just.bats b/tests/test_shared_just.bats index be6fd1fd1..f5ce3a4b2 100644 --- a/tests/test_shared_just.bats +++ b/tests/test_shared_just.bats @@ -63,17 +63,21 @@ EOF printf 'bctl %s\n' "$*" >> "${CALLS}" EOF - # systemctl stub. MOCK_UNIT_PRESENT=0 makes `systemctl --user cat` fail, - # which is how the recipes detect that the thumbnailer is not installed. + # systemctl stub. MOCK_UNIT_PRESENT=0 makes both `systemctl --user cat` and + # `systemctl --user is-enabled` fail, which is how the recipes detect that + # the thumbnailer is not installed. MOCK_CAT_FAIL=1 fails only `cat`, the + # way a masked unit can, so the is-enabled fallback is exercised. cat > "${WORKDIR}/bin/systemctl" <<'EOF' #!/usr/bin/bash printf 'systemctl %s\n' "$*" >> "${CALLS}" [[ "${1:-}" == "--user" ]] && shift case "${1:-}" in cat) - [[ "${MOCK_UNIT_PRESENT:-1}" == "1" ]] && exit 0 || exit 1 + [[ "${MOCK_UNIT_PRESENT:-1}" == "1" && "${MOCK_CAT_FAIL:-0}" == "0" ]] || exit 1 + exit 0 ;; is-enabled) + [[ "${MOCK_UNIT_PRESENT:-1}" == "1" ]] || exit 1 [[ "${MOCK_STATE_FAIL:-0}" == "1" ]] && exit 1 if [[ "${MOCK_MASKED:-0}" == "1" ]]; then printf 'masked\n' @@ -82,6 +86,7 @@ case "${1:-}" in printf '%s\n' "${MOCK_ENABLED_STATE:-enabled}" ;; is-active) + [[ "${MOCK_UNIT_PRESENT:-1}" == "1" ]] || exit 1 [[ "${MOCK_STATE_FAIL:-0}" == "1" ]] && exit 1 printf '%s\n' "${MOCK_ACTIVE_STATE:-inactive}" ;; @@ -375,13 +380,45 @@ _make_socket() { [ "${status}" -eq 0 ] run grep -Fqx "systemctl --user daemon-reload" <<< "$(_calls)" [ "${status}" -eq 0 ] - run grep -Fqx "systemctl --user enable --now ffmpeg-thumbnailer-daemon.service" <<< "$(_calls)" + run grep -Fqx "systemctl --user enable --now --no-block ffmpeg-thumbnailer-daemon.service" <<< "$(_calls)" [ "${status}" -eq 0 ] - run grep -Fqx "systemctl --user start ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + run grep -Fqx "systemctl --user start --no-block ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" [ "${status}" -eq 0 ] [[ "${result}" == *"has been enabled"* ]] } +@test "toggle-ffmpeg-thumbnailer: enable never issues a blocking start" { + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable + [ "${status}" -eq 0 ] + + # Quadlets carry TimeoutStartSec=900 and pull a 111 MB image on first + # start, so every start/enable --now must hand back control immediately. + run grep -E "^systemctl --user (start|enable --now)( |$)" <<< "$(_calls)" + [ "${status}" -eq 0 ] + while read -r line; do + [[ "${line}" == *" --no-block"* ]] + done <<< "${output}" +} + +@test "toggle-ffmpeg-thumbnailer: a masked daemon still counts as installed" { + # A masked unit can fail the `cat` probe; is-enabled still reports it, so + # the recipe must not claim the thumbnailer is missing and skip the toggle. + _run_thumbnailer toggle-thumbnailer.sh ACTION=enable MOCK_CAT_FAIL=1 MOCK_MASKED=1 + + [ "${status}" -eq 0 ] + [[ "${output}" != *"not installed"* ]] + run grep -Fqx "systemctl --user unmask ffmpeg-thumbnailer-daemon.service ffmpeg-thumbnailer.service ffmpeg-thumbnailer-nvidia.service" <<< "$(_calls)" + [ "${status}" -eq 0 ] +} + +@test "status-ffmpeg-thumbnailer: a masked daemon still counts as installed" { + _run_thumbnailer status-thumbnailer.sh MOCK_CAT_FAIL=1 MOCK_MASKED=1 + + [ "${status}" -eq 0 ] + [[ "${output}" != *"not installed"* ]] + [[ "${output}" == *"Units (enabled-state / active-state):"* ]] +} + @test "toggle-ffmpeg-thumbnailer: disable and enable both state the re-login caveat" { _run_thumbnailer toggle-thumbnailer.sh ACTION=disable [ "${status}" -eq 0 ]