Fix desktop launch, app icons and keyboard parameter editing
This commit is contained in:
1 parent
8fa52c8e5e
commit
6dd202d277
45 files changed
+5993
-272
No files matched your search
@@ -62,7 +62,9 @@ Url=$base_url/flatpak/repo/
|
||||
Homepage=https://github.com/$GITHUB_REPOSITORY
|
||||
META
|
||||
cat >"$site/flatpak/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolidCraft Flatpak repository</title></head><body><main><h1>SolidCraft Flatpak repository</h1><p>Latest published release: <strong>$tag</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolidCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
|
||||
flatpak install solvecraft $app_id</pre></main></body></html>
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolidCraft Flatpak repository</title></head><body><main><h1>SolidCraft Flatpak repository</h1><p>Latest published release: <strong>$tag</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolidCraft repository</a></p><p>Flathub provides the required Freedesktop runtime. Add both remotes to the same user installation:</p><pre>flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak remote-add --user --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
|
||||
flatpak install --user solvecraft $app_id
|
||||
flatpak run $app_id</pre></main></body></html>
|
||||
HTML
|
||||
touch "$site/.nojekyll"
|
||||
@@ -9,8 +9,9 @@
|
||||
# is built for the host architecture (x86_64 or aarch64), which must match the tarball's.
|
||||
# Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's
|
||||
# `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are
|
||||
# installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and
|
||||
# `solvecraft-cli --version` is run inside the sandbox as a smoke test.
|
||||
# installed per-user from Flathub. Unless --no-test, the bundle is installed per-user, the CLI
|
||||
# is checked, and the desktop creates/measures a box on headless X11 and Wayland. The desktop
|
||||
# check also needs python3, dbus-x11, xvfb, xauth and weston; no screenshots are produced.
|
||||
# Manifest: packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml.
|
||||
# Adapted from PhotoCraft's (storytold/photocraft, Apache-2.0; see ATTRIBUTION.md).
|
||||
set -euo pipefail
|
||||
@@ -67,6 +68,7 @@ if [ "$TEST" = 1 ]; then
|
||||
flatpak info --user "$APP_ID"
|
||||
flatpak run --command=solvecraft-cli "$APP_ID" --version
|
||||
flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/256x256/apps /app/share/doc/solvecraft'
|
||||
python3 "$HERE/flatpak-smoke.py" --backend both
|
||||
fi
|
||||
echo "==> done"
|
||||
ls -lh "$OUT"
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/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
|
||||
solvecraft --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('solvecraft --control',
|
||||
'mkdir -p "$XDG_DATA_HOME/solvecraft"\n'
|
||||
'printf \'{"msaa":4}\' >"$XDG_DATA_HOME/solvecraft/graphics.json"\n'
|
||||
'export WGPU_BACKEND=gl\nsolvecraft --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("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)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Fast failure-path checks for the desktop Flatpak process probe."""
|
||||
import importlib.util
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SPEC = importlib.util.spec_from_file_location("flatpak_smoke", ROOT / "packaging/linux/flatpak-smoke.py")
|
||||
SMOKE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(SMOKE)
|
||||
|
||||
|
||||
def healthy_replies():
|
||||
return [
|
||||
{"id": 1, "ok": True, "result": {"renderer": "gpu", "viewport": [250, 126, 1000, 700]}},
|
||||
{"id": 2, "ok": True, "result": {"feature": 1, "bodies": ["Body1"]}},
|
||||
{"id": 3, "ok": True, "result": {
|
||||
"timeline": [{"error": None}],
|
||||
"bodies": [{"volume_mm3": 20 * 30 * 10, "bbox": {"min": [0, 0, 0], "max": [30, 20, 10]}}],
|
||||
}},
|
||||
]
|
||||
|
||||
|
||||
def output(replies):
|
||||
return "\n".join(json.dumps(reply) for reply in replies)
|
||||
|
||||
|
||||
def chunk(kind, data):
|
||||
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(data, zlib.crc32(kind)))
|
||||
|
||||
|
||||
def rgba_png(width, height, rows):
|
||||
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
|
||||
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header)
|
||||
+ chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b""))
|
||||
|
||||
|
||||
class DesktopProbe(unittest.TestCase):
|
||||
def test_real_presented_frame_is_required_not_just_responsive_control(self):
|
||||
black = rgba_png(2, 2, b"\x00" + bytes([0, 0, 0, 255]) * 2 + b"\x00" + bytes([0, 0, 0, 255]) * 2)
|
||||
with self.assertRaisesRegex(ValueError, "blank presented frame"):
|
||||
SMOKE.frame_statistics(black)
|
||||
# Independently encoded two rows: red/green, blue/white.
|
||||
colour = rgba_png(2, 2, b"\x00" + bytes([255, 0, 0, 255, 0, 255, 0, 255])
|
||||
+ b"\x00" + bytes([0, 0, 255, 255, 255, 255, 255, 255]))
|
||||
self.assertEqual(SMOKE.frame_statistics(colour), {"width": 2, "height": 2, "nonblack": 4, "pixels": 4})
|
||||
# Same pixels encoded with PNG Sub filtering (not a second decoder).
|
||||
sub = rgba_png(2, 2, b"\x01" + bytes([255, 0, 0, 255, 1, 255, 0, 0])
|
||||
+ b"\x01" + bytes([0, 0, 255, 255, 255, 255, 0, 0]))
|
||||
self.assertEqual(SMOKE.frame_statistics(sub), SMOKE.frame_statistics(colour))
|
||||
|
||||
def test_frame_byte_dimensions_and_decompression_are_bounded(self):
|
||||
with self.assertRaisesRegex(ValueError, "dimensions"):
|
||||
SMOKE.frame_statistics(rgba_png(SMOKE.MAX_PIXELS + 1, 1, b""))
|
||||
with self.assertRaisesRegex(ValueError, "decompressed size"):
|
||||
SMOKE.frame_statistics(rgba_png(1, 1, b"\x00" * 10000))
|
||||
truncated = rgba_png(1, 1, b"\x00\xff\x00\x00\xff")[:-1]
|
||||
with self.assertRaises(ValueError):
|
||||
SMOKE.frame_statistics(truncated)
|
||||
|
||||
def test_ui_thread_and_independent_box_measurement_are_required(self):
|
||||
replies = healthy_replies()
|
||||
self.assertEqual(SMOKE.validate_replies(output(replies)), "gpu")
|
||||
replies[2]["result"]["bodies"][0]["volume_mm3"] = 5900
|
||||
with self.assertRaises(ValueError):
|
||||
SMOKE.validate_replies(output(replies))
|
||||
|
||||
def test_cli_version_or_partial_control_output_is_not_desktop_success(self):
|
||||
with self.assertRaises(ValueError):
|
||||
SMOKE.validate_replies("solvecraft-cli 0.3.0\n")
|
||||
with self.assertRaises(ValueError):
|
||||
SMOKE.validate_replies(output(healthy_replies()[:1]))
|
||||
|
||||
def test_startup_without_viewport_or_failed_feature_is_not_success(self):
|
||||
for failure in ["viewport", "feature", "control", "bounds"]:
|
||||
with self.subTest(failure=failure):
|
||||
replies = healthy_replies()
|
||||
if failure == "viewport":
|
||||
replies[0]["result"]["viewport"][2] = 0
|
||||
elif failure == "feature":
|
||||
replies[2]["result"]["timeline"][0]["error"] = "operation failed"
|
||||
elif failure == "control":
|
||||
replies[1] = {"id": 2, "ok": False, "error": "startup failure"}
|
||||
else:
|
||||
replies[2]["result"]["bodies"][0]["bbox"]["max"][0] = 29
|
||||
with self.assertRaises(ValueError):
|
||||
SMOKE.validate_replies(output(replies))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""PE icon guard: Windows PE32/PE32+ resources, and missing/broken groups."""
|
||||
import pathlib
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "windows/check-icon.ps1"
|
||||
|
||||
|
||||
def executable(pe64=True, icon=True, broken=False):
|
||||
data = bytearray(1024)
|
||||
def u16(at, value): struct.pack_into("<H", data, at, value)
|
||||
def u32(at, value): struct.pack_into("<I", data, at, value)
|
||||
u16(0, 0x5A4D)
|
||||
u32(0x3C, 0x80)
|
||||
u32(0x80, 0x4550)
|
||||
u16(0x86, 1)
|
||||
optional = 0x98
|
||||
size = 240 if pe64 else 224
|
||||
u16(0x94, size)
|
||||
u16(optional, 0x20B if pe64 else 0x10B)
|
||||
directories = optional + (112 if pe64 else 96)
|
||||
u32(directories + 16, 0x1000)
|
||||
u32(directories + 20, 512)
|
||||
section = optional + size
|
||||
u32(section + 12, 0x1000)
|
||||
u32(section + 16, 512)
|
||||
u32(section + 20, 512)
|
||||
base = 512
|
||||
def directory(relative, entries):
|
||||
u16(base + relative + 14, len(entries))
|
||||
for i, (name, offset) in enumerate(entries):
|
||||
u32(base + relative + 16 + 8*i, name)
|
||||
u32(base + relative + 20 + 8*i, offset)
|
||||
if icon:
|
||||
directory(0, [(3, 0x80000030), (14, 0x80000070)])
|
||||
directory(0x30, [(1, 0x80000050)])
|
||||
directory(0x50, [(1033, 0xB0)])
|
||||
directory(0x70, [(1, 0x80000090)])
|
||||
directory(0x90, [(1033, 0xC0)])
|
||||
u32(base + 0xB0, 0x1100)
|
||||
u32(base + 0xB4, 40)
|
||||
u32(base + 0xC0, 0x1140)
|
||||
u32(base + 0xC4, 20)
|
||||
u16(base + 0x142, 1)
|
||||
u16(base + 0x144, 1)
|
||||
u32(base + 0x14E, 40)
|
||||
u16(base + 0x152, 2 if broken else 1)
|
||||
return data
|
||||
|
||||
|
||||
class IconGuard(unittest.TestCase):
|
||||
def run_guard(self, data):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = pathlib.Path(folder) / "app.exe"
|
||||
path.write_bytes(data)
|
||||
return subprocess.run([shutil.which("pwsh") or "powershell", "-NoProfile", "-File", str(SCRIPT), "-Path", str(path)], capture_output=True, text=True)
|
||||
|
||||
def test_valid_pe32_and_pe64(self):
|
||||
for pe64 in (False, True):
|
||||
result = self.run_guard(executable(pe64))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("1 images, 1 group", result.stdout)
|
||||
|
||||
def test_generic_executable_is_rejected(self):
|
||||
result = self.run_guard(executable(icon=False))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("RT_ICON", result.stderr)
|
||||
|
||||
def test_group_must_reference_a_real_image(self):
|
||||
result = self.run_guard(executable(broken=True))
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("missing image", result.stderr)
|
||||
|
||||
def test_truncated_executable_is_rejected(self):
|
||||
self.assertNotEqual(self.run_guard(b"MZ").returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
# Validate the actual executable used by MSI, portable and standalone distributions.
|
||||
param([Parameter(Mandatory)][string] $Path)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$bytes = [IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path))
|
||||
function U16([int] $At) { if ($At -lt 0 -or $At + 2 -gt $bytes.Length) { throw 'truncated PE' }; [BitConverter]::ToUInt16($bytes, $At) }
|
||||
function U32([int] $At) { if ($At -lt 0 -or $At + 4 -gt $bytes.Length) { throw 'truncated PE' }; [BitConverter]::ToUInt32($bytes, $At) }
|
||||
if ((U16 0) -ne 0x5A4D) { throw 'not an executable' }
|
||||
$pe = U32 0x3C
|
||||
if ((U32 $pe) -ne 0x4550) { throw 'missing PE signature' }
|
||||
$optional = $pe + 24
|
||||
$magic = U16 $optional
|
||||
$directories = switch ($magic) { 0x10B { $optional + 96 }; 0x20B { $optional + 112 }; default { throw 'unknown PE format' } }
|
||||
$resourceRva = U32 ($directories + 16)
|
||||
$resourceSize = U32 ($directories + 20)
|
||||
if (!$resourceRva -or !$resourceSize) { throw 'executable has no resources' }
|
||||
$sections = $optional + (U16 ($pe + 20))
|
||||
function RvaOffset([uint32] $Rva) {
|
||||
for ($i = 0; $i -lt (U16 ($pe + 6)); $i++) {
|
||||
$section = $sections + 40 * $i
|
||||
$start = U32 ($section + 12)
|
||||
$rawSize = U32 ($section + 16)
|
||||
if ($Rva -ge $start -and [uint64]$Rva -lt [uint64]$start + $rawSize) { return (U32 ($section + 20)) + ($Rva - $start) }
|
||||
}
|
||||
throw 'resource RVA outside file sections'
|
||||
}
|
||||
$base = RvaOffset $resourceRva
|
||||
function Entries([uint32] $Relative) {
|
||||
if ([uint64]$Relative + 16 -gt $resourceSize) { throw 'resource directory outside bounds' }
|
||||
$at = $base + $Relative
|
||||
$count = (U16 ($at + 12)) + (U16 ($at + 14))
|
||||
if ([uint64]$Relative + 16 + 8 * $count -gt $resourceSize) { throw 'resource entries outside bounds' }
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$entry = $at + 16 + 8 * $i
|
||||
@{ Name = (U32 $entry); Offset = (U32 ($entry + 4)) }
|
||||
}
|
||||
}
|
||||
function Payloads($Entry) {
|
||||
if (!( $Entry.Offset -band 0x80000000L )) { throw 'expected resource directory' }
|
||||
foreach ($name in @(Entries ($Entry.Offset -band 0x7FFFFFFFL))) {
|
||||
if (!( $name.Offset -band 0x80000000L )) { throw 'expected resource language directory' }
|
||||
foreach ($language in @(Entries ($name.Offset -band 0x7FFFFFFFL))) {
|
||||
if ($language.Offset -band 0x80000000L) { throw 'unexpected nested resource directory' }
|
||||
if ([uint64]$language.Offset + 16 -gt $resourceSize) { throw 'resource data outside bounds' }
|
||||
$data = $base + $language.Offset
|
||||
$offset = RvaOffset (U32 $data)
|
||||
$length = U32 ($data + 4)
|
||||
if (!$length -or [uint64]$offset + $length -gt $bytes.Length) { throw 'empty or truncated icon resource' }
|
||||
@{ Id = $name.Name; At = $offset; Length = $length }
|
||||
}
|
||||
}
|
||||
}
|
||||
$root = @(Entries 0)
|
||||
$iconType = @($root | Where-Object Name -eq 3)
|
||||
$groupType = @($root | Where-Object Name -eq 14)
|
||||
if ($iconType.Count -ne 1 -or $groupType.Count -ne 1) { throw 'missing RT_ICON or RT_GROUP_ICON' }
|
||||
$icons = @(Payloads $iconType[0])
|
||||
$groups = @(Payloads $groupType[0])
|
||||
foreach ($group in $groups) {
|
||||
if ((U16 $group.At) -ne 0 -or (U16 ($group.At + 2)) -ne 1) { throw 'invalid icon group' }
|
||||
$count = U16 ($group.At + 4)
|
||||
if (!$count -or 6 + 14 * $count -gt $group.Length) { throw 'empty or truncated icon group' }
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$entry = $group.At + 6 + 14 * $i
|
||||
$id = U16 ($entry + 12)
|
||||
$length = U32 ($entry + 8)
|
||||
if (!@($icons | Where-Object { $_.Id -eq $id -and $_.Length -eq $length }).Count) { throw 'icon group refers to missing image' }
|
||||
}
|
||||
}
|
||||
Write-Output "ok embedded app icon: $($icons.Count) images, $($groups.Count) group(s): $Path"
|
||||
@@ -79,6 +79,7 @@ foreach ($check in @(@('solvecraft.exe', 2), @('solvecraft-cli.exe', 3))) {
|
||||
Write-Output "ok $($check[0]): $Arch, PE subsystem $($h.Subsystem)"
|
||||
}
|
||||
$Stage = Join-Path $TargetDir "windows-package\$Arch"
|
||||
& (Join-Path $PSScriptRoot 'check-icon.ps1') -Path (Join-Path $Bin 'solvecraft.exe')
|
||||
Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $Stage | Out-Null
|
||||
Copy-Item (Join-Path $Bin 'solvecraft.exe'), (Join-Path $Bin 'solvecraft-cli.exe') $Stage
|
||||
|
||||
Reference in new issue
Block a user