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"));