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