* 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
262 lines
12 KiB
Python
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)
|