iso: enable bakery user units globally for later accounts

Bins live in /usr/local, so a later useradd no longer gets
~/.local/bin copies. systemctl --global enable the bakery
--user units (bake writes /etc/systemd/user/*.wants/, and
post-install + live-setup run the same enable) so first
login starts breadd, breadbox-sync, breadclipd, breadcrumbs,
and breadmill. Stock useradd -m copies skel (Hyprland +
bakery state). Rollback is still grub-btrfs.
This commit is contained in:
Breadway 2026-08-16 00:27:14 +08:00
parent 34043086b9
commit 70d4dd424b
12 changed files with 336 additions and 21 deletions

View file

@ -129,10 +129,11 @@ machine's bakery-installed bread binaries + breadhelp content from the
builder's `~/.local` into the image at `/usr/local` (bins, share/data,
desktop files, licenses) and `/usr/lib/systemd/user` (units). Per-user
bakery state (`installed.json` + index cache) is seeded in `/etc/skel`.
BOS opts in via `/etc/bakery/config.toml` (`prefix = "/usr/local"`);
default bakery without that file is still `~/.local`. Snapper `@`
snapshots include `/usr/local`; recovery is still grub-btrfs, not
`snapper rollback`.
User units are `systemctl --global enable`'d so a later `useradd -m`
starts them on first login. BOS opts in via `/etc/bakery/config.toml`
(`prefix = "/usr/local"`); default bakery without that file is still
`~/.local`. Snapper `@` snapshots include `/usr/local`; recovery is
still grub-btrfs, not `snapper rollback`.
```sh
sudo ./build-local.sh # release-quality (xz squashfs)
@ -197,8 +198,38 @@ Hyprland session in QEMU. The disk lives on NVMe (not the tmpfs `/tmp`) to
avoid memory pressure.
Post-install, `scripts/smoke-test.sh` (run as the installed user) checks
subvolumes, services, bakery bins on PATH, and breadhelp content under
`/usr/local/share/breadhelp/content`.
subvolumes, services, bakery bins on PATH, breadhelp content under
`/usr/local/share/breadhelp/content`, and that bakery user units are
`--global` enabled (or the preset / wants files exist).
## Second account
Bakery desktop apps live in `/usr/local` — shared, already on PATH. A later
account does **not** get a private copy of those binaries.
`/etc/default/useradd` keeps `SKEL=/etc/skel`. Stock `useradd -m` is enough:
```sh
sudo useradd -m alice
sudo passwd alice
```
- **Apps**: `/usr/local/bin` (and `/usr/local/share`) — already there.
- **Session files**: `useradd -m` copies `/etc/skel` (Hyprland, bread
config, bakery `installed.json` + index cache) so first login has a
session. Skel does not contain bakery binaries.
- **Daemons**: `breadd`, `breadbox-sync`, `breadclipd`, `breadcrumbs`,
`breadmill`, … are `systemctl --global enable`'d at install (and on
the live image). Creating a user starts them on first login.
- **Login**: greetd/breadgreet lists any local user with a login shell
(`SHELL=/usr/bin/zsh` is the useradd default).
`breadclipd` is WantedBy=`graphical-session.target`. BOS does not activate
that target (no uwsm), so Hyprland still `systemctl --user start`s it after
the compositor is up. `--global enable` still records it for every account.
Rollback is still the GRUB snapshots submenu (grub-btrfs), not
`snapper rollback`. `/usr/local` rides the `@` snapshot.
## bos-settings

View file

@ -249,26 +249,87 @@ PY
# bakery package's service (breadbox-sync, breadmill, breadclipd, ...) was
# silently left out, so those daemons never start on a fresh install/live
# boot until the user re-runs `bakery install` (which needs network).
# Source of truth is the *filtered* installed.json we just wrote: only
# lockfile packages. Units go to /usr/lib/systemd/user with ExecStart
# rewritten to /usr/local/bin (not %h/.local/bin). Recreate whichever
# Units come from installed.json + the bakery index + local unit files
# whose ExecStart is a lockfile binary (installed.json has omitted
# breadcrumbs.service before). Units go to /usr/lib/systemd/user with
# ExecStart rewritten to /usr/local/bin. Recreate whichever
# *.target.wants enable symlink bakery created locally (or that skel
# already ships). Hand-committed skel units (breadd.service carries a
# already ships), and write /etc/systemd/user/*.wants/ (--global).
# Hand-committed skel units (breadd.service carries a
# RuntimeDirectoryPreserve=yes fix not yet upstreamed) are the source
# for that unit and also get their ExecStart rewritten in skel.
echo "=== baking bakery service units into /usr/lib/systemd/user ==="
SYSTEMD_USER_DIR="$LAPTOP_HOME/.config/systemd/user"
SKEL_SYSTEMD="$SKEL/.config/systemd/user"
install -d -m 0755 "$IMAGE_UNITS"
mapfile -t SERVICE_UNITS < <(python3 - "$SKEL/.local/state/bakery/installed.json" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
d = json.load(f)
for pkg in d.get("packages", d).values():
for s in pkg.get("services", []):
print(s["unit"] if isinstance(s, dict) else s)
# installed.json on the builder can omit a service even when the index and
# the local unit file exist (breadcrumbs has done this). Merge all three
# so every lockfile daemon is baked and can be --global enabled.
mapfile -t SERVICE_UNITS < <(python3 - \
"$SKEL/.local/state/bakery/installed.json" \
"$BAKERY_CACHE/index.json" \
"$SYSTEMD_USER_DIR" \
"${BREAD_BINS[@]}" <<'PY'
import json, os, sys
installed_path, index_path, user_dir, *bins = sys.argv[1:]
wanted = set(bins)
units = set()
def add_svc(svc):
name = svc["unit"] if isinstance(svc, dict) else svc
if not name or str(name).startswith(("breadcast", "breadarr")):
return
units.add(str(name))
if os.path.isfile(installed_path):
with open(installed_path) as f:
data = json.load(f)
for pkg in data.get("packages", data).values():
if isinstance(pkg, dict):
for svc in pkg.get("services") or []:
add_svc(svc)
if os.path.isfile(index_path):
with open(index_path) as f:
idx = json.load(f)
for name, pkg in (idx.get("packages") or {}).items():
if not isinstance(pkg, dict):
continue
pbins = []
for b in pkg.get("binaries") or []:
n = b["name"] if isinstance(b, dict) else b
pbins.append(str(n).removesuffix("-x86_64"))
if name in wanted or any(b in wanted for b in pbins):
for svc in pkg.get("services") or []:
add_svc(svc)
if os.path.isdir(user_dir):
for fn in os.listdir(user_dir):
if not fn.endswith(".service"):
continue
path = os.path.join(user_dir, fn)
if not os.path.isfile(path):
continue
try:
text = open(path).read()
except OSError:
continue
for line in text.splitlines():
if line.lstrip().startswith("ExecStart="):
argv0 = line.split("=", 1)[1].split()
if argv0 and os.path.basename(argv0[0]) in wanted:
add_svc(fn)
break
for unit in sorted(units):
print(unit)
PY
)
if [[ ! " ${SERVICE_UNITS[*]} " =~ " breadd.service " ]]; then
echo "ERROR: breadd.service not in the bakery unit list — refusing to bake" >&2
exit 1
fi
rewrite_exec_start() {
local src="$1" dest="$2"
python3 - "$src" "$dest" <<'PY'
@ -301,7 +362,7 @@ for unit in "${SERVICE_UNITS[@]}"; do
else
src="$SYSTEMD_USER_DIR/$unit"
if [[ ! -f "$src" ]]; then
echo "ERROR: $unit listed in bakery installed.json but not found at $src" >&2
echo "ERROR: $unit listed as a bakery service but not found at $src" >&2
echo "Refusing to bake an image whose daemons will never start." >&2
exit 1
fi
@ -320,9 +381,50 @@ for unit in "${SERVICE_UNITS[@]}"; do
ln -sf "../$unit" "$IMAGE_UNITS/$target_name/$unit"
done
done
# systemctl --global enable equivalent: /etc/systemd/user/<WantedBy>.wants/
# so the live image and a later useradd inherit the unit without a per-home
# enable. Vendor wants above are extra; this is what --global writes.
python3 - "$IMAGE_UNITS/$unit" "$AIROOTFS/etc/systemd/user" "$unit" <<'PY'
import os, sys
unit_path, etc_user, unit = sys.argv[1:]
in_install = False
targets = []
for line in open(unit_path):
s = line.strip()
if s.startswith("[") and s.endswith("]"):
in_install = s == "[Install]"
continue
if in_install and s.startswith("WantedBy="):
targets.extend(t for t in s.split("=", 1)[1].split() if t)
for target in targets:
wants = os.path.join(etc_user, f"{target}.wants")
os.makedirs(wants, exist_ok=True)
dest = os.path.join(wants, unit)
if os.path.lexists(dest):
os.remove(dest)
os.symlink(f"/usr/lib/systemd/user/{unit}", dest)
print(f" global enable {unit} -> {dest}")
PY
echo " baked $unit -> $IMAGE_UNITS/$unit"
done
# Document the baked set. The committed preset is the fallback; the staged
# copy lists whatever this bake actually shipped.
preset_dest="$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset"
install -d -m 0755 "$(dirname "$preset_dest")"
{
echo "# Bakery systemd --user units baked into this image."
echo "# Applied by systemctl --global enable (post-install + live setup)"
echo "# so a later useradd starts them on first login."
echo "# breadclipd is also started from hyprland.lua: WantedBy="
echo "# graphical-session.target is not reached on BOS (no uwsm)."
for unit in "${SERVICE_UNITS[@]}"; do
[[ -n "$unit" ]] || continue
printf 'enable %s\n' "$unit"
done
} >"$preset_dest"
echo " wrote $preset_dest"
# mkarchiso resets every airootfs file to 0644, so executables must be declared
# in profiledef.sh's file_permissions array or they ship non-executable and the
# exec-once launches fail with "permission denied". Inject a 0755 entry for each

View file

@ -430,6 +430,15 @@ fi
# /usr/lib/systemd/user (system prefix). Per-user bakery state (installed.json
# + index cache) is seeded from /etc/skel/.local and copied into the user's
# home below, so the install works fully offline with no DNS for bakery.
#
# systemd --user units in /usr/lib/systemd/user are not enabled for new
# accounts unless enabled --global (or the user enables them). Do that here
# so a later `useradd -m` starts breadd / breadbox-sync / breadclipd /
# breadcrumbs / breadmill on first login. Safe if the helper is missing.
if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then
/usr/local/bin/bos-enable-bakery-user-units \
|| echo "WARN: enabling bakery user units globally failed"
fi
# ---------------------------------------------------------------------------
# Deploy dotfiles + the bakery bread ecosystem into the user's home (Calamares

View file

@ -3,5 +3,8 @@ GROUP=users
HOME=/home
INACTIVE=-1
EXPIRE=
# useradd -m copies Hyprland + bakery per-user state from here. Bakery
# binaries live in /usr/local/bin (not skel). User units are enabled
# --global so a second account starts them on first login.
SKEL=/etc/skel
CREATE_MAIL_SPOOL=no

View file

@ -141,7 +141,8 @@ hl.on("hyprland.start", function()
-- pywal only runs for real once the user picks a wallpaper themselves.
[[bash -c 'until awww img /usr/share/backgrounds/bos/bread-background.png 2>/dev/null; do sleep 0.3; done']],
-- breadd runs as a systemd user service (/usr/lib/systemd/user/breadd.service,
-- plus a skel copy). It autostarts at login but before Hyprland exists, so
-- enabled --global so every account starts it). It autostarts at login
-- but before Hyprland exists, so
-- push the compositor's Wayland env into the user manager and restart breadd
-- to pick it up — that's how it gets HYPRLAND_INSTANCE_SIGNATURE to talk to Hyprland.
"dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE",

View file

@ -0,0 +1 @@
/usr/lib/systemd/user/breadd.service

View file

@ -0,0 +1,12 @@
# Bakery systemd --user units. `systemctl --global enable` (post-install and
# live setup) applies these so a later `useradd -m` starts them on first login.
# Bake rewrites this list from the units actually copied into the image.
#
# breadclipd is WantedBy=graphical-session.target. BOS does not activate that
# target (no uwsm); Hyprland still `systemctl --user start`s it after the
# compositor is up. --global enable still records it for every account.
enable breadd.service
enable breadbox-sync.service
enable breadclipd.service
enable breadcrumbs.service
enable breadmill.service

View file

@ -0,0 +1,90 @@
#!/bin/bash
# Enable bakery systemd --user units for every account (current and future).
#
# `systemctl --global enable` writes /etc/systemd/user/<target>.wants/ so a
# later `useradd -m` does not need per-home enablement. Bins live in
# /usr/local; only per-user state comes from skel.
#
# Safe on the live image and in the Calamares post-install chroot.
# Idempotent. Does not start units (no user session required).
#
# breadclipd is WantedBy=graphical-session.target. BOS does not activate
# that target (no uwsm), so Hyprland still `systemctl --user start`s it.
# --global enable still records it for every account / bos-settings.
set -uo pipefail
UNITS_DIR=/usr/lib/systemd/user
PRESET=/usr/lib/systemd/user-preset/90-bos-bakery.preset
is_blocked() {
case "$1" in
breadcast*|breadarr*) return 0 ;;
*) return 1 ;;
esac
}
is_bakery_unit() {
local unit="$1" path="$UNITS_DIR/$unit"
[[ -f "$path" ]] || return 1
is_blocked "$unit" && return 1
grep -qE '^ExecStart=/usr/local/bin/' "$path"
}
list_from_preset() {
[[ -f "$PRESET" ]] || return 0
awk '/^enable[[:space:]]/ { print $2 }' "$PRESET"
}
list_from_units_dir() {
[[ -d "$UNITS_DIR" ]] || return 0
local path unit
for path in "$UNITS_DIR"/*.service; do
[[ -f "$path" ]] || continue
unit="$(basename "$path")"
is_bakery_unit "$unit" && printf '%s\n' "$unit"
done
}
list_from_installed_json() {
local json=/etc/skel/.local/state/bakery/installed.json
[[ -f "$json" ]] || return 0
command -v python3 >/dev/null 2>&1 || return 0
python3 - "$json" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
data = json.load(f)
for pkg in data.get("packages", data).values():
if not isinstance(pkg, dict):
continue
for svc in pkg.get("services") or []:
name = svc["unit"] if isinstance(svc, dict) else svc
if name and not str(name).startswith(("breadcast", "breadarr")):
print(name)
PY
}
mapfile -t units < <(
{ list_from_preset; list_from_units_dir; list_from_installed_json; } \
| sed '/^$/d' | sort -u
)
if [[ ${#units[@]} -eq 0 ]]; then
echo "WARN: no bakery user units found to enable globally"
exit 0
fi
if ! command -v systemctl >/dev/null 2>&1; then
echo "WARN: systemctl missing — cannot --global enable bakery user units"
exit 0
fi
for unit in "${units[@]}"; do
[[ -f "$UNITS_DIR/$unit" ]] || continue
is_blocked "$unit" && continue
if ! grep -q '^\[Install\]' "$UNITS_DIR/$unit"; then
echo "WARN: $unit has no [Install] section — skip --global enable"
continue
fi
systemctl --global enable "$unit" \
|| echo "WARN: systemctl --global enable $unit failed"
done

View file

@ -7,9 +7,17 @@
# bos-launch-calamares). Runs once at boot, before the tty1 autologin getty.
set -e
# Bakery user units live in /usr/lib/systemd/user. --global enable writes
# /etc/systemd/user/*.wants/ so liveuser (and any later account) starts
# them on first login. Idempotent; bins are already in /usr/local.
if [[ -x /usr/local/bin/bos-enable-bakery-user-units ]]; then
/usr/local/bin/bos-enable-bakery-user-units \
|| echo "WARN: enabling bakery user units globally failed"
fi
# useradd -m copies /etc/skel, so the live user gets the real BOS desktop
# (breadd + breadbar + breadbox + keybinds) — proper live-media functionality,
# not an installer kiosk.
# (hypr + bread config + bakery state) — proper live-media functionality,
# not an installer kiosk. Binaries are /usr/local, not skel.
if ! id liveuser &>/dev/null; then
useradd -m -s /usr/bin/zsh liveuser
for g in wheel video input audio storage power; do

View file

@ -31,4 +31,5 @@ file_permissions=(
["/usr/local/bin/bos-update"]="0:0:755"
["/usr/local/bin/bos-rescue"]="0:0:755"
["/usr/local/bin/bos-first-boot"]="0:0:755"
["/usr/local/bin/bos-enable-bakery-user-units"]="0:0:755"
)

View file

@ -151,6 +151,7 @@ if [[ -n "$AIROOTFS" || -n "$SKEL" ]]; then
bad "skel still has bakery bin $b (belongs in /usr/local/bin)"
fi
done
check_file "$SKEL/.config/hypr/hyprland.lua" "skel hyprland.lua"
fi
image_units_json=""
if [[ -n "$SKEL" && -f "$SKEL/.local/state/bakery/installed.json" ]]; then
@ -183,6 +184,34 @@ PY
fi
fi
done
check_file "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" \
"bakery user preset"
if [[ -L "$AIROOTFS/etc/systemd/user/default.target.wants/breadd.service" ]] \
|| [[ -f "$AIROOTFS/etc/systemd/user/default.target.wants/breadd.service" ]]; then
ok "breadd.service globally enabled (etc wants)"
else
bad "breadd.service missing from /etc/systemd/user/default.target.wants"
fi
# After bake the image has /usr/local/bin/breadd and every preset unit.
# The committed airootfs only has the preset + breadd wants.
if [[ -f "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset" ]] \
&& [[ -x "$AIROOTFS/usr/local/bin/breadd" ]]; then
while read -r verb unit; do
[[ "$verb" == enable && -n "$unit" ]] || continue
check_file "$AIROOTFS/usr/lib/systemd/user/$unit" "preset unit $unit"
if [[ -L "$AIROOTFS/etc/systemd/user/default.target.wants/$unit" ]] \
|| [[ -L "$AIROOTFS/etc/systemd/user/graphical-session.target.wants/$unit" ]]; then
ok "$unit globally enabled (etc wants)"
else
bad "$unit missing from /etc/systemd/user/*.target.wants"
fi
done < "$AIROOTFS/usr/lib/systemd/user-preset/90-bos-bakery.preset"
fi
if [[ -x "$AIROOTFS/usr/local/bin/bos-enable-bakery-user-units" ]]; then
ok "bos-enable-bakery-user-units executable"
else
bad "bos-enable-bakery-user-units missing or not executable"
fi
fi
fi

View file

@ -68,6 +68,34 @@ check "bos-netcheck present" "command -v bos-netcheck"
check "bos-rescue present" "command -v bos-rescue"
check "bos-first-boot present" "command -v bos-first-boot"
echo "== bakery user units (global enable) =="
# A later useradd does not enable --user units unless they were enabled
# --global (or the user enables them). post-install + live-setup + bake
# write /etc/systemd/user/<target>.wants/ and a preset listing the set.
check "bakery user preset present" \
"[ -f /usr/lib/systemd/user-preset/90-bos-bakery.preset ]"
check "bos-enable-bakery-user-units present" \
"command -v bos-enable-bakery-user-units"
check "breadd.service globally enabled" \
"systemctl --global is-enabled breadd.service || [ -L /etc/systemd/user/default.target.wants/breadd.service ]"
if [[ -f /usr/lib/systemd/user-preset/90-bos-bakery.preset ]]; then
while read -r verb unit; do
[[ "$verb" == enable && -n "$unit" ]] || continue
[[ -f /usr/lib/systemd/user/$unit ]] || continue
check "$unit globally enabled" \
"systemctl --global is-enabled $unit || [ -L /etc/systemd/user/default.target.wants/$unit ] || [ -L /etc/systemd/user/graphical-session.target.wants/$unit ]"
done < /usr/lib/systemd/user-preset/90-bos-bakery.preset
fi
check "skel hyprland.lua present" "[ -f /etc/skel/.config/hypr/hyprland.lua ]"
check "skel bakery installed.json present" \
"[ -f /etc/skel/.local/state/bakery/installed.json ]"
check "skel bakery index cache present" \
"[ -f /etc/skel/.cache/bakery/index.json ]"
check "skel has no bakery binaries" \
"! [ -e /etc/skel/.local/bin/bakery ] && ! [ -e /etc/skel/.local/bin/breadd ]"
check "useradd SKEL is /etc/skel" \
"grep -q '^SKEL=/etc/skel' /etc/default/useradd"
echo "== default dotfiles =="
check "hyprland.lua present" "[ -f \"\$HOME/.config/hypr/hyprland.lua\" ]"
check "binds.json present" "[ -f \"\$HOME/.config/hypr/binds.json\" ]"