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