Files
OrcaSlicer/scripts/CAD/start-headless-gui.sh
T
Tommaso BianchiandClaude Opus 5 648b930e75 gesture rungs for the six new constraint buttons, and the DistanceX/Y bug they found
Port of snaporca 2951e3c60b; parity holds (17 identical, DesignPanel.cpp still exactly 32
divergent lines, so the hunk landed on the right side of the DropDown divergence).

The sketch-constraint epic added six toolbar buttons and covered all six with kernel
tests. Not one of them was ever clicked. The gesture ladder only pressed 'perpendicular'
and 'equal' -- and CON_BTN, which locates buttons by index, was silently wrong for every
entry past index 5 for the whole epic. The untested half of the toolbar was exactly the
broken half (snaporca-rqsy).

Four rungs now drive them: D4 Equal on two circles (must mean equal RADIUS, not the
equal-length no-op the epic fixed), D5 Collinear on two oblique lines, D6 a horizontal
distance, D7 symmetric about the implicit vertical axis. Full ladder 118/118 on snaporca;
this fork's rig has not been rebuilt against the change yet, so here it is reviewed,
parity-checked and NOT exercised.

D6 found a shipped defect. apply_entity_constraint enumerated {P0,p0},{P1,p1} for BOTH
entities regardless of type, but a Point's p1 is unused and reads (0,0), as does a
Circle's. The closest-pair search then picked those two phantom origins, distance 0: the
field opened pre-filled 0.00 and the solver dropped the constraint, because ptOf(Point,P1)
resolves to no handle. Nothing errored -- the dimension simply did nothing. ends_of() now
enumerates only the roles an entity actually exposes, and the pair with no point at all is
refused with a message instead of a silent no-op.

Five kernel tests, a 7/7 ladder, a review and a fork port all passed over this, because
every one of them exercises the kernel, where the geometry was always right.

Two rig faults fixed in the same pass, both of which produce a green-looking session that
tests nothing: start-headless-gui.sh never exported SNAPORCA_MCP or SNAPORCA_KEYTRACE, so
a freshly launched rig comes up healthy and every ladder dies on "Connection refused"; and
it never dismissed the "Restore" dialog a killed session leaves behind, which grabs every
synthetic click afterwards.

The value field also takes no keyboard focus from the WM -- typed digits go to the canvas
and Return commits the pre-filled number (typed 40, got 54.94). focus_field() finds it as
its own top-level window and clicks it first. The no-op tolerance is now 5e-3, the field's
own two-decimal display resolution, not 1e-6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrMzTpAf78U4NG2M8jfvHY
2026-08-31 10:40:38 +02:00

