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

scripts/cce-shadow (36.4K)

  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-secrets (Secret Service), cce-remote (binds 0.0.0.0:17017). Every
271 other cce app is safe; cce-cloud's daemon socket is already display-keyed.
272 EOF
273 }
274 
275 # ── locating binaries ────────────────────────────────────────────────────────
276 # Prefer whatever is installed, because the usual reason to start a shadow is to
277 # verify what `ccebuild install` just deployed. Fall back to the workspace build
278 # so the script also works in a tree that was never installed.
279 workspace() {
280     if [ -n "${CCE_WORKSPACE:-}" ]; then printf '%s\n' "$CCE_WORKSPACE"; return; fi
281     cargo locate-project --workspace --message-format plain 2>/dev/null | xargs -r dirname
282 }
283 
284 # Pick a render node whose textures the compositor can actually read back.
285 #
286 # Full-output capture reads the output's own buffer and works anywhere, but
287 # `screenshot window` reads the *client's* imported dmabuf, and when the
288 # compositor is on the NVIDIA node while the client rendered on another,
289 # wlr_texture_read_pixels reports format 0x0 and the capture fails. Preferring
290 # a non-NVIDIA node keeps window capture working; `--gpu none` opts out, and
291 # an explicit `--gpu <path>` always wins.
292 default_gpu() {
293     local d drv
294     for d in /dev/dri/renderD*; do
295         [ -e "$d" ] || continue
296         drv=$(sed -n 's/^DRIVER=//p' "/sys/class/drm/${d##*/}/device/uevent" 2>/dev/null)
297         [ "$drv" = nvidia ] && continue
298         printf '%s\n' "$d"
299         return
300     done
301 }
302 
303 find_bin() {
304     local name=$1 override=${2:-} ws
305     if [ -n "$override" ]; then
306         [ -x "$override" ] || die "no executable at $override"
307         printf '%s\n' "$override"; return
308     fi
309     local p
310     if p=$(command -v "$name" 2>/dev/null); then printf '%s\n' "$p"; return; fi
311     ws=$(workspace)
312     if [ -n "$ws" ] && [ -x "$ws/target/release/$name" ]; then
313         printf '%s\n' "$ws/target/release/$name"; return
314     fi
315     die "cannot find $name — install it, or pass --bin / set CCE_WORKSPACE"
316 }
317 
318 # ── process identity ─────────────────────────────────────────────────────────
319 # Always confirm via /proc/<pid>/exe before signalling. A pidfile can go stale
320 # and have its number reused, and matching on argv instead would be worse: the
321 # live session is also a cce-fx, and killing the wrong one ends the user's
322 # desktop. `ccebuild install` unlinks before writing, so a running binary's exe
323 # often reads "<path> (deleted)" — strip that before comparing.
324 pid_from() {
325     local file=$1 pid exe
326     [ -f "$file" ] || return 1
327     pid=$(cat "$file" 2>/dev/null) || return 1
328     [ -n "$pid" ] && [ -d "/proc/$pid" ] || return 1
329     exe=$(readlink "/proc/$pid/exe" 2>/dev/null) || return 1
330     exe=${exe% (deleted)}
331     case "${exe##*/}" in cce-fx|cce) ;; *) return 1 ;; esac
332     printf '%s\n' "$pid"
333 }
334 
335 shadow_pid() { pid_from "$PIDFILE"; }
336 
337 shadow_display() { cat "$DISPLAY_FILE" 2>/dev/null || true; }
338 
339 # Every process the shadow started, the compositor excepted.
340 #
341 # They cannot be found by process group: the compositor setsid's whatever it
342 # spawns, so each client is its own session leader and a `kill -- -PGID` on the
343 # compositor reaches none of them. They also must not be found by name — the
344 # live session runs the very same binaries. The environment is the one honest
345 # marker: only a shadow process has HOME pointing inside the shadow. Without
346 # this sweep the clients survive `stop`, and because the next `start` reuses the
347 # same display name they reattach to the new compositor — which looks exactly
348 # like session restore gone wrong (15 windows from one spawn).
349 # Taking the home as an argument is what lets `list` report on instances other
350 # than the resolved one — and it is also why instances cannot bleed into each
351 # other: two shadows have two homes, so neither sweep can see the other's
352 # clients.
353 children_of() {
354     local pid home=$1 comp=${2:-}
355     for pid in /proc/[0-9]*; do
356         pid=${pid#/proc/}
357         [ "$pid" = "$comp" ] && continue
358         grep -qz "^HOME=$home$" "/proc/$pid/environ" 2>/dev/null && printf '%s\n' "$pid"
359     done
360     # The loop almost always ends on a process whose environ this user cannot
361     # read, where grep exits 2 — and as the last command that becomes the
362     # function's status. With `set -o pipefail` on, `x=$(children_of ... | wc -l)`
363     # then fails the assignment and `set -e` kills the script with no message.
364     return 0
365 }
366 
367 shadow_children() { children_of "$SHADOW_HOME" "${1:-}"; }
368 
369 require_running() {
370     shadow_pid >/dev/null || die "not running — start it with: cce-shadow start"
371     [ -n "$(shadow_display)" ] || die "running but no display recorded; try: cce-shadow stop"
372 }
373 
374 # The environment a client (or ccectl) needs to talk to the shadow.
375 # The X display of a shadow started with --xwayland, ":N", or nothing.
376 # Only the compositor's log says which one Xwayland took.
377 shadow_x_display() {
378     grep -a -m1 -oE 'Starting Xwayland on :[0-9]+' "$LOG" 2>/dev/null | awk '{print $4}' || true
379 }
380 
381 # `-u DISPLAY` comes first: env(1) takes its options before assignments. It
382 # is there so an X11 client cannot fall through to the LIVE session's X
383 # server — without it a `run` of a GTK program under an Xwayland-less shadow
384 # put its windows on the user's screen.
385 shadow_env() {
386     printf '%s\n' \
387         "-u" "DISPLAY" \
388         "HOME=$SHADOW_HOME" \
389         "XDG_CONFIG_HOME=$SHADOW_HOME/.config" \
390         "XDG_STATE_HOME=$SHADOW_HOME/.local/state" \
391         "XDG_CACHE_HOME=$SHADOW_HOME/.cache" \
392         "XDG_DATA_HOME=$SHADOW_HOME/.local/share" \
393         "WAYLAND_DISPLAY=$(shadow_display)"
394     local x; x=$(shadow_x_display)
395     [ -n "$x" ] && printf 'DISPLAY=%s\n' "$x"
396     return 0
397 }
398 
399 # ── config seeding ───────────────────────────────────────────────────────────
400 # Copy only config.kdl and input.kdl. The real config dir also holds
401 # accounts.json, google_client.json and cce-remote.pin — credentials that have
402 # no business being duplicated into a scratch directory.
403 seed_config() {
404     local scale=$1 cfg="$SHADOW_HOME/.config/cce"
405     mkdir -p "$cfg" "$SHADOW_HOME/Pictures/screenshots" \
406              "$SHADOW_HOME/.local/state" "$SHADOW_HOME/.cache" \
407              "$SHADOW_HOME/.local/share"
408 
409     if [ ! -f "$cfg/config.kdl" ]; then
410         if [ -f "$REAL_CONFIG/config.kdl" ]; then
411             cp "$REAL_CONFIG/config.kdl" "$cfg/config.kdl"
412             note "seeded config from $REAL_CONFIG/config.kdl"
413         else
414             : > "$cfg/config.kdl"
415             note "no config at $REAL_CONFIG/config.kdl — starting empty"
416         fi
417         [ -f "$REAL_CONFIG/input.kdl" ] && cp "$REAL_CONFIG/input.kdl" "$cfg/input.kdl"
418 
419         # Per-app overrides (~/.config/cce/<app>/config.kdl) decide fonts and
420         # colours for most clients — without them cce-terminal and friends fall
421         # back to defaults and look nothing like the real session. These are
422         # config only; the credentials in this tree (accounts.json,
423         # google_client.json, cce-remote.pin) sit at the top level and are
424         # deliberately not matched by this.
425         local appdir app
426         for appdir in "$REAL_CONFIG"/*/; do
427             [ -d "$appdir" ] || continue
428             app=$(basename "$appdir")
429             [ "$app" = backups ] && continue
430             [ -f "$appdir/config.kdl" ] || continue
431             mkdir -p "$cfg/$app"
432             cp "$appdir/config.kdl" "$cfg/$app/config.kdl"
433         done
434     fi
435 
436     # cce-ui resolves font *aliases* (monospace, terminal, status-interface,
437     # window-borders …) by reading $HOME/.config/fontconfig/fonts.conf itself —
438     # keyed on HOME, not XDG_CONFIG_HOME (cce-ui/src/layout.rs,
439     # read_preferred_fonts). With HOME isolated the file is missing, the content
440     # defaults to empty and every alias falls back to Noto, so surfaces that ask
441     # for an alias rather than a concrete family (the status bar, cce-terminal)
442     # would render in the wrong face. Widgets naming a family outright are
443     # unaffected — verified by pixel-comparing this app with and without the
444     # copy. Seed it so both kinds match the real session.
445     if [ ! -f "$SHADOW_HOME/.config/fontconfig/fonts.conf" ] \
446        && [ -f "$HOME/.config/fontconfig/fonts.conf" ]; then
447         mkdir -p "$SHADOW_HOME/.config/fontconfig"
448         cp "$HOME/.config/fontconfig/fonts.conf" "$SHADOW_HOME/.config/fontconfig/fonts.conf"
449     fi
450 
451     # The compositor spawns the desktop and window context menus by absolute
452     # path, "$HOME/.local/bin/cce-desktop-menu" / "cce-app-menu" (cursor.rs),
453     # and those scripts reach ccectl and cce-cloud the same way. With HOME
454     # isolated the directory is missing and a right-click silently opens
455     # nothing, so point it at the real one. A symlink, not copies: the
456     # binaries stay whatever is installed, and `--fresh`'s rm -rf removes
457     # the link, never what it points to.
458     if [ ! -e "$SHADOW_HOME/.local/bin" ] && [ -d "$HOME/.local/bin" ]; then
459         ln -s "$HOME/.local/bin" "$SHADOW_HOME/.local/bin"
460     fi
461 
462     # See the header: readable-but-key-absent means notifications default ON.
463     if ! grep -q '^notifications' "$cfg/config.kdl" 2>/dev/null; then
464         printf '\n// cce-shadow: keep captures off the real screen.\nnotifications {\n    screenshots (bool)false\n}\n' \
465             >> "$cfg/config.kdl"
466     fi
467 
468     # KDL is typed and the parser reads this with as_f64(), which returns None
469     # for an integer literal — "(f64)2" silently leaves the output at scale 1,
470     # while "(f64)2.0" applies. Normalise before writing.
471     case "$scale" in *.*) ;; *) scale="$scale.0" ;; esac
472 
473     if [ "$scale" != "1.0" ]; then
474         # The headless output is HEADLESS-1. Resolution is not settable (the
475         # config output block understands scale but not mode), so scale is the
476         # only lever on effective size: 1280x720 at scale 2 is a 640x360
477         # logical desktop, which is how HiDPI layout gets exercised.
478         if grep -q '^output {' "$cfg/config.kdl"; then
479             awk -v ins="    HEADLESS-1 scale=(f64)$scale" '
480                 /^output \{/ && !done { print; print ins; done=1; next }
481                 /HEADLESS-1 scale=/ { next }
482                 { print }' "$cfg/config.kdl" > "$cfg/config.kdl.tmp"
483             mv "$cfg/config.kdl.tmp" "$cfg/config.kdl"
484         else
485             printf '\noutput {\n    HEADLESS-1 scale=(f64)%s\n}\n' "$scale" >> "$cfg/config.kdl"
486         fi
487         note "output scale $scale"
488     fi
489 }
490 
491 # ── commands ─────────────────────────────────────────────────────────────────
492 cmd_start() {
493     local fresh=0 restore=0 exec_cmd=':' scale=1 gpu="${CCE_SHADOW_GPU:-}" bin="" new=0 xwayland=0
494     while [ $# -gt 0 ]; do
495         case "$1" in
496             --new)   new=1; shift ;;
497             --instance) INSTANCE=${2:?--instance needs a name}; resolve_paths; shift 2 ;;
498             --fresh) fresh=1; shift ;;
499             --restore) restore=1; shift ;;
500             --exec)  exec_cmd=${2:?--exec needs a command}; shift 2 ;;
501             --scale) scale=${2:?--scale needs a number}; shift 2 ;;
502             --xwayland) xwayland=1; shift ;;
503             --gpu)   gpu=${2:?--gpu needs a device path}; shift 2 ;;
504             --bin)   bin=${2:?--bin needs a path}; shift 2 ;;
505             *) die "unknown option: $1" ;;
506         esac
507     done
508 
509     if [ "$new" = 1 ]; then
510         [ -n "${CCE_SHADOW_DIR:-}" ] && die "--new cannot be combined with CCE_SHADOW_DIR"
511         INSTANCE=$(alloc_instance)
512         resolve_paths
513         note "claimed instance '$INSTANCE'"
514     fi
515 
516     # Attaching to a session that is already up is the intended no-op, but say
517     # whose it is: the reason to name instances at all is that this line used to
518     # be the last thing between an agent and someone else's windows.
519     local pid
520     if pid=$(shadow_pid); then
521         note "instance '$INSTANCE' already running (pid $pid, display $(shadow_display))"
522         note "drive it with: $(addr) ctl windows"
523         return 0
524     fi
525 
526     [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] \
527         || die "XDG_RUNTIME_DIR is unset or missing — the wayland socket needs it"
528 
529     local cce_fx; cce_fx=$(find_bin cce-fx "$bin")
530 
531     if [ "$fresh" = 1 ]; then
532         note "discarding $SHADOW_HOME"
533         rm -rf "$SHADOW_HOME"
534     fi
535     mkdir -p "$RUN_DIR" "$SHOTS"
536     seed_config "$scale"
537     rm -f "$DISPLAY_FILE"
538 
539     # The compositor saves its windows on shutdown and respawns them on start.
540     # That is correct behaviour and it stays inside the shadow, but it makes a
541     # verification run depend on whatever the previous one left behind — three
542     # start/stop cycles had nine cce-files windows stacked up. A harness should
543     # begin from a known state, so discard it unless the run is *about* restore.
544     if [ "$restore" = 0 ]; then
545         rm -f "$SHADOW_HOME/.local/state/cce/state.json"
546     else
547         note "keeping saved window state"
548     fi
549 
550     [ -z "$gpu" ] && gpu=$(default_gpu)
551     case "$gpu" in none) gpu="" ;; esac
552 
553     local -a env_args=(-u WAYLAND_DISPLAY -u DISPLAY)
554     local e; while read -r e; do env_args+=("$e"); done < <(
555         printf '%s\n' \
556             "HOME=$SHADOW_HOME" \
557             "XDG_CONFIG_HOME=$SHADOW_HOME/.config" \
558             "XDG_STATE_HOME=$SHADOW_HOME/.local/state" \
559             "XDG_CACHE_HOME=$SHADOW_HOME/.cache" \
560             "XDG_DATA_HOME=$SHADOW_HOME/.local/share" \
561             "WLR_BACKENDS=headless" \
562             "WLR_HEADLESS_OUTPUTS=1")
563     [ -n "$gpu" ] && env_args+=("WLR_RENDER_DRM_DEVICE=$gpu")
564 
565     # Written before the launch, so an instance is attributable even if the
566     # compositor dies during startup and leaves the tree behind.
567     owner_token > "$RUN_DIR/owner"
568 
569     local -a xwayland_args=(--no-xwayland)
570     [ "$xwayland" = 1 ] && xwayland_args=()
571 
572     note "starting $cce_fx (headless)"
573     env "${env_args[@]}" setsid nohup \
574         "$cce_fx" "${xwayland_args[@]}" --log-level info -c "$exec_cmd" \
575         > "$LOG" 2>&1 &
576     local started=$!
577     printf '%s\n' "$started" > "$PIDFILE"
578 
579     # The compositor picks its own display via wl_display_add_socket_auto, so
580     # the log is the only authority on which one it got. Waiting for that line
581     # is also what proves it survived startup.
582     local waited=0 display=""
583     while [ "$waited" -lt "$((START_TIMEOUT_MS / 50))" ]; do
584         if [ -d "/proc/$started" ]; then
585             display=$(grep -a -m1 -oE 'display socket: [^ ]+' "$LOG" 2>/dev/null | awk '{print $3}' || true)
586             [ -n "$display" ] && break
587         else
588             printf '%s\n' "--- last lines of $LOG ---" >&2
589             tail -20 "$LOG" >&2 || true
590             rm -f "$PIDFILE"
591             die "cce-fx exited during startup"
592         fi
593         sleep 0.05
594         waited=$((waited + 1))
595     done
596     [ -n "$display" ] || { rm -f "$PIDFILE"; die "no display socket after $((START_TIMEOUT_MS / 1000))s; see $LOG"; }
597 
598     printf '%s\n' "$display" > "$DISPLAY_FILE"
599     note "instance '$INSTANCE' up on $display (pid $started)"
600     note "drive it with: $(addr) ctl windows"
601 }
602 
603 cmd_stop() {
604     if [ "${1:-}" = --all ]; then shift; cmd_stop_all "$@"; return; fi
605 
606     local pid display
607     pid=$(shadow_pid) || pid=""
608     display=$(shadow_display)
609 
610     if [ -n "$pid" ]; then
611         kill "$pid" 2>/dev/null || true
612         local waited=0
613         while [ -d "/proc/$pid" ] && [ "$waited" -lt 100 ]; do sleep 0.05; waited=$((waited + 1)); done
614         [ -d "/proc/$pid" ] && { kill -9 "$pid" 2>/dev/null || true; }
615     fi
616 
617     # Sweep the clients even when the compositor was already gone — that is
618     # precisely the case where they are left behind.
619     local -a kids=(); local k
620     while read -r k; do [ -n "$k" ] && kids+=("$k"); done < <(shadow_children "$pid")
621     if [ ${#kids[@]} -gt 0 ]; then
622         note "stopping ${#kids[@]} client(s) left in the shadow"
623         kill "${kids[@]}" 2>/dev/null || true
624         local waited=0
625         while [ "$waited" -lt 60 ]; do
626             local alive=0
627             for k in "${kids[@]}"; do [ -d "/proc/$k" ] && alive=1 && break; done
628             [ "$alive" = 0 ] && break
629             sleep 0.05; waited=$((waited + 1))
630         done
631         for k in "${kids[@]}"; do [ -d "/proc/$k" ] && kill -9 "$k" 2>/dev/null || true; done
632     fi
633 
634     if [ -z "$pid" ]; then
635         rm -f "$PIDFILE" "$DISPLAY_FILE"
636         note "not running"
637         return 0
638     fi
639 
640     # The compositor does not always unlink these on the way out, and a stale
641     # socket makes the next ccectl hang instead of failing fast.
642     if [ -n "$display" ]; then
643         rm -f "/tmp/cce-$display.sock" "/tmp/cce-stream-$display.sock" \
644               "/tmp/cce-status-interface-$display.sock" "/tmp/cce-status-$display.sock"
645     fi
646     rm -f "$PIDFILE" "$DISPLAY_FILE"
647     note "stopped (was $display, pid $pid)"
648 }
649 
650 cmd_stop_all() {
651     [ -n "${CCE_SHADOW_DIR:-}" ] && die "--all is meaningless with CCE_SHADOW_DIR set"
652     local force=0
653     [ "${1:-}" = --force ] && force=1
654     local name n=0 skipped=0
655     while read -r name; do
656         [ -n "$name" ] || continue
657         INSTANCE=$name
658         resolve_paths
659         # Never silently: an instance that survives --all has to say why, or
660         # the next reading is "stop --all left something running".
661         if [ "$force" = 0 ] && [ "$(instance_ownership)" = other ]; then
662             note "skipping '$name' — another live session started it (--force overrides)"
663             skipped=$((skipped + 1))
664             continue
665         fi
666         note "instance '$name'"
667         cmd_stop
668         n=$((n + 1))
669     done < <(instance_names)
670     [ "$n" = 0 ] && [ "$skipped" = 0 ] && note "no instances"
671     return 0
672 }
673 
674 # One row of `list`. The pid may be passed in for a live legacy tree, whose
675 # pidfile is not where an instance's would be.
676 list_row() {
677     local name=$1 dir=$2 pid=${3:-}
678     local status=stopped disp="" up="" kids=0
679     [ -n "$pid" ] || pid=$(pid_from "$dir/run/cce-fx.pid") || pid=""
680     if [ -n "$pid" ]; then
681         status=running
682         disp=$(cat "$dir/run/display" 2>/dev/null || true)
683         up=$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ')
684         kids=$(children_of "$dir/home" "$pid" | wc -l)
685     fi
686     printf '%-12s %-8s %-8s %-11s %-9s %-8s %s\n' \
687         "$name" "$status" "${pid:--}" "${disp:--}" "${up:--}" "$kids" \
688         "$(instance_ownership "$dir")"
689 }
690 
691 cmd_list() {
692     local name dir legacy="" found=0
693     printf '%-12s %-8s %-8s %-11s %-9s %-8s %s\n' \
694         INSTANCE STATUS PID DISPLAY UPTIME CLIENTS OWNER
695     legacy=$(legacy_live 2>/dev/null || true)
696     while read -r name; do
697         [ -n "$name" ] || continue
698         found=1
699         dir="$SHADOW_BASE/$name"
700         if [ ! -d "$dir" ] && [ -n "$legacy" ]; then
701             list_row "$name" "$SHADOW_BASE" "$legacy"
702         else
703             list_row "$name" "$dir"
704         fi
705     done < <(instance_names)
706     [ "$found" = 0 ] && printf '(none)\n'
707     return 0
708 }
709 
710 # Only agent-N instances, the ones --new hands out: an agent that dies never
711 # calls stop, and a leaked headless compositor runs forever. Instances someone
712 # named by hand are left alone, because pruning takes their shots/ with them and
713 # a name chosen deliberately is not garbage.
714 cmd_prune() {
715     [ -n "${CCE_SHADOW_DIR:-}" ] && die "prune is meaningless with CCE_SHADOW_DIR set"
716     local force=0
717     [ "${1:-}" = --force ] && force=1
718     local name dir n=0
719     while read -r name; do
720         case "$name" in agent-[0-9]*) ;; *) continue ;; esac
721         dir="$SHADOW_BASE/$name"
722         [ -d "$dir" ] || continue
723         if pid_from "$dir/run/cce-fx.pid" >/dev/null; then
724             note "keeping '$name' — still running"
725             continue
726         fi
727         # A stopped instance is still someone's workspace: its shots and its
728         # seeded config are what they come back to. Only reclaim what is mine,
729         # unowned, or orphaned — an owner whose process is gone is exactly the
730         # leak this command exists for.
731         if [ "$force" = 0 ] && [ "$(instance_ownership "$dir")" = other ]; then
732             note "keeping '$name' — another live session started it (--force overrides)"
733             continue
734         fi
735         rm -rf "$dir"
736         note "removed '$name'"
737         n=$((n + 1))
738     done < <(instance_names)
739     note "pruned $n instance(s)"
740 }
741 
742 cmd_status() {
743     local pid
744     if ! pid=$(shadow_pid); then
745         printf 'stopped    (instance %s)\n' "$INSTANCE"
746         [ -f "$PIDFILE" ] && printf 'note: stale pidfile at %s\n' "$PIDFILE"
747         return 0
748     fi
749     printf 'running   pid %s on %s\n' "$pid" "$(shadow_display)"
750     printf 'instance  %s\n' "$INSTANCE"
751     printf 'home      %s\n' "$SHADOW_HOME"
752     printf 'log       %s\n' "$LOG"
753     printf 'uptime    %s\n' "$(ps -o etime= -p "$pid" 2>/dev/null | tr -d ' ')"
754     local n; n=$(cmd_ctl windows 2>/dev/null | grep -c 'window id=' || true)
755     printf 'windows   %s\n' "${n:-0}"
756     printf 'clients   %s\n' "$(shadow_children "$pid" | wc -l)"
757 }
758 
759 cmd_ctl() {
760     require_running
761     local ccectl; ccectl=$(find_bin ccectl)
762     local -a env_args=(); local e
763     while read -r e; do env_args+=("$e"); done < <(shadow_env)
764     env "${env_args[@]}" "$ccectl" "$@"
765 }
766 
767 cmd_run() {
768     require_running
769     [ $# -gt 0 ] || die "run needs a command"
770     local -a env_args=(); local e
771     while read -r e; do env_args+=("$e"); done < <(shadow_env)
772     env "${env_args[@]}" "$@"
773 }
774 
775 # A complete PNG ends with the 12-byte IEND chunk, whose last 8 bytes are the
776 # literal "IEND" plus its fixed CRC. Checking for it is exact, where checking
777 # for a non-zero size is not.
778 png_complete() {
779     [ -s "$1" ] || return 1
780     tail -c 8 "$1" 2>/dev/null | od -An -tx1 | tr -d ' \n' | grep -q '49454e44ae426082'
781 }
782 
783 # Screenshots land in the shadow's own $HOME/Pictures/screenshots.
784 #
785 # "ok <path>" means the *capture* succeeded, not that the file is ready: the
786 # compositor PNG-encodes on a worker thread and answers the IPC first on
787 # purpose, so that a large output does not stall the socket. The path is
788 # therefore created before it is filled, and copying on the reply alone yields
789 # a 0-byte file. Wait for the terminator instead.
790 capture() {
791     local name=$1; shift
792     local reply path
793     reply=$(cmd_ctl "$@") || die "capture failed: $reply"
794     case "$reply" in
795         ok\ *) path=${reply#ok } ;;
796         *) die "unexpected ccectl reply: $reply" ;;
797     esac
798     local waited=0
799     while ! png_complete "$path"; do
800         [ "$waited" -lt 200 ] || die "timed out waiting for $path to finish encoding"
801         sleep 0.05
802         waited=$((waited + 1))
803     done
804     if [ -n "$name" ]; then
805         mkdir -p "$SHOTS"
806         cp "$path" "$SHOTS/$name.png"
807         printf '%s\n' "$SHOTS/$name.png"
808     else
809         printf '%s\n' "$path"
810     fi
811 }
812 
813 cmd_shot()        { capture "${1:-}" screenshot; }
814 cmd_shot_window() { local n=${2:-}; capture "$n" screenshot window ${1:+"$1"}; }
815 
816 cmd_logs() {
817     [ -f "$LOG" ] || die "no log at $LOG"
818     if [ "${1:-}" = "-f" ]; then tail -f "$LOG"; else tail -40 "$LOG"; fi
819 }
820 
821 cmd_env() { require_running; shadow_env | sed 's/^/export /'; }
822 
823 # --instance is global: it applies to every command, so it is parsed before the
824 # command word and the paths are resolved from it once, here.
825 while [ $# -gt 0 ]; do
826     case "$1" in
827         --instance)   INSTANCE=${2:?--instance needs a name}; shift 2 ;;
828         --instance=*) INSTANCE=${1#*=}; shift ;;
829         *) break ;;
830     esac
831 done
832 
833 # Before resolve_paths, so that plain `help` neither migrates nor creates.
834 case "${1:-}" in -h|--help|help|"") usage; exit 0 ;; esac
835 
836 resolve_paths
837 
838 case "$1" in
839     start)        shift; cmd_start "$@" ;;
840     stop)         shift; cmd_stop "$@" ;;
841     status)       shift; cmd_status "$@" ;;
842     ctl)          shift; cmd_ctl "$@" ;;
843     spawn)        shift; [ $# -gt 0 ] || die "spawn needs a command"; cmd_ctl spawn "$@" ;;
844     shot)         shift; cmd_shot "$@" ;;
845     shot-window)  shift; cmd_shot_window "$@" ;;
846     run)          shift; cmd_run "$@" ;;
847     logs)         shift; cmd_logs "$@" ;;
848     env)          shift; cmd_env "$@" ;;
849     list)         shift; cmd_list "$@" ;;
850     prune)        shift; cmd_prune "$@" ;;
851     *) die "unknown command: $1 (try: cce-shadow help)" ;;
852 esac