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.
This commit is contained in:
2026-07-27 09:03:24 -04:00
parent a7e57f3549
commit c339a6161d
4 changed files with 244 additions and 60 deletions
+22 -2
View File
@@ -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-<AppId>`, 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-<AppId>`, 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.
+220 -56
View File
@@ -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()
+1 -1
View File
@@ -1,3 +1,3 @@
XTEINK-MANIFEST|1
main.lua|16109|48e6396b98a0ff24d1af2325f7e4431354ac07b851364cb4e0aa3a2b28d09355
main.lua|22282|91eb39358d50a89ac9cc345fe0ef9d84ff595f7a0db60bae84127dac1368f3cc
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
+1 -1
View File
@@ -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