Files
xteink-apps/apps/AppStore/main.lua
T
evan 39afd7fac7 build: add a shared editor config and reformat apps to match
The apps were on four-space indent only because nothing pinned a style;
.editorconfig is what lua-language-server reads on save, so the tree now
matches the sibling repos. Manifests and the catalog regenerate from the
reformatted sources.
2026-08-04 13:20:40 -04:00

725 lines
21 KiB
Lua

-- DESCRIPTION: Install apps from public Xteink repositories
local ROOT = "/.apps/AppStore"
local REPOSITORIES_PATH = ROOT .. "/repositories.txt"
local DEFAULT_REPOSITORIES_PATH = ROOT .. "/repositories.default.txt"
local MAX_CATALOG_BYTES = 32768
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
local pendingApp = nil
local needsDraw = true
local lastScreen = nil
local function removeFile(path)
if fs.exists(path) then
fs.remove(path)
end
end
local function clearTree(path)
if not fs.exists(path) then
return true
end
local ok, err = fs.removeTree(path)
if not ok or fs.exists(path) then
return nil, err or "Could not remove " .. path
end
return true
end
local function validRelativePath(path)
if not path or path == "" or path:sub(1, 1) == "/" or path:find("\\", 1, true) then
return false
end
if not path:match "^[%w%._/%-]+$" then
return false
end
for component in path:gmatch "[^/]+" do
if component == "." or component == ".." then
return false
end
end
return not path:find("//", 1, true) and path:sub(-1) ~= "/"
end
local function validHash(value)
return value and #value == 64 and value:match "^[0-9a-fA-F]+$" ~= nil
end
-- Version Marker - the catalog carries no version field, so the manifest hash stands
-- in for one. An installed tree with no marker predates this and counts as stale.
local function installState(id, manifestHash)
local target = "/.apps/" .. id
if not fs.exists(target) then
return "none"
end
return fs.readFile(target .. "/.manifest.hash") == manifestHash and "current" or "update"
end
local function readLines(path, expectedHeader, onLine)
local offset = 0
local first = true
while true do
local line, nextOffset = fs.readLineAt(path, offset)
if not line then
if nextOffset then
return nil, "Line exceeds 192 bytes"
end
break
end
offset = nextOffset
if first then
first = false
if line ~= expectedHeader then
return nil, "Unsupported format"
end
elseif line ~= "" and line:sub(1, 1) ~= "#" then
local ok, err = onLine(line)
if not ok then
return nil, err
end
end
end
if first then
return nil, "Empty file"
end
return true
end
local function loadRepositories()
if not fs.exists(REPOSITORIES_PATH) then
local defaults = fs.readFile(DEFAULT_REPOSITORIES_PATH)
if not defaults or not fs.writeFile(REPOSITORIES_PATH, defaults) then
return nil, "Missing repositories.txt"
end
end
local result = {}
local ok, err = readLines(REPOSITORIES_PATH, "XTEINK-REPOSITORIES|1", function(line)
local name, url = line:match "^([^|]+)|(https://[^/|]+/[^|]+)$"
if not name or #name > 48 or #url > 160 or url:find "[?#]" then
return nil, "Invalid repository entry"
end
if #result >= 8 then
return nil, "Too many repositories"
end
result[#result + 1] = {
name = name,
url = url,
downloadPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt",
}
return true
end)
if not ok then
return nil, err
end
if #result == 0 then
return nil, "No repositories configured"
end
repositories = result
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)
-- 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
local function download(url, destination, maxBytes, expectedSize, hash)
removeFile(destination)
collectgarbage "collect"
log.debug("APPSTORE DOWNLOAD " .. destination)
return http.download(url, destination, {
maxBytes = maxBytes,
expectedSize = expectedSize,
sha256 = 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)
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(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] = line
return true
end)
if not ok then
return nil, err
end
if #result == 0 then
return nil, "Repository has no apps"
end
apps = result
log.info("APPSTORE CATALOG_READY " .. #apps)
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"
renderStatus "Downloading catalog..."
removeFile(repository.downloadPath)
local bytes, err = download(repository.url, repository.downloadPath, MAX_CATALOG_BYTES)
if not bytes then
return nil, err
end
local ok
ok, err = parseCatalog(repository, repository.downloadPath)
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 = {}
local seenDirectories = {}
local total = 0
local hasMain = false
local ok, err = readLines(path, "XTEINK-MANIFEST|1", function(line)
local relative, sizeText, hash = line:match "^([^|]+)|(%d+)|([0-9a-fA-F]+)$"
local size = tonumber(sizeText)
local lower = relative and relative:lower()
if
not validRelativePath(relative)
or lower == ".manifest.txt"
or not size
or size < 1
or size > MAX_FILE_BYTES
or not validHash(hash)
or seenFiles[lower]
or seenDirectories[lower]
then
return nil, "Invalid manifest entry"
end
local parent = ""
for component in lower:gmatch "[^/]+" do
if parent ~= "" then
if seenFiles[parent] then
return nil, "Manifest path collision"
end
seenDirectories[parent] = true
end
parent = parent == "" and component or parent .. "/" .. component
end
if #files >= MAX_FILES or total + size > MAX_APP_BYTES then
return nil, "App is too large"
end
seenFiles[lower] = true
total = total + size
hasMain = hasMain or relative == "main.lua"
files[#files + 1] = { path = relative, size = size, hash = hash:lower() }
return true
end)
if not ok then
return nil, err
end
if not hasMain then
return nil, "Manifest has no main.lua"
end
return files
end
local function failInstall(stage, installError)
log.error("APPSTORE INSTALL_FAILED " .. tostring(installError))
local ok, cleanupError = clearTree(stage)
if not ok then
return nil, tostring(installError) .. "; cleanup failed: " .. tostring(cleanupError)
end
return nil, installError
end
local function install(app)
log.info("APPSTORE INSTALL_START " .. app.id)
local target = "/.apps/" .. app.id
local stage = "/.apps/.install-" .. app.id
local ok, err = clearTree(stage)
if not ok then
return nil, err
end
ok, err = fs.mkdir(stage)
if not ok then
return nil, err
end
local manifestUrl = catalogBaseUrl .. "/" .. app.manifestPath
local manifestBase = manifestUrl:match "^(.*)/[^/]+$"
local manifestPath = stage .. "/.manifest.txt"
renderStatus("Downloading " .. app.id .. " manifest...")
local bytes
bytes, err = download(manifestUrl, manifestPath, MAX_MANIFEST_BYTES, app.manifestSize, app.manifestHash)
if not bytes then
return failInstall(stage, err)
end
local files
files, err = parseManifest(manifestPath)
if not files then
return failInstall(stage, err)
end
log.info("APPSTORE MANIFEST_READY " .. app.id .. " " .. #files)
for index, file in ipairs(files) do
local parent = file.path:match "^(.*)/[^/]+$"
if parent then
ok, err = fs.mkdir(stage .. "/" .. parent)
if not ok then
return failInstall(stage, err)
end
end
renderStatus("Installing " .. app.id .. " " .. index .. "/" .. #files)
bytes, err = download(manifestBase .. "/" .. file.path, stage .. "/" .. file.path, file.size, file.size, file.hash)
if not bytes then
return failInstall(stage, err)
end
end
ok, err = fs.remove(manifestPath)
if not ok then
return failInstall(stage, err)
end
if not fs.writeFile(stage .. "/.manifest.hash", app.manifestHash) then
return failInstall(stage, "Could not record app version")
end
-- Swap Via Backup - fs.rename refuses an existing destination, and clearing the old
-- tree up front would lose the installed app if the rename then failed.
local backup = "/.apps/.old-" .. app.id
local replacing = fs.exists(target)
if replacing then
ok, err = clearTree(backup)
if not ok then
return failInstall(stage, err)
end
ok, err = fs.rename(target, backup)
if not ok then
return failInstall(stage, err)
end
end
ok, err = fs.rename(stage, target)
if not ok then
if replacing then
fs.rename(backup, target)
end
return failInstall(stage, err)
end
if replacing then
clearTree(backup)
end
log.info("APPSTORE INSTALL_DONE " .. app.id)
return true, app.id .. (replacing and " updated" or " installed")
end
local function showMessage(text, returnScreen)
log.info("APPSTORE MESSAGE " .. tostring(text))
message = text
screen = "message"
pendingRepository = returnScreen
if returnScreen == "repositories" then
selected = 1
end
needsDraw = true
end
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)
gui.clear()
gui.drawCenteredText(FONT_UI_12, 28, title, COLOR_BLACK, STYLE_BOLD)
local visible = math.max(1, math.floor((gui.height() - 130) / 38))
local first = math.max(1, math.min(selected - math.floor(visible / 2), #items - visible + 1))
local last = math.min(#items, first + visible - 1)
local y = 72
for index = first, last do
local prefix = index == selected and "> " or " "
gui.drawText(
FONT_UI_12,
36,
y,
prefix .. label(items[index]),
COLOR_BLACK,
index == selected and STYLE_BOLD or STYLE_NORMAL
)
y = y + 38
end
if items[selected] and description then
gui.drawCenteredText(FONT_UI_10, gui.height() - 82, description(items[selected]), COLOR_BLACK, STYLE_NORMAL)
end
gui.drawButtonHints("Back", "Select", "", rightHint or "")
gui.refresh(refreshMode())
end
local APP_STATE_SUFFIX = { current = " (installed)", update = " (update)" }
local function render()
if screen == "repositories" then
drawList("Repositories", repositories, function(item)
return item.name
end, nil, "Refresh")
elseif screen == "apps" then
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
gui.clear()
gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), message:sub(1, 100), COLOR_BLACK, STYLE_BOLD)
gui.drawButtonHints("Back", "OK", "", "")
gui.refresh(refreshMode())
end
needsDraw = false
end
local function moveSelection(delta, items)
if #items == 0 then
return
end
selected = ((selected - 1 + delta) % #items) + 1
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 = {}
if wifi.status().state == "connected" then
local ok, result = install(detailApp)
wifi.disconnect()
finishInstall(result)
else
pendingApp = detailApp
wifi.connect()
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(forceDownload)
detailApp = nil
detailMessage = nil
currentRepository = repositories[selected]
-- 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"
-- Browse Offline - Wi-Fi and TLS cost ~70 KB that stays resident for the whole session, and
-- scrolling a list at that heap level is what aborts the firmware. Serve the cached catalog
-- and bring the radio up only for an explicit refresh or an install.
if not forceDownload and fs.exists(currentRepository.downloadPath) and ensureCatalog() then
selected = 1
screen = "apps"
needsDraw = true
return
end
pendingRepository = currentRepository
wifi.connect()
screen = "connecting"
needsDraw = true
end
function init()
local ok, err = loadRepositories()
if not ok then
showMessage(err, "exit")
return
end
log.info "APPSTORE READY"
end
function draw()
if screen == "connecting" then
if input.wasPressed "back" then
wifi.disconnect()
pendingRepository = nil
if pendingApp then
pendingApp = nil
finishInstall(nil)
else
selected = 1
screen = "repositories"
needsDraw = true
end
else
local status = wifi.status().state
if status == "connected" then
log.info "APPSTORE WIFI_CONNECTED"
if pendingApp then
local app = pendingApp
pendingApp = nil
local ok, result = install(app)
wifi.disconnect()
finishInstall(result)
else
local repository = pendingRepository
pendingRepository = nil
local ok, err = loadCatalog(repository)
wifi.disconnect()
if ok then
selected = 1
screen = "apps"
needsDraw = true
else
showMessage(err, "repositories")
end
end
elseif status == "failed" then
log.error "APPSTORE WIFI_FAILED"
wifi.disconnect()
if pendingApp then
pendingApp = nil
finishInstall "Wi-Fi connection failed"
else
showMessage("Wi-Fi connection failed", "repositories")
end
end
end
elseif input.wasPressed "up" and (screen == "apps" or screen == "repositories") then
moveSelection(-1, screen == "apps" and apps or repositories)
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 == "repositories" then
openRepository(true)
elseif 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
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
wifi.disconnect()
sys.exit()
return
end
leaveMessage()
end
elseif input.wasPressed "back" then
if screen == "apps" then
selected = 1
screen = "repositories"
needsDraw = true
elseif screen == "detail" then
leaveDetail()
elseif screen == "confirmDelete" then
screen = "detail"
needsDraw = true
elseif screen == "message" and pendingRepository ~= "exit" then
leaveMessage()
else
wifi.disconnect()
sys.exit()
return
end
end
if needsDraw then
render()
end
end