diff --git a/docs/index.md b/docs/index.md index fbca5f1..40ec6f7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,6 +106,10 @@ features: spruce experimental +
+ Allium + experimental +
diff --git a/docs/introduction.md b/docs/introduction.md index 29bc111..a40ecb5 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -4,7 +4,7 @@ ## What is RAOfflineProxy? -RAOfflineProxy is a local proxy that acts between supported emulators and [RetroAchievements](https://retroachievements.org/) (RA). It currently supports RetroArch, Dolphin, PPSSPP, ARMSX2, WatermelonDS, and Flycast on **Android**, and **KNULLI**, **Onion**, **muOS**, and **ROCKNIX** for Linux. +RAOfflineProxy is a local proxy that acts between supported emulators and [RetroAchievements](https://retroachievements.org/) (RA). It currently supports RetroArch, Dolphin, PPSSPP, ARMSX2, WatermelonDS, and Flycast on **Android**, and **KNULLI**, **Onion**, **muOS**, **ROCKNIX**, **spruce**, and **Allium** for Linux. Their achievement systems talk directly to RetroAchievements over the internet. This works great online, but the moment your connection drops, achievements stop unlocking and games may fail to load their achievement lists at all. diff --git a/docs/linux-support/cfg-patching.md b/docs/linux-support/cfg-patching.md index 434ac5d..4d4f1f8 100644 --- a/docs/linux-support/cfg-patching.md +++ b/docs/linux-support/cfg-patching.md @@ -54,6 +54,16 @@ spruce launches RetroArch with its own per-device config rather than the usual o spruce rewrites the account and achievement keys in this file on every game launch from its own RetroAchievements settings, so starting the proxy also switches spruce's achievements mode to `Softcore`. Stopping the proxy restores whatever it was before. +== Allium + +Allium launches stock RetroArch directly, so the usual config is the one that gets patched: + +```text +/RetroArch/.retroarch/retroarch.cfg +``` + +Allium has no RetroAchievements settings of its own, so nothing rewrites these keys behind the proxy: RetroArch's own achievements login is the single source of your account details. + ::: ## What Gets Patched diff --git a/docs/linux-support/index.md b/docs/linux-support/index.md index 697a68a..1b606bc 100644 --- a/docs/linux-support/index.md +++ b/docs/linux-support/index.md @@ -11,7 +11,7 @@ The Linux version is for handheld Linux devices where you want the same basic of - Keep earning casual achievements while offline - Let queued awards sync later when you reconnect -Use the `KNULLI`, `Onion`, `muOS`, `ROCKNIX`, and `spruce` tabs throughout the Linux section to switch target-specific instructions. +Use the `KNULLI`, `Onion`, `muOS`, `ROCKNIX`, `spruce`, and `Allium` tabs throughout the Linux section to switch target-specific instructions. ## Supported Targets @@ -20,6 +20,7 @@ Use the `KNULLI`, `Onion`, `muOS`, `ROCKNIX`, and `spruce` tabs throughout the L - muOS - ROCKNIX - spruce (experimental) +- Allium (experimental) ## Specifics @@ -55,6 +56,12 @@ spruce support is experimental. It is currently compatible with [spruce 4.3.x](h The bundled Python runtime and SDL libraries are 32-bit ARM builds, which covers spruce's 32-bit devices: the Miyoo Mini family and the A30. spruce's other devices (TrimUI, Anbernic, Miyoo Flip, GKD Pixel 2, MagicX Zero 28) are 64-bit and would need an aarch64 build, which this bundle does not ship. +== Allium + +Allium support is experimental. It is currently compatible with [Allium v1.0.1](https://github.com/goweiwen/Allium/releases), tested on a Miyoo Mini Plus. + +Allium is a Miyoo-only launcher, so this target covers exactly the devices Allium itself runs on: the Miyoo Mini, Mini Plus and Mini Flip. + ::: ## Important Notes diff --git a/docs/linux-support/installation.md b/docs/linux-support/installation.md index cd19b12..4849ba6 100644 --- a/docs/linux-support/installation.md +++ b/docs/linux-support/installation.md @@ -39,6 +39,13 @@ Before using `RAOfflineProxy` on Linux: > Settings -> Additional Settings -> RetroAchievements Settings - Make sure the system clock is correct so award timestamps are accurate +== Allium + +- Enter your RetroAchievements account details in RetroArch
+ > Allium launches RetroArch directly, so its own achievements login is what RAOfflineProxy reads. +- Make sure the system clock is correct so award timestamps are accurate
+ > Settings -> Wi-Fi -> enable NTP + ::: ::: warning Emulator configs are patched automatically @@ -144,6 +151,23 @@ RAOfflineProxy patches the emulator configs it needs in order to redirect RetroA 4. Start the proxy while online 5. Launch a game once so its data is cached +== Allium + +> Allium support is currently experimental. +> +> Tested on a Miyoo Mini Plus running [Allium v1.0.1](https://github.com/goweiwen/Allium/releases). Allium runs only on the Miyoo Mini, Mini Plus and Mini Flip, and this bundle covers all of them. + +1. Download the latest `RAOfflineProxy-Allium-*.zip` from [GitHub Releases](https://github.com/misantronic/RAOfflineProxy/releases) +2. Extract it over the root of your SD card so the app lands in: + +```text +/Apps/RAOfflineProxy.pak/ +``` + +3. Launch **RAOfflineProxy** from Allium's Apps section +4. Start the proxy while online +5. Launch a game once so its data is cached + ::: See [Caching Games](/linux-support/caching-games) for more detail. diff --git a/docs/linux-support/settings.md b/docs/linux-support/settings.md index 1f59494..6bd92d2 100644 --- a/docs/linux-support/settings.md +++ b/docs/linux-support/settings.md @@ -82,6 +82,28 @@ That block calls the app's headless launcher: A spruce update replaces `/.tmp_update` and removes the block. The app reinstalls it every time you open RAOfflineProxy, so autostart repairs itself after a spruce update. ::: +== Allium + +Allium autostart is available from the app menu. + +Allium has no drop-in startup folder either, so RAOfflineProxy adds a small guarded block to Allium's own boot script: + +```text +/.tmp_update/updater +``` + +That block calls the app's headless launcher in the background, so Allium's own startup is never held up: + +```text +/Apps/RAOfflineProxy.pak/autostart-launch.sh +``` + +It is also skipped entirely while an Allium firmware update is pending, so the proxy never starts up in the middle of an update. + +::: tip Reinstalled automatically +An Allium update rewrites `/.tmp_update` and removes the block. The app reinstalls it every time you open RAOfflineProxy, so autostart repairs itself after an Allium update. +::: + ::: ## Start / Stop Behavior diff --git a/docs/platforms.md b/docs/platforms.md index 3d52b1f..58fe7df 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -26,4 +26,5 @@ This page tracks which emulators and Linux handheld platforms `RAOfflineProxy` c | | **muOS** | โœ… RetroArch Supported | | | **ROCKNIX** | โœ… RetroArch, PPSSPP Supported | | | **spruce** | ๐Ÿงช RetroArch Supported (experimental) | +| | **Allium** | ๐Ÿงช RetroArch Supported (experimental) | | | **dArkOS** | ๐Ÿ”œ Planned, [pending release](https://github.com/misantronic/RAOfflineProxy/pull/47) | diff --git a/linux/allium/.gitignore b/linux/allium/.gitignore new file mode 100644 index 0000000..178135c --- /dev/null +++ b/linux/allium/.gitignore @@ -0,0 +1 @@ +/dist/ diff --git a/linux/allium/README.md b/linux/allium/README.md new file mode 100644 index 0000000..7435888 --- /dev/null +++ b/linux/allium/README.md @@ -0,0 +1,107 @@ +# RAOfflineProxy โ€” Allium + +[Allium](https://github.com/goweiwen/Allium) bundle, derived from the Onion one. Support is +**experimental**. + +Allium runs on the same Miyoo Mini armv7 hardware as Onion and spruce (same kernel, same +`/config/lib` MI SDK, same `.tmp_update` boot chain), so this package reuses Onion's CPython +runtime, its pygame + `Mini` SDL2 vendor libraries and its armv7 `libraproxy_rchash.so`. + +## What differs from Onion + +| | Onion | Allium | +| --- | --- | --- | +| App layout | `/mnt/SDCARD/App/RAOfflineProxy/` | `/mnt/SDCARD/Apps/RAOfflineProxy.pak/` | +| RetroArch config | `/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg` | same | +| RA credentials | the RetroArch config | same (Allium launches stock RetroArch, whose own achievements login writes there directly) | +| Default proxy port | 8080 | 8080 (Allium's own HTTP/FTP/telnet toggles use ports 80/21/23) | +| Autostart | drop-in `/mnt/SDCARD/.tmp_update/startup/raofflineproxy.sh` | prepended block in `.tmp_update/updater` | +| Version gate | requires Onion v4.4.0+ | none | + +Apps on Allium are `.pak` directories carrying their own `config.json` +(`{"label", "icon", "launch", "description"}`); there is no shared `App/` tree to install +into. `alliumd` runs the pak's `launch` script with the pak directory as the working +directory. + +## Autostart + +Allium has no drop-in boot directory either: `.tmp_update/updater` is the entire boot entry +point (it execs `alliumd` directly and only reaches the reboot loop after that returns), the +same shape spruce's boot script has on this hardware โ€” in fact it is the literal same file +path. `install_allium_boot_hook()` therefore prepends a sentinel-guarded block straight +after the shebang, exactly like spruce's `install_spruce_boot_hook()`, and the dispatch in +`platform.py` is keyed off `running_on_allium()` rather than path equality alone, since the +two boot script constants collide by value on real hardware (they're never true on the same +device, but the guard makes that explicit instead of accidental). + +The updater is regenerated by every Allium update, while `Apps/RAOfflineProxy.pak` is not, +so `launch.sh` reinstalls the hook on each app launch โ€” the same self-repair pattern spruce +and ROCKNIX need. Concretely: `.allium/scripts/ota-update.sh` extracts the release with +`miniunz -x -o` over `/mnt/SDCARD`, so any file the archive carries โ€” `.tmp_update/updater` +included โ€” is overwritten. Autostart therefore stays dead from an OTA update until the app +is next opened, which is the same gap spruce has. + +The hook also skips autostart entirely while `/mnt/SDCARD/allium-ota.zip` is present. It +has to: the block sits above the updater's own OTA check, and `ota-update.sh` extracts the +whole release over the card and then forces a reboot, so an unguarded hook would have the +service opening its SQLite database on that card mid-extract and seconds before the reboot. +The guard tests the same path Allium's updater does, and a test pins the two together so a +rename can't silently disable it. + +### Free space + +`ota-update.sh` refuses to run with less than 300MB free on the card. This bundle installs +~129MB, so on a nearly-full card installing it can be what pushes Allium below its own +update threshold. Not a problem on a roomy card, but worth knowing before blaming the OTA. + +## Timezone + +`alliumd` exports `TZ` from `/mnt/SDCARD/.allium/state/timezone` itself, but only partway +through `.tmp_update/updater` โ€” after our boot hook, which is prepended right after the +shebang so it survives a dispatch that never returns. A backgrounded autostart process would +otherwise inherit an empty `TZ` and stamp every award timestamp in UTC. +`resolve_allium_timezone()` in `common.sh` reads that same file directly and exports it +verbatim (matching how the updater itself applies the value) before starting the backend. +It never overrides a `TZ` that is already set. + +## Credentials + +Unlike spruce, Allium does not run its own settings UI in front of RetroArch: the +`RetroArch.pak` entry execs the stock `retroarch` binary directly, so RetroArch's own +Achievements login writes `cheevos_username`/`cheevos_password` straight into +`.retroarch/retroarch.cfg`. No separate credentials source or mode-patcher is needed โ€” the +existing Onion-style `retroarch_cfg.py` flow (`cheevos_custom_host`, `cheevos_enable`, +etc.) applies unchanged. + +## Build + +``` +./linux/onion/fetch_runtime.sh # once +./linux/onion/fetch_vendor.sh # once +./linux/allium/build_bundle.sh +``` + +Produces `linux/allium/dist/RAOfflineProxy-Allium-v.zip`, extracted over the SD card +root so the app lands in `/mnt/SDCARD/Apps/RAOfflineProxy.pak`. + +## Hardware coverage + +Allium runs only on the Miyoo Mini, Mini Plus and Mini Flip, and the armv7 runtime, native +lib and SDL2 bundled here cover all three. There is no uncovered hardware: unlike spruce, +whose 64-bit devices this stack cannot reach, Allium has no targets beyond the Miyoo Mini +family. + +Verified on a Miyoo Mini Plus running Allium v1.0.1. + +## Self-update + +Supported, through the same flow every other zip-based target uses. The archive installer +was layout-agnostic apart from one hardcoded path, so it takes an `archive_root` argument +instead: `App` for Onion and spruce, `Apps` for this bundle's `.pak`. Everything else โ€” +atomic replace, rollback on failure, preserving `data/` across a same-major update โ€” is +shared. + +Releasing therefore requires attaching `RAOfflineProxy-Allium-v.zip` to the GitHub +release. `find_platform_asset_url()` matches on `allium` + `.zip`, and a test pins the +in-archive layout (`Apps/RAOfflineProxy.pak`) to what `build_bundle.sh` produces, so a +rename on either side fails the suite rather than breaking updates on device. diff --git a/linux/allium/app/RAOfflineProxy.pak/autostart-launch.sh b/linux/allium/app/RAOfflineProxy.pak/autostart-launch.sh new file mode 100644 index 0000000..b33969a --- /dev/null +++ b/linux/allium/app/RAOfflineProxy.pak/autostart-launch.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +appdir="$(cd "$(dirname "$0")" && pwd)" + +. "$appdir/common.sh" + +prepare_env + +if ! resolve_python_bin; then + exit 0 +fi + +PYTHON_BIN="$RESOLVED_PYTHON_BIN" + +run_backend "$PYTHON_BIN" boot-reconcile >/dev/null 2>&1 || true diff --git a/linux/allium/app/RAOfflineProxy.pak/common.sh b/linux/allium/app/RAOfflineProxy.pak/common.sh new file mode 100644 index 0000000..03ec40b --- /dev/null +++ b/linux/allium/app/RAOfflineProxy.pak/common.sh @@ -0,0 +1,169 @@ +#!/bin/sh + +APP_DIR=/mnt/SDCARD/Apps/RAOfflineProxy.pak +APP_VERSION=v1.11.1-alpha1 +APP_MAX_CACHED_GAMES=100 +APP_DATA_DIR="$APP_DIR/data" +APP_RUNTIME_DIR="$APP_DIR/runtime" +APP_PACKAGE_DIR="$APP_DIR/app" +APP_LIB_DIR="$APP_DIR/lib" +APP_RETROARCH_CFG=/mnt/SDCARD/RetroArch/.retroarch/retroarch.cfg +APP_CERT_FILE="$APP_RUNTIME_DIR/lib/python3.9/site-packages/pip/_vendor/certifi/cacert.pem" +# alliumd itself reads this file and exports TZ before running, but that happens later in +# .tmp_update/updater than our boot hook (which is prepended right after the shebang so it +# survives a dispatch that never returns), so anything started from the hook would +# otherwise inherit an empty TZ and stamp every award timestamp in UTC. +APP_ALLIUM_TZ_FILE=/mnt/SDCARD/.allium/state/timezone +APP_ACTIVE_RUNTIME_ROOT= +RESOLVED_PYTHON_BIN= +RUNTIME_FAILURE_REASON= +RUNTIME_DETECT_LOG="$APP_DATA_DIR/runtime-detect.log" + +normalize_display_paths() { + sed 's#/mnt/SDCARD/#/#g' +} + +resolve_allium_timezone() { + if [ -n "${TZ:-}" ]; then + return 0 + fi + + if [ ! -r "$APP_ALLIUM_TZ_FILE" ]; then + return 0 + fi + + # Exported verbatim, matching how .tmp_update/updater itself applies the value. + allium_tz="$(cat "$APP_ALLIUM_TZ_FILE" 2>/dev/null || true)" + if [ -n "$allium_tz" ]; then + export TZ="$allium_tz" + fi + + return 0 +} + +prepare_env() { + mkdir -p "$APP_DATA_DIR" + : > "$RUNTIME_DETECT_LOG" + + resolve_allium_timezone + + export RAOFFLINEPROXY_CONFIG_DIR="$APP_DATA_DIR" + export RAOFFLINEPROXY_RETROARCH_CFG="$APP_RETROARCH_CFG" + export RAOFFLINEPROXY_APP_VERSION="${APP_VERSION#v}" + export RAOFFLINEPROXY_CACHE_IMAGES=0 + export PYTHONPATH="$APP_PACKAGE_DIR${PYTHONPATH:+:$PYTHONPATH}" + # glibc hands every allocating thread its own heap and grows each in 1MB chunks it + # never returns. The proxy runs a thread per connection plus background workers, which + # on a 103MB device cost ~15MB of arenas โ€” about as much as the interpreter itself. + export MALLOC_ARENA_MAX=2 + export LD_LIBRARY_PATH="$APP_LIB_DIR:/config/lib:/customer/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + # Same "Mini" SDL2 vendor build Onion ships (see linux/onion/README.md): only + # verified on Miyoo Mini hardware, which is the only Allium target this bundle covers. + export SDL_VIDEODRIVER=Mini + + if [ -f "$APP_CERT_FILE" ]; then + export SSL_CERT_FILE="$APP_CERT_FILE" + export RAOFFLINEPROXY_CA_FILE="$APP_CERT_FILE" + fi +} + +activate_runtime_env() { + runtime_root="$1" + APP_ACTIVE_RUNTIME_ROOT="$runtime_root" + + export PYTHONHOME="$runtime_root" + export LD_LIBRARY_PATH="$APP_LIB_DIR:$runtime_root/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export PATH="$runtime_root/bin${PATH:+:$PATH}" + if [ -f "$runtime_root/lib/python3.9/site-packages/pip/_vendor/certifi/cacert.pem" ]; then + export SSL_CERT_FILE="$runtime_root/lib/python3.9/site-packages/pip/_vendor/certifi/cacert.pem" + export RAOFFLINEPROXY_CA_FILE="$runtime_root/lib/python3.9/site-packages/pip/_vendor/certifi/cacert.pem" + fi +} + +python_supports_backend() { + candidate="$1" + runtime_root="${2:-}" + + if [ -n "$runtime_root" ]; then + PYTHONHOME="$runtime_root" LD_LIBRARY_PATH="$runtime_root/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info[0] >= 3 else 1)' >/dev/null 2>"$RUNTIME_DETECT_LOG" + return $? + fi + + "$candidate" -c 'import sys; raise SystemExit(0 if sys.version_info[0] >= 3 else 1)' >/dev/null 2>"$RUNTIME_DETECT_LOG" + return $? +} + +capture_runtime_failure_reason() { + if [ ! -s "$RUNTIME_DETECT_LOG" ]; then + RUNTIME_FAILURE_REASON= + return 0 + fi + + if IFS= read -r first_line < "$RUNTIME_DETECT_LOG"; then + RUNTIME_FAILURE_REASON="$first_line" + return 0 + fi + + RUNTIME_FAILURE_REASON= +} + +resolve_python_bin() { + if [ -x "$APP_RUNTIME_DIR/bin/python3" ]; then + if python_supports_backend "$APP_RUNTIME_DIR/bin/python3" "$APP_RUNTIME_DIR"; then + activate_runtime_env "$APP_RUNTIME_DIR" + RESOLVED_PYTHON_BIN="$APP_RUNTIME_DIR/bin/python3" + RUNTIME_FAILURE_REASON= + return 0 + fi + capture_runtime_failure_reason + fi + + if [ -x "$APP_RUNTIME_DIR/python/bin/python3" ]; then + if python_supports_backend "$APP_RUNTIME_DIR/python/bin/python3" "$APP_RUNTIME_DIR/python"; then + activate_runtime_env "$APP_RUNTIME_DIR/python" + RESOLVED_PYTHON_BIN="$APP_RUNTIME_DIR/python/bin/python3" + RUNTIME_FAILURE_REASON= + return 0 + fi + capture_runtime_failure_reason + fi + + if command -v python3 >/dev/null 2>&1; then + candidate="$(command -v python3)" + if python_supports_backend "$candidate"; then + RESOLVED_PYTHON_BIN="$candidate" + RUNTIME_FAILURE_REASON= + return 0 + fi + capture_runtime_failure_reason + fi + + RESOLVED_PYTHON_BIN= + return 1 +} + +run_backend() { + python_bin="$1" + shift + run_backend_raw "$python_bin" "$@" | normalize_display_paths +} + +run_backend_raw() { + python_bin="$1" + shift + case "${1:-}" in + boot-reconcile | start-proxy) + # raofflineproxy.boot opens the proxy port before loading the rest + # of the package, so an emulator started alongside this hook is not + # refused. + "$python_bin" -m raofflineproxy.boot "$@" + ;; + *) + "$python_bin" -m raofflineproxy.main "$@" + ;; + esac +} + +log_path() { + printf '%s\n' "$APP_DATA_DIR/service.log" +} diff --git a/linux/allium/app/RAOfflineProxy.pak/config.json b/linux/allium/app/RAOfflineProxy.pak/config.json new file mode 100644 index 0000000..5626898 --- /dev/null +++ b/linux/allium/app/RAOfflineProxy.pak/config.json @@ -0,0 +1,6 @@ +{ + "label": "RAOfflineProxy", + "icon": "icon.png", + "launch": "launch.sh", + "description": "Offline RetroAchievements" +} diff --git a/linux/allium/app/RAOfflineProxy.pak/launch.sh b/linux/allium/app/RAOfflineProxy.pak/launch.sh new file mode 100644 index 0000000..421c5e5 --- /dev/null +++ b/linux/allium/app/RAOfflineProxy.pak/launch.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +appdir="$(cd "$(dirname "$0")" && pwd)" + +touch /tmp/stay_awake +cd "$appdir" + +. "$appdir/common.sh" +prepare_env + +if resolve_python_bin; then + PYTHON_BIN="$RESOLVED_PYTHON_BIN" + run_backend_raw "$PYTHON_BIN" probe-online >/dev/null 2>&1 & + # Reinstalled on every launch: an Allium update rewrites .tmp_update/updater from + # scratch, and the app directory survives it, so this is the only thing that repairs + # autostart. + run_backend "$PYTHON_BIN" ensure-boot-hook >/dev/null 2>&1 || true + exec "$PYTHON_BIN" -m raofflineproxy.main menu-sdl +fi + +{ + printf 'RAOfflineProxy %s\n\n' "$APP_VERSION" + printf 'No compatible Python runtime was found, the app cannot start.\n\n' + printf 'Expected one of:\n' + printf ' - %s/runtime/bin/python3\n' "$appdir" + printf ' - %s/runtime/python/bin/python3\n' "$appdir" + printf ' - python3 on PATH\n\n' + if [ -n "$RUNTIME_FAILURE_REASON" ]; then + printf 'Last runtime error:\n %s\n\n' "$RUNTIME_FAILURE_REASON" + fi + printf 'RetroArch cfg: %s\n' "$RAOFFLINEPROXY_RETROARCH_CFG" +} | tee "$APP_DATA_DIR/launch-failure.log" + +exit 1 diff --git a/linux/allium/build_bundle.sh b/linux/allium/build_bundle.sh new file mode 100755 index 0000000..c910929 --- /dev/null +++ b/linux/allium/build_bundle.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Allium bundle. Allium runs on the same Miyoo Mini armv7 hardware as Onion/spruce, so this +# package reuses Onion's CPython runtime, its pygame + "Mini" SDL2 vendor libraries and its +# armv7 libraproxy_rchash.so rather than duplicating them here. +# Run linux/onion/fetch_runtime.sh and linux/onion/fetch_vendor.sh once if they're missing. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LINUX_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +ONION_DIR="${LINUX_DIR}/onion" +DIST_DIR="${SCRIPT_DIR}/dist" +BUILD_DIR="${DIST_DIR}/raofflineproxy-allium-app" +APP_DIR="${BUILD_DIR}/Apps/RAOfflineProxy.pak" +LIB_DIR="${APP_DIR}/lib" +RUNTIME_CACHE_DIR="${ONION_DIR}/runtime-cache" +RUNTIME_ARCHIVE_NAME="cpython-3.9.20+20241016-armv7-unknown-linux-gnueabihf-install_only_stripped.tar.gz" +RUNTIME_ARCHIVE_PATH="${RUNTIME_CACHE_DIR}/${RUNTIME_ARCHIVE_NAME}" +VENDOR_DIR="${ONION_DIR}/vendor" +APP_VERSION="${RAOFFLINEPROXY_APP_VERSION:-1.11.1-alpha1}" +ZIP_NAME="RAOfflineProxy-Allium-v${APP_VERSION}.zip" + +TARGET="arm-linux-gnueabihf.2.17" OUT_DIR="${SCRIPT_DIR}/native" \ + "${LINUX_DIR}/build_rchash.sh" + +rm -rf "${BUILD_DIR}" +rm -f "${DIST_DIR}/${ZIP_NAME}" + +mkdir -p "${APP_DIR}" +mkdir -p "${LIB_DIR}" + +export COPYFILE_DISABLE=1 + +cp -R "${SCRIPT_DIR}/app/RAOfflineProxy.pak/." "${APP_DIR}/" +mkdir -p "${APP_DIR}/app" +cp -R "${LINUX_DIR}/raofflineproxy" "${APP_DIR}/app/raofflineproxy" +cp "${LINUX_DIR}/../docs/public/logo-320.png" "${APP_DIR}/app/raofflineproxy/logo-320.png" +cp "${SCRIPT_DIR}/native/libraproxy_rchash.so" "${LIB_DIR}/libraproxy_rchash.so" +cp "${LINUX_DIR}/requirements.txt" "${APP_DIR}/app/requirements.txt" +sips -z 74 74 "${LINUX_DIR}/../docs/public/logo.png" --out "${APP_DIR}/icon.png" >/dev/null +mkdir -p "${APP_DIR}/data" + +if [ -f "${RUNTIME_ARCHIVE_PATH}" ]; then + rm -rf "${APP_DIR}/runtime" + mkdir -p "${APP_DIR}/runtime" + tar -xzf "${RUNTIME_ARCHIVE_PATH}" -C "${APP_DIR}/runtime" --strip-components 1 + python3 "${ONION_DIR}/flatten_symlinks.py" "${APP_DIR}/runtime" +else + echo "No cached runtime at ${RUNTIME_ARCHIVE_PATH} โ€” run ./linux/onion/fetch_runtime.sh first" +fi + +if [ -d "${VENDOR_DIR}/pygame" ] && [ -d "${VENDOR_DIR}/pygame.libs" ]; then + SITE_PACKAGES="${APP_DIR}/runtime/lib/python3.9/site-packages" + mkdir -p "${SITE_PACKAGES}" + rm -rf "${SITE_PACKAGES}/pygame" + cp -R "${VENDOR_DIR}/pygame" "${SITE_PACKAGES}/pygame" + cp "${VENDOR_DIR}"/pygame.libs/* "${LIB_DIR}/" + echo "Included pygame + SDL2 vendor libs from ${VENDOR_DIR}" +else + echo "No vendor directory found at ${VENDOR_DIR} โ€” run ./linux/onion/fetch_vendor.sh first (menu-sdl will be unavailable)" +fi + +find "${APP_DIR}" -name "__pycache__" -type d -prune -exec rm -rf {} + +find "${APP_DIR}" -name "*.pyc" -delete + +chmod +x "${APP_DIR}/launch.sh" +chmod +x "${APP_DIR}/autostart-launch.sh" + +mkdir -p "${DIST_DIR}" + +BUILD_DIR="${BUILD_DIR}" ZIP_PATH="${DIST_DIR}/${ZIP_NAME}" python3 - <<'PY' +import os +import zipfile +from pathlib import Path + +build_dir = Path(os.environ["BUILD_DIR"]) +zip_path = Path(os.environ["ZIP_PATH"]) + +with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(build_dir.rglob("*")): + if path.is_dir(): + continue + archive.write(path, path.relative_to(build_dir)) +PY + +echo "Created ${BUILD_DIR}" +echo "Created ${DIST_DIR}/${ZIP_NAME}" diff --git a/linux/raofflineproxy/config.py b/linux/raofflineproxy/config.py index 9c693f0..376c592 100644 --- a/linux/raofflineproxy/config.py +++ b/linux/raofflineproxy/config.py @@ -10,6 +10,10 @@ DEFAULT_ONION_APP_DIR = Path("/mnt/SDCARD/App/RAOfflineProxy") DEFAULT_ONION_STARTUP_SCRIPT = Path("/mnt/SDCARD/.tmp_update/startup/raofflineproxy.sh") +# Allium packages apps as .pak directories under Apps/, not Onion's App/ layout, but runs +# on the same Miyoo Mini firmware base (same /mnt/SDCARD/.tmp_update/updater boot chain). +DEFAULT_ALLIUM_APP_DIR = Path("/mnt/SDCARD/Apps/RAOfflineProxy.pak") +ALLIUM_MARKER_DIR = Path("/mnt/SDCARD/.allium") # spruceOS keeps its bare version string ("4.3.3") in this file โ€” the same one its own # updater and spruceRestore upgrade scripts read. SPRUCE_VERSION_FILE = Path("/mnt/SDCARD/spruce/spruce") @@ -81,11 +85,15 @@ def running_on_onion() -> bool: return DEFAULT_ONION_APP_DIR.exists() and not running_on_spruce() -def running_on_onion_or_spruce() -> bool: - """These two share the /mnt/SDCARD layout and the hardware, so this app ships one - bundled stack for both: the "Mini" SDL2 video driver, no fontconfig, and +def running_on_allium() -> bool: + return ALLIUM_MARKER_DIR.is_dir() + + +def running_on_shared_miyoo_stack() -> bool: + """Onion, spruce and Allium share the same Miyoo Mini hardware, so this app ships one + bundled stack for all three: the "Mini" SDL2 video driver, no fontconfig, and gpio-keys-polled raw evdev input.""" - return running_on_onion() or running_on_spruce() + return running_on_onion() or running_on_spruce() or running_on_allium() # Mirrors spruce's own device detection (spruce/scripts/helperFunctions.sh). The Anbernic @@ -168,6 +176,9 @@ def resolve_config_dir() -> Path: if DEFAULT_ONION_APP_DIR.exists(): return DEFAULT_ONION_APP_DIR / "data" + if DEFAULT_ALLIUM_APP_DIR.exists(): + return DEFAULT_ALLIUM_APP_DIR / "data" + if DEFAULT_MUOS_APPLICATION_DIR.exists(): return DEFAULT_MUOS_APPLICATION_DIR / "data" diff --git a/linux/raofflineproxy/log_uploader.py b/linux/raofflineproxy/log_uploader.py index 9a17bb0..75ed401 100644 --- a/linux/raofflineproxy/log_uploader.py +++ b/linux/raofflineproxy/log_uploader.py @@ -25,6 +25,8 @@ # two files for its "System log snapshot" support tool. ONION_VERSION_FILE = config.ONION_VERSION_FILE ONION_DEVICE_MODEL_FILE = Path("/tmp/deviceModel") +# Allium's own version marker, the same file its updater migrations check against. +ALLIUM_VERSION_FILE = Path("/mnt/SDCARD/.allium/version.txt") # /etc/os-release's OS_VERSION is deliberately truncated to just the leading number by Knulli's # own build script (board/scripts/post-build-script.sh in knulli-cfw/knulli-linux); this # untruncated file (version + build date + time) is what Knulli's own knulli-report-stats tool @@ -159,6 +161,8 @@ def _platform_label() -> str: return "spruce" if config.running_on_onion(): return "Onion" + if config.running_on_allium(): + return "Allium" if MUOS_MARKER_PATH.exists(): return "muOS" if config.running_on_rocknix(): @@ -217,6 +221,10 @@ def spruce_os_version() -> str | None: return _spruce_os_version() +def _allium_os_version() -> str | None: + return _read_stripped(ALLIUM_VERSION_FILE) + + # OnionOS v4.3.1-1 ships a bundled RetroArch build whose achievements client is not # reliably compatible with a custom host, so we only support the build after it. SUPPORTED_ONION_VERSION_PREFIX = "v4.4.0" @@ -262,6 +270,11 @@ def _device_label(platform: str) -> str: return config.spruce_platform() if platform == "Onion": return _onion_device_label() + if platform == "Allium": + # Deliberately NOT _onion_device_label(): /tmp/deviceModel is written by Onion's own + # runtime and does not exist on Allium (verified on a Miyoo Mini Plus), so that + # helper's fallback would label every Allium device the literal string "Onion". + return _hardware_device_label(platform) if platform == "muOS": return _muos_device_label() or _hardware_device_label(platform) return _hardware_device_label(platform) @@ -274,6 +287,8 @@ def _os_version_value(platform: str) -> str | None: if platform == "Onion": # Onion has no /etc/os-release; its own diagnostics tool reads this file instead. return _onion_os_version() + if platform == "Allium": + return _allium_os_version() if platform == "Knulli": # The dedicated file has the full version + build date/time; os-release's OS_VERSION # is a truncated copy of just the leading number. diff --git a/linux/raofflineproxy/menu_sdl.py b/linux/raofflineproxy/menu_sdl.py index 4a95d00..8250d36 100644 --- a/linux/raofflineproxy/menu_sdl.py +++ b/linux/raofflineproxy/menu_sdl.py @@ -31,10 +31,12 @@ from .config import ( APP_VERSION, CONFIG_DIR, + DEFAULT_ALLIUM_APP_DIR, DEFAULT_ONION_APP_DIR, load_config, + running_on_allium, running_on_onion, - running_on_onion_or_spruce, + running_on_shared_miyoo_stack, running_on_rocknix, running_on_spruce, save_config, @@ -318,7 +320,7 @@ def run_menu_sdl(command_runner: str) -> None: pygame.init() pygame.font.init() - if running_on_onion_or_spruce(): + if running_on_shared_miyoo_stack(): try: surface = _init_onion_display(pygame) except pygame.error as exc: @@ -571,7 +573,7 @@ def __init__( self.refresh_cached_games() def load_font(self, size: int, bold: bool = False): - if running_on_onion_or_spruce(): + if running_on_shared_miyoo_stack(): font_path = ONION_FONT_BOLD if bold else ONION_FONT_REGULAR if font_path.exists(): return self.pygame.font.Font(str(font_path), size) @@ -1042,7 +1044,7 @@ def handle_events(self) -> None: # existing KEYDOWN path on unverified assumptions. Keep draining the # event queue regardless (QUIT still matters, and an undrained SDL # event queue can back up). - skip_keydown = running_on_onion_or_spruce() and bool(getattr(self, "input_handles", None)) + skip_keydown = running_on_shared_miyoo_stack() and bool(getattr(self, "input_handles", None)) for event in self.pygame.event.get(): if event.type == self.pygame.QUIT: self.running = False @@ -1380,13 +1382,21 @@ def clear_cache_and_return(self) -> None: def is_knulli_platform(self) -> bool: return Path("/userdata/system").exists() - def update_platform(self) -> str: + def update_platform(self) -> str | None: + """The release channel to check for updates, or None where there is none. + + None is a normal state, not a failure: it must not be signalled by returning a + platform name that `update.validate_platform()` rejects, because every caller + would then log a spurious "update check failed" line on each refresh. + """ if running_on_muos(): return "muos" if running_on_spruce(): return "spruce" if running_on_onion(): return "onion" + if running_on_allium(): + return "allium" if running_on_rocknix(): return "rocknix" return "knulli" @@ -2388,16 +2398,22 @@ def refresh_main_menu_state(self, force: bool = False) -> None: and is_autostart_enabled(self.config_data) ) if force: - try: - update = update_status(self.update_platform()) - self.main_update_available = update.update_available - self.main_update_version = update.latest_version - self.main_update_asset_url = update.asset_url - except Exception as exc: - log_menu_sdl(f"update check failed error={exc}") + update_platform = self.update_platform() + if update_platform is None: self.main_update_available = False self.main_update_version = None self.main_update_asset_url = None + else: + try: + update = update_status(update_platform) + self.main_update_available = update.update_available + self.main_update_version = update.latest_version + self.main_update_asset_url = update.asset_url + except Exception as exc: + log_menu_sdl(f"update check failed error={exc}") + self.main_update_available = False + self.main_update_version = None + self.main_update_asset_url = None self.main_state_refreshed_at = now if ( self.view == "main" @@ -2745,7 +2761,10 @@ def resolve_update_asset_url(self) -> str | None: asset_url = getattr(self, "main_update_asset_url", None) if asset_url: return asset_url - update = update_status(self.update_platform(), force=True) + update_platform = self.update_platform() + if update_platform is None: + return None + update = update_status(update_platform, force=True) self.main_update_asset_url = update.asset_url self.main_update_version = update.latest_version self.main_update_available = update.update_available @@ -2762,8 +2781,13 @@ def install_update(self) -> None: if running_on_muos(): self.install_update_muos() return - if running_on_onion_or_spruce(): - self.install_update_onion() + # Each target passes its own layout: Onion and spruce share App/RAOfflineProxy, + # Allium ships Apps/RAOfflineProxy.pak. Everything else about the flow is common. + if running_on_allium(): + self.install_update_archive(DEFAULT_ALLIUM_APP_DIR, "Apps") + return + if running_on_onion() or running_on_spruce(): + self.install_update_archive(DEFAULT_ONION_APP_DIR, "App") return self.show_update_progress("Checking for update...") @@ -2787,7 +2811,12 @@ def install_update(self) -> None: self.message = (f"Update install failed: {exc}", time.monotonic() + ERROR_SECONDS) self.dismiss_update_prompt() - def install_update_onion(self) -> None: + def install_update_archive(self, app_dir: Path, archive_root: str) -> None: + """Zip-archive update flow, shared by every target that ships one. + + `archive_root` is where the app directory sits inside the archive โ€” `App` for + Onion and spruce, `Apps` for Allium's .pak layout. + """ self.show_update_progress("Checking for update...") asset_url = self.resolve_update_asset_url() if not asset_url: @@ -2795,14 +2824,13 @@ def install_update_onion(self) -> None: self.dismiss_update_prompt() return - app_dir = DEFAULT_ONION_APP_DIR try: self.show_update_progress("Stopping proxy...") stop_proxy_inline() self.show_update_progress("Downloading update...") archive_path = download_onion_update_archive(asset_url) self.show_update_progress("Installing update...") - install_onion_update_archive(archive_path, app_dir) + install_onion_update_archive(archive_path, app_dir, archive_root) self.storage.close() close_input_devices(self.input_handles) self.pygame.quit() diff --git a/linux/raofflineproxy/platform.py b/linux/raofflineproxy/platform.py index 4077bef..aaaa269 100644 --- a/linux/raofflineproxy/platform.py +++ b/linux/raofflineproxy/platform.py @@ -7,6 +7,7 @@ DEFAULT_ONION_STARTUP_SCRIPT, MUOS_USER_INIT_CONFIG, detect_retroarch_cfg, + running_on_allium, running_on_rocknix, running_on_spruce, save_config, @@ -24,6 +25,13 @@ # hook is inserted into that file, above the dispatch, rather than appended. DEFAULT_SPRUCE_STARTUP_SCRIPT = Path("/mnt/SDCARD/.tmp_update/updater") SPRUCE_AUTOSTART_LAUNCHER = Path("/mnt/SDCARD/App/RAOfflineProxy/autostart-launch.sh") +# Allium's alliumd is exec'd from the same .tmp_update/updater file spruce uses (both are +# Miyoo Mini firmwares built on the same base), so the boot hook is installed the same way. +DEFAULT_ALLIUM_STARTUP_SCRIPT = Path("/mnt/SDCARD/.tmp_update/updater") +ALLIUM_AUTOSTART_LAUNCHER = Path("/mnt/SDCARD/Apps/RAOfflineProxy.pak/autostart-launch.sh") +# The drop file Allium's own updater looks for before handing off to ota-update.sh. Our +# boot hook sits above that check, so it has to test the same path to stay out of the way. +ALLIUM_OTA_ARCHIVE = Path("/mnt/SDCARD/allium-ota.zip") ROCKNIX_MODULES_DIR = Path("/storage/.config/modules") ROCKNIX_MODULES_LAUNCHER = ROCKNIX_MODULES_DIR / "RAOfflineProxy.sh" ROCKNIX_TOOL_SOURCE = Path("/storage/.local/share/raofflineproxy/RAOfflineProxy.sh") @@ -146,6 +154,10 @@ def ensure_boot_hook(config_data: dict) -> None: startup_script.write_text(onion_boot_hook_script(), encoding="utf-8") return + if startup_script == DEFAULT_ALLIUM_STARTUP_SCRIPT and running_on_allium(): + install_allium_boot_hook(startup_script) + return + if startup_script == DEFAULT_SPRUCE_STARTUP_SCRIPT: install_spruce_boot_hook(startup_script) return @@ -198,6 +210,12 @@ def resolve_startup_script_path(config_data: dict) -> Path | None: if configured: return Path(str(configured)) + # Checked before spruce/Onion: Allium also ships a /mnt/SDCARD/.tmp_update of its own, + # never sources the startup/ directory Onion uses, and packages apps as .pak + # directories rather than spruce/Onion's shared App/ layout. + if running_on_allium(): + return DEFAULT_ALLIUM_STARTUP_SCRIPT + # Checked before Onion: spruce ships a /mnt/SDCARD/.tmp_update of its own, but never # sources the startup/ directory Onion uses, so an Onion hook there would never fire. if running_on_spruce(): @@ -236,6 +254,9 @@ def autostart_command(config_data: dict) -> tuple[str]: if startup_script == DEFAULT_ONION_STARTUP_SCRIPT: return ("/mnt/SDCARD/App/RAOfflineProxy/autostart-launch.sh",) + if startup_script == DEFAULT_ALLIUM_STARTUP_SCRIPT and running_on_allium(): + return (str(ALLIUM_AUTOSTART_LAUNCHER),) + if startup_script == DEFAULT_SPRUCE_STARTUP_SCRIPT: return (str(SPRUCE_AUTOSTART_LAUNCHER),) @@ -316,6 +337,47 @@ def install_spruce_boot_hook(startup_script: Path) -> None: startup_script.write_text(updated, encoding="utf-8") +def allium_boot_hook_block() -> str: + # Same reasoning as spruce_boot_hook_block(): .tmp_update/updater is the only path to a + # usable device on this firmware, so the hook is backgrounded and guarded. + # + # The pending-OTA guard matters because this block sits above the updater's own OTA + # check: on an update boot, ota-update.sh extracts the release over the whole card and + # then reboots, so without the guard the service would be opening its SQLite database + # on that card mid-extract and seconds before a forced reboot. + return "\n".join( + [ + AUTOSTART_SENTINEL_START, + f'if [ ! -f "{ALLIUM_OTA_ARCHIVE}" ] && [ -x "{ALLIUM_AUTOSTART_LAUNCHER}" ]; then', + f' sh "{ALLIUM_AUTOSTART_LAUNCHER}" >/dev/null 2>&1 &', + "fi", + AUTOSTART_SENTINEL_END, + ] + ) + + +def install_allium_boot_hook(startup_script: Path) -> None: + """Prepends the hook to Allium's boot entry point, straight after the shebang. + + Same file and mechanism as spruce's install_spruce_boot_hook(): the script ends by + exec'ing alliumd, which never returns, so appending would leave the hook dead code. + """ + if not startup_script.exists(): + raise ValueError(f"allium boot script not found: {startup_script}") + + existing = startup_script.read_text(encoding="utf-8", errors="replace") + if not existing.startswith("#!"): + raise ValueError( + f"unrecognised allium boot script, autostart not installed: {startup_script}" + ) + + cleaned = strip_autostart_block(existing) + shebang, _, remainder = cleaned.partition("\n") + updated = f"{shebang}\n\n{allium_boot_hook_block()}\n\n{remainder.lstrip(chr(10))}" + if updated != existing: + startup_script.write_text(updated, encoding="utf-8") + + def onion_boot_hook_script() -> str: return "\n".join( [ diff --git a/linux/raofflineproxy/update.py b/linux/raofflineproxy/update.py index 35ce768..d4303e5 100644 --- a/linux/raofflineproxy/update.py +++ b/linux/raofflineproxy/update.py @@ -26,6 +26,7 @@ PLATFORM_SPRUCE = "spruce" PLATFORM_MUOS = "muos" PLATFORM_ROCKNIX = "rocknix" +PLATFORM_ALLIUM = "allium" KNULLI_UPDATE_INSTALLER_PATH = CONFIG_DIR / "update-installer.sh" ONION_UPDATE_ARCHIVE_PATH = CONFIG_DIR / "update-onion.zip" MUOS_UPDATE_ARCHIVE_PATH = CONFIG_DIR / "update-muos.muxapp" @@ -257,6 +258,9 @@ def find_platform_asset_url(platform: str, assets: list[dict]) -> str | None: elif platform == PLATFORM_ROCKNIX: if "rocknix" not in lower_name or not lower_name.endswith(".sh"): continue + elif platform == PLATFORM_ALLIUM: + if "allium" not in lower_name or not lower_name.endswith(".zip"): + continue else: continue @@ -344,7 +348,43 @@ def download_onion_update_archive(asset_url: str, destination: Path | None = Non return destination_path -def install_onion_update_archive(archive_path: Path, app_dir: Path) -> None: +CLEANUP_RETRIES = 3 +CLEANUP_RETRY_DELAY_SECONDS = 1.0 + + +def remove_tree_best_effort(path: Path) -> None: + """Delete a temp tree, retrying before giving up quietly. + + A plain rmtree(ignore_errors=True) measurably loses on a Miyoo Mini: right after an + install has written ~129MB, the first pass over the old tree bails partway on the + card's FAT32 write-back and silently leaves hundreds of files behind, while an + immediate second pass removes all of them with no errors at all. Left as-is the + debris accumulates with every update โ€” which on Allium can push the card under the + 300MB its own OTA updater demands (see linux/allium/README.md). + """ + for attempt in range(1, CLEANUP_RETRIES + 1): + if not path.exists(): + return + shutil.rmtree(path, ignore_errors=attempt < CLEANUP_RETRIES) + if not path.exists(): + return + if attempt < CLEANUP_RETRIES: + time.sleep(CLEANUP_RETRY_DELAY_SECONDS) + + # Never fatal: the update itself already succeeded, so this is only tidy-up. But it is + # logged rather than swallowed, so the debris is diagnosable instead of invisible. + LOGGER.warning("Could not fully remove temporary update directory path=%s", path) + + +def install_onion_update_archive( + archive_path: Path, app_dir: Path, archive_root: str = "App" +) -> None: + """Swap `app_dir` for the copy inside the archive, atomically and with rollback. + + Everything here is layout-agnostic except where the app directory sits inside the + archive, which is what `archive_root` names: Onion and spruce ship `App/RAOfflineProxy`, + Allium ships `Apps/RAOfflineProxy.pak`. + """ app_dir = Path(app_dir) parent_dir = app_dir.parent temp_extract_dir = parent_dir / f".{app_dir.name}.update" @@ -364,9 +404,9 @@ def install_onion_update_archive(archive_path: Path, app_dir: Path) -> None: archive.extractall(temp_extract_dir) restore_archive_permissions(archive, temp_extract_dir) - extracted_app_dir = temp_extract_dir / "App" / app_dir.name + extracted_app_dir = temp_extract_dir / archive_root / app_dir.name if not extracted_app_dir.exists(): - raise RuntimeError("update archive missing App/RAOfflineProxy") + raise RuntimeError(f"update archive missing {archive_root}/{app_dir.name}") preserve_data = should_preserve_onion_data(current_version(), extracted_app_dir) @@ -391,12 +431,9 @@ def install_onion_update_archive(archive_path: Path, app_dir: Path) -> None: os.replace(backup_dir, app_dir) raise finally: - if temp_extract_dir.exists(): - shutil.rmtree(temp_extract_dir, ignore_errors=True) - if backup_dir.exists(): - shutil.rmtree(backup_dir, ignore_errors=True) - if preserved_data_dir.exists(): - shutil.rmtree(preserved_data_dir, ignore_errors=True) + remove_tree_best_effort(temp_extract_dir) + remove_tree_best_effort(backup_dir) + remove_tree_best_effort(preserved_data_dir) clear_stale_update_status(app_dir) @@ -484,12 +521,9 @@ def install_muos_update_archive(archive_path: Path, app_dir: Path) -> None: os.replace(backup_dir, app_dir) raise finally: - if temp_extract_dir.exists(): - shutil.rmtree(temp_extract_dir, ignore_errors=True) - if backup_dir.exists(): - shutil.rmtree(backup_dir, ignore_errors=True) - if preserved_data_dir.exists(): - shutil.rmtree(preserved_data_dir, ignore_errors=True) + remove_tree_best_effort(temp_extract_dir) + remove_tree_best_effort(backup_dir) + remove_tree_best_effort(preserved_data_dir) if archive_path.exists(): archive_path.unlink() @@ -623,6 +657,7 @@ def validate_platform(platform: str) -> str: PLATFORM_SPRUCE, PLATFORM_MUOS, PLATFORM_ROCKNIX, + PLATFORM_ALLIUM, }: raise ValueError(f"Unsupported update platform: {platform}") return lowered diff --git a/linux/tests/test_linux_allium.py b/linux/tests/test_linux_allium.py new file mode 100644 index 0000000..5d63c9d --- /dev/null +++ b/linux/tests/test_linux_allium.py @@ -0,0 +1,481 @@ +import os +import tempfile +import zipfile +import unittest +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import patch + +from linux.raofflineproxy import config, log_uploader, menu_sdl, platform, update + + +class AlliumDetectionTests(unittest.TestCase): + def test_running_on_allium_requires_the_marker_dir(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + marker = Path(temp_dir) / ".allium" + with patch.object(config, "ALLIUM_MARKER_DIR", marker): + self.assertFalse(config.running_on_allium()) + marker.mkdir() + self.assertTrue(config.running_on_allium()) + + def test_running_on_shared_miyoo_stack_includes_allium(self) -> None: + with patch.object(config, "running_on_allium", return_value=True): + with patch.object(config, "running_on_onion", return_value=False): + with patch.object(config, "running_on_spruce", return_value=False): + self.assertTrue(config.running_on_shared_miyoo_stack()) + + def test_resolve_config_dir_uses_allium_pak_data_dir(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + app_dir = Path(temp_dir) / "RAOfflineProxy.pak" + app_dir.mkdir() + # Both env vars are checked before any platform path, so leaving them to the + # ambient environment makes this pass or fail depending on the machine: CI + # sets XDG_CONFIG_HOME and returned from it long before the Allium branch. + with patch.dict(os.environ): + os.environ.pop("RAOFFLINEPROXY_CONFIG_DIR", None) + os.environ.pop("XDG_CONFIG_HOME", None) + with patch.object(config, "DEFAULT_ONION_APP_DIR", Path(temp_dir) / "absent"): + with patch.object(config, "DEFAULT_ALLIUM_APP_DIR", app_dir): + self.assertEqual(config.resolve_config_dir(), app_dir / "data") + + +# Verbatim copy of Allium v1.0.1's /mnt/SDCARD/.tmp_update/updater, pulled off a real Miyoo +# Mini running Allium. The hook is inserted into this exact file at boot, so the tests run +# against the real thing rather than a hand-written approximation. It is the same boot +# entry point spruce uses on this hardware (execs its own long-running UI process and never +# returns), so the hook is prepended after the shebang rather than appended. +ALLIUM_UPDATER = """#!/bin/sh +export ROOT="/mnt/SDCARD" +export LD_LIBRARY_PATH="/lib:/config/lib:/customer/lib:$ROOT/miyoo:$ROOT/.tmp_update/lib" +export PATH="$ROOT/.tmp_update/bin:$PATH" + +# init backlight +echo 0 >/sys/class/pwm/pwmchip0/export +echo 800 >/sys/class/pwm/pwmchip0/pwm0/period +echo 6 >/sys/class/pwm/pwmchip0/pwm0/duty_cycle +echo 1 >/sys/class/pwm/pwmchip0/pwm0/enable + +# restore timezone +if [ -f $ROOT/.allium/state/timezone ]; then + export TZ + TZ="$(cat $ROOT/.allium/state/timezone)" +fi + +# check for OTA update +if [ -f "$ROOT/allium-ota.zip" ]; then + "$ROOT/.allium/scripts/ota-update.sh" +fi + +# run migration scripts that haven't been run yet +for script in "$ROOT"/.allium/migrations/*; do + if [ ! -f "$script/.done" ]; then + if ! "$script/run.sh" >"$script/run.log"; then + reboot + sleep 10 + fi + touch "$script/.done" + fi +done + +# run Allium +RUST_LOG=info "$ROOT"/.allium/bin/alliumd >"$ROOT"/allium.log 2>&1 + +while true; do + reboot + sleep 10 +done +""" + + +class AlliumBootHookTests(unittest.TestCase): + def _updater(self): + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + path = Path(temp_dir.name) / "updater" + path.write_text(ALLIUM_UPDATER, encoding="utf-8") + return path + + def test_hook_is_inserted_above_the_dispatch_not_appended(self) -> None: + path = self._updater() + platform.install_allium_boot_hook(path) + content = path.read_text(encoding="utf-8") + + hook_at = content.index(platform.AUTOSTART_SENTINEL_START) + self.assertTrue(content.startswith("#!")) + self.assertLess(hook_at, content.index("alliumd")) + self.assertIn(str(platform.ALLIUM_AUTOSTART_LAUNCHER), content) + self.assertIn("&", content[hook_at : content.index("alliumd")]) + + def test_original_boot_logic_is_preserved(self) -> None: + path = self._updater() + platform.install_allium_boot_hook(path) + content = path.read_text(encoding="utf-8") + + for line in ALLIUM_UPDATER.splitlines(): + if line.strip(): + self.assertIn(line, content) + + def test_hook_skips_autostart_when_an_ota_update_is_pending(self) -> None: + # The hook is prepended above the updater's own OTA check, and ota-update.sh + # extracts the release over the whole card and then reboots. Starting the service + # into that would open its SQLite database mid-extract, seconds before the reboot. + path = self._updater() + platform.install_allium_boot_hook(path) + content = path.read_text(encoding="utf-8") + + block = content[ + content.index(platform.AUTOSTART_SENTINEL_START) : content.index( + platform.AUTOSTART_SENTINEL_END + ) + ] + self.assertIn(f'[ ! -f "{platform.ALLIUM_OTA_ARCHIVE}" ]', block) + # Guard must sit above the updater's own check, or it would never be reached. + self.assertLess( + content.index(platform.AUTOSTART_SENTINEL_END), content.index("ota-update.sh") + ) + + def test_ota_guard_matches_the_path_allium_itself_checks(self) -> None: + # Verbatim from the real updater: a drifted path would silently disable the guard. + self.assertIn('if [ -f "$ROOT/allium-ota.zip" ]', ALLIUM_UPDATER) + self.assertEqual( + str(platform.ALLIUM_OTA_ARCHIVE), "/mnt/SDCARD/allium-ota.zip" + ) + + def test_install_is_idempotent(self) -> None: + path = self._updater() + platform.install_allium_boot_hook(path) + once = path.read_text(encoding="utf-8") + platform.install_allium_boot_hook(path) + platform.install_allium_boot_hook(path) + self.assertEqual(path.read_text(encoding="utf-8"), once) + self.assertEqual(once.count(platform.AUTOSTART_SENTINEL_START), 1) + + def test_remove_strips_the_block_without_deleting_the_updater(self) -> None: + path = self._updater() + platform.install_allium_boot_hook(path) + with patch.object(platform, "running_on_allium", return_value=True): + with patch.object(platform, "DEFAULT_ALLIUM_STARTUP_SCRIPT", path): + platform.remove_boot_hook({"startup_script": str(path)}) + + content = path.read_text(encoding="utf-8") + self.assertTrue(path.exists()) + self.assertNotIn(platform.AUTOSTART_SENTINEL_START, content) + self.assertIn("alliumd", content) + + def test_unrecognised_boot_file_is_refused(self) -> None: + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + path = Path(temp_dir.name) / "updater" + path.write_text("not a shell script at all\n", encoding="utf-8") + + with self.assertRaises(ValueError): + platform.install_allium_boot_hook(path) + self.assertNotIn(platform.AUTOSTART_SENTINEL_START, path.read_text(encoding="utf-8")) + + def test_autostart_is_supported_on_allium(self) -> None: + with patch.object(platform, "running_on_allium", return_value=True): + with patch.object(platform, "running_on_spruce", return_value=False): + self.assertEqual( + platform.resolve_startup_script_path({}), + platform.DEFAULT_ALLIUM_STARTUP_SCRIPT, + ) + self.assertTrue(platform.autostart_supported({})) + self.assertEqual( + platform.autostart_command({}), + (str(platform.ALLIUM_AUTOSTART_LAUNCHER),), + ) + + def test_allium_branch_does_not_fire_on_spruce(self) -> None: + # DEFAULT_ALLIUM_STARTUP_SCRIPT and DEFAULT_SPRUCE_STARTUP_SCRIPT are literally the + # same path on real hardware (.tmp_update/updater), so the dispatch must key off + # running_on_allium() rather than path equality alone. + with patch.object(platform, "running_on_allium", return_value=False): + with patch.object(platform, "running_on_spruce", return_value=True): + self.assertEqual( + platform.autostart_command({}), + (str(platform.SPRUCE_AUTOSTART_LAUNCHER),), + ) + + +class AlliumUpdateSafetyTests(unittest.TestCase): + def _session(self): + return menu_sdl.MenuSdlSession.__new__(menu_sdl.MenuSdlSession) + + def test_update_platform_is_allium_not_knulli(self) -> None: + # A "knulli" fallback here would make install_update()'s generic branch download + # and exec a Knulli installer binary on Miyoo Mini hardware. + session = self._session() + with patch.object(menu_sdl, "running_on_muos", return_value=False): + with patch.object(menu_sdl, "running_on_spruce", return_value=False): + with patch.object(menu_sdl, "running_on_onion", return_value=False): + with patch.object(menu_sdl, "running_on_allium", return_value=True): + self.assertEqual(session.update_platform(), "allium") + + def test_allium_is_an_installable_update_platform(self) -> None: + self.assertEqual(update.validate_platform("Allium"), update.PLATFORM_ALLIUM) + + def test_asset_lookup_does_not_cross_match_allium_and_onion(self) -> None: + assets = [ + { + "name": "RAOfflineProxy-Onion-v1.11.1-alpha1.zip", + "browser_download_url": "https://example.test/onion.zip", + }, + { + "name": "RAOfflineProxy-Allium-v1.11.1-alpha1.zip", + "browser_download_url": "https://example.test/allium.zip", + }, + ] + self.assertEqual( + update.find_platform_asset_url(update.PLATFORM_ALLIUM, assets), + "https://example.test/allium.zip", + ) + self.assertEqual( + update.find_platform_asset_url(update.PLATFORM_ONION, assets), + "https://example.test/onion.zip", + ) + + def test_install_update_uses_the_pak_layout_not_the_onion_one(self) -> None: + session = self._session() + with ExitStack() as stack: + stack.enter_context(patch.object(menu_sdl, "running_on_muos", return_value=False)) + stack.enter_context(patch.object(menu_sdl, "running_on_allium", return_value=True)) + install = stack.enter_context( + patch.object(menu_sdl.MenuSdlSession, "install_update_archive") + ) + session.install_update() + + install.assert_called_once_with(config.DEFAULT_ALLIUM_APP_DIR, "Apps") + + def test_onion_and_spruce_still_get_the_app_layout(self) -> None: + # The archive_root parameter was added for Allium; these two must keep the layout + # they already ship, or the refactor silently breaks their updates instead. + for onion, spruce in ((True, False), (False, True)): + with self.subTest(onion=onion, spruce=spruce): + session = self._session() + with ExitStack() as stack: + stack.enter_context( + patch.object(menu_sdl, "running_on_muos", return_value=False) + ) + stack.enter_context( + patch.object(menu_sdl, "running_on_allium", return_value=False) + ) + stack.enter_context( + patch.object(menu_sdl, "running_on_onion", return_value=onion) + ) + stack.enter_context( + patch.object(menu_sdl, "running_on_spruce", return_value=spruce) + ) + install = stack.enter_context( + patch.object(menu_sdl.MenuSdlSession, "install_update_archive") + ) + session.install_update() + + install.assert_called_once_with(config.DEFAULT_ONION_APP_DIR, "App") + + def test_no_update_channel_is_silent_not_a_logged_failure(self) -> None: + # Signalling "no update channel" by returning a name validate_platform() rejects + # logged an "update check failed" line on every menu refresh โ€” 17 of them in one + # real support upload, drowning genuine errors in the diagnostics log. + session = self._session() + session.main_service_mode = False + session.config_data = {} + session.view = "main" + session.main_update_dialog_seen = True + + logged: list[str] = [] + with ExitStack() as stack: + stack.enter_context(patch.object(menu_sdl, "log_menu_sdl", logged.append)) + stack.enter_context(patch.object(menu_sdl, "load_config", return_value={})) + stack.enter_context( + patch.object(menu_sdl, "service_mode_active", return_value=False) + ) + stack.enter_context( + patch.object(menu_sdl, "online_check", return_value=False) + ) + stack.enter_context( + patch.object(menu_sdl, "autostart_supported", return_value=False) + ) + stack.enter_context( + patch.object( + menu_sdl.MenuSdlSession, "read_proxy_running", return_value=False + ) + ) + stack.enter_context( + patch.object( + menu_sdl.MenuSdlSession, "is_logged_in", return_value=False + ) + ) + stack.enter_context( + patch.object( + menu_sdl.MenuSdlSession, "update_platform", return_value=None + ) + ) + update_status = stack.enter_context( + patch.object(menu_sdl, "update_status") + ) + menu_sdl.MenuSdlSession.refresh_main_menu_state(session, force=True) + + update_status.assert_not_called() + self.assertFalse(session.main_update_available) + self.assertEqual([line for line in logged if "update check failed" in line], []) + + def test_resolve_update_asset_url_is_none_without_a_channel(self) -> None: + session = self._session() + session.main_update_asset_url = None + with patch.object(menu_sdl.MenuSdlSession, "update_platform", return_value=None): + with patch.object(menu_sdl, "update_status") as update_status: + self.assertIsNone(session.resolve_update_asset_url()) + update_status.assert_not_called() + + +class AlliumUpdateInstallTests(unittest.TestCase): + """The .pak layout driven through the real installer, not just asserted as a string. + + This is the flow that decides whether an Allium release is upgradeable at all, so it + runs against an archive shaped exactly like the one build_bundle.sh produces. + """ + + def _archive(self, root: Path, version: str = "v1.12.0-alpha1") -> Path: + archive_path = root / "update.zip" + pak = "Apps/RAOfflineProxy.pak" + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr(f"{pak}/new.txt", "new") + archive.writestr(f"{pak}/common.sh", f"APP_VERSION={version}\n") + archive.writestr(f"{pak}/data/proxy.sqlite3", "fresh-db") + launch_info = zipfile.ZipInfo(f"{pak}/launch.sh") + launch_info.external_attr = 0o755 << 16 + archive.writestr(launch_info, "#!/bin/sh\nexit 0\n") + return archive_path + + def _installed_pak(self, root: Path) -> Path: + app_dir = root / "RAOfflineProxy.pak" + (app_dir / "data").mkdir(parents=True) + (app_dir / "old.txt").write_text("old", encoding="utf-8") + (app_dir / "data" / "proxy.sqlite3").write_text("cached-db", encoding="utf-8") + return app_dir + + def _install(self, archive_path: Path, app_dir: Path, current: str) -> None: + with patch.object(update, "current_version", lambda: current): + update.install_onion_update_archive(archive_path, app_dir, "Apps") + + def test_replaces_the_pak_and_preserves_the_data_dir(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + app_dir = self._installed_pak(root) + self._install(self._archive(root), app_dir, "1.11.1-alpha1") + + self.assertFalse((app_dir / "old.txt").exists()) + self.assertEqual((app_dir / "new.txt").read_text(encoding="utf-8"), "new") + # The queued-award database must survive a same-major update. + self.assertEqual( + (app_dir / "data" / "proxy.sqlite3").read_text(encoding="utf-8"), "cached-db" + ) + self.assertEqual((app_dir / "launch.sh").stat().st_mode & 0o777, 0o755) + + def test_a_wrong_archive_root_leaves_the_install_untouched(self) -> None: + # Guards the parameter itself: passing Onion's "App" against a .pak archive must + # fail before anything is swapped, not half-replace the app directory. + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + app_dir = self._installed_pak(root) + + with self.assertRaises(RuntimeError): + with patch.object(update, "current_version", lambda: "1.11.1-alpha1"): + update.install_onion_update_archive(self._archive(root), app_dir, "App") + + self.assertEqual((app_dir / "old.txt").read_text(encoding="utf-8"), "old") + self.assertEqual( + (app_dir / "data" / "proxy.sqlite3").read_text(encoding="utf-8"), "cached-db" + ) + + def test_the_built_bundle_matches_the_layout_the_installer_expects(self) -> None: + # Ties the installer to build_bundle.sh: if the bundle's in-archive path ever + # changes, the update flow breaks on device and nothing else would catch it. + build_bundle = ( + Path(__file__).resolve().parents[1] / "allium" / "build_bundle.sh" + ).read_text(encoding="utf-8") + self.assertIn('APP_DIR="${BUILD_DIR}/Apps/RAOfflineProxy.pak"', build_bundle) + self.assertIn("RAOfflineProxy-Allium-v${APP_VERSION}.zip", build_bundle) + self.assertEqual( + str(config.DEFAULT_ALLIUM_APP_DIR), "/mnt/SDCARD/Apps/RAOfflineProxy.pak" + ) + + +class UpdateCleanupRetryTests(unittest.TestCase): + """Measured on a Miyoo Mini: the first rmtree after an install writes ~129MB bails + partway on FAT32 write-back, leaving 318 files, while an immediate retry removes all of + them with zero errors. ignore_errors=True made that invisible.""" + + def test_a_transient_first_failure_is_retried(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + target = Path(temp_dir) / "leftover" + (target / "nested").mkdir(parents=True) + (target / "nested" / "file.txt").write_text("x", encoding="utf-8") + + real_rmtree = update.shutil.rmtree + calls = [] + + def flaky(path, ignore_errors=False, **kwargs): + calls.append(path) + if len(calls) == 1: + return # first pass silently leaves the tree behind + return real_rmtree(path, ignore_errors=ignore_errors, **kwargs) + + with patch.object(update.shutil, "rmtree", flaky): + with patch.object(update.time, "sleep"): + update.remove_tree_best_effort(target) + + self.assertFalse(target.exists()) + self.assertEqual(len(calls), 2) + + def test_a_persistent_failure_warns_instead_of_silently_leaving_debris(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + target = Path(temp_dir) / "leftover" + target.mkdir() + + with patch.object(update.shutil, "rmtree", lambda *a, **k: None): + with patch.object(update.time, "sleep"): + with self.assertLogs(update.LOGGER, level="WARNING") as logged: + update.remove_tree_best_effort(target) + + self.assertTrue(any("Could not fully remove" in m for m in logged.output)) + + def test_an_already_absent_tree_does_no_work(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + with patch.object(update.shutil, "rmtree") as rmtree: + update.remove_tree_best_effort(Path(temp_dir) / "absent") + rmtree.assert_not_called() + + +class AlliumLogMetadataTests(unittest.TestCase): + def test_platform_label_and_version_use_allium_file(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + version_file = Path(temp_dir) / "version.txt" + version_file.write_text("v1.0.1\n", encoding="utf-8") + with patch.object(log_uploader, "ALLIUM_VERSION_FILE", version_file): + with patch.object(config, "running_on_spruce", return_value=False): + with patch.object(config, "running_on_onion", return_value=False): + with patch.object(config, "running_on_allium", return_value=True): + self.assertEqual(log_uploader._platform_label(), "Allium") + self.assertEqual( + log_uploader._os_version_value("Allium"), "v1.0.1" + ) + + def test_device_label_does_not_fall_back_to_the_string_onion(self) -> None: + # /tmp/deviceModel is written by Onion's own runtime and does not exist on Allium + # (verified on a Miyoo Mini Plus), so routing Allium through _onion_device_label() + # would label every Allium device "Onion" in support logs. + with tempfile.TemporaryDirectory() as temp_dir: + missing = Path(temp_dir) / "deviceModel" + model_file = Path(temp_dir) / "model" + model_file.write_text("INFINITY2M SSC011A-S01A-S\0", encoding="utf-8") + with patch.object(log_uploader, "ONION_DEVICE_MODEL_FILE", missing): + with patch.object(log_uploader, "DEVICETREE_MODEL_FILE", model_file): + self.assertEqual( + log_uploader._device_label("Allium"), "INFINITY2M SSC011A-S01A-S" + ) + self.assertEqual(log_uploader._device_label("Onion"), "Onion") + + +if __name__ == "__main__": + unittest.main() diff --git a/linux/tests/test_linux_release_versions.py b/linux/tests/test_linux_release_versions.py new file mode 100644 index 0000000..bc1aa06 --- /dev/null +++ b/linux/tests/test_linux_release_versions.py @@ -0,0 +1,88 @@ +import re +import unittest +from pathlib import Path + +LINUX_DIR = Path(__file__).resolve().parent.parent +CONFIG_PY = LINUX_DIR / "raofflineproxy" / "config.py" + +# The release process is manual: no CI builds these bundles and no script bumps the +# version, so each target is another place to forget on release day. A stale APP_VERSION in +# a target's common.sh is not cosmetic โ€” it is the value the app reports as its current +# version, which update_status() compares against the latest release to decide whether an +# update is available. Get it wrong and that target either never offers an update or keeps +# offering one the user already has. +# +# The three declaration styles in use, all matched below: an APP_VERSION/VERSION assignment +# defaulting off RAOFFLINEPROXY_APP_VERSION (allium, spruce, muos, rocknix), and the version +# baked straight into the artifact filename (onion, which does it twice; knulli). +VERSION = r"\d+\.\d+\.\d+(?:-[a-z]+\d+)?" +DECLARATION_PATTERNS = ( + re.compile(rf"RAOFFLINEPROXY_APP_VERSION:-({VERSION})"), + re.compile(rf"RAOfflineProxy-[A-Za-z]+-v({VERSION})"), +) + + +def _config_fallback_version() -> str: + match = re.search( + rf'APP_VERSION = os\.environ\.get\("RAOFFLINEPROXY_APP_VERSION"\) or "({VERSION})"', + CONFIG_PY.read_text(encoding="utf-8"), + ) + assert match, "config.py APP_VERSION fallback not found โ€” did its shape change?" + return match.group(1) + + +def _declared_versions(path: Path) -> set[str]: + text = path.read_text(encoding="utf-8") + return {m for pattern in DECLARATION_PATTERNS for m in pattern.findall(text)} + + +def _build_bundles() -> dict[Path, set[str]]: + return {p: _declared_versions(p) for p in sorted(LINUX_DIR.glob("*/build_bundle.sh"))} + + +def _common_shs() -> dict[Path, set[str]]: + versions = {} + for common in sorted(LINUX_DIR.glob("*/app/*/common.sh")): + found = re.findall( + rf"^APP_VERSION=v?({VERSION})", common.read_text(encoding="utf-8"), re.MULTILINE + ) + versions[common] = set(found) + return versions + + +class ReleaseVersionConsistencyTests(unittest.TestCase): + def test_every_declared_version_matches_the_config_fallback(self) -> None: + expected = _config_fallback_version() + drift = { + str(path.relative_to(LINUX_DIR)): sorted(found - {expected}) + for path, found in {**_build_bundles(), **_common_shs()}.items() + if found - {expected} + } + self.assertEqual( + drift, {}, f"version drift against config.py's {expected!r} โ€” bump these too" + ) + + def test_every_target_declares_a_version_somewhere(self) -> None: + # Guards the patterns themselves. Without this, a target that starts declaring its + # version some new way would contribute zero matches, and the drift check above + # would pass vacuously on it โ€” silently losing the coverage this file exists for. + undeclared = [ + str(path.relative_to(LINUX_DIR)) + for path, found in {**_build_bundles(), **_common_shs()}.items() + if not found + ] + self.assertEqual( + undeclared, + [], + "no version found โ€” add its declaration style to DECLARATION_PATTERNS", + ) + + def test_all_known_targets_are_discovered(self) -> None: + found = {path.parent.name for path in _build_bundles()} + self.assertEqual( + found, {"allium", "knulli", "muos", "onion", "rocknix", "spruce"} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/linux/tests/test_linux_spruce.py b/linux/tests/test_linux_spruce.py index 1bc5aff..7cdab93 100644 --- a/linux/tests/test_linux_spruce.py +++ b/linux/tests/test_linux_spruce.py @@ -22,13 +22,13 @@ def test_running_on_onion_is_false_when_spruce_marker_present(self) -> None: with patch.object(config, "running_on_spruce", return_value=True): with patch.object(Path, "exists", return_value=True): self.assertFalse(config.running_on_onion()) - self.assertTrue(config.running_on_onion_or_spruce()) + self.assertTrue(config.running_on_shared_miyoo_stack()) def test_running_on_onion_still_true_without_spruce_marker(self) -> None: with patch.object(config, "running_on_spruce", return_value=False): with patch.object(Path, "exists", return_value=True): self.assertTrue(config.running_on_onion()) - self.assertTrue(config.running_on_onion_or_spruce()) + self.assertTrue(config.running_on_shared_miyoo_stack()) def test_onion_wins_when_a_stale_spruce_directory_is_left_on_the_card(self) -> None: # Reinstalling Onion over a card that once ran spruce leaves /mnt/SDCARD/spruce