Files
xteink-apps/apps/AppStore/main.lua
T
evan 39f51ccd48 feat(appstore): cache catalogs on the SD card
Select opens the cached catalog when its sidecar URL still matches, so
browsing costs no network or TLS memory; Refresh fetches. Caches are
replaced through a backup so an interrupted download cannot leave an
empty catalog behind.

Switch to structured log.debug/info/error milestones and add
BinaryKeyboard, a two-button recursive text entry experiment.
2026-07-26 12:40:51 -04:00

500 lines
18 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 screen = "repositories"
local repositories = {}
local apps = {}
local selected = 1
local message = ""
local pendingRepository = nil
local pendingApp = nil
local pendingRefresh = false
local needsDraw = true
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
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,
cachePath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt",
cacheUrlPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".url"
}
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
local function renderStatus(text)
gui.clear()
gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), text, COLOR_BLACK, STYLE_BOLD)
gui.refresh(REFRESH_HALF)
end
local function download(url, destination, maxBytes, expectedSize, hash)
removeFile(destination)
collectgarbage("collect")
log.debug("APPSTORE DOWNLOAD " .. destination)
return net.download(url, destination, {
maxBytes = maxBytes,
expectedSize = expectedSize,
sha256 = hash
})
end
local function parseCatalog(repository, path)
local baseUrl = repository.url:match("^(.*)/[^/]+$")
if not baseUrl then return nil, "Invalid catalog URL" end
local result = {}
local ok, err = readLines(path or repository.cachePath, "XTEINK-CATALOG|1", function(line)
local id, description, manifestPath, sizeText, hash =
line:match("^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$")
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
}
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 hasCatalogCache(repository)
return fs.exists(repository.cachePath) and fs.readFile(repository.cacheUrlPath) == repository.url
end
local function clearCatalogCache(repository)
removeFile(repository.cachePath)
removeFile(repository.cacheUrlPath)
end
local function recoverCatalogCache(repository)
local cacheBackup = repository.cachePath .. ".backup"
local urlBackup = repository.cacheUrlPath .. ".backup"
if not fs.exists(repository.cachePath) and fs.exists(cacheBackup) then
fs.rename(cacheBackup, repository.cachePath)
end
if not fs.exists(repository.cacheUrlPath) and fs.exists(urlBackup) then
fs.rename(urlBackup, repository.cacheUrlPath)
end
if fs.exists(repository.cachePath) and fs.exists(repository.cacheUrlPath) then
removeFile(cacheBackup)
removeFile(urlBackup)
end
end
local function replaceCatalogCache(repository, temporary, urlTemporary)
recoverCatalogCache(repository)
local cacheBackup = repository.cachePath .. ".backup"
local urlBackup = repository.cacheUrlPath .. ".backup"
local hadCache = fs.exists(repository.cachePath)
local hadUrl = fs.exists(repository.cacheUrlPath)
if hadCache then removeFile(cacheBackup) end
if hadUrl then removeFile(urlBackup) end
local ok, err
if hadCache then
ok, err = fs.rename(repository.cachePath, cacheBackup)
if not ok then return nil, err or "Could not back up catalog cache" end
end
if hadUrl then
ok, err = fs.rename(repository.cacheUrlPath, urlBackup)
if not ok then
if hadCache then fs.rename(cacheBackup, repository.cachePath) end
return nil, err or "Could not back up catalog URL"
end
end
ok, err = fs.rename(temporary, repository.cachePath)
if ok then ok, err = fs.rename(urlTemporary, repository.cacheUrlPath) end
if not ok then
removeFile(repository.cachePath)
removeFile(repository.cacheUrlPath)
if hadCache then fs.rename(cacheBackup, repository.cachePath) end
if hadUrl then fs.rename(urlBackup, repository.cacheUrlPath) end
return nil, err or "Could not update catalog cache"
end
removeFile(cacheBackup)
removeFile(urlBackup)
return true
end
local function loadCatalog(repository, refresh)
apps = {}
collectgarbage("collect")
if not refresh and hasCatalogCache(repository) then
renderStatus("Loading cached catalog...")
local ok = parseCatalog(repository)
if ok then return true end
clearCatalogCache(repository)
end
renderStatus("Downloading catalog...")
local temporary = repository.cachePath .. ".download"
local bytes, err = download(repository.url, temporary, MAX_CATALOG_BYTES)
if not bytes then return nil, err end
local ok
ok, err = parseCatalog(repository, temporary)
if not ok then removeFile(temporary) return nil, err end
local urlTemporary = repository.cacheUrlPath .. ".download"
removeFile(urlTemporary)
if not fs.writeFile(urlTemporary, repository.url) then
removeFile(temporary)
return true
end
ok, err = replaceCatalogCache(repository, temporary, urlTemporary)
if not ok then
removeFile(temporary)
removeFile(urlTemporary)
return nil, err
end
return true
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
if fs.exists(target) then return nil, "App is already installed" end
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 = app.baseUrl .. "/" .. 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
ok, err = fs.rename(stage, target)
if not ok then return failInstall(stage, err) end
log.info("APPSTORE INSTALL_DONE " .. app.id)
return true, app.id .. " installed"
end
local function showMessage(text, returnScreen)
log.info("APPSTORE MESSAGE " .. tostring(text))
message = text
screen = "message"
pendingRepository = returnScreen
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(REFRESH_HALF)
end
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(item) return item.id end, function(item) return item.description end)
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(REFRESH_HALF)
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 openRepository(refresh)
local repository = repositories[selected]
recoverCatalogCache(repository)
if not refresh and hasCatalogCache(repository) then
net.wifiConnect()
apps = {}
collectgarbage("collect")
renderStatus("Loading cached catalog...")
local ok = parseCatalog(repository)
if ok then
selected = 1
screen = "apps"
needsDraw = true
return
end
clearCatalogCache(repository)
end
pendingRepository = repository
pendingRefresh = refresh
net.wifiConnect()
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
net.wifiDisconnect()
local returnScreen = pendingApp and "apps" or "repositories"
pendingRepository = nil
pendingApp = nil
pendingRefresh = false
if returnScreen == "repositories" then selected = 1 end
screen = returnScreen
needsDraw = true
else
local status = net.wifiStatus()
if status == "connected" then
log.info("APPSTORE WIFI_CONNECTED")
if pendingApp then
local app = pendingApp
pendingApp = nil
local ok, result = install(app)
showMessage(result, "apps")
else
local repository = pendingRepository
local refresh = pendingRefresh
pendingRepository = nil
pendingRefresh = false
local ok, err = loadCatalog(repository, refresh)
if ok then
selected = 1
screen = "apps"
needsDraw = true
else
showMessage(err, "repositories")
end
end
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)
end
end
elseif input.wasPressed("up") then
moveSelection(-1, screen == "apps" and apps or repositories)
elseif input.wasPressed("down") then
moveSelection(1, screen == "apps" and apps or repositories)
elseif input.wasPressed("right") and screen == "repositories" then
openRepository(true)
elseif input.wasPressed("confirm") then
if screen == "repositories" then
openRepository(false)
elseif screen == "apps" then
if net.wifiStatus() == "connected" then
local ok, result = install(apps[selected])
showMessage(result, "apps")
else
pendingApp = apps[selected]
net.wifiConnect()
screen = "connecting"
needsDraw = true
end
elseif screen == "message" then
if pendingRepository == "exit" then
net.wifiDisconnect()
sys.exit()
return
end
screen = pendingRepository or "repositories"
pendingRepository = nil
needsDraw = true
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
needsDraw = true
else
net.wifiDisconnect()
sys.exit()
return
end
end
if needsDraw then render() end
end