Files
solidcraft-upstream/packaging/linux/flatpak-smoke.py
T
bherbruckandClaude Opus 5.5 e67cc0c747 Linux app id: ai.storyteller.solidcraft, with an end-of-life rebase from the old id (#619)
The Flatpak app id, desktop/metainfo/MIME file names, icon name and the window's Wayland app_id / X11 class are now ai.storyteller.solidcraft, the id the ArtCraft launcher expects.

Existing Flatpak installs move over: the Pages repo marks app/ai.storyteller.solvecraft end-of-life with a rebase to the new id, so flatpak update offers the switch and Flatpak carries ~/.var/app along. A new install next to the old one copies settings and autosaves on first launch through a read-only grant on the old data folder. The metainfo declares <replaces>/<provides>, the desktop entry X-Flatpak-RenamedFrom, and AppImages drop the menu entry an older AppImage installed. deb/rpm upgrades remove the old-named files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 05:20:58 -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.solidcraft"
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/solidcraft"\n'
'printf \'{"msaa":4}\' >"$XDG_DATA_HOME/solidcraft/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) # the legacy name is still read
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)