149 lines
8.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# Bring the headless GUI up on a VNC-served X display, ready to drive or to attach Remmina to.
#
# Runs INSIDE the long-lived GUI container (see the header of scripts/CAD/build-gui-incremental.sh for how
# that container is created). Idempotent: safe to re-run to recover a session whose app died.
#
# docker exec <container> /OrcaSlicer/scripts/CAD/start-headless-gui.sh # launch + settle
# docker exec <container> /OrcaSlicer/scripts/CAD/start-headless-gui.sh --status # report, change nothing
#
# WHY THIS EXISTS. Dismissing the first-run dialogs by computing the titlebar close box from
# `xdotool getwindowgeometry --shell` and clicking it went wrong whenever the dialog had already
# closed: the eval left the geometry variables stale or empty, the click landed at a garbage
# coordinate, and it repeatedly hit the Sketch button in the toolbar underneath, so the app came up
# in sketch mode with a stray Sketch feature. Three of those in one session.
#
# The titlebar click is nevertheless the RIGHT mechanism and is kept. `xdotool windowclose` looks
# cleaner but kills the app: it destroys the GdkWindow out from under the dialog and the process
# dies with "GdkWindow unexpectedly destroyed", three GLib-GObject criticals and a segfault
# (measured 2026-07-30). Escape does not close the Setup Wizard either. So the fix is not a
# different mechanism, it is refusing to click on geometry we have not validated.
set -euo pipefail
DISP="${DISP:-:11}"
GEOM="${GEOM:-1920x1080}"
BIN="${BIN:-/OrcaSlicer/build/src/Release/orca-slicer}"
# Packaging cannot bundle python (the deps python layer carries a doubled-DESTDIR RUNPATH), so the
# build-tree binary needs the deps libpython on the path. Packaging-only issue; the app runs fine.
LIBPY="/OrcaSlicer/deps/build/destdir/usr/local/libpython/lib"
LIBPY2="/OrcaSlicer/build/src/Release/python/lib"
LOG="${LOG:-/tmp/gui-session.log}"
export DISPLAY="$DISP" HOME=/root
export LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
# The ladders read the document back through the MCP socket, and the offer ladder's only
# instrument is the [OFFER] keytrace. Neither is on by default, and a session launched without
# them comes up looking perfectly healthy: the window is there, status says app up, and every
# ladder then dies on "Connection refused" — which reads as a dead app rather than a rig that was
# started without its instrument. The script that launches the rig is where they belong.
export SNAPORCA_MCP="${SNAPORCA_MCP:-/tmp/mcp.sock}"
export SNAPORCA_KEYTRACE="${SNAPORCA_KEYTRACE:-1}"
export LD_LIBRARY_PATH="$LIBPY:$LIBPY2:${LD_LIBRARY_PATH:-}"
mkdir -p /root/.config # startup dies in boost::filesystem::create_directory without this
# Skip zombies. This container accumulates <defunct> instances of the app across runs, and pgrep
# matches them, so the naive "first match" reported a dead pid as if the session were healthy.
app_pid() {
local p
for p in $(pgrep -f "$(basename "$BIN")" 2>/dev/null); do
[ "$(awk "{print \$3}" "/proc/$p/stat" 2>/dev/null)" = "Z" ] && continue
echo "$p"; return 0
done
return 1
}
status() {
echo "display : $(pgrep -f "Xvfb $DISP" >/dev/null && echo up || echo DOWN)"
echo "wm : $(pgrep -x openbox >/dev/null && echo up || echo DOWN)"
if pgrep -x x11vnc >/dev/null; then echo "vnc : up on :5900"
elif ! command -v x11vnc >/dev/null; then echo "vnc : n/a (x11vnc not installed here)"
else echo "vnc : DOWN"; fi
local p; p="$(app_pid || true)"
echo "app : ${p:-DOWN}"
# WHICH binary is on screen, not just that something is. A pid alone cannot tell you whether
# you are looking at the build you just linked or one from last week, and that is precisely
# the question every rig verification is asking.
[ -n "${p:-}" ] && echo "binary : $(readlink -f "/proc/$p/exe" 2>/dev/null || echo unknown)"
[ -n "${p:-}" ] && echo "windows : $(xdotool search --name . getwindowname %@ 2>/dev/null | paste -sd'|' -)"
return 0
}
[ "${1:-}" = "--status" ] && { status; exit 0; }
# --- desktop: Xvfb, a window manager, and the VNC server ------------------------------------
# openbox is REQUIRED: without it xdotool windowactivate aborts with "windowmanager claims not to
# support _NET_ACTIVE_WINDOW" and dialogs never take focus.
pgrep -f "Xvfb $DISP" >/dev/null || { nohup Xvfb "$DISP" -screen 0 "${GEOM}x24" -nolisten tcp >/tmp/xvfb.log 2>&1 & sleep 3; }
pgrep -x openbox >/dev/null || { nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; }
if ! pgrep -x x11vnc >/dev/null && command -v x11vnc >/dev/null; then
AUTH=()
[ -f /root/.vnc/passwd ] && AUTH=(-rfbauth /root/.vnc/passwd)
nohup x11vnc -display "$DISP" -rfbport 5900 "${AUTH[@]}" -forever -shared -noxdamage \
>/tmp/x11vnc.log 2>&1 &
sleep 2
fi
# --- app ------------------------------------------------------------------------------------
# Kill by BASENAME, not by "$BIN". The app enforces a single instance, so an older copy launched
# from a DIFFERENT path (the packaged build/package/bin/ one, say, when BIN points at the freshly
# linked build/src/Release/ one) survives a path-matched pkill, keeps the instance lock, and the
# new process exits seconds after loading fonts — leaving no error anywhere. app_pid() below has
# always matched by basename, so status then reported that stale process as a healthy session:
# the launch looked green while the window on screen was days old. Measured 2026-08-02, where it
# nearly passed a UI change against a Jul-30 binary. The killer and the reporter must agree on
# what counts as "the app".
pkill -9 -f "$(basename "$BIN")" 2>/dev/null || true
sleep 2
nohup "$BIN" >"$LOG" 2>&1 &
echo "launched $(basename "$BIN") pid $!"
# Wait for the main window rather than sleeping a fixed amount: cold starts vary a lot under
# software GL, and a fixed sleep either wastes time or races.
for _ in $(seq 1 40); do
xdotool search --name "Untitled" >/dev/null 2>&1 && break
sleep 1
done
# --- first-run dialogs ----------------------------------------------------------------------
# Click the titlebar close box, but only on geometry we have just read for a window that still
# exists, and only if the resulting point is inside the screen. Every variable is unset first so a
# failed read cannot leave the previous dialog's numbers behind — that is the whole bug.
screen_w="${GEOM%x*}"; screen_h="${GEOM#*x}"
close_dialog() {
local name="$1" id X Y WIDTH HEIGHT cx cy
id="$(xdotool search --name "$name" 2>/dev/null | head -1 || true)"
[ -z "$id" ] && return 1
unset X Y WIDTH HEIGHT
eval "$(xdotool getwindowgeometry --shell "$id" 2>/dev/null || true)"
# All four must be present and numeric: an empty or stale read is how the stray click happened.
for v in "${X:-}" "${Y:-}" "${WIDTH:-}" "${HEIGHT:-}"; do
[[ "$v" =~ ^-?[0-9]+$ ]] || { echo " $name: unreadable geometry, not clicking"; return 1; }
done
cx=$((X + WIDTH - 11)); cy=$((Y - 31)) # openbox decoration: close box above the frame
if [ "$cx" -lt 0 ] || [ "$cy" -lt 0 ] || [ "$cx" -ge "$screen_w" ] || [ "$cy" -ge "$screen_h" ]; then
echo " $name: close box at ${cx},${cy} is off-screen, not clicking"; return 1
fi
echo " $name: closing via titlebar at ${cx},${cy}"
xdotool mousemove "$cx" "$cy" click 1
sleep 2
return 0
}
# "Restore" is not a first-RUN dialog, it is a second-run one: killing the app mid-session leaves
# unsaved items behind, and the next launch asks whether to restore them. It sits over the tab bar
# with a modal grab, so every synthetic click afterwards lands on it and the ladder reports
# geometry that never got drawn — that is the "success with no log" shape twice already.
for name in "Setup Wizard" "New version" "Restore"; do
for _ in 1 2 3; do close_dialog "$name" || break; done
done
# --- main window ----------------------------------------------------------------------------
main="$(xdotool search --name "Untitled" 2>/dev/null | head -1 || true)"
if [ -n "$main" ]; then
xdotool windowmove "$main" 0 0 windowsize "$main" "$screen_w" "$screen_h" 2>/dev/null || true
xdotool windowactivate "$main" 2>/dev/null || true
sleep 2
fi
echo "--- session ---"
status