From 8f802fceaa611164e06065206bb9d2a2cc768ab8 Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Tue, 29 Sep 2026 16:25:21 -0600 Subject: [PATCH 1/7] Add PAXX-managed multiACE package contract # Conflicts: # multiace/README.md # multiace/klipper/extras/ace.py # multiace/web/backend/main.py --- .github/workflows/paxx-package.yml | 88 ++++++++++ README.md | 27 ++- .../extended/multiace/ace_mode_switch.sh | 6 + multiace/i18n/de.json | 1 + multiace/i18n/en.json | 1 + multiace/i18n/zh.json | 1 + multiace/install_multiace.sh | 7 + multiace/paxx/README.md | 57 +++++++ multiace/paxx/build_package.py | 160 ++++++++++++++++++ multiace/paxx/manifest.json | 109 ++++++++++++ multiace/paxx/tests/test_package.py | 69 ++++++++ multiace/tools/multiace_update.sh | 9 + multiace/uninstall_multiace.sh | 6 + multiace/web/deploy/S98multiace-web | 8 +- multiace/web/frontend/app.js | 10 ++ multiace/web/frontend/index.html | 5 +- 16 files changed, 559 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/paxx-package.yml create mode 100644 multiace/paxx/README.md create mode 100644 multiace/paxx/build_package.py create mode 100644 multiace/paxx/manifest.json create mode 100644 multiace/paxx/tests/test_package.py diff --git a/.github/workflows/paxx-package.yml b/.github/workflows/paxx-package.yml new file mode 100644 index 00000000..4b1b1928 --- /dev/null +++ b/.github/workflows/paxx-package.yml @@ -0,0 +1,88 @@ +name: Build PAXX-managed multiACE package + +on: + workflow_dispatch: + push: + tags: + - "multiace-v*" + +permissions: + contents: write + +jobs: + package: + runs-on: ubuntu-latest + defaults: + run: + working-directory: multiace + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Validate modified sources + run: | + python -m py_compile \ + klipper/extras/ace.py \ + web/backend/main.py \ + paxx/build_package.py \ + paxx/tests/test_package.py + node --check web/frontend/app.js + bash -n \ + install_multiace.sh \ + uninstall_multiace.sh \ + tools/multiace_update.sh \ + config/extended/multiace/ace_mode_switch.sh \ + web/deploy/S98multiace-web + + - name: Run package tests + run: python -m unittest discover -s paxx/tests -v + + - name: Read package version + id: version + shell: bash + run: | + version="$(tr -d '\r\n' < VERSION)" + test -n "$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Verify release tag version + if: startsWith(github.ref, 'refs/tags/') + shell: bash + env: + PACKAGE_VERSION: ${{ steps.version.outputs.version }} + run: | + test "${GITHUB_REF_NAME#multiace-v}" = "$PACKAGE_VERSION" + + - name: Build package and checksum + run: python paxx/build_package.py + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: multiace-paxx-${{ steps.version.outputs.version }} + path: | + multiace/dist/multiace-paxx-${{ steps.version.outputs.version }}.tar.gz + multiace/dist/multiace-paxx-${{ steps.version.outputs.version }}.tar.gz.sha256 + if-no-files-found: error + + - name: Publish release assets + if: startsWith(github.ref, 'refs/tags/') + working-directory: multiace + env: + GH_TOKEN: ${{ github.token }} + PACKAGE_VERSION: ${{ steps.version.outputs.version }} + run: | + archive="dist/multiace-paxx-${PACKAGE_VERSION}.tar.gz" + checksum="${archive}.sha256" + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "$archive" "$checksum" --clobber + else + gh release create "$GITHUB_REF_NAME" "$archive" "$checksum" \ + --title "multiACE PAXX package ${PACKAGE_VERSION}" \ + --generate-notes + fi diff --git a/README.md b/README.md index d6c7ca25..983d55e8 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,31 @@ ACE units do not read or expose the spools uid so it uses the sku field. (Spoolm - **Online Updates ** - **Auto-Load** - Load all filaments autmatically, Parallel preload in bg mode - **RFID Handling** - Automatic RFID detection and display across ACE switches -- **PAXX Firmware Compatible / Installer** - Works with PAXX firmware which provides display mirroring, allowing full load/unload control from your computer / Integrated PAXX Firmware -- **Clean Install/Uninstall** - One-command scripts with automatic backup and restore +- **PAXX Firmware Compatible / Installer** - Works with PAXX firmware which provides display mirroring, allowing full load/unload control from your computer / Integrated PAXX Firmware +- **Clean Install/Uninstall** - One-command scripts with automatic backup and restore + +### PAXX-managed package + +The repository also provides a separate package contract for PAXX firmware in +`multiace/paxx/`. PAXX selects a pinned multiACE release, verifies its SHA-256 +checksum, installs it under a versioned application directory, and activates +the selected files at startup without overwriting the stock Klipper files on +disk. PAXX owns activation, compatibility checks, persistent configuration, +updates, rollback, and the firmware-config UI. + +The managed package does not include the standalone SSH installer, uninstaller, +online updater, init scripts, or file-copy mode switch helper. When PAXX sets +`MULTIACE_MANAGED=1`, multiACE refuses self-updates and reports that PAXX owns +the update path. Ordinary standalone multiACE installation remains available. + +Build and test the package from the repository root with: + +```bash +python3 multiace/paxx/build_package.py +``` + +The `paxx-package.yml` workflow runs the package tests and publishes the +versioned archive and checksum for tags named `multiace-v`. ## ACE Pro 2 Support diff --git a/multiace/config/extended/multiace/ace_mode_switch.sh b/multiace/config/extended/multiace/ace_mode_switch.sh index 660858ec..149d4253 100644 --- a/multiace/config/extended/multiace/ace_mode_switch.sh +++ b/multiace/config/extended/multiace/ace_mode_switch.sh @@ -1,5 +1,11 @@ #!/bin/bash set -e +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ]; then + echo "multiACE mode switching is managed by the host firmware; refusing to copy Klipper files" >&2 + exit 2 +fi + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" HOME_DIR="/home/lava" EXTRAS_DIR="${HOME_DIR}/klipper/klippy/extras" diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index 25cd2d86..f82e49aa 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -281,6 +281,7 @@ "update_failed": "Update fehlgeschlagen", "update_intro": "Holt die neueste multiACE-Release und installiert sie.", "update_latest": "Verfügbar", + "update_managed": "Updates werden von der Host-Firmware verwaltet. Verwende die PAXX Firmware Config, um eine getestete multiACE-Version zu installieren.", "update_not_checked": "noch nicht geprüft", "update_title": "multiACE Updates" }, diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index dfc313b9..00de75f3 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -281,6 +281,7 @@ "update_failed": "Update failed", "update_intro": "Pulls the latest multiACE release and installs it.", "update_latest": "Available", + "update_managed": "Updates are managed by the host firmware. Use PAXX Firmware Config to install a tested multiACE release.", "update_not_checked": "not checked yet", "update_title": "multiACE updates" }, diff --git a/multiace/i18n/zh.json b/multiace/i18n/zh.json index 9f21726b..775ce396 100644 --- a/multiace/i18n/zh.json +++ b/multiace/i18n/zh.json @@ -107,6 +107,7 @@ "update_intro": "拉取最新的 multiACE 版本并安装。", "update_current": "已安装", "update_latest": "可用版本", + "update_managed": "更新由主机固件管理。请使用 PAXX Firmware Config 安装经过测试的 multiACE 版本。", "update_not_checked": "尚未检查", "update_check_btn": "检查更新", "update_checking": "检查中...", diff --git a/multiace/install_multiace.sh b/multiace/install_multiace.sh index 9a25cb7e..77ac979a 100755 --- a/multiace/install_multiace.sh +++ b/multiace/install_multiace.sh @@ -1,5 +1,12 @@ #!/bin/bash set -e + +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ]; then + echo "multiACE is managed by the host firmware; use the host integration instead of install_multiace.sh" >&2 + exit 2 +fi + INSTALL_WEB=0 KEEP_CONFIG=0 for arg in "$@"; do diff --git a/multiace/paxx/README.md b/multiace/paxx/README.md new file mode 100644 index 00000000..ab9c5592 --- /dev/null +++ b/multiace/paxx/README.md @@ -0,0 +1,57 @@ +# PAXX-managed multiACE package + +This directory describes the package boundary used when PAXX manages +multiACE. It is intentionally separate from the existing SSH installer. + +## Ownership + +- multiACE owns the ACE protocols, Klipper integration modules, web interface, + and versioned release archives. +- PAXX owns activation, compatibility checks, persistent configuration, + upgrades, rollback, and the firmware-config user interface. + +PAXX must select a specific release and verify its archive checksum. It must +not install an arbitrary moving `latest` build. + +## Runtime contract + +PAXX installs the package under `runtime.versioned_app_root` and exposes the +selected version through `runtime.active_app_root` (`latest`). The PAXX hook then +bind-mounts the listed Klipper modules at startup. The stock files are never +overwritten on disk. + +The package does not contain or run the standalone SSH installer, uninstaller, +updater, init scripts, or mode-switch file-copy helper. Those operations are +owned by the host firmware integration. + +The following environment variables are supplied to Klipper and the web +service by PAXX: + +- `MULTIACE_MANAGED=1` +- `MULTIACE_APP_DIR` +- `MULTIACE_WEB_DIR` +- `MULTIACE_CONFIG_DIR` +- `MULTIACE_DISABLE_UPDATES=1` + +When managed mode is active, multiACE must refuse its own online updater and +must not copy ACE files over the stock Klipper tree. Updates are staged and +activated by PAXX instead. + +## Building a package + +From the repository root: + +```text +python3 multiace/paxx/build_package.py +``` + +The builder uses an allowlist from `manifest.json`, writes a deterministic +versioned archive, and emits a matching `.sha256` file. The resulting archive +is safe to use as the payload for a PAXX `extended-pkg` definition; PAXX still +supplies the activation hook and persistent configuration seeding. + +The `paxx-package.yml` workflow runs the same tests and publishes the archive +and checksum as release assets for tags named `multiace-v< version >`. + +The existing `install_multiace.sh` path remains available for standalone +multiACE installations. It is not used by the PAXX-managed package. diff --git a/multiace/paxx/build_package.py b/multiace/paxx/build_package.py new file mode 100644 index 00000000..478e9293 --- /dev/null +++ b/multiace/paxx/build_package.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Build the allowlisted PAXX-managed multiACE archive. + +The regular multiACE repository contains an SSH installer and maintenance +scripts for standalone users. Those scripts must not be shipped as the +PAXX-managed payload, because PAXX owns installation and update lifecycle. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import sys +import tarfile +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = Path(__file__).with_name("manifest.json") +VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$") + + +def _load_manifest() -> dict: + return json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + + +def _read_version(manifest: dict) -> str: + path = ROOT / manifest["version_file"] + version = path.read_text(encoding="utf-8").strip() + if not version or not VERSION_RE.fullmatch(version): + raise ValueError(f"invalid version in {path}") + return version + + +def _safe_relative(value: str, field: str) -> str: + path = Path(value) + if path.is_absolute() or ".." in path.parts: + raise ValueError(f"invalid {field} path: {value}") + return path.as_posix().rstrip("/") + + +def _validate_manifest(manifest: dict) -> None: + if manifest.get("schema") != 1: + raise ValueError("unsupported package manifest schema") + for relative in manifest.get("payload", []): + _safe_relative(relative, "payload") + for relative in manifest.get("excluded_from_package", []): + _safe_relative(relative, "excluded") + for mount in manifest.get("klipper_mounts", []): + _safe_relative(mount["source"], "mount source") + if not mount["target"].startswith("/"): + raise ValueError(f"mount target is not absolute: {mount['target']}") + + +def _copy_payload(stage: Path, manifest: dict) -> None: + for relative in manifest["payload"]: + relative = _safe_relative(relative, "payload") + source = ROOT / relative + if not source.exists(): + raise FileNotFoundError(f"payload entry does not exist: {relative}") + destination = stage / relative + if source.is_dir(): + shutil.copytree(source, destination) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + for relative in (manifest["version_file"], "LICENSE"): + relative = _safe_relative(relative, "metadata") + source = ROOT / relative + destination = stage / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + manifest_destination = stage / "paxx" / "manifest.json" + manifest_destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(MANIFEST_PATH, manifest_destination) + + +def _assert_excluded(stage: Path, manifest: dict) -> None: + forbidden = tuple( + _safe_relative(item, "excluded") + for item in manifest["excluded_from_package"] + ) + for path in stage.rglob("*"): + relative = path.relative_to(stage).as_posix() + if any(relative == item or relative.startswith(item + "/") + for item in forbidden): + raise AssertionError(f"excluded path leaked into package: {relative}") + + +def _tar_filter(info: tarfile.TarInfo) -> tarfile.TarInfo: + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mtime = 0 + return info + + +def build(output: Path) -> tuple[Path, str]: + manifest = _load_manifest() + _validate_manifest(manifest) + version = _read_version(manifest) + root_name = f"multiace-{version}" + output.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(prefix="multiace-paxx-") as temporary: + stage = Path(temporary) / root_name + stage.mkdir() + _copy_payload(stage, manifest) + _assert_excluded(stage, manifest) + + with output.open("wb") as raw: + import gzip + + with gzip.GzipFile( + filename="", fileobj=raw, mode="wb", mtime=0) as compressed: + with tarfile.open(fileobj=compressed, mode="w") as archive: + archive.add(stage, arcname=root_name, + filter=_tar_filter) + + digest = hashlib.sha256(output.read_bytes()).hexdigest() + checksum = output.with_name(output.name + ".sha256") + checksum.write_text(f"{digest} {output.name}\n", encoding="utf-8") + return output, digest + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--output", + type=Path, + default=None, + help="output archive path", + ) + args = parser.parse_args(argv) + try: + if args.output is None: + manifest = _load_manifest() + version = _read_version(manifest) + prefix = manifest["release"]["asset_prefix"] + suffix = manifest["release"]["archive_suffix"] + args.output = Path("dist") / f"{prefix}{version}{suffix}" + output, digest = build(args.output) + except (OSError, ValueError, AssertionError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"created {output}") + print(f"sha256 {digest}") + print(f"checksum {output}.sha256") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/multiace/paxx/manifest.json b/multiace/paxx/manifest.json new file mode 100644 index 00000000..5d6921f0 --- /dev/null +++ b/multiace/paxx/manifest.json @@ -0,0 +1,109 @@ +{ + "schema": 1, + "package": "multiace", + "version_file": "VERSION", + "release": { + "asset_prefix": "multiace-paxx-", + "archive_suffix": ".tar.gz", + "checksum_suffix": ".sha256" + }, + "runtime": { + "install_root": "/oem/apps/multiace", + "versioned_app_root": "/oem/apps/multiace/{version}", + "active_app_root": "/oem/apps/multiace/latest", + "config_dir": "/home/lava/printer_data/config/extended/multiace", + "web_dir": "/oem/apps/multiace/latest/web", + "managed_environment": { + "MULTIACE_MANAGED": "1", + "MULTIACE_APP_DIR": "/oem/apps/multiace/latest", + "MULTIACE_WEB_DIR": "/oem/apps/multiace/latest/web", + "MULTIACE_CONFIG_DIR": "/home/lava/printer_data/config/extended/multiace", + "MULTIACE_DISABLE_UPDATES": "1" + } + }, + "persistent_files": [ + "ace_vars.cfg", + "slot_overrides.json", + "filament_snapshots/", + "i18n/" + ], + "payload": [ + "klipper/extras/ace.py", + "klipper/extras/ace_protocol.py", + "klipper/extras/ace_protocol_v1.py", + "klipper/extras/ace_protocol_v2.py", + "klipper/extras/ace_bg_swap.py", + "klipper/extras/ace_tipform.py", + "klipper/extras/ace_rc522.py", + "klipper/extras/filament_feed_ace.py", + "klipper/extras/filament_switch_sensor_ace.py", + "klipper/kinematics/extruder_ace.py", + "config/extended/ace.cfg", + "config/extended/multiace/ace_vars.cfg", + "i18n/", + "web/backend/", + "web/frontend/" + ], + "klipper_mounts": [ + { + "source": "klipper/extras/ace.py", + "target": "/home/lava/klipper/klippy/extras/ace.py" + }, + { + "source": "klipper/extras/ace_protocol.py", + "target": "/home/lava/klipper/klippy/extras/ace_protocol.py" + }, + { + "source": "klipper/extras/ace_protocol_v1.py", + "target": "/home/lava/klipper/klippy/extras/ace_protocol_v1.py" + }, + { + "source": "klipper/extras/ace_protocol_v2.py", + "target": "/home/lava/klipper/klippy/extras/ace_protocol_v2.py" + }, + { + "source": "klipper/extras/ace_bg_swap.py", + "target": "/home/lava/klipper/klippy/extras/ace_bg_swap.py" + }, + { + "source": "klipper/extras/ace_tipform.py", + "target": "/home/lava/klipper/klippy/extras/ace_tipform.py" + }, + { + "source": "klipper/extras/ace_rc522.py", + "target": "/home/lava/klipper/klippy/extras/ace_rc522.py" + }, + { + "source": "klipper/extras/filament_feed_ace.py", + "target": "/home/lava/klipper/klippy/extras/filament_feed_ace.py" + }, + { + "source": "klipper/extras/filament_feed_ace.py", + "target": "/home/lava/klipper/klippy/extras/filament_feed.py" + }, + { + "source": "klipper/extras/filament_switch_sensor_ace.py", + "target": "/home/lava/klipper/klippy/extras/filament_switch_sensor_ace.py" + }, + { + "source": "klipper/extras/filament_switch_sensor_ace.py", + "target": "/home/lava/klipper/klippy/extras/filament_switch_sensor.py" + }, + { + "source": "klipper/kinematics/extruder_ace.py", + "target": "/home/lava/klipper/klippy/kinematics/extruder_ace.py" + }, + { + "source": "klipper/kinematics/extruder_ace.py", + "target": "/home/lava/klipper/klippy/kinematics/extruder.py" + } + ], + "excluded_from_package": [ + "install_multiace.sh", + "uninstall_multiace.sh", + "tools/", + "deploy/", + "config/extended/multiace/ace_mode_switch.sh", + "web/deploy/" + ] +} diff --git a/multiace/paxx/tests/test_package.py b/multiace/paxx/tests/test_package.py new file mode 100644 index 00000000..bd218132 --- /dev/null +++ b/multiace/paxx/tests/test_package.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path + + +PAXX_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PAXX_DIR)) +import build_package # noqa: E402 + + +class PackageTests(unittest.TestCase): + def test_manifest_is_self_consistent(self) -> None: + manifest = json.loads( + (PAXX_DIR / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["schema"], 1) + self.assertEqual(manifest["release"]["asset_prefix"], "multiace-paxx-") + self.assertIn("{version}", manifest["runtime"]["versioned_app_root"]) + for relative in manifest["payload"]: + self.assertTrue( + (build_package.ROOT / relative.rstrip("/")).exists(), relative) + self.assertIn("install_multiace.sh", manifest["excluded_from_package"]) + self.assertIn("uninstall_multiace.sh", manifest["excluded_from_package"]) + + def test_mount_sources_are_allowlisted(self) -> None: + manifest = json.loads( + (PAXX_DIR / "manifest.json").read_text(encoding="utf-8")) + payload = set(manifest["payload"]) + for mount in manifest["klipper_mounts"]: + source = mount["source"] + self.assertTrue( + source in payload or any( + item.rstrip("/") == source + or source.startswith(item.rstrip("/") + "/") + for item in payload if item.endswith("/") + ), source) + + def test_package_contains_only_managed_payload(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-paxx-test-") as tmp: + output = Path(tmp) / "multiace.tar.gz" + archive_path, digest = build_package.build(output) + checksum = hashlib.sha256(archive_path.read_bytes()).hexdigest() + self.assertEqual(checksum, digest) + with tarfile.open(archive_path, "r:gz") as archive: + names = {member.name for member in archive.getmembers()} + self.assertTrue(any(name.endswith("/paxx/manifest.json") for name in names)) + self.assertFalse(any(name.endswith("/install_multiace.sh") for name in names)) + self.assertFalse(any(name.endswith("/uninstall_multiace.sh") for name in names)) + self.assertFalse(any("/tools/" in name for name in names)) + self.assertFalse(any("/deploy/" in name for name in names)) + + def test_package_is_deterministic(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-paxx-deterministic-") as tmp: + first = Path(tmp) / "first.tar.gz" + second = Path(tmp) / "second.tar.gz" + _, first_digest = build_package.build(first) + _, second_digest = build_package.build(second) + self.assertEqual(first.read_bytes(), second.read_bytes()) + self.assertEqual(first_digest, second_digest) + + +if __name__ == "__main__": + unittest.main() diff --git a/multiace/tools/multiace_update.sh b/multiace/tools/multiace_update.sh index 416788ce..36794a61 100644 --- a/multiace/tools/multiace_update.sh +++ b/multiace/tools/multiace_update.sh @@ -11,6 +11,15 @@ # MULTIACE_UPDATE_PRERELEASE 1 = consider prereleases / beta.txt set -e + +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ "${MULTIACE_DISABLE_UPDATES:-0}" = "1" ] || \ + [ "${MULTIACE_DISABLE_UPDATES:-}" = "true" ]; then + echo "multiACE updates are managed by the host firmware; use PAXX Firmware Config" >&2 + exit 2 +fi + REPO="${MULTIACE_UPDATE_REPO:-decay71/multiACE}" STATIC_BASE="${MULTIACE_UPDATE_URL_BASE:-}" USE_STATIC=0 diff --git a/multiace/uninstall_multiace.sh b/multiace/uninstall_multiace.sh index 6abbce59..5c32b601 100755 --- a/multiace/uninstall_multiace.sh +++ b/multiace/uninstall_multiace.sh @@ -1,4 +1,10 @@ #!/bin/bash +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ]; then + echo "multiACE is managed by the host firmware; use the host integration instead of uninstall_multiace.sh" >&2 + exit 2 +fi + sed -i 's/\r$//' "$0" 2>/dev/null set -e HOME_DIR="/home/lava" diff --git a/multiace/web/deploy/S98multiace-web b/multiace/web/deploy/S98multiace-web index 1a597d8d..90c1e75a 100644 --- a/multiace/web/deploy/S98multiace-web +++ b/multiace/web/deploy/S98multiace-web @@ -10,7 +10,8 @@ # Either way the same script controls the lifecycle. NAME="multiace-web" -DAEMON_DIR="/home/lava/multiace_web/backend" +MULTIACE_WEB_DIR="${MULTIACE_WEB_DIR:-/home/lava/multiace_web}" +DAEMON_DIR="$MULTIACE_WEB_DIR/backend" DAEMON="/usr/bin/python3" ARGS="-m uvicorn main:app --host 127.0.0.1 --port 7126 --log-level warning" PIDFILE="/tmp/multiace_web.pid" @@ -23,7 +24,10 @@ USER="root" export MOONRAKER_URL="${MOONRAKER_URL:-http://127.0.0.1:7125}" export MULTIACE_CFG_PATH="${MULTIACE_CFG_PATH:-/home/lava/printer_data/config/extended/ace.cfg}" -export MULTIACE_FRONTEND_DIR="${MULTIACE_FRONTEND_DIR:-/home/lava/multiace_web/frontend}" +export MULTIACE_FRONTEND_DIR="${MULTIACE_FRONTEND_DIR:-$MULTIACE_WEB_DIR/frontend}" +export MULTIACE_CONFIG_DIR="${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config/extended/multiace}" +export MULTIACE_MANAGED="${MULTIACE_MANAGED:-0}" +export MULTIACE_DISABLE_UPDATES="${MULTIACE_DISABLE_UPDATES:-0}" # Every uvicorn instance we ever start matches this cmdline pattern. # /proc scan instead of pgrep: busybox builds here don't guarantee pgrep. diff --git a/multiace/web/frontend/app.js b/multiace/web/frontend/app.js index 27418570..9c589763 100644 --- a/multiace/web/frontend/app.js +++ b/multiace/web/frontend/app.js @@ -4833,6 +4833,7 @@ createApp({ latest: "", statusText: "", canApply: false, + managed: false, busy: null, log: "", }); @@ -4849,6 +4850,14 @@ createApp({ } catch (e) { } } + async function refreshUpdateStatus() { + try { + const r = await fetch(`${API}/update/status`); + const j = await r.json(); + if (r.ok) updateState.managed = !!j.managed; + } catch (e) { + } + } async function debugEnable() { if (debugState.busy) return; debugState.busy = true; @@ -6916,6 +6925,7 @@ createApp({ await loadMaterials(); await loadNotifications(); await refreshDebugState(); + await refreshUpdateStatus(); await refreshPlugins(); if (state.mode === "normal" && ["dashboard", "calibration"].includes(tab.value)) { tab.value = "config"; diff --git a/multiace/web/frontend/index.html b/multiace/web/frontend/index.html index 9ac51b25..7ad44c55 100644 --- a/multiace/web/frontend/index.html +++ b/multiace/web/frontend/index.html @@ -1921,7 +1921,10 @@

