From e3153f57c561937bfe9bb804cd411974b3cd69c3 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Tue, 4 Aug 2026 20:51:25 -0400 Subject: [PATCH] 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. --- lua/api/core/runtime.lua | 19 +++--- native/include/lua/runtime.h | 47 ++++++++------ native/src/runtime/loader.cpp | 8 +-- native/src/runtime/runtime.cpp | 112 +++++++++++++++++++++++--------- native/test/runtime_test.cpp | 114 +++++++++++++++++++++------------ 5 files changed, 192 insertions(+), 108 deletions(-) diff --git a/lua/api/core/runtime.lua b/lua/api/core/runtime.lua index 77eb27f..c08231c 100644 --- a/lua/api/core/runtime.lua +++ b/lua/api/core/runtime.lua @@ -2,21 +2,22 @@ -- Generated from native/src/runtime/runtime.cpp. Do not edit. --- Runtime layout: --- /.lua/apps//main.lua application entry point --- /.lua/apps///main.lua nested route, omitted from the launcher --- /.lua/data// persistent app data, preserved across updates --- /.lua/lib/.lua shared require() modules --- require() also searches the running application's directory +-- 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. +-- +-- Fields the firmware reads: home, the route sys.back() lands on once history is +-- empty, and data, the sys.getAppDataPath() template whose ? is the app id. -- -- The firmware does not clear the frame before calling draw(), and commits changed -- display content after each callback batch using the panel's own refresh policy. -- 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 The string passed to sys.launch or sys.replace. -function init(arg) end +function start(route, arg) end ----Optional frame loop, called once after init and then at most 30 FPS, best effort. +---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. function draw(deltaMs) end diff --git a/native/include/lua/runtime.h b/native/include/lua/runtime.h index e19908e..357b2d6 100644 --- a/native/include/lua/runtime.h +++ b/native/include/lua/runtime.h @@ -15,14 +15,10 @@ namespace esp32lua { // build. constexpr int32_t API_VERSION = 1; -// Where the runtime looks for apps, their data, and shared modules. -struct Paths { - std::string apps = "/.lua/apps"; - std::string data = "/.lua/data"; - std::string lib = "/.lua/lib"; - // Where sys.back() lands once history is empty. It is an app like any other. - std::string home = "Home"; -}; +// 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. +constexpr const char* MAIN_PATH = "/.lua/main.lua"; // Firmware supplies every core provider; a null feature provider is how // sys.hasFeature() answers false, and its namespace additions are simply never @@ -44,7 +40,7 @@ struct Providers { class Runtime { public: - explicit Runtime(const Providers& providers, const Paths& paths = Paths()); + explicit Runtime(const Providers& providers); ~Runtime(); Runtime(const Runtime&) = delete; @@ -55,9 +51,9 @@ public: void close(); lua_State* state() const { return state_; } - // Replaces the running app with a fresh lua_State, loads - // //main.lua, and calls init(arg). A failure leaves no app - // running rather than a half-built one. + // 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. bool startApp(const std::string& path, const std::string& arg = std::string()); bool hasApp() const { return !appPath_.empty(); } @@ -99,11 +95,11 @@ public: ui::Tree& tree() { return tree_; } - // Entry points into the app. The firmware decides whether an event reaches - // the app at all -- jitter, chrome and debouncing are its business -- and the - // runtime decides what the app sees. Only a failed init() stops an app; every - // other callback logs and carries on. - bool callInit(const std::string& arg); + // Entry points into main.lua, which forwards whatever the app it mounted + // 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); 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); @@ -136,7 +132,12 @@ private: }; bool loadScript(const std::string& path); - void installLoader(const std::string& appDir); + // Runs main.lua and keeps the table it returns; the app is mounted by it, not + // by the runtime. + bool loadMain(); + 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); @@ -155,9 +156,10 @@ private: Runtime& runtime_; }; - // Pushes the named global, or returns false when the app does not define it. + // Pushes main., or returns false when main.lua defines no such handler. bool beginCall(const char* name); bool finishCall(const char* name, int argc); + bool finishCallValue(const char* name); void cancelAllTimers(); Providers providers_; @@ -167,7 +169,12 @@ private: TimerId nextTimerId_ = 1; int batchDepth_ = 0; - Paths paths_; + // Registry reference to the table main.lua returned, or 0 before one loads. + int mainRef_ = 0; + // Read from main.lua once per load, because sys.back() out of the last app + // needs the route after that app's state is gone. + std::string home_; + std::string dataTemplate_; std::string appPath_; std::string appTitle_; std::vector history_; diff --git a/native/src/runtime/loader.cpp b/native/src/runtime/loader.cpp index 3c0094c..60509a4 100644 --- a/native/src/runtime/loader.cpp +++ b/native/src/runtime/loader.cpp @@ -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"); diff --git a/native/src/runtime/runtime.cpp b/native/src/runtime/runtime.cpp index 71ca5b9..e62a3cb 100644 --- a/native/src/runtime/runtime.cpp +++ b/native/src/runtime/runtime.cpp @@ -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//main.lua application entry -// point -// @lua-preamble -- /.lua/apps///main.lua nested route, -// omitted from the launcher -// @lua-preamble -- /.lua/data// persistent app -// data, preserved across updates -// @lua-preamble -- /.lua/lib/.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(); diff --git a/native/test/runtime_test.cpp b/native/test/runtime_test.cpp index 1a329ff..637623d 100644 --- a/native/test/runtime_test.cpp +++ b/native/test/runtime_test.cpp @@ -178,35 +178,6 @@ int main() { "node.draw(root)\n" "assert(painted == 320)"); - // Callbacks: only init failing stops an app, and a release fires the tap - // alias after the up. - run(state, - "events = {}\n" - "local function note(name) return function(a) events[#events + 1] = name " - ".. ':' .. tostring(a) end end\n" - "function init(arg) events[#events + 1] = 'init:' .. tostring(arg) end\n" - "function draw(delta) events[#events + 1] = 'draw:' .. delta end\n" - "on_touch_down = note('down')\n" - "on_touch_up = note('up')\n" - "on_touch = note('tap')\n" - "on_button_up = note('bup')\n" - "on_button = note('btap')"); - bench.gui.commits = 0; - assert(runtime.callInit("book.epub")); - runtime.callDraw(33); - runtime.callTouch(esp32lua::TouchPhase::Down, 5, 6); - runtime.callTouch(esp32lua::TouchPhase::Move, 5, - 7); // the app defines no on_touch_move - runtime.callTouch(esp32lua::TouchPhase::Up, 5, 8); - runtime.callButton("confirm", false); - run(state, - "assert(table.concat(events, ' ') == " - "'init:book.epub draw:33 down:5 up:5 tap:5 bup:confirm btap:confirm')"); - // One commit per visit to the app, so six calls and not seven: the release - // and its tap alias are one visible change, and the move nobody handled still - // ends a batch. - assert(bench.gui.commits == 6); - // A timer firing inside draw is still one batch. run(state, "function draw() timer.after(1, function() end) end\n" "nested = timer.after(1, function() draw() end)"); @@ -214,12 +185,57 @@ int main() { runtime.callTimer(bench.timer.scheduled.back()); assert(bench.gui.commits == 1); - run(state, "function init() error('boom') end"); - assert(!runtime.callInit("")); - assert(bench.log.message.find("init: ") == 0); - run(state, "function draw() error('kaboom') end"); - runtime.callDraw(1); // a failed frame logs and the app keeps running - assert(bench.log.message.find("draw: ") == 0); + // Callbacks land on the table main.lua returns, only start() failing stops an + // app, and a release fires the tap alias after the up. + { + fake::Bench chrome; + chrome.fs.files["/.lua/main.lua"] = + "events = {}\n" + "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" + " draw = function(delta) if delta < 0 then error('kaboom') end\n" + " events[#events + 1] = 'draw:' .. delta end,\n" + " on_touch_down = note('down'),\n" + " on_touch_up = note('up'),\n" + " on_touch = note('tap'),\n" + " on_button_up = note('bup'),\n" + " on_button = note('btap'),\n" + "}\n"; + esp32lua::Runtime hosted(chrome.providers()); + assert(hosted.startApp("Reader", "book.epub")); + chrome.gui.commits = 0; + hosted.callDraw(33); + hosted.callTouch(esp32lua::TouchPhase::Down, 5, 6); + hosted.callTouch(esp32lua::TouchPhase::Move, 5, + 7); // main.lua defines no on_touch_move + hosted.callTouch(esp32lua::TouchPhase::Up, 5, 8); + hosted.callButton("confirm", false); + run(hosted.state(), + "assert(table.concat(events, ' ') == " + "'start:Reader:book.epub draw:33 down:5 up:5 tap:5 bup:confirm " + "btap:confirm')"); + // One commit per visit, so five calls and not six: the release and its tap + // alias are one visible change, and the move nobody handled still ends a + // batch. + assert(chrome.gui.commits == 5); + + hosted.callDraw(-1); // a failed frame logs and the app keeps running + assert(chrome.log.message.find("draw: ") == 0); + assert(hosted.hasApp()); + + chrome.fs.files["/.lua/main.lua"] = + "return { start = function() error('boom') end }"; + assert(!hosted.startApp("Reader")); + assert(chrome.log.message.find("start: ") == 0); + assert(!hosted.hasApp()); + + chrome.fs.files["/.lua/main.lua"] = "return 7"; + assert(!hosted.startApp("Reader")); + assert(chrome.log.message == "main.lua returned no table"); + } // A feature callback without its provider is a wiring bug, not a silent // no-op. @@ -235,22 +251,36 @@ int main() { "assert(input.getTouch == nil and input.isPressed ~= nil)"); } - // App loading: a fresh state per app, require reaching the app directory and - // /.lua/lib, and navigation applied between batches rather than inside a + // App loading: a fresh state per app, main.lua deciding where apps and + // modules live, and navigation applied between batches rather than inside a // callback. { fake::Bench host; + // The tree is main.lua's, so the test states it the way a card would. + 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" + " 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" + " end,\n" + "}\n"; host.fs.files["/.lua/lib/greet.lua"] = "return {hello = function() return 'hi' end}"; host.fs.files["/.lua/apps/Home/main.lua"] = "local greet = require('greet')\n" - "function init(arg) started = greet.hello() .. ':' .. tostring(arg) " - "end"; + "return {init = function(arg) started = greet.hello() .. ':' .. " + "tostring(arg) end}"; host.fs.files["/.lua/apps/Reader/main.lua"] = "local page = require('page')\n" - "function init(arg) started = page.name .. ':' .. arg end"; + "return {init = function(arg) started = page.name .. ':' .. arg end}"; host.fs.files["/.lua/apps/Reader/page.lua"] = "return {name = 'page'}"; - host.fs.files["/.lua/apps/Reader/Notes/main.lua"] = "function init() end"; + host.fs.files["/.lua/apps/Reader/Notes/main.lua"] = + "return {init = function() end}"; esp32lua::Runtime app(host.providers()); assert(app.startApp("Home")); @@ -289,7 +319,7 @@ int main() { assert(!app.startApp("Absent")); assert(!app.hasApp() && app.state() == nullptr); host.fs.files["/.lua/apps/Broken/main.lua"] = - "function init() error('nope') end"; + "return {init = function() error('nope') end}"; assert(!app.startApp("Broken")); assert(!app.hasApp()); assert(!app.startApp("../secrets"));