diff --git a/README.md b/README.md index 387c5f4..e8f2eba 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build-local.sh b/build-local.sh index 8548d86..e360d42 100755 --- a/build-local.sh +++ b/build-local.sh @@ -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/.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 diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index ac13a97..5817f2e 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -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 diff --git a/iso/airootfs/etc/default/useradd b/iso/airootfs/etc/default/useradd index f16b7d8..4cae86c 100644 --- a/iso/airootfs/etc/default/useradd +++ b/iso/airootfs/etc/default/useradd @@ -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 diff --git a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua index 07ed155..c6c8e0b 100644 --- a/iso/airootfs/etc/skel/.config/hypr/hyprland.lua +++ b/iso/airootfs/etc/skel/.config/hypr/hyprland.lua @@ -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", diff --git a/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service b/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service new file mode 120000 index 0000000..d284527 --- /dev/null +++ b/iso/airootfs/etc/systemd/user/default.target.wants/breadd.service @@ -0,0 +1 @@ +/usr/lib/systemd/user/breadd.service \ No newline at end of file diff --git a/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset b/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset new file mode 100644 index 0000000..f7f73f9 --- /dev/null +++ b/iso/airootfs/usr/lib/systemd/user-preset/90-bos-bakery.preset @@ -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 diff --git a/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units b/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units new file mode 100755 index 0000000..46c128e --- /dev/null +++ b/iso/airootfs/usr/local/bin/bos-enable-bakery-user-units @@ -0,0 +1,90 @@ +#!/bin/bash +# Enable bakery systemd --user units for every account (current and future). +# +# `systemctl --global enable` writes /etc/systemd/user/.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 diff --git a/iso/airootfs/usr/local/bin/bos-live-setup b/iso/airootfs/usr/local/bin/bos-live-setup index f7b1d26..0609cac 100644 --- a/iso/airootfs/usr/local/bin/bos-live-setup +++ b/iso/airootfs/usr/local/bin/bos-live-setup @@ -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 diff --git a/iso/profiledef.sh b/iso/profiledef.sh index 5034cbc..f52295b 100644 --- a/iso/profiledef.sh +++ b/iso/profiledef.sh @@ -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" ) diff --git a/scripts/ci-verify-bake.sh b/scripts/ci-verify-bake.sh index 0fcd06a..68e357d 100755 --- a/scripts/ci-verify-bake.sh +++ b/scripts/ci-verify-bake.sh @@ -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 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index d5b6fa9..81bad6a 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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/.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\" ]"