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

scripts/cce-shadow (36.7K)

  1 #!/usr/bin/env bash
  2 # cce-shadow — run a second cce-fx session that is completely invisible.
  3 #
  4 # The session runs on the wlroots headless backend: it has a real output, a real
  5 # scenefx renderer and real clients, but nothing is ever scanned out to a
  6 # monitor. That makes it the place to verify compositor and client changes
  7 # without taking over the screen and keyboard of whoever is using the machine.
  8 # The nested (wayland-backend) approach it replaces needed a visible window,
  9 # stole focus, and had to be re-centred before every capture.
 10 #
 11 # Everything it touches is confined to $CCE_SHADOW_DIR. The isolation is the
 12 # whole point, so it is worth knowing which parts are load-bearing:
 13 #
 14 #   HOME             Screenshots are written to a hardcoded $HOME/Pictures/
 15 #                    screenshots and ignore XDG entirely, so without this the
 16 #                    shadow litters the real one.
 17 #   XDG_STATE_HOME   Holds state.json. Sharing the real one makes the shadow
 18 #                    restore the live session's windows — it respawns a
 19 #                    duplicate of every app the user has open.
 20 #   notifications    `ccectl screenshot` shells out to notify-send, and the
 21 #                    D-Bus session bus is shared with the live session, so a
 22 #                    toast would pop on the user's real screen. The compositor
 23 #                    only defaults this off when the config is *unreadable*; a
 24 #                    config that exists but omits the key defaults it ON, and
 25 #                    the seeded config is a copy of the user's, which omits it.
 26 #                    So seed_config writes the key explicitly. Do not drop it.
 27 #
 28 # Deliberately NOT isolated: XDG_RUNTIME_DIR (the wayland socket must live in a
 29 # real user-owned dir, and the display name already differs) and the D-Bus
 30 # session bus (unavoidable, and harmless as long as the shadow does not run the
 31 # apps that *claim* a name — see "Do not run" below).
 32 #
 33 # Several shadows can run at once, because every path above derives from one
 34 # directory: --instance <name> (or CCE_SHADOW_INSTANCE) gives each its own tree
 35 # under $CCE_SHADOW_BASE, hence its own HOME, its own windows, and its own
 36 # client sweep. The display is not a collision point either — cce-fx picks its
 37 # socket with wl_display_add_socket_auto and start() reads the name back out of
 38 # the log, so a second compositor lands on a different one unprompted, and the
 39 # /tmp/cce-<display>.sock IPC sockets follow it.
 40 #
 41 # That separation is the point: without it, two agents share one session, and
 42 # each one's `stop` (or plain `start`, which clears saved state) tears down the
 43 # other's run — silently, because `start` reports an existing session as
 44 # success. What stays global across instances is the D-Bus name claims in "Do
 45 # not run" below: those are one-at-a-time for the whole machine, not per
 46 # instance.
 47 
 48 set -euo pipefail
 49 
 50 SHADOW_BASE="${CCE_SHADOW_BASE:-${XDG_STATE_HOME:-$HOME/.local/state}/cce-shadow}"
 51 INSTANCE="${CCE_SHADOW_INSTANCE:-default}"
 52 
 53 # All set by resolve_paths(), once the instance name is settled.
 54 SHADOW_DIR=""; SHADOW_HOME=""; RUN_DIR=""
 55 PIDFILE=""; DISPLAY_FILE=""; LOG=""; SHOTS=""
 56 
 57 # Resolved before any override, so seeding reads the user's real config.
 58 REAL_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/cce"
 59 
 60 START_TIMEOUT_MS=15000
 61 
 62 die()  { printf 'cce-shadow: %s\n' "$*" >&2; exit 1; }
 63 note() { printf '==> %s\n' "$*"; }
 64 
 65 # ── instances ────────────────────────────────────────────────────────────────
 66 # An instance is just a directory: $SHADOW_BASE/<name>, holding the home/, run/
 67 # and shots/ that used to sit at $SHADOW_BASE itself. Everything else follows
 68 # from it, which is why one variable is enough to keep two sessions apart.
 69 
 70 # Before instances existed, the single session lived directly at $SHADOW_BASE.
 71 # Such a tree would be orphaned by the move — and a *running* one would become
 72 # unreachable, since its pidfile is at the old path and nothing would ever stop
 73 # it again. Both cases are handled: a stopped legacy tree is migrated into the
 74 # "default" instance, and a live one keeps the old path for as long as it runs.
 75 legacy_live() { pid_from "$SHADOW_BASE/run/cce-fx.pid"; }
 76 
 77 # Returns non-zero when a live legacy session must keep the old layout.
 78 migrate_legacy() {
 79     [ -d "$SHADOW_BASE/run" ] || return 0
 80     [ -e "$SHADOW_BASE/default" ] && return 0
 81     legacy_live >/dev/null && return 1
 82     mkdir -p "$SHADOW_BASE/default"
 83     local d
 84     for d in home run shots; do
 85         if [ -e "$SHADOW_BASE/$d" ]; then mv "$SHADOW_BASE/$d" "$SHADOW_BASE/default/$d"; fi
 86     done
 87     # stderr, not stdout: `env` is eval'd and `shot` is parsed by the caller.
 88     printf '==> migrated the pre-instance shadow tree into instance %s\n' "'default'" >&2
 89     return 0
 90 }
 91 
 92 resolve_paths() {
 93     if [ -n "${CCE_SHADOW_DIR:-}" ]; then
 94         SHADOW_DIR="$CCE_SHADOW_DIR"
 95     else
 96         case "$INSTANCE" in
 97             ''|.|..|*/*)   die "bad instance name: '$INSTANCE'" ;;
 98             home|run|shots) die "'$INSTANCE' is reserved (it is a directory inside an instance)" ;;
 99         esac
100         if [ "$INSTANCE" = default ] && ! migrate_legacy; then
101             SHADOW_DIR="$SHADOW_BASE"
102         else
103             SHADOW_DIR="$SHADOW_BASE/$INSTANCE"
104         fi
105     fi
106     SHADOW_HOME="$SHADOW_DIR/home"
107     RUN_DIR="$SHADOW_DIR/run"
108     PIDFILE="$RUN_DIR/cce-fx.pid"
109     DISPLAY_FILE="$RUN_DIR/display"
110     LOG="$RUN_DIR/cce-fx.log"
111     SHOTS="$SHADOW_DIR/shots"
112 }
113 
114 # How the caller should address this instance in the commands that follow.
115 addr() {
116     if [ -z "${CCE_SHADOW_DIR:-}" ] && [ "$INSTANCE" != default ]; then
117         printf 'cce-shadow --instance %s' "$INSTANCE"
118     else
119         printf 'cce-shadow'
120     fi
121 }
122 
123 # Claim a name no other instance holds. mkdir is the atomic part: two agents
124 # racing on --new cannot come away with the same one. Names are not recycled
125 # while the directory exists, so a finished run leaves a stopped instance
126 # behind — `prune` is what reclaims those.
127 alloc_instance() {
128     local n=1
129     mkdir -p "$SHADOW_BASE"
130     while [ "$n" -le 99 ]; do
131         if mkdir "$SHADOW_BASE/agent-$n" 2>/dev/null; then
132             printf 'agent-%s\n' "$n"
133             return
134         fi
135         n=$((n + 1))
136     done
137     die "no free instance name (agent-1..agent-99 all exist); try: cce-shadow prune"
138 }
139 
140 # Every instance that exists, one per line. A live legacy tree has no directory
141 # of its own, but "default" is the name that reaches it, so it is listed too.
142 instance_names() {
143     local d name
144     if [ ! -e "$SHADOW_BASE/default" ] && legacy_live >/dev/null; then
145         printf 'default\n'
146     fi
147     for d in "$SHADOW_BASE"/*/; do
148         [ -d "$d" ] || continue
149         name=$(basename "$d")
150         case "$name" in home|run|shots) continue ;; esac
151         printf '%s\n' "$name"
152     done
153     return 0
154 }
155 
156 # ── ownership ────────────────────────────────────────────────────────────────
157 # Named instances stop two sessions from *accidentally* sharing one shadow, but
158 # not from deliberately cleaning up: `stop --all` and `prune` would otherwise
159 # reach across and kill or delete an instance another agent is mid-run in. So
160 # an instance records who started it, and those two commands leave other
161 # people's alone.
162 #
163 # The token names the session that drives this instance, and has to stay the
164 # same across the many short-lived shells one session spawns. Neither the
165 # script nor its parent will do: each invocation is a fresh setsid'd session
166 # leader, and $PPID is the throwaway shell of a single tool call, which is dead
167 # by the next one — an instance would read as an orphan to the very session
168 # that started it. So walk up past the shells to the first process that is not
169 # one: an agent's `claude`, or a human's terminal emulator behind their
170 # interactive shell. Both are stable for as long as the session lasts. A bare
171 # pid would be reusable once that process exits, so the token carries its start
172 # time too.
173 owner_pid() {
174     local pid=$PPID depth=0 comm
175     while [ "$pid" -gt 1 ] && [ "$depth" -lt 10 ]; do
176         comm=$(tr -d '\0' < "/proc/$pid/comm" 2>/dev/null) || break
177         case "$comm" in
178             sh|bash|zsh|dash|ksh|fish|busybox) ;;
179             *) break ;;
180         esac
181         pid=$(sed 's/.*) //' "/proc/$pid/stat" 2>/dev/null | awk '{print $2}')
182         [ -n "$pid" ] || return 1
183         depth=$((depth + 1))
184     done
185     printf '%s\n' "$pid"
186 }
187 
188 owner_token() {
189     if [ -n "${CCE_SHADOW_OWNER:-}" ]; then
190         printf '%s\n' "$CCE_SHADOW_OWNER"
191         return
192     fi
193     local pid; pid=$(owner_pid) || pid=$PPID
194     printf '%s:%s\n' "$pid" "$(proc_starttime "$pid")"
195 }
196 
197 # Field 22 of /proc/<pid>/stat, reached by cutting past the comm field first:
198 # comm is parenthesised and may contain spaces, which would shift every
199 # positional field after it. What remains starts at field 3, so 22 is 20 there.
200 proc_starttime() {
201     sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | awk '{print $20}'
202 }
203 
204 owner_alive() {
205     local token=$1 pid start
206     case "$token" in
207         [0-9]*:[0-9]*) pid=${token%%:*}; start=${token#*:} ;;
208         # Not <pid>:<starttime> — an explicit CCE_SHADOW_OWNER string, whose
209         # liveness cannot be checked. Call it live: declining to delete what we
210         # cannot prove is dead is the safe direction, and --force is the way out.
211         *) return 0 ;;
212     esac
213     [ -d "/proc/$pid" ] || return 1
214     [ "$(proc_starttime "$pid")" = "$start" ]
215 }
216 
217 # me | other | orphan | none — for the instance whose paths are resolved, or
218 # for the directory passed as $1.
219 instance_ownership() {
220     local dir=${1:-$SHADOW_DIR} token
221     token=$(cat "$dir/run/owner" 2>/dev/null) || token=""
222     [ -n "$token" ] || { printf 'none\n'; return; }
223     if [ "$token" = "$(owner_token)" ]; then printf 'me\n'
224     elif owner_alive "$token";           then printf 'other\n'
225     else                                      printf 'orphan\n'
226     fi
227 }
228 
229 usage() {
230     cat <<'EOF'
231 usage: cce-shadow [--instance <name>] <command> [args...]
232 
233 Instances let several sessions run at once — one per agent — each with its own
234 tree, HOME, windows and display, so neither can stop or reset the other. The
235 default instance is "default"; CCE_SHADOW_INSTANCE sets it for a whole shell.
236 
237   start [opts]        start the invisible session (no-op if already running)
238       --instance <n>    the global option, also accepted here
239       --new             claim an unused instance (agent-N) and start in it;
240                         prints the name it took
241       --fresh           discard the existing shadow home and reseed it
242       --restore         keep saved window state (default: start empty, so
243                         runs do not inherit the previous one's windows)
244       --exec <cmd>      run <cmd> inside the session once it is up
245       --scale <n>       output scale, e.g. 2 for HiDPI (default 1)
246       --xwayland        start Xwayland too, for X11 clients (off by default:
247                         a shadow rarely needs it and it slows startup)
248       --gpu <path>      pin the renderer (default: first non-NVIDIA render
249                         node, because window capture fails on NVIDIA);
250                         --gpu none leaves the choice to wlroots
251       --bin <path>      cce-fx to run (default: PATH, then target/release)
252   stop [--all]        stop it and clean up its sockets
253       --all             every instance, not just this one
254       --force           with --all: include instances another live session
255                         started (they are skipped by default)
256   list                every instance, running or not, and who owns it
257   prune [--force]     delete stopped agent-N instances, and their shots;
258                       instances you named yourself are never touched, nor
259                       are ones another live session started
260   status              is it running, on which display, with what in it
261   ctl <args...>       run ccectl against it   (e.g. ctl windows)
262   spawn <cmd>         launch a client inside it
263   shot [name]         screenshot it; copies to <shots>/<name>.png and prints the path
264   shot-window [id]    screenshot one window (works even off-screen)
265   run <cmd...>        run any command with the session's environment
266   logs [-f]           show the compositor log
267   env                 print the environment as shell exports
268 
269 Do not run inside the shadow: cce-authenticator (claims the PolicyKit D-Bus
270 name), cce-remote (binds 0.0.0.0:17017). Every other cce app is safe;
271 cce-cloud's daemon socket is already display-keyed.
272 
273 cce-secrets runs, but browse only: the shadow shares the live session bus and
274 XDG_RUNTIME_DIR, so it lists and can EDIT the live keyring. To test one-time
275 codes, point CCE_KEYRING_SYNC_SOCK at a stand-in socket (cce-secrets/
276 KEYRING-SYNC.md, "One-time codes in cce-secrets").
277 EOF
278 }
279 
280 # ── locating binaries ────────────────────────────────────────────────────────
281 # Prefer whatever is installed, because the usual reason to start a shadow is to
282 # verify what `ccebuild install` just deployed. Fall back to the workspace build
283 # so the script also works in a tree that was never installed.
284 workspace() {
285     if [ -n "${CCE_WORKSPACE:-}" ]; then printf '%s\n' "$CCE_WORKSPACE"; return; fi
286     cargo locate-project --workspace --message-format plain 2>/dev/null | xargs -r dirname
287 }
288 
289 # Pick a render node whose textures the compositor can actually read back.
290 #
291 # Full-output capture reads the output's own buffer and works anywhere, but
292 # `screenshot window` reads the *client's* imported dmabuf, and when the
293 # compositor is on the NVIDIA node while the client rendered on another,
294 # wlr_texture_read_pixels reports format 0x0 and the capture fails. Preferring
295 # a non-NVIDIA node keeps window capture working; `--gpu none` opts out, and
296 # an explicit `--gpu <path>` always wins.
297 default_gpu() {
298     local d drv
299     for d in /dev/dri/renderD*; do
300         [ -e "$d" ] || continue
301         drv=$(sed -n 's/^DRIVER=//p' "/sys/class/drm/${d##*/}/device/uevent" 2>/dev/null)
302         [ "$drv" = nvidia ] && continue
303         printf '%s\n' "$d"
304         return
305     done
306 }
307 
308 find_bin() {
309     local name=$1 override=${2:-} ws
310     if [ -n "$override" ]; then
311         [ -x "$override" ] || die "no executable at $override"
312         printf '%s\n' "$override"; return
313     fi
314     local p
315     if p=$(command -v "$name" 2>/dev/null); then printf '%s\n' "$p"; return; fi
316     ws=$(workspace)
317     if [ -n "$ws" ] && [ -x "$ws/target/release/$name" ]; then
318         printf '%s\n' "$ws/target/release/$name"; return
319     fi
320     die "cannot find $name — install it, or pass --bin / set CCE_WORKSPACE"
321 }
322 
323 # ── process identity ─────────────────────────────────────────────────────────
324 # Always confirm via /proc/<pid>/exe before signalling. A pidfile can go stale
325 # and have its number reused, and matching on argv instead would be worse: the
326 # live session is also a cce-fx, and killing the wrong one ends the user's
327 # desktop. `ccebuild install` unlinks before writing, so a running binary's exe
328 # often reads "<path> (deleted)" — strip that before comparing.
329 pid_from() {
330     local file=$1 pid exe
331     [ -f "$file" ] || return 1
332     pid=$(cat "$file" 2>/dev/null) || return 1
333     [ -n "$pid" ] && [ -d "/proc/$pid" ] || return 1
334     exe=$(readlink "/proc/$pid/exe" 2>/dev/null) || return 1
335     exe=${exe% (deleted)}
336     case "${exe##*/}" in cce-fx|cce) ;; *) return 1 ;; esac
337     printf '%s\n' "$pid"
338 }
339 
340 shadow_pid() { pid_from "$PIDFILE"; }
341 
342 shadow_display() { cat "$DISPLAY_FILE" 2>/dev/null || true; }
343 
344 # Every process the shadow started, the compositor excepted.
345 #
346 # They cannot be found by process group: the compositor setsid's whatever it
347 # spawns, so each client is its own session leader and a `kill -- -PGID` on the
348 # compositor reaches none of them. They also must not be found by name — the
349 # live session runs the very same binaries. The environment is the one honest
350 # marker: only a shadow process has HOME pointing inside the shadow. Without
351 # this sweep the clients survive `stop`, and because the next `start` reuses the
352 # same display name they reattach to the new compositor — which looks exactly
353 # like session restore gone wrong (15 windows from one spawn).
354 # Taking the home as an argument is what lets `list` report on instances other
355 # than the resolved one — and it is also why instances cannot bleed into each
356 # other: two shadows have two homes, so neither sweep can see the other's
357 # clients.
358 children_of() {
359     local pid home=$1 comp=${2:-}
360     for pid in /proc/[0-9]*; do
361         pid=${pid#/proc/}
362         [ "$pid" = "$comp" ] && continue
363         grep -qz "^HOME=$home$" "/proc/$pid/environ" 2>/dev/null && printf '%s\n' "$pid"
364     done
365     # The loop almost always ends on a process whose environ this user cannot
366     # read, where grep exits 2 — and as the last command that becomes the
367     # function's status. With `set -o pipefail` on, `x=$(children_of ... | wc -l)`
368     # then fails the assignment and `set -e` kills the script with no message.
369     return 0
370 }
371 
372 shadow_children() { children_of "$SHADOW_HOME" "${1:-}"; }
373 
374 require_running() {
375     shadow_pid >/dev/null || die "not running — start it with: cce-shadow start"
376     [ -n "$(shadow_display)" ] || die "running but no display recorded; try: cce-shadow stop"
377 }
378 
379 # The environment a client (or ccectl) needs to talk to the shadow.
380 # The X display of a shadow started with --xwayland, ":N", or nothing.
381 # Only the compositor's log says which one Xwayland took.
382 shadow_x_display() {
383     grep -a -m1 -oE 'Starting Xwayland on :[0-9]+' "$LOG" 2>/dev/null | awk '{print $4}' || true
384 }
385 
386 # `-u DISPLAY` comes first: env(1) takes its options before assignments. It
387 # is there so an X11 client cannot fall through to the LIVE session's X
388 # server — without it a `run` of a GTK program under an Xwayland-less shadow
389 # put its windows on the user's screen.
390 shadow_env() {
391     printf '%s\n' \
392         "-u" "DISPLAY" \
393         "HOME=$SHADOW_HOME" \
394         "XDG_CONFIG_HOME=$SHADOW_HOME/.config" \
395         "XDG_STATE_HOME=$SHADOW_HOME/.local/state" \
396         "XDG_CACHE_HOME=$SHADOW_HOME/.cache" \
397         "XDG_DATA_HOME=$SHADOW_HOME/.local/share" \
398         "WAYLAND_DISPLAY=$(shadow_display)"
399     local x; x=$(shadow_x_display)
400     [ -n "$x" ] && printf 'DISPLAY=%s\n' "$x"
401     return 0
402 }
403 
404 # ── config seeding ───────────────────────────────────────────────────────────
405 # Copy only config.kdl and input.kdl. The real config dir also holds
406 # accounts.json, google_client.json and cce-remote.pin — credentials that have
407 # no business being duplicated into a scratch directory.
408 seed_config() {
409     local scale=$1 cfg="$SHADOW_HOME/.config/cce"
410     mkdir -p "$cfg" "$SHADOW_HOME/Pictures/screenshots" \
411              "$SHADOW_HOME/.local/state" "$SHADOW_HOME/.cache" \
412              "$SHADOW_HOME/.local/share"
413 
414     if [ ! -f "$cfg/config.kdl" ]; then
415         if [ -f "$REAL_CONFIG/config.kdl" ]; then
416             cp "$REAL_CONFIG/config.kdl" "$cfg/config.kdl"
417             note "seeded config from $REAL_CONFIG/config.kdl"
418         else
419             : > "$cfg/config.kdl"
420             note "no config at $REAL_CONFIG/config.kdl — starting empty"
421         fi
422         [ -f "$REAL_CONFIG/input.kdl" ] && cp "$REAL_CONFIG/input.kdl" "$cfg/input.kdl"
423 
424         # Per-app overrides (~/.config/cce/<app>/config.kdl) decide fonts and
425         # colours for most clients — without them cce-terminal and friends fall
426         # back to defaults and look nothing like the real session. These are
427         # config only; the credentials in this tree (accounts.json,
428         # google_client.json, cce-remote.pin) sit at the top level and are
429         # deliberately not matched by this.
430         local appdir app
431         for appdir in "$REAL_CONFIG"/*/; do
432             [ -d "$appdir" ] || continue
433             app=$(basename "$appdir")
434             [ "$app" = backups ] && continue
435             [ -f "$appdir/config.kdl" ] || continue
436             mkdir -p "$cfg/$app"
437             cp "$appdir/config.kdl" "$cfg/$app/config.kdl"
438         done
439     fi
440 
441     # cce-ui resolves font *aliases* (monospace, terminal, status-interface,
442     # window-borders …) by reading $HOME/.config/fontconfig/fonts.conf itself —
443     # keyed on HOME, not XDG_CONFIG_HOME (cce-ui/src/layout.rs,
444     # read_preferred_fonts). With HOME isolated the file is missing, the content
445     # defaults to empty and every alias falls back to Noto, so surfaces that ask
446     # for an alias rather than a concrete family (the status bar, cce-terminal)
447     # would render in the wrong face. Widgets naming a family outright are
448     # unaffected — verified by pixel-comparing this app with and without the
449     # copy. Seed it so both kinds match the real session.
450     if [ ! -f "$SHADOW_HOME/.config/fontconfig/fonts.conf" ] \
451        && [ -f "$HOME/.config/fontconfig/fonts.conf" ]; then
452         mkdir -p "$SHADOW_HOME/.config/fontconfig"
453         cp "$HOME/.config/fontconfig/fonts.conf" "$SHADOW_HOME/.config/fontconfig/fonts.conf"
454     fi
455 
456     # The compositor spawns the desktop and window context menus by absolute
457     # path, "$HOME/.local/bin/cce-desktop-menu" / "cce-app-menu" (cursor.rs),
458     # and those scripts reach ccectl and cce-cloud the same way. With HOME
459     # isolated the directory is missing and a right-click silently opens
460     # nothing, so point it at the real one. A symlink, not copies: the
461     # binaries stay whatever is installed, and `--fresh`'s rm -rf removes
462     # the link, never what it points to.
463     if [ ! -e "$SHADOW_HOME/.local/bin" ] && [ -d "$HOME/.local/bin" ]; then
464         ln -s "$HOME/.local/bin" "$SHADOW_HOME/.local/bin"
465     fi
466 
467     # See the header: readable-but-key-absent means notifications default ON.
468     if ! grep -q '^notifications' "$cfg/config.kdl" 2>/dev/null; then
469         printf '\n// cce-shadow: keep captures off the real screen.\nnotifications {\n    screenshots (bool)false\n}\n' \
470             >> "$cfg/config.kdl"
471     fi
472 
473     # KDL is typed and the parser reads this with as_f64(), which returns None
474     # for an integer literal — "(f64)2" silently leaves the output at scale 1,
475     # while "(f64)2.0" applies. Normalise before writing.
476     case "$scale" in *.*) ;; *) scale="$scale.0" ;; esac
477 
478     if [ "$scale" != "1.0" ]; then
479         # The headless output is HEADLESS-1. Resolution is not settable (the
480         # config output block understands scale but not mode), so scale is the
481         # only lever on effective size: 1280x720 at scale 2 is a 640x360
482         # logical desktop, which is how HiDPI layout gets exercised.
483         if grep -q '^output {' "$cfg/config.kdl"; then
484             awk -v ins="    HEADLESS-1 scale=(f64)$scale" '
485                 /^output \{/ && !done { print; print ins; done=1; next }
486                 /HEADLESS-1 scale=/ { next }
487                 { print }' "$cfg/config.kdl" > "$cfg/config.kdl.tmp"
488             mv "$cfg/config.kdl.tmp" "$cfg/config.kdl"
489         else
490             printf '\noutput {\n    HEADLESS-1 scale=(f64)%s\n}\n' "$scale" >> "$cfg/config.kdl"
491         fi
492         note "output scale $scale"
493     fi
494 }
495 
496 # ── commands ─────────────────────────────────────────────────────────────────
497 cmd_start() {
498     local fresh=0 restore=0 exec_cmd=':' scale=1 gpu="${CCE_SHADOW_GPU:-}" bin="" new=0 xwayland=0
499     while [ $# -gt 0 ]; do
500         case "$1" in
501             --new)   new=1; shift ;;
502             --instance) INSTANCE=${2:?--instance needs a name}; resolve_paths; shift 2 ;;
503             --fresh) fresh=1; shift ;;
504             --restore) restore=1; shift ;;
505             --exec)  exec_cmd=${2:?--exec needs a command}; shift 2 ;;
506             --scale) scale=${2:?--scale needs a number}; shift 2 ;;
507             --xwayland) xwayland=1; shift ;;
508             --gpu)   gpu=${2:?--gpu needs a device path}; shift 2 ;;
509             --bin)   bin=${2:?--bin needs a path}; shift 2 ;;
510             *) die "unknown option: $1" ;;
511         esac
512     done
513 
514     if [ "$new" = 1 ]; then
515         [ -n "${CCE_SHADOW_DIR:-}" ] && die "--new cannot be combined with CCE_SHADOW_DIR"
516         INSTANCE=$(alloc_instance)
517         resolve_paths
518         note "claimed instance '$INSTANCE'"
519     fi
520 
521     # Attaching to a session that is already up is the intended no-op, but say
522     # whose it is: the reason to name instances at all is that this line used to
523     # be the last thing between an agent and someone else's windows.
524     local pid
525     if pid=$(shadow_pid); then
526         note "instance '$INSTANCE' already running (pid $pid, display $(shadow_display))"
527         note "drive it with: $(addr) ctl windows"
528         return 0
529     fi
530 
531     [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] \
532         || die "XDG_RUNTIME_DIR is unset or missing — the wayland socket needs it"
533 
534     local cce_fx; cce_fx=$(find_bin cce-fx "$bin")
535 
536     if [ "$fresh" = 1 ]; then
537         note "discarding $SHADOW_HOME"
538         rm -rf "$SHADOW_HOME"
539     fi
540     mkdir -p "$RUN_DIR" "$SHOTS"
541     seed_config "$scale"
542     rm -f "$DISPLAY_FILE"
543 
544     # The compositor saves its windows on shutdown and respawns them on start.
545     # That is correct behaviour and it stays inside the shadow, but it makes a
546     # verification run depend on whatever the previous one left behind — three
547     # start/stop cycles had nine cce-files windows stacked up. A harness should
548     # begin from a known state, so discard it unless the run is *about* restore.
549     if [ "$restore" = 0 ]; then
550         rm -f "$SHADOW_HOME/.local/state/cce/state.json"
551     else
552         note "keeping saved window state"
553     fi
554 
555     [ -z "$gpu" ] && gpu=$(default_gpu)
556     case "$gpu" in none) gpu="" ;; esac
557 
558     local -a env_args=(-u WAYLAND_DISPLAY -u DISPLAY)
559     local e; while read -r e; do env_args+=("$e"); done < <(
560         printf '%s\n' \
561             "HOME=$SHADOW_HOME" \
562             "XDG_CONFIG_HOME=$SHADOW_HOME/.config" \
563             "XDG_STATE_HOME=$SHADOW_HOME/.local/state" \
564             "XDG_CACHE_HOME=$SHADOW_HOME/.cache" \
565             "XDG_DATA_HOME=$SHADOW_HOME/.local/share" \
566             "WLR_BACKENDS=headless" \
567             "WLR_HEADLESS_OUTPUTS=1")
568     [ -n "$gpu" ] && env_args+=("WLR_RENDER_DRM_DEVICE=$gpu")
569 
570     # Written before the launch, so an instance is attributable even if the
571     # compositor dies during startup and leaves the tree behind.
572     owner_token > "$RUN_DIR/owner"
573 
574     local -a xwayland_args=(--no-xwayland)
575     [ "$xwayland" = 1 ] && xwayland_args=()
576 
577     note "starting $cce_fx (headless)"
578     env "${env_args[@]}" setsid nohup \
579         "$cce_fx" "${xwayland_args[@]}" --log-level info -c "$exec_cmd" \
580         > "$LOG" 2>&1 &
581     local started=$!
582     printf '%s\n' "$started" > "$PIDFILE"
583 
584     # The compositor picks its own display via wl_display_add_socket_auto, so
585     # the log is the only authority on which one it got. Waiting for that line
586     # is also what proves it survived startup.
587     local waited=0 display=""
588     while [ "$waited" -lt "$((START_TIMEOUT_MS / 50))" ]; do
589         if [ -d "/proc/$started" ]; then
590             display=$(grep -a -m1 -oE 'display socket: [^ ]+' "$LOG" 2>/dev/null | awk '{print $3}' || true)
591             [ -n "$display" ] && break
592         else
593             printf '%s\n' "--- last lines of $LOG ---" >&2
594             tail -20 "$LOG" >&2 || true
595             rm -f "$PIDFILE"
596             die "cce-fx exited during startup"
597         fi
598         sleep 0.05
599         waited=$((waited + 1))
600     done
601     [ -n "$display" ] || { rm -f "$PIDFILE"; die "no display socket after $((START_TIMEOUT_MS / 1000))s; see $LOG"; }
602 
603     printf '%s\n' "$display" > "$DISPLAY_FILE"
604     note "instance '$INSTANCE' up on $display (pid $started)"
605     note "drive it with: $(addr) ctl windows"
606 }
607 
608 cmd_stop() {
609     if [ "${1:-}" = --all ]; then shift; cmd_stop_all "$@"; return; fi
610 
611     local pid display
612     pid=$(shadow_pid) || pid=""
613     display=$(shadow_display)
614 
615     if [ -n "$pid" ]; then
616         kill "$pid" 2>/dev/null || true
617         local waited=0
618         while [ -d "/proc/$pid" ] && [ "$waited" -lt 100 ]; do sleep 0.05; waited=$((waited + 1)); done
619         [ -d "/proc/$pid" ] && { kill -9 "$pid" 2>/dev/null || true; }
620     fi
621 
622     # Sweep the clients even when the compositor was already gone — that is
623     # precisely the case where they are left behind.
624     local -a kids=(); local k
625     while read -r k; do [ -n "$k" ] && kids+=("$k"); done < <(shadow_children "$pid")
626     if [ ${#kids[@]} -gt 0 ]; then
627         note "stopping ${#kids[@]} client(s) left in the shadow"
628         kill "${kids[@]}" 2>/dev/null || true
629         local waited=0
630         while [ "$waited" -lt 60 ]; do
631             local alive=0
632             for k in "${kids[@]}"; do [ -d "/proc/$k" ] && alive=1 && break; done
633             [ "$alive" = 0 ] && break
634             sleep 0.05; waited=$((waited + 1))
635         done
636         for k in "${kids[@]}"; do [ -d "/proc/$k" ] && kill -9 "$k" 2>/dev/null || true; done
637     fi
638 
639     if [ -z "$pid" ]; then
640         rm -f "$PIDFILE" "$DISPLAY_FILE"
641         note "not running"
642         return 0
643     fi
644 
645     # The compositor does not always unlink these on the way out, and a stale
646     # socket makes the next ccectl hang instead of failing fast.
647     if [ -n "$display" ]; then
648         rm -f "/tmp/cce-$display.sock" "/tmp/cce-stream-$display.sock" \
649               "/tmp/cce-status-interface-$display.sock" "/tmp/cce-status-$display.sock"
650     fi
651     rm -f "$PIDFILE" "$DISPLAY_FILE"
652     note "stopped (was $display, pid $pid)"
653 }
654 
655 cmd_stop_all() {
656     [ -n "${CCE_SHADOW_DIR:-}" ] && die "--all is meaningless with CCE_SHADOW_DIR set"
657     local force=0
658     [ "${1:-}" = --force ] && force=1
659     local name n=0 skipped=0
660     while read -r name; do
661         [ -n "$name" ] || continue
662         INSTANCE=$name
663         resolve_paths
664         # Never silently: an instance that survives --all has to say why, or
665         # the next reading is "stop --all left something running".
666         if [ "$force" = 0 ] && [ "$(instance_ownership)" = other ]; then
667             note "skipping '$name' — another live session started it (--force overrides)"
668             skipped=$((skipped + 1))
669             continue
670         fi
671         note "instance '$name'"
672         cmd_stop
673         n=$((n + 1))
674     done < <(instance_names)
675     [ "$n" = 0 ] && [ "$skipped" = 0 ] && note "no instances"
676     return 0
677 }
678 
679 # One row of `list`. The pid may be passed in for a live legacy tree, whose
680 # pidfile is not where an instance's would be.
681 list_row() {
682     local name=$1 dir=$2 pid=${3:-}
683     local status=stopped disp="" up="" kids=0
684     [ -n "$pid" ] || pid=$(pid_from "$dir/run/cce-fx.pid") || pid=""
685     if [ -n "$pid" ]; then
686         status=running
687         disp=$(cat "$dir/run/display" 2>/dev/null || true)
688         up=$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ')
689         kids=$(children_of "$dir/home" "$pid" | wc -l)
690     fi
691     printf '%-12s %-8s %-8s %-11s %-9s %-8s %s\n' \
692         "$name" "$status" "${pid:--}" "${disp:--}" "${up:--}" "$kids" \
693         "$(instance_ownership "$dir")"
694 }
695 
696 cmd_list() {
697     local name dir legacy="" found=0
698     printf '%-12s %-8s %-8s %-11s %-9s %-8s %s\n' \
699         INSTANCE STATUS PID DISPLAY UPTIME CLIENTS OWNER
700     legacy=$(legacy_live 2>/dev/null || true)
701     while read -r name; do
702         [ -n "$name" ] || continue
703         found=1
704         dir="$SHADOW_BASE/$name"
705         if [ ! -d "$dir" ] && [ -n "$legacy" ]; then
706             list_row "$name" "$SHADOW_BASE" "$legacy"
707         else
708             list_row "$name" "$dir"
709         fi
710     done < <(instance_names)
711     [ "$found" = 0 ] && printf '(none)\n'
712     return 0
713 }
714 
715 # Only agent-N instances, the ones --new hands out: an agent that dies never
716 # calls stop, and a leaked headless compositor runs forever. Instances someone
717 # named by hand are left alone, because pruning takes their shots/ with them and
718 # a name chosen deliberately is not garbage.
719 cmd_prune() {
720     [ -n "${CCE_SHADOW_DIR:-}" ] && die "prune is meaningless with CCE_SHADOW_DIR set"
721     local force=0
722     [ "${1:-}" = --force ] && force=1
723     local name dir n=0
724     while read -r name; do
725         case "$name" in agent-[0-9]*) ;; *) continue ;; esac
726         dir="$SHADOW_BASE/$name"
727         [ -d "$dir" ] || continue
728         if pid_from "$dir/run/cce-fx.pid" >/dev/null; then
729             note "keeping '$name' — still running"
730             continue
731         fi
732         # A stopped instance is still someone's workspace: its shots and its
733         # seeded config are what they come back to. Only reclaim what is mine,
734         # unowned, or orphaned — an owner whose process is gone is exactly the
735         # leak this command exists for.
736         if [ "$force" = 0 ] && [ "$(instance_ownership "$dir")" = other ]; then
737             note "keeping '$name' — another live session started it (--force overrides)"
738             continue
739         fi
740         rm -rf "$dir"
741         note "removed '$name'"
742         n=$((n + 1))
743     done < <(instance_names)
744     note "pruned $n instance(s)"
745 }
746 
747 cmd_status() {
748     local pid
749     if ! pid=$(shadow_pid); then
750         printf 'stopped    (instance %s)\n' "$INSTANCE"
751         [ -f "$PIDFILE" ] && printf 'note: stale pidfile at %s\n' "$PIDFILE"
752         return 0
753     fi
754     printf 'running   pid %s on %s\n' "$pid" "$(shadow_display)"
755     printf 'instance  %s\n' "$INSTANCE"
756     printf 'home      %s\n' "$SHADOW_HOME"
757     printf 'log       %s\n' "$LOG"
758     printf 'uptime    %s\n' "$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ')"
759     local n; n=$(cmd_ctl windows 2>/dev/null | grep -c 'window id=' || true)
760     printf 'windows   %s\n' "${n:-0}"
761     printf 'clients   %s\n' "$(shadow_children "$pid" | wc -l)"
762 }
763 
764 cmd_ctl() {
765     require_running
766     local ccectl; ccectl=$(find_bin ccectl)
767     local -a env_args=(); local e
768     while read -r e; do env_args+=("$e"); done < <(shadow_env)
769     env "${env_args[@]}" "$ccectl" "$@"
770 }
771 
772 cmd_run() {
773     require_running
774     [ $# -gt 0 ] || die "run needs a command"
775     local -a env_args=(); local e
776     while read -r e; do env_args+=("$e"); done < <(shadow_env)
777     env "${env_args[@]}" "$@"
778 }
779 
780 # A complete PNG ends with the 12-byte IEND chunk, whose last 8 bytes are the
781 # literal "IEND" plus its fixed CRC. Checking for it is exact, where checking
782 # for a non-zero size is not.
783 png_complete() {
784     [ -s "$1" ] || return 1
785     tail -c 8 "$1" 2>/dev/null | od -An -tx1 | tr -d ' \n' | grep -q '49454e44ae426082'
786 }
787 
788 # Screenshots land in the shadow's own $HOME/Pictures/screenshots.
789 #
790 # "ok <path>" means the *capture* succeeded, not that the file is ready: the
791 # compositor PNG-encodes on a worker thread and answers the IPC first on
792 # purpose, so that a large output does not stall the socket. The path is
793 # therefore created before it is filled, and copying on the reply alone yields
794 # a 0-byte file. Wait for the terminator instead.
795 capture() {
796     local name=$1; shift
797     local reply path
798     reply=$(cmd_ctl "$@") || die "capture failed: $reply"
799     case "$reply" in
800         ok\ *) path=${reply#ok } ;;
801         *) die "unexpected ccectl reply: $reply" ;;
802     esac
803     local waited=0
804     while ! png_complete "$path"; do
805         [ "$waited" -lt 200 ] || die "timed out waiting for $path to finish encoding"
806         sleep 0.05
807         waited=$((waited + 1))
808     done
809     if [ -n "$name" ]; then
810         mkdir -p "$SHOTS"
811         cp "$path" "$SHOTS/$name.png"
812         printf '%s\n' "$SHOTS/$name.png"
813     else
814         printf '%s\n' "$path"
815     fi
816 }
817 
818 cmd_shot()        { capture "${1:-}" screenshot; }
819 cmd_shot_window() { local n=${2:-}; capture "$n" screenshot window ${1:+"$1"}; }
820 
821 cmd_logs() {
822     [ -f "$LOG" ] || die "no log at $LOG"
823     if [ "${1:-}" = "-f" ]; then tail -f "$LOG"; else tail -40 "$LOG"; fi
824 }
825 
826 cmd_env() { require_running; shadow_env | sed 's/^/export /'; }
827 
828 # --instance is global: it applies to every command, so it is parsed before the
829 # command word and the paths are resolved from it once, here.
830 while [ $# -gt 0 ]; do
831     case "$1" in
832         --instance)   INSTANCE=${2:?--instance needs a name}; shift 2 ;;
833         --instance=*) INSTANCE=${1#*=}; shift ;;
834         *) break ;;
835     esac
836 done
837 
838 # Before resolve_paths, so that plain `help` neither migrates nor creates.
839 case "${1:-}" in -h|--help|help|"") usage; exit 0 ;; esac
840 
841 resolve_paths
842 
843 case "$1" in
844     start)        shift; cmd_start "$@" ;;
845     stop)         shift; cmd_stop "$@" ;;
846     status)       shift; cmd_status "$@" ;;
847     ctl)          shift; cmd_ctl "$@" ;;
848     spawn)        shift; [ $# -gt 0 ] || die "spawn needs a command"; cmd_ctl spawn "$@" ;;
849     shot)         shift; cmd_shot "$@" ;;
850     shot-window)  shift; cmd_shot_window "$@" ;;
851     run)          shift; cmd_run "$@" ;;
852     logs)         shift; cmd_logs "$@" ;;
853     env)          shift; cmd_env "$@" ;;
854     list)         shift; cmd_list "$@" ;;
855     prune)        shift; cmd_prune "$@" ;;
856     *) die "unknown command: $1 (try: cce-shadow help)" ;;
857 esac