feat(runtime)!: sys.startApp replaces routing, history and app identity
The runtime kept a back stack, a launcher fallback, an app id and a title because a teardown destroys the Lua that would otherwise hold them. Only the first of those is true: everything about where an app came from can ride in the arguments, and the arguments are the one value that has to outlive the VM. So the runtime now does four things -- close the state, load a path, hand the next state its arguments, defer the swap to a batch boundary -- and sys.startApp(path, args) is the whole of navigation. Routing, history, titles and data directories move to the Lua file a firmware boots, where they can differ per product without a flag on Runtime. Arguments cross as JSON, encoded while the sending state still holds the table, so a function or a cycle raises at the call rather than stranding a launch. start(args) receives the decoded table, or nil at boot, which is how the entry file knows to open its own launcher. Removes launch, replace, back, canGoBack, getAppID, getAppTitle, setAppTitle and getAppDataPath, along with the home and data fields. LANDSCAPE.md goes with them: it recorded a divergence from firmwares that have since migrated.
This commit is contained in:
@@ -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
|
||||
|
||||
-174
@@ -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/<AppId>`. 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.
|
||||
@@ -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/<AppId>/main.lua
|
||||
apps/<AppId>/<Subapp>/main.lua
|
||||
data/<AppId>/
|
||||
lib/<module>.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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-32
@@ -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
|
||||
|
||||
@@ -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<Route> history_;
|
||||
Pending pending_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
#include <lua/runtime.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
extern "C" {
|
||||
#include "lauxlib.h"
|
||||
#include "lua.h"
|
||||
@@ -46,4 +48,43 @@ void registerJson(lua_State* state) {
|
||||
}
|
||||
|
||||
} // namespace bindings
|
||||
|
||||
namespace {
|
||||
|
||||
// Leaves cjson.<name> 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user