Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
21 changes: 20 additions & 1 deletion docs/skills/index.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"generated_at": "2026-09-26",
"generated_at": "2026-09-27",
"schema_version": "1.0",
"skills": [
{
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion docs/skills/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|---|
Expand Down Expand Up @@ -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. |
87 changes: 87 additions & 0 deletions docs/skills/shell-scripts/references/bats-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,93 @@ _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.

### 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
Expand Down
207 changes: 207 additions & 0 deletions docs/skills/systemd-user-units.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
---
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/<target>.wants/<unit>` | 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 `<filename-stem>.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: `[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/<target>.wants/<unit>` 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 `<stem>.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`:

- 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 <unit>` 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. 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 <unit>` 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 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
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.
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. 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

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. 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. 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

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 <unit>`) 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 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 <quadlet>.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 `{{ ... }}`
- 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

## 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/"

# 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
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
Loading
Loading