diff --git a/.gitignore b/.gitignore index ca791b9..4eefb0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /target -matrixbox_simulator/device/sandbox_fs/ __pycache__/ *.pyc .venv/ diff --git a/README.md b/README.md index 185acd4..d06263d 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ It's two halves that talk over a local WebSocket: `bitmaptools`, `wifi`, and so on), backed by plain Python instead of real hardware. It runs an app's actual code, and every `display.refresh()` gets pushed out as a frame. An app runs from a staged copy of its own - code, kept in a gitignored folder so settings it saves (brightness, - Wi-Fi, whatever it writes to `settings.txt`) survive between runs, the - same way they'd survive a reflash on real hardware. + code, kept in a per-user cache directory (see "Sandbox" below) so + settings it saves (brightness, Wi-Fi, whatever it writes to + `settings.txt`) survive between runs, the same way they'd survive a + reflash on real hardware. - **`matrixbox_simulator.term`**: a terminal renderer, built on `rich`, that connects, decodes those frames, and draws them with Unicode half-blocks and truecolor. Framed in a white border, with a stats line underneath @@ -121,12 +122,9 @@ needs a real terminal, not a redirected or piped one): - `s` / `l`: short or long front-panel button press. Long usually exits the app, same as holding the real button. -- `r`: reload the running app, only offered when booted at a checkout's - root (not a single app). Copies that app's code fresh from your - checkout, then triggers the same exit a long press would, so it comes - back running your changes without a full restart. -- `R`: restart the whole process, picking up a core code change (not - just an app's own) rather than requiring a manual stop and rerun. +- `r`: reload by restarting the whole process. Staging always re-syncs + the entire checkout fresh on boot, so this alone picks up both app and + core code changes, then boots straight back into whatever was running. - `+` / `-`: adjust refresh pacing live. See "Animation speed" below. - `[` / `]`: adjust color gamma live. See "Colors" below. - `z`: cycle through panel sizes live. See "Panel sizes" above. @@ -274,6 +272,33 @@ sets the output PNG's pixel scale factor (default 8, so a 128x32 panel becomes a 1024x256 image). `--size` / `--width` / `--height` pick the panel size, same as `matrixbox app` (see "Panel sizes" above). +## Sandbox + +Every `matrixbox app`/`screenshot` run stages a fresh copy of the +checkout (or one app) into a per-user cache directory, never inside this +package's own install — a global install's site-packages often isn't +writable at all, and installed code should stay read-only regardless. +That's also where saved settings (brightness, Wi-Fi, whatever an app +writes to `settings.txt`) persist between runs. + +Location, in priority order: + +1. `$XDG_CACHE_HOME/matrixbox-simulator/sandbox_fs`, if `XDG_CACHE_HOME` + is set. +2. `~/Library/Caches/matrixbox-simulator/sandbox_fs` on macOS. +3. `%LOCALAPPDATA%\matrixbox-simulator\sandbox_fs` on Windows. +4. `~/.cache/matrixbox-simulator/sandbox_fs` otherwise (the POSIX + default, and also what `XDG_CACHE_HOME` itself defaults to when + unset). + +Nothing precious lives there — it's all re-derived from the real +checkout on the next run. Inspect or clear it with: + +```sh +uv run matrixbox sandbox info # prints its location and on-disk size +uv run matrixbox sandbox clean # deletes it entirely +``` + ## Useful flags `uv run matrixbox app`: `--size` or `--width` / `--height` for panel @@ -287,6 +312,8 @@ and start fresh, `--refresh-fps` / `--gamma` (see above). `uv run matrixbox screenshot`: see "Screenshots" above. +`uv run matrixbox sandbox`: see "Sandbox" above. + ## Limitations - `Group(scale=...)` is accepted but not honored: nothing renders diff --git a/matrixbox_simulator/cli.py b/matrixbox_simulator/cli.py index 5440725..481bfe7 100644 --- a/matrixbox_simulator/cli.py +++ b/matrixbox_simulator/cli.py @@ -1,9 +1,9 @@ -"""Single `matrixbox` entrypoint, with `app`, `simulator`, and `screenshot` -as subcommands.""" +"""Single `matrixbox` entrypoint, with `app`, `simulator`, `screenshot`, +and `sandbox` as subcommands.""" import argparse -from matrixbox_simulator.device import run_app, run_screenshot +from matrixbox_simulator.device import run_app, run_sandbox, run_screenshot from matrixbox_simulator.term import run_simulator @@ -32,6 +32,13 @@ def main() -> None: description=run_screenshot.__doc__, ) ) + run_sandbox.build_parser( + subparsers.add_parser( + "sandbox", + help="inspect or clear the simulator's staged sandbox", + description=run_sandbox.__doc__, + ) + ) args = parser.parse_args() if args.command == "app": @@ -40,6 +47,8 @@ def main() -> None: run_screenshot.run(args) elif args.command == "simulator": run_simulator.run(args) + elif args.command == "sandbox": + run_sandbox.run(args) else: raise AssertionError(f"unhandled command: {args.command!r}") diff --git a/matrixbox_simulator/device/run_app.py b/matrixbox_simulator/device/run_app.py index 25c4fe3..d127e2d 100644 --- a/matrixbox_simulator/device/run_app.py +++ b/matrixbox_simulator/device/run_app.py @@ -52,8 +52,23 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent MATRIXBOX_ROOT = REPO_ROOT.parent / "matrixbox" -STUB_DIR = Path(__file__).resolve().parent / "cpstubs" -SANDBOX_ROOT = Path(__file__).resolve().parent / "sandbox_fs" +STUB_DIR = Path(__file__).resolve().parent / "cpstubs" # bundled, read-only + + +def _default_sandbox_root() -> Path: + if xdg_cache := os.environ.get("XDG_CACHE_HOME"): + cache_root = Path(xdg_cache) + elif sys.platform == "darwin": + cache_root = Path.home() / "Library" / "Caches" + elif sys.platform == "win32" and (local_app_data := os.environ.get("LOCALAPPDATA")): + cache_root = Path(local_app_data) + else: + cache_root = Path.home() / ".cache" + + return cache_root / "matrixbox-simulator" / "sandbox_fs" + + +SANDBOX_ROOT = _default_sandbox_root() # The matrixbox device profile this sim pretends to be. Only the board # name has to match matrixbox's own board-detection logic; the pins it diff --git a/matrixbox_simulator/device/run_sandbox.py b/matrixbox_simulator/device/run_sandbox.py new file mode 100644 index 0000000..2c93bdf --- /dev/null +++ b/matrixbox_simulator/device/run_sandbox.py @@ -0,0 +1,78 @@ +"""Inspect or clear the simulator's entire staged sandbox: every staged +checkout and app, plus whatever settings.txt/wifi creds got saved along +the way. Nothing precious lives there — it's all re-derived from the +real checkout on the next `matrixbox app`/`screenshot` run. + +Usage: + + matrixbox sandbox info + matrixbox sandbox clean +""" + +import argparse +import shutil +from pathlib import Path + +from matrixbox_simulator.device import run_app + + +def build_parser( + parser: argparse.ArgumentParser | None = None, +) -> argparse.ArgumentParser: + if parser is None: + parser = argparse.ArgumentParser(description=__doc__) + + subparsers = parser.add_subparsers(dest="sandbox_command", required=True) + subparsers.add_parser("info", help="show the sandbox's location and on-disk size") + subparsers.add_parser("clean", help="delete the sandbox entirely") + + return parser + + +def _dir_size(path: Path) -> int: + return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + + +def _format_size(num_bytes: int) -> str: + size = float(num_bytes) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}" + size /= 1024 + + raise AssertionError("unreachable") + + +def _run_info() -> None: + if not run_app.SANDBOX_ROOT.exists(): + print(f"matrixbox-simulator: sandbox: {run_app.SANDBOX_ROOT} (doesn't exist)") + return + + size = _format_size(_dir_size(run_app.SANDBOX_ROOT)) + print(f"matrixbox-simulator: sandbox: {run_app.SANDBOX_ROOT} ({size})") + + +def _run_clean() -> None: + if not run_app.SANDBOX_ROOT.exists(): + print(f"matrixbox-simulator: nothing to clean at {run_app.SANDBOX_ROOT}") + return + + shutil.rmtree(run_app.SANDBOX_ROOT) + print(f"matrixbox-simulator: cleaned {run_app.SANDBOX_ROOT}") + + +def run(args: argparse.Namespace) -> None: + if args.sandbox_command == "info": + _run_info() + elif args.sandbox_command == "clean": + _run_clean() + else: + raise AssertionError(f"unhandled sandbox command: {args.sandbox_command!r}") + + +def main() -> None: + run(build_parser().parse_args()) + + +if __name__ == "__main__": + main()