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:
+22
-2
@@ -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
@@ -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,3 +1,3 @@
|
||||
XTEINK-MANIFEST|1
|
||||
main.lua|16109|48e6396b98a0ff24d1af2325f7e4431354ac07b851364cb4e0aa3a2b28d09355
|
||||
main.lua|22282|91eb39358d50a89ac9cc345fe0ef9d84ff595f7a0db60bae84127dac1368f3cc
|
||||
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user