diff --git a/.github/release-notes.md b/.github/release-notes.md index 458154d6..964081fe 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -32,7 +32,8 @@ old-name Pages addresses are not permanent aliases. - **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications. The CLI is in `solidcraft-cli-{version}-macos-universal.zip`. - **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the - `.deb` or `.rpm`, the `.flatpak` (`flatpak install --user `), or the `.tar.gz`. + `.deb` or `.rpm`, the `.flatpak` (`flatpak install --user `), the `.snap` + (`sudo snap install --dangerous `, or `sudo snap install solidcraft` from the Snap Store), or the `.tar.gz`. - **FreeBSD:** `tar -xzf solidcraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`. - **Web:** `solidcraft-web-{version}.zip` is a static site to host yourself. - `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`. diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 4461c06f..66e69f84 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -57,3 +57,5 @@ jobs: sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml" appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml" python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solidcraft'" + python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'" + python3 -m py_compile packaging/linux/snap-smoke.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7577e8da..66a4a9c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ # At midnight Eastern, changed main commits build and publish a nightly pre-release. # A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet. # -# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux +# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux # FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd # macOS universal .dmg, and the CLI as a .zip packaging/macos # Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows @@ -199,6 +199,59 @@ jobs: + snap: + name: Snap ${{ matrix.arch }} + needs: [version, linux] + if: needs.version.outputs.build == 'true' + # Repackages the Linux job's tarball, like the Flatpak. On 24.04 because the core24 base is + # Ubuntu 24.04, which lets snapcraft build on the runner itself (--destructive-mode). + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - { arch: x86_64, runner: ubuntu-24.04 } + - { arch: aarch64, runner: ubuntu-24.04-arm } + env: + SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }} + SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-linux-${{ matrix.arch }} + path: dist/linux + - name: Install snapcraft and the smoke-test tools + run: | + sudo snap install snapcraft --classic + sudo apt-get update + sudo apt-get install -y --no-install-recommends xvfb xauth weston dbus-x11 + - name: Build the snap + run: | + sudo --preserve-env=SOLIDCRAFT_VERSION,SOLIDCRAFT_BUILD_DATE env "PATH=$PATH" \ + packaging/linux/snap-pack.sh --destructive-mode "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" + sudo chown -R "$(id -u):$(id -g)" dist target + - name: Install it and start the app on headless X11 and Wayland + run: | + snap_file="dist/release/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.snap" + sudo snap install --dangerous "$snap_file" + snap info --verbose "$snap_file" | head -n 12 + snap connections solidcraft + solidcraft.cli --version + # Snaps expect the login session's runtime directory, which runners don't create. + sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "/run/user/$(id -u)" + python3 packaging/linux/snap-smoke.py --out snap-smoke + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: snap-smoke-${{ matrix.arch }} + path: snap-smoke/*.png + if-no-files-found: error + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-snap-${{ matrix.arch }} + path: dist/release/*.snap + if-no-files-found: error + freebsd: name: FreeBSD x86_64 needs: version @@ -337,7 +390,7 @@ jobs: release: name: GitHub Release or dry-run artifacts - needs: [version, linux, flatpak, freebsd, macos, windows, web] + needs: [version, linux, flatpak, snap, freebsd, macos, windows, web] if: needs.version.outputs.build == 'true' runs-on: ubuntu-latest permissions: @@ -425,6 +478,35 @@ jobs: echo sed 's/^/ /' SHA256SUMS.txt } >>"$GITHUB_STEP_SUMMARY" + # Snap Store: nightlies go to the edge channel and release drafts to candidate, only after + # every package built and the GitHub release was staged. Publishing the GitHub release + # promotes candidate to stable (snap-store.yml). Dry runs never upload. Skipped until the + # owner adds the SNAPCRAFT_STORE_CREDENTIALS secret (docs/releasing.md). + - id: snapstore + name: Check for Snap Store credentials + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + run: | + if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then + echo "configured=true" >>"$GITHUB_OUTPUT" + else + echo "No SNAPCRAFT_STORE_CREDENTIALS secret: skipping the Snap Store upload." + fi + - name: Upload the snaps to the Snap Store + if: >- + steps.snapstore.outputs.configured == 'true' && (github.event_name == 'schedule' || + (github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft))) + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + working-directory: artifacts + run: | + sudo snap install snapcraft --classic + channel=candidate + if [ "$GITHUB_EVENT_NAME" = schedule ]; then channel=edge; fi + for arch in x86_64 aarch64; do + snapcraft upload --release="$channel" "solidcraft-$VERSION-linux-$arch.snap" + done + echo "Uploaded the snaps to the Snap Store's $channel channel." >>"$GITHUB_STEP_SUMMARY" - name: Keep the files (dry run) if: github.event_name == 'workflow_dispatch' && !inputs.draft run: cp notes.md artifacts/release-notes.md diff --git a/.github/workflows/snap-store.yml b/.github/workflows/snap-store.yml new file mode 100644 index 00000000..1c421174 --- /dev/null +++ b/.github/workflows/snap-store.yml @@ -0,0 +1,40 @@ +# Publishing a GitHub release (not a prerelease) promotes the snaps that release.yml uploaded to +# the Snap Store's candidate channel to stable, so `sudo snap install solidcraft` gets it. +# Skipped until the owner adds the SNAPCRAFT_STORE_CREDENTIALS secret; see docs/releasing.md. +name: Snap Store + +on: + release: + types: [released] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: snap-store + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - id: creds + name: Check for Snap Store credentials + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + run: | + if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then + echo "configured=true" >>"$GITHUB_OUTPUT" + else + echo "No SNAPCRAFT_STORE_CREDENTIALS secret: nothing to promote." + fi + - name: Promote candidate to stable + if: steps.creds.outputs.configured == 'true' + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} + run: | + sudo snap install snapcraft --classic + snapcraft status solidcraft + snapcraft promote solidcraft --from-channel=latest/candidate --to-channel=latest/stable --yes + snapcraft status solidcraft diff --git a/README.md b/README.md index 7b338478..5fe94836 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ model through the built-in MCP server. [![Download for Windows][badge-windows]][windows-msi] [![Download for macOS][badge-macos]][macos-dmg] [![Download for Linux][badge-linux]][linux-appimage] +[![Get it from the Snap Store][badge-snap]][snap-store] [![Try it in your browser][badge-web]][web-app] - **Windows:** download and run the [installer][windows-msi]. @@ -25,6 +26,7 @@ model through the built-in MCP server. - **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it. Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora, openSUSE), or use [Flatpak](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak). + With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]). For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm]. - **Web:** [try it in your browser][web-app], no install. @@ -38,6 +40,7 @@ Install details, upgrading from earlier builds and building from source are in [badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B [badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white [badge-linux]: https://img.shields.io/badge/Linux-AppImage-E95420?style=for-the-badge&logo=linux&logoColor=white +[badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white [badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D [windows-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64.msi [windows-arm64-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi @@ -50,6 +53,8 @@ Install details, upgrading from earlier builds and building from source are in [linux-arm64-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage [linux-arm64-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb [linux-arm64-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm +[linux-snap]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap +[snap-store]: https://snapcraft.io/solidcraft [web-app]: https://bherbruck.github.io/solidcraft/app/ **Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide, diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index c114f6c8..0c1cc7c5 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -51,6 +51,22 @@ missing runtime. Launch with `flatpak run ai.storyteller.solvecraft`, rather tha a host `solidcraft` binary. Files chosen through Open/Save use the desktop file-chooser portal; command-line paths outside Documents may need a specific additional filesystem permission. +### Linux Snap + +On Ubuntu and other distributions with snapd: + +```sh +sudo snap install solidcraft +solidcraft # or open SolidCraft from the app menu +solidcraft.cli --version # the CLI, for scripts and `solidcraft.cli mcp` +``` + +The snap can open designs in your home folder (not hidden folders) and, through the file +chooser, anywhere else. For USB drives and other mounted disks, run +`sudo snap connect solidcraft:removable-media` once. A release's `.snap` file installs without +the store with `sudo snap install --dangerous ./solidcraft-VERSION-linux-ARCH.snap`; it then +doesn't update automatically. + ## Web The same app runs in the browser: [open SolidCraft on the web](../app/) (`?sample` opens the sample diff --git a/docs/releasing.md b/docs/releasing.md index 9d2760fc..601c36d1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -15,7 +15,7 @@ Every release asset, binary, installer product name and package uses the SolidCr copies, the deprecated old-name CLI alias); keep them until that document says they can go. Do not rewrite published assets, checksums, tags or historical release notes of earlier versions. -The main installers (`.msi`, portable `.zip`, `.dmg`, `.AppImage`, `.deb`, `.rpm`, `.flatpak`) are +The main installers (`.msi`, portable `.zip`, `.dmg`, `.AppImage`, `.deb`, `.rpm`, `.flatpak`, `.snap`) are also attached without the version in their names, for example `solidcraft-windows-x64.msi` and `solidcraft-linux-x86_64.AppImage`, and listed in `SHA256SUMS.txt`. The README's Download section links to `https://github.com/bherbruck/solidcraft/releases/latest/download/`, which follows @@ -145,6 +145,55 @@ docs updates include the latest stable Flatpak release too. Verify the resulting not only the dispatch job, so a release cannot erase the docs or app and docs updates cannot erase the package repository. +## Snap package and Snap Store + +Every release and nightly attaches `solidcraft--linux-.snap` for x86_64 and +aarch64 (`packaging/linux/snap-pack.sh`, manifest `packaging/linux/snap/snapcraft.yaml`). Like the +Flatpak, it repackages the Linux job's tarball. The `snap` job installs it with `--dangerous`, +starts the confined app on headless X11 and Wayland (`packaging/linux/snap-smoke.py`), models a +box over the control channel and keeps the captured frames as the `snap-smoke-` artifact. + +The Snap Store steps run only when the `SNAPCRAFT_STORE_CREDENTIALS` repository secret exists; +without it they are skipped and the `.snap` files are still attached to the release: + +- nightlies are uploaded to `latest/edge`; +- release drafts (pushes to `release`) are uploaded to `latest/candidate`, after every package + built and the draft was staged; +- publishing a non-prerelease GitHub release runs `snap-store.yml`, which promotes + `latest/candidate` to `latest/stable`. As with the Flatpak repository, a publication made with + `GITHUB_TOKEN` doesn't trigger it: then run `gh workflow run snap-store.yml`. + +To enable the Snap Store once (owner): + +1. Sign in at with an Ubuntu One account and register the name at + (or `snapcraft login` then `snapcraft register solidcraft`). +2. On a machine with snapcraft (`sudo snap install snapcraft --classic`), export upload-only + credentials for this snap: + + ```sh + snapcraft export-login --snaps=solidcraft \ + --channels=edge,candidate,stable \ + --acls=package_access,package_push,package_update,package_release \ + --expires=2027-10-01 solidcraft-store-credentials.txt + ``` + +3. Store them as the repository secret and delete the file: + + ```sh + gh secret set SNAPCRAFT_STORE_CREDENTIALS -R bherbruck/solidcraft < solidcraft-store-credentials.txt + rm solidcraft-store-credentials.txt + ``` + +4. Optional: fill in the store listing at (the icon, + summary and description come from the snap; add screenshots and the category "Art and design"). +5. The next nightly fills `edge` and the next release fills `candidate`, then `stable` on + publication. To fill `stable` from an already published release right away, download its + `.snap` files and run `snapcraft upload --release=stable ` for each. + +Renew the credentials before `--expires` by repeating steps 2 and 3. The snap uses strict +confinement and only plugs that connect automatically, apart from `removable-media`, which users +connect with `sudo snap connect solidcraft:removable-media` (getting-started.md). + ## Repository setup The `release` branch is advanced only for intentional release batches. Restrict its writers to diff --git a/packaging/linux/snap-pack.sh b/packaging/linux/snap-pack.sh new file mode 100755 index 00000000..8ae24a85 --- /dev/null +++ b/packaging/linux/snap-pack.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Repackage a SolidCraft Linux tarball as a snap: +# +# $DIST/solidcraft--linux-.snap +# +# Usage: packaging/linux/snap-pack.sh [--destructive-mode] [TARBALL] +# +# TARBALL defaults to $DIST/solidcraft--linux-.tar.gz from package.sh. The snap is +# built for the host architecture (x86_64 or aarch64), which must match the tarball's. Needs +# snapcraft (`sudo snap install snapcraft --classic`) and, without --destructive-mode, LXD. +# --destructive-mode builds on the host itself, which must be Ubuntu 24.04 (the core24 base), +# as in CI. Install and smoke-test the result with: +# sudo snap install --dangerous dist/release/solidcraft--linux-.snap +# python3 packaging/linux/snap-smoke.py +# Manifest: packaging/linux/snap/snapcraft.yaml. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" +HERE="$ROOT/packaging/linux" + +MODE=(--use-lxd) +TARBALL="" +while [ $# -gt 0 ]; do + case "$1" in + --destructive-mode) MODE=(--destructive-mode); shift ;; + -h | --help) sed -n '2,15p' "$0"; exit 0 ;; + -*) echo "unknown argument: $1" >&2; exit 2 ;; + *) TARBALL="$1"; shift ;; + esac +done + +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) SNAP_ARCH=amd64 ;; + aarch64 | arm64) ARCH=aarch64; SNAP_ARCH=arm64 ;; + *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; +esac +BASENAME="solidcraft-$VERSION-linux-$ARCH" +TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" +[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } +command -v snapcraft >/dev/null || { echo "error: snapcraft not found (sudo snap install snapcraft --classic)" >&2; exit 1; } +# Snap versions: at most 32 characters of [A-Za-z0-9.+~-]. +if ! printf '%s' "$VERSION" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9.+~-]{0,31}$'; then + echo "error: version $VERSION is not a valid snap version" >&2 + exit 1 +fi + +echo "==> SolidCraft $VERSION snap for $SNAP_ARCH from $(basename "$TARBALL")" + +WORK="$CARGO_TARGET_DIR/snap-pack" +rm -rf "$WORK" +mkdir -p "$WORK/snap" "$WORK/stage" +tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 +[ -x "$WORK/stage/bin/solidcraft" ] || { echo "error: $TARBALL has no bin/solidcraft" >&2; exit 1; } +cp "$HERE/snap/snapcraft.yaml" "$WORK/snap/snapcraft.yaml" +printf '%s\n' "$VERSION" >"$WORK/version.txt" + +(cd "$WORK" && snapcraft pack "${MODE[@]}" --build-for="$SNAP_ARCH" --output="$WORK/out.snap") + +OUT="$DIST/$BASENAME.snap" +mv "$WORK/out.snap" "$OUT" +echo "wrote $OUT" +ls -lh "$OUT" diff --git a/packaging/linux/snap-smoke.py b/packaging/linux/snap-smoke.py new file mode 100755 index 00000000..c910296b --- /dev/null +++ b/packaging/linux/snap-smoke.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Start the installed SolidCraft snap on headless X11 and Wayland and model a box in it. + +Runs the confined app (`snap install --dangerous` first) with its normal plugs, drives it over +the control channel from the host (snaps share the host's network namespace), checks a +30x20x10 box measures 6000 mm³ and that the GPU drew a non-blank frame, and copies each frame +to --out (default: the current directory) as snap-.png. Needs xvfb-run and weston. +Reuses the frame checks of flatpak-smoke.py. +""" +import argparse +import importlib.util +import json +import os +from pathlib import Path +import shutil +import signal +import socket +import subprocess +import sys +import tempfile +import time + +HERE = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location("flatpak_smoke", HERE / "flatpak-smoke.py") +flatpak_smoke = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(flatpak_smoke) + +PORTS = {"x11": 18839, "wayland": 18840} +REQUESTS = [ + {"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}}, +] + + +def drive(app, port, frame): + """Connect to the app's control port, model the box and capture a frame.""" + deadline = time.monotonic() + 60 + while True: + if app.poll() is not None: + raise RuntimeError(f"the app exited with {app.returncode} before listening") + try: + conn = socket.create_connection(("127.0.0.1", port), timeout=60) + break + except OSError: + if time.monotonic() >= deadline: + raise RuntimeError("the control port never opened") from None + time.sleep(.2) + with conn, conn.makefile("rw") as io: + replies = [] + for request in REQUESTS + [{"id": 4, "method": "ui.screenshot", "params": {"path": str(frame)}}, + {"id": 5, "method": "app.quit", "params": {"force": True}}]: + io.write(json.dumps(request) + "\n") + io.flush() + replies.append(io.readline()) + renderer = flatpak_smoke.validate_replies("".join(replies[:3])) + capture = json.loads(replies[3]) + if any(json.loads(reply).get("ok") is not True for reply in replies[4:]): + raise ValueError(f"app.quit failed: {replies[4:]}") + if capture.get("ok") is not True or renderer != "gpu": + raise ValueError(f"GPU frame capture failed: renderer={renderer}, {capture}") + return renderer, flatpak_smoke.frame_statistics(frame.read_bytes()) + + +def run_backend(backend, out): + runtime = Path(f"/run/user/{os.getuid()}") + if not runtime.is_dir(): + raise RuntimeError(f"{runtime} is missing: snaps expect the login session's runtime directory") + env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime)) + for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET", "DISPLAY", "SOLIDCRAFT_WAYLAND", "SOLVECRAFT_WAYLAND"): + env.pop(name, None) + # The snap can write to the (non-hidden) home folder, not to the host's /tmp. + work = Path(tempfile.mkdtemp(prefix="solidcraft-snap-smoke-", dir=Path.home())) + compositor = None + log = (work / "app.log").open("wb") + try: + # No private dbus-run-session bus: snapd moves the app into its own systemd scope through + # the session's systemd, which a private bus doesn't have. + prefix = [] + if backend == "x11": + prefix += ["xvfb-run", "-a", "-s", "-screen 0 1600x1000x24 -nolisten tcp"] + else: + # The wayland plug only allows sockets named wayland-N in the runtime directory. + env["WAYLAND_DISPLAY"] = "wayland-77" + compositor = subprocess.Popen(["weston", "--backend=headless", "--renderer=pixman", "--no-config", + "--socket=wayland-77", "--width=1600", "--height=1000"], + env=env, stdout=log, stderr=subprocess.STDOUT) + deadline = time.monotonic() + 20 + while not (runtime / "wayland-77").exists(): + if compositor.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError("headless Weston failed") + time.sleep(.1) + with socket.socket() as probe: + if probe.connect_ex(("127.0.0.1", PORTS[backend])) == 0: + raise RuntimeError(f"port {PORTS[backend]} is already in use (another SolidCraft?)") + # Its own process group, so stopping it also stops the app under xvfb-run. + app = subprocess.Popen(prefix + ["/snap/bin/solidcraft", "--control", str(PORTS[backend])], + env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + try: + renderer, stats = drive(app, PORTS[backend], work / "frame.png") + # app.quit exits cleanly, so the next run doesn't report a crash. + app.wait(timeout=30) + finally: + if app.poll() is None: + os.killpg(app.pid, signal.SIGTERM) + try: + app.wait(timeout=15) + except subprocess.TimeoutExpired: + os.killpg(app.pid, signal.SIGKILL) + app.wait() + shutil.copy(work / "frame.png", out / f"snap-{backend}.png") + print(f"snap {backend}: PASS renderer={renderer}, box=6000 mm³, frame={stats}") + except BaseException: + log.flush() + sys.stderr.write((work / "app.log").read_text(errors="replace")[-8000:]) + raise + finally: + log.close() + if compositor: + compositor.terminate() + compositor.wait(timeout=10) + shutil.rmtree(work, ignore_errors=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=["x11", "wayland", "both"], default="both") + parser.add_argument("--out", type=Path, default=Path.cwd()) + args = parser.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + for backend in (["x11", "wayland"] if args.backend == "both" else [args.backend]): + run_backend(backend, args.out.resolve()) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError, TypeError, RuntimeError, subprocess.SubprocessError) as error: + print(f"snap desktop smoke failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/packaging/linux/snap/snapcraft.yaml b/packaging/linux/snap/snapcraft.yaml new file mode 100644 index 00000000..e9740def --- /dev/null +++ b/packaging/linux/snap/snapcraft.yaml @@ -0,0 +1,81 @@ +# Snap package (solidcraft--linux-.snap on every GitHub release, and the Snap +# Store when its credentials are configured). Build it with packaging/linux/snap-pack.sh. +# +# Like the Flatpak, it repackages the release's own Linux tarball (built on Ubuntu 22.04 by +# package.sh) instead of compiling Rust: the same binaries as the AppImage/deb/rpm. snap-pack.sh +# unpacks the tarball into stage/ next to snap/ and writes the package version to version.txt. +# The binaries need glibc >= 2.35 (core24 has 2.39) and load X11, Wayland, xkbcommon and Vulkan +# at runtime: the gnome extension supplies the desktop libraries and the GPU userspace (mesa, +# through the gpu-2404 content interface) for the wgpu viewport. +name: solidcraft +title: SolidCraft +summary: Open-source parametric 3D CAD +description: | + SolidCraft is an open-source parametric 3D CAD application written in Rust. Sketch on a + plane, constrain and dimension the sketch, turn profiles into solids on a feature timeline, + and change a parameter to watch the timeline rebuild. + + - Sketches with a constraint solver and parameter-driven dimensions + - Extrude, revolve, sweep, loft, fillet, chamfer, shell, holes and patterns + - Components and joints, sheet metal, and a plastic-enclosure subset + - STEP and IGES import and export; 3MF, STL and OBJ + - Every action is a command, scriptable from a CLI, a JSON control channel and an MCP server + + The command-line tool is `solidcraft.cli` (for example `solidcraft.cli mcp`). +adopt-info: solidcraft +license: MIT OR Apache-2.0 +contact: https://github.com/bherbruck/solidcraft/issues +website: https://bherbruck.github.io/solidcraft/ +source-code: https://github.com/bherbruck/solidcraft +issues: https://github.com/bherbruck/solidcraft/issues +icon: stage/share/icons/hicolor/512x512/apps/ai.storyteller.solvecraft.png +base: core24 +grade: stable +confinement: strict +# lzo starts faster than the default xz, at a somewhat larger download. +compression: lzo +platforms: + amd64: + arm64: + +apps: + solidcraft: + command: usr/bin/solidcraft + desktop: usr/share/applications/ai.storyteller.solvecraft.desktop + # Ties the snap to the AppStream id (ai.storyteller.solvecraft, a compatibility shim kept by + # every package; see docs/compatibility.md) so software centres treat it as the same app. + common-id: ai.storyteller.solvecraft + extensions: [gnome] + plugs: + - desktop + - desktop-legacy + - wayland + - x11 + - opengl + # Designs anywhere in the home folder (not hidden files) and on USB drives; Open/Save + # also work through the desktop file-chooser portal. + - home + - removable-media + # Opening links (Create Issue, documentation); the optional --control server listens + # on 127.0.0.1 for agents and tests. + - network + - network-bind + cli: + command: usr/bin/solidcraft-cli + plugs: [home, removable-media, network, network-bind] + +parts: + solidcraft: + plugin: dump + source: stage + organize: + bin/: usr/bin/ + share/: usr/share/ + override-pull: | + craftctl default + craftctl set version="$(cat "$CRAFT_PROJECT_DIR/version.txt")" + override-prime: | + craftctl default + # Point the menu entry at the packaged icon (the hicolor name isn't on the host's path). + sed -i 's|^Icon=.*|Icon=${SNAP}/usr/share/icons/hicolor/256x256/apps/ai.storyteller.solvecraft.png|' \ + usr/share/applications/ai.storyteller.solvecraft.desktop