diff --git a/AGENTS.md b/AGENTS.md index 10741b8..009e64e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,12 @@ guarantees the complete matching contract. Every namespace belongs to exactly on core, so a feature is a provider pointer rather than a claim to validate: a panel is the `screen` feature (`screen` and `tree`, including the saved rotation and theme), registered only when the firmware supplies a `GuiProvider`, and calibration is `touch.setCalibration()`. +The runtime owns what survives a teardown and nothing else: `sys.startApp(path, args)` closes the +`lua_State`, loads a path, and hands the next one its arguments as JSON. Routing, history, titles +and data directories are that Lua file's, because a back stack that cannot outlive the VM is not a +back stack, and everything else about where an app came from can. Encoding happens in the state +that still holds the table, so unencodable arguments raise at the call rather than stranding a +launch. A global namespace is a provider contract a firmware implements; anything this library provides itself is a module instead, so `require` and globals divide by who supplies the code. Those live in `native/src/bindings/lib/` and register into `package.preload`, which puts them ahead of the SD-card diff --git a/LANDSCAPE.md b/LANDSCAPE.md deleted file mode 100644 index 7be86af..0000000 --- a/LANDSCAPE.md +++ /dev/null @@ -1,174 +0,0 @@ -# Current API Landscape - -Snapshot taken 2026-08-03 from the generated LuaLS stubs in Slate32 and CrossPoint Reader. -This inventory explains the gaps between the current firmwares and the contracts under -`lua/api/core/` and `lua/api/features/`. - -## Summary - -| | LCD / Slate32 | E-ink / CrossPoint | -|---|---:|---:| -| Declared functions | 77 | 67 | -| Names present in both | 27 | 27 | -| Same parameter list | 21 | 21 | -| Same name, different parameter list | 6 | 6 | - -Matching declarations do not guarantee matching behavior. Important differences remain in -filesystem limits and ordering, TLS verification, network ownership, colors, and lifecycle. - -## Original Common Ground - -These names and parameter lists matched before the clean contract was defined. They are useful -implementation inventory, not constraints on `lua/api/core/`. - -| Namespace | Functions | Remaining decisions | -|---|---|---| -| `http` | `get`, `head`, `delete`, `post`, `patch`, `download`, `urlencode` | The contract now returns `HttpResponse|nil, error`, requires a response `maxBytes`, and authenticates TLS. | -| `fs` | `exists`, `readFile`, `writeFile`, `listFiles`, `listDirs` | Standardize the read cap, sorted listings, path validation, and failure returns. | -| `log` | `debug`, `info`, `error` | Mostly ready. | -| `sys` | `delay`, `setTickInterval` | The clean contract drops both; callback timers replace periodic ticks and blocking delays. | -| `wifi` | `disconnect`, `isConnected` | Decide whether disconnect ownership is app-specific or device-wide. | - -The basic `gui` drawing primitives are also intended for core. Colors will be opaque, -platform-native integers produced by `gui.color(r, g, b)`: RGB565 on LCD and quantized grayscale -on e-ink. Apps that need to be portable use `gui.color` rather than hard-coded values. - -## Same Name, Different Signature - -| Function | LCD | E-ink | -|---|---|---| -| `gui.clear` | `(color)` | `()` | -| `gui.drawLine` | `(x1, y1, x2, y2, color)` | `(x1, y1, x2, y2, width, color)` | -| `gui.drawText` | `(text, x, y, color, bg)` | `(font, x, y, text, color, style)` | -| `gui.fillCircle` | `(x, y, radius, color, bg)` | `(cx, cy, radius, color)` | -| `gui.getTextWidth` | `(text)` | `(font, text, style)` | -| `wifi.connect` | `(ssid, password)` | `()` using stored credentials | - -These GUI differences are current implementation drift, not permanent display features. Wi-Fi -is also generic drift: both connection forms are useful, so the eventual shared signature can -make credentials optional after both firmwares implement both behaviors. - -## Agreed GUI Direction - -Basic geometry and text measurement belong in core with identical signatures: - -```lua -gui.getWidth() -gui.getHeight() -gui.color(r, g, b) - -gui.clear(color?) -gui.fillRect(x, y, w, h, color) -gui.drawRect(x, y, w, h, color) -gui.drawLine(x1, y1, x2, y2, color, width?) -gui.fillCircle(x, y, radius, color, background?) - -gui.getTextWidth(font, text, style?) -gui.getFontHeight(font, style?) -gui.drawText(font, x, y, text, color?, style?, background?) -``` - -Fonts are opaque, zero-based integer IDs selected through shared semantic constants: - -```lua -gui.FONT_SMALL -gui.FONT_UI -gui.FONT_BODY -gui.FONT_LARGE -``` - -Each implementation maps those roles to its available fonts or scales. Portable apps use the -named constants and metric functions rather than literal IDs or assumed pixel dimensions. -Physical names such as Bookerly and Noto Sans remain legacy implementation APIs, not part of -the core or e-ink feature contracts. - -The optional background describes the surface behind anti-aliased output; it can have no visible -effect on a renderer without partial edge pixels. E-ink refresh policy and LCD live-frame -behavior remain feature-specific. - -## Generic Features to Converge - -These are not inherently LCD or e-ink concerns and should not be permanently assigned to a -display feature. - -### Present only on E-ink - -- Filesystem mutation and paging: `fileSize`, `mkdir`, `readLineAt`, `remove`, `removeTree`, `rename`. - These are intentionally under `lua/api/core/`; Slate32 must still implement them. -- Independent timers: `timer.after`, `timer.every`, `timer.cancel` -- `sys.uptime` / `sys.millis` naming - -### Present only on LCD - -- App routing: `sys.launch`, `sys.replace`, `sys.back` -- Runtime state: `sys.getAppName`, `setAppName`, `getMemory`, `isClockSynced` -- Wi-Fi management: `scan`, explicit credential connection, `forget` -- SD-backed `require` and app-local modules (runtime behavior, not a binding declaration) -- Native compact widget tree in `node.*` - -The contract intentionally includes all of these generic capabilities. Both firmwares are under -our control, so they migrate to the clean API without compatibility aliases. The compact node -tree remains useful on e-ink for layout, painting, invalidation, and directional focus. Focus is -tree-level state with a deliberate appearance; moving it invalidates only the old and new nodes. - -## Feature Contracts - -- `buttons` guarantees physical-button polling/callbacks and button hints. -- `touch` guarantees calibrated/raw coordinates, touch callbacks, and calibration persistence. - -A feature exists only where an app can call something a device may not have. Panel technology is -not such a case: firmware-owned publication and waveform policy already hold for every display, and -`gui.setFullscreen()` and `gui.roundRect()` are core, with a panel that has no gradient flattening -it exactly as `gui.color()` quantizes to grayscale. So the display is never a feature, and input is -never inferred from it. BLE/GATT remains -required core `ble`; a future `bt` namespace is reserved for Classic Bluetooth. Generic BMP, -polygon, shape, rotation, color, and text operations are core. Rotation and timezone settings are -also core, while theme persistence and application belong to shared `ui.lua`. - -## Lifecycle Differences - -| Concern | LCD | E-ink | -|---|---|---| -| Entry | required `init(arg)` | required `init()` | -| Navigation | Lua route stack | exit to C++ launcher | -| Input callbacks | touch down/move/up/tap | button callback and polling | -| Publication | drawing is immediately visible | apps currently call explicit refresh | -| Modules | app-local and shared `require` | effectively single-file apps | - -The contract standardizes `init(arg?)`, navigation, and module loading. Optional `draw(deltaMs)` -runs once after initialization and then at most 30 FPS, best effort. The host passes monotonic -elapsed milliseconds, with zero on the first frame. Callback timers replace `on_tick`. Input -callbacks remain feature-specific. Firmware commits dirty content after callback batches and owns panel -refresh policy, including e-ink waveforms. `sys.getAPIVersion()` identifies the integer contract -version implemented by the firmware. - -## Contract Rules - -1. This is a clean API. Backward compatibility has no weight because all consumers are controlled - and migrate with the implementations. -2. A core function matches in name, parameters, returns, and observable behavior. -3. GUI coordinates use a top-left origin, including text; implementations translate driver - baselines and orientation details. -4. Invalid arguments raise Lua errors. Runtime failures return `nil, error`; filesystem reads - take an explicit `maxBytes` up to 64 KiB and fail rather than truncate. `fs.writeFile()` is - atomic: failure leaves the previous contents intact. -5. Apps live under `/.lua/apps`, shared modules under `/.lua/lib`, and persistent app data under - `/.lua/data/`. Launch paths name directories relative to `apps`; firmware appends - `main.lua`. The first path component is the immutable app ID and determines `getAppDataPath()`. -6. `node` owns one focused ID outside its 16-byte nodes. Directional movement is geometric and - non-wrapping, with a distinct focused appearance. -7. `draw(deltaMs)` is the optional frame loop, capped at 30 FPS; the host supplies elapsed - monotonic milliseconds and callback-based `timer.after/every` handle periodic work. -8. Firmware commits dirty display content and owns panel refresh policy; no refresh API is - exposed to apps. -9. Apps are fully trusted; the contract adds no permissions or sandbox. -10. BLE/GATT is core `ble`; Classic Bluetooth remains undefined rather than sharing an inaccurate - namespace. -11. This repository owns portable modules under `lua/lib/`, including global theme behavior and the - declarative widget toolkit in `ui.lua`. Widgets expose only `on_enter`, `on_exit`, and - `on_click`: touch and directional focus map onto the same active-state lifecycle. -12. HTTPS authenticates certificates. -13. Generated firmware stubs are checked against this repository rather than copied here as - competing sources of truth. -14. Feature contracts contain only their declared hardware capability. -15. Runtime smoke tests cover semantics that LuaLS declarations cannot express. diff --git a/README.md b/README.md index 69085f9..a81bef3 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,8 @@ A future shared contract for Lua applications running on the ESP32 firmwares in `../slate32` and `../crosspoint-reader`. Lua declarations and shared modules live under `lua/`; the vendored interpreter and shared C/C++ -runtime live under `native/`. [`LANDSCAPE.md`](LANDSCAPE.md) records how the contract differs from -the current firmwares. Runtime bindings remain authoritative until this repository is wired into -their tests. +runtime live under `native/`. Runtime bindings remain authoritative until this repository is wired +into their tests. ## Layout @@ -59,36 +58,40 @@ registered. The declarations are a clean target, not the intersection of today's APIs. Existing apps and firmwares migrate to it without compatibility aliases. Safe filesystem mutation, app navigation, module loading, and `ble` are core even where a firmware does not implement them yet. -Every app may use `require`; modules resolve from its app directory and `/.lua/lib`. This repository -owns portable shared modules such as `ui.lua`; firmware-specific modules stay with their firmware. +Every app may use `require`; the entry file points `package.path` wherever it keeps apps and +modules. This repository owns portable shared modules such as `ui.lua`; firmware-specific modules +stay with their firmware. The tree below is a convention of the Lua that boots, not something the +runtime knows -- it loads the one path it is given. ```text /.lua/ + main.lua the entry file a firmware boots apps//main.lua - apps///main.lua data// lib/.lua ``` -The runtime owns app loading: `Runtime::startApp()` opens a fresh `lua_State`, points `require` at -the app directory and `/.lua/lib`, runs the chunk, and calls `init(arg)`. `sys.launch`/`replace`/ -`back` only record intent, because swapping the state inside a callback would free the VM that is -still executing; the firmware calls `applyPendingNavigation()` between batches. History, the -launcher fallback, app identity, and `sys.hasFeature()` all live there too, which is why +The runtime owns the teardown and nothing above it. `sys.startApp(path, args)` closes the +`lua_State`, opens a fresh one, loads a path, and calls `start(args)` on the table it returns. +The arguments cross as JSON, because the table they came from dies with the state that built it; +encoding happens while that state still lives, so an argument JSON cannot carry raises at the call +rather than stranding a launch. + +That is the whole of navigation. Routing, history, titles and data directories are decided by the +Lua file a firmware boots, since the only thing that structurally cannot live there is a value +that has to outlive the VM -- and the arguments are that value. `sys.startApp` records intent and +returns, because swapping the state inside a callback would free the VM still executing it; the +firmware calls `applyPendingNavigation()` between batches. With app identity gone from C++, `SysProvider` is down to `millis`, `memory`, and `isClockSynced`. -`sys.launch("Settings/Calibration")` resolves the nested `main.lua`; only top-level apps appear in -the launcher. The first path component is the immutable app ID, so every Settings route shares -`sys.getAppDataPath()` and `/.lua/data/Settings`. Package updates do not touch persistent data. - -`draw(deltaMs)` is an optional frame loop called once after `init()` and then at most 30 FPS, +`draw(deltaMs)` is an optional frame loop called once after `start()` and then at most 30 FPS, best effort. The host passes monotonic elapsed milliseconds (`0` on the first frame). Timers take Lua callbacks and return cancellation handles. The firmware decides whether an event reaches an app at all -- jitter filtering, chrome, and debouncing are its business -- and `Runtime::call*` decides what the app sees. A release fires `on_touch_up` then the `on_touch` alias, and a button release fires `on_button_up` then -`on_button`, so the ordering is identical on every device. Only a failed `init()` stops an app; +`on_button`, so the ordering is identical on every device. Only a failed `start()` stops an app; every other callback logs through `LogProvider` and carries on. Calling a feature callback without its provider is a wiring bug and says so. diff --git a/lua/api/core/runtime.lua b/lua/api/core/runtime.lua index 065b5a8..252633e 100644 --- a/lua/api/core/runtime.lua +++ b/lua/api/core/runtime.lua @@ -2,10 +2,10 @@ -- Generated from native/src/runtime/runtime.cpp. Do not edit. --- The firmware loads /.lua/main.lua into every fresh state and calls these on the --- table it returns. Where apps live, what surrounds them and which of these an app --- itself sees are all main.lua's to decide, which is why an app composes the --- classes for the features it handles: +-- The firmware loads the path it was booted with into every fresh state and calls +-- these on the table it returns. Where apps live, what surrounds them and which of +-- these an app itself sees are all that file's to decide, which is why an app +-- composes the classes for the features it handles: -- -- ---@class PaintApp : App, TouchHandlers -- @@ -14,7 +14,5 @@ -- Timer callbacks are registered directly with timer.after/every. ---@class App ----@field home? string The route sys.back() lands on once history is empty. ----@field data? string The sys.getAppDataPath() template whose ? is the app id. ----@field start fun(route: string, arg?: string) Required. Mounts the route; failing here leaves no app running. +---@field start fun(args?: table) Required. Mounts whatever the arguments describe; failing here leaves no app running. ---@field draw? fun(deltaMs: integer) Optional frame loop, called once after start and then at most 30 FPS, best effort. diff --git a/lua/api/core/sys.lua b/lua/api/core/sys.lua index 59cc4b6..3f1b1cd 100644 --- a/lua/api/core/sys.lua +++ b/lua/api/core/sys.lua @@ -20,38 +20,10 @@ function sys.hasFeature(feature) end ---@return integer function sys.getMillis() end ----Returns the immutable first path component of the running app. ----@return string -function sys.getAppID() end - ----Returns the running app title, initially the app ID. ----@return string -function sys.getAppTitle() end - ----Returns the current app's guaranteed-existing persistent data directory. ----@return string Absolute path under /.lua/data, preserved across app updates. -function sys.getAppDataPath() end - ----Changes the running app's display title. ----@param title string -function sys.setAppTitle(title) end - ----Launches a route, which main.lua resolves, and pushes the current one. ----@param path string App-relative route; traversal is rejected. ----@param arg? string Passed to main.start(route, arg). -function sys.launch(path, arg) end - ----Launches a route without retaining the current one. ----@param path string App-relative route; traversal is rejected. ----@param arg? string Passed to main.start(route, arg). -function sys.replace(path, arg) end - ----Returns to the previous app, or the launcher when history is empty. -function sys.back() end - ----Whether sys.back() would return somewhere rather than land on the launcher, which is what chrome needs to decide whether to offer a back control. ----@return boolean -function sys.canGoBack() end +---Tears the runtime down and starts over from a Lua file, which is the only navigation there is: history, titles and where apps live are whatever that file makes of the arguments. +---@param path string Absolute path to the Lua file to load; traversal is rejected. +---@param args? table Plain data, carried across the teardown as JSON and handed to start(args). Raises on anything JSON cannot represent. +function sys.startApp(path, args) end ---Returns heap statistics. ---@return integer freeBytes diff --git a/native/include/lua/runtime.h b/native/include/lua/runtime.h index b2a376c..6ae61d2 100644 --- a/native/include/lua/runtime.h +++ b/native/include/lua/runtime.h @@ -15,9 +15,9 @@ namespace esp32lua { // build. constexpr int32_t API_VERSION = 1; -// The one path the firmware knows. Everything below it -- where apps live, -// where their data goes, what chrome surrounds them -- is decided by the table -// this file returns. +// The path a firmware boots. Nothing else here knows it: startApp() takes +// whatever path it is given, and where apps live, where their data goes and +// what chrome surrounds them are decided by the Lua it loads. constexpr const char* MAIN_PATH = "/.lua/main.lua"; // Firmware supplies every core provider; a null feature provider is how @@ -37,6 +37,13 @@ struct Providers { ButtonsProvider* buttons = nullptr; }; +// The arguments a launch carries cross the teardown as JSON, because the table +// they came from dies with the state that built it. Encoding raises, so an app +// that passes a function sees the error at its own sys.startApp() call; +// decoding cannot, because by then there is no app to report it to. +std::string encodeJson(lua_State* state, int index); +bool decodeJson(lua_State* state, const std::string& json); + class Runtime { public: explicit Runtime(const Providers& providers); @@ -50,34 +57,24 @@ public: void close(); lua_State* state() const { return state_; } - // Replaces the running app with a fresh lua_State, loads main.lua, and hands - // it the route through start(route, arg). A failure leaves no app running - // rather than a half-built one. + // Replaces the running app with a fresh lua_State, loads the path, and hands + // the table it returns its arguments through start(args). A failure leaves no + // app running rather than a half-built one. `argsJson` is the JSON a previous + // state encoded, and is the only thing that crosses the teardown. bool startApp(const std::string& path, - const std::string& arg = std::string()); + const std::string& argsJson = std::string()); bool hasApp() const { return !appPath_.empty(); } - // The app-relative route, its immutable first component, and the title the - // app chose. + // The path that was loaded, which is all the runtime knows about an app. const std::string& appPath() const { return appPath_; } - std::string appId() const; - std::string appDataPath() const; - const std::string& appTitle() const { return appTitle_; } - void setAppTitle(const std::string& title) { appTitle_ = title; } bool hasFeature(const std::string& feature) const; - // sys.launch/replace/back record intent and return; swapping the lua_State - // inside a callback would free the VM that is still executing. The firmware - // applies it between batches. - void requestLaunch(const std::string& path, const std::string& arg, - bool replace); - void requestBack(); - bool hasPendingNavigation() const { return pending_.kind != Pending::None; } - // Whether sys.back() would return somewhere rather than land on the launcher, - // which is what firmware chrome needs to decide whether to offer a back - // control. - bool canGoBack() const { return !history_.empty(); } - // Loads whatever was requested. False means the app failed to start or - // history ran out at the launcher, in which case no app is running. + // sys.startApp records intent and returns; swapping the lua_State inside a + // callback would free the VM that is still executing. The firmware applies it + // between batches. + void requestStart(const std::string& path, const std::string& argsJson); + bool hasPendingNavigation() const { return pending_.pending; } + // Loads whatever was requested. False means the app failed to start, in which + // case no app is running. bool applyPendingNavigation(); LogProvider& log() const { return *providers_.log; } @@ -97,7 +94,7 @@ public: // defines. The firmware decides whether an event happens at all -- jitter and // debouncing are its business -- and main.lua decides who sees it. Only a // failed start() stops an app; every other callback logs and carries on. - bool callStart(const std::string& route, const std::string& arg); + bool callStart(const std::string& argsJson); void callDraw(int32_t deltaMs); // An Up phase also fires the on_touch tap alias, in that order. void callTouch(TouchPhase phase, int32_t x, int32_t y); @@ -119,23 +116,17 @@ private: bool repeating; }; - struct Route { - std::string path; - std::string arg; - }; - struct Pending { - enum Kind { None, Launch, Replace, Back } kind = None; - Route route; + bool pending = false; + std::string path; + std::string argsJson; }; bool loadScript(const std::string& path); - // Runs main.lua and keeps the table it returns; the app is mounted by it, not - // by the runtime. - bool loadMain(); + // Runs the app's entry file and keeps the table it returns; the app is + // mounted by that table, not by the runtime. + bool loadMain(const std::string& path); void installLoader(); - // A field of the main table, or the fallback when main.lua names none. - std::string mainField(const char* key, const char* fallback); static int searchModule(lua_State* state); static int searchEmbedded(lua_State* state); static int loadFile(lua_State* state); @@ -167,15 +158,10 @@ private: TimerId nextTimerId_ = 1; int batchDepth_ = 0; - // Registry reference to the table main.lua returned, or 0 before one loads. + // Registry reference to the table the entry file returned, or 0 before one + // loads. int mainRef_ = 0; - // Read from main.lua once per load, because sys.back() out of the last app - // needs the route after that app's state is gone. - std::string home_; - std::string dataTemplate_; std::string appPath_; - std::string appTitle_; - std::vector history_; Pending pending_; }; diff --git a/native/src/bindings/core/sys.cpp b/native/src/bindings/core/sys.cpp index 3776a62..5ccec4b 100644 --- a/native/src/bindings/core/sys.cpp +++ b/native/src/bindings/core/sys.cpp @@ -20,40 +20,19 @@ int getMillis(lua_State* state) { lua_pushinteger(state, Runtime::from(state)->sys().millis()); return 1; } -int getAppID(lua_State* state) { - pushString(state, Runtime::from(state)->appId()); - return 1; -} -int getAppTitle(lua_State* state) { - pushString(state, Runtime::from(state)->appTitle()); - return 1; -} -int getAppDataPath(lua_State* state) { - pushString(state, Runtime::from(state)->appDataPath()); - return 1; -} -int setAppTitle(lua_State* state) { - Runtime::from(state)->setAppTitle(luaL_checkstring(state, 1)); - return 0; -} - -int navigate(lua_State* state, bool replace) { +// Encoding happens here, in the state that still holds the table, so an app +// passing something JSON cannot carry raises at its own call rather than +// stranding the launch. +int startApp(lua_State* state) { + luaL_checkstring(state, 1); + if (!lua_isnoneornil(state, 2)) + luaL_checktype(state, 2, LUA_TTABLE); + const std::string json = + lua_isnoneornil(state, 2) ? std::string() : encodeJson(state, 2); const std::string path = checkString(state, 1); - const std::string arg = - lua_isnoneornil(state, 2) ? std::string() : checkString(state, 2); - Runtime::from(state)->requestLaunch(path, arg, replace); + Runtime::from(state)->requestStart(path, json); return 0; } -int launch(lua_State* state) { return navigate(state, false); } -int replace(lua_State* state) { return navigate(state, true); } -int back(lua_State* state) { - Runtime::from(state)->requestBack(); - return 0; -} -int canGoBack(lua_State* state) { - lua_pushboolean(state, Runtime::from(state)->canGoBack()); - return 1; -} int getMemory(lua_State* state) { const MemoryInfo memory = Runtime::from(state)->sys().memory(); @@ -86,36 +65,14 @@ const luaL_Reg FUNCTIONS[] = { // --- Returns monotonic milliseconds since boot. // @return integer {"getMillis", getMillis}, - // --- Returns the immutable first path component of the running app. - // @return string - {"getAppID", getAppID}, - // --- Returns the running app title, initially the app ID. - // @return string - {"getAppTitle", getAppTitle}, - // --- Returns the current app's guaranteed-existing persistent data - // directory. - // @return string Absolute path under /.lua/data, preserved across app - // updates. - {"getAppDataPath", getAppDataPath}, - // --- Changes the running app's display title. - // @param title string - {"setAppTitle", setAppTitle}, - // --- Launches a route, which main.lua resolves, and pushes the current - // one. - // @param path string App-relative route; traversal is rejected. - // @param arg string|nil Passed to main.start(route, arg). - {"launch", launch}, - // --- Launches a route without retaining the current one. - // @param path string App-relative route; traversal is rejected. - // @param arg string|nil Passed to main.start(route, arg). - {"replace", replace}, - // --- Returns to the previous app, or the launcher when history is empty. - {"back", back}, - // --- Whether sys.back() would return somewhere rather than land on the - // launcher, which is what chrome needs to decide whether to offer a back - // control. - // @return boolean - {"canGoBack", canGoBack}, + // --- Tears the runtime down and starts over from a Lua file, which is the + // only navigation there is: history, titles and where apps live are + // whatever that file makes of the arguments. + // @param path string Absolute path to the Lua file to load; traversal is + // rejected. + // @param args table|nil Plain data, carried across the teardown as JSON and + // handed to start(args). Raises on anything JSON cannot represent. + {"startApp", startApp}, // --- Returns heap statistics. // @return integer freeBytes // @return integer totalBytes diff --git a/native/src/bindings/lib/cjson.cpp b/native/src/bindings/lib/cjson.cpp index db0466b..971be1e 100644 --- a/native/src/bindings/lib/cjson.cpp +++ b/native/src/bindings/lib/cjson.cpp @@ -4,6 +4,8 @@ #include +#include + extern "C" { #include "lauxlib.h" #include "lua.h" @@ -46,4 +48,43 @@ void registerJson(lua_State* state) { } } // namespace bindings + +namespace { + +// Leaves cjson. on the stack. Going through require() rather than a +// second luaopen_cjson keeps one module, and one configured depth, per state. +void pushCjson(lua_State* state, const char* name) { + lua_getglobal(state, "require"); + lua_pushstring(state, "cjson"); + lua_call(state, 1, 1); + lua_getfield(state, -1, name); + lua_remove(state, -2); +} + +} // namespace + +std::string encodeJson(lua_State* state, int index) { + const int value = lua_absindex(state, index); + pushCjson(state, "encode"); + lua_pushvalue(state, value); + lua_call(state, 1, 1); + size_t length = 0; + const char* text = lua_tolstring(state, -1, &length); + const std::string json(text ? text : "", length); + lua_pop(state, 1); + return json; +} + +bool decodeJson(lua_State* state, const std::string& json) { + if (lua_gettop(state) + 4 > LUAI_MAXSTACK) + return false; + pushCjson(state, "decode"); + lua_pushlstring(state, json.data(), json.size()); + if (lua_pcall(state, 1, 1, 0) != LUA_OK) { + lua_pop(state, 1); + return false; + } + return true; +} + } // namespace esp32lua diff --git a/native/src/runtime/runtime.cpp b/native/src/runtime/runtime.cpp index 3c82be2..434042d 100644 --- a/native/src/runtime/runtime.cpp +++ b/native/src/runtime/runtime.cpp @@ -26,10 +26,13 @@ namespace { // App routes are relative and stay inside the apps root, so a traversal // component is a hard no. -bool isSafeRoute(const std::string& path) { - if (path.empty() || path[0] == '/') +// Absolute, and no component that could climb out of the card. Apps are +// trusted, so this is a guard against a mistake rather than an attacker -- but +// it is the one place a path from Lua becomes a file the runtime opens. +bool isSafePath(const std::string& path) { + if (path.empty() || path[0] != '/') return false; - size_t start = 0; + size_t start = 1; while (start <= path.size()) { const size_t end = path.find('/', start); const std::string part = path.substr( @@ -95,7 +98,6 @@ void Runtime::close() { mainRef_ = 0; tree_.reset(); appPath_.clear(); - appTitle_.clear(); } Runtime::Batch::Batch(Runtime& runtime) : runtime_(runtime) { @@ -119,17 +121,6 @@ bool Runtime::beginCall(const char* name) { return false; } -std::string Runtime::mainField(const char* key, const char* fallback) { - if (!mainRef_) - return fallback; - lua_rawgeti(state_, LUA_REGISTRYINDEX, mainRef_); - lua_getfield(state_, -1, key); - const char* value = lua_tostring(state_, -1); - const std::string result = value ? value : fallback; - lua_pop(state_, 2); - return result; -} - // Calls a chunk or handler that leaves one value on the stack; the caller owns // it. bool Runtime::finishCallValue(const char* name) { @@ -153,13 +144,13 @@ bool Runtime::finishCall(const char* name, int argc) { } // @lua-app App core/runtime -// @lua-preamble -- The firmware loads /.lua/main.lua into every fresh state and -// calls these on the -// @lua-preamble -- table it returns. Where apps live, what surrounds them and -// which of these an app -// @lua-preamble -- itself sees are all main.lua's to decide, which is why an -// app composes the -// @lua-preamble -- classes for the features it handles: +// @lua-preamble -- The firmware loads the path it was booted with into every +// fresh state and calls +// @lua-preamble -- these on the table it returns. Where apps live, what +// surrounds them and which of +// @lua-preamble -- these an app itself sees are all that file's to decide, +// which is why an app +// @lua-preamble -- composes the classes for the features it handles: // @lua-preamble -- // @lua-preamble -- ---@class PaintApp : App, TouchHandlers // @lua-preamble -- @@ -169,23 +160,28 @@ bool Runtime::finishCall(const char* name, int argc) { // own refresh policy. // @lua-preamble -- Timer callbacks are registered directly with // timer.after/every. -// @lua-field home? string The route sys.back() lands on once history is empty. -// @lua-field data? string The sys.getAppDataPath() template whose ? is the app -// id. -// ---Required. Mounts the route; failing here leaves no app running. -// @param route string The app path sys.launch, sys.back or the boot recorded. -// @param arg string|nil The string passed to sys.launch or sys.replace. +// ---Required. Mounts whatever the arguments describe; failing here leaves no +// app running. +// @param args table|nil The table passed to sys.startApp, carried across the +// teardown as JSON. // @lua-fn start -bool Runtime::callStart(const std::string& route, const std::string& arg) { +bool Runtime::callStart(const std::string& argsJson) { const Batch batch(*this); if (!beginCall("start")) { - providers_.log->write(LogLevel::Error, "start: main.lua defines none"); + providers_.log->write(LogLevel::Error, + "start: the entry file defines none"); return false; } - lua_pushlstring(state_, route.data(), route.size()); - lua_pushlstring(state_, arg.data(), arg.size()); - return finishCall("start", 2); + if (argsJson.empty()) { + lua_pushnil(state_); + } else if (!decodeJson(state_, argsJson)) { + providers_.log->write(LogLevel::Error, + "start: cannot decode arguments: " + argsJson); + lua_pop(state_, 1); + return false; + } + return finishCall("start", 1); } // ---Optional frame loop, called once after start and then at most 30 FPS, best @@ -305,21 +301,6 @@ void Runtime::cancelAllTimers() { timers_.clear(); } -std::string Runtime::appId() const { - const size_t slash = appPath_.find('/'); - return slash == std::string::npos ? appPath_ : appPath_.substr(0, slash); -} - -// The template is main.lua's; substituting the app id here keeps every app on -// its own directory whatever tree the card uses. -std::string Runtime::appDataPath() const { - const size_t mark = dataTemplate_.find('?'); - if (mark == std::string::npos) - return dataTemplate_; - return dataTemplate_.substr(0, mark) + appId() + - dataTemplate_.substr(mark + 1); -} - bool Runtime::hasFeature(const std::string& feature) const { if (feature == "screen") return providers_.gui != nullptr; @@ -330,8 +311,8 @@ bool Runtime::hasFeature(const std::string& feature) const { return false; } -bool Runtime::startApp(const std::string& path, const std::string& arg) { - if (!isSafeRoute(path)) { +bool Runtime::startApp(const std::string& path, const std::string& argsJson) { + if (!isSafePath(path)) { providers_.log->write(LogLevel::Error, "refusing to start '" + path + "'"); return false; } @@ -340,73 +321,48 @@ bool Runtime::startApp(const std::string& path, const std::string& arg) { if (!open()) return false; appPath_ = path; - appTitle_ = appId(); installLoader(); - // main.lua runs first and start() mounts the route, so an app that fails - // either way leaves nothing behind. - if (!loadMain() || !callStart(path, arg)) { + // The entry file runs first and start() mounts whatever it decides to, so an + // app that fails either way leaves nothing behind. + if (!loadMain(path) || !callStart(argsJson)) { close(); return false; } return true; } -bool Runtime::loadMain() { - if (!loadScript(MAIN_PATH)) { +bool Runtime::loadMain(const std::string& path) { + if (!loadScript(path)) { providers_.log->write(LogLevel::Error, lua_tostring(state_, -1) ? lua_tostring(state_, -1) - : "cannot load main.lua"); + : "cannot load " + path); return false; } - if (!finishCallValue("main.lua")) + if (!finishCallValue(path.c_str())) return false; if (!lua_istable(state_, -1)) { - providers_.log->write(LogLevel::Error, "main.lua returned no table"); + providers_.log->write(LogLevel::Error, path + " returned no table"); lua_pop(state_, 1); return false; } mainRef_ = luaL_ref(state_, LUA_REGISTRYINDEX); - home_ = mainField("home", "Home"); - dataTemplate_ = mainField("data", "/.lua/data/?"); return true; } -void Runtime::requestLaunch(const std::string& path, const std::string& arg, - bool replace) { - pending_.kind = replace ? Pending::Replace : Pending::Launch; - pending_.route.path = path; - pending_.route.arg = arg; -} - -void Runtime::requestBack() { - pending_.kind = Pending::Back; - pending_.route = Route(); +void Runtime::requestStart(const std::string& path, + const std::string& argsJson) { + pending_.pending = true; + pending_.path = path; + pending_.argsJson = argsJson; } bool Runtime::applyPendingNavigation() { const Pending pending = pending_; pending_ = Pending(); - if (pending.kind == Pending::None) + if (!pending.pending) return hasApp(); - - if (pending.kind == Pending::Back) { - // An empty history means the launcher, which is an app like any other. - Route target; - target.path = home_; - if (!history_.empty()) { - target = history_.back(); - history_.pop_back(); - } - return startApp(target.path, target.arg); - } - - if (pending.kind == Pending::Launch && hasApp()) { - Route current; - current.path = appPath_; - history_.push_back(current); - } - return startApp(pending.route.path, pending.route.arg); + return startApp(pending.path, pending.argsJson); } Runtime* Runtime::from(lua_State* state) { diff --git a/native/test/runtime_test.cpp b/native/test/runtime_test.cpp index 3aba8f2..7b9563b 100644 --- a/native/test/runtime_test.cpp +++ b/native/test/runtime_test.cpp @@ -93,21 +93,23 @@ int main() { bench.http.headers[0].name == "Accept"); expectError(state, "http.get('https://example.test', {maxBytes = 999999})"); - // cjson is required rather than global, because the library provides it and no - // firmware implements it. Decoding is lua-cjson's; what is asserted here is - // the wiring and the depth limit the panel's C stack needs. - run(state, "local cjson = require 'cjson'\n" - "assert(rawget(_G, 'cjson') == nil)\n" - "local value = cjson.decode('{\"a\":[1,2.5,true],\"b\":\"x\\\\u00e9\"}')\n" - "assert(math.type(value.a[1]) == 'integer' and value.a[2] == 2.5)\n" - "assert(value.a[3] == true and value.b == 'x\\u{e9}')\n" - "assert(cjson.decode('null') == cjson.null)\n" - "assert(cjson.encode({1, 2, 3}) == '[1,2,3]')\n" - "assert(not pcall(cjson.decode, '{'))\n" - "assert(not pcall(cjson.encode, print))\n" - "local deep = {}; for _ = 1, 40 do deep = {deep} end\n" - "assert(not pcall(cjson.encode, deep))\n" - "assert(not pcall(cjson.decode, string.rep('[', 40)))"); + // cjson is required rather than global, because the library provides it and + // no firmware implements it. Decoding is lua-cjson's; what is asserted here + // is the wiring and the depth limit the panel's C stack needs. + run(state, + "local cjson = require 'cjson'\n" + "assert(rawget(_G, 'cjson') == nil)\n" + "local value = " + "cjson.decode('{\"a\":[1,2.5,true],\"b\":\"x\\\\u00e9\"}')\n" + "assert(math.type(value.a[1]) == 'integer' and value.a[2] == 2.5)\n" + "assert(value.a[3] == true and value.b == 'x\\u{e9}')\n" + "assert(cjson.decode('null') == cjson.null)\n" + "assert(cjson.encode({1, 2, 3}) == '[1,2,3]')\n" + "assert(not pcall(cjson.decode, '{'))\n" + "assert(not pcall(cjson.encode, print))\n" + "local deep = {}; for _ = 1, 40 do deep = {deep} end\n" + "assert(not pcall(cjson.encode, deep))\n" + "assert(not pcall(cjson.decode, string.rep('[', 40)))"); run(state, "assert(wifi.scan()[1].ssid == 'home')\n" "assert(wifi.isConnected())\n" @@ -212,8 +214,8 @@ int main() { "local function note(name) return function(a) events[#events + 1] = " "name .. ':' .. tostring(a) end end\n" "return {\n" - " start = function(route, arg) events[#events + 1] = 'start:' .. " - "route .. ':' .. arg end,\n" + " start = function(args) events[#events + 1] = 'start:' .. " + "args.app .. ':' .. args.arg end,\n" " draw = function(delta) if delta < 0 then error('kaboom') end\n" " events[#events + 1] = 'draw:' .. delta end,\n" " onTouchDown = note('down'),\n" @@ -223,7 +225,8 @@ int main() { " onButton = note('btap'),\n" "}\n"; esp32lua::Runtime hosted(chrome.providers()); - assert(hosted.startApp("Reader", "book.epub")); + assert(hosted.startApp(esp32lua::MAIN_PATH, + "{\"app\":\"Reader\",\"arg\":\"book.epub\"}")); chrome.gui.commits = 0; hosted.callDraw(33); hosted.callTouch(esp32lua::TouchPhase::Down, 5, 6); @@ -246,13 +249,19 @@ int main() { chrome.fs.files["/.lua/main.lua"] = "return { start = function() error('boom') end }"; - assert(!hosted.startApp("Reader")); + assert(!hosted.startApp(esp32lua::MAIN_PATH)); assert(chrome.log.message.find("start: ") == 0); assert(!hosted.hasApp()); + // Arguments that are not JSON leave no app running rather than a state with + // no start() behind it. + chrome.fs.files["/.lua/main.lua"] = "return { start = function() end }"; + assert(!hosted.startApp(esp32lua::MAIN_PATH, "{not json")); + assert(!hosted.hasApp()); + chrome.fs.files["/.lua/main.lua"] = "return 7"; - assert(!hosted.startApp("Reader")); - assert(chrome.log.message == "main.lua returned no table"); + assert(!hosted.startApp(esp32lua::MAIN_PATH)); + assert(chrome.log.message == "/.lua/main.lua returned no table"); } // A feature callback without its provider is a wiring bug, not a silent @@ -289,16 +298,18 @@ int main() { { fake::Bench host; // The tree is main.lua's, so the test states it the way a card would. + // Routing, history and the launcher are all this file's, built out of the + // arguments it is handed; the runtime knows only the path it loads. host.fs.files["/.lua/main.lua"] = "package.path = '/.lua/lib/?.lua'\n" "return {\n" - " home = 'Home',\n" - " data = '/.lua/data/?',\n" - " start = function(route, arg)\n" + " start = function(args)\n" + " args = args or {app = 'Home'}\n" + " route, history = args.app, args.history or {}\n" " local dir = '/.lua/apps/' .. route\n" " package.path = dir .. '/?.lua;/.lua/lib/?.lua'\n" " app = assert(loadfile(dir .. '/main.lua'))()\n" - " app.init(arg)\n" + " app.init(args.arg)\n" " end,\n" "}\n"; host.fs.files["/.lua/lib/greet.lua"] = @@ -315,44 +326,40 @@ int main() { "return {init = function() end}"; esp32lua::Runtime app(host.providers()); - assert(app.startApp("Home")); - assert(app.appId() == "Home" && app.appTitle() == "Home"); - assert(app.appDataPath() == "/.lua/data/Home"); - run(app.state(), "assert(started == 'hi:')"); + assert(app.startApp(esp32lua::MAIN_PATH)); + assert(app.appPath() == esp32lua::MAIN_PATH); + run(app.state(), "assert(started == 'hi:nil' and route == 'Home')"); - // A subapp shares the app ID, so both routes share one data directory. - run(app.state(), "sys.launch('Reader', 'book.epub')"); + // The arguments cross the teardown as JSON, so a nested table survives and + // the history is whatever main.lua chose to put in it. + run(app.state(), + "sys.startApp('/.lua/main.lua', " + "{app = 'Reader', arg = 'book.epub', history = {'Home'}})"); assert(app.hasPendingNavigation()); - run(app.state(), "assert(started == 'hi:')"); // the current app keeps - // running until applied + run(app.state(), "assert(started == 'hi:nil')"); // still running until + // applied assert(app.applyPendingNavigation()); - run(app.state(), "assert(started == 'page:book.epub')"); - run(app.state(), "sys.launch('Reader/Notes')"); + run(app.state(), "assert(started == 'page:book.epub')\n" + "assert(route == 'Reader' and history[1] == 'Home')"); + + // Going back is the same call with the stack main.lua kept, popped by + // main.lua: nothing in C++ remembers where the app came from. + run(app.state(), "sys.startApp('/.lua/main.lua', {app = history[1]})"); assert(app.applyPendingNavigation()); - assert(app.appPath() == "Reader/Notes" && app.appId() == "Reader"); - assert(app.appDataPath() == "/.lua/data/Reader"); + run(app.state(), "assert(route == 'Home' and #history == 0)"); - // Back unwinds history, then lands on the launcher. - run(app.state(), "sys.back()"); - assert(app.applyPendingNavigation() && app.appPath() == "Reader"); - run(app.state(), "sys.back()"); - assert(app.applyPendingNavigation() && app.appPath() == "Home"); - run(app.state(), "sys.back()"); - assert(app.applyPendingNavigation() && app.appPath() == "Home"); + // Arguments JSON cannot carry raise at the call, leaving the app running. + expectError(app.state(), "sys.startApp('/.lua/main.lua', {f = print})"); + assert(!app.hasPendingNavigation()); + run(app.state(), "assert(route == 'Home')"); + expectError(app.state(), "sys.startApp('/.lua/main.lua', 'not a table')"); - // sys.replace does not grow history, so back from it still reaches the - // launcher. - run(app.state(), "sys.replace('Reader', 'other.epub')"); - assert(app.applyPendingNavigation() && app.appPath() == "Reader"); - run(app.state(), "sys.back()"); - assert(app.applyPendingNavigation() && app.appPath() == "Home"); - - // A missing app, a broken app, and a traversal all leave nothing running. - assert(!app.startApp("Absent")); + // A missing file, a broken app, and a traversal all leave nothing running. + assert(!app.startApp("/.lua/absent.lua")); assert(!app.hasApp() && app.state() == nullptr); host.fs.files["/.lua/apps/Broken/main.lua"] = "return {init = function() error('nope') end}"; - assert(!app.startApp("Broken")); + assert(!app.startApp(esp32lua::MAIN_PATH, "{\"app\":\"Broken\"}")); assert(!app.hasApp()); assert(!app.startApp("../secrets")); assert(host.log.message.find("refusing to start") == 0);