{{ t('ui.config.tipform_title') }}

{{ t('ui.config.tipform_unsupported') }}

{{ t('ui.config.update_title') }}

{{ t('ui.config.update_intro') }}

-
+

+ {{ t('ui.config.update_managed') }} +

+
From 91480b3b2974ad88ee5d338906d5595f0c69affb Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Tue, 29 Sep 2026 16:25:39 -0600 Subject: [PATCH 2/7] Harden multiACE managed package boundaries # Conflicts: # .gitattributes # multiace/klipper/extras/ace.py # multiace/web/backend/main.py --- .github/workflows/paxx-package.yml | 1 + .../extended/multiace/ace_mode_switch.sh | 3 +- multiace/install_multiace.sh | 7 ++- multiace/paxx/README.md | 6 +++ multiace/paxx/build_package.py | 3 ++ multiace/paxx/manifest.json | 2 + multiace/paxx/tests/test_managed_guards.sh | 52 +++++++++++++++++++ multiace/paxx/tests/test_package.py | 8 +++ multiace/tools/multiace_update.sh | 3 +- multiace/uninstall_multiace.sh | 7 ++- multiace/web/deploy/S98multiace-web | 1 + 11 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 multiace/paxx/tests/test_managed_guards.sh diff --git a/.github/workflows/paxx-package.yml b/.github/workflows/paxx-package.yml index 4b1b1928..d083ad94 100644 --- a/.github/workflows/paxx-package.yml +++ b/.github/workflows/paxx-package.yml @@ -38,6 +38,7 @@ jobs: tools/multiace_update.sh \ config/extended/multiace/ace_mode_switch.sh \ web/deploy/S98multiace-web + bash paxx/tests/test_managed_guards.sh - name: Run package tests run: python -m unittest discover -s paxx/tests -v diff --git a/multiace/config/extended/multiace/ace_mode_switch.sh b/multiace/config/extended/multiace/ace_mode_switch.sh index 149d4253..741106db 100644 --- a/multiace/config/extended/multiace/ace_mode_switch.sh +++ b/multiace/config/extended/multiace/ace_mode_switch.sh @@ -1,7 +1,8 @@ #!/bin/bash set -e if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ - [ "${MULTIACE_MANAGED:-}" = "true" ]; then + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" ]; then echo "multiACE mode switching is managed by the host firmware; refusing to copy Klipper files" >&2 exit 2 fi diff --git a/multiace/install_multiace.sh b/multiace/install_multiace.sh index 77ac979a..235726b8 100755 --- a/multiace/install_multiace.sh +++ b/multiace/install_multiace.sh @@ -1,9 +1,12 @@ #!/bin/bash set -e -if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ - [ "${MULTIACE_MANAGED:-}" = "true" ]; then +if [ "${MULTIACE_IGNORE_FIRMWARE_MANAGED:-0}" != "1" ] && \ + { [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" ]; }; then echo "multiACE is managed by the host firmware; use the host integration instead of install_multiace.sh" >&2 + echo "Set MULTIACE_IGNORE_FIRMWARE_MANAGED=1 only for an intentional standalone override." >&2 exit 2 fi diff --git a/multiace/paxx/README.md b/multiace/paxx/README.md index ab9c5592..6394249f 100644 --- a/multiace/paxx/README.md +++ b/multiace/paxx/README.md @@ -28,6 +28,7 @@ The following environment variables are supplied to Klipper and the web service by PAXX: - `MULTIACE_MANAGED=1` +- `MULTIACE_MANAGED_MARKER=/oem/apps/multiace/.paxx-managed` - `MULTIACE_APP_DIR` - `MULTIACE_WEB_DIR` - `MULTIACE_CONFIG_DIR` @@ -37,6 +38,11 @@ When managed mode is active, multiACE must refuse its own online updater and must not copy ACE files over the stock Klipper tree. Updates are staged and activated by PAXX instead. +The marker file is a durable fallback for SSH sessions or services that do not +inherit the activation hook's environment. Standalone install and uninstall +also refuse to run when the marker exists; an intentional manual override is +available with `MULTIACE_IGNORE_FIRMWARE_MANAGED=1`. + ## Building a package From the repository root: diff --git a/multiace/paxx/build_package.py b/multiace/paxx/build_package.py index 478e9293..c45783d5 100644 --- a/multiace/paxx/build_package.py +++ b/multiace/paxx/build_package.py @@ -54,6 +54,9 @@ def _validate_manifest(manifest: dict) -> None: _safe_relative(mount["source"], "mount source") if not mount["target"].startswith("/"): raise ValueError(f"mount target is not absolute: {mount['target']}") + marker = manifest.get("runtime", {}).get("managed_marker", "") + if not marker.startswith("/"): + raise ValueError(f"managed marker is not absolute: {marker}") def _copy_payload(stage: Path, manifest: dict) -> None: diff --git a/multiace/paxx/manifest.json b/multiace/paxx/manifest.json index 5d6921f0..94acb9bf 100644 --- a/multiace/paxx/manifest.json +++ b/multiace/paxx/manifest.json @@ -13,8 +13,10 @@ "active_app_root": "/oem/apps/multiace/latest", "config_dir": "/home/lava/printer_data/config/extended/multiace", "web_dir": "/oem/apps/multiace/latest/web", + "managed_marker": "/oem/apps/multiace/.paxx-managed", "managed_environment": { "MULTIACE_MANAGED": "1", + "MULTIACE_MANAGED_MARKER": "/oem/apps/multiace/.paxx-managed", "MULTIACE_APP_DIR": "/oem/apps/multiace/latest", "MULTIACE_WEB_DIR": "/oem/apps/multiace/latest/web", "MULTIACE_CONFIG_DIR": "/home/lava/printer_data/config/extended/multiace", diff --git a/multiace/paxx/tests/test_managed_guards.sh b/multiace/paxx/tests/test_managed_guards.sh new file mode 100644 index 00000000..105ecf9f --- /dev/null +++ b/multiace/paxx/tests/test_managed_guards.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +MULTIACE_ROOT="$(CDPATH= cd -- "$TEST_DIR/../.." && pwd)" +MARKER="$(mktemp)" +trap 'rm -f "$MARKER"' EXIT + +GUARDED_SCRIPTS=( + "install_multiace.sh" + "uninstall_multiace.sh" + "tools/multiace_update.sh" + "config/extended/multiace/ace_mode_switch.sh" +) + +run_guard_test() { + local label="$1" + shift + local script output status + for script in "${GUARDED_SCRIPTS[@]}"; do + set +e + output="$(env "$@" bash "$MULTIACE_ROOT/$script" 2>&1)" + status=$? + set -e + if [ "$status" -ne 2 ]; then + printf 'FAIL: %s guard for %s returned %s\n%s\n' \ + "$label" "$script" "$status" "$output" >&2 + exit 1 + fi + case "$output" in + *managed*) ;; + *) + printf 'FAIL: %s guard for %s did not explain managed ownership\n' \ + "$label" "$script" >&2 + exit 1 + ;; + esac + done +} + +# The marker is the fallback used when a shell session does not inherit the +# activation hook's environment. +run_guard_test marker \ + -u MULTIACE_MANAGED \ + MULTIACE_MANAGED_MARKER="$MARKER" + +# The explicit environment contract remains supported as well. +run_guard_test environment \ + MULTIACE_MANAGED=1 \ + MULTIACE_MANAGED_MARKER="/path/that/does/not/exist" + +printf 'Managed-install guard tests passed\n' diff --git a/multiace/paxx/tests/test_package.py b/multiace/paxx/tests/test_package.py index bd218132..77b36f5f 100644 --- a/multiace/paxx/tests/test_package.py +++ b/multiace/paxx/tests/test_package.py @@ -22,6 +22,14 @@ def test_manifest_is_self_consistent(self) -> None: self.assertEqual(manifest["schema"], 1) self.assertEqual(manifest["release"]["asset_prefix"], "multiace-paxx-") self.assertIn("{version}", manifest["runtime"]["versioned_app_root"]) + self.assertEqual( + manifest["runtime"]["managed_marker"], + "/oem/apps/multiace/.paxx-managed", + ) + self.assertEqual( + manifest["runtime"]["managed_environment"]["MULTIACE_MANAGED_MARKER"], + manifest["runtime"]["managed_marker"], + ) for relative in manifest["payload"]: self.assertTrue( (build_package.ROOT / relative.rstrip("/")).exists(), relative) diff --git a/multiace/tools/multiace_update.sh b/multiace/tools/multiace_update.sh index 36794a61..0c9fac55 100644 --- a/multiace/tools/multiace_update.sh +++ b/multiace/tools/multiace_update.sh @@ -15,7 +15,8 @@ set -e if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ [ "${MULTIACE_MANAGED:-}" = "true" ] || \ [ "${MULTIACE_DISABLE_UPDATES:-0}" = "1" ] || \ - [ "${MULTIACE_DISABLE_UPDATES:-}" = "true" ]; then + [ "${MULTIACE_DISABLE_UPDATES:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" ]; then echo "multiACE updates are managed by the host firmware; use PAXX Firmware Config" >&2 exit 2 fi diff --git a/multiace/uninstall_multiace.sh b/multiace/uninstall_multiace.sh index 5c32b601..4c3dd906 100755 --- a/multiace/uninstall_multiace.sh +++ b/multiace/uninstall_multiace.sh @@ -1,7 +1,10 @@ #!/bin/bash -if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ - [ "${MULTIACE_MANAGED:-}" = "true" ]; then +if [ "${MULTIACE_IGNORE_FIRMWARE_MANAGED:-0}" != "1" ] && \ + { [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" ]; }; then echo "multiACE is managed by the host firmware; use the host integration instead of uninstall_multiace.sh" >&2 + echo "Set MULTIACE_IGNORE_FIRMWARE_MANAGED=1 only for an intentional standalone override." >&2 exit 2 fi diff --git a/multiace/web/deploy/S98multiace-web b/multiace/web/deploy/S98multiace-web index 90c1e75a..312afebb 100644 --- a/multiace/web/deploy/S98multiace-web +++ b/multiace/web/deploy/S98multiace-web @@ -27,6 +27,7 @@ export MULTIACE_CFG_PATH="${MULTIACE_CFG_PATH:-/home/lava/printer_data/config/ex export MULTIACE_FRONTEND_DIR="${MULTIACE_FRONTEND_DIR:-$MULTIACE_WEB_DIR/frontend}" export MULTIACE_CONFIG_DIR="${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config/extended/multiace}" export MULTIACE_MANAGED="${MULTIACE_MANAGED:-0}" +export MULTIACE_MANAGED_MARKER="${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" export MULTIACE_DISABLE_UPDATES="${MULTIACE_DISABLE_UPDATES:-0}" # Every uvicorn instance we ever start matches this cmdline pattern. From caa4b5178a8638eb052fc29505293bc5db8b75fd Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Tue, 29 Sep 2026 16:26:06 -0600 Subject: [PATCH 3/7] Support package-root translation catalogs # Conflicts: # multiace/web/backend/main.py --- .github/workflows/paxx-package.yml | 4 +- multiace/paxx/README.md | 6 +++ multiace/paxx/tests/test_i18n_path.py | 60 +++++++++++++++++++++++++++ multiace/web/backend/i18n_path.py | 28 +++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 multiace/paxx/tests/test_i18n_path.py create mode 100644 multiace/web/backend/i18n_path.py diff --git a/.github/workflows/paxx-package.yml b/.github/workflows/paxx-package.yml index d083ad94..93df30b7 100644 --- a/.github/workflows/paxx-package.yml +++ b/.github/workflows/paxx-package.yml @@ -28,9 +28,11 @@ jobs: run: | python -m py_compile \ klipper/extras/ace.py \ + web/backend/i18n_path.py \ web/backend/main.py \ paxx/build_package.py \ - paxx/tests/test_package.py + paxx/tests/test_package.py \ + paxx/tests/test_i18n_path.py node --check web/frontend/app.js bash -n \ install_multiace.sh \ diff --git a/multiace/paxx/README.md b/multiace/paxx/README.md index 6394249f..23d5914d 100644 --- a/multiace/paxx/README.md +++ b/multiace/paxx/README.md @@ -38,6 +38,12 @@ When managed mode is active, multiACE must refuse its own online updater and must not copy ACE files over the stock Klipper tree. Updates are staged and activated by PAXX instead. +Read-only translation catalogs are provider data, not persistent user +configuration. The web backend prefers the package-root `i18n/` directory and +falls back to the historical standalone `web/i18n/` layout. An explicit +`MULTIACE_I18N_DIR` override remains available for deployments with a custom +layout, but the standard managed package does not need to set it. + The marker file is a durable fallback for SSH sessions or services that do not inherit the activation hook's environment. Standalone install and uninstall also refuse to run when the marker exists; an intentional manual override is diff --git a/multiace/paxx/tests/test_i18n_path.py b/multiace/paxx/tests/test_i18n_path.py new file mode 100644 index 00000000..ab6111bd --- /dev/null +++ b/multiace/paxx/tests/test_i18n_path.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + + +BACKEND_DIR = Path(__file__).resolve().parents[2] / "web" / "backend" +sys.path.insert(0, str(BACKEND_DIR)) +from i18n_path import resolve_i18n_dir # noqa: E402 + + +class I18nPathTests(unittest.TestCase): + @staticmethod + def _module_file(root: Path) -> Path: + path = root / "multiace" / "web" / "backend" / "main.py" + path.parent.mkdir(parents=True) + path.touch() + return path + + def test_managed_package_root_layout(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-i18n-root-") as tmp: + root = Path(tmp) / "multiace" + catalog = root / "i18n" + catalog.mkdir(parents=True) + module_file = self._module_file(Path(tmp)) + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("MULTIACE_I18N_DIR", None) + self.assertEqual(resolve_i18n_dir(module_file), catalog) + + def test_standalone_web_layout_fallback(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-i18n-web-") as tmp: + root = Path(tmp) / "multiace" + catalog = root / "web" / "i18n" + catalog.mkdir(parents=True) + module_file = self._module_file(Path(tmp)) + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("MULTIACE_I18N_DIR", None) + self.assertEqual(resolve_i18n_dir(module_file), catalog) + + def test_explicit_override_wins(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-i18n-override-") as tmp: + root = Path(tmp) / "multiace" + root_catalog = root / "i18n" + root_catalog.mkdir(parents=True) + module_file = self._module_file(Path(tmp)) + override = Path(tmp) / "external-catalogs" + + with patch.dict(os.environ, {"MULTIACE_I18N_DIR": str(override)}): + self.assertEqual(resolve_i18n_dir(module_file), override) + + +if __name__ == "__main__": + unittest.main() diff --git a/multiace/web/backend/i18n_path.py b/multiace/web/backend/i18n_path.py new file mode 100644 index 00000000..f8cb65e2 --- /dev/null +++ b/multiace/web/backend/i18n_path.py @@ -0,0 +1,28 @@ +"""Locate multiACE translation catalogs across supported install layouts.""" +from __future__ import annotations + +import os +from pathlib import Path + + +def resolve_i18n_dir(module_file: str | os.PathLike[str]) -> Path: + """Return the catalog directory for a backend module. + + Managed packages keep shared provider data at the application root while + the standalone installer historically copied catalogs into ``web/i18n``. + Prefer the application-root layout and retain the standalone layout as a + fallback so both installations use the same backend code. + """ + configured = os.environ.get("MULTIACE_I18N_DIR", "").strip() + if configured: + return Path(configured) + + backend_dir = Path(module_file).resolve().parent + web_dir = backend_dir.parent + package_dir = web_dir.parent + candidates = (package_dir / "i18n", web_dir / "i18n") + + for candidate in candidates: + if candidate.is_dir(): + return candidate + return candidates[0] From 02135cf9d34dede86df4d0c62d6739762fc96ac8 Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Tue, 29 Sep 2026 16:26:18 -0600 Subject: [PATCH 4/7] Harden PAXX managed package boundaries # Conflicts: # multiace/klipper/extras/ace.py --- multiace/paxx/README.md | 27 ++++++---- multiace/paxx/build_package.py | 50 +++++++++++++++--- multiace/paxx/manifest.json | 81 +++-------------------------- multiace/paxx/tests/test_package.py | 37 ++++++------- 4 files changed, 83 insertions(+), 112 deletions(-) diff --git a/multiace/paxx/README.md b/multiace/paxx/README.md index 23d5914d..9f96b106 100644 --- a/multiace/paxx/README.md +++ b/multiace/paxx/README.md @@ -15,20 +15,21 @@ not install an arbitrary moving `latest` build. ## Runtime contract -PAXX installs the package under `runtime.versioned_app_root` and exposes the -selected version through `runtime.active_app_root` (`latest`). The PAXX hook then -bind-mounts the listed Klipper modules at startup. The stock files are never -overwritten on disk. +PAXX installs the package under its own versioned application root and exposes +the selected version through a `latest` link. The PAXX hook owns the host-side +bind-mount map and applies it at startup. The stock files are never overwritten +on disk. The package does not contain or run the standalone SSH installer, uninstaller, updater, init scripts, or mode-switch file-copy helper. Those operations are owned by the host firmware integration. -The following environment variables are supplied to Klipper and the web -service by PAXX: +PAXX supplies the managed runtime environment to Klipper and the web service. +The exact host paths are a PAXX concern rather than provider package metadata. +The managed environment includes: - `MULTIACE_MANAGED=1` -- `MULTIACE_MANAGED_MARKER=/oem/apps/multiace/.paxx-managed` +- `MULTIACE_MANAGED_MARKER` - `MULTIACE_APP_DIR` - `MULTIACE_WEB_DIR` - `MULTIACE_CONFIG_DIR` @@ -39,16 +40,20 @@ must not copy ACE files over the stock Klipper tree. Updates are staged and activated by PAXX instead. Read-only translation catalogs are provider data, not persistent user -configuration. The web backend prefers the package-root `i18n/` directory and -falls back to the historical standalone `web/i18n/` layout. An explicit -`MULTIACE_I18N_DIR` override remains available for deployments with a custom -layout, but the standard managed package does not need to set it. +configuration. Both runtimes prefer the selected package's `i18n/` directory +and retain the historical standalone layout as a fallback. An explicit +`MULTIACE_I18N_DIR` override remains available for custom deployments, but the +standard managed package does not need to copy catalogs into persistent state. The marker file is a durable fallback for SSH sessions or services that do not inherit the activation hook's environment. Standalone install and uninstall also refuse to run when the marker exists; an intentional manual override is available with `MULTIACE_IGNORE_FIRMWARE_MANAGED=1`. +The managed package's configuration template omits the standalone update +wrapper macros. PAXX may still perform a one-time migration of an older +persistent configuration created by an earlier managed package. + ## Building a package From the repository root: diff --git a/multiace/paxx/build_package.py b/multiace/paxx/build_package.py index c45783d5..62dcbd29 100644 --- a/multiace/paxx/build_package.py +++ b/multiace/paxx/build_package.py @@ -50,13 +50,39 @@ def _validate_manifest(manifest: dict) -> None: _safe_relative(relative, "payload") for relative in manifest.get("excluded_from_package", []): _safe_relative(relative, "excluded") - for mount in manifest.get("klipper_mounts", []): - _safe_relative(mount["source"], "mount source") - if not mount["target"].startswith("/"): - raise ValueError(f"mount target is not absolute: {mount['target']}") - marker = manifest.get("runtime", {}).get("managed_marker", "") - if not marker.startswith("/"): - raise ValueError(f"managed marker is not absolute: {marker}") + managed_config = manifest.get("managed_config", {}) + config_path = managed_config.get("path", "") + if not config_path: + raise ValueError("managed package has no config path contract") + _safe_relative(config_path, "managed config") + sections = managed_config.get("remove_sections", []) + if not sections: + raise ValueError("managed package has no config section contract") + if any(not isinstance(section, str) or not section.strip() + for section in sections): + raise ValueError("managed config section names must be non-empty strings") + + +def _remove_ini_sections(text: str, section_names: set[str]) -> str: + """Remove exact INI sections while preserving all other config text.""" + output: list[str] = [] + skipping = False + for line in text.splitlines(keepends=True): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + section = stripped[1:-1] + skipping = section in section_names + if not skipping: + output.append(line) + return "".join(output) + + +def _managed_config(manifest: dict, relative: str, text: str) -> str: + managed_config = manifest["managed_config"] + if relative != managed_config["path"]: + return text + sections = set(managed_config["remove_sections"]) + return _remove_ini_sections(text, sections) def _copy_payload(stage: Path, manifest: dict) -> None: @@ -70,7 +96,15 @@ def _copy_payload(stage: Path, manifest: dict) -> None: shutil.copytree(source, destination) else: destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) + if relative == manifest["managed_config"]["path"]: + destination.write_text( + _managed_config( + manifest, relative, source.read_text(encoding="utf-8")), + encoding="utf-8", + newline="", + ) + else: + shutil.copy2(source, destination) for relative in (manifest["version_file"], "LICENSE"): relative = _safe_relative(relative, "metadata") diff --git a/multiace/paxx/manifest.json b/multiace/paxx/manifest.json index 94acb9bf..8d865b80 100644 --- a/multiace/paxx/manifest.json +++ b/multiace/paxx/manifest.json @@ -7,28 +7,13 @@ "archive_suffix": ".tar.gz", "checksum_suffix": ".sha256" }, - "runtime": { - "install_root": "/oem/apps/multiace", - "versioned_app_root": "/oem/apps/multiace/{version}", - "active_app_root": "/oem/apps/multiace/latest", - "config_dir": "/home/lava/printer_data/config/extended/multiace", - "web_dir": "/oem/apps/multiace/latest/web", - "managed_marker": "/oem/apps/multiace/.paxx-managed", - "managed_environment": { - "MULTIACE_MANAGED": "1", - "MULTIACE_MANAGED_MARKER": "/oem/apps/multiace/.paxx-managed", - "MULTIACE_APP_DIR": "/oem/apps/multiace/latest", - "MULTIACE_WEB_DIR": "/oem/apps/multiace/latest/web", - "MULTIACE_CONFIG_DIR": "/home/lava/printer_data/config/extended/multiace", - "MULTIACE_DISABLE_UPDATES": "1" - } + "managed_config": { + "path": "config/extended/ace.cfg", + "remove_sections": [ + "gcode_macro ACEH__Update_Check", + "gcode_macro ACEH__Update_Apply" + ] }, - "persistent_files": [ - "ace_vars.cfg", - "slot_overrides.json", - "filament_snapshots/", - "i18n/" - ], "payload": [ "klipper/extras/ace.py", "klipper/extras/ace_protocol.py", @@ -46,60 +31,6 @@ "web/backend/", "web/frontend/" ], - "klipper_mounts": [ - { - "source": "klipper/extras/ace.py", - "target": "/home/lava/klipper/klippy/extras/ace.py" - }, - { - "source": "klipper/extras/ace_protocol.py", - "target": "/home/lava/klipper/klippy/extras/ace_protocol.py" - }, - { - "source": "klipper/extras/ace_protocol_v1.py", - "target": "/home/lava/klipper/klippy/extras/ace_protocol_v1.py" - }, - { - "source": "klipper/extras/ace_protocol_v2.py", - "target": "/home/lava/klipper/klippy/extras/ace_protocol_v2.py" - }, - { - "source": "klipper/extras/ace_bg_swap.py", - "target": "/home/lava/klipper/klippy/extras/ace_bg_swap.py" - }, - { - "source": "klipper/extras/ace_tipform.py", - "target": "/home/lava/klipper/klippy/extras/ace_tipform.py" - }, - { - "source": "klipper/extras/ace_rc522.py", - "target": "/home/lava/klipper/klippy/extras/ace_rc522.py" - }, - { - "source": "klipper/extras/filament_feed_ace.py", - "target": "/home/lava/klipper/klippy/extras/filament_feed_ace.py" - }, - { - "source": "klipper/extras/filament_feed_ace.py", - "target": "/home/lava/klipper/klippy/extras/filament_feed.py" - }, - { - "source": "klipper/extras/filament_switch_sensor_ace.py", - "target": "/home/lava/klipper/klippy/extras/filament_switch_sensor_ace.py" - }, - { - "source": "klipper/extras/filament_switch_sensor_ace.py", - "target": "/home/lava/klipper/klippy/extras/filament_switch_sensor.py" - }, - { - "source": "klipper/kinematics/extruder_ace.py", - "target": "/home/lava/klipper/klippy/kinematics/extruder_ace.py" - }, - { - "source": "klipper/kinematics/extruder_ace.py", - "target": "/home/lava/klipper/klippy/kinematics/extruder.py" - } - ], "excluded_from_package": [ "install_multiace.sh", "uninstall_multiace.sh", diff --git a/multiace/paxx/tests/test_package.py b/multiace/paxx/tests/test_package.py index 77b36f5f..03b41769 100644 --- a/multiace/paxx/tests/test_package.py +++ b/multiace/paxx/tests/test_package.py @@ -21,34 +21,26 @@ def test_manifest_is_self_consistent(self) -> None: (PAXX_DIR / "manifest.json").read_text(encoding="utf-8")) self.assertEqual(manifest["schema"], 1) self.assertEqual(manifest["release"]["asset_prefix"], "multiace-paxx-") - self.assertIn("{version}", manifest["runtime"]["versioned_app_root"]) self.assertEqual( - manifest["runtime"]["managed_marker"], - "/oem/apps/multiace/.paxx-managed", + manifest["managed_config"]["path"], + "config/extended/ace.cfg", ) self.assertEqual( - manifest["runtime"]["managed_environment"]["MULTIACE_MANAGED_MARKER"], - manifest["runtime"]["managed_marker"], + manifest["managed_config"]["remove_sections"], + [ + "gcode_macro ACEH__Update_Check", + "gcode_macro ACEH__Update_Apply", + ], ) + self.assertNotIn("runtime", manifest) + self.assertNotIn("persistent_files", manifest) + self.assertNotIn("klipper_mounts", manifest) for relative in manifest["payload"]: self.assertTrue( (build_package.ROOT / relative.rstrip("/")).exists(), relative) self.assertIn("install_multiace.sh", manifest["excluded_from_package"]) self.assertIn("uninstall_multiace.sh", manifest["excluded_from_package"]) - def test_mount_sources_are_allowlisted(self) -> None: - manifest = json.loads( - (PAXX_DIR / "manifest.json").read_text(encoding="utf-8")) - payload = set(manifest["payload"]) - for mount in manifest["klipper_mounts"]: - source = mount["source"] - self.assertTrue( - source in payload or any( - item.rstrip("/") == source - or source.startswith(item.rstrip("/") + "/") - for item in payload if item.endswith("/") - ), source) - def test_package_contains_only_managed_payload(self) -> None: with tempfile.TemporaryDirectory(prefix="multiace-paxx-test-") as tmp: output = Path(tmp) / "multiace.tar.gz" @@ -57,11 +49,20 @@ def test_package_contains_only_managed_payload(self) -> None: self.assertEqual(checksum, digest) with tarfile.open(archive_path, "r:gz") as archive: names = {member.name for member in archive.getmembers()} + config_member = next( + member for member in archive.getmembers() + if member.name.endswith("/config/extended/ace.cfg")) + config_text = archive.extractfile(config_member).read().decode() self.assertTrue(any(name.endswith("/paxx/manifest.json") for name in names)) self.assertFalse(any(name.endswith("/install_multiace.sh") for name in names)) self.assertFalse(any(name.endswith("/uninstall_multiace.sh") for name in names)) self.assertFalse(any("/tools/" in name for name in names)) self.assertFalse(any("/deploy/" in name for name in names)) + self.assertNotIn("[gcode_macro ACEH__Update_Check]", config_text) + self.assertNotIn("[gcode_macro ACEH__Update_Apply]", config_text) + source_config = (build_package.ROOT / "config/extended/ace.cfg").read_text() + self.assertIn("[gcode_macro ACEH__Update_Check]", source_config) + self.assertIn("[gcode_macro ACEH__Update_Apply]", source_config) def test_package_is_deterministic(self) -> None: with tempfile.TemporaryDirectory(prefix="multiace-paxx-deterministic-") as tmp: From e5a63103125b18c86d5276c26f49cc455815e6ca Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Tue, 29 Sep 2026 17:21:21 -0600 Subject: [PATCH 5/7] Align managed package with shared platform contract --- .github/workflows/paxx-package.yml | 91 ------------------- .github/workflows/release.yml | 44 ++++++++- README.md | 30 +++--- .../extended/multiace/ace_mode_switch.sh | 4 +- multiace/i18n/de.json | 2 +- multiace/i18n/en.json | 2 +- multiace/i18n/zh.json | 2 +- multiace/install_multiace.sh | 10 +- multiace/klipper/extras/ace.py | 49 ++++++++-- multiace/managed/README.md | 62 +++++++++++++ multiace/{paxx => managed}/build_package.py | 10 +- multiace/{paxx => managed}/manifest.json | 3 +- .../{paxx => managed}/tests/test_i18n_path.py | 11 +++ .../tests/test_managed_guards.sh | 0 .../{paxx => managed}/tests/test_package.py | 21 +++-- multiace/paxx/README.md | 74 --------------- multiace/tools/multiace_update.sh | 4 +- multiace/uninstall_multiace.sh | 10 +- multiace/web/backend/i18n_path.py | 6 ++ multiace/web/backend/main.py | 70 +++++++++++--- multiace/web/deploy/S98multiace-web | 7 +- 21 files changed, 275 insertions(+), 237 deletions(-) delete mode 100644 .github/workflows/paxx-package.yml create mode 100644 multiace/managed/README.md rename multiace/{paxx => managed}/build_package.py (96%) rename multiace/{paxx => managed}/manifest.json (94%) rename multiace/{paxx => managed}/tests/test_i18n_path.py (79%) rename multiace/{paxx => managed}/tests/test_managed_guards.sh (100%) rename multiace/{paxx => managed}/tests/test_package.py (78%) delete mode 100644 multiace/paxx/README.md diff --git a/.github/workflows/paxx-package.yml b/.github/workflows/paxx-package.yml deleted file mode 100644 index 93df30b7..00000000 --- a/.github/workflows/paxx-package.yml +++ /dev/null @@ -1,91 +0,0 @@ -name: Build PAXX-managed multiACE package - -on: - workflow_dispatch: - push: - tags: - - "multiace-v*" - -permissions: - contents: write - -jobs: - package: - runs-on: ubuntu-latest - defaults: - run: - working-directory: multiace - steps: - - name: Check out source - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - - name: Validate modified sources - run: | - python -m py_compile \ - klipper/extras/ace.py \ - web/backend/i18n_path.py \ - web/backend/main.py \ - paxx/build_package.py \ - paxx/tests/test_package.py \ - paxx/tests/test_i18n_path.py - node --check web/frontend/app.js - bash -n \ - install_multiace.sh \ - uninstall_multiace.sh \ - tools/multiace_update.sh \ - config/extended/multiace/ace_mode_switch.sh \ - web/deploy/S98multiace-web - bash paxx/tests/test_managed_guards.sh - - - name: Run package tests - run: python -m unittest discover -s paxx/tests -v - - - name: Read package version - id: version - shell: bash - run: | - version="$(tr -d '\r\n' < VERSION)" - test -n "$version" - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Verify release tag version - if: startsWith(github.ref, 'refs/tags/') - shell: bash - env: - PACKAGE_VERSION: ${{ steps.version.outputs.version }} - run: | - test "${GITHUB_REF_NAME#multiace-v}" = "$PACKAGE_VERSION" - - - name: Build package and checksum - run: python paxx/build_package.py - - - name: Upload package artifact - uses: actions/upload-artifact@v4 - with: - name: multiace-paxx-${{ steps.version.outputs.version }} - path: | - multiace/dist/multiace-paxx-${{ steps.version.outputs.version }}.tar.gz - multiace/dist/multiace-paxx-${{ steps.version.outputs.version }}.tar.gz.sha256 - if-no-files-found: error - - - name: Publish release assets - if: startsWith(github.ref, 'refs/tags/') - working-directory: multiace - env: - GH_TOKEN: ${{ github.token }} - PACKAGE_VERSION: ${{ steps.version.outputs.version }} - run: | - archive="dist/multiace-paxx-${PACKAGE_VERSION}.tar.gz" - checksum="${archive}.sha256" - if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then - gh release upload "$GITHUB_REF_NAME" "$archive" "$checksum" --clobber - else - gh release create "$GITHUB_REF_NAME" "$archive" "$checksum" \ - --title "multiACE PAXX package ${PACKAGE_VERSION}" \ - --generate-notes - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36968461..9e2c1932 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,51 @@ -name: Build release tarball +name: Build and test multiACE packages on: push: tags: - 'v*' + pull_request: permissions: - contents: write + contents: read jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate managed package sources + run: | + python -m py_compile \ + multiace/klipper/extras/ace.py \ + multiace/web/backend/i18n_path.py \ + multiace/web/backend/main.py \ + multiace/managed/build_package.py \ + multiace/managed/tests/test_package.py \ + multiace/managed/tests/test_i18n_path.py + node --check multiace/web/frontend/app.js + bash -n \ + multiace/install_multiace.sh \ + multiace/uninstall_multiace.sh \ + multiace/tools/multiace_update.sh \ + multiace/config/extended/multiace/ace_mode_switch.sh \ + multiace/web/deploy/S98multiace-web + bash multiace/managed/tests/test_managed_guards.sh + python -m unittest discover -s multiace/managed/tests -v + + python multiace/managed/build_package.py \ + --output "$RUNNER_TEMP/multiace-managed-check.tar.gz" + (cd "$RUNNER_TEMP" && \ + sha256sum --check multiace-managed-check.tar.gz.sha256) + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: validate runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v4 @@ -21,6 +56,9 @@ jobs: TAG="${GITHUB_REF_NAME}" OUT="dist/release" mkdir -p "$OUT" + VERSION="$(tr -d '\r\n' < multiace/VERSION)" + test -n "$VERSION" + test "${TAG#v}" = "$VERSION" # The whole multiace/ tree at its repo layout (same as the # release recipe): the updater runs install_multiace.sh from # inside, and the installer needs deploy/, config/, tools/, @@ -28,6 +66,8 @@ jobs: tar -czf "$OUT/multiace-${TAG}.tar.gz" multiace/ (cd "$OUT" && sha256sum "multiace-${TAG}.tar.gz" \ > "multiace-${TAG}.tar.gz.sha256") + python multiace/managed/build_package.py \ + --output "$OUT/multiace-managed-${VERSION}.tar.gz" ls -la "$OUT/" - name: Publish diff --git a/README.md b/README.md index 983d55e8..41e7c8bd 100644 --- a/README.md +++ b/README.md @@ -106,28 +106,30 @@ ACE units do not read or expose the spools uid so it uses the sku field. (Spoolm - **PAXX Firmware Compatible / Installer** - Works with PAXX firmware which provides display mirroring, allowing full load/unload control from your computer / Integrated PAXX Firmware - **Clean Install/Uninstall** - One-command scripts with automatic backup and restore -### PAXX-managed package +### Platform-managed package -The repository also provides a separate package contract for PAXX firmware in -`multiace/paxx/`. PAXX selects a pinned multiACE release, verifies its SHA-256 -checksum, installs it under a versioned application directory, and activates -the selected files at startup without overwriting the stock Klipper files on -disk. PAXX owns activation, compatibility checks, persistent configuration, -updates, rollback, and the firmware-config UI. +The repository also provides a platform-neutral managed package in +`multiace/managed/`. A host platform can pin the archive and SHA-256 checksum, +install the payload under its own application root, and own activation, +persistent configuration, updates, rollback, and user-facing controls. The +managed package is intended to share the same contract across platform +integrations, including PAXX and the planned Bespok3d packaging. The managed package does not include the standalone SSH installer, uninstaller, -online updater, init scripts, or file-copy mode switch helper. When PAXX sets -`MULTIACE_MANAGED=1`, multiACE refuses self-updates and reports that PAXX owns -the update path. Ordinary standalone multiACE installation remains available. +self-updater, boot service, or file-copy mode switch helper. Those remain in +the repository for stock-firmware users who choose a standalone installation. +Managed hosts pass `MULTIACE_MANAGED=1` and the shared path variables described +in `multiace/managed/README.md`; multiACE then defers update and installation +ownership to the platform. -Build and test the package from the repository root with: +Build the managed archive and matching checksum from the repository root with: ```bash -python3 multiace/paxx/build_package.py +python3 multiace/managed/build_package.py ``` -The `paxx-package.yml` workflow runs the package tests and publishes the -versioned archive and checksum for tags named `multiace-v`. +The `release.yml` workflow publishes both the standalone source archive and +the managed archive with their checksums for each `v` release tag. ## ACE Pro 2 Support diff --git a/multiace/config/extended/multiace/ace_mode_switch.sh b/multiace/config/extended/multiace/ace_mode_switch.sh index 741106db..4800d56e 100644 --- a/multiace/config/extended/multiace/ace_mode_switch.sh +++ b/multiace/config/extended/multiace/ace_mode_switch.sh @@ -2,8 +2,8 @@ set -e if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ [ "${MULTIACE_MANAGED:-}" = "true" ] || \ - [ -e "${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" ]; then - echo "multiACE mode switching is managed by the host firmware; refusing to copy Klipper files" >&2 + [ -e "${MULTIACE_MANAGED_MARKER:-${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config}/extended/multiace/.multiace-managed}" ]; then + echo "multiACE mode switching is managed by the platform; refusing to copy Klipper files" >&2 exit 2 fi diff --git a/multiace/i18n/de.json b/multiace/i18n/de.json index f82e49aa..5dd90e79 100644 --- a/multiace/i18n/de.json +++ b/multiace/i18n/de.json @@ -281,7 +281,7 @@ "update_failed": "Update fehlgeschlagen", "update_intro": "Holt die neueste multiACE-Release und installiert sie.", "update_latest": "Verfügbar", - "update_managed": "Updates werden von der Host-Firmware verwaltet. Verwende die PAXX Firmware Config, um eine getestete multiACE-Version zu installieren.", + "update_managed": "Updates werden von der Plattform verwaltet. Verwende die multiACE-Integration deines Druckers, um eine geprüfte Version zu installieren.", "update_not_checked": "noch nicht geprüft", "update_title": "multiACE Updates" }, diff --git a/multiace/i18n/en.json b/multiace/i18n/en.json index 00de75f3..a98d79ab 100644 --- a/multiace/i18n/en.json +++ b/multiace/i18n/en.json @@ -281,7 +281,7 @@ "update_failed": "Update failed", "update_intro": "Pulls the latest multiACE release and installs it.", "update_latest": "Available", - "update_managed": "Updates are managed by the host firmware. Use PAXX Firmware Config to install a tested multiACE release.", + "update_managed": "Updates are managed by the platform. Use your printer's multiACE integration to install a tested release.", "update_not_checked": "not checked yet", "update_title": "multiACE updates" }, diff --git a/multiace/i18n/zh.json b/multiace/i18n/zh.json index 775ce396..4bdfb204 100644 --- a/multiace/i18n/zh.json +++ b/multiace/i18n/zh.json @@ -107,7 +107,7 @@ "update_intro": "拉取最新的 multiACE 版本并安装。", "update_current": "已安装", "update_latest": "可用版本", - "update_managed": "更新由主机固件管理。请使用 PAXX Firmware Config 安装经过测试的 multiACE 版本。", + "update_managed": "更新由平台管理。请使用打印机的 multiACE 集成安装经过验证的版本。", "update_not_checked": "尚未检查", "update_check_btn": "检查更新", "update_checking": "检查中...", diff --git a/multiace/install_multiace.sh b/multiace/install_multiace.sh index 235726b8..23f9ed0b 100755 --- a/multiace/install_multiace.sh +++ b/multiace/install_multiace.sh @@ -1,12 +1,10 @@ #!/bin/bash set -e -if [ "${MULTIACE_IGNORE_FIRMWARE_MANAGED:-0}" != "1" ] && \ - { [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ - [ "${MULTIACE_MANAGED:-}" = "true" ] || \ - [ -e "${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" ]; }; then - echo "multiACE is managed by the host firmware; use the host integration instead of install_multiace.sh" >&2 - echo "Set MULTIACE_IGNORE_FIRMWARE_MANAGED=1 only for an intentional standalone override." >&2 +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config}/extended/multiace/.multiace-managed}" ]; then + echo "multiACE is managed by the platform; use its integration instead of install_multiace.sh" >&2 exit 2 fi diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index 5e7e9e36..a17ed6fd 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -27,6 +27,20 @@ MULTIACE_BUILD_TAG = "eed86c9b" MULTIACE_BUNDLE_SHA1 = "40a00eb" + +def _env_flag(name): + return os.environ.get(name, '').strip().lower() in ('1', 'true', 'yes', 'on') + + +_MULTIACE_CONFIG_DIR = os.environ.get( + 'MULTIACE_CONFIG_DIR', '/home/lava/printer_data/config') +MULTIACE_MANAGED_MARKER = os.environ.get( + 'MULTIACE_MANAGED_MARKER', '').strip() or os.path.join( + _MULTIACE_CONFIG_DIR, 'extended', 'multiace', '.multiace-managed') +MULTIACE_MANAGED = ( + _env_flag('MULTIACE_MANAGED') + or os.path.exists(MULTIACE_MANAGED_MARKER)) + def _load_i18n_catalog(i18n_dir, lang): """Read /.json overlaid on en.json. Returns a dict (possibly empty if the i18n dir is missing) - caller falls back to @@ -697,8 +711,9 @@ def __init__(self, config): self.paths = _resolve_multiace_paths(config) self.host = self.paths['host_type'] - # Derived, never a config option: the web backend edits the same - # file (MULTIACE_CFG_PATH), so the two must not be able to diverge. + self._managed_by_host = MULTIACE_MANAGED + # Derived from the shared config-directory contract so Klipper and + # the web UI always write through to the same ace.cfg. self.ACE_CFG_PATH = self.paths['ace_cfg'] self.gate_status = [GATE_UNKNOWN, GATE_UNKNOWN, GATE_UNKNOWN, GATE_UNKNOWN] @@ -1433,7 +1448,11 @@ def _parse_idx_list(key): 'inbox_max_mb', 256, minval=1, maxval=4096) self._i18n_primary = config.get('i18n_dir', self.paths['i18n_primary']) - self._i18n_fallback = os.path.join(self._web_dir, 'i18n') + self._i18n_fallbacks = [] + app_dir = os.environ.get('MULTIACE_APP_DIR', '').strip() + if app_dir: + self._i18n_fallbacks.append(os.path.join(app_dir, 'i18n')) + self._i18n_fallbacks.append(os.path.join(self._web_dir, 'i18n')) self._reload_i18n_catalog() self._head_source = {0: None, 1: None, 2: None, 3: None} @@ -2347,8 +2366,9 @@ def _t(self, key, **params): def _reload_i18n_catalog(self): """(Re)load self._i18n for the current self._language. Used at startup and live by MULTIACE_SET_LANGUAGE.""" - i18n_dir = self._i18n_primary if os.path.isdir(self._i18n_primary) \ - else self._i18n_fallback + candidates = [self._i18n_primary] + self._i18n_fallbacks + i18n_dir = next((path for path in candidates if os.path.isdir(path)), + self._i18n_primary) try: self._i18n = _load_i18n_catalog(i18n_dir, self._language) except Exception as e: @@ -20732,7 +20752,13 @@ def cmd_ACE_RUN_MODE_SWITCH(self, gcmd): # multi<->head stay on the SAME ace files -> pure runtime flip, no file # swap / reboot. Only transitions involving 'normal' (stock files) run # the file switch script below. - if mode in ('multi', 'head') and current in ('multi', 'head'): + if self._managed_by_host and mode == 'normal': + raise gcmd.error( + '[multiACE] Normal mode is controlled by the host platform. ' + 'Disable the managed multiACE integration and reboot.') + + if mode in ('multi', 'head') and ( + current in ('multi', 'head') or self._managed_by_host): self.gcode.run_script_from_command( "SAVE_VARIABLE VARIABLE=ace__mode VALUE=\"'%s'\"" % mode) self._ace_mode = mode @@ -20766,6 +20792,14 @@ def cmd_ACE_RUN_MODE_SWITCH(self, gcmd): pass return + if self._managed_by_host: + # The host has already selected and activated the ACE modules. + # Managed mode changes runtime state only; it never invokes the + # standalone helper that copies over stock Klipper files. + raise gcmd.error( + '[multiACE] This mode change is not supported by the managed ' + 'runtime; the host platform controls file activation.') + save_vars = self.printer.lookup_object('save_variables') vars_path = save_vars.filename script_dir = os.path.dirname(os.path.abspath(vars_path)) @@ -20818,6 +20852,9 @@ def cmd_ACE_RUN_MODE_SWITCH(self, gcmd): _UPDATE_SCRIPT = '/home/lava/multiace_update.sh' if os.path.isfile('/home/lava/multiace_update.sh') else os.path.expanduser('~/multiace_update.sh') def _run_update_script(self, gcmd, sub_args, timeout): + if self._managed_by_host: + raise gcmd.error( + '[multiACE] Updates are managed by the platform.') if not os.path.isfile(self._UPDATE_SCRIPT): raise gcmd.error( '[multiACE] Updater script not found at %s - re-run ' diff --git a/multiace/managed/README.md b/multiace/managed/README.md new file mode 100644 index 00000000..a5fc73b1 --- /dev/null +++ b/multiace/managed/README.md @@ -0,0 +1,62 @@ +# multiACE managed package + +This package is the platform-neutral payload for host-managed multiACE +installations. It is separate from the standalone SSH installer so users on +stock firmware can continue using the existing standalone installation and +self-update process. + +## Ownership boundary + +- multiACE owns ACE behavior, Klipper modules, and the web interface. +- The host platform owns the selected multiACE version, package installation, + activation, persistent configuration, and upgrades. +- The package omits the standalone installer, uninstaller, updater, boot + service, and file-copy mode-switch helper. The source repository still + contains those standalone components. + +Managed deployments use a shared runtime contract. The host passes these +variables to both Klipper and the web service: + +- `MULTIACE_MANAGED=1` enables managed behavior. +- `MULTIACE_MANAGED_MARKER` points to the durable `.multiace-managed` marker + under the shared multiACE configuration/state directory. +- `MULTIACE_CONFIG_DIR` is the directory containing `printer.cfg` (for the + U1, `/home/lava/printer_data/config`). +- `MULTIACE_PRINTER_DATA` is the printer-data root (for the U1, + `/home/lava/printer_data`). +- `MULTIACE_APP_DIR` is the active package root, used to locate packaged + provider data such as translation catalogs. + +The two printer paths follow the shared contract in +[issue #142](https://github.com/decay71/multiACE/issues/142). multiACE derives +`extended/ace.cfg` and its persistent state from these roots; a host should not +provide a second config-file path that could diverge. + +In managed mode, install, uninstall, and self-update entry points refuse to +run. The web Update area reports that updates are managed by the platform +instead of invoking the updater. `SET_ACE_MODE MODE=normal` and the standalone +file-copy mode switch are refused; switching between multi and head remains a +runtime-only operation. + +The managed archive contains the provider's clean `ace.cfg` defaults and +macros, but omits the standalone `[save_variables]` path and self-update +wrapper macros. Each platform seeds its own persistent save-variable path and +preserves existing user values. The standalone source config remains +unchanged. + +## Archive + +The allowlisted archive is named `multiace-managed-.tar.gz` and is +published with a matching SHA256 file for each release. The host pins both the +release and checksum before installation. + +From the repository root, a package and checksum can be built with: + +```text +python3 multiace/managed/build_package.py +``` + +The `release.yml` workflow builds both the standalone source archive and this +managed archive from the same release tag, then publishes both checksums on +that release. Package construction does not replace or alter the standalone +installation path. diff --git a/multiace/paxx/build_package.py b/multiace/managed/build_package.py similarity index 96% rename from multiace/paxx/build_package.py rename to multiace/managed/build_package.py index 62dcbd29..ea5232cb 100644 --- a/multiace/paxx/build_package.py +++ b/multiace/managed/build_package.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -"""Build the allowlisted PAXX-managed multiACE archive. +"""Build the allowlisted host-managed multiACE archive. The regular multiACE repository contains an SSH installer and maintenance -scripts for standalone users. Those scripts must not be shipped as the -PAXX-managed payload, because PAXX owns installation and update lifecycle. +scripts for standalone users. Those scripts must not be shipped in a managed +payload, because the host platform owns installation and update lifecycle. """ from __future__ import annotations @@ -113,7 +113,7 @@ def _copy_payload(stage: Path, manifest: dict) -> None: destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) - manifest_destination = stage / "paxx" / "manifest.json" + manifest_destination = stage / "managed" / "manifest.json" manifest_destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(MANIFEST_PATH, manifest_destination) @@ -146,7 +146,7 @@ def build(output: Path) -> tuple[Path, str]: root_name = f"multiace-{version}" output.parent.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix="multiace-paxx-") as temporary: + with tempfile.TemporaryDirectory(prefix="multiace-managed-") as temporary: stage = Path(temporary) / root_name stage.mkdir() _copy_payload(stage, manifest) diff --git a/multiace/paxx/manifest.json b/multiace/managed/manifest.json similarity index 94% rename from multiace/paxx/manifest.json rename to multiace/managed/manifest.json index 8d865b80..06e1745e 100644 --- a/multiace/paxx/manifest.json +++ b/multiace/managed/manifest.json @@ -3,13 +3,14 @@ "package": "multiace", "version_file": "VERSION", "release": { - "asset_prefix": "multiace-paxx-", + "asset_prefix": "multiace-managed-", "archive_suffix": ".tar.gz", "checksum_suffix": ".sha256" }, "managed_config": { "path": "config/extended/ace.cfg", "remove_sections": [ + "save_variables", "gcode_macro ACEH__Update_Check", "gcode_macro ACEH__Update_Apply" ] diff --git a/multiace/paxx/tests/test_i18n_path.py b/multiace/managed/tests/test_i18n_path.py similarity index 79% rename from multiace/paxx/tests/test_i18n_path.py rename to multiace/managed/tests/test_i18n_path.py index ab6111bd..2cd60e0d 100644 --- a/multiace/paxx/tests/test_i18n_path.py +++ b/multiace/managed/tests/test_i18n_path.py @@ -55,6 +55,17 @@ def test_explicit_override_wins(self) -> None: with patch.dict(os.environ, {"MULTIACE_I18N_DIR": str(override)}): self.assertEqual(resolve_i18n_dir(module_file), override) + def test_managed_app_dir_override(self) -> None: + with tempfile.TemporaryDirectory(prefix="multiace-i18n-app-override-") as tmp: + app_root = Path(tmp) / "active-package" + catalog = app_root / "i18n" + catalog.mkdir(parents=True) + module_file = self._module_file(Path(tmp) / "fallback") + + with patch.dict(os.environ, {"MULTIACE_APP_DIR": str(app_root)}): + os.environ.pop("MULTIACE_I18N_DIR", None) + self.assertEqual(resolve_i18n_dir(module_file), catalog) + if __name__ == "__main__": unittest.main() diff --git a/multiace/paxx/tests/test_managed_guards.sh b/multiace/managed/tests/test_managed_guards.sh similarity index 100% rename from multiace/paxx/tests/test_managed_guards.sh rename to multiace/managed/tests/test_managed_guards.sh diff --git a/multiace/paxx/tests/test_package.py b/multiace/managed/tests/test_package.py similarity index 78% rename from multiace/paxx/tests/test_package.py rename to multiace/managed/tests/test_package.py index 03b41769..918157db 100644 --- a/multiace/paxx/tests/test_package.py +++ b/multiace/managed/tests/test_package.py @@ -10,17 +10,17 @@ from pathlib import Path -PAXX_DIR = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(PAXX_DIR)) +MANAGED_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(MANAGED_DIR)) import build_package # noqa: E402 class PackageTests(unittest.TestCase): def test_manifest_is_self_consistent(self) -> None: manifest = json.loads( - (PAXX_DIR / "manifest.json").read_text(encoding="utf-8")) + (MANAGED_DIR / "manifest.json").read_text(encoding="utf-8")) self.assertEqual(manifest["schema"], 1) - self.assertEqual(manifest["release"]["asset_prefix"], "multiace-paxx-") + self.assertEqual(manifest["release"]["asset_prefix"], "multiace-managed-") self.assertEqual( manifest["managed_config"]["path"], "config/extended/ace.cfg", @@ -28,6 +28,7 @@ def test_manifest_is_self_consistent(self) -> None: self.assertEqual( manifest["managed_config"]["remove_sections"], [ + "save_variables", "gcode_macro ACEH__Update_Check", "gcode_macro ACEH__Update_Apply", ], @@ -42,7 +43,7 @@ def test_manifest_is_self_consistent(self) -> None: self.assertIn("uninstall_multiace.sh", manifest["excluded_from_package"]) def test_package_contains_only_managed_payload(self) -> None: - with tempfile.TemporaryDirectory(prefix="multiace-paxx-test-") as tmp: + with tempfile.TemporaryDirectory(prefix="multiace-managed-test-") as tmp: output = Path(tmp) / "multiace.tar.gz" archive_path, digest = build_package.build(output) checksum = hashlib.sha256(archive_path.read_bytes()).hexdigest() @@ -53,19 +54,25 @@ def test_package_contains_only_managed_payload(self) -> None: member for member in archive.getmembers() if member.name.endswith("/config/extended/ace.cfg")) config_text = archive.extractfile(config_member).read().decode() - self.assertTrue(any(name.endswith("/paxx/manifest.json") for name in names)) + self.assertTrue(any(name.endswith("/managed/manifest.json") for name in names)) self.assertFalse(any(name.endswith("/install_multiace.sh") for name in names)) self.assertFalse(any(name.endswith("/uninstall_multiace.sh") for name in names)) self.assertFalse(any("/tools/" in name for name in names)) self.assertFalse(any("/deploy/" in name for name in names)) self.assertNotIn("[gcode_macro ACEH__Update_Check]", config_text) self.assertNotIn("[gcode_macro ACEH__Update_Apply]", config_text) + self.assertNotIn("[save_variables]", config_text) + self.assertIn("[ace]", config_text) + self.assertRegex(config_text, r"(?m)^ace_device_count:\s*1$") + self.assertRegex(config_text, r"(?m)^enable_ace_v2:\s*true$") source_config = (build_package.ROOT / "config/extended/ace.cfg").read_text() self.assertIn("[gcode_macro ACEH__Update_Check]", source_config) self.assertIn("[gcode_macro ACEH__Update_Apply]", source_config) + self.assertIn("[save_variables]", source_config) + self.assertRegex(source_config, r"(?m)^ace_device_count\s*:") def test_package_is_deterministic(self) -> None: - with tempfile.TemporaryDirectory(prefix="multiace-paxx-deterministic-") as tmp: + with tempfile.TemporaryDirectory(prefix="multiace-managed-deterministic-") as tmp: first = Path(tmp) / "first.tar.gz" second = Path(tmp) / "second.tar.gz" _, first_digest = build_package.build(first) diff --git a/multiace/paxx/README.md b/multiace/paxx/README.md deleted file mode 100644 index 9f96b106..00000000 --- a/multiace/paxx/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# PAXX-managed multiACE package - -This directory describes the package boundary used when PAXX manages -multiACE. It is intentionally separate from the existing SSH installer. - -## Ownership - -- multiACE owns the ACE protocols, Klipper integration modules, web interface, - and versioned release archives. -- PAXX owns activation, compatibility checks, persistent configuration, - upgrades, rollback, and the firmware-config user interface. - -PAXX must select a specific release and verify its archive checksum. It must -not install an arbitrary moving `latest` build. - -## Runtime contract - -PAXX installs the package under its own versioned application root and exposes -the selected version through a `latest` link. The PAXX hook owns the host-side -bind-mount map and applies it at startup. The stock files are never overwritten -on disk. - -The package does not contain or run the standalone SSH installer, uninstaller, -updater, init scripts, or mode-switch file-copy helper. Those operations are -owned by the host firmware integration. - -PAXX supplies the managed runtime environment to Klipper and the web service. -The exact host paths are a PAXX concern rather than provider package metadata. -The managed environment includes: - -- `MULTIACE_MANAGED=1` -- `MULTIACE_MANAGED_MARKER` -- `MULTIACE_APP_DIR` -- `MULTIACE_WEB_DIR` -- `MULTIACE_CONFIG_DIR` -- `MULTIACE_DISABLE_UPDATES=1` - -When managed mode is active, multiACE must refuse its own online updater and -must not copy ACE files over the stock Klipper tree. Updates are staged and -activated by PAXX instead. - -Read-only translation catalogs are provider data, not persistent user -configuration. Both runtimes prefer the selected package's `i18n/` directory -and retain the historical standalone layout as a fallback. An explicit -`MULTIACE_I18N_DIR` override remains available for custom deployments, but the -standard managed package does not need to copy catalogs into persistent state. - -The marker file is a durable fallback for SSH sessions or services that do not -inherit the activation hook's environment. Standalone install and uninstall -also refuse to run when the marker exists; an intentional manual override is -available with `MULTIACE_IGNORE_FIRMWARE_MANAGED=1`. - -The managed package's configuration template omits the standalone update -wrapper macros. PAXX may still perform a one-time migration of an older -persistent configuration created by an earlier managed package. - -## Building a package - -From the repository root: - -```text -python3 multiace/paxx/build_package.py -``` - -The builder uses an allowlist from `manifest.json`, writes a deterministic -versioned archive, and emits a matching `.sha256` file. The resulting archive -is safe to use as the payload for a PAXX `extended-pkg` definition; PAXX still -supplies the activation hook and persistent configuration seeding. - -The `paxx-package.yml` workflow runs the same tests and publishes the archive -and checksum as release assets for tags named `multiace-v< version >`. - -The existing `install_multiace.sh` path remains available for standalone -multiACE installations. It is not used by the PAXX-managed package. diff --git a/multiace/tools/multiace_update.sh b/multiace/tools/multiace_update.sh index 0c9fac55..88f9d12e 100644 --- a/multiace/tools/multiace_update.sh +++ b/multiace/tools/multiace_update.sh @@ -16,8 +16,8 @@ if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ [ "${MULTIACE_MANAGED:-}" = "true" ] || \ [ "${MULTIACE_DISABLE_UPDATES:-0}" = "1" ] || \ [ "${MULTIACE_DISABLE_UPDATES:-}" = "true" ] || \ - [ -e "${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" ]; then - echo "multiACE updates are managed by the host firmware; use PAXX Firmware Config" >&2 + [ -e "${MULTIACE_MANAGED_MARKER:-${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config}/extended/multiace/.multiace-managed}" ]; then + echo "multiACE updates are managed by the platform; use its multiACE integration" >&2 exit 2 fi diff --git a/multiace/uninstall_multiace.sh b/multiace/uninstall_multiace.sh index 4c3dd906..8893b501 100755 --- a/multiace/uninstall_multiace.sh +++ b/multiace/uninstall_multiace.sh @@ -1,10 +1,8 @@ #!/bin/bash -if [ "${MULTIACE_IGNORE_FIRMWARE_MANAGED:-0}" != "1" ] && \ - { [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ - [ "${MULTIACE_MANAGED:-}" = "true" ] || \ - [ -e "${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" ]; }; then - echo "multiACE is managed by the host firmware; use the host integration instead of uninstall_multiace.sh" >&2 - echo "Set MULTIACE_IGNORE_FIRMWARE_MANAGED=1 only for an intentional standalone override." >&2 +if [ "${MULTIACE_MANAGED:-0}" = "1" ] || \ + [ "${MULTIACE_MANAGED:-}" = "true" ] || \ + [ -e "${MULTIACE_MANAGED_MARKER:-${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config}/extended/multiace/.multiace-managed}" ]; then + echo "multiACE is managed by the platform; use its integration instead of uninstall_multiace.sh" >&2 exit 2 fi diff --git a/multiace/web/backend/i18n_path.py b/multiace/web/backend/i18n_path.py index f8cb65e2..4bf830dc 100644 --- a/multiace/web/backend/i18n_path.py +++ b/multiace/web/backend/i18n_path.py @@ -17,6 +17,12 @@ def resolve_i18n_dir(module_file: str | os.PathLike[str]) -> Path: if configured: return Path(configured) + app_dir = os.environ.get("MULTIACE_APP_DIR", "").strip() + if app_dir: + app_catalog = Path(app_dir).expanduser() / "i18n" + if app_catalog.is_dir(): + return app_catalog + backend_dir = Path(module_file).resolve().parent web_dir = backend_dir.parent package_dir = web_dir.parent diff --git a/multiace/web/backend/main.py b/multiace/web/backend/main.py index bd87b991..55096172 100644 --- a/multiace/web/backend/main.py +++ b/multiace/web/backend/main.py @@ -8,8 +8,12 @@ Environment variables: MOONRAKER_URL default http://127.0.0.1:7125 - MULTIACE_CFG_PATH default /home/lava/printer_data/config/extended/ace.cfg + MULTIACE_CONFIG_DIR printer_data/config directory + MULTIACE_PRINTER_DATA printer data root + MULTIACE_CFG_PATH legacy explicit config-file override MULTIACE_FRONTEND_DIR default ../frontend (relative to this file) + MULTIACE_MANAGED set to 1 when the platform owns installation/updates + MULTIACE_MANAGED_MARKER durable neutral managed-install marker path MULTIACE_WEB_VERSION default "0.1.0" """ from __future__ import annotations @@ -43,10 +47,25 @@ from pydantic import BaseModel import preflight_core +from i18n_path import resolve_i18n_dir MOONRAKER_URL = os.environ.get("MOONRAKER_URL", "http://127.0.0.1:7125") +def _env_flag(name: str) -> bool: + return os.environ.get(name, "").strip().lower() in ( + "1", "true", "yes", "on") + + +MULTIACE_MANAGED_MARKER = os.environ.get( + "MULTIACE_MANAGED_MARKER", "").strip() or os.path.join( + os.environ.get("MULTIACE_CONFIG_DIR", "/home/lava/printer_data/config"), + "extended", "multiace", ".multiace-managed") +MULTIACE_MANAGED = ( + _env_flag("MULTIACE_MANAGED") + or os.path.exists(MULTIACE_MANAGED_MARKER)) + + def _user_paths(rel: str) -> list[str]: """Ordered candidates for a path under the Klipper user's home. @@ -70,11 +89,18 @@ def _first_existing(candidates: list[str]) -> str: return candidates[0] -# Anchor on printer_data/config, which exists wherever Klipper runs. Probing -# for 'extended' or 'persistent' instead would fall back to the U1 path on -# any host that does not have those multiACE subfolders yet, which is every -# fresh generic install. -_CFG_DIR = _first_existing(_user_paths("printer_data/config")) +# These two roots are the shared host-path contract. Keep fallback discovery +# for standalone installs, but let managed platforms supply canonical paths. +_CONFIG_DIR_ENV = os.environ.get("MULTIACE_CONFIG_DIR", "").strip() +_PRINTER_DATA_ENV = os.environ.get("MULTIACE_PRINTER_DATA", "").strip() +if _PRINTER_DATA_ENV: + MULTIACE_PRINTER_DATA = os.path.abspath(_PRINTER_DATA_ENV) +elif _CONFIG_DIR_ENV: + MULTIACE_PRINTER_DATA = os.path.dirname(os.path.abspath(_CONFIG_DIR_ENV)) +else: + MULTIACE_PRINTER_DATA = _first_existing(_user_paths("printer_data")) +_CFG_DIR = os.path.abspath(_CONFIG_DIR_ENV) if _CONFIG_DIR_ENV else os.path.join( + MULTIACE_PRINTER_DATA, "config") _CFG_EXT_DIR = os.path.join(_CFG_DIR, "extended") def _resolve_cfg_path() -> str: @@ -128,10 +154,7 @@ def _resolve_cfg_path() -> str: "PC", "PC-ABS", "PVA", ] -I18N_DIR = os.environ.get( - "MULTIACE_I18N_DIR", - str((Path(__file__).resolve().parent.parent / "i18n")), -) +I18N_DIR = str(resolve_i18n_dir(__file__)) SCREEN_PROBE_URL = os.environ.get("SCREEN_PROBE_URL", "http://127.0.0.1:8092/snapshot") # 0003 mitigation: ace.py (the Klipper module) touches this tmpfs flag on @@ -1703,10 +1726,16 @@ def _read_update_cfg() -> dict[str, str]: async def _run_update_script(args: list[str], timeout: float) -> dict: """Exec the bundled multiace_update.sh and capture stdout+rc.""" - # Canonical install location first. The PAXX-baked - # /home/lava/multiace/tools/multiace_update.sh comes from the - # squashfs and never gets refreshed by online updates, so it - # serves only as a last-resort fallback. + if MULTIACE_MANAGED: + raise HTTPException( + status_code=409, + detail="multiACE updates are managed by the platform.", + ) + + # The installed updater is preferred. The legacy + # /home/lava/multiace/tools/multiace_update.sh path comes from the + # firmware image and is not refreshed by online updates, so it serves + # only as a last-resort standalone fallback. # The two U1 entries keep their exact order; the home-relative # pair is appended for a generic Klipper host and can never reorder them. update_script = None @@ -1835,9 +1864,22 @@ async def preflight_inbox_clear() -> dict: async def update_check() -> dict: return await _run_update_script(["check"], timeout=30.0) +@app.get("/api/update/status") +async def update_status() -> dict: + return { + "managed": MULTIACE_MANAGED, + "owner": "platform" if MULTIACE_MANAGED else "multiACE", + } + @app.post("/api/update/apply") async def update_apply(force: bool = False) -> dict: + if MULTIACE_MANAGED: + raise HTTPException( + status_code=409, + detail="multiACE updates are managed by the platform.", + ) + if not _DEBUG_FLAG_PATH.exists(): raise HTTPException( status_code=409, diff --git a/multiace/web/deploy/S98multiace-web b/multiace/web/deploy/S98multiace-web index 312afebb..95198254 100644 --- a/multiace/web/deploy/S98multiace-web +++ b/multiace/web/deploy/S98multiace-web @@ -23,12 +23,11 @@ LOGFILE="/home/lava/printer_data/logs/multiace_web.log" USER="root" export MOONRAKER_URL="${MOONRAKER_URL:-http://127.0.0.1:7125}" -export MULTIACE_CFG_PATH="${MULTIACE_CFG_PATH:-/home/lava/printer_data/config/extended/ace.cfg}" +export MULTIACE_PRINTER_DATA="${MULTIACE_PRINTER_DATA:-/home/lava/printer_data}" +export MULTIACE_CONFIG_DIR="${MULTIACE_CONFIG_DIR:-$MULTIACE_PRINTER_DATA/config}" export MULTIACE_FRONTEND_DIR="${MULTIACE_FRONTEND_DIR:-$MULTIACE_WEB_DIR/frontend}" -export MULTIACE_CONFIG_DIR="${MULTIACE_CONFIG_DIR:-/home/lava/printer_data/config/extended/multiace}" export MULTIACE_MANAGED="${MULTIACE_MANAGED:-0}" -export MULTIACE_MANAGED_MARKER="${MULTIACE_MANAGED_MARKER:-/oem/apps/multiace/.paxx-managed}" -export MULTIACE_DISABLE_UPDATES="${MULTIACE_DISABLE_UPDATES:-0}" +export MULTIACE_MANAGED_MARKER="${MULTIACE_MANAGED_MARKER:-$MULTIACE_CONFIG_DIR/extended/multiace/.multiace-managed}" # Every uvicorn instance we ever start matches this cmdline pattern. # /proc scan instead of pgrep: busybox builds here don't guarantee pgrep. From 090d54da9a7aa52f72ed9e2049306f330827e2d0 Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Tue, 29 Sep 2026 22:04:27 -0600 Subject: [PATCH 6/7] Add manual test release workflow --- .github/release.yml | 26 ++++ .github/workflows/release.yml | 82 +++++++++-- multiace/managed/README.md | 31 ++++- .../tests/test_updater_asset_selection.sh | 131 ++++++++++++++++++ multiace/tools/multiace_update.sh | 67 ++++++--- 5 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 .github/release.yml create mode 100644 multiace/managed/tests/test_updater_asset_selection.sh diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..9ceba3a2 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,26 @@ +changelog: + exclude: + labels: + - ignore-for-release + - skip-changelog + categories: + - title: Breaking changes + labels: + - breaking-change + - breaking + - title: Features + labels: + - enhancement + - feature + - title: Bug fixes + labels: + - bug + - bugfix + - title: Maintenance and dependencies + labels: + - maintenance + - dependencies + - chore + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e2c1932..39118424 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,18 @@ name: Build and test multiACE packages on: push: + branches: + - '**' tags: - 'v*' pull_request: + workflow_dispatch: + inputs: + source_ref: + description: Branch, tag, or commit SHA to build for a test prerelease + required: true + default: main + type: string permissions: contents: read @@ -12,9 +21,17 @@ permissions: jobs: validate: runs-on: ubuntu-latest + outputs: + source_sha: ${{ steps.source.outputs.sha }} steps: - - name: Checkout + - name: Checkout source uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref || github.sha }} + + - name: Record exact source commit + id: source + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Validate managed package sources run: | @@ -33,6 +50,7 @@ jobs: multiace/config/extended/multiace/ace_mode_switch.sh \ multiace/web/deploy/S98multiace-web bash multiace/managed/tests/test_managed_guards.sh + bash multiace/managed/tests/test_updater_asset_selection.sh python -m unittest discover -s multiace/managed/tests -v python multiace/managed/build_package.py \ @@ -41,39 +59,75 @@ jobs: sha256sum --check multiace-managed-check.tar.gz.sha256) release: - if: startsWith(github.ref, 'refs/tags/v') + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' needs: validate runs-on: ubuntu-latest permissions: contents: write steps: - - name: Checkout + - name: Checkout validated source uses: actions/checkout@v4 + with: + ref: ${{ needs.validate.outputs.source_sha }} - - name: Compose tarball + - name: Build release packages and notes + id: package + shell: bash run: | set -euo pipefail - TAG="${GITHUB_REF_NAME}" OUT="dist/release" mkdir -p "$OUT" VERSION="$(tr -d '\r\n' < multiace/VERSION)" + SOURCE_SHA="$(git rev-parse HEAD)" test -n "$VERSION" - test "${TAG#v}" = "$VERSION" - # The whole multiace/ tree at its repo layout (same as the - # release recipe): the updater runs install_multiace.sh from - # inside, and the installer needs deploy/, config/, tools/, - # i18n/ and web/ next to it. + + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + TAG="v${VERSION}-test.${GITHUB_RUN_ID}" + RELEASE_NAME="multiACE ${VERSION} test build (${GITHUB_RUN_NUMBER})" + cat > "$OUT/release-prefix.md" < "$OUT/release-prefix.md" < "multiace-${TAG}.tar.gz.sha256") python multiace/managed/build_package.py \ --output "$OUT/multiace-managed-${VERSION}.tar.gz" + + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "name=$RELEASE_NAME" >> "$GITHUB_OUTPUT" ls -la "$OUT/" - - name: Publish + - name: Publish stable release or test prerelease uses: softprops/action-gh-release@v2 with: - files: | - dist/release/multiace-*.tar.gz - dist/release/multiace-*.tar.gz.sha256 + tag_name: ${{ steps.package.outputs.tag }} + target_commitish: ${{ needs.validate.outputs.source_sha }} + name: ${{ steps.package.outputs.name }} + body_path: dist/release/release-prefix.md generate_release_notes: true + prerelease: ${{ github.event_name == 'workflow_dispatch' }} + fail_on_unmatched_files: true + files: | + dist/release/*.tar.gz + dist/release/*.tar.gz.sha256 diff --git a/multiace/managed/README.md b/multiace/managed/README.md index a5fc73b1..abd825dd 100644 --- a/multiace/managed/README.md +++ b/multiace/managed/README.md @@ -9,7 +9,8 @@ self-update process. - multiACE owns ACE behavior, Klipper modules, and the web interface. - The host platform owns the selected multiACE version, package installation, - activation, persistent configuration, and upgrades. + activation, persistent configuration, updates, rollback, and user-facing + controls. - The package omits the standalone installer, uninstaller, updater, boot service, and file-copy mode-switch helper. The source repository still contains those standalone components. @@ -56,7 +57,27 @@ From the repository root, a package and checksum can be built with: python3 multiace/managed/build_package.py ``` -The `release.yml` workflow builds both the standalone source archive and this -managed archive from the same release tag, then publishes both checksums on -that release. Package construction does not replace or alter the standalone -installation path. +## Release and test-build flow + +Every branch push and pull request runs validation and builds a check package; +those runs do not publish release assets. + +After this workflow is present on the repository's default branch, a maintainer +can manually run **Build and test multiACE packages** with a `source_ref` +(branch, tag, or commit SHA). That publishes a uniquely tagged GitHub +prerelease containing both the standalone archive and the managed archive, +each with its SHA-256 sidecar. The release notes identify the exact source +commit and clearly mark the build as a test prerelease. This is intended for +deliberate compatibility testing and is not the stable update channel. + +Stable releases keep the existing tag-driven process: pushing +`v` runs validation, verifies that the tag matches `multiace/VERSION`, +and automatically publishes both package types with their checksums. GitHub +generates the change list using the categories in `.github/release.yml`; no +hand-maintained changelog is required. + +The standalone updater selects only the release's exact +`multiace-.tar.gz` archive and matching checksum. It will fail closed if +either asset is missing or if checksum verification fails. The managed archive +is never a candidate for the standalone installer. Package construction and +publishing do not replace or alter the standalone installation path. diff --git a/multiace/managed/tests/test_updater_asset_selection.sh b/multiace/managed/tests/test_updater_asset_selection.sh new file mode 100644 index 00000000..3935fe7c --- /dev/null +++ b/multiace/managed/tests/test_updater_asset_selection.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -euo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "$0")" && pwd)" +UPDATER="$TEST_DIR/../../tools/multiace_update.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +mkdir -p "$TMP/bin" + +cat > "$TMP/bin/curl" <<'CURL' +#!/bin/sh +printf '%s\n' "${MULTIACE_TEST_RELEASE_JSON:?missing test release JSON}" +CURL +chmod +x "$TMP/bin/curl" + +run_assets() { + local fixture="$1" + env \ + PATH="$TMP/bin:$PATH" \ + MULTIACE_TEST_RELEASE_JSON="$fixture" \ + MULTIACE_UPDATE_REPO="fixture/multiACE" \ + MULTIACE_UPDATE_PRERELEASE=1 \ + MULTIACE_MANAGED=0 \ + MULTIACE_DISABLE_UPDATES=0 \ + MULTIACE_MANAGED_MARKER="$TMP/no-managed-marker" \ + MULTIACE_CONFIG_DIR="$TMP/config" \ + sh "$UPDATER" assets +} + +RELEASE_JSON="$(cat <<'JSON' +[ + { + "tag_name": "v1.11b-test.123456", + "assets": [ + { + "name": "multiace-managed-1.11b.tar.gz", + "browser_download_url": "https://example.invalid/multiace-managed-1.11b.tar.gz" + }, + { + "name": "multiace-managed-1.11b.tar.gz.sha256", + "browser_download_url": "https://example.invalid/multiace-managed-1.11b.tar.gz.sha256" + }, + { + "name": "multiace-v1.11b-test.123456.tar.gz", + "browser_download_url": "https://example.invalid/multiace-v1.11b-test.123456.tar.gz" + }, + { + "name": "multiace-v1.11b-test.123456.tar.gz.sha256", + "browser_download_url": "https://example.invalid/multiace-v1.11b-test.123456.tar.gz.sha256" + } + ] + } +] +JSON +)" + +OUTPUT="$(run_assets "$RELEASE_JSON")" +case "$OUTPUT" in + *"TARBALL_URL=https://example.invalid/multiace-v1.11b-test.123456.tar.gz"*);; + *) + printf 'FAIL: updater did not select the exact standalone archive:\n%s\n' "$OUTPUT" >&2 + exit 1 + ;; +esac +case "$OUTPUT" in + *"SHA_URL=https://example.invalid/multiace-v1.11b-test.123456.tar.gz.sha256"*);; + *) + printf 'FAIL: updater did not select the matching checksum:\n%s\n' "$OUTPUT" >&2 + exit 1 + ;; +esac + +MANAGED_ONLY_JSON="$(cat <<'JSON' +[ + { + "tag_name": "v1.11b-test.123456", + "assets": [ + { + "name": "multiace-managed-1.11b.tar.gz", + "browser_download_url": "https://example.invalid/multiace-managed-1.11b.tar.gz" + }, + { + "name": "multiace-managed-1.11b.tar.gz.sha256", + "browser_download_url": "https://example.invalid/multiace-managed-1.11b.tar.gz.sha256" + } + ] + } +] +JSON +)" +if OUTPUT="$(run_assets "$MANAGED_ONLY_JSON" 2>&1)"; then + printf 'FAIL: updater accepted a managed archive without the standalone asset\n%s\n' "$OUTPUT" >&2 + exit 1 +fi +case "$OUTPUT" in + *"missing the exact standalone asset"*) ;; + *) + printf 'FAIL: missing-asset error was unclear:\n%s\n' "$OUTPUT" >&2 + exit 1 + ;; +esac + + + +NO_CHECKSUM_JSON="$(cat <<'JSON' +[ + { + "tag_name": "v1.11b-test.123456", + "assets": [ + { + "name": "multiace-v1.11b-test.123456.tar.gz", + "browser_download_url": "https://example.invalid/multiace-v1.11b-test.123456.tar.gz" + } + ] + } +] +JSON +)" +if OUTPUT="$(run_assets "$NO_CHECKSUM_JSON" 2>&1)"; then + printf 'FAIL: updater accepted a standalone archive without its checksum\n%s\n' "$OUTPUT" >&2 + exit 1 +fi +case "$OUTPUT" in + *"missing the matching checksum"*) ;; + *) + printf 'FAIL: missing-checksum error was unclear:\n%s\n' "$OUTPUT" >&2 + exit 1 + ;; +esac + +printf 'Standalone release asset selection tests passed\n' diff --git a/multiace/tools/multiace_update.sh b/multiace/tools/multiace_update.sh index 88f9d12e..fb0ffc94 100644 --- a/multiace/tools/multiace_update.sh +++ b/multiace/tools/multiace_update.sh @@ -1,6 +1,7 @@ #!/bin/sh -# Usage: multiace_update.sh [check | apply [--force] [--keep-web] [--install-web] | --help] +# Usage: multiace_update.sh [check | assets | apply [--force] [--keep-web] [--install-web] | --help] # check compare the installed version with the latest release +# assets print the exact standalone archive and checksum selected # apply download and install the latest release # --force reinstall even when already on latest / older release # --keep-web leave the web UI untouched @@ -144,8 +145,18 @@ resolve_latest() { echo "ERROR: could not parse latest tag from $API" >&2 return 1 fi - TARBALL_URL="$(echo "$JSON" | json_asset_urls | grep -E 'multiace-.*\.tar\.gz$' | head -1)" - SHA_URL="$(echo "$JSON" | json_asset_urls | grep -E 'multiace-.*\.tar\.gz\.sha256$' | head -1)" + TARBALL_NAME="multiace-${LATEST}.tar.gz" + SHA_NAME="${TARBALL_NAME}.sha256" + TARBALL_URL="$(printf '%s\n' "$JSON" | json_asset_url "$TARBALL_NAME" || true)" + SHA_URL="$(printf '%s\n' "$JSON" | json_asset_url "$SHA_NAME" || true)" + if [ -z "$TARBALL_URL" ]; then + echo "ERROR: release $LATEST is missing the exact standalone asset $TARBALL_NAME" >&2 + return 1 + fi + if [ -z "$SHA_URL" ]; then + echo "ERROR: release $LATEST is missing the matching checksum $SHA_NAME" >&2 + return 1 + fi return 0 } json_field() { @@ -154,6 +165,15 @@ json_field() { json_asset_urls() { sed -n 's/.*"browser_download_url":[[:space:]]*"\([^"]*\)".*/\1/p' } +json_asset_url() { + expected="$1" + json_asset_urls | while IFS= read -r url; do + if [ "${url##*/}" = "$expected" ]; then + printf '%s\n' "$url" + break + fi + done +} normalize_version() { echo "${1:-}" | sed -n 's/^v\?\([0-9][0-9.]*[a-z]\?\).*/\1/p' } @@ -184,6 +204,12 @@ cmd_check() { fi return 0 } +cmd_assets() { + resolve_latest || return 1 + printf 'STATUS: release=%s\n' "$LATEST" + printf 'TARBALL_URL=%s\n' "$TARBALL_URL" + printf 'SHA_URL=%s\n' "$SHA_URL" +} cmd_apply() { FORCE=0 KEEP_WEB=0 @@ -246,7 +272,11 @@ cmd_apply() { fi fi if [ -z "$TARBALL_URL" ]; then - echo "ERROR: release $LATEST has no multiace-*.tar.gz asset" >&2 + echo "ERROR: release $LATEST has no exact standalone archive asset" >&2 + return 1 + fi + if [ -z "$SHA_URL" ]; then + echo "ERROR: release $LATEST has no matching SHA-256 checksum asset" >&2 return 1 fi echo "STATUS: downloading tarball=$TARBALL_URL" @@ -257,23 +287,18 @@ cmd_apply() { echo "ERROR: tarball download failed from $TARBALL_URL" >&2 return 1 } - if [ -n "$SHA_URL" ]; then - echo "STATUS: verifying sha256" - if fetch_url "$SHA_URL" > "$TARBALL.sha256"; then - EXPECTED="$(awk '{print $1}' "$TARBALL.sha256" | head -1)" - ACTUAL="$(sha256sum "$TARBALL" | awk '{print $1}')" - if [ -z "$EXPECTED" ] || [ "$EXPECTED" != "$ACTUAL" ]; then - echo "ERROR: sha256 mismatch - expected $EXPECTED got $ACTUAL" >&2 - return 1 - fi - echo "STATUS: sha256_ok" - else - echo "WARN: sha256 download failed - skipping verification (tarball came over TLS)" >&2 - echo "STATUS: sha256_skipped (sha256 download failed)" - fi - else - echo "STATUS: sha256_skipped (no .sha256 asset on release - trust GitHub TLS)" + echo "STATUS: verifying sha256" + if ! fetch_url "$SHA_URL" > "$TARBALL.sha256"; then + echo "ERROR: checksum download failed from $SHA_URL" >&2 + return 1 + fi + EXPECTED="$(awk '{print $1}' "$TARBALL.sha256" | head -1)" + ACTUAL="$(sha256sum "$TARBALL" | awk '{print $1}')" + if [ -z "$EXPECTED" ] || [ "$EXPECTED" != "$ACTUAL" ]; then + echo "ERROR: sha256 mismatch - expected $EXPECTED got $ACTUAL" >&2 + return 1 fi + echo "STATUS: sha256_ok" echo "STATUS: extracting" mkdir "$TMP/extracted" tar xzf "$TARBALL" -C "$TMP/extracted" @@ -298,6 +323,8 @@ cmd_apply() { case "${1:-check}" in check) shift; cmd_check "$@" ;; + assets) + shift; cmd_assets "$@" ;; apply) shift; cmd_apply "$@" ;; -h|--help|help) From 0d109576cc6138e2ee9b17e76674abc51e0ca954 Mon Sep 17 00:00:00 2001 From: Tareku99 Date: Wed, 30 Sep 2026 14:55:54 -0600 Subject: [PATCH 7/7] Fix managed package payload and release workflow --- .github/workflows/release.yml | 94 +++++++++++++++----------- README.md | 29 +------- multiace/managed/README.md | 11 +-- multiace/managed/build_package.py | 9 ++- multiace/managed/manifest.json | 5 +- multiace/managed/tests/test_package.py | 11 ++- 6 files changed, 86 insertions(+), 73 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39118424..9cb3c4bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: inputs: source_ref: - description: Branch, tag, or commit SHA to build for a test prerelease + description: Branch, tag, or commit SHA to build as a test artifact (no release is published) required: true default: main type: string @@ -37,8 +37,10 @@ jobs: run: | python -m py_compile \ multiace/klipper/extras/ace.py \ + multiace/klipper/extras/ace_gen1_tunnel.py \ multiace/web/backend/i18n_path.py \ multiace/web/backend/main.py \ + multiace/tools/post_process_virtual_toolheads.py \ multiace/managed/build_package.py \ multiace/managed/tests/test_package.py \ multiace/managed/tests/test_i18n_path.py @@ -58,20 +60,17 @@ jobs: (cd "$RUNNER_TEMP" && \ sha256sum --check multiace-managed-check.tar.gz.sha256) - release: + package: if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' needs: validate runs-on: ubuntu-latest - permissions: - contents: write steps: - name: Checkout validated source uses: actions/checkout@v4 with: ref: ${{ needs.validate.outputs.source_sha }} - - name: Build release packages and notes - id: package + - name: Build packages and metadata shell: bash run: | set -euo pipefail @@ -82,51 +81,70 @@ jobs: test -n "$VERSION" if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - TAG="v${VERSION}-test.${GITHUB_RUN_ID}" - RELEASE_NAME="multiACE ${VERSION} test build (${GITHUB_RUN_NUMBER})" - cat > "$OUT/release-prefix.md" < "$OUT/release-prefix.md" < "multiace-${TAG}.tar.gz.sha256") + tar -czf "$OUT/multiace-$PACKAGE_TAG.tar.gz" multiace/ + (cd "$OUT" && sha256sum "multiace-$PACKAGE_TAG.tar.gz" \ + > "multiace-$PACKAGE_TAG.tar.gz.sha256") python multiace/managed/build_package.py \ - --output "$OUT/multiace-managed-${VERSION}.tar.gz" - - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "name=$RELEASE_NAME" >> "$GITHUB_OUTPUT" + --output "$OUT/multiace-managed-$VERSION.tar.gz" + + { + printf 'Build kind: %s\n' "$BUILD_KIND" + printf 'Version: %s\n' "$VERSION" + printf 'Source commit: %s\n' "$SOURCE_SHA" + printf 'Workflow run: https://github.com/%s/actions/runs/%s\n' \ + "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID" + } > "$OUT/build-info.txt" + + if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ]; then + { + printf '## Release packages\n\n' + printf -- '- Standalone installer: multiace-%s.tar.gz and its SHA-256 checksum.\n' "$TAG" + printf -- '- Platform-managed payload: multiace-managed-%s.tar.gz and its SHA-256 checksum.\n\n' "$VERSION" + printf 'Built from source commit %s (https://github.com/%s/commit/%s).\n' \ + "$SOURCE_SHA" "$GITHUB_REPOSITORY" "$SOURCE_SHA" + } > "$OUT/release-prefix.md" + fi ls -la "$OUT/" - - name: Publish stable release or test prerelease + - name: Upload package artifacts + uses: actions/upload-artifact@v4 + with: + name: multiace-packages-${{ github.run_id }} + path: dist/release/* + if-no-files-found: error + retention-days: 14 + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: [validate, package] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download validated packages + uses: actions/download-artifact@v4 + with: + name: multiace-packages-${{ github.run_id }} + path: dist/release + + - name: Publish stable release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ steps.package.outputs.tag }} + tag_name: ${{ github.ref_name }} target_commitish: ${{ needs.validate.outputs.source_sha }} - name: ${{ steps.package.outputs.name }} + name: ${{ github.ref_name }} body_path: dist/release/release-prefix.md generate_release_notes: true - prerelease: ${{ github.event_name == 'workflow_dispatch' }} fail_on_unmatched_files: true files: | dist/release/*.tar.gz diff --git a/README.md b/README.md index 41e7c8bd..d6c7ca25 100644 --- a/README.md +++ b/README.md @@ -103,33 +103,8 @@ ACE units do not read or expose the spools uid so it uses the sku field. (Spoolm - **Online Updates ** - **Auto-Load** - Load all filaments autmatically, Parallel preload in bg mode - **RFID Handling** - Automatic RFID detection and display across ACE switches -- **PAXX Firmware Compatible / Installer** - Works with PAXX firmware which provides display mirroring, allowing full load/unload control from your computer / Integrated PAXX Firmware -- **Clean Install/Uninstall** - One-command scripts with automatic backup and restore - -### Platform-managed package - -The repository also provides a platform-neutral managed package in -`multiace/managed/`. A host platform can pin the archive and SHA-256 checksum, -install the payload under its own application root, and own activation, -persistent configuration, updates, rollback, and user-facing controls. The -managed package is intended to share the same contract across platform -integrations, including PAXX and the planned Bespok3d packaging. - -The managed package does not include the standalone SSH installer, uninstaller, -self-updater, boot service, or file-copy mode switch helper. Those remain in -the repository for stock-firmware users who choose a standalone installation. -Managed hosts pass `MULTIACE_MANAGED=1` and the shared path variables described -in `multiace/managed/README.md`; multiACE then defers update and installation -ownership to the platform. - -Build the managed archive and matching checksum from the repository root with: - -```bash -python3 multiace/managed/build_package.py -``` - -The `release.yml` workflow publishes both the standalone source archive and -the managed archive with their checksums for each `v` release tag. +- **PAXX Firmware Compatible / Installer** - Works with PAXX firmware which provides display mirroring, allowing full load/unload control from your computer / Integrated PAXX Firmware +- **Clean Install/Uninstall** - One-command scripts with automatic backup and restore ## ACE Pro 2 Support diff --git a/multiace/managed/README.md b/multiace/managed/README.md index abd825dd..2c2c1f37 100644 --- a/multiace/managed/README.md +++ b/multiace/managed/README.md @@ -64,11 +64,12 @@ those runs do not publish release assets. After this workflow is present on the repository's default branch, a maintainer can manually run **Build and test multiACE packages** with a `source_ref` -(branch, tag, or commit SHA). That publishes a uniquely tagged GitHub -prerelease containing both the standalone archive and the managed archive, -each with its SHA-256 sidecar. The release notes identify the exact source -commit and clearly mark the build as a test prerelease. This is intended for -deliberate compatibility testing and is not the stable update channel. +(branch, tag, or commit SHA). The workflow uploads both archives, their SHA-256 +sidecars, and build metadata as a 14-day Actions artifact; it does not create a +tag or GitHub Release. Anyone with repository read access can download the +artifact from its workflow run, but these files are not release assets and are +not offered by the standalone updater. This is intended for deliberate +compatibility testing, not as an update channel. Stable releases keep the existing tag-driven process: pushing `v` runs validation, verifies that the tag matches `multiace/VERSION`, diff --git a/multiace/managed/build_package.py b/multiace/managed/build_package.py index ea5232cb..9622af75 100644 --- a/multiace/managed/build_package.py +++ b/multiace/managed/build_package.py @@ -93,7 +93,14 @@ def _copy_payload(stage: Path, manifest: dict) -> None: raise FileNotFoundError(f"payload entry does not exist: {relative}") destination = stage / relative if source.is_dir(): - shutil.copytree(source, destination) + # Validation compiles/imports provider modules before packaging. + # Do not ship host-specific Python bytecode or make the archive + # depend on which tests ran first. + shutil.copytree( + source, + destination, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"), + ) else: destination.parent.mkdir(parents=True, exist_ok=True) if relative == manifest["managed_config"]["path"]: diff --git a/multiace/managed/manifest.json b/multiace/managed/manifest.json index 06e1745e..2336f8d8 100644 --- a/multiace/managed/manifest.json +++ b/multiace/managed/manifest.json @@ -23,11 +23,13 @@ "klipper/extras/ace_bg_swap.py", "klipper/extras/ace_tipform.py", "klipper/extras/ace_rc522.py", + "klipper/extras/ace_gen1_tunnel.py", "klipper/extras/filament_feed_ace.py", "klipper/extras/filament_switch_sensor_ace.py", "klipper/kinematics/extruder_ace.py", "config/extended/ace.cfg", "config/extended/multiace/ace_vars.cfg", + "tools/post_process_virtual_toolheads.py", "i18n/", "web/backend/", "web/frontend/" @@ -35,7 +37,8 @@ "excluded_from_package": [ "install_multiace.sh", "uninstall_multiace.sh", - "tools/", + "tools/multiace_update.sh", + "tools/merge_ace_cfg.py", "deploy/", "config/extended/multiace/ace_mode_switch.sh", "web/deploy/" diff --git a/multiace/managed/tests/test_package.py b/multiace/managed/tests/test_package.py index 918157db..21df3344 100644 --- a/multiace/managed/tests/test_package.py +++ b/multiace/managed/tests/test_package.py @@ -41,6 +41,10 @@ def test_manifest_is_self_consistent(self) -> None: (build_package.ROOT / relative.rstrip("/")).exists(), relative) self.assertIn("install_multiace.sh", manifest["excluded_from_package"]) self.assertIn("uninstall_multiace.sh", manifest["excluded_from_package"]) + self.assertIn("klipper/extras/ace_gen1_tunnel.py", manifest["payload"]) + self.assertIn("tools/post_process_virtual_toolheads.py", manifest["payload"]) + self.assertIn("tools/multiace_update.sh", manifest["excluded_from_package"]) + self.assertIn("tools/merge_ace_cfg.py", manifest["excluded_from_package"]) def test_package_contains_only_managed_payload(self) -> None: with tempfile.TemporaryDirectory(prefix="multiace-managed-test-") as tmp: @@ -57,8 +61,13 @@ def test_package_contains_only_managed_payload(self) -> None: self.assertTrue(any(name.endswith("/managed/manifest.json") for name in names)) self.assertFalse(any(name.endswith("/install_multiace.sh") for name in names)) self.assertFalse(any(name.endswith("/uninstall_multiace.sh") for name in names)) - self.assertFalse(any("/tools/" in name for name in names)) + self.assertTrue(any(name.endswith("/klipper/extras/ace_gen1_tunnel.py") for name in names)) + self.assertTrue(any(name.endswith("/tools/post_process_virtual_toolheads.py") for name in names)) + self.assertFalse(any(name.endswith("/tools/multiace_update.sh") for name in names)) + self.assertFalse(any(name.endswith("/tools/merge_ace_cfg.py") for name in names)) self.assertFalse(any("/deploy/" in name for name in names)) + self.assertFalse(any("/__pycache__/" in name for name in names)) + self.assertFalse(any(name.endswith((".pyc", ".pyo")) for name in names)) self.assertNotIn("[gcode_macro ACEH__Update_Check]", config_text) self.assertNotIn("[gcode_macro ACEH__Update_Apply]", config_text) self.assertNotIn("[save_variables]", config_text)