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