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