From c339a6161df19e70f8f9be6b8ad356d14ef8c0f5 Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Mon, 27 Jul 2026 09:03:24 -0400 Subject: [PATCH] feat(appstore): add app detail screen, cut catalog memory use Selecting an app now opens a detail window (name, state, description) with Back / Install-Update-Reinstall / Delete, where delete needs confirmation and refuses to remove AppStore itself. Install progress replaces the button hints in that window instead of a full-screen status. Catalog entries are kept as raw lines and expanded only for drawn rows, and the catalog is released before net.wifiConnect() then rebuilt from the SD cache, which is what caused OOM when bringing up Wi-Fi. Status screens use REFRESH_FAST between updates rather than REFRESH_HALF. --- apps/AppStore/README.md | 24 +++- apps/AppStore/main.lua | 276 +++++++++++++++++++++++++++++-------- apps/AppStore/manifest.txt | 2 +- catalog.txt | 2 +- 4 files changed, 244 insertions(+), 60 deletions(-) diff --git a/apps/AppStore/README.md b/apps/AppStore/README.md index f3600bb..6bd9ca5 100644 --- a/apps/AppStore/README.md +++ b/apps/AppStore/README.md @@ -13,8 +13,28 @@ XTEINK-REPOSITORIES|1 Name|https://host/path/catalog.txt ``` -AppStore caches each validated catalog on the SD card. Select opens the cache when available; use Refresh to fetch the latest catalog. +## Screens -Packages download into `/.apps/.install-`, verify declared sizes and SHA-256 hashes, require `main.lua`, then rename into place. Existing apps are left unchanged; updates, including self-update, are not supported yet. +Repositories → Apps → app detail. Opening a repository always fetches its catalog over Wi-Fi, so +the list is current on every launch. The validated catalog is then kept on the SD card and re-read +when returning to the Apps list, which avoids a second fetch after an install. + +The detail screen shows the app name, install state, and description, with `Back` / `Install` +(`Update` or `Reinstall` when already present) / `Delete`. Install progress replaces the button +hints in that same window rather than taking over the screen. Delete asks for confirmation and +refuses to remove AppStore itself. + +## Memory + +Catalog entries are held as raw lines and expanded only for the rows being drawn — expanding all 64 +into tables costs roughly 30 KB, which has to coexist with the Wi-Fi and TLS stacks on a 380 KB +device. The catalog is released entirely before `net.wifiConnect()` and rebuilt from the SD cache +afterwards. + +## Installation + +Packages download into `/.apps/.install-`, verify declared sizes and SHA-256 hashes, require +`main.lua`, then rename into place. Updates rename the existing tree aside first and restore it if +the swap fails. Install state comes from the `.manifest.hash` marker written at install time. HTTPS is encrypted without peer-certificate authentication. An active network attacker can replace both metadata and payload hashes. diff --git a/apps/AppStore/main.lua b/apps/AppStore/main.lua index 2b02fae..6968115 100644 --- a/apps/AppStore/main.lua +++ b/apps/AppStore/main.lua @@ -8,10 +8,15 @@ local MAX_MANIFEST_BYTES = 16384 local MAX_APP_BYTES = 16 * 1024 * 1024 local MAX_FILE_BYTES = 8 * 1024 * 1024 local MAX_FILES = 64 +local CATALOG_PATTERN = "^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$" local screen = "repositories" local repositories = {} local apps = {} +local catalogBaseUrl = nil +local currentRepository = nil +local detailApp = nil +local detailMessage = nil local selected = 1 local message = "" local pendingRepository = nil @@ -101,10 +106,77 @@ local function loadRepositories() return true end +-- Fast Waveform For Same-Screen Edits - FAST is a differential drive that needs a +-- trusted previous frame; only a wholesale content change needs HALF's ghost clear. +local function refreshMode() + local mode = screen == lastScreen and REFRESH_FAST or REFRESH_HALF + lastScreen = screen + return mode +end + +local function wrapText(fontId, text, maxWidth) + local lines = {} + local line = "" + for word in text:gmatch("%S+") do + local candidate = line == "" and word or line .. " " .. word + if line ~= "" and gui.getTextWidth(fontId, candidate) > maxWidth then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + if line ~= "" then lines[#lines + 1] = line end + return lines +end + +local DETAIL_STATE_LABEL = { none = "Not installed", current = "Installed", update = "Update available" } +local DETAIL_ACTION_LABEL = { none = "Install", current = "Reinstall", update = "Update" } + +-- Progress Lives In The Detail Window - install status replaces the button hints rather than +-- taking over the screen, so the app being installed stays visible throughout. +local function renderDetail(status) + local state = installState(detailApp.id, detailApp.manifestHash) + local margin = 40 + gui.clear() + gui.drawCenteredText(FONT_UI_12, 44, detailApp.id, COLOR_BLACK, STYLE_BOLD) + gui.drawCenteredText(FONT_UI_10, 78, DETAIL_STATE_LABEL[state] or state) + gui.drawLine(margin, 100, gui.width() - margin, 100) + + local y = 134 + for _, line in ipairs(wrapText(FONT_UI_10, detailApp.description, gui.width() - margin * 2)) do + gui.drawText(FONT_UI_10, margin, y, line) + y = y + 28 + end + + -- The hint row owns the bottom 40 px, so status and result text stack upwards from above it. + if status then + gui.drawCenteredText(FONT_UI_12, gui.height() - 90, status, COLOR_BLACK, STYLE_BOLD) + else + if detailMessage then + local lines = wrapText(FONT_UI_10, detailMessage, gui.width() - margin * 2) + local y = gui.height() - 114 + for index = 1, math.min(#lines, 2) do + gui.drawCenteredText(FONT_UI_10, y, lines[index]) + y = y + 26 + end + end + gui.drawButtonHints("Back", DETAIL_ACTION_LABEL[state] or "Install", "", + state ~= "none" and "Delete" or "") + end + gui.refresh(refreshMode()) +end + local function renderStatus(text) + if detailApp then + renderDetail(text) + return + end gui.clear() gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), text, COLOR_BLACK, STYLE_BOLD) - gui.refresh(REFRESH_HALF) + -- Consecutive status updates only change the centred line, so FAST's differential drive is + -- valid; only the first entry from a list screen needs HALF to clear the previous frame. + gui.refresh(lastScreen == "status" and REFRESH_FAST or REFRESH_HALF) lastScreen = "status" end @@ -119,29 +191,22 @@ local function download(url, destination, maxBytes, expectedSize, hash) }) end +-- Entries Stay As Raw Lines - expanding 64 catalog rows into tables costs ~30KB that has to +-- coexist with the Wi-Fi and TLS stacks. Re-matching the handful of visible rows is far cheaper. local function parseCatalog(repository, path) - local baseUrl = repository.url:match("^(.*)/[^/]+$") - if not baseUrl then return nil, "Invalid catalog URL" end + catalogBaseUrl = repository.url:match("^(.*)/[^/]+$") + if not catalogBaseUrl then return nil, "Invalid catalog URL" end local result = {} local ok, err = readLines(path, "XTEINK-CATALOG|1", function(line) - local id, description, manifestPath, sizeText, hash = - line:match("^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$") + local id, description, manifestPath, sizeText, hash = line:match(CATALOG_PATTERN) local size = tonumber(sizeText) if not id or #id > 48 or #description > 80 or not validRelativePath(manifestPath) or not size or size < 1 or size > MAX_MANIFEST_BYTES or not validHash(hash) then return nil, "Invalid catalog entry" end if #result >= 64 then return nil, "Too many apps" end - result[#result + 1] = { - id = id, - description = description, - manifestPath = manifestPath, - manifestSize = size, - manifestHash = hash:lower(), - baseUrl = baseUrl, - state = installState(id, hash:lower()) - } + result[#result + 1] = line return true end) if not ok then return nil, err end @@ -151,6 +216,17 @@ local function parseCatalog(repository, path) return true end +local function catalogEntry(line) + local id, description, manifestPath, sizeText, hash = line:match(CATALOG_PATTERN) + return { + id = id, + description = description, + manifestPath = manifestPath, + manifestSize = tonumber(sizeText), + manifestHash = hash:lower() + } +end + local function loadCatalog(repository) apps = {} collectgarbage("collect") @@ -162,11 +238,24 @@ local function loadCatalog(repository) local ok ok, err = parseCatalog(repository, repository.downloadPath) - removeFile(repository.downloadPath) - if not ok then return nil, err end + if not ok then + removeFile(repository.downloadPath) + return nil, err + end return true end +-- Rehydrate From The Downloaded File - the catalog is dropped from RAM before Wi-Fi comes up, so +-- returning to the Apps list re-parses the file rather than re-fetching it. Install state is +-- derived per row at draw time, so a freshly installed app shows correctly without a refetch. +local function ensureCatalog() + if #apps > 0 then return true end + if not currentRepository or not fs.exists(currentRepository.downloadPath) then + return nil, "Catalog unavailable" + end + return parseCatalog(currentRepository, currentRepository.downloadPath) +end + local function parseManifest(path) local files = {} local seenFiles = {} @@ -211,7 +300,6 @@ end local function install(app) log.info("APPSTORE INSTALL_START " .. app.id) local target = "/.apps/" .. app.id - if app.state == "current" then return nil, app.id .. " is up to date" end local stage = "/.apps/.install-" .. app.id local ok, err = clearTree(stage) @@ -219,7 +307,7 @@ local function install(app) ok, err = fs.mkdir(stage) if not ok then return nil, err end - local manifestUrl = app.baseUrl .. "/" .. app.manifestPath + local manifestUrl = catalogBaseUrl .. "/" .. app.manifestPath local manifestBase = manifestUrl:match("^(.*)/[^/]+$") local manifestPath = stage .. "/.manifest.txt" renderStatus("Downloading " .. app.id .. " manifest...") @@ -267,7 +355,6 @@ local function install(app) end if replacing then clearTree(backup) end - app.state = "current" log.info("APPSTORE INSTALL_DONE " .. app.id) return true, app.id .. (replacing and " updated" or " installed") end @@ -277,15 +364,28 @@ local function showMessage(text, returnScreen) message = text screen = "message" pendingRepository = returnScreen + if returnScreen == "repositories" then selected = 1 end needsDraw = true end --- Fast Waveform For Same-Screen Edits - FAST is a differential drive that needs a --- trusted previous frame; only a wholesale content change needs HALF's ghost clear. -local function refreshMode() - local mode = screen == lastScreen and REFRESH_FAST or REFRESH_HALF - lastScreen = screen - return mode +local function leaveMessage() + screen = pendingRepository or "repositories" + pendingRepository = nil + needsDraw = true +end + +-- Returning To The Apps List - the catalog is dropped from RAM before every install, so the +-- list has to be rebuilt from the cached file; falling back to Repositories keeps it honest. +local function leaveDetail() + detailApp = nil + detailMessage = nil + if ensureCatalog() then + screen = "apps" + else + selected = 1 + screen = "repositories" + end + needsDraw = true end local function drawList(title, items, label, description, rightHint) @@ -314,8 +414,20 @@ local function render() if screen == "repositories" then drawList("Repositories", repositories, function(item) return item.name end) elseif screen == "apps" then - drawList("Apps", apps, function(item) return item.id .. (APP_STATE_SUFFIX[item.state] or "") end, - function(item) return item.description end) + drawList("Apps", apps, function(line) + local app = catalogEntry(line) + return app.id .. (APP_STATE_SUFFIX[installState(app.id, app.manifestHash)] or "") + end, function(line) return catalogEntry(line).description end) + elseif screen == "detail" then + renderDetail(nil) + elseif screen == "confirmDelete" then + gui.clear() + gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2) - 24, + "Delete " .. detailApp.id .. "?", COLOR_BLACK, STYLE_BOLD) + gui.drawCenteredText(FONT_UI_10, math.floor(gui.height() / 2) + 18, + "Removes the installed app from this device.") + gui.drawButtonHints("Cancel", "Delete", "", "") + gui.refresh(refreshMode()) elseif screen == "connecting" then renderStatus("Connecting to Wi-Fi...") else @@ -333,8 +445,50 @@ local function moveSelection(delta, items) needsDraw = true end +local function finishInstall(result) + detailMessage = result + screen = "detail" + needsDraw = true +end + +local function startInstall() + detailMessage = nil + -- Shed The Catalog Before The Radio - install downloads run with the Wi-Fi and TLS stacks + -- resident, and 64 held entries are what tips this into OOM. Rebuilt from cache on the way out. + apps = {} + collectgarbage("collect") + if net.wifiStatus() == "connected" then + local ok, result = install(detailApp) + finishInstall(result) + else + pendingApp = detailApp + net.wifiConnect() + screen = "connecting" + needsDraw = true + end +end + +local function deleteApp() + -- Never Delete Ourselves - the running chunk is already in RAM so this would not crash, but it + -- would silently uninstall the store mid-session. + if "/.apps/" .. detailApp.id == ROOT then + finishInstall("Cannot delete the running app") + return + end + local ok, err = clearTree("/.apps/" .. detailApp.id) + log.info("APPSTORE DELETE " .. detailApp.id .. " " .. tostring(ok)) + finishInstall(ok and (detailApp.id .. " deleted") or tostring(err)) +end + local function openRepository() - pendingRepository = repositories[selected] + detailApp = nil + detailMessage = nil + currentRepository = repositories[selected] + pendingRepository = currentRepository + -- Shed The Stale Catalog Before The Radio - bringing up Wi-Fi allocates tens of KB, and the + -- previous repository's entries are dead weight from the moment we leave the list. + apps = {} + collectgarbage("collect") net.wifiConnect() screen = "connecting" needsDraw = true @@ -353,12 +507,15 @@ function draw() if screen == "connecting" then if input.wasPressed("back") then net.wifiDisconnect() - local returnScreen = pendingApp and "apps" or "repositories" pendingRepository = nil - pendingApp = nil - if returnScreen == "repositories" then selected = 1 end - screen = returnScreen - needsDraw = true + if pendingApp then + pendingApp = nil + finishInstall(nil) + else + selected = 1 + screen = "repositories" + needsDraw = true + end else local status = net.wifiStatus() if status == "connected" then @@ -367,7 +524,7 @@ function draw() local app = pendingApp pendingApp = nil local ok, result = install(app) - showMessage(result, "apps") + finishInstall(result) else local repository = pendingRepository pendingRepository = nil @@ -383,49 +540,56 @@ function draw() elseif status == "failed" then log.error("APPSTORE WIFI_FAILED") net.wifiDisconnect() - local returnScreen = pendingApp and "apps" or "repositories" - pendingApp = nil - showMessage("Wi-Fi connection failed", returnScreen) + if pendingApp then + pendingApp = nil + finishInstall("Wi-Fi connection failed") + else + showMessage("Wi-Fi connection failed", "repositories") + end end end - elseif input.wasPressed("up") then + elseif input.wasPressed("up") and (screen == "apps" or screen == "repositories") then moveSelection(-1, screen == "apps" and apps or repositories) - elseif input.wasPressed("down") then + elseif input.wasPressed("down") and (screen == "apps" or screen == "repositories") then moveSelection(1, screen == "apps" and apps or repositories) + elseif input.wasPressed("right") then + if screen == "detail" and installState(detailApp.id, detailApp.manifestHash) ~= "none" then + detailMessage = nil + screen = "confirmDelete" + needsDraw = true + end elseif input.wasPressed("confirm") then if screen == "repositories" then openRepository() elseif screen == "apps" then - if apps[selected].state == "current" then - showMessage(apps[selected].id .. " is up to date", "apps") - elseif net.wifiStatus() == "connected" then - local ok, result = install(apps[selected]) - showMessage(result, "apps") - else - pendingApp = apps[selected] - net.wifiConnect() - screen = "connecting" - needsDraw = true - end + detailApp = catalogEntry(apps[selected]) + detailMessage = nil + screen = "detail" + needsDraw = true + elseif screen == "detail" then + startInstall() + elseif screen == "confirmDelete" then + deleteApp() elseif screen == "message" then if pendingRepository == "exit" then net.wifiDisconnect() sys.exit() return end - screen = pendingRepository or "repositories" - pendingRepository = nil - needsDraw = true + leaveMessage() end elseif input.wasPressed("back") then if screen == "apps" then selected = 1 screen = "repositories" needsDraw = true - elseif screen == "message" and pendingRepository ~= "exit" then - screen = pendingRepository or "repositories" - pendingRepository = nil + elseif screen == "detail" then + leaveDetail() + elseif screen == "confirmDelete" then + screen = "detail" needsDraw = true + elseif screen == "message" and pendingRepository ~= "exit" then + leaveMessage() else net.wifiDisconnect() sys.exit() diff --git a/apps/AppStore/manifest.txt b/apps/AppStore/manifest.txt index 428ec8d..939a8cf 100644 --- a/apps/AppStore/manifest.txt +++ b/apps/AppStore/manifest.txt @@ -1,3 +1,3 @@ XTEINK-MANIFEST|1 -main.lua|16109|48e6396b98a0ff24d1af2325f7e4431354ac07b851364cb4e0aa3a2b28d09355 +main.lua|22282|91eb39358d50a89ac9cc345fe0ef9d84ff595f7a0db60bae84127dac1368f3cc repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df diff --git a/catalog.txt b/catalog.txt index 5a1b243..c3d1b2e 100644 --- a/catalog.txt +++ b/catalog.txt @@ -1,6 +1,6 @@ XTEINK-CATALOG|1 2048|Slide and merge tiles to reach 2048|apps/2048/manifest.txt|97|7cc1eee488186698bf294a649fe850164af23e32aaee1cd86754555e282bce04 -AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|2eb608b92de6bdeff4fc8ed447b9319f45c0d79d5d73b14db1ab5a92f91e42d4 +AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|1497912a4b9e58e95b4d8b0745a03c98ddea8ef9b47479bb47b5c89635c0b166 BinaryKeyboard|Two-button recursive keyboard experiment|apps/BinaryKeyboard/manifest.txt|97|893867dba0ae2725caca1eb56375c98c4dd00d351134aa129698c51f97e25690 HomeAssistant|Home Assistant status cards|apps/HomeAssistant/manifest.txt|177|b38ca49804444fd383a505ce9c51680ad9b7443950d682ca1b9ef3047bff98a5 Wordle|Guess the five-letter word in six tries|apps/Wordle/manifest.txt|177|199fafdf0d0f6e77916befc011f6e8466c254867800e1d4a9e67e95a06d0366b