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:
2026-08-05 17:04:35 -04:00
parent 772618ef89
commit 26f3fdb6e6
10 changed files with 233 additions and 481 deletions
+32 -46
View File
@@ -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_;
};
+18 -61
View File
@@ -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
+41
View File
@@ -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
+46 -90
View File
@@ -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) {
+61 -54
View File
@@ -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);