From 76717495332ae09b8c4d086a51d9bc23d401d141 Mon Sep 17 00:00:00 2001 From: Andrii Balakhtar Date: Fri, 30 Oct 2026 21:37:12 +0100 Subject: [PATCH] Device: run inside PyUI's process instead of launch.sh Opening Cheevos used to make PyUI exit so principal.sh could run launch.sh, which started a second Python, brought up its own PyUI for the platform, and left PyUI to start again from scratch on the way back. With spruceUI/spruceOS#NNNN, PyUI runs the app in its own process instead: config.json names an entry point ("pyuiEntry": "cheevos.native:run"), PyUI calls it from the Apps menu, or with game_id from the game menu's "View achievement progress", and unloads every module from App/Cheevos when it returns. - Add cheevos.native: run() logs to the app's own log and turns an error into a message; session() takes out everything the app puts into PyUI (texture budget, log redaction factory), waits for the cheevos-* workers, and copies the API key to Spruce's setting. - Add ui/pyui/host.py for what a session asks of PyUI. Spruce's Web API Key setting is now changed through PyUI's CfwSystemConfig: launch.sh's jq edit would be overwritten by PyUI's copy in memory. - Remove launch.sh, cheevos/__main__.py, platform/arguments.py and ui/pyui/bootstrap.py, with its device workarounds (Wi-Fi status warm-up, the Mini's backlight helper, our own PyUI state file). The desktop runner's PyUI start-up moves to platform/desktop/pyui_setup.py. - make launch [GAME=id] opens Cheevos on a device the way the menus do: scripts/device_session.py (dev only, not packaged) restarts with the running PyUI's environment and calls PyUI's own runner. - CI's spruceos job refuses to open the mirror PR until the target branch has PyUI's runner (App/PyUI/main-ui/apps/native_app.py). - tests/screens/test_native_sessions.py runs two sessions in a desktop PyUI and checks that no Cheevos module, object, thread or hook is left behind. On a Miyoo Mini Plus, Cheevos opens in about 0.5 s and closes in about 0.1 s. After closing, PyUI keeps 6-7 MB more, which stayed flat over six sessions. --- .agents/code.md | 28 +-- .agents/device.md | 105 +++++------ .agents/integration.md | 27 +-- .agents/product.md | 4 +- .agents/pyui.md | 104 +++++++---- .agents/sync-and-storage.md | 1 - .github/workflows/ci.yml | 11 +- AGENTS.md | 13 +- Makefile | 6 +- RELEASING.md | 10 +- TESTING.md | 30 +++- app/config.json | 2 +- app/launch.sh | 148 ---------------- docs/Installation.md | 2 +- docs/Troubleshooting.md | 4 +- pyproject.toml | 6 +- scripts/build_package.py | 8 +- scripts/device.sh | 63 +++++-- scripts/device_session.py | 120 +++++++++++++ scripts/release_notes.py | 2 +- src/cheevos/__main__.py | 108 ----------- src/cheevos/app.py | 7 +- src/cheevos/core/ra_client/redact.py | 24 ++- src/cheevos/native.py | 131 ++++++++++++++ src/cheevos/platform/arguments.py | 21 --- src/cheevos/platform/desktop/__init__.py | 2 +- src/cheevos/platform/desktop/__main__.py | 82 ++++++--- src/cheevos/platform/desktop/pyui_setup.py | 115 ++++++++++++ src/cheevos/ui/pyui/__init__.py | 5 +- src/cheevos/ui/pyui/bootstrap.py | 197 --------------------- src/cheevos/ui/pyui/host.py | 61 +++++++ src/cheevos/ui/pyui/texture_budget.py | 27 ++- src/cheevos/ui/strings.py | 1 + tests/screens/native_session.py | 93 ++++++++++ tests/screens/test_native_sessions.py | 49 +++++ tests/unit/test_build_package.py | 12 +- tests/unit/test_game_launch.py | 15 +- tests/unit/test_native.py | 162 +++++++++++++++++ tests/unit/test_startup_imports.py | 5 +- tests/unit/test_ui_bootstrap.py | 93 ---------- tests/unit/test_ui_textures.py | 14 ++ 41 files changed, 1135 insertions(+), 783 deletions(-) delete mode 100755 app/launch.sh create mode 100644 scripts/device_session.py delete mode 100644 src/cheevos/__main__.py create mode 100644 src/cheevos/native.py delete mode 100644 src/cheevos/platform/arguments.py create mode 100644 src/cheevos/platform/desktop/pyui_setup.py delete mode 100644 src/cheevos/ui/pyui/bootstrap.py create mode 100644 src/cheevos/ui/pyui/host.py create mode 100644 tests/screens/native_session.py create mode 100644 tests/screens/test_native_sessions.py create mode 100644 tests/unit/test_native.py delete mode 100644 tests/unit/test_ui_bootstrap.py diff --git a/.agents/code.md b/.agents/code.md index f62f450..e956ce4 100644 --- a/.agents/code.md +++ b/.agents/code.md @@ -20,7 +20,7 @@ in an upstream PR. │ core/ models · storage · ra_client · sync · settings · │ credentials · screenshots · proxy · local_games ├─────────────────────────────────────────────────────────────┤ -│ platform/ paths · env detection · device bootstrap · desktop runner (dev only) +│ platform/ paths · env detection · ROM identification · desktop runner (dev only) └─────────────────────────────────────────────────────────────┘ ``` @@ -44,7 +44,8 @@ in an upstream PR. ``` src/cheevos/ - __main__.py device entry point: python -m cheevos + native.py the entry point PyUI calls (config.json "pyuiEntry"): one session + inside PyUI's process, and everything it puts into PyUI taken out again app.py composition root: credentials, caches, sync, media, then home core/ models.py dataclasses: UserProfile, GameProgress, Achievement, Award, Unlock, ... @@ -72,8 +73,10 @@ src/cheevos/ net.py connectivity check through the shared transport clock.py shared RA Date + monotonic app clock (system clock unchanged) ui/ - pyui/ bridge: bootstrap, views (lists, grids, carousel, popups), primitives - (drawing, input), text (fitting, glyph fallbacks), status_bar, + pyui/ bridge: host (what a session borrows from PyUI: textures, + messages, Spruce's settings), views (lists, grids, carousel, + popups), primitives (drawing, input), text (fitting, glyph + fallbacks), status_bar, bar_layout, glyphs, generated (PNG swatches, button glyphs), award_images (static awards), row_bars, bar_colors, image_frames (shared outlines), badge_grid (compact badge grid), carousel (the @@ -87,23 +90,23 @@ src/cheevos/ strings.py every user-facing string (ready for PyUI Language later) platform/ paths.py every path, from CHEEVOS_SDCARD_ROOT (default /mnt/SDCARD) - arguments.py the --game-id argument shared by the launcher and desktop runner identify_rom.py the identification helper's entry point (identify-rom.sh runs it) curl_transport.py the helper's RA requests through Spruce's curl, Python's HTTPS as the fallback proxy_hasher.py adapter over RAOfflineProxy's rom_hashing wrapper (its only import from the proxy) - desktop/ dev only: runner CLI on PyUI's DESKTOP device, fixture/live - environments, drills (showcase_awards: the showcase's event and site - badges) + desktop/ dev only: runner CLI on PyUI's DESKTOP device (pyui_setup brings + PyUI up), fixture/live environments, drills (showcase_awards: the + showcase's event and site badges) res/icons/ original outlines: 24/48 px status, 96/144 px lists and fallbacks res/awards/ pre-rendered awards: dark/light and black/white contrast variants -app/ packaging: config.json, launch.sh, identify-rom.sh, cheevos.png +app/ packaging: config.json, identify-rom.sh, cheevos.png assets/ icon sources (SVG) tests/ unit and screen tests, fixtures/ scripts/ check_conventions, record_fixtures, fetch_dev_media, fetch_pyui.sh, build_package, release_notes, render_icons, make_synthetic_cache, - doc_screens, device.sh, fetch_proxy.sh, check_proxy_compat (with + doc_screens, device.sh (with device_session: make launch's + dev-only session), fetch_proxy.sh, check_proxy_compat (with proxy_native: ELF exports, a simulated native hasher) docs/ the user guide, published as the GitHub wiki (images/ from doc_screens) .agents/ docs for coding agents, by area (AGENTS.md is the entry point) @@ -141,7 +144,8 @@ On the device, everything is assembled into `/mnt/SDCARD/App/Cheevos/` ([device. `make check` runs all of these locally; `make fmt` formats and auto-fixes. Per-file ignores worth knowing about: - `PLC0415` (import outside top level) is allowed in `cheevos/ui/pyui/**` and the desktop - `__main__`, because PyUI is importable only after `add_pyui_to_path()`. + `__main__`, because PyUI is importable only inside PyUI's process or after the desktop + runner's `add_pyui_to_path()`. - `T20` (print) is allowed in `scripts/`. - Tests skip docstrings and annotations, and allow `assert`, magic numbers, subprocesses, many arguments and boolean flags. @@ -247,7 +251,7 @@ Tooling gotchas: How to run all of this: [TESTING.md](../TESTING.md). -## Shell (`launch.sh`, scripts) +## Shell (`identify-rom.sh`, scripts) - POSIX `sh`, compatible with busybox ash, checked with `shellcheck -s sh`. - Start by sourcing `/mnt/SDCARD/spruce/scripts/helperFunctions.sh` and `appEnv.sh`. Read only `SPRUCE_*` / `PLATFORM` variables from them. diff --git a/.agents/device.md b/.agents/device.md index b791b31..7db8e93 100644 --- a/.agents/device.md +++ b/.agents/device.md @@ -20,10 +20,9 @@ always apply. The device commands are in [TESTING.md](../TESTING.md). ``` /mnt/SDCARD/App/Cheevos/ - config.json {"label": "Cheevos", "icon": "cheevos.png", "launch": "launch.sh", - "description": "RetroAchievements hub"} (no "devices": every device) - launch.sh sources helperFunctions.sh, sets the per-platform SDL env (mirroring PyUI's - launcher), runs Spruce's Python with -m cheevos; stderr to /tmp + config.json {"label": "Cheevos", "icon": "cheevos.png", "pyuiEntry": "cheevos.native:run", + "description": "RetroAchievements hub", "gameProgress": 2} + (no "launch": PyUI runs it in its own process; no "devices": every device) identify-rom.sh Spruce's game menu runs it to identify a ROM: sources appEnv.sh and execs -m cheevos.platform.identify_rom, headless ([integration.md](integration.md)) cheevos.png app icon @@ -32,22 +31,21 @@ always apply. The device commands are in [TESTING.md](../TESTING.md). ``` - `make package` builds it into `dist/App/Cheevos/` (no desktop runner, no tests) and zips that - as `dist/Cheevos-.zip`, with the `Cheevos/` prefix and no `App/` folder. Both shell - scripts are executable: Spruce runs them directly. -- `config.json`'s `"gameProgress"` tells Spruce what the package supports: 1 for - `launch.sh --game-id N`, 2 when `identify-rom.sh` ships too. Raise it only with a new contract + as `dist/Cheevos-.zip`, with the `Cheevos/` prefix and no `App/` folder. + `identify-rom.sh` is executable: Spruce runs it directly. +- The Apps launcher discovers any `App/*/config.json`. With `"pyuiEntry"`, PyUI runs Cheevos + inside its own process and unloads it when it closes ([pyui.md](pyui.md), "In-process + sessions"); it needs a SpruceOS whose PyUI has `apps/native_app.py`. An app with no `"launch"` + is known to PyUI's hidden-apps list by its folder. +- `config.json`'s `"gameProgress"` tells Spruce what the package supports: 1 for opening one + game (`run(game_id=N)`), 2 when `identify-rom.sh` ships too. Raise it only with a new contract Spruce checks for. -- The Apps launcher discovers any `App/*/config.json`. PyUI exits fully while the app runs and - restarts when it quits. - **No `devices` list:** a `devices` list in an app's `config.json` hides it on every device not - named there. Cheevos ships without one, so the Apps menu shows it on every device. - `launch.sh` covers every platform PyUI's launcher does. -- **Shared UI across devices:** use the same screens and application logic on every platform, - with platform setup handled by the launcher and PyUI bridge. -- **Start-up failures:** when the app exits non-zero, `launch.sh` appends its stderr to the app - log and shows a message ([product.md](product.md), "Offline and error behaviour"). - `/mnt/SDCARD/App/PyUI/launch.sh -msgDisplay "text" -msgDisplayTimeMs 5000` shows a message on - any platform. + named there. Cheevos ships without one, so the Apps menu shows it on every device: it runs + wherever PyUI does, with no platform set-up of its own. +- **Failures:** `cheevos.native.run` logs an error and shows a message; PyUI does the same for + one it raises before Cheevos's code runs ([product.md](product.md), "Offline and error + behaviour"). - The release is that zip; users copy its `Cheevos` folder into `App` on the SD card. Pushing a `v` tag publishes it as a GitHub release (the `release` job in `ci.yml`; steps in [RELEASING.md](../RELEASING.md), under "Releases"). The description links merged PR titles @@ -60,11 +58,14 @@ always apply. The device commands are in [TESTING.md](../TESTING.md). that neither Cheevos nor a game is running first. - **Stray taps:** once Cheevos exits (B on its home screen), further taps reach Spruce's menu, where A starts a game. A blind tap loop once launched a game this way. `device.sh press` now - refuses on the device unless Cheevos is running, and stops at the first refusal. Still, - take a screenshot between screens rather than sending long sequences. + refuses on the device unless Cheevos is running, and stops at the first refusal. Opened from + the menus, Cheevos has no process of its own: `device.sh running` (which `press` uses) looks + for PyUI holding the app's log open, or for a `make launch` session. In PyUI's own menus, A + on Settings' first row powers off. Still, take a screenshot between screens rather than + sending long sequences. - **`pgrep -f` self-match:** a plain `pgrep -f` pattern also matches the remote shell's own command line. Use the `[x]` trick (`'[c]heevos'`), and keep the plain text out of the rest of - the same remote script too: a later `grep "python3.10 -m cheevos..."` made an idle check + the same remote script too: a later `grep "python3.10 -m cheevos..."` once made an idle check find "Cheevos" (the shell itself). - **Backups must stay off device `/tmp`.** It is RAM-backed tmpfs. An 18 MB app backup during integration testing caused memory pressure, thread creation failures and a device @@ -80,16 +81,17 @@ always apply. The device commands are in [TESTING.md](../TESTING.md). - **Input injection:** `send_event /dev/input/event0 CODE:1|0`. Codes: A=57, B=29, X=42, Y=56, Start=28, Select=97, L1=18, R1=20, D-pad 103/108/105/106. Never send MENU (1), power or combos. -- **Launching an app remotely:** write `/tmp/cmd_to_run.sh` (PyUI's own format), then send - SIGTERM to PyUI; `principal.sh` runs the command and restarts PyUI afterwards. This also - records autoresume, exactly as launching from the Apps menu does. +- **Running a command remotely:** write `/tmp/cmd_to_run.sh` (PyUI's own format), then send + SIGTERM to PyUI; `principal.sh` runs the command and restarts PyUI afterwards. It also + records the command for autoresume. `make launch` uses it ([pyui.md](pyui.md), "In-process + sessions"). - **Graphics memory:** SDL textures come from the MMA pool (`mma_heap=…sz=0x1500000`, 21 MB), not from the RAM `MemAvailable` reports. The framebuffer and PyUI's screen-sized canvases take about 11.5 MB of it, leaving about 9 MB for textures. Free space: `/proc/mi_modules/mi_sys_mma/mma_heap_name0` (`chunk_mgr_avail`, hex). Big textures need one contiguous block. See [pyui.md](pyui.md), "Texture caches never evict". -- **Log times are UTC** (marked `Z`): PyUI applies the saved time zone partway through - start-up, so local times would jump within one run. +- **Log times are UTC** (marked `Z`), comparable with logs from before the app ran inside + PyUI (which applies the saved time zone partway through its start-up). - **PIDs wrap at 4096**, so a low PID doesn't mean an old process. Check `/proc//stat` against `/proc/uptime` for its age. - **Network services:** Spruce restarts Samba and SFTPGo after every app exits; SSH (dropbear) @@ -102,6 +104,10 @@ always apply. The device commands are in [TESTING.md](../TESTING.md). The hardware: a dual-core Cortex-A7 at 1.2 GHz, 128 MB RAM (103 MB for Linux, the rest for the graphics pool), slow SD writes. Big lists and image-heavy screens need a check on it. +- **Inside PyUI** (2026-10-30): the same memory as the separate process it used to be while + open, and ~6-7 MB kept by PyUI after it closes, flat from one session to the next; opens in + 0.5 s ([pyui.md](pyui.md), "In-process sessions"). The figures below were taken when Cheevos + was a separate process (with PyUI exited). - **Memory headroom is small.** Measured 2026-10-14 on Spruce 4.5.5 nightly with a 2,948-game account: about 17 MB available on Home, with RAOfflineProxy's service at 30–36 MB beside Cheevos (33–36 MB RSS). Browsing images fills the 4 MB `/tmp` image scratch, and the heap @@ -112,43 +118,26 @@ graphics pool), slow SD writes. Big lists and image-heavy screens need a check o - **CPU:** measure per thread from `/proc//task/*/stat` (fields 14 + 15, 1/100 s ticks); 100 ticks a second is one core. Busybox `top` shows % of both cores together. -### Start-up -Measured 2026-10-14 (Spruce 4.5.5 nightly, PyUI `346b4092`, warm, 2,948-game account), before -[SpruceOS #1857](https://github.com/spruceUI/spruceOS/pull/1857): Home's first frame appeared -4.1–4.5 s after Python started, down from 4.6–4.9 s before Cheevos deferred its screens and -sync imports. The first start after an update also compiled every module, about 1.5 s more. -Python's imports took most of it (3.4 s): finding and reading each module from the SD card -costs a few ms before its code even runs, and each dataclass 5–7 ms to build. The upstream -factory and game-menu fixes have not yet been timed on a device. +### Opening +Inside PyUI (2026-10-30, Mini+, warm): A to Cheevos running 0.5 s, nearly all of it Python +importing the package from the SD card; Home's first frame 0.05 s later. The first open after an +update also compiles every module (3 s). As a separate process (2026-10-14, 2,948-game +account) Home appeared 4.1–4.5 s after Python started, 3.4 s of it imports: finding and reading +each module from the SD card costs a few ms before its code even runs, and each dataclass +5–7 ms to build. - **Rule: import what Home's first frame doesn't need on first use.** Every screen but Home (`home._open`), the badge grid and carousel (`views`), the RA client and transport (`app`'s factories), the sync engine (on the sync thread, `background`) and `email.utils` (`clock`) load when first used; `ra_client/__init__.py` imports nothing. Names the UI needs without the engine live in `sync/options.py`; `Credentials` lives in `core/credentials.py`. - `tests/unit/test_startup_imports.py` fails if any of them loads with `cheevos.app`: add a - new screen or worker to its list. The modules that import on first use are listed in - `pyproject.toml`'s per-file `PLC0415` ignores. The cost moves to each screen's first - opening (Games +0.3 s, Profile +0.2 s, Awards +0.1 s the first time). + `tests/unit/test_startup_imports.py` fails if any of them loads with `cheevos.native` (what + PyUI imports): add a new screen or worker to its list. The modules that import on first use + are listed in `pyproject.toml`'s per-file `PLC0415` ignores. The cost moves to each screen's + first opening (Games +0.3 s, Profile +0.2 s, Awards +0.1 s the first time). Inside PyUI every + open imports the package again, since closing unloads it. - **The auto-sync starts after Home's first frame** (`Home(sync_when_shown=...)`, on its first tick): started before, the sync thread's imports competed with the first frame (+0.3 s). -- **PyUI's upstream device factory:** `bootstrap.pyui_device` imports `initialize_device` - from `devices.device_factory` for every device, including `DESKTOP`. This avoids the - launcher imports without copying any device branches; errors inside the factory surface - as start-up errors. `tests/unit/test_ui_bootstrap.py` checks that importing it leaves the - launcher and its game menus unloaded. -- **PyUI's game menus load on first activity-icon lookup:** SpruceOS #1857 (merge commit - `a3c494b1`) moved the two menu imports into `MiyooTrimCommon.get_image_for_activity`, which - Cheevos never calls. On a Mac, after importing PyUI's shared UI modules, the Mini device - adds 48 modules instead of 141, and the Brick 43 instead of 137. The pre-fix device timing - put about 0.36 s in these menu imports; the new hardware saving remains unmeasured. -- **PyUI's Wi-Fi status is read on a thread during the display's start-up** - (`bootstrap._warm_wifi_status`): its first read runs `wifiCmd status` (0.4 s) and the top - bar draws it from the first frame. -- **PyUI's remaining share** in the pre-fix measurements: `display.display` imports pysdl2's - `sdl2.ext`, whose `resources` module imports `urllib.request` with `http.client`, `email` - and `ssl` (0.2 s). `Display.init` took about 1 s (0.63 s re-initialising, 0.32 s loading - fonts). Don't plant stub modules to skip them: Spruce updates PyUI without a Cheevos release. - **ROM identification helper** (`identify-rom.sh`), measured 2026-10-18 on the Mini+ with Spruce 4.5.5 and RAOfflineProxy 2.2.0-alpha1 nightly 54, warm, from the shell to the JSON: a saved path 0.6-0.7 s (1.3 s on the first run, compiling bytecode): `appEnv.sh` 0.05 s, @@ -173,6 +162,6 @@ factory and game-menu fixes have not yet been timed on a device. (1.9-2.1 s either way). The first lookup after a reboot spent 11 s hashing and reading the proxy's cache, against 0.25 s afterwards. - **How it was measured:** a patched build (never shipped) logs the first frame's time from - the process start (`/proc/self/stat`), and can run `cProfile` up to it or start Python with - `-X importtime`. A launch with `-X` doesn't match `pgrep -f 'python3[.]10 -m [c]heevos'` - (`device.sh` misses it): stop it by PID. + the process start (`/proc/self/stat`), and can run `cProfile` up to it. `make launch` passes + `DEVICE_PYTHON_FLAGS` to PyUI's Python (e.g. `-X importtime`, written to + `/tmp/cheevos-dev/stderr.log`). diff --git a/.agents/integration.md b/.agents/integration.md index d4a7142..0d1ca74 100644 --- a/.agents/integration.md +++ b/.agents/integration.md @@ -2,9 +2,12 @@ Cheevos reads other software's files on the SD card and never writes them. It writes only `Saves/cheevos/`, `/tmp/cheevos/` and its log. Every path comes from -`cheevos.platform.paths`. One exception: after the app exits, `launch.sh` copies the Web API -key into Spruce's `webApiKey` setting (RetroAchievements Settings), when Spruce has it, so a key -typed in the app shows there too. PyUI isn't running at that point, so it can't overwrite it. +`cheevos.platform.paths`. One exception: when a session ends, Cheevos copies the Web API key +into Spruce's `webApiKey` setting (RetroAchievements Settings), when Spruce has it and it +differs, so a key typed in the app shows there too. It goes through PyUI's `CfwSystemConfig` +(`ui/pyui/host.py`): PyUI keeps Spruce's settings in memory, and a write to the file behind its +back would be lost the next time PyUI saves them. PyUI also runs the setting's change command, +which writes the same key to `apikey.txt`. ## Credentials and first run (`core/credentials.py`) - **Username**, first non-empty of: @@ -57,9 +60,10 @@ hashes one ROM the player picks in Spruce; scanning a whole library the same way the helper's saved paths, could close that gap. ## Direct launch from Spruce (`core/identification/`, `platform/identify_rom.py`) -Spruce's game popup ("View achievement progress") resolves the selected ROM, then launches -`Cheevos/launch.sh --game-id N`. Spruce owns the menu, its fast paths, cancellation, -messages and the launch. Cheevos owns the rest, in a headless helper process. +Spruce's game popup ("View achievement progress") resolves the selected ROM, then opens that +game's session inside PyUI (`cheevos.native.run(game_id=N)`, see [pyui.md](pyui.md), +"In-process sessions"); B returns to the game menu. Spruce owns the menu, its fast paths, +cancellation, messages and the launch. Cheevos owns the rest, in a headless helper process. **Why a Cheevos helper, not a proxy command:** the first version (SpruceOS #1885) added an `identify-rom` command to Spruce's bundled RAOfflineProxy. A Spruce OTA then copied three @@ -89,8 +93,8 @@ wrapper, from its own process. `Saves/cheevos/cache/identify-work` and removes it afterwards, so cleanup also works after a forced kill. The launcher sources `appEnv.sh` (its output to stderr), sets `PLATFORM` and `PYTHONPATH`, changes to the app directory (Python puts the working directory on `sys.path`) - and `exec`s `$SPRUCE_PYTHON -m cheevos.platform.identify_rom`. It never goes through - `launch.sh` or `cheevos.__main__`, which start the UI. It adds no library path: + and `exec`s `$SPRUCE_PYTHON -m cheevos.platform.identify_rom`. It never loads the UI + (`cheevos.native`). It adds no library path: `rom_hashing` finds `lib//libraproxy_rchash.so` itself (it needs only libc). 4. **The result contract.** One JSON object on stdout; diagnostics go to the app log: `{"status":"matched","game_id":1479}`, `{"status":"no_match"}` or @@ -101,9 +105,10 @@ wrapper, from its own process. `timeout` and the `cancelled` status. Spruce treats malformed output or a crash as a failed lookup, keeps a structured error whatever the exit status, and accepts positive integer game IDs only (no booleans, no upper bound). -5. **Availability.** Spruce offers the action when Cheevos's `config.json` has - `"gameProgress": 2` or more (1: `--game-id` launches; 2: also ships `identify-rom.sh`) and - both scripts exist. A missing or broken proxy shows the `dependency` message instead. +5. **Availability.** Spruce offers the action when Cheevos's `config.json` has a + `"pyuiEntry"` and `"gameProgress": 2` or more (1: the entry opens one game with + `game_id=N`; 2: also ships `identify-rom.sh`), and the helper exists. A missing or broken + proxy shows the `dependency` message instead. **Inside the helper** (in this order; each step loads its code only when reached): - **Saved paths:** `Saves/cheevos/cache/rom-identifications.json` (256 entries, at most diff --git a/.agents/product.md b/.agents/product.md index 123614f..a95dd28 100644 --- a/.agents/product.md +++ b/.agents/product.md @@ -314,5 +314,5 @@ so the bridge shows "[START] Done [B] Delete [L1] Shift [R1] Caps" in the bottom | Proxy missing or unreadable | Hide the Settings status row and pending markers. | | Event details unavailable (never fetched offline, or RA's v2 API refused) | Event badges keep RA's own title and image, described as "Event award"; the sync carries on (not "API key rejected"), and the next sync asks again. Fetched details are kept for offline browsing. | | Theme lacks an asset we use | Fall back to our bundled icons (`cheevos/res/`) or generate it (button badges). | -| The app can't start (PyUI import, bootstrap, any crash) | `launch.sh` sees the non-zero exit, appends stderr to the app log (errors before logging starts only reach stderr), and shows "Cheevos couldn't start" with the log's path through `App/PyUI/launch.sh -msgDisplay` (PyUI's launcher sets up every platform's display). Verified on the Mini. | -| Unknown platform | `launch.sh` shows "Cheevos doesn't support this device yet" the same way. | +| An error | `cheevos.native.run` logs it to the app log, takes every hook out of PyUI, shows "Cheevos stopped after an error. Details: Saves/spruce/cheevos-.log" for 5 s, and returns to Spruce's menu. An error before Cheevos's own code runs (a broken or incomplete package) is logged by PyUI (`Saves/spruce/pyui.log`), which shows "The app stopped after an error." Checked on the desktop, not yet on a device. | +| Unknown platform | None: Cheevos runs wherever PyUI does, with no platform set-up of its own. | diff --git a/.agents/pyui.md b/.agents/pyui.md index 9d39e02..b2b02da 100644 --- a/.agents/pyui.md +++ b/.agents/pyui.md @@ -5,26 +5,67 @@ PyUI is SpruceOS's Python/SDL2 UI toolkit. Cheevos imports it at runtime from weekly). Only `cheevos.ui.pyui` (the bridge) and `cheevos.platform.desktop` (the desktop runner) may import it ([code.md](code.md), "Layers"). Read this file before touching either. -PyUI can be started from a separate app (`app/launch.sh` mirrors the platform branch of -PyUI's own launcher; measured start-up times are in [device.md](device.md)). Its views draw -what the app needs: lists with icons and right-aligned values, badge grids, popups and custom -screens. Progress bars, frames and the bottom-bar status are drawn by the bridge on top of -PyUI's views, without patching PyUI. +Cheevos runs only inside PyUI's own process, as the Activity Tracker does ("In-process +sessions", below); it has no launcher of its own. The desktop runner brings PyUI up itself +first ("The desktop runner's start-up"), then runs the same session. PyUI's views draw what the +app needs: lists with icons and right-aligned values, badge grids, popups and custom screens. +Progress bars, frames and the bottom-bar status are drawn by the bridge on top of PyUI's +views, without patching PyUI. + +## In-process sessions +- **How PyUI opens it:** `config.json` has `"pyuiEntry": "cheevos.native:run"`. PyUI's + `apps/native_app.py` (SpruceOS) appends `App/Cheevos` to `sys.path` (PyUI stays at + `sys.path[0]`), imports the module and calls `run()` from the Apps menu, or `run(game_id=N)` + from the game menu's "View achievement progress", in PyUI's menu loop: PyUI doesn't exit, and + B returns straight to its menu. It clears its texture caches before and after, and when + `run` returns it drops every module imported from the app's folder, the loggers of those + packages and their importer and `linecache` entries, then runs `gc.collect()` and glibc's + `malloc_trim`. An exception escaping the entry point is logged to PyUI's log and shown as + "The app stopped after an error"; PyUI carries on. +- **What `cheevos.native` does:** `run` adds the app log (a handler on the `cheevos` logger, + which stops propagating, so neither log gets the other's records), then runs `session`, which + the desktop runner also runs. `run` catches any error, logs it and shows "Cheevos stopped after + an error" with the log's path. PyUI is already up and owns its device, configs, theme, display, + controller, view state (`PyUiState`), logger and signal handlers; Cheevos sets none of them + up. Spruce's environment (`PLATFORM`, `SSL_CERT_FILE`) is PyUI's. PyUI's configs and Spruce's + are read, never written, except the API key copy below. +- **Leave PyUI as we found it.** `session` undoes everything it puts into PyUI, in reverse and + even after an error (an `ExitStack`): the texture budget (`host.enter`/`leave`); the bottom- + and top-bar hooks (`status_bar.installed`, `title_bar.installed`, inside `app.run`); the + workers (`app.run` closes them, then `_await_workers` waits up to 2 s more for any + `cheevos-*` thread); and last the log redaction's record factory (`uninstall_redaction`: it + is process-wide, and inside PyUI it would otherwise run our code for every PyUI record). It + also copies the API key into Spruce's settings through PyUI ([integration.md](integration.md)). + **Rule:** anything process-wide (a PyUI attribute, a logging hook, a signal handler, `atexit`, + a thread) is undone by the session. `tests/screens/test_native_sessions.py` opens and closes + the app twice in one PyUI process (once for a single game), drops its modules as PyUI does, + and fails if any Cheevos module, object, thread or hook survives; a new kind of hook needs a + check in `native_session.py`. +- **Memory** (Miyoo Mini+, 20-game account, 2026-10-30, same boot): while Cheevos is open the + system's anonymous memory is the same as with the separate process it used to be (Home + 51.8 MB against 52.5 MB, Awards 52.8 against 53.0; MemAvailable within 0.5 MB): PyUI's own + ~15 MB of dirty memory stays, but Cheevos no longer loads a second copy of Python and PyUI. + After closing, every Cheevos module, object, thread, file and texture is gone (Python's live + objects end within ~350 of where they started), but PyUI keeps ~6-7 MB more dirty memory than + before the first session: CPython never returns an arena while any block in it is alive, and glibc + doesn't trim its thread heaps. It's reused: over six sessions PyUI stayed flat at 22.2 MB + (±16 KB) from the second one on, so it doesn't grow per session. PyUI exits before every game, + which returns it all. Preloading the C extensions, one glibc arena, fixed trim thresholds and + a 256 KB SQLite page cache made no difference. Inside PyUI the sync imports `ssl`, + `http.client`, `email` and `sqlite3` (PyUI on devices loads none of them), and they stay + loaded: only the app's own folder is dropped. +- **Speed** (same device): A to Cheevos running 0.5 s warm, its Home 0.05 s later; B to + PyUI's menu with everything unloaded 0.1 s. The first open after an update compiles the + package (3 s). As a separate process it took 1.8 s to its UI, plus PyUI's exit before and + its restart after. +- **On a device without the menus:** `make launch [GAME=]` saves the running PyUI's + environment and command line, asks it to exit, and `principal.sh` runs + `scripts/device_session.py` with PyUI's interpreter: it brings PyUI up as `mainui.py` does + and opens Cheevos through PyUI's own `native_app.run`, then exits, and PyUI restarts. Dev + only, never packaged; it has no per-platform set-up to maintain. Its memory figures leave + out PyUI's menus. ## The bridge -- **Bootstrap**: mirror `mainui.py` start-up: - 1. `PyUiConfig.init`, `UserConfig.reload_config`, `CfwSystemConfig.init`, `Language.init`; - 2. `initialize_device(, main_ui_mode=False)` (then the Wi-Fi status is read on a - thread, see [device.md](device.md), "Start-up"), `PyUiState.init`; - 3. `Theme.init`, `Display.init`, `Controller.init`. -- **Device name**: the same `-device` value PyUI's `launch.sh` would pass, e.g. - `MIYOO_MINI_PLUS`. `app/launch.sh` gets it from Spruce (`get_miyoo_mini_variant` on the Mini - family) and passes `CHEEVOS_PYUI_DEVICE`. Every device is created through - `devices.device_factory.initialize_device(..., main_ui_mode=False)` (the mode Spruce's - `-msgDisplay` helpers use). The factory was extracted from `mainui` in - [SpruceOS #1857](https://github.com/spruceUI/spruceOS/pull/1857), so creating a device no - longer loads the launcher or its game menus. The bridge keeps no copy of PyUI's device - table. - **Standard views** (`ViewCreator.create_view`): lists (`ICON_AND_DESC`) and popups (`POPUP`), through `views.choose()`, or a `views.PreparedView` that a screen keeps to show again ("Showing a view again", below). The same `PreparedView` builds our two subclasses of @@ -48,22 +89,16 @@ PyUI's views, without patching PyUI. `.spruceos/`. The weekly `pyui-drift` workflow tests the latest `Development`. Bump the commit after re-verifying against a newer SpruceOS. -## Start-up gotchas +## The desktop runner's start-up +`platform/desktop/pyui_setup.py` mirrors `mainui.py`'s start-up for PyUI's `DESKTOP` device +(`initialize_device("DESKTOP", main_ui_mode=False)`): configs, device, time zone, view state, +theme, display, controller. - **Import path:** PyUI must sit at `sys.path[0]`; `Language` finds `lang/` relative to it - (`bootstrap.add_pyui_to_path`). + (`add_pyui_to_path`). - **Logging:** never call `PyUiLogger.init`. It replaces stdout/stderr. Inject our logger into - `PyUiLogger._logger` instead (`bootstrap.install_logger`). Skip - `Theme.convert_theme_if_needed` (PyUI already did it at its own start-up). -- **Configs:** PyUI's and Spruce's configs are read, never written. PyUI's view state goes to - our own `Saves/cheevos/pyui-state.json`. `UserConfig.FILEPATH` is hard-coded to the SD card, - so the desktop bootstrap overrides it. -- **Timezone:** call `device.restore_saved_timezone()` after creating the device, or every - clock shows UTC. PyUI's launcher does this; we must too. -- **Screensaver:** in non-launcher mode (`main_ui_mode=False`) the Mini lacks - `miyoo_mini_flip_shared_memory_writer`, so the screensaver can't dim the backlight. The - bridge creates it (`bootstrap._enable_backlight_control`). -- **Fullscreen window:** PyUI opens a fullscreen window at the monitor's size, through - `device.create_window()`; its `DESKTOP` device opens a normal window instead. + `PyUiLogger._logger` instead (`install_logger`). +- **Configs:** `UserConfig.FILEPATH` is hard-coded to the SD card, so the runner points it at + the fake card; PyUI's view state goes to `Saves/cheevos/pyui-state.json` there. ## View gotchas - **Timeouts:** `view.get_selection()` never returns `None`. On a timeout or cursor move it @@ -207,8 +242,9 @@ PyUI's views, without patching PyUI. path forever. When an allocation fails, it flushes both and retries ("Clearing cache : Out of memory" in the log). On the Mini that happened every half minute of browsing long lists, and a big screenshot texture could fail to load. `texture_budget.py` swaps each cache's dict for - an LRU of 2.5 screens' worth of pixels (3 MB each at 640×480), installed after - `Display.init`. Text that changes every frame (sync progress) is still drawn with + an LRU of 2.5 screens' worth of pixels (3 MB each at 640×480) for the session + (`host.enter`), and `uninstall` destroys them and gives PyUI plain dicts back when it ends. + Text that changes every frame (sync progress) is still drawn with `alpha=255`, which skips the cache entirely. - **PyUI logs text it can't draw:** `Display.render_text` logs the string on SDL errors ("SDL Error received on loading "), which do happen when memory runs low. Don't put secrets diff --git a/.agents/sync-and-storage.md b/.agents/sync-and-storage.md index 55bb3ed..ee87a80 100644 --- a/.agents/sync-and-storage.md +++ b/.agents/sync-and-storage.md @@ -15,7 +15,6 @@ the app deletes it and starts fresh. |---|---|---|---| | API key | `Saves/cheevos/apikey.txt` | User data | Plain text, user-editable | | Settings | `Saves/cheevos/settings.json` | User data | JSON, written atomically (temp file + rename). Unknown keys are ignored and invalid values fall back to defaults. | -| PyUI view state | `Saves/cheevos/pyui-state.json` | Cache | PyUI's last selections, kept apart from the launcher's own state. | | RA data cache | `Saves/cheevos/cache/data.db` | Cache | SQLite: profile, games, achievements, awards, events, sync state. Rebuildable from RA. | | Image cache | `Saves/cheevos/cache/media.db` | Cache | SQLite blobs keyed `badge/[_lock]`, `icon/`, `avatar/`, `award/` (event and site badges, e.g. `award/Images/122639`). Kept separate so "Clear image cache" doesn't drop RA data. | | ROM identifications | `Saves/cheevos/cache/rom-identifications.json` | Cache | Game IDs the identification helper found, by ROM path, for Spruce's "View achievement progress". JSON, at most 256 entries and 256 KB, written atomically. Spruce reads its matches directly, so their shape is a contract ([integration.md](integration.md), "Direct launch from Spruce"). | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f32909c..752e5f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,15 @@ jobs: sparse-checkout: | App/Cheevos Licenses/Cheevos + App/PyUI/main-ui/apps + + - name: Check that SpruceOS can run the app + run: | + # Cheevos has no launch script: PyUI runs it in its own process (pyuiEntry). + if [ ! -f build/spruceos/App/PyUI/main-ui/apps/native_app.py ]; then + echo "::error::SpruceOS $SPRUCEOS_BASE_BRANCH has no PyUI in-process app runner (App/PyUI/main-ui/apps/native_app.py). Merge it first, then rerun this job (see RELEASING.md)." + exit 1 + fi - name: Mirror the released app run: | @@ -218,7 +227,7 @@ jobs: rsync --archive --delete --exclude /LICENSE \ build/spruce-release/Cheevos/ build/spruceos/App/Cheevos/ cp build/spruce-release/Cheevos/LICENSE build/spruceos/Licenses/Cheevos/LICENSE.Cheevos.txt - chmod 755 build/spruceos/App/Cheevos/launch.sh build/spruceos/App/Cheevos/identify-rom.sh + chmod 755 build/spruceos/App/Cheevos/identify-rom.sh - name: Build the SpruceOS pull request description env: diff --git a/AGENTS.md b/AGENTS.md index a7b445f..a41bebc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Instructions for coding agents. Claude Code reads this file itself (v2.1.277+), | `retroachievements.md` | The RA client and API quirks; the website's rules we copy (colours, profile stats); secrets | | `sync-and-storage.md` | Caches, the sync engine, threads; FAT32, SQLite and RAM limits | | `integration.md` | What we read from Spruce, RetroArch and RAOfflineProxy: credentials, screenshots, on-device games | - | `device.md` | Target platform, packaging and `launch.sh`, device tooling quirks, performance | + | `device.md` | Target platform, packaging, device tooling quirks, performance | - User docs: `docs/` (published as the GitHub wiki). Keep them in step with the app, and regenerate their screenshots with `make doc-screens` after UI changes. - `TESTING.md`: tests, desktop runner, drills, headless screenshots, device tools. @@ -38,7 +38,8 @@ Instructions for coding agents. Claude Code reads this file itself (v2.1.277+), ## Devices - `make deploy launch shot logs stop` with `DEVICE_HOST=` (or `DEVICE_SSH="ssh user@ip"`). `scripts/device.sh press down a b` taps buttons. Screenshots land in `build/device.png`. - Read them. + Read them. Cheevos runs inside PyUI's process (`.agents/pyui.md`, "In-process sessions"); + `make launch [GAME=]` opens it as PyUI's menus do, without tapping through them. - **Device safety:** - Write only inside `/mnt/SDCARD/App/Cheevos/`, `/mnt/SDCARD/Saves/cheevos/`, the app's log `Saves/spruce/cheevos-*.log`, and `/tmp`. @@ -51,10 +52,10 @@ Instructions for coding agents. Claude Code reads this file itself (v2.1.277+), and don't even list it recursively. - Only send single taps of A/B/X/Y/D-pad/L1/R1/Start/Select. Never MENU, power or combos: they trigger Spruce hotkeys. - - **Check that the device is idle before acting.** Run - `pgrep -f 'python3[.]10 -m [c]heevos'` and `pgrep -f '[r]a32[.]'` first. If Cheevos or a - game is running, someone may be using the device: don't launch, tap, or replace files. - Ask first. + - **Check that the device is idle before acting.** Run `scripts/device.sh running` (it also + sees Cheevos inside PyUI, where `pgrep` can't) and `pgrep -f '[r]a32[.]'` first. If + Cheevos or a game is running, someone may be using the device: don't launch, tap, or + replace files. Ask first. ## Ground rules - **Don't commit or push** unless asked: a maintainer reviews and commits changes. diff --git a/Makefile b/Makefile index bc73be9..da12e9b 100644 --- a/Makefile +++ b/Makefile @@ -79,10 +79,10 @@ package: ## Build the device package: dist/App/Cheevos and dist/Cheevos- or DEVICE_SSH=...) scripts/device.sh deploy -launch: ## Start Cheevos on the device remotely - scripts/device.sh launch +launch: ## Open Cheevos on the device as PyUI's menus do: make launch [GAME=] + scripts/device.sh launch $(GAME) -stop: ## Ask Cheevos on the device to exit +stop: ## Close a launched Cheevos on the device scripts/device.sh stop logs: ## Show the device log diff --git a/RELEASING.md b/RELEASING.md index 7df1fa8..7e0ca45 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -48,8 +48,14 @@ need only the `Cheevos-.zip`. ## Mirroring releases to SpruceOS The `spruceos` job replaces only `App/Cheevos/` in an upstream checkout, removing files that -are no longer in the device package. It preserves the launcher's executable bit and includes -Cheevos's MIT license. Source docs, tests and the desktop runner are not part of the package. +are no longer in the device package. It preserves the identification helper's executable bit +and includes Cheevos's MIT license. Source docs, tests and the desktop runner are not part of +the package. + +Cheevos has no launch script: PyUI runs it in its own process, which needs PyUI's in-process +app runner (`App/PyUI/main-ui/apps/native_app.py`, SpruceOS's own PR, not part of this mirror). +The job checks the target branch for it and fails without opening a PR until it is merged; +rerun the failed job afterwards. The PR links the Cheevos release and copies the published release's Changes section, including the linked PRs, commits and full changelog. This covers changes since the previous stable release, including intervening pre-releases (or the whole history for the first stable release). diff --git a/TESTING.md b/TESTING.md index 7f62668..aa6e093 100644 --- a/TESTING.md +++ b/TESTING.md @@ -243,13 +243,22 @@ same network. ```sh export DEVICE_HOST= # or DEVICE_SSH="ssh user@ip" for a full ssh command make package deploy # build dist/App/Cheevos and copy it to App/Cheevos -make launch # start Cheevos, as the Apps menu would +make launch # open Cheevos as PyUI's Apps menu does +make launch GAME=519 # ...or one game, as the game menu does make shot # screenshot into build/device.png -make logs # the app log and the last stderr -make stop # ask Cheevos to exit +make logs # the app log and the launched session's stderr +make stop # close a launched Cheevos; PyUI comes back +scripts/device.sh running # is Cheevos open, launched or from the menus? scripts/device.sh press down a b # tap buttons ``` +Cheevos runs inside PyUI's own process (`.agents/pyui.md`, "In-process sessions"). `make launch` +asks PyUI to exit and runs `scripts/device_session.py` in its place, with PyUI's interpreter, +environment and options: it brings PyUI up without its menus and opens Cheevos through PyUI's +own app runner; when Cheevos closes, PyUI restarts. `DEVICE_PYTHON_FLAGS="-X importtime"` +passes flags to that Python. To test Spruce's menus themselves, open Cheevos from Apps with +single taps, checking a screenshot before each A, and close it with B. + Password prompts work; an SSH key saves typing. The only thing `deploy` deletes is the old `App/Cheevos/cheevos` package before copying the new one. @@ -261,9 +270,11 @@ them in the device's `/tmp`. **Be careful with a device that's in use:** - Check that it's idle before launching, tapping or deploying: neither Cheevos nor a game should - be running (`pgrep -f 'python3[.]10 -m [c]heevos'`, `pgrep -f '[r]a32[.]'`). + be running (`scripts/device.sh running`, `pgrep -f '[r]a32[.]'`). Opened from the menus, + Cheevos has no process of its own: `running` looks for PyUI holding the app's log open. - `press` sends single taps, and only while Cheevos is running: a tap after it exits would reach - Spruce's menu, where A starts a game. Don't send MENU, power or button combinations: they + Spruce's menu, where A starts a game. In PyUI's own menus, A on Settings' first row powers + the device off. Don't send MENU, power or button combinations: they trigger Spruce hotkeys. - Treat everything outside `App/Cheevos/`, `Saves/cheevos/` and `/tmp` as read-only, and leave `Roms/` alone entirely. A ROM library is usually irreplaceable. @@ -298,10 +309,11 @@ cd /mnt/SDCARD/App/Cheevos && PLATFORM=MiyooMini \ The window, keyboard, gamepad, scripts and screenshots are PyUI's own `DESKTOP` device (SpruceOS `App/PyUI/main-ui/devices/desktop/`, documented in `App/PyUI/docs/desktop_readme.txt`; in a SpruceOS checkout, `python3 App/PyUI/main-ui/desktop.py` runs Spruce's whole launcher the -same way). Cheevos starts it with `devices.device_factory.initialize_device` -(`bootstrap.pyui_device("DESKTOP")`), falling back to `mainui.initialize_device` on older -SpruceOS releases. `src/cheevos/platform/desktop/` adds only Cheevos' side: the command line, -recorded fixtures or the live API, and the drills. +same way). On a device PyUI is already running when it opens Cheevos; on the desktop, +`platform/desktop/pyui_setup.py` starts it with `devices.device_factory.initialize_device`, as +PyUI's launcher would, then the runner opens the same session PyUI would +(`cheevos.native.session`). `src/cheevos/platform/desktop/` adds only Cheevos' side: the +command line, recorded fixtures or the live API, and the drills. - **Configuration:** the runner passes its options to PyUI as `PYUI_DESKTOP_*` variables (size, zoom, headless, script, capture folder). The fake card is PyUI's data folder, so the device's own settings land in its `Saves/desktop-system.json`. diff --git a/app/config.json b/app/config.json index a5cf882..4d73412 100644 --- a/app/config.json +++ b/app/config.json @@ -1,7 +1,7 @@ { "label": "Cheevos", "icon": "cheevos.png", - "launch": "launch.sh", + "pyuiEntry": "cheevos.native:run", "description": "RetroAchievements hub", "gameProgress": 2 } diff --git a/app/launch.sh b/app/launch.sh deleted file mode 100755 index 341eaba..0000000 --- a/app/launch.sh +++ /dev/null @@ -1,148 +0,0 @@ -#!/bin/sh -# Cheevos launcher. Spruce's Apps menu runs this through principal.sh after PyUI exits; -# PyUI restarts when it returns. -# -# Each branch mirrors the matching branch of the App/PyUI/launch.sh Cheevos ships with: the SDL -# libraries and drivers, the PyUI device name, the Python binary and the working directory -# PyUI uses on that platform. Cheevos supports every device SpruceOS supports. If the app -# can't start, or the platform is unknown, a message is shown through PyUI's own launcher, -# which knows every platform Spruce supports. - -# shellcheck source=/dev/null -. /mnt/SDCARD/spruce/scripts/helperFunctions.sh - -APP_DIR="$(cd "$(dirname "$0")" && pwd)" -STDERR_LOG="/tmp/cheevos-stderr.log" -PYTHON="" -RUN_DIR="$APP_DIR" -CLEANUP="" - -# Show a message with PyUI's own launcher (it sets up the display for every platform). -show_message() { - /mnt/SDCARD/App/PyUI/launch.sh -msgDisplay "$1" -msgDisplayTimeMs 5000 -} - -# PLATFORM comes from helperFunctions.sh. -# shellcheck disable=SC2154 -case "$PLATFORM" in - "MiyooMini") - export PATH="/mnt/SDCARD/spruce/miyoomini/bin:$PATH" - export PYSDL2_DLL_PATH="/mnt/SDCARD/spruce/miyoomini/lib" - export LD_LIBRARY_PATH="/mnt/SDCARD/spruce/bin/python/lib:$LD_LIBRARY_PATH" - export SDL_VIDEODRIVER=mmiyoo - export SDL_AUDIODRIVER=mmiyoo - export EGL_VIDEODRIVER=mmiyoo - export SDL_MMIYOO_DOUBLE_BUFFER=1 - # Mirror PyUI's menu audio setup: libpadsp sends /dev/dsp writes to audioserver. - pidof audioserver >/dev/null || { audioserver >/dev/null 2>&1 & sleep 0.5; } - export LD_PRELOAD="/mnt/SDCARD/miyoo/lib/libpadsp.so" - freemma - CHEEVOS_PYUI_DEVICE="$(get_miyoo_mini_variant)" - ;; - "A30") - ln -s /dev/ttyS2 /dev/ttyS0 2>/dev/null - CLEANUP="rm -f /dev/ttyS0" - export PYSDL2_DLL_PATH="/mnt/SDCARD/spruce/a30/sdl2" - CHEEVOS_PYUI_DEVICE="MIYOO_A30" - ;; - "Flip") - RUN_DIR="/usr/miyoo/bin" - export PYSDL2_DLL_PATH="/mnt/SDCARD/App/PyUI/dll" - PYTHON="/mnt/SDCARD/spruce/flip/bin/MainUI" - CHEEVOS_PYUI_DEVICE="MIYOO_FLIP" - ;; - "Brick" | "BrickPro" | "SmartPro" | "SmartProS") - RUN_DIR="/usr/trimui/bin" - export PYSDL2_DLL_PATH="/mnt/SDCARD/spruce/brick/sdl2" - PYTHON="/mnt/SDCARD/spruce/flip/bin/MainUI" - case "$PLATFORM" in - "Brick") CHEEVOS_PYUI_DEVICE="TRIMUI_BRICK" ;; - "BrickPro") CHEEVOS_PYUI_DEVICE="TRIMUI_BRICK_PRO" ;; - "SmartProS") CHEEVOS_PYUI_DEVICE="TRIMUI_SMART_PRO_S" ;; - *) CHEEVOS_PYUI_DEVICE="TRIMUI_SMART_PRO" ;; - esac - ;; - "AnbernicXX720480" | "AnbernicXX720480NoStick" | "AnbernicXX640480" | \ - "AnbernicXX640480NoStick" | "AnbernicXX640480OneStick" | "AnbernicRG28XX" | \ - "AnbernicRGCubeXX") - PYUI_DLL=/mnt/SDCARD/App/PyUI/dll - [ -f /mnt/SDCARD/App/PyUI/dll-mali/libSDL2-2.0.so.0 ] && PYUI_DLL=/mnt/SDCARD/App/PyUI/dll-mali - export PYSDL2_DLL_PATH="$PYUI_DLL" - export LD_LIBRARY_PATH="$PYUI_DLL:/mnt/SDCARD/spruce/flip/lib:/usr/lib" - export SDL_JOYSTICK_DISABLE_UDEV=1 - case "$PYUI_DLL" in - *dll-mali) export SDL_VIDEODRIVER=mali ;; - esac - PYTHON="/mnt/SDCARD/spruce/flip/bin/MainUI" - case "$PLATFORM" in - "AnbernicXX720480" | "AnbernicXX720480NoStick") CHEEVOS_PYUI_DEVICE="ANBERNIC_RGXX720480" ;; - "AnbernicRG28XX") CHEEVOS_PYUI_DEVICE="ANBERNIC_RG28XX" ;; - "AnbernicRGCubeXX") CHEEVOS_PYUI_DEVICE="ANBERNIC_RGCUBEXX" ;; - *) CHEEVOS_PYUI_DEVICE="ANBERNIC_RGXX640480" ;; - esac - ;; - "Miniloong" | "RGB30" | "RGB20SX") - export PYSDL2_DLL_PATH="/mnt/SDCARD/App/PyUI/dll" - export LD_LIBRARY_PATH="/mnt/SDCARD/App/PyUI/dll:/mnt/SDCARD/spruce/flip/lib:/usr/lib/aarch64-linux-gnu" - export SDL_VIDEODRIVER=kmsdrm - export SDL_AUDIODRIVER=alsa - PYTHON="/mnt/SDCARD/spruce/flip/bin/MainUI" - if [ "$PLATFORM" = "Miniloong" ]; then - CHEEVOS_PYUI_DEVICE="MINILOONG_POCKET1" - else - CHEEVOS_PYUI_DEVICE="$PLATFORM" - fi - ;; - "Pixel2") - RUN_DIR="/usr/bin" - export PYSDL2_DLL_PATH="/usr/lib" - PYTHON="/usr/bin/MainUI" - CHEEVOS_PYUI_DEVICE="GKD_PIXEL2" - ;; - "Zero28" | "Zero40" | "XU20") - RUN_DIR="/usr/magicx/bin" - PYSDL2_DLL_PATH="$(magicx_pyui_sdl_dir)" - export PYSDL2_DLL_PATH - PYTHON="/mnt/SDCARD/spruce/flip/bin/MainUI" - case "$PLATFORM" in - "Zero40") CHEEVOS_PYUI_DEVICE="MAGICX_ZERO40" ;; - "XU20") CHEEVOS_PYUI_DEVICE="MAGICX_XU20" ;; - *) CHEEVOS_PYUI_DEVICE="MAGICX_ZERO28" ;; - esac - ;; - *) - log_message "Cheevos: platform $PLATFORM is not supported yet" - show_message "Cheevos doesn't support this device ($PLATFORM) yet." - exit 1 - ;; -esac - -[ -n "$PYTHON" ] || PYTHON="$(get_python_path)" -export CHEEVOS_PYUI_DEVICE -export PLATFORM -export PYTHONPATH="$APP_DIR" -export SSL_CERT_FILE=/mnt/SDCARD/spruce/etc/ca-certificates.crt - -cd "$RUN_DIR" || cd "$APP_DIR" || exit 1 -"$PYTHON" -m cheevos "$@" 2>"$STDERR_LOG" -STATUS=$? -[ -n "$CLEANUP" ] && $CLEANUP -if [ "$STATUS" -ne 0 ]; then - # Errors before the app's own logging starts (e.g. an import error) only reach stderr: - # keep them in the app log too, so the message points to one file with everything. - LOG="/mnt/SDCARD/Saves/spruce/cheevos-$PLATFORM.log" - { echo "--- launch.sh: exit status $STATUS, stderr:"; cat "$STDERR_LOG"; } >>"$LOG" 2>/dev/null - show_message "Cheevos couldn't start. Details: Saves/spruce/cheevos-$PLATFORM.log" -fi - -SPRUCE_CONFIG=/mnt/SDCARD/Saves/spruce/spruce-config.json -KEY_PATH='.menuOptions."RetroAchievements Settings".webApiKey.selected' -if jq -e "$KEY_PATH | strings" "$SPRUCE_CONFIG" >/dev/null 2>&1; then - key="$(tr -d ' \t\r' 2>/dev/null "$SPRUCE_CONFIG.tmp" && - [ -s "$SPRUCE_CONFIG.tmp" ] && mv "$SPRUCE_CONFIG.tmp" "$SPRUCE_CONFIG" - rm -f "$SPRUCE_CONFIG.tmp" - fi -fi -exit "$STATUS" diff --git a/docs/Installation.md b/docs/Installation.md index 1a9f31c..eca24b0 100644 --- a/docs/Installation.md +++ b/docs/Installation.md @@ -17,7 +17,7 @@ it. You can also install or update it manually from the latest Cheevos release. page (not the "Source code" archives), or build it yourself with `make package` (see `RELEASING.md` in the repository). 2. Unzip it on your computer, then copy the `Cheevos` folder into `App` on your SD card. - You should end up with `App/Cheevos/launch.sh`. + You should end up with `App/Cheevos/config.json`. 3. Put the SD card back, open **Apps** in Spruce's main menu and start **Cheevos**. The first start asks for your Web API key (see [Setup](Setup.md)), then syncs your account. That diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index 4e56f85..54e4bdf 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -3,7 +3,8 @@ ## Common problems | What you see | What to do | |---|---| -| "Cheevos couldn't start" | Something went wrong before the first screen. The log named in the message has the details; please report it on the project's GitHub page. | +| "Cheevos stopped after an error" | Something went wrong and Cheevos closed. The log named in the message has the details; please report it on the project's GitHub page. | +| "The app stopped after an error" | Spruce couldn't open Cheevos at all, often because the `App/Cheevos` folder is incomplete. Copy it again from the release. Spruce's log (`Saves/spruce/pyui.log`) has the details. | | "Sign in to RetroAchievements first" | Sign in in **Spruce Settings → RetroAchievements** (or in RetroArch), then start Cheevos again. | | "API key rejected" | The Web API key is wrong or was reset on the site. Copy it again from retroachievements.org (**Settings → Keys**), press **Start** and type it. | | "apikey.txt doesn't hold a Web API key" | The file needs just the key: 32 letters and digits on the first line, nothing else. Press **A** to type it instead. | @@ -24,7 +25,6 @@ Cheevos' data on the SD card: |---|---|---| | `Saves/cheevos/apikey.txt` | Your Web API key | Yes. Cheevos asks for it again. | | `Saves/cheevos/settings.json` | Your settings | Yes. Back to defaults. | -| `Saves/cheevos/pyui-state.json` | Last selected rows | Yes. | | `Saves/cheevos/cache/` | Synced data, downloaded images and ROM identifications | Yes. The next start syncs everything again. | Deleting `Saves/cheevos/cache/` fixes most problems that downloading every game again doesn't. diff --git a/pyproject.toml b/pyproject.toml index b7e3057..44879b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,9 +64,13 @@ max-returns = 6 "tests/**" = ["D", "S101", "S603", "ANN", "PLR2004", "PLR0913", "FBT"] # CLI scripts report to the terminal. "scripts/**" = ["T20"] -# PyUI is importable only after bootstrap.add_pyui_to_path(), so these import it lazily. +# PyUI is importable only inside its process or after the desktop runner puts it on sys.path. "src/cheevos/ui/pyui/**" = ["PLC0415"] "src/cheevos/platform/desktop/__main__.py" = ["PLC0415"] +"src/cheevos/platform/desktop/pyui_setup.py" = ["PLC0415"] +"scripts/device_session.py" = ["PLC0415"] +# Imports the app inside functions so that it can be unloaded, and reports on stdout. +"tests/screens/native_session.py" = ["PLC0415", "T201"] # These import what the first frame doesn't need on first use: start-up time on a Mini # (.agents/device.md, "Start-up"; tests/unit/test_startup_imports.py keeps it so). "src/cheevos/app.py" = ["PLC0415"] diff --git a/scripts/build_package.py b/scripts/build_package.py index 809c4ad..c42bdbe 100644 --- a/scripts/build_package.py +++ b/scripts/build_package.py @@ -1,7 +1,7 @@ """Assemble the device package: ``dist/App/Cheevos/`` and ``dist/Cheevos-.zip``. -Contents: ``config.json``, ``launch.sh``, ``identify-rom.sh``, ``cheevos.png``, ``LICENSE`` and -the package without the dev-only desktop runner and caches. The zip holds ``Cheevos/`` for +Contents: ``config.json``, ``identify-rom.sh``, ``cheevos.png``, ``LICENSE`` and the package +without the dev-only desktop runner and caches. The zip holds ``Cheevos/`` for copying into the SD card's ``App/`` folder; the release job in ``.github/workflows/ci.yml`` publishes it. """ @@ -20,8 +20,8 @@ DIST = REPO / "dist" OUTPUT = DIST / "App" / "Cheevos" EXCLUDED = shutil.ignore_patterns("__pycache__", "*.pyc", "desktop") -# Spruce runs these directly: the Apps menu the launcher, its game menu the identification helper. -EXECUTABLES = ("launch.sh", "identify-rom.sh") +# Spruce's game menu runs the identification helper; PyUI imports the app itself. +EXECUTABLES = ("identify-rom.sh",) def build(output: Path = OUTPUT) -> Path: diff --git a/scripts/device.sh b/scripts/device.sh index 78f8c62..b5a708e 100755 --- a/scripts/device.sh +++ b/scripts/device.sh @@ -1,13 +1,20 @@ #!/bin/sh # Developer helper for a SpruceOS device on the network. Runs on the dev machine. # -# scripts/device.sh deploy copy dist/App/Cheevos to the device (run `make package` first) -# scripts/device.sh launch start Cheevos remotely (same handoff as the Apps menu) -# scripts/device.sh stop ask Cheevos to exit (PyUI comes back) -# scripts/device.sh logs show the app log and the last stderr -# scripts/device.sh shot FILE save the device screen as PNG -# scripts/device.sh press KEY… tap buttons: up down left right a b x y l1 r1 start select -# (only while Cheevos runs: stops at the first refused tap) +# scripts/device.sh deploy copy dist/App/Cheevos to the device (run `make package` first) +# scripts/device.sh launch [GAME] open Cheevos (or one RA game) as PyUI's menus do +# scripts/device.sh stop close a launched Cheevos (PyUI comes back) +# scripts/device.sh running exit 0 while Cheevos is open, launched or from the menus +# scripts/device.sh logs show the app log and the launched session's stderr +# scripts/device.sh shot FILE save the device screen as PNG +# scripts/device.sh press KEY… tap buttons: up down left right a b x y l1 r1 start select +# (only while Cheevos runs: stops at the first refused tap) +# +# Cheevos runs inside PyUI's process (config.json "pyuiEntry"). "launch" saves the running +# PyUI's environment and command line, asks it to exit, and principal.sh runs +# scripts/device_session.py with PyUI's interpreter: it brings PyUI up again and opens Cheevos +# through PyUI's own runner. Opened from Spruce's menus, Cheevos has no process of its own: +# "running" then looks for PyUI holding the app's log open. # # Connection: DEVICE_SSH (full ssh command, e.g. "ssh spruce@192.168.0.105"), or # DEVICE_HOST (used as "ssh spruce@$DEVICE_HOST"). @@ -18,6 +25,7 @@ set -eu APP_DIR="/mnt/SDCARD/App/Cheevos" +DEV_DIR="/tmp/cheevos-dev" LOG_FILE="/mnt/SDCARD/Saves/spruce/cheevos-MiyooMini.log" REPO="$(cd "$(dirname "$0")/.." && pwd)" BIN="/mnt/SDCARD/spruce/miyoomini/bin" @@ -36,6 +44,11 @@ remote() { $SSH "$@" } +# Remote shell test: a launched session runs, or PyUI holds the app's log open (menus). +RUNNING="{ pgrep -f '[c]heevos-dev/session[.]py' >/dev/null || \ + for pid in \$(pgrep -f 'main-ui/[m]ainui[.]py'); do \ + ls -l /proc/\$pid/fd 2>/dev/null | grep -q 'Saves/spruce/cheevos-' && break; false; done; }" + key_code() { case "$1" in up) echo 103 ;; down) echo 108 ;; left) echo 105 ;; right) echo 106 ;; @@ -51,22 +64,40 @@ case "${1:-}" in remote "rm -rf '$APP_DIR/cheevos' && mkdir -p '$APP_DIR'" # ustar without macOS metadata: busybox tar on the device chokes on pax/xattr headers. COPYFILE_DISABLE=1 tar -C "$REPO/dist/App/Cheevos" --format ustar -cf - . | - remote "tar -xf - -C '$APP_DIR' && chmod +x '$APP_DIR/launch.sh' '$APP_DIR/identify-rom.sh' && sync" + remote "tar -xf - -C '$APP_DIR' && chmod +x '$APP_DIR/identify-rom.sh' && sync" echo "Deployed to $APP_DIR" ;; launch) + game="" + flags="${DEVICE_PYTHON_FLAGS:-}" # e.g. -X importtime (see .agents/device.md) + if [ -n "${2:-}" ]; then + case "$2" in *[!0-9]* | 0*) echo "GAME must be a positive RA game ID" >&2; exit 2 ;; esac + game=" --game-id $2" + fi + # The session script travels on stdin; it is read only once the device is idle. Its path + # is built from $d, or the idle check's pgrep -f would match this shell's own text. remote " - if pgrep -f 'python3[.]10 -m [c]heevos' >/dev/null; then echo 'Cheevos is already running'; exit 0; fi + if $RUNNING; then echo 'Cheevos is already running'; exit 0; fi if pgrep -f '[r]a32[.]|[r]etroarch' >/dev/null; then echo 'A game is running; not launching' >&2; exit 1; fi - printf '%s\n' 'cd \"$APP_DIR\"; chmod a+x \"$APP_DIR/launch.sh\"; \"$APP_DIR/launch.sh\"' > /tmp/cmd_to_run.sh - pkill -TERM -f 'main-ui/[m]ainui[.]py' && echo 'PyUI asked to exit; principal.sh launches Cheevos' - " + pid=\$(pgrep -f 'main-ui/[m]ainui[.]py' | head -n 1) + [ -n \"\$pid\" ] || { echo 'PyUI is not running: no environment to start from' >&2; exit 1; } + d='$DEV_DIR' + mkdir -p \"\$d\" && cat > \"\$d/session.py\" + cat /proc/\$pid/environ > \"\$d/environ\" + cat /proc/\$pid/cmdline > \"\$d/cmdline\" + python=\$(tr '\\0' '\\n' < /proc/\$pid/cmdline | head -n 1) + printf '%s\\n' \"cd '\$(readlink /proc/\$pid/cwd)'; '\$python' $flags '\$d/session.py'$game 2>'\$d/stderr.log'\" > /tmp/cmd_to_run.sh + kill -TERM \$pid && echo 'PyUI asked to exit; principal.sh opens Cheevos' + " <"$REPO/scripts/device_session.py" ;; stop) - remote "pkill -TERM -f 'python3[.]10 -m [c]heevos' && echo 'Cheevos asked to exit' || echo 'Cheevos is not running'" + remote "pkill -TERM -f '[c]heevos-dev/session[.]py' && echo 'Cheevos asked to close; PyUI comes back' || echo 'No launched Cheevos (opened from the menus, press b to close it)'" + ;; + running) + if remote "$RUNNING"; then echo 'Cheevos is running'; else echo 'Cheevos is not running'; exit 1; fi ;; logs) - remote "tail -n 60 '$LOG_FILE' 2>/dev/null; echo '--- stderr'; tail -n 30 /tmp/cheevos-stderr.log 2>/dev/null || true" + remote "tail -n 60 '$LOG_FILE' 2>/dev/null; echo '--- launched session stderr'; tail -n 30 '$DEV_DIR/stderr.log' 2>/dev/null || true" ;; shot) out="${2:?usage: device.sh shot FILE.png}" @@ -81,14 +112,14 @@ case "${1:-}" in code="$(key_code "$key")" # Only ever tap Cheevos: once it exits, taps reach Spruce's menu and can start a game. remote " - pgrep -f 'python3[.]10 -m [c]heevos' >/dev/null || { echo 'Cheevos is not running: not pressing $key' >&2; exit 3; } + $RUNNING || { echo 'Cheevos is not running: not pressing $key' >&2; exit 3; } $BIN/send_event /dev/input/event0 $code:1; usleep 80000 2>/dev/null || sleep 1; $BIN/send_event /dev/input/event0 $code:0 " sleep 0.4 done ;; *) - sed -n '2,13p' "$0" + sed -n '2,20p' "$0" exit 2 ;; esac diff --git a/scripts/device_session.py b/scripts/device_session.py new file mode 100644 index 0000000..3a54833 --- /dev/null +++ b/scripts/device_session.py @@ -0,0 +1,120 @@ +"""Development only: open Cheevos on a device as PyUI's menus do, without tapping through them. + +``scripts/device.sh launch`` copies this to ``/tmp/cheevos-dev/session.py`` on the device, saves +the running PyUI's environment, command line and working directory next to it, then asks PyUI +to exit; ``principal.sh`` runs this with PyUI's own interpreter. It takes PyUI's environment +back, brings PyUI up the way ``mainui.py`` does (minus the menus and background services), and +opens Cheevos with PyUI's ``apps.native_app.run``, the call the Apps menu makes. When Cheevos +closes, so does this, and ``principal.sh`` restarts PyUI. + +Never part of the device package. Memory figures taken this way leave out PyUI's menus. + +Usage (on the device): `` /tmp/cheevos-dev/session.py [--game-id N]`` +""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import sys +from pathlib import Path +from types import FrameType + +DEV_DIR = Path(__file__).resolve().parent +APP_DIR = Path("/mnt/SDCARD/App/Cheevos") +_PYUI_ENV = "CHEEVOS_DEV_SESSION" # set once PyUI's environment is in place + + +def _saved(name: str) -> list[str]: + """Read a NUL-separated file ``device.sh`` saved from the running PyUI's ``/proc`` entry. + + Args: + name: ``environ`` or ``cmdline``. + + Returns: + Its entries. + """ + return [item.decode() for item in (DEV_DIR / name).read_bytes().split(b"\0") if item] + + +def _take_pyui_environment() -> None: + """Restart this interpreter with PyUI's environment, as its library paths need it at exec.""" + if os.environ.get(_PYUI_ENV): + return + env = dict(item.split("=", 1) for item in _saved("environ") if "=" in item) + env[_PYUI_ENV] = "1" + os.execve(sys.executable, sys.orig_argv, env) # noqa: S606 — this interpreter, again + + +def _pyui_options() -> tuple[Path, dict[str, str]]: + """Find PyUI's ``main-ui`` directory and the options PyUI was started with. + + Returns: + ``(main-ui directory, {"-device": ..., "-pyUiConfig": ..., ...})``. + """ + argv = _saved("cmdline") + script = next(Path(arg) for arg in argv if arg.endswith("mainui.py")) + rest = argv[argv.index(str(script)) + 1 :] + return script.parent, dict(zip(rest[::2], rest[1::2], strict=False)) + + +def _start_pyui(main_ui: Path, options: dict[str, str]) -> None: + """Bring PyUI up as ``mainui.py`` does for its menus, with its log in ``/tmp/cheevos-dev``. + + Args: + main_ui: PyUI's ``main-ui`` directory. + options: PyUI's command-line options. + """ + sys.path.insert(0, str(main_ui)) # PyUI first: Language finds lang/ from sys.path[0] + from controller.controller import Controller # PyUI, importable only now + from devices.device import Device + from devices.device_factory import initialize_device + from devices.miyoo.user_config import UserConfig + from display.display import Display + from menus.language.language import Language + from themes.theme import Theme + from utils.cfw_system_config import CfwSystemConfig + from utils.logger import PyUiLogger + from utils.py_ui_config import PyUiConfig + from utils.py_ui_state import PyUiState + + PyUiLogger.init(str(DEV_DIR), "PyUI") + PyUiConfig.init(options["-pyUiConfig"]) + UserConfig.reload_config() + CfwSystemConfig.init(options.get("-cfwConfig")) + Language.init() + initialize_device(options["-device"], main_ui_mode=True) + device = Device.get_device() + device.restore_saved_timezone() + PyUiState.init(device.get_state_path()) + theme = Path(PyUiConfig.get("themeDir")) / device.get_system_config().get_theme() + Theme.init(str(theme), device.screen_width(), device.screen_height()) + Display.init() + Display.present() + Controller.init() + + +def _exit_on_sigterm(_signum: int, _frame: FrameType | None) -> None: + """Leave through the app's own clean-up when ``device.sh stop`` sends SIGTERM.""" + sys.exit(0) + + +def main() -> None: + """Open Cheevos once, as the Apps menu (or, with ``--game-id``, the game menu) does.""" + parser = argparse.ArgumentParser(prog="session.py") + parser.add_argument("--game-id", type=int) + args = parser.parse_args() + _take_pyui_environment() + signal.signal(signal.SIGTERM, _exit_on_sigterm) + _start_pyui(*_pyui_options()) + from apps import native_app # PyUI's in-process app runner + + entry = json.loads((APP_DIR / "config.json").read_text(encoding="utf-8"))["pyuiEntry"] + extra = {} if args.game_id is None else {"game_id": args.game_id} + native_app.run(str(APP_DIR), entry, **extra) + + +if __name__ == "__main__": + main() diff --git a/scripts/release_notes.py b/scripts/release_notes.py index 8fd0da4..594059e 100644 --- a/scripts/release_notes.py +++ b/scripts/release_notes.py @@ -107,7 +107,7 @@ def render_notes( version = tag.removeprefix("v") lines = [ f"Install: unzip `Cheevos-{version}.zip`, then copy its `Cheevos` folder into " - "`App` on the SD card, so that `App/Cheevos/launch.sh` exists.", + "`App` on the SD card, so that `App/Cheevos/config.json` exists.", "", "## Changes", "", diff --git a/src/cheevos/__main__.py b/src/cheevos/__main__.py deleted file mode 100644 index 5ec9125..0000000 --- a/src/cheevos/__main__.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Device entry point: ``python -m cheevos``, started by ``App/Cheevos/launch.sh``. - -``launch.sh`` prepares the platform's SDL environment and passes the PyUI device name in -``CHEEVOS_PYUI_DEVICE``; this module brings PyUI up and runs the app. -""" - -from __future__ import annotations - -import argparse -import logging -import os -import signal -import sys -import time -from logging.handlers import RotatingFileHandler -from pathlib import Path -from types import FrameType - -STARTED_AT = time.monotonic() - -from cheevos import app # noqa: E402 — after STARTED_AT so start-up timing includes imports -from cheevos.platform.arguments import add_game_argument # noqa: E402 -from cheevos.platform.paths import Paths # noqa: E402 -from cheevos.ui.pyui import bootstrap # noqa: E402 - -logger = logging.getLogger("cheevos") - -_SDCARD = Path(os.environ.get("CHEEVOS_SDCARD_ROOT", "/mnt/SDCARD")) -_PYUI_DIR = _SDCARD / "App" / "PyUI" -_LOG_BYTES = 1024 * 1024 - - -def _configure_logging(log_file: Path) -> logging.Logger: - """Log to a rotating file (Spruce convention) and return the logger for PyUI. - - Args: - log_file: Destination, e.g. ``Saves/spruce/cheevos-MiyooMini.log``. - - Returns: - The logger PyUI's records are routed to (warnings and above). - """ - log_file.parent.mkdir(parents=True, exist_ok=True) - handler = RotatingFileHandler(log_file, maxBytes=_LOG_BYTES, backupCount=1, encoding="utf-8") - formatter = logging.Formatter( - "%(asctime)s.%(msecs)03dZ %(levelname)s %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S" - ) - # UTC: PyUI applies the saved time zone partway through start-up, so local times would jump. - formatter.converter = time.gmtime - handler.setFormatter(formatter) - root = logging.getLogger() - root.addHandler(handler) - root.setLevel(logging.INFO) - pyui_logger = logging.getLogger("cheevos.pyui") - pyui_logger.setLevel(logging.WARNING) - return pyui_logger - - -def _exit_on_sigterm(signum: int, _frame: FrameType | None) -> None: - """Exit cleanly when Spruce (or a developer) sends SIGTERM. - - Args: - signum: The received signal number. - """ - logger.info("Received signal %d, exiting", signum) - sys.exit(0) - - -def main() -> int: - """Bring up PyUI on the device and run the app. - - Returns: - Process exit code. - """ - parser = argparse.ArgumentParser(prog="python -m cheevos") - add_game_argument(parser) - args = parser.parse_args() - platform_name = os.environ.get("PLATFORM", "unknown") - pyui_logger = _configure_logging(_SDCARD / "Saves" / "spruce" / f"cheevos-{platform_name}.log") - device_name = os.environ.get("CHEEVOS_PYUI_DEVICE") - if not device_name: - logger.error("CHEEVOS_PYUI_DEVICE is not set; start the app through launch.sh") - return 2 - signal.signal(signal.SIGTERM, _exit_on_sigterm) - logger.info("Starting on %s (%s)", platform_name, device_name) - - bootstrap.add_pyui_to_path(_PYUI_DIR / "main-ui") - bootstrap.install_logger(pyui_logger) - setup = bootstrap.PyUiSetup( - main_ui_dir=_PYUI_DIR / "main-ui", - config_path=_PYUI_DIR / "py-ui-config.json", - state_path=_SDCARD / "Saves" / "cheevos" / "pyui-state.json", - cfw_config_path=_SDCARD / "Saves" / "spruce" / "spruce-config.json", - ) - bootstrap.bootstrap(setup, lambda: bootstrap.pyui_device(device_name)) - logger.info("PyUI ready after %.2fs", time.monotonic() - STARTED_AT) - - try: - env = app.AppEnvironment(paths=Paths.from_env()) - app.run(started_at=STARTED_AT, env=env, game_id=args.game_id) - except Exception: - logger.exception("Unhandled error") - return 1 - logger.info("Exiting normally") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/cheevos/app.py b/src/cheevos/app.py index 5267a66..a09049b 100644 --- a/src/cheevos/app.py +++ b/src/cheevos/app.py @@ -1,11 +1,12 @@ """Composition root: wire credentials, caches, sync, lazy media and screens, then run home. -Shared by the device entry point (``cheevos.__main__``) and the desktop runner, which differ -only in the :class:`AppEnvironment` they pass (real HTTPS vs. recorded fixtures). +Run by :func:`cheevos.native.session`, inside PyUI on a device and in the desktop runner, +which differ only in the :class:`AppEnvironment` they pass (real HTTPS vs. recorded fixtures). """ from __future__ import annotations +import contextlib import logging import shutil import threading @@ -380,6 +381,8 @@ def run(*, started_at: float, env: AppEnvironment, game_id: int | None = None) - # Extracted and enlarged images live in RAM (tmpfs) on devices; give it back. shutil.rmtree(paths.media_scratch, ignore_errors=True) shutil.rmtree(paths.scaled_scratch, ignore_errors=True) + with contextlib.suppress(OSError): # kept if anything else is in it + paths.scratch.rmdir() def _session_bar(ctx: AppContext, icons: Path, game_id: int | None) -> SyncBar: diff --git a/src/cheevos/core/ra_client/redact.py b/src/cheevos/core/ra_client/redact.py index ee0af39..70dc2cd 100644 --- a/src/cheevos/core/ra_client/redact.py +++ b/src/cheevos/core/ra_client/redact.py @@ -17,7 +17,7 @@ _QUERY_KEY = re.compile(r"(? str: @@ -89,7 +89,7 @@ def install_redaction(api_key: str) -> None: with _lock: if api_key: _secrets.add(api_key) - if _installed: + if _installed is not None: return previous: Callable[..., logging.LogRecord] = logging.getLogRecordFactory() @@ -100,4 +100,22 @@ def factory(*args: object, **kwargs: object) -> logging.LogRecord: return record logging.setLogRecordFactory(factory) - _installed = True + _installed = (factory, previous) + + +def uninstall_redaction() -> None: + """Put the record factory back and forget the secrets, when the app leaves PyUI's process. + + The factory is process-wide: inside PyUI it would otherwise keep running this module for + every PyUI record after the app has gone (.agents/pyui.md, "In-process sessions"). A factory + wrapped again by someone else since is left in place. + """ + global _installed # noqa: PLW0603 — process-wide logging hook + with _lock: + _secrets.clear() + if _installed is None: + return + factory, previous = _installed + if logging.getLogRecordFactory() is factory: + logging.setLogRecordFactory(previous) + _installed = None diff --git a/src/cheevos/native.py b/src/cheevos/native.py new file mode 100644 index 0000000..e79d761 --- /dev/null +++ b/src/cheevos/native.py @@ -0,0 +1,131 @@ +"""Cheevos's entry point: it runs inside PyUI's own process, the way Spruce's menus open it. + +PyUI calls :func:`run` (``"pyuiEntry"`` in ``config.json``) from its Apps menu or its game +menu, with its display, theme and controller already up, and carries on when it returns. A +:func:`session` puts the app's hooks into PyUI, runs it, then takes every hook out again and +waits for the app's workers, so that nothing of Cheevos keeps running or stays referenced and +PyUI can drop its modules (.agents/pyui.md, "In-process sessions"). The desktop runner brings +PyUI up itself, then runs the same session with recorded fixtures. +""" + +from __future__ import annotations + +import contextlib +import logging +import threading +import time +from collections.abc import Iterator +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from cheevos import app +from cheevos.core.credentials import RA_SETTINGS_SECTION, read_api_key +from cheevos.core.ra_client.redact import uninstall_redaction +from cheevos.platform.paths import Paths +from cheevos.ui import strings +from cheevos.ui.pyui import host + +logger = logging.getLogger(__name__) + +APP_LOGGER = "cheevos" # every module logs under it (``logging.getLogger(__name__)``) +WORKER_PREFIX = "cheevos-" # the app's worker threads: cheevos-sync, cheevos-details, ... +_LOG_BYTES = 1024 * 1024 +_WORKERS_TIMEOUT = 2.0 # on top of the app's own waits for each worker +_ERROR_SECONDS = 5.0 + + +def run(game_id: int | None = None) -> None: + """Run one session for PyUI, logged to the app's own log. Never raises. + + An error is logged and shown, and PyUI's menu carries on after the message. + + Args: + game_id: Open this game only, for Spruce's "View achievement progress"; B returns to + Spruce's game menu. + """ + started_at = time.monotonic() + paths = Paths.from_env() + with app_log(paths.log_file): + logger.info("Opening in PyUI on %s", paths.platform) + try: + session(app.AppEnvironment(paths=paths), started_at=started_at, game_id=game_id) + except Exception: + logger.exception("Unhandled error") + log_path = paths.log_file.relative_to(paths.sdcard) + host.show_message(strings.APP_FAILED.format(log=log_path), _ERROR_SECONDS) + logger.info("Closed after %.1fs", time.monotonic() - started_at) + + +def session(env: app.AppEnvironment, *, started_at: float, game_id: int | None = None) -> None: + """Run the app once in an initialised PyUI, then take all of it out of PyUI again. + + Args: + env: App environment. + started_at: ``time.monotonic()`` when the app was asked to open. + game_id: Open this game only (see :func:`run`). + """ + with contextlib.ExitStack() as cleanup: # undone in reverse, each step even if one fails + cleanup.callback(uninstall_redaction) # last: the workers may still log the key + cleanup.callback(_await_workers) + host.enter() + cleanup.callback(host.leave) + cleanup.callback(_share_api_key, env.paths) + app.run(started_at=started_at, env=env, game_id=game_id) + + +@contextlib.contextmanager +def app_log(log_file: Path) -> Iterator[None]: + """Log the app to its own file during the block (Spruce's ``Saves/spruce/-*.log``). + + The handler sits on the app's top logger, which stops propagating, so the app's records + stay out of PyUI's log, and PyUI's own records stay out of the app's. + + Args: + log_file: Destination, e.g. ``Saves/spruce/cheevos-MiyooMini.log``. + + Yields: + Nothing. + """ + log_file.parent.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler(log_file, maxBytes=_LOG_BYTES, backupCount=1, encoding="utf-8") + formatter = logging.Formatter( + "%(asctime)s.%(msecs)03dZ %(levelname)s %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S" + ) + # UTC, marked Z: comparable across time-zone changes and with the app's earlier logs. + formatter.converter = time.gmtime + handler.setFormatter(formatter) + top = logging.getLogger(APP_LOGGER) + level, propagate = top.level, top.propagate + top.addHandler(handler) + top.setLevel(logging.INFO) + top.propagate = False + try: + yield + finally: + top.removeHandler(handler) + handler.close() + top.setLevel(level) + top.propagate = propagate + + +def _share_api_key(paths: Paths) -> None: + """Copy the key into Spruce's Web API Key setting, so a key typed in the app shows there. + + Args: + paths: Device paths. + """ + host.set_spruce_setting(RA_SETTINGS_SECTION, "webApiKey", read_api_key(paths) or "") + + +def _await_workers(timeout: float = _WORKERS_TIMEOUT) -> None: + """Wait for the app's workers to finish: they close their sockets and caches as they end. + + Args: + timeout: Seconds to wait in all. + """ + deadline = time.monotonic() + timeout + for thread in threading.enumerate(): + if thread.name.startswith(WORKER_PREFIX): + thread.join(max(0.0, deadline - time.monotonic())) + if left := [t.name for t in threading.enumerate() if t.name.startswith(WORKER_PREFIX)]: + logger.warning("Workers still running at exit: %s", ", ".join(left)) diff --git a/src/cheevos/platform/arguments.py b/src/cheevos/platform/arguments.py deleted file mode 100644 index e67471d..0000000 --- a/src/cheevos/platform/arguments.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Parse the game target shared by the device launcher and desktop runner.""" - -from __future__ import annotations - -import argparse - - -def game_id(value: str) -> int: - """Accept a positive RA game ID.""" - try: - parsed = int(value) - except ValueError as exc: - raise argparse.ArgumentTypeError("expected a positive game ID") from exc - if parsed <= 0: - raise argparse.ArgumentTypeError("expected a positive game ID") - return parsed - - -def add_game_argument(parser: argparse.ArgumentParser) -> None: - """Add the optional direct-game launch target to an argument parser.""" - parser.add_argument("--game-id", type=game_id, help="open this RA game and refresh it only") diff --git a/src/cheevos/platform/desktop/__init__.py b/src/cheevos/platform/desktop/__init__.py index f5bdaf1..96013e7 100644 --- a/src/cheevos/platform/desktop/__init__.py +++ b/src/cheevos/platform/desktop/__init__.py @@ -2,5 +2,5 @@ Development only: excluded from the device package. The window, input, scripts and screenshots belong to PyUI's desktop device (SpruceOS ``App/PyUI/docs/desktop_readme.txt``); ``__main__`` -imports PyUI only after :func:`cheevos.ui.pyui.bootstrap.add_pyui_to_path` has run. +imports PyUI only after :func:`cheevos.platform.desktop.pyui_setup.add_pyui_to_path` has run. """ diff --git a/src/cheevos/platform/desktop/__main__.py b/src/cheevos/platform/desktop/__main__.py index ba5cb72..74dbfe1 100644 --- a/src/cheevos/platform/desktop/__main__.py +++ b/src/cheevos/platform/desktop/__main__.py @@ -30,13 +30,12 @@ from dataclasses import dataclass from pathlib import Path -from cheevos.platform.arguments import add_game_argument +from cheevos.platform.desktop import pyui_setup logger = logging.getLogger(__name__) _REPO_ROOT = Path(__file__).resolve().parents[4] _SPRUCE_CHECKOUT = _REPO_ROOT / ".spruceos" # scripts/fetch_pyui.sh -_DEVICE = "DESKTOP" @dataclass(frozen=True, slots=True) @@ -116,6 +115,27 @@ def _time_of_day(text: str) -> dt.time: raise argparse.ArgumentTypeError(f"expected HH:MM, got {text!r}") from exc +def _game_id(text: str) -> int: + """Parse a positive RA game ID. + + Args: + text: The ID as typed. + + Returns: + The ID. + + Raises: + argparse.ArgumentTypeError: If it is not a positive integer. + """ + try: + parsed = int(text) + except ValueError as exc: + raise argparse.ArgumentTypeError("expected a positive game ID") from exc + if parsed <= 0: + raise argparse.ArgumentTypeError("expected a positive game ID") + return parsed + + def _env_path(name: str, default: Path) -> Path: """Return the path in environment variable ``name``, or ``default``. @@ -142,7 +162,11 @@ def parse_options(argv: list[str] | None = None) -> RunnerOptions: The runner options. """ parser = argparse.ArgumentParser(prog="python -m cheevos.platform.desktop") - add_game_argument(parser) + parser.add_argument( + "--game-id", + type=_game_id, + help="open this RA game only, as Spruce's game menu does", + ) parser.add_argument("--res", type=_resolution, default=(640, 480), help="e.g. 640x480") parser.add_argument("--theme", default="SPRUCE") parser.add_argument("--headless", action="store_true", help="no window; use with --script") @@ -254,7 +278,7 @@ def _check_desktop_device(options: RunnerOptions) -> str | None: """ if not (options.pyui_dir / "devices" / "desktop").is_dir(): return ( - f"PyUI at {options.pyui_dir} has no {_DEVICE} device (SpruceOS predates it); " + f"PyUI at {options.pyui_dir} has no {pyui_setup.DEVICE} device (SpruceOS predates it); " "run `make pyui`" ) from devices.desktop.desktop_options import DesktopOptions @@ -307,41 +331,53 @@ def run(options: RunnerOptions) -> int: Process exit code. """ started_at = time.monotonic() + if problem := start_pyui(options): + logger.error("%s", problem) + return 2 + from cheevos import native + from cheevos.platform.desktop.environments import fixture_environment, live_environment + + env = ( + live_environment(options.sd_root) if options.live else fixture_environment(options.sd_root) + ) + try: + native.session(env, started_at=started_at, game_id=options.game_id) + except SystemExit as exc: # Esc, closing the window, or the end of a headless script + return int(exc.code or 0) + return _check_script_finished() + + +def start_pyui(options: RunnerOptions) -> str | None: + """Bring up PyUI on its desktop device, as PyUI's launcher would before opening the app. + + Args: + options: Runner options. + + Returns: + Why PyUI can't start, or ``None`` once it is up. + """ os.environ.update(desktop_environment(options)) os.environ.setdefault("SDL_AUDIODRIVER", "dummy") # pysdl2-dll is exactly what the desktop should use; its import-time notice is noise. warnings.filterwarnings("ignore", message="Using SDL2 binaries from pysdl2-dll") - from cheevos.ui.pyui import bootstrap - pyui_logger = _configure_logging(verbose=options.verbose) - bootstrap.add_pyui_to_path(options.pyui_dir) - bootstrap.install_logger(pyui_logger) + pyui_setup.add_pyui_to_path(options.pyui_dir) + pyui_setup.install_logger(pyui_logger) if problem := _check_desktop_device(options): - logger.error("%s", problem) - return 2 + return problem - setup = bootstrap.PyUiSetup( + setup = pyui_setup.PyUiSetup( main_ui_dir=options.pyui_dir, config_path=_write_pyui_config(options), state_path=options.sd_root / "Saves" / "cheevos" / "pyui-state.json", theme=options.theme, user_config_path=options.sd_root / "Saves" / "pyui-common.json", ) - bootstrap.bootstrap(setup, lambda: bootstrap.pyui_device(_DEVICE)) + pyui_setup.start(setup) if options.clock is not None: _fix_top_bar_clock(options.clock) - from cheevos import app - from cheevos.platform.desktop.environments import fixture_environment, live_environment - - env = ( - live_environment(options.sd_root) if options.live else fixture_environment(options.sd_root) - ) - try: - app.run(started_at=started_at, env=env, game_id=options.game_id) - except SystemExit as exc: # Esc, closing the window, or the end of a headless script - return int(exc.code or 0) - return _check_script_finished() + return None def _check_script_finished() -> int: diff --git a/src/cheevos/platform/desktop/pyui_setup.py b/src/cheevos/platform/desktop/pyui_setup.py new file mode 100644 index 0000000..7aac4f3 --- /dev/null +++ b/src/cheevos/platform/desktop/pyui_setup.py @@ -0,0 +1,115 @@ +"""Bring PyUI up on its ``DESKTOP`` device, as PyUI's launcher would before opening the app. + +On a handheld PyUI is already running when it opens Cheevos (:mod:`cheevos.native`). The desktop +runner has no launcher, so it mirrors the start-up order of PyUI's ``mainui.py`` (config, +device, state, theme, display, controller) without the launcher's own parts: menu loops, +background services, theme conversion and the stdout/stderr redirection of ``PyUiLogger.init``. +""" + +from __future__ import annotations + +import logging +import sys +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger(__name__) + +DEVICE = "DESKTOP" + + +@dataclass(frozen=True, slots=True) +class PyUiSetup: + """Everything needed to bring PyUI up. + + Attributes: + main_ui_dir: PyUI's ``main-ui`` directory (contains ``mainui.py``). + config_path: PyUI config JSON; its ``themeDir`` key locates the themes directory. + state_path: Where PyUI keeps its view state (last selections). + theme: Theme folder name inside ``themeDir``, e.g. ``"SPRUCE"``. + user_config_path: PyUI's ``pyui-common.json``, which PyUI hard-codes to the SD card. + """ + + main_ui_dir: Path + config_path: Path + state_path: Path + theme: str + user_config_path: Path + + +def add_pyui_to_path(main_ui_dir: Path) -> None: + """Make PyUI's top-level packages importable. + + PyUI must sit at ``sys.path[0]``: its ``Language`` class finds the ``lang`` directory + relative to ``sys.path[0]``. + + Args: + main_ui_dir: PyUI's ``main-ui`` directory. + + Raises: + FileNotFoundError: If ``main_ui_dir`` does not contain ``mainui.py``. + """ + if not (main_ui_dir / "mainui.py").is_file(): + raise FileNotFoundError(f"PyUI not found at {main_ui_dir}") + entry = str(main_ui_dir) + if entry in sys.path: + sys.path.remove(entry) + sys.path.insert(0, entry) + + +def install_logger(pyui_logger: logging.Logger) -> None: + """Route PyUI's logging into ``pyui_logger`` instead of calling ``PyUiLogger.init``. + + ``PyUiLogger.init`` replaces ``sys.stdout``/``sys.stderr`` and adds its own handlers; + every PyUI module only ever calls ``PyUiLogger.get_logger()``, so injecting the cached + logger is enough. + + Args: + pyui_logger: Logger that receives all PyUI log records. + """ + from utils.logger import PyUiLogger + + # Private attribute, but it is PyUI's only logger handle; see the docstring. + PyUiLogger._logger = pyui_logger + + +def start(setup: PyUiSetup) -> None: + """Initialise PyUI's config, device, theme, display and controller. + + Call :func:`add_pyui_to_path` and :func:`install_logger` first. The ``DESKTOP`` device is + configured by the ``PYUI_DESKTOP_*`` variables. + + Args: + setup: Paths and theme to use. + """ + from controller.controller import Controller + from devices.device import Device + from devices.device_factory import initialize_device + from devices.miyoo.user_config import UserConfig + from display.display import Display + from menus.language.language import Language + from themes.theme import Theme + from utils.cfw_system_config import CfwSystemConfig + from utils.py_ui_config import PyUiConfig + from utils.py_ui_state import PyUiState + + PyUiConfig.init(str(setup.config_path)) + UserConfig.FILEPATH = str(setup.user_config_path) + UserConfig.reload_config() + CfwSystemConfig.init(None) + Language.init() + + initialize_device(DEVICE, main_ui_mode=False) + device = Device.get_device() + device.restore_saved_timezone() + setup.state_path.parent.mkdir(parents=True, exist_ok=True) + if not setup.state_path.exists(): + setup.state_path.write_text("{}", encoding="utf-8") + PyUiState.init(str(setup.state_path)) + + theme_dir = Path(PyUiConfig.get("themeDir")) / setup.theme + logger.info("PyUI theme %s at %dx%d", theme_dir, device.screen_width(), device.screen_height()) + Theme.init(str(theme_dir), device.screen_width(), device.screen_height()) + Display.init() + Display.present() + Controller.init() diff --git a/src/cheevos/ui/pyui/__init__.py b/src/cheevos/ui/pyui/__init__.py index 3b2785d..9474272 100644 --- a/src/cheevos/ui/pyui/__init__.py +++ b/src/cheevos/ui/pyui/__init__.py @@ -1,7 +1,6 @@ """Bridge to PyUI, SpruceOS's UI toolkit. This is the only package (besides the desktop runner) allowed to import PyUI modules. PyUI is not -installed as a distribution: its ``main-ui`` directory is put on ``sys.path`` at runtime by -:func:`cheevos.ui.pyui.bootstrap.add_pyui_to_path`, so nothing here may import PyUI at module -import time. +installed as a distribution: Cheevos runs inside PyUI's process (or the desktop runner puts +``main-ui`` on ``sys.path`` first), so nothing here may import PyUI at module import time. """ diff --git a/src/cheevos/ui/pyui/bootstrap.py b/src/cheevos/ui/pyui/bootstrap.py deleted file mode 100644 index e53fa28..0000000 --- a/src/cheevos/ui/pyui/bootstrap.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Start PyUI's subsystems from outside PyUI. - -Mirrors the start-up order of PyUI's ``mainui.py`` (config, device, state, theme, display, -controller) without the parts that belong to the launcher itself: menu loops, background -threads, theme conversion and the stdout/stderr redirection done by ``PyUiLogger.init``. -""" - -from __future__ import annotations - -import logging -import sys -import threading -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from cheevos.ui.pyui import texture_budget - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True, slots=True) -class PyUiSetup: - """Everything needed to bring PyUI up. - - Attributes: - main_ui_dir: PyUI's ``main-ui`` directory (contains ``mainui.py``). - config_path: PyUI config JSON; its ``themeDir`` key locates the themes directory. - state_path: Where PyUI keeps its view state (last selections). Kept separate from - PyUI's own state file so the app never alters the launcher's. - theme: Theme folder name inside ``themeDir``, e.g. ``"SPRUCE"``. ``None`` uses the - theme the user selected in Spruce (the device's system config). - cfw_config_path: Spruce's ``spruce-config.json``, or ``None`` when there is none - (desktop). - user_config_path: Override for PyUI's ``pyui-common.json`` location, which PyUI - hard-codes to the SD card. ``None`` keeps PyUI's default (correct on a device). - """ - - main_ui_dir: Path - config_path: Path - state_path: Path - theme: str | None = None - cfw_config_path: Path | None = None - user_config_path: Path | None = None - - -def add_pyui_to_path(main_ui_dir: Path) -> None: - """Make PyUI's top-level packages importable. - - PyUI must sit at ``sys.path[0]``: its ``Language`` class finds the ``lang`` directory - relative to ``sys.path[0]``. - - Args: - main_ui_dir: PyUI's ``main-ui`` directory. - - Raises: - FileNotFoundError: If ``main_ui_dir`` does not contain ``mainui.py``. - """ - if not (main_ui_dir / "mainui.py").is_file(): - raise FileNotFoundError(f"PyUI not found at {main_ui_dir}") - entry = str(main_ui_dir) - if entry in sys.path: - sys.path.remove(entry) - sys.path.insert(0, entry) - - -def install_logger(pyui_logger: logging.Logger) -> None: - """Route PyUI's logging into ``pyui_logger`` instead of calling ``PyUiLogger.init``. - - ``PyUiLogger.init`` replaces ``sys.stdout``/``sys.stderr`` and adds its own handlers; - every PyUI module only ever calls ``PyUiLogger.get_logger()``, so injecting the cached - logger is enough. - - Args: - pyui_logger: Logger that receives all PyUI log records. - """ - from utils.logger import PyUiLogger - - # Private attribute, but it is PyUI's only logger handle; see the docstring. - PyUiLogger._logger = pyui_logger - - -def bootstrap(setup: PyUiSetup, device_factory: Callable[[], object]) -> None: - """Initialise PyUI's config, device, theme, display and controller. - - Call :func:`add_pyui_to_path` and :func:`install_logger` first. - - Args: - setup: Paths and theme to use. - device_factory: Creates the PyUI device object (the handheld's, or PyUI's - ``DESKTOP`` device during development). Called after configs are loaded, because - device constructors read them. - """ - from controller.controller import Controller - from devices.device import Device - from devices.miyoo.user_config import UserConfig - from display.display import Display - from menus.language.language import Language - from themes.theme import Theme - from utils.cfw_system_config import CfwSystemConfig - from utils.py_ui_config import PyUiConfig - from utils.py_ui_state import PyUiState - - PyUiConfig.init(str(setup.config_path)) - if setup.user_config_path is not None: - UserConfig.FILEPATH = str(setup.user_config_path) - UserConfig.reload_config() - CfwSystemConfig.init(str(setup.cfw_config_path) if setup.cfw_config_path else None) - Language.init() - - Device.init(device_factory()) - device = Device.get_device() - _warm_wifi_status(device) - # TZ lives only in the process environment; PyUI's launcher re-applies the saved zone at - # every start, and so must we, or clocks show UTC. - device.restore_saved_timezone() - setup.state_path.parent.mkdir(parents=True, exist_ok=True) - if not setup.state_path.exists(): - setup.state_path.write_text("{}", encoding="utf-8") - PyUiState.init(str(setup.state_path)) - - theme = setup.theme or device.get_system_config().get_theme() - theme_dir = Path(PyUiConfig.get("themeDir")) / theme - logger.info("PyUI theme %s at %dx%d", theme_dir, device.screen_width(), device.screen_height()) - Theme.init(str(theme_dir), device.screen_width(), device.screen_height()) - Display.init() - texture_budget.install(Display, device.screen_width(), device.screen_height()) - Display.present() - Controller.init() - - -def _warm_wifi_status(device: Any) -> None: # noqa: ANN401 — PyUI device - """Read PyUI's Wi-Fi status on a thread while the display starts. - - The top bar draws it from the first frame and PyUI caches it: only the first read waits - for ``wifiCmd status`` (0.4 s on a Mini), the rest return the cached value and refresh in - the background. Reading it here, during the display's second of start-up, takes that wait - off the first frame (.agents/device.md, "Start-up"). - - Args: - device: The PyUI device object. - """ - - def warm() -> None: - """Read once; a failure leaves the first frame to read it as before.""" - try: - device.get_wifi_status() - except Exception: # the warm-up must never stop start-up - logger.debug("Wi-Fi status warm-up failed", exc_info=True) - - threading.Thread(target=warm, name="wifi-warm-up", daemon=True).start() - - -def pyui_device(name: str) -> object: - """Create PyUI's device object, exactly as PyUI's launcher does. - - Use ``devices.device_factory`` to avoid importing the launcher. ``DESKTOP``, the - development window, is configured by ``PYUI_DESKTOP_*`` variables. - - Args: - name: PyUI device name as passed to ``mainui.py -device``, e.g. ``"MIYOO_MINI_PLUS"``. - - Returns: - The initialised device, also registered with PyUI's ``Device`` holder. - """ - from devices.device import Device - from devices.device_factory import initialize_device - - # main_ui_mode=False: none of the launcher-only background services. - initialize_device(name, main_ui_mode=False) - device = Device.get_device() - _enable_backlight_control(device) - return device - - -def _enable_backlight_control(device: object) -> None: - """Give Miyoo Mini devices the backlight helper PyUI only creates in launcher mode. - - PyUI's screensaver dims and restores the backlight through - ``miyoo_mini_flip_shared_memory_writer``; outside launcher mode that attribute is missing, - so the screensaver failed to dim ("object has no attribute ...") and the screen stayed at - full brightness while idle in Cheevos. - - Args: - device: The PyUI device object. - """ - if hasattr(device, "miyoo_mini_flip_shared_memory_writer"): - return - if not hasattr(type(device), "_set_lumination_to_config"): - return # not a Miyoo Mini family device - from devices.miyoo.mini.miyoo_mini_flip_shared_memory_writer import ( - MiyooMiniFlipSharedMemoryWriter, - ) - - writer = MiyooMiniFlipSharedMemoryWriter() - setattr(device, "miyoo_mini_flip_shared_memory_writer", writer) # noqa: B010 — PyUI device attr diff --git a/src/cheevos/ui/pyui/host.py b/src/cheevos/ui/pyui/host.py new file mode 100644 index 0000000..938f60a --- /dev/null +++ b/src/cheevos/ui/pyui/host.py @@ -0,0 +1,61 @@ +"""What a session asks of the PyUI process it runs in (.agents/pyui.md, "In-process sessions"). + +PyUI is already up when a session starts: Spruce's menus run the app inside PyUI's own process. +The session borrows its texture caches and gives them back empty, shows a message when it +fails, and changes Spruce's settings through PyUI, which keeps them in memory and would +otherwise write its own copy over a change made to the file. +""" + +from __future__ import annotations + +import logging + +from cheevos.ui.pyui import texture_budget + +logger = logging.getLogger(__name__) + + +def enter() -> None: + """Put PyUI's texture caches on the app's budget for the session.""" + from devices.device import Device + from display.display import Display + + device = Device.get_device() + texture_budget.install(Display, device.screen_width(), device.screen_height()) + + +def leave() -> None: + """Free the session's textures and give PyUI back plain caches.""" + from display.display import Display + + texture_budget.uninstall(Display) + + +def show_message(text: str, seconds: float) -> None: + """Show a message on a blank screen, the way Spruce shows its own. + + Args: + text: The message; PyUI wraps it to the screen. + seconds: How long it stays, blocking. + """ + from display.display import Display + + Display.display_message(text, int(seconds * 1000)) + + +def set_spruce_setting(section: str, name: str, value: str) -> None: + """Change one of Spruce's text settings, when it has that setting and another value. + + PyUI saves Spruce's settings file and runs the setting's change command. + + Args: + section: Settings section, e.g. ``"RetroAchievements Settings"``. + name: Setting key, e.g. ``"webApiKey"``. + value: The new value. + """ + from utils.cfw_system_config import CfwSystemConfig + + current = CfwSystemConfig.get_selected_value(section, name) + if isinstance(current, str) and current != value: + CfwSystemConfig.set_menu_option(section, name, value) + logger.info("Updated Spruce's %s setting", name) diff --git a/src/cheevos/ui/pyui/texture_budget.py b/src/cheevos/ui/pyui/texture_budget.py index 1247753..17b36e8 100644 --- a/src/cheevos/ui/pyui/texture_budget.py +++ b/src/cheevos/ui/pyui/texture_budget.py @@ -9,7 +9,8 @@ :func:`install` swaps each cache's dictionary for a :class:`TextureLru`, which destroys the least recently drawn textures beyond a budget measured in screens' worth of pixels (a bigger screen gets a bigger budget). PyUI's own code is unchanged: it reads and writes ``cache`` as -before. +before. A session installs it when it starts and :func:`uninstall` frees it when it ends, so +PyUI's process keeps none of the app's textures. """ from __future__ import annotations @@ -101,6 +102,12 @@ def clear(self) -> None: self._entries.clear() self.used = 0 + def release(self) -> None: + """Destroy every texture and forget it.""" + for entry in self._entries.values(): + self._destroy(entry.texture) + self.clear() + def __len__(self) -> int: """Return the number of cached textures. @@ -142,3 +149,21 @@ def install(display: Any, screen_width: int, screen_height: int) -> None: # noq lru[key] = entry cache.cache = lru # ty: ignore[invalid-assignment] — PyUI's cache object is untyped logger.debug("%s: %d KB budget", name, lru.budget // 1024) + + +def uninstall(display: Any) -> None: # noqa: ANN401 — PyUI's Display + """Free every budgeted texture and give PyUI back plain, empty caches. + + The textures are the app's, so none are handed back: PyUI draws its own again when it + needs them. + + Args: + display: PyUI's ``Display`` class. + """ + for name, _ in _CACHES: + cache = getattr(display, name, None) + entries = getattr(cache, "cache", None) + if not isinstance(entries, TextureLru): + continue + entries.release() + cache.cache = {} # ty: ignore[invalid-assignment] — PyUI's cache object is untyped diff --git a/src/cheevos/ui/strings.py b/src/cheevos/ui/strings.py index e636ab8..83f9447 100644 --- a/src/cheevos/ui/strings.py +++ b/src/cheevos/ui/strings.py @@ -292,6 +292,7 @@ EXIT = "Exit" # --- generic ---------------------------------------------------------------------------------- +APP_FAILED = "Cheevos stopped after an error. Details: {log}" LOADING = "Loading…" HINT_OK = "OK" JUST_NOW = "just now" diff --git a/tests/screens/native_session.py b/tests/screens/native_session.py new file mode 100644 index 0000000..5ef8b6e --- /dev/null +++ b/tests/screens/native_session.py @@ -0,0 +1,93 @@ +"""Open and close Cheevos twice in one PyUI process, as Spruce's menus do, then unload it. + +Run by ``test_native_sessions.py`` in a subprocess. PyUI comes up on its desktop device first, +as its launcher would; the script drives both sessions. Afterwards the app's modules are dropped +the way PyUI drops them (``App/PyUI/main-ui/apps/native_app.py``), and the report on stdout +lists whatever of Cheevos is still alive, running or hooked into PyUI. + +Usage: ``python native_session.py