Files
OneCAD/packaging/linux/flatpak-smoke.py
T
bherbruckandClaude Opus 5.5 6090e89b3f Release under the SolidCraft name: binaries, assets and installers (#383)
* Release under the SolidCraft name: binaries, assets and installers

The desktop binary is now `solidcraft` and the CLI `solidcraft-cli` (the two app packages
are renamed; their folders move with the rest of the rename later). Every release asset is
named solidcraft-<ver>-… / SolidCraft-<ver>-windows-x64.exe, and the installers show the
SolidCraft product name.

Kept so existing installs keep updating (docs/compatibility.md):
- `solvecraft-cli` alias binary in every package, forwarding to solidcraft-cli with a
  deprecation note on stderr (tests/legacy_alias.rs).
- Linux app ID ai.storyteller.solvecraft (Flatpak ID, desktop entry, Wayland app_id).
- MSI UpgradeCode, so the MSI upgrades in place.
- deb Replaces/Conflicts/Provides and rpm Obsoletes/Provides solvecraft.
- An old-named .zsync copy so earlier AppImages find updates; a legacy .flatpakrepo copy.
- .solvecraft file associations; SOLVECRAFT_* fallbacks in the packaging scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

* MSI: register the legacy .solvecraft extension without a duplicate open verb

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:05:35 -04:00

262 lines
12 KiB
Python

#!/usr/bin/env python3
"""Exercise desktop startup and modelling in the installed Flatpak, without images.
Uses the app's normal sandbox permissions. The control client runs inside that
sandbox, so this does not grant host network access or save Flatpak overrides.
Each headless backend has private runtime/configuration directories and a bus.
"""
import argparse
import base64
import json
import math
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
import tempfile
import time
import zlib
APP_ID = "ai.storyteller.solvecraft"
MAX_PIXELS = 16_000_000
MAX_PNG_BYTES = 70_000_000
SANDBOX_PROBE = r'''
set -euo pipefail
work=$(mktemp -d)
export XDG_CONFIG_HOME="$work/config" XDG_DATA_HOME="$work/data" XDG_CACHE_HOME="$work/cache"
mkdir -p "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
unset WGPU_BACKEND WGPU_BACKENDS
solidcraft --control 18837 >"$work/app.log" 2>&1 &
app=$!
cleanup() {
kill "$app" 2>/dev/null || true
wait "$app" 2>/dev/null || true
cat "$work/app.log" >&2
rm -rf -- "$work"
}
trap cleanup EXIT
ready=0
for ((attempt=0; attempt<300; attempt++)); do
if ! kill -0 "$app" 2>/dev/null; then exit 1; fi
if { exec 3<>/dev/tcp/127.0.0.1/18837; } 2>/dev/null; then ready=1; break; fi
sleep .1
done
test "$ready" = 1
for request in '{"id":1,"method":"ui.inspect","params":{}}' '{"id":2,"method":"engine.execute","params":{"command":"solid.box","params":{"length":30,"width":20,"height":10}}}' '{"id":3,"method":"document.inspect","params":{"measure":true}}'; do
printf '%s\n' "$request" >&3
IFS= read -r -t 30 reply <&3
printf '%s\n' "$reply"
done
printf '{"id":4,"method":"ui.screenshot","params":{"path":"%s/frame.png"}}\n' "$work" >&3
IFS= read -r -t 30 reply <&3
printf '%s\n' "$reply"
base64 -w0 "$work/frame.png"
printf '\n'
'''
def validate_replies(stdout):
replies = [json.loads(line) for line in stdout.splitlines()]
if len(replies) != 3 or any(reply.get("id") != i or reply.get("ok") is not True
for i, reply in enumerate(replies, 1)):
raise ValueError(f"desktop control failed: {replies}")
ui = replies[0]["result"]
viewport = ui["viewport"]
if (ui["renderer"] not in {"gpu", "cpu"} or len(viewport) != 4
or not all(math.isfinite(value) for value in viewport)
or viewport[2] <= 0 or viewport[3] <= 0):
raise ValueError("desktop did not create a usable viewport")
document = replies[2]["result"]
bodies = document["bodies"]
if (len(bodies) != 1 or len(document["timeline"]) != 1
or document["timeline"][0]["error"] is not None
or not math.isclose(bodies[0]["volume_mm3"], 6000, rel_tol=0, abs_tol=1e-7)
or bodies[0]["bbox"] != {"min": [0, 0, 0], "max": [30, 20, 10]}):
raise ValueError(f"desktop modelling failed: {document}")
return ui["renderer"]
def frame_statistics(png):
"""Bounded PNG readback check for the app's non-interlaced RGBA8 output."""
if len(png) > MAX_PNG_BYTES or not png.startswith(b"\x89PNG\r\n\x1a\n"):
raise ValueError("invalid or oversized presented-frame PNG")
offset, width, height, ended = 8, 0, 0, False
compressed = bytearray()
while offset < len(png):
if offset + 12 > len(png):
raise ValueError("truncated PNG chunk")
length = struct.unpack_from(">I", png, offset)[0]
kind = png[offset + 4:offset + 8]
end = offset + 12 + length
if end > len(png):
raise ValueError("truncated PNG data")
data = memoryview(png)[offset + 8:offset + 8 + length]
crc = zlib.crc32(data, zlib.crc32(kind)) & 0xffffffff
if crc != struct.unpack_from(">I", png, end - 4)[0]:
raise ValueError("PNG checksum mismatch")
if kind == b"IHDR":
if offset != 8 or length != 13:
raise ValueError("invalid PNG header")
width, height, depth, colour, compression, filtering, interlace = struct.unpack(">IIBBBBB", data)
if (width == 0 or height == 0 or width * height > MAX_PIXELS
or (depth, colour, compression, filtering, interlace) != (8, 6, 0, 0, 0)):
raise ValueError("unsupported PNG dimensions or format")
elif kind == b"IDAT":
if not width or len(compressed) + length > MAX_PNG_BYTES:
raise ValueError("invalid PNG image data")
compressed.extend(data)
elif kind == b"IEND":
if length or end != len(png):
raise ValueError("invalid PNG end")
ended = True
break
elif not (kind[0] & 32):
raise ValueError("unsupported critical PNG chunk")
offset = end
if not ended or not width or not compressed:
raise ValueError("incomplete PNG")
stride = width * 4
expected = height * (stride + 1)
inflater = zlib.decompressobj()
try:
raw = inflater.decompress(compressed, expected + 1)
except zlib.error as error:
raise ValueError("invalid compressed PNG data") from error
if (len(raw) != expected or not inflater.eof or inflater.unconsumed_tail or inflater.unused_data):
raise ValueError("PNG decompressed size mismatch")
previous = bytearray(stride)
first = None
nonblack, varied = 0, False
for y in range(height):
start = y * (stride + 1)
method = raw[start]
if method > 4:
raise ValueError("unsupported PNG row filter")
row = bytearray(raw[start + 1:start + 1 + stride])
if method:
for i in range(stride):
left = row[i - 4] if i >= 4 else 0
above = previous[i]
corner = previous[i - 4] if i >= 4 else 0
if method == 1:
prediction = left
elif method == 2:
prediction = above
elif method == 3:
prediction = (left + above) // 2
else:
p = left + above - corner
a, b, c = abs(p - left), abs(p - above), abs(p - corner)
prediction = left if a <= b and a <= c else (above if b <= c else corner)
row[i] = (row[i] + prediction) & 255
if first is None:
first = tuple(row[:3])
for i in range(0, stride, 4):
nonblack += row[i + 3] > 8 and (row[i] > 8 or row[i + 1] > 8 or row[i + 2] > 8)
varied |= row[i] != first[0] or row[i + 1] != first[1] or row[i + 2] != first[2]
previous = row
if not varied or nonblack <= width * height // 10:
raise ValueError(f"blank presented frame: {nonblack}/{width * height} nonblack pixels, varied={varied}")
return {"width": width, "height": height, "nonblack": nonblack, "pixels": width * height}
def probe(gl_saved_four=False):
shell = SANDBOX_PROBE
if gl_saved_four:
shell = shell.replace('solidcraft --control',
'mkdir -p "$XDG_DATA_HOME/solvecraft"\n'
'printf \'{"msaa":4}\' >"$XDG_DATA_HOME/solvecraft/graphics.json"\n'
'export WGPU_BACKEND=gl\nsolidcraft --control')
result = subprocess.run(["flatpak", "run", "--user", "--command=bash", APP_ID, "-c", shell],
text=True, capture_output=True, timeout=125)
if result.returncode:
raise RuntimeError(f"Flatpak desktop exited {result.returncode}\n{result.stderr[-8000:]}")
lines = result.stdout.splitlines()
if len(lines) != 5 or len(lines[4]) > ((MAX_PNG_BYTES + 2) // 3) * 4:
raise ValueError("invalid presented-frame response")
renderer = validate_replies("\n".join(lines[:3]))
capture = json.loads(lines[3])
if capture.get("id") != 4 or capture.get("ok") is not True or renderer != "gpu":
raise ValueError("GPU presented-frame capture failed")
statistics = frame_statistics(base64.b64decode(lines[4], validate=True))
if (capture["result"]["width"], capture["result"]["height"]) != (statistics["width"], statistics["height"]):
raise ValueError("presented-frame dimensions disagree")
if gl_saved_four and "SolidCraft graphics: Gl, MSAA 1x (requested 4x)" not in result.stderr:
raise ValueError("forced GL did not apply the safe sample count")
mode = "GL saved 4x" if gl_saved_four else "default backend"
print(f"Flatpak desktop PASS: {mode}, renderer={renderer}, box=6000 mm³, frame={statistics}")
def run_backend(backend):
tools = ["flatpak", "dbus-run-session", "xvfb-run"] if backend == "x11" else ["flatpak", "dbus-run-session", "weston"]
for tool in tools:
if not shutil.which(tool):
raise RuntimeError(f"{tool} missing: install flatpak, dbus-x11, xvfb, xauth and weston")
with tempfile.TemporaryDirectory(prefix="solidcraft-flatpak-smoke-") as temporary:
runtime = Path(temporary) / "runtime"
runtime.mkdir(mode=0o700)
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime))
env.pop("WAYLAND_DISPLAY", None)
env.pop("WAYLAND_SOCKET", None)
env.pop("DISPLAY", None)
env.pop("SOLIDCRAFT_WAYLAND", None)
env.pop("SOLVECRAFT_WAYLAND", None)
command = ["dbus-run-session", "--"]
compositor = None
with (Path(temporary) / "weston.log").open("wb") as log:
try:
if backend == "x11":
command += ["xvfb-run", "-a", "-s", "-screen 0 1600x1000x24 -nolisten tcp"]
else:
env["WAYLAND_DISPLAY"] = "solidcraft-smoke"
compositor = subprocess.Popen(["weston", "--backend=headless", "--renderer=pixman", "--no-config",
"--socket=solidcraft-smoke", "--width=1600", "--height=1000"],
env=env, stdout=log, stderr=subprocess.STDOUT)
deadline = time.monotonic() + 20
while not (runtime / "solidcraft-smoke").exists():
if compositor.poll() is not None or time.monotonic() >= deadline:
raise RuntimeError("headless Weston failed: " + (Path(temporary) / "weston.log").read_text())
time.sleep(.1)
command += [sys.executable, str(Path(__file__).resolve()), "--probe"]
subprocess.run(command, env=env, check=True, timeout=150)
if backend == "wayland":
subprocess.run(command + ["--gl-saved-four"], env=env, check=True, timeout=150)
print(f"{backend}: default sandbox desktop startup and modelling PASS")
finally:
if compositor:
compositor.terminate()
try:
compositor.wait(timeout=10)
except subprocess.TimeoutExpired:
compositor.kill()
compositor.wait()
# The isolated document portal can leave a disconnected FUSE
# mount after its private session bus exits. Unmount only our
# temporary runtime's portal before deleting that directory.
if shutil.which("fusermount3"):
subprocess.run(["fusermount3", "-u", str(runtime / "doc")],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--backend", choices=["x11", "wayland", "both"], default="both")
parser.add_argument("--probe", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--gl-saved-four", action="store_true", help=argparse.SUPPRESS)
args = parser.parse_args()
if args.probe:
probe(args.gl_saved_four)
else:
for backend in (["x11", "wayland"] if args.backend == "both" else [args.backend]):
run_backend(backend)
if __name__ == "__main__":
try:
main()
except (OSError, ValueError, KeyError, TypeError, RuntimeError, subprocess.SubprocessError) as error:
print(f"Flatpak desktop smoke failed: {error}", file=sys.stderr)
sys.exit(1)