Fix desktop launch, app icons and keyboard parameter editing

This commit is contained in:
bherbruck committed 2026-10-11 19:09:47 -04:00
1 parent 8fa52c8e5e
commit 6dd202d277
45 files changed
+5993 -272

No files matched your search

+4 -2
View File
@@ -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"
+4 -2
View File
@@ -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"
+260
View File
@@ -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)
+93
View File
@@ -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()
+82
View File
@@ -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()
+69
View File
@@ -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"
+1
View File
@@ -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