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 d6c7ca25..41e7c8bd 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,33 @@ 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 + +### 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. ## 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..4800d56e 100644 --- a/multiace/config/extended/multiace/ace_mode_switch.sh +++ b/multiace/config/extended/multiace/ace_mode_switch.sh @@ -1,5 +1,12 @@ #!/bin/bash set -e +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 mode switching is managed by the platform; 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..5dd90e79 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 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 dfc313b9..a98d79ab 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 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 9f21726b..4bdfb204 100644 --- a/multiace/i18n/zh.json +++ b/multiace/i18n/zh.json @@ -107,6 +107,7 @@ "update_intro": "拉取最新的 multiACE 版本并安装。", "update_current": "已安装", "update_latest": "可用版本", + "update_managed": "更新由平台管理。请使用打印机的 multiACE 集成安装经过验证的版本。", "update_not_checked": "尚未检查", "update_check_btn": "检查更新", "update_checking": "检查中...", diff --git a/multiace/install_multiace.sh b/multiace/install_multiace.sh index 9a25cb7e..23f9ed0b 100755 --- a/multiace/install_multiace.sh +++ b/multiace/install_multiace.sh @@ -1,5 +1,13 @@ #!/bin/bash set -e + +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 + INSTALL_WEB=0 KEEP_CONFIG=0 for arg in "$@"; do 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/managed/build_package.py b/multiace/managed/build_package.py new file mode 100644 index 00000000..ea5232cb --- /dev/null +++ b/multiace/managed/build_package.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""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 in a managed +payload, because the host platform 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") + 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: + 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) + 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") + source = ROOT / relative + destination = stage / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + manifest_destination = stage / "managed" / "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-managed-") 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/managed/manifest.json b/multiace/managed/manifest.json new file mode 100644 index 00000000..06e1745e --- /dev/null +++ b/multiace/managed/manifest.json @@ -0,0 +1,43 @@ +{ + "schema": 1, + "package": "multiace", + "version_file": "VERSION", + "release": { + "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" + ] + }, + "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/" + ], + "excluded_from_package": [ + "install_multiace.sh", + "uninstall_multiace.sh", + "tools/", + "deploy/", + "config/extended/multiace/ace_mode_switch.sh", + "web/deploy/" + ] +} diff --git a/multiace/managed/tests/test_i18n_path.py b/multiace/managed/tests/test_i18n_path.py new file mode 100644 index 00000000..2cd60e0d --- /dev/null +++ b/multiace/managed/tests/test_i18n_path.py @@ -0,0 +1,71 @@ +#!/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) + + 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/managed/tests/test_managed_guards.sh b/multiace/managed/tests/test_managed_guards.sh new file mode 100644 index 00000000..105ecf9f --- /dev/null +++ b/multiace/managed/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/managed/tests/test_package.py b/multiace/managed/tests/test_package.py new file mode 100644 index 00000000..918157db --- /dev/null +++ b/multiace/managed/tests/test_package.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import json +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path + + +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( + (MANAGED_DIR / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(manifest["schema"], 1) + self.assertEqual(manifest["release"]["asset_prefix"], "multiace-managed-") + self.assertEqual( + manifest["managed_config"]["path"], + "config/extended/ace.cfg", + ) + self.assertEqual( + manifest["managed_config"]["remove_sections"], + [ + "save_variables", + "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_package_contains_only_managed_payload(self) -> None: + 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() + 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("/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-managed-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..88f9d12e 100644 --- a/multiace/tools/multiace_update.sh +++ b/multiace/tools/multiace_update.sh @@ -11,6 +11,16 @@ # 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" ] || \ + [ -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 + 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..8893b501 100755 --- a/multiace/uninstall_multiace.sh +++ b/multiace/uninstall_multiace.sh @@ -1,4 +1,11 @@ #!/bin/bash +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 + sed -i 's/\r$//' "$0" 2>/dev/null set -e HOME_DIR="/home/lava" diff --git a/multiace/web/backend/i18n_path.py b/multiace/web/backend/i18n_path.py new file mode 100644 index 00000000..4bf830dc --- /dev/null +++ b/multiace/web/backend/i18n_path.py @@ -0,0 +1,34 @@ +"""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) + + 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 + candidates = (package_dir / "i18n", web_dir / "i18n") + + for candidate in candidates: + if candidate.is_dir(): + return candidate + return candidates[0] 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 1a597d8d..95198254 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" @@ -22,8 +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_FRONTEND_DIR="${MULTIACE_FRONTEND_DIR:-/home/lava/multiace_web/frontend}" +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_MANAGED="${MULTIACE_MANAGED:-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. 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') }} +

+