git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

scripts/ccebuild (37.8K)

  1 #!/usr/bin/env bash
  2 # ccebuild — build, install and inspect every binary in the cce workspace.
  3 #
  4 # The per-crate Makefiles name their binaries by hand, so a crate with extra
  5 # [[bin]] targets (cce-ui's cce-bevel/cce-ramp, cce-display-manager's keyring
  6 # helpers) or a crate with no Makefile at all silently never gets installed —
  7 # 11 of the workspace's 33 bin targets were unreachable that way. Everything
  8 # here is derived from `cargo metadata` instead, so new crates and new
  9 # src/bin/*.rs files are picked up with no edit to this script.
 10 
 11 set -euo pipefail
 12 
 13 # Dev-only binaries: built by the workspace, never installed.
 14 EXCLUDE=(vk-smoke)
 15 
 16 # Session targets a USER unit can be wanted by. Anything else (graphical.target,
 17 # multi-user.target) is a SYSTEM unit: root-owned, /etc/systemd/system, never
 18 # installed here.
 19 #
 20 # Classified per FILE, from the unit's own [Install] WantedBy, because a crate
 21 # can legitimately ship both: cce-display-manager once had the root unlock
 22 # daemon (multi-user.target) next to the per-session unlock client and KeePassXC
 23 # units (graphical-session.target). The old per-crate exclusion list swallowed the
 24 # whole crate, so its user units silently never installed — the same
 25 # hand-maintained-list failure this script exists to avoid. Matching is exact:
 26 # graphical.target and graphical-session.target are different targets.
 27 USER_UNIT_TARGETS=(default.target graphical-session.target cce-session.target timers.target)
 28 
 29 PREFIX="${CCE_PREFIX:-$HOME/.local}"
 30 BINDIR="$PREFIX/bin"
 31 UNITDIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
 32 DESKTOPDIR="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
 33 ICONDIR="${XDG_DATA_HOME:-$HOME/.local/share}/icons"
 34 DBUSDIR="${XDG_DATA_HOME:-$HOME/.local/share}/dbus-1/services"
 35 PORTALDIR="${XDG_DATA_HOME:-$HOME/.local/share}/xdg-desktop-portal/portals"
 36 
 37 die() { printf 'ccebuild: %s\n' "$*" >&2; exit 1; }
 38 
 39 # Where the last `install` ran from, so a caller with neither $CCE_WORKSPACE nor
 40 # a cwd inside the tree can still find it: a GUI (the settings app's System
 41 # page shells out to install-system) and anything under pkexec, which scrubs
 42 # the environment. Written on every install, so it tracks a moved tree.
 43 WS_STAMP="${XDG_STATE_HOME:-$HOME/.local/state}/cce/workspace"
 44 
 45 # The workspace root. Never hardcoded: honour $CCE_WORKSPACE, else ask cargo
 46 # about the current directory (this script lives in ~/.local/bin once installed,
 47 # so its own path says nothing about where the source tree is), else the last
 48 # tree an install ran from.
 49 resolve_workspace() {
 50     if [ -n "${CCE_WORKSPACE:-}" ]; then
 51         [ -f "$CCE_WORKSPACE/Cargo.toml" ] || die "CCE_WORKSPACE=$CCE_WORKSPACE has no Cargo.toml"
 52         printf '%s\n' "$CCE_WORKSPACE"
 53         return
 54     fi
 55     local manifest
 56     if manifest=$(cargo locate-project --workspace --message-format plain 2>/dev/null); then
 57         dirname "$manifest"
 58         return
 59     fi
 60     local remembered
 61     if [ -r "$WS_STAMP" ] && remembered=$(cat "$WS_STAMP" 2>/dev/null) \
 62         && [ -n "$remembered" ] && [ -f "$remembered/Cargo.toml" ]; then
 63         printf '%s\n' "$remembered"
 64         return
 65     fi
 66     die "not inside the cce workspace — cd into it, or set CCE_WORKSPACE=/path/to/cce"
 67 }
 68 
 69 # Record the tree this run resolved, for the fallback above.
 70 remember_workspace() {
 71     mkdir -p "$(dirname "$WS_STAMP")" 2>/dev/null || return 0
 72     printf '%s\n' "$WS" > "$WS_STAMP" 2>/dev/null || true
 73 }
 74 
 75 # Every bin target cargo knows about, minus EXCLUDE. This is the whole point of
 76 # the script: one authoritative list, not 20 hand-written ones. With package
 77 # names as arguments, only those packages' bins — what a per-crate `make install`
 78 # needs.
 79 workspace_bins() {
 80     local filter='.packages[]'
 81     if [ "$#" -gt 0 ]; then
 82         local json
 83         json=$(printf '%s\n' "$@" | jq -R . | jq -sc .)
 84         filter=".packages[] | select(.name as \$n | $json | index(\$n))"
 85     fi
 86     cargo metadata --manifest-path "$WS/Cargo.toml" --no-deps --format-version 1 2>/dev/null \
 87         | jq -r "$filter | .targets[] | select(.kind | index(\"bin\")) | .name" \
 88         | sort -u \
 89         | while read -r bin; do
 90             case " ${EXCLUDE[*]} " in *" $bin "*) continue ;; esac
 91             printf '%s\n' "$bin"
 92           done
 93 }
 94 
 95 # Guard against a typo'd package name silently installing nothing.
 96 assert_packages() {
 97     local known name
 98     known=$(cargo metadata --manifest-path "$WS/Cargo.toml" --no-deps --format-version 1 2>/dev/null \
 99             | jq -r '.packages[].name')
100     for name in "$@"; do
101         printf '%s\n' "$known" | grep -qxF "$name" || die "unknown package '$name'"
102     done
103 }
104 
105 # Every systemd unit a crate ships, of any type. Units are NOT only .service:
106 # cce-session.target is the session root — startcce starts and stops it, and
107 # cce-grid/cce-remote are WantedBy it — and it sat unversioned for months partly
108 # because a .service-only glob could not see it, so versioning it would have
109 # deployed nothing. One helper feeds both classifiers so their globs cannot
110 # drift apart.
111 #
112 # dbus/ is excluded outright: D-Bus activation files share the .service
113 # extension but are not units (see dbus_services()). That used to be implicit —
114 # they carry no [Install] WantedBy, so the old classifiers skipped them — but
115 # the no-[Install] fallback below would otherwise adopt one.
116 unit_files() {
117     find "$WS" -maxdepth 3 \
118          \( -name '*.service' -o -name '*.target' -o -name '*.timer' \
119             -o -name '*.socket' -o -name '*.path' \) \
120          -not -path "$WS/target/*" -not -path '*/dbus/*' 2>/dev/null
121 }
122 
123 # The unit's [Install] WantedBy list; empty when it has no [Install] section.
124 unit_wanted_by() {
125     sed -n 's/^[[:space:]]*WantedBy[[:space:]]*=[[:space:]]*//p' "$1" | tr -d '\r'
126 }
127 
128 # User-session unit files shipped by crates (system units excluded — see above).
129 user_units() {
130     local unit wanted t match base
131     unit_files | while read -r unit; do
132             wanted=$(unit_wanted_by "$unit")
133             if [ -z "$wanted" ]; then
134                 # No [Install] at all: a static unit, pulled in by a dependency
135                 # or started by hand (cce-session.target — startcce starts it
136                 # explicitly). There is no WantedBy to classify on, so default
137                 # to the USER side, which is the direction that fails safe: the
138                 # worst case is an inert file in ~/.config/systemd/user, whereas
139                 # defaulting to the system side would write to /etc as root.
140                 # .service is deliberately NOT eligible for this fallback —
141                 # still requiring WantedBy there keeps the classification of
142                 # every unit shipped today bit-for-bit unchanged.
143                 # .service is deliberately NOT eligible for this fallback,
144                 # with ONE exception: a service activated by a sibling
145                 # .timer/.socket/.path. That pair is how a scheduled or
146                 # socket-activated job is expressed and the service half
147                 # legitimately carries no [Install] — without this, shipping a
148                 # timer installed the timer and silently dropped the unit it
149                 # triggers. Keyed on the sibling so a stray service file is
150                 # still skipped.
151                 case "$unit" in
152                     *.service)
153                         base=${unit%.service}
154                         [ -f "$base.timer" ] || [ -f "$base.socket" ] \
155                             || [ -f "$base.path" ] || continue
156                         ;;
157                 esac
158                 printf '%s\n' "$unit"
159                 continue
160             fi
161             match=0
162             for t in $wanted; do
163                 case " ${USER_UNIT_TARGETS[*]} " in *" $t "*) match=1 ;; esac
164             done
165             [ "$match" -eq 1 ] && printf '%s\n' "$unit"
166           done
167 }
168 
169 # System unit files: same per-file [Install] WantedBy classification as
170 # user_units(), inverted — anything wanted by a non-session target
171 # (graphical.target, multi-user.target) is root-owned and belongs in
172 # /etc/systemd/system. Installed by install-system only, never here.
173 system_units() {
174     local unit wanted t match
175     unit_files | while read -r unit; do
176             wanted=$(unit_wanted_by "$unit")
177             # A unit with no [Install] is never installed to /etc from here:
178             # root deployment must be explicit, so it gets no fallback. See the
179             # note in user_units().
180             [ -n "$wanted" ] || continue
181             match=0
182             for t in $wanted; do
183                 case " ${USER_UNIT_TARGETS[*]} " in *" $t "*) match=1 ;; esac
184             done
185             [ "$match" -eq 0 ] && printf '%s\n' "$unit"
186           done
187 }
188 
189 # PAM service files a crate ships in its pam/ dir, for /etc/pam.d. These were
190 # hand-installed (`sudo install -m644 pam/* /etc/pam.d/`), which is the same
191 # drift-by-hand failure as everything else this script absorbs: the /etc copy
192 # quietly stops matching the repo the first time someone forgets the command.
193 pam_files() {
194     find "$WS" -mindepth 3 -maxdepth 3 -path '*/pam/*' -type f \
195         -not -path "$WS/target/*" 2>/dev/null
196 }
197 
198 # udev rules a crate ships in its udev/ dir, for /etc/udev/rules.d. Root-owned
199 # like the system units, so install-system only; today that is the Power
200 # page's plug/unplug trigger (cce-system-interface/udev/).
201 udev_rules() {
202     find "$WS" -mindepth 3 -maxdepth 3 -path '*/udev/*.rules' -type f \
203         -not -path "$WS/target/*" 2>/dev/null
204 }
205 
206 # Helper scripts a crate ships in its own scripts/ dir. Not just this crate's:
207 # a script belongs in the repo whose code it is about (cce-keyring-selftest
208 # reports on the keyring chain, so it lives with it in cce-display-manager), and
209 # anything installed from outside a repo is unversioned and lost on a fresh
210 # clone. Units are excluded here — user_units() installs those, to a different
211 # directory — and .desktop entries never live here.
212 crate_scripts() {
213     find "$WS" -mindepth 3 -maxdepth 3 -path '*/scripts/*' -type f \
214         -not -path "$WS/target/*" 2>/dev/null
215 }
216 
217 # D-Bus activation files a crate ships in its dbus/ dir, installed to
218 # ~/.local/share/dbus-1/services where they shadow /usr/share ones by basename
219 # (that shadowing is the mechanism: org.freedesktop.secrets.service overrides
220 # the stock gnome-keyring activation with the TPM-unlocking unit's). Same
221 # .service extension as systemd
222 # units, but user_units() cannot mistake one for a unit: it keys on [Install]
223 # WantedBy, which D-Bus files don't have.
224 dbus_services() {
225     find "$WS" -mindepth 3 -maxdepth 3 -path '*/dbus/*.service' -type f \
226         -not -path "$WS/target/*" 2>/dev/null
227 }
228 
229 # xdg-desktop-portal backend declarations a crate ships in its portals/ dir
230 # (`<name>.portal`: the backend's bus name and the impl.portal interfaces it
231 # serves), installed to $XDG_DATA_HOME/xdg-desktop-portal/portals where the
232 # portal frontend reads them beside /usr/share's. The file only announces the
233 # backend; the bus name it names must be activatable, which is the crate's
234 # dbus/ service file. Which backend a desktop USES for an interface is
235 # ~/.config/xdg-desktop-portal/cce-portals.conf, not installed from here.
236 portal_files() {
237     find "$WS" -mindepth 3 -maxdepth 3 -path '*/portals/*.portal' -type f \
238         -not -path "$WS/target/*" 2>/dev/null
239 }
240 
241 # XDG .desktop entries shipped by crates, at the crate root next to Cargo.toml.
242 #
243 # These used to be hand-written straight into ~/.local/share/applications, which
244 # put them outside version control entirely: the workspace root is not a git
245 # repo, so nothing there survives a fresh clone. They also went stale silently —
246 # every one hardcoded Exec=/home/lsgalante/.local/bin/<bin>, and one pointed its
247 # Icon at a path that no longer existed. A crate that ships its own entry is the
248 # same rule as the units above: the file lives in the repo that owns it.
249 #
250 # An app is only reachable as an XDG default (mailto:, inode/directory, …) if it
251 # declares a MimeType here, so this is also what makes cce apps selectable on the
252 # settings app's Default Apps page.
253 desktop_entries() {
254     find "$WS" -maxdepth 2 -name '*.desktop' -not -path "$WS/target/*" 2>/dev/null
255 }
256 
257 # App icons, as a whole XDG icon theme tree a crate ships under hicolor/ —
258 # cce-icons/hicolor/scalable/apps/cce-files.svg installs to
259 # $XDG_DATA_HOME/icons/hicolor/scalable/apps/cce-files.svg. The path IS the
260 # install path, so an icon's size and context are its directory rather than a
261 # rule in this script: adding 48x48/apps or scalable/mimetypes later needs no
262 # edit here. The icon's basename must equal the Icon= key in the crate's
263 # .desktop entry, which is what makes them resolve at all.
264 #
265 # Two things are load-bearing:
266 #
267 # - **`-type l` as well as `-type f`.** Every file in cce-icons/hicolor is a
268 #   symlink into ../../../svg (svg/ is the sole source; an entry there is a name
269 #   for a glyph, not a second copy of it). `-type f` alone does not match a
270 #   symlink, so the plain `-type f` used by crate_scripts/dbus_services would
271 #   find nothing at all here and report success. `install` dereferences, so the
272 #   destination is a real file either way.
273 # - **hicolor, and no index.theme.** hicolor is the spec's fallback theme,
274 #   searched whatever the user's icon theme is; the sibling cce cursor theme
275 #   installs to icons/cce and nothing anywhere selects it. The system
276 #   hicolor-icon-theme package owns the index.theme listing every size/context
277 #   dir, and a theme is the union of its trees across base dirs — shipping a
278 #   second index here that named only scalable/apps would be read first and hide
279 #   every other hicolor directory.
280 #
281 # The extension filter is the icon formats the icon-theme spec defines, not a
282 # per-crate list: it is what keeps the tree's own README.md out of the install.
283 app_icons() {
284     find "$WS" -mindepth 3 -path '*/hicolor/*' \( -type f -o -type l \) \
285         \( -name '*.svg' -o -name '*.png' -o -name '*.xpm' \) \
286         -not -path "$WS/target/*" 2>/dev/null
287 }
288 
289 # The crate directory a shipped data file belongs to. Units may sit one level
290 # deeper (cce-compositor/scripts/gpu-watcher.service), so that case unwraps.
291 file_crate_dir() {
292     local dir
293     dir=$(basename "$(dirname "$1")")
294     # The subdirectories a crate ships installable files in; a file one level
295     # down reports the crate above. A new kind of shipped file needs its dir
296     # here, or its crate reads as e.g. "portals" and the package filter drops
297     # it silently (which is how the first portal declaration went uninstalled).
298     case "$dir" in
299         scripts|dbus|portals) dir=$(basename "$(dirname "$(dirname "$1")")") ;;
300     esac
301     printf '%s\n' "$dir"
302 }
303 
304 cmd_build() {
305     printf '==> building workspace (release)\n'
306     # One invocation for the whole workspace. Building per-crate with -p would
307     # resolve a different unified feature set and re-invalidate crates on every
308     # alternation between the two command shapes.
309     ( cd "$WS" && cargo build --release --workspace )
310 }
311 
312 # install [--no-build] [PKG...] — no packages means the whole workspace; named
313 # packages install only their own bins and units, which is what the per-crate
314 # Makefile wrappers call.
315 cmd_install() {
316     remember_workspace
317     local build=1
318     [ "${1:-}" = --no-build ] && { build=0; shift; }
319     local pkgs=("$@")
320     [ ${#pkgs[@]} -gt 0 ] && assert_packages "${pkgs[@]}"
321 
322     if [ "$build" -eq 1 ]; then
323         if [ ${#pkgs[@]} -gt 0 ]; then
324             printf '==> building %s (release)\n' "${pkgs[*]}"
325             ( cd "$WS" && cargo build --release "${pkgs[@]/#/-p}" )
326         else
327             cmd_build
328         fi
329     fi
330 
331     mkdir -p "$BINDIR" "$UNITDIR" "$DESKTOPDIR" "$DBUSDIR"
332     local n=0 missing=()
333 
334     printf '==> installing binaries -> %s\n' "$BINDIR"
335     while read -r bin; do
336         if [ ! -f "$WS/target/release/$bin" ]; then
337             missing+=("$bin")
338             continue
339         fi
340         # `install` unlinks the destination first, so replacing a binary that is
341         # currently running is safe (the live process keeps its inode).
342         install -m 755 "$WS/target/release/$bin" "$BINDIR/$bin"
343         n=$((n + 1))
344     done < <(workspace_bins "${pkgs[@]}")
345 
346     # The compositor is installed as cce-fx and invoked as `cce`.
347     if [ -f "$BINDIR/cce-fx" ]; then
348         ln -sf cce-fx "$BINDIR/cce"
349     fi
350 
351     local s scripts=0
352     while read -r s; do
353         [ -n "$s" ] || continue
354         # Units living in a scripts/ dir (gpu-watcher.service) are installed by
355         # user_units() to a different directory — never as an executable here.
356         # Every unit extension, not just .service: a .target landing in BINDIR
357         # would be an unexecutable "helper script" on PATH.
358         case "$s" in *.service|*.target|*.timer|*.socket|*.path) continue ;; esac
359         # Same filtered install as the units and desktop entries below.
360         if [ ${#pkgs[@]} -gt 0 ]; then
361             crate_selected "$(file_crate_dir "$s")" "${pkgs[@]}" || continue
362         fi
363         [ "$scripts" -eq 0 ] && printf '==> installing helper scripts -> %s\n' "$BINDIR"
364         install -m 755 "$s" "$BINDIR/$(basename "$s")"
365         scripts=$((scripts + 1))
366         n=$((n + 1))
367     done < <(crate_scripts)
368 
369     local u units=0
370     while read -r u; do
371         [ -n "$u" ] || continue
372         # Filtered install: only units belonging to a selected package's crate.
373         if [ ${#pkgs[@]} -gt 0 ]; then
374             crate_selected "$(file_crate_dir "$u")" "${pkgs[@]}" || continue
375         fi
376         [ "$units" -eq 0 ] && printf '==> installing user units -> %s\n' "$UNITDIR"
377         install -m 644 "$u" "$UNITDIR/$(basename "$u")"
378         units=$((units + 1))
379     done < <(user_units)
380     [ "$units" -gt 0 ] && { systemctl --user daemon-reload 2>/dev/null || true; }
381 
382     local b dbus=0
383     while read -r b; do
384         [ -n "$b" ] || continue
385         # Same filtered install as the units above.
386         if [ ${#pkgs[@]} -gt 0 ]; then
387             crate_selected "$(file_crate_dir "$b")" "${pkgs[@]}" || continue
388         fi
389         [ "$dbus" -eq 0 ] && printf '==> installing dbus activation files -> %s\n' "$DBUSDIR"
390         install -m 644 "$b" "$DBUSDIR/$(basename "$b")"
391         dbus=$((dbus + 1))
392     done < <(dbus_services)
393 
394     local pf portals=0
395     while read -r pf; do
396         [ -n "$pf" ] || continue
397         # Same filtered install as the units above.
398         if [ ${#pkgs[@]} -gt 0 ]; then
399             crate_selected "$(file_crate_dir "$pf")" "${pkgs[@]}" || continue
400         fi
401         [ "$portals" -eq 0 ] && { printf '==> installing portal declarations -> %s\n' "$PORTALDIR"; mkdir -p "$PORTALDIR"; }
402         install -m 644 "$pf" "$PORTALDIR/$(basename "$pf")"
403         portals=$((portals + 1))
404     done < <(portal_files)
405 
406     local d desktops=0
407     while read -r d; do
408         [ -n "$d" ] || continue
409         # Same filtered install as the units above.
410         if [ ${#pkgs[@]} -gt 0 ]; then
411             crate_selected "$(file_crate_dir "$d")" "${pkgs[@]}" || continue
412         fi
413         [ "$desktops" -eq 0 ] && printf '==> installing desktop entries -> %s\n' "$DESKTOPDIR"
414         install -m 644 "$d" "$DESKTOPDIR/$(basename "$d")"
415         desktops=$((desktops + 1))
416     done < <(desktop_entries)
417     # Refreshes the MIME cache that makes a newly-declared MimeType resolvable.
418     # Absent on a minimal install and non-fatal there, so failure is ignored.
419     [ "$desktops" -gt 0 ] && { update-desktop-database "$DESKTOPDIR" 2>/dev/null || true; }
420 
421     # Deliberately NOT filtered by package. The icon theme is one shared tree
422     # owned by cce-icons, which has no Cargo.toml — it is an asset repo, not a
423     # crate — so crate_selected() can never match it and a filtered install
424     # would silently install no icons at all. file_crate_dir() is no help
425     # either: the dir above an icon is `apps`, not the crate. Over-installing a
426     # dozen small files on `ccebuild install cce-preview` is the cheaper wrong
427     # answer than an app whose entry points at an icon that was never shipped.
428     local i icons=0 rel
429     while read -r i; do
430         [ -n "$i" ] || continue
431         # Everything after the hicolor/ component, so the source tree's layout
432         # is reproduced verbatim under $ICONDIR.
433         rel="hicolor/${i#*/hicolor/}"
434         [ "$icons" -eq 0 ] && printf '==> installing app icons -> %s\n' "$ICONDIR"
435         install -D -m 644 "$i" "$ICONDIR/$rel"
436         icons=$((icons + 1))
437         n=$((n + 1))
438     done < <(app_icons)
439     # GTK reads the mmapped cache in preference to the directory when it is
440     # newer than the directory, so a new icon can be invisible until it is
441     # rebuilt. --ignore-theme-index because this tree deliberately ships no
442     # index.theme (see app_icons above); absent on a minimal install, so
443     # failure is ignored.
444     [ "$icons" -gt 0 ] && {
445         gtk-update-icon-cache --ignore-theme-index -q -f "$ICONDIR/hicolor" 2>/dev/null || true
446     }
447 
448     if [ ${#missing[@]} -gt 0 ]; then
449         printf 'ccebuild: NOT BUILT, skipped: %s\n' "${missing[*]}" >&2
450     fi
451     printf '==> installed %d files\n' "$n"
452 }
453 
454 # Is directory name $1 the crate dir of any of the packages in $2..? Package name
455 # and directory usually match, but not always (cce-fx lives in cce-compositor).
456 crate_selected() {
457     local dir=$1; shift
458     local pkg manifest
459     for pkg in "$@"; do
460         manifest=$(cargo metadata --manifest-path "$WS/Cargo.toml" --no-deps --format-version 1 2>/dev/null \
461                    | jq -r --arg p "$pkg" '.packages[] | select(.name == $p) | .manifest_path')
462         [ "$(basename "$(dirname "$manifest")")" = "$dir" ] && return 0
463     done
464     return 1
465 }
466 
467 # Report drift three ways: built-vs-installed, and installed-vs-running. The
468 # last one matters because an app launched straight out of target/ (or from a
469 # stale path) keeps running old code that no reinstall can touch.
470 cmd_status() {
471     local built inst stale=0 missing=0 ok=0
472     printf '%-28s %-8s %s\n' NAME STATE DETAIL
473     while read -r bin; do
474         built="$WS/target/release/$bin"
475         inst="$BINDIR/$bin"
476         if [ ! -f "$built" ]; then
477             printf '%-28s %-8s %s\n' "$bin" "nobuild" "not in target/release"
478             continue
479         fi
480         if [ ! -f "$inst" ]; then
481             printf '%-28s %-8s %s\n' "$bin" "MISSING" "never installed"
482             missing=$((missing + 1))
483         elif [ "$built" -nt "$inst" ]; then
484             printf '%-28s %-8s %s\n' "$bin" "STALE" "built $(date -r "$built" +%m-%d_%H:%M:%S) > installed $(date -r "$inst" +%m-%d_%H:%M:%S)"
485             stale=$((stale + 1))
486         else
487             ok=$((ok + 1))
488         fi
489     done < <(workspace_bins)
490     printf -- '-- %d up to date, %d stale, %d missing\n' "$ok" "$stale" "$missing"
491 
492     printf '\n==> running processes not using the installed binary\n'
493     # Scan /proc directly rather than prefiltering with `pgrep -f cce-`: a
494     # process's ARGV has no reliable bearing on which binary it is. The
495     # compositor is the case that matters — startcce launches it through the
496     # `cce` symlink, so its argv is "…/bin/cce --log-level debug -c
497     # /tmp/cce-launch.sh" and it only ever matched 'cce-' by way of that
498     # incidental script path. Rename or drop that argument and the one process
499     # this report exists for would silently vanish from it, precisely because
500     # `restart` cannot restart the compositor and it therefore sits on a stale
501     # binary longer than anything else. (`ccectl` was unreachable for the same
502     # reason.) The exe basename below is the authoritative test; it needs no
503     # help from a guess about argv.
504     local found=0 pid exe base
505     for pid in /proc/[0-9]*; do
506         pid=${pid#/proc/}
507         exe=$(readlink "/proc/$pid/exe" 2>/dev/null) || continue
508         base=${exe%% (deleted)}
509         base=$(basename "$base")
510         # `cce*` already covers ccectl and the bare `cce` symlink.
511         case "$base" in cce*) ;; *) continue ;; esac
512         if [ "$exe" != "$BINDIR/$base" ]; then
513             printf '  %-8s %-24s %s\n' "$pid" "$base" "$exe"
514             found=1
515         fi
516     done
517     [ "$found" -eq 0 ] && printf '  (none — every running cce process is on its installed binary)\n'
518 }
519 
520 # Delete target/ artifacts belonging to crates cargo no longer knows about —
521 # what renamed crates leave behind (cce-system-settings, cce-wallpaper, …), once
522 # 15G of it. Dry-run by default.
523 #
524 # The matching is deliberately strict. A glob like 'cce-status*' also eats the
525 # LIVE cce-status-interface, and a bare 'cce*' matches the entire tree; both were
526 # real near-misses. So: a basename must equal a dead crate name exactly, or that
527 # name followed by a hex hash (cargo's artifact suffix) — never a name followed
528 # by more words.
529 cmd_prune() {
530     local apply=0
531     [ "${1:-}" = --apply ] && apply=1
532 
533     # Live names in both hyphen and underscore form (cargo uses both).
534     local live
535     live=$( { cargo metadata --manifest-path "$WS/Cargo.toml" --no-deps --format-version 1 2>/dev/null \
536                 | jq -r '.packages[] | .name, (.targets[] | .name)'; } | sort -u )
537     live=$(printf '%s\n%s\n' "$live" "$(printf '%s\n' "$live" | tr '-' '_')" | sort -u)
538 
539     # Detect dead crates from .fingerprint/ ONLY. Those directories are exactly
540     # <pkgname>-<hex hash>, one per crate, so stripping the hash yields a real
541     # package name. Deriving names from deps/ instead reads artifacts like
542     # cce_terminal-0qsvll1iqr9dj (incremental) whose suffix is not hex, leaving a
543     # bogus "crate name" that looks orphaned — a false positive that would have
544     # deleted live cce-ui and cce-terminal caches.
545     local dead
546     dead=$(find "$WS/target" -maxdepth 3 -path '*/.fingerprint/*' -name 'cce*' -printf '%f\n' 2>/dev/null \
547            | sed -E 's/-[0-9a-f]{8,}$//' | sort -u)
548     dead=$(comm -23 <(printf '%s\n' "$dead" | grep -v '^$' | sort -u) <(printf '%s\n' "$live"))
549 
550     if [ -z "$dead" ]; then
551         printf 'ccebuild: no orphaned crate artifacts\n'
552         return
553     fi
554 
555     printf '==> orphaned crates (no longer in cargo metadata):\n'
556     printf '%s\n' "$dead" | sed 's/^/  /'
557 
558     local list total
559     list=$(mktemp); trap 'rm -f "$list"' RETURN
560     # Only hex-suffixed artifacts (deps/, .fingerprint/, top-level binaries).
561     # incremental/ is deliberately NOT pruned: its directory suffixes are not hex,
562     # so a pattern loose enough to catch them ("cce" + any alnum) would also match
563     # live siblings like cce-authenticator. It is regenerable anyway — clear the
564     # whole of target/*/incremental if you want that space.
565     local name under
566     while read -r name; do
567         [ -n "$name" ] || continue
568         under=$(printf '%s' "$name" | tr '-' '_')
569         find "$WS/target" -maxdepth 4 -regextype posix-extended \
570              -not -path '*/incremental/*' \
571              -regex ".*/($name|$under)(-[0-9a-f]{8,})?(\..*)?" -prune -print 2>/dev/null >> "$list"
572     done <<< "$dead"
573     sort -u "$list" -o "$list"
574 
575     # Guard: nothing whose basename stem is a live crate may be in the list.
576     local leak
577     leak=$(awk -F/ 'NR==FNR{l[$0];next}{b=$NF; sub(/\.(d|rlib|rmeta|so|o|dwo)$/,"",b); sub(/-[0-9a-f]{8,}$/,"",b); if(b in l) print $0}' \
578            <(printf '%s\n' "$live") "$list" | head -3)
579     [ -n "$leak" ] && die "refusing to prune: live artifacts matched:"$'\n'"$leak"
580 
581     total=$(tr '\n' '\0' < "$list" | du -shc --files0-from=- 2>/dev/null | tail -1 | cut -f1)
582     printf -- '-- %s entries, %s\n' "$(wc -l < "$list")" "${total:-0}"
583 
584     if [ "$apply" -eq 0 ]; then
585         printf 'ccebuild: dry run — pass `prune --apply` to delete\n'
586         return
587     fi
588     tr '\n' '\0' < "$list" | xargs -0 rm -rf
589     printf '==> pruned\n'
590 }
591 
592 # Restart the user services whose binary has been replaced underneath them.
593 #
594 # `install` unlinks the destination before writing, so a service still running
595 # the pre-install inode reports its exe as "... (deleted)" — a precise signal
596 # that a restart is owed, and one that needs no timestamp bookkeeping. Units
597 # already on the current binary are left alone, so this is safe to run after
598 # every install.
599 #
600 # The compositor is deliberately unreachable here: it is not a user unit (it is
601 # started by startcce), and restarting it would tear down the session.
602 #
603 # `restart PKG...` scopes the sweep to units whose unit files live in the named
604 # crates (same crate_selected/file_crate_dir mapping as the filtered install).
605 # This is the multi-session-safe form: several agents work in sibling crates
606 # concurrently, and an unscoped restart bounces services another session has
607 # installed but is not ready to restart (WORKSPACE.md "Concurrent sessions").
608 cmd_restart() {
609     local all=0
610     [ "${1:-}" = --all ] && { all=1; shift; }
611     local pkgs=("$@")
612     [ ${#pkgs[@]} -gt 0 ] && assert_packages "${pkgs[@]}"
613 
614     # Crate scope: unit-file basenames belonging to the named packages.
615     local scoped="" u
616     if [ ${#pkgs[@]} -gt 0 ]; then
617         while read -r u; do
618             [ -n "$u" ] || continue
619             crate_selected "$(file_crate_dir "$u")" "${pkgs[@]}" || continue
620             scoped="$scoped $(basename "$u")"
621         done < <(user_units)
622         [ -n "$scoped" ] || { printf 'ccebuild: no user units belong to: %s\n' "${pkgs[*]}"; return; }
623     fi
624 
625     systemctl --user daemon-reload 2>/dev/null || true
626 
627     local units unit pid exe want=()
628     units=$(systemctl --user list-units --type=service --state=running --no-legend 2>/dev/null \
629             | awk '{print $1}' | grep -E '^(cce|gpu-watcher)' || true)
630     [ -n "$units" ] || { printf 'ccebuild: no cce user services running\n'; return; }
631 
632     for unit in $units; do
633         if [ -n "$scoped" ]; then
634             case " $scoped " in
635                 *" $unit "*) ;;
636                 *) continue ;;
637             esac
638         fi
639         if [ "$all" -eq 1 ]; then
640             want+=("$unit")
641             continue
642         fi
643         pid=$(systemctl --user show -p MainPID --value "$unit" 2>/dev/null)
644         [ -n "$pid" ] && [ "$pid" != 0 ] || continue
645         exe=$(readlink "/proc/$pid/exe" 2>/dev/null) || continue
646         case "$exe" in *"(deleted)") want+=("$unit") ;; esac
647     done
648 
649     if [ ${#want[@]} -eq 0 ]; then
650         if [ ${#pkgs[@]} -gt 0 ]; then
651             printf 'ccebuild: no running service of %s is on a replaced binary\n' "${pkgs[*]}"
652         else
653             printf 'ccebuild: every running cce service is already on its current binary\n'
654         fi
655         return
656     fi
657 
658     printf '==> restarting %d service(s)\n' "${#want[@]}"
659     for unit in "${want[@]}"; do
660         printf '  %s ... ' "$unit"
661         if systemctl --user restart "$unit" 2>/dev/null; then
662             printf '%s\n' "$(systemctl --user is-active "$unit")"
663         else
664             printf 'FAILED\n'
665         fi
666     done
667 
668     # A unit that comes back inactive is a silent breakage — surface it.
669     local bad=0
670     for unit in "${want[@]}"; do
671         [ "$(systemctl --user is-active "$unit")" = active ] || { printf 'ccebuild: %s is NOT active\n' "$unit" >&2; bad=1; }
672     done
673     [ "$bad" -eq 0 ] || exit 1
674 }
675 
676 # The root-owned install paths. Separate command because it needs sudo, which is
677 # exactly why these drifted three weeks behind everything else.
678 #
679 # Planned here as the normal user (every target is world-readable, so the
680 # compare needs no privilege), then applied by ONE `sudo bash -c` call. One
681 # prompt however sudo authenticates: fingerprint sudo never caches a timestamp
682 # on this machine, so the former `sudo -n true` guard refused every run and a
683 # chain of separate `sudo` calls would prompt once per file. A single script
684 # under `set -e` is also all-or-nothing — no half-applied update.
685 cmd_install_system() {
686     local dry=0 how=auto
687     while [ $# -gt 0 ]; do
688         case "$1" in
689             -n|--dry-run) dry=1 ;;
690             --pkexec) how=pkexec ;;
691             --sudo) how=sudo ;;
692             *) die "install-system takes only --dry-run, --pkexec or --sudo" ;;
693         esac
694         shift
695     done
696     local stamp; stamp=$(date +%F)
697     local plan="set -e"
698     # Append one root command to the plan, each word shell-quoted.
699     queue() { plan+=$'\n'"$(printf '%q ' "$@")"; }
700 
701     local pairs=(
702         "cce-display-manager:/usr/bin/cce-display-manager"
703         # The Power page's root-side applier: the udev rule and system unit
704         # both name this path.
705         "cce-power-apply:/usr/bin/cce-power-apply"
706     )
707     local entry bin dest src
708     for entry in "${pairs[@]}"; do
709         bin=${entry%%:*}; dest=${entry#*:}
710         src="$WS/target/release/$bin"
711         [ -f "$src" ] || die "$bin not built — run: ccebuild build"
712         if [ -e "$dest" ] && cmp -s "$src" "$dest"; then continue; fi
713         printf '  %s -> %s\n' "$bin" "$dest"
714         # A first install has nothing to back up. (This used to skip an
715         # absent destination, which made a NEW root binary — cce-power-apply
716         # — uninstallable by the one command meant to install it.)
717         if [ -e "$dest" ]; then queue cp -a "$dest" "$dest.bak-$stamp"; fi
718         # `install` unlinks first: safe even though these are running as root.
719         queue install -m 755 "$src" "$dest"
720     done
721 
722     # System units and PAM stacks ship from the crates too (discovered, not
723     # hand-listed). Backups only when the content actually changed, so a no-op
724     # run does not accrete .bak files.
725     local file reload=0
726     while read -r file; do
727         dest="/etc/systemd/system/$(basename "$file")"
728         if [ -e "$dest" ] && cmp -s "$file" "$dest"; then continue; fi
729         printf '  %s -> %s\n' "$(basename "$file")" "$dest"
730         if [ -e "$dest" ]; then queue cp -a "$dest" "$dest.bak-$stamp"; fi
731         queue install -m 644 "$file" "$dest"
732         reload=1
733     done < <(system_units)
734     if [ "$reload" -eq 1 ]; then queue systemctl daemon-reload; fi
735 
736     while read -r file; do
737         dest="/etc/pam.d/$(basename "$file")"
738         if [ -e "$dest" ] && cmp -s "$file" "$dest"; then continue; fi
739         printf '  %s -> %s\n' "$(basename "$file")" "$dest"
740         if [ -e "$dest" ]; then queue cp -a "$dest" "$dest.bak-$stamp"; fi
741         queue install -m 644 "$file" "$dest"
742     done < <(pam_files)
743 
744     # udev rules: reload after a change, and re-trigger the power_supply
745     # class so a freshly installed plug/unplug rule applies the plan now
746     # rather than at the next replug. (A trigger is not a restart: it runs
747     # the oneshot the rule names, nothing else.)
748     local rules_changed=0
749     while read -r file; do
750         dest="/etc/udev/rules.d/$(basename "$file")"
751         if [ -e "$dest" ] && cmp -s "$file" "$dest"; then continue; fi
752         printf '  %s -> %s\n' "$(basename "$file")" "$dest"
753         if [ -e "$dest" ]; then queue cp -a "$dest" "$dest.bak-$stamp"; fi
754         queue install -m 644 "$file" "$dest"
755         rules_changed=1
756     done < <(udev_rules)
757     if [ "$rules_changed" -eq 1 ]; then
758         queue udevadm control --reload
759         queue udevadm trigger --subsystem-match=power_supply --action=change
760     fi
761 
762     if [ "$plan" = "set -e" ]; then
763         printf '==> system artifacts already up to date\n'
764         return 0
765     fi
766     if [ "$dry" -eq 1 ]; then
767         printf '==> dry run; would run as root:\n%s\n' "${plan#set -e}"
768         return 0
769     fi
770     # How the one root call authenticates. sudo on a terminal, pkexec
771     # otherwise — a GUI caller (the settings app's System page) and an agent
772     # shell both have no TTY, and sudo there dies on "a terminal is required
773     # to read the password" after the fingerprint prompt it cannot show times
774     # out. pkexec asks the session's polkit agent (cce-authenticator) instead,
775     # which is how every other privileged action in this desktop already
776     # authenticates. The PLAN is still built as the user either way, which is
777     # what makes this safe: pkexec scrubs the environment, so $WS, $HOME and
778     # `cargo locate-project` would all be gone if the resolution happened on
779     # the far side.
780     if [ "$how" = auto ]; then
781         if [ -t 0 ]; then how=sudo; else how=pkexec; fi
782     fi
783     case "$how" in
784         sudo)
785             printf '==> applying as root (one sudo prompt)\n'
786             sudo bash -c "$plan"
787             ;;
788         pkexec)
789             command -v pkexec >/dev/null 2>&1 || die "pkexec not found — run from a terminal for the sudo path"
790             printf '==> applying as root (authenticate in the polkit prompt)\n'
791             # Full path: pkexec refuses a bare program name.
792             pkexec /bin/bash -c "$plan"
793             ;;
794     esac
795     printf '==> system artifacts updated (binaries take effect at next login; nothing restarted)\n'
796 }
797 
798 usage() {
799     cat <<'EOF'
800 usage: ccebuild <command>
801 
802   build                 cargo build --release --workspace
803   install [--no-build]  build, then install every bin target, helper script
804                         and user unit (derived from cargo metadata)
805   status                show built-vs-installed drift, and running processes
806                         that are not on their installed binary
807   restart [--all] [PKG...]
808                         restart user services still running a replaced binary
809                         (--all: regardless of binary state; PKG...: only units
810                         belonging to those crates — the safe form when other
811                         sessions are working). Never the compositor.
812   prune [--apply]       delete target/ artifacts of crates cargo no longer
813                         knows about (renamed/retired); dry-run by default
814   install-system [--dry-run] [--pkexec|--sudo]
815                         update the root-owned artifacts: binaries, system
816                         units, /etc/pam.d stacks, udev rules — one prompt for the whole
817                         batch; backs up each changed file and restarts
818                         nothing (--dry-run: print the root commands only).
819                         Authenticates with sudo on a terminal and pkexec
820                         (the session's polkit agent) without one; --pkexec
821                         and --sudo force the choice.
822 
823 environment:
824   CCE_WORKSPACE  workspace root (default: located from the current directory)
825   CCE_PREFIX     install prefix (default: ~/.local)
826 EOF
827 }
828 
829 command -v jq >/dev/null || die "jq is required"
830 WS=$(resolve_workspace)
831 
832 case "${1:-}" in
833     build)          cmd_build ;;
834     install)        shift; cmd_install "$@" ;;
835     status)         cmd_status ;;
836     restart)        shift; cmd_restart "$@" ;;
837     prune)          shift; cmd_prune "$@" ;;
838     install-system) shift; cmd_install_system "$@" ;;
839     -h|--help|help|"") usage ;;
840     *) die "unknown command '$1' (try: ccebuild --help)" ;;
841 esac