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

scripts/ccebuild (44.5K)

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