feat(runtime): hand the whole app contract to /.lua/main.lua

The firmware knew four paths and called four globals, so the card could
not change its own layout or put anything around an app. It now loads one
file, and the table that file returns owns the rest: start() mounts the
route, home and data name the tree, and every callback is a field on it
rather than a global the app and its chrome would have to share.
This commit is contained in:
2026-08-04 20:51:25 -04:00
parent e85adfa757
commit e3153f57c5
5 changed files with 192 additions and 108 deletions
+3 -5
View File
@@ -123,13 +123,11 @@ int Runtime::loadFile(lua_State* state) {
return 2;
}
void Runtime::installLoader(const std::string& appDir) {
// package.path is left to main.lua, which is loaded by absolute path and knows
// where its libraries and its apps are.
void Runtime::installLoader() {
lua_getglobal(state_, "package");
const std::string path = appDir + "/?.lua;" + paths_.lib + "/?.lua";
lua_pushlstring(state_, path.data(), path.size());
lua_setfield(state_, -2, "path");
// Keep the preload searcher, drop the C loaders: they can only report
// misleading errors about shared objects that were never there.
lua_getfield(state_, -1, "searchers");
+80 -32
View File
@@ -45,8 +45,7 @@ bool isSafeRoute(const std::string& path) {
} // namespace
Runtime::Runtime(const Providers& providers, const Paths& paths)
: providers_(providers), paths_(paths) {}
Runtime::Runtime(const Providers& providers) : providers_(providers) {}
Runtime::~Runtime() { close(); }
@@ -91,6 +90,7 @@ void Runtime::close() {
cancelAllTimers();
lua_close(state_);
state_ = nullptr;
mainRef_ = 0; // the registry it referenced went with the state
// Node handles mean nothing to the next lua_State, so an app that inherited
// the previous tree would build onto its nodes.
tree_.reset();
@@ -108,13 +108,40 @@ Runtime::Batch::~Batch() {
}
bool Runtime::beginCall(const char* name) {
lua_getglobal(state_, name);
if (!mainRef_)
return false;
lua_rawgeti(state_, LUA_REGISTRYINDEX, mainRef_);
lua_getfield(state_, -1, name);
lua_remove(state_, -2);
if (lua_isfunction(state_, -1))
return true;
lua_pop(state_, 1);
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) {
if (lua_pcall(state_, 0, 1, 0) == LUA_OK)
return true;
const char* message = lua_tostring(state_, -1);
providers_.log->write(LogLevel::Error, std::string(name) + ": " +
(message ? message : "failed"));
lua_pop(state_, 1);
return false;
}
bool Runtime::finishCall(const char* name, int argc) {
if (lua_pcall(state_, argc, 0, 0) == LUA_OK)
return true;
@@ -126,17 +153,16 @@ bool Runtime::finishCall(const char* name, int argc) {
}
// @lua-global core/runtime
// @lua-preamble -- Runtime layout:
// @lua-preamble -- /.lua/apps/<AppId>/main.lua application entry
// point
// @lua-preamble -- /.lua/apps/<AppId>/<Subapp>/main.lua nested route,
// omitted from the launcher
// @lua-preamble -- /.lua/data/<AppId>/ persistent app
// data, preserved across updates
// @lua-preamble -- /.lua/lib/<module>.lua shared require()
// modules
// @lua-preamble -- require() also searches the running application's
// directory
// @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.
// @lua-preamble --
// @lua-preamble -- Fields the firmware reads: home, the route sys.back() lands
// on once history is
// @lua-preamble -- empty, and data, the sys.getAppDataPath() template whose ?
// is the app id.
// @lua-preamble --
// @lua-preamble -- The firmware does not clear the frame before calling draw(),
// and commits changed
@@ -145,20 +171,22 @@ bool Runtime::finishCall(const char* name, int argc) {
// @lua-preamble -- Timer callbacks are registered directly with
// timer.after/every.
// ---Required. Runs once before the first draw; failing here stops the app.
// ---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.
// @lua-fn init
bool Runtime::callInit(const std::string& arg) {
// @lua-fn start
bool Runtime::callStart(const std::string& route, const std::string& arg) {
const Batch batch(*this);
if (!beginCall("init")) {
providers_.log->write(LogLevel::Error, "init: the app defines none");
if (!beginCall("start")) {
providers_.log->write(LogLevel::Error, "start: main.lua defines none");
return false;
}
lua_pushlstring(state_, route.data(), route.size());
lua_pushlstring(state_, arg.data(), arg.size());
return finishCall("init", 1);
return finishCall("start", 2);
}
// ---Optional frame loop, called once after init and then at most 30 FPS, best
// ---Optional frame loop, called once after start and then at most 30 FPS, best
// effort.
// @param deltaMs integer Monotonic milliseconds since the previous draw; zero
// on the first.
@@ -280,7 +308,15 @@ std::string Runtime::appId() const {
return slash == std::string::npos ? appPath_ : appPath_.substr(0, slash);
}
std::string Runtime::appDataPath() const { return paths_.data + "/" + appId(); }
// 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 == "touch")
@@ -302,21 +338,33 @@ bool Runtime::startApp(const std::string& path, const std::string& arg) {
appPath_ = path;
appTitle_ = appId();
const std::string directory = paths_.apps + "/" + path;
installLoader(directory);
if (!loadScript(directory + "/main.lua")) {
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)) {
close();
return false;
}
return true;
}
bool Runtime::loadMain() {
if (!loadScript(MAIN_PATH)) {
providers_.log->write(LogLevel::Error, lua_tostring(state_, -1)
? lua_tostring(state_, -1)
: "load failed");
close();
: "cannot load main.lua");
return false;
}
// The chunk body runs first, then init(), so an app that fails either way
// leaves nothing behind.
if (!finishCall("main.lua", 0) || !callInit(arg)) {
close();
if (!finishCallValue("main.lua"))
return false;
if (!lua_istable(state_, -1)) {
providers_.log->write(LogLevel::Error, "main.lua 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;
}
@@ -341,7 +389,7 @@ bool Runtime::applyPendingNavigation() {
if (pending.kind == Pending::Back) {
// An empty history means the launcher, which is an app like any other.
Route target;
target.path = paths_.home;
target.path = home_;
if (!history_.empty()) {
target = history_.back();
history_.pop_back();