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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user