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

commit220169d8ba3a4773019559950aef0f57c3dc6af1
parenta975575b28
authorLucas Galante <[email protected]>
date2026-08-22 12:57
build: ship cce-session.target; teach ccebuild non-.service units

cce-session.target was unversioned — a hand-written file in
~/.config/systemd/user, in no repo. It is the session root: startcce starts and
stops it by name, and cce-grid/cce-remote are WantedBy it, so losing it breaks
both the session lifecycle and those autostarts, with nothing in the tree to
recreate it.

Versioning it alone would have deployed nothing, which is the more interesting
half. user_units()/system_units() globbed '*.service', so a .target at a crate
root would have been tracked and still missing on a fresh clone — worse than
before, because it looks handled. Both classifiers now share one unit_files()
helper covering .service/.target/.timer/.socket/.path, so their globs cannot
drift apart.

A .target also has no [Install], and the old code skipped any unit without a
WantedBy. Units with no [Install] now default to the USER side: that is the
direction that fails safe, since the worst case is an inert file in
~/.config/systemd/user, whereas defaulting to system would write to /etc as
root. The fallback deliberately excludes .service, which keeps every unit
shipped today classified bit-for-bit as before — verified by diffing
user_units()/system_units() output across the change: the only difference is
cce-session.target appearing, nothing reclassified.

Two things that fallback would otherwise have broken, both now explicit:
dbus/*.service activation files carry no [Install] and were skipped only as a
side effect of that (unit_files() excludes dbus/ outright), and the helper-script
installer skipped '*.service' by name, so a .target in a scripts/ dir would have
been installed to BINDIR as an unexecutable "helper script".

Verified: install places the target, systemd reloads and it stays
loaded/active/static with its directives unchanged (comments only), cce-grid and
cce-remote still resolve against it, and no unit leaked into ~/.local/bin.

Co-Authored-By: Claude Opus 5 <[email protected]>

 cce-session.target | 11 +++++++++
 scripts/ccebuild   | 65 ++++++++++++++++++++++++++++++++++++++++++++----------
 2 files changed, 64 insertions(+), 12 deletions(-)

diff --git a/cce-session.target b/cce-session.target
new file mode 100644
index 0000000..c5088a5
--- /dev/null
+++ b/cce-session.target
@@ -0,0 +1,11 @@
+[Unit]
+Description=CCE Session
+# The session root. `startcce` starts this target after the compositor is up and
+# stops it on the way out, so it is the thing user units hang off to mean "runs
+# for the life of a cce session" — cce-grid and cce-remote are WantedBy it.
+# It is deliberately static (no [Install]): nothing enables it, startcce starts
+# it by name.
+BindsTo=graphical-session.target
+Before=graphical-session.target
+RefuseManualStart=no
+RefuseManualStop=no
diff --git a/scripts/ccebuild b/scripts/ccebuild
index a44b872..e7d30ed 100755
--- a/scripts/ccebuild
+++ b/scripts/ccebuild
@@ -82,14 +82,49 @@ assert_packages() {
     done
 }
 
+# Every systemd unit a crate ships, of any type. Units are NOT only .service:
+# cce-session.target is the session root — startcce starts and stops it, and
+# cce-grid/cce-remote are WantedBy it — and it sat unversioned for months partly
+# because a .service-only glob could not see it, so versioning it would have
+# deployed nothing. One helper feeds both classifiers so their globs cannot
+# drift apart.
+#
+# dbus/ is excluded outright: D-Bus activation files share the .service
+# extension but are not units (see dbus_services()). That used to be implicit —
+# they carry no [Install] WantedBy, so the old classifiers skipped them — but
+# the no-[Install] fallback below would otherwise adopt one.
+unit_files() {
+    find "$WS" -maxdepth 3 \
+         \( -name '*.service' -o -name '*.target' -o -name '*.timer' \
+            -o -name '*.socket' -o -name '*.path' \) \
+         -not -path "$WS/target/*" -not -path '*/dbus/*' 2>/dev/null
+}
+
+# The unit's [Install] WantedBy list; empty when it has no [Install] section.
+unit_wanted_by() {
+    sed -n 's/^[[:space:]]*WantedBy[[:space:]]*=[[:space:]]*//p' "$1" | tr -d '\r'
+}
+
 # User-session unit files shipped by crates (system units excluded — see above).
 user_units() {
-    find "$WS" -maxdepth 3 -name '*.service' -not -path "$WS/target/*" 2>/dev/null \
-        | while read -r unit; do
-            local wanted
-            wanted=$(sed -n 's/^[[:space:]]*WantedBy[[:space:]]*=[[:space:]]*//p' "$unit" | tr -d '\r')
-            [ -n "$wanted" ] || continue
-            local t match=0
+    local unit wanted t match
+    unit_files | while read -r unit; do
+            wanted=$(unit_wanted_by "$unit")
+            if [ -z "$wanted" ]; then
+                # No [Install] at all: a static unit, pulled in by a dependency
+                # or started by hand (cce-session.target — startcce starts it
+                # explicitly). There is no WantedBy to classify on, so default
+                # to the USER side, which is the direction that fails safe: the
+                # worst case is an inert file in ~/.config/systemd/user, whereas
+                # defaulting to the system side would write to /etc as root.
+                # .service is deliberately NOT eligible for this fallback —
+                # still requiring WantedBy there keeps the classification of
+                # every unit shipped today bit-for-bit unchanged.
+                case "$unit" in *.service) continue ;; esac
+                printf '%s\n' "$unit"
+                continue
+            fi
+            match=0
             for t in $wanted; do
                 case " ${USER_UNIT_TARGETS[*]} " in *" $t "*) match=1 ;; esac
             done
@@ -102,12 +137,14 @@ user_units() {
 # (graphical.target, multi-user.target) is root-owned and belongs in
 # /etc/systemd/system. Installed by install-system only, never here.
 system_units() {
-    find "$WS" -maxdepth 3 -name '*.service' -not -path "$WS/target/*" 2>/dev/null \
-        | while read -r unit; do
-            local wanted
-            wanted=$(sed -n 's/^[[:space:]]*WantedBy[[:space:]]*=[[:space:]]*//p' "$unit" | tr -d '\r')
+    local unit wanted t match
+    unit_files | while read -r unit; do
+            wanted=$(unit_wanted_by "$unit")
+            # A unit with no [Install] is never installed to /etc from here:
+            # root deployment must be explicit, so it gets no fallback. See the
+            # note in user_units().
             [ -n "$wanted" ] || continue
-            local t match=0
+            match=0
             for t in $wanted; do
                 case " ${USER_UNIT_TARGETS[*]} " in *" $t "*) match=1 ;; esac
             done
@@ -254,7 +291,11 @@ cmd_install() {
     local s scripts=0
     while read -r s; do
         [ -n "$s" ] || continue
-        case "$s" in *.service) continue ;; esac
+        # Units living in a scripts/ dir (gpu-watcher.service) are installed by
+        # user_units() to a different directory — never as an executable here.
+        # Every unit extension, not just .service: a .target landing in BINDIR
+        # would be an unexecutable "helper script" on PATH.
+        case "$s" in *.service|*.target|*.timer|*.socket|*.path) continue ;; esac
         # Same filtered install as the units and desktop entries below.
         if [ ${#pkgs[@]} -gt 0 ]; then
             crate_selected "$(file_crate_dir "$s")" "${pkgs[@]}" || continue