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.
This commit is contained in:
2026-07-26 12:40:51 -04:00
parent 4193c25120
commit 39f51ccd48
7 changed files with 326 additions and 36 deletions
+3 -1
View File
@@ -13,6 +13,8 @@ XTEINK-REPOSITORIES|1
Name|https://host/path/catalog.txt
```
AppStore downloads each package into `/.apps/.install-<AppId>`, verifies declared sizes and SHA-256 hashes, requires `main.lua`, then renames the completed directory into place. Existing apps are left unchanged; updates, including self-update, are not supported yet.
AppStore caches each validated catalog on the SD card. Select opens the cache when available; use Refresh to fetch the latest catalog.
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.
HTTPS is encrypted without peer-certificate authentication. An active network attacker can replace both metadata and payload hashes.
+184 -33
View File
@@ -3,7 +3,6 @@
local ROOT = "/.apps/AppStore"
local REPOSITORIES_PATH = ROOT .. "/repositories.txt"
local DEFAULT_REPOSITORIES_PATH = ROOT .. "/repositories.default.txt"
local CATALOG_PATH = ROOT .. "/.catalog.txt"
local MAX_CATALOG_BYTES = 32768
local MAX_MANIFEST_BYTES = 16384
local MAX_APP_BYTES = 16 * 1024 * 1024
@@ -16,6 +15,8 @@ local apps = {}
local selected = 1
local message = ""
local pendingRepository = nil
local pendingApp = nil
local pendingRefresh = false
local needsDraw = true
local function removeFile(path)
@@ -79,7 +80,12 @@ local function loadRepositories()
return nil, "Invalid repository entry"
end
if #result >= 8 then return nil, "Too many repositories" end
result[#result + 1] = { name = name, url = url }
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
@@ -96,6 +102,8 @@ 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,
@@ -103,19 +111,12 @@ local function download(url, destination, maxBytes, expectedSize, hash)
})
end
local function loadCatalog(repository)
apps = {}
collectgarbage("collect")
renderStatus("Downloading catalog...")
local bytes, err = download(repository.url, CATALOG_PATH, MAX_CATALOG_BYTES)
if not bytes then return nil, err 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
ok, err = readLines(CATALOG_PATH, "XTEINK-CATALOG|1", function(line)
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)
@@ -134,10 +135,107 @@ local function loadCatalog(repository)
}
return true
end)
removeFile(CATALOG_PATH)
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
@@ -176,12 +274,14 @@ local function parseManifest(path)
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
@@ -202,6 +302,7 @@ local function install(app)
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("^(.*)/[^/]+$")
@@ -219,17 +320,19 @@ local function install(app)
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)
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))
@@ -245,13 +348,13 @@ local function drawList(title, items, label, description)
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", "")
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)
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
@@ -259,7 +362,7 @@ local function render()
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.drawButtonHints("Back", "OK", "", "")
gui.refresh(REFRESH_HALF)
end
needsDraw = false
@@ -271,53 +374,101 @@ local function moveSelection(delta, items)
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
selected = 1
screen = "repositories"
pendingApp = nil
pendingRefresh = false
if returnScreen == "repositories" then selected = 1 end
screen = returnScreen
needsDraw = true
else
local status = net.wifiStatus()
if status == "connected" then
local repository = pendingRepository
pendingRepository = nil
local ok, err = loadCatalog(repository)
if ok then
selected = 1
screen = "apps"
needsDraw = true
log.info("APPSTORE WIFI_CONNECTED")
if pendingApp then
local app = pendingApp
pendingApp = nil
local ok, result = install(app)
showMessage(result, "apps")
else
showMessage(err, "repositories")
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()
showMessage("Wi-Fi connection failed", "repositories")
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
pendingRepository = repositories[selected]
net.wifiConnect()
screen = "connecting"
needsDraw = true
openRepository(false)
elseif screen == "apps" then
local ok, result = install(apps[selected])
showMessage(result, "apps")
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()
+1 -1
View File
@@ -1,3 +1,3 @@
XTEINK-MANIFEST|1
main.lua|12516|4e21087da632730d82049e405e5d86209cfb707c3c3b5fb574dc2f602d904961
main.lua|18026|d324b6ffd2ca9b2e4f8cf6ff4676a114b754dd300d054d4f8c80c9d6bddcdd9a
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
+12
View File
@@ -0,0 +1,12 @@
# Binary Keyboard
Proof of concept for entering text by recursively bisecting a QWERTY-ordered character set.
- Left side button selects the left group.
- Right side button selects the right group.
- Back undoes one choice, or exits from the root.
- Confirm inserts a space.
- Left front button deletes a character.
- Right front button toggles case.
A character is committed automatically when only one candidate remains. This is a standalone interaction experiment, not yet a reusable text-entry component.
+122
View File
@@ -0,0 +1,122 @@
-- DESCRIPTION: Two-button recursive keyboard experiment
local KEYS = {
"Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P",
"A", "S", "D", "F", "G", "H", "J", "K", "L",
"Z", "X", "C", "V", "B", "N", "M"
}
local MAX_TEXT_BYTES = 80
local text = ""
local candidates = {}
local history = {}
local uppercase = false
local needsDraw = true
local firstDraw = true
local function copyKeys(source, first, last)
local result = {}
for index = first, last do result[#result + 1] = source[index] end
return result
end
local function resetCandidates()
candidates = copyKeys(KEYS, 1, #KEYS)
history = {}
end
local function halves()
local midpoint = math.ceil(#candidates / 2)
return copyKeys(candidates, 1, midpoint), copyKeys(candidates, midpoint + 1, #candidates)
end
local function append(value)
if #text + #value <= MAX_TEXT_BYTES then text = text .. value end
end
local function chooseHalf(left)
local leftHalf, rightHalf = halves()
history[#history + 1] = candidates
candidates = left and leftHalf or rightHalf
if #candidates == 1 then
local letter = candidates[1]
append(uppercase and letter or letter:lower())
resetCandidates()
end
needsDraw = true
end
local function undo()
if #history == 0 then
sys.exit()
return
end
candidates = history[#history]
history[#history] = nil
needsDraw = true
end
local function drawPanel(x, y, width, height, title, values)
gui.drawRoundedRect(x, y, width, height, 2, 8, COLOR_BLACK)
gui.drawText(FONT_UI_10, x + 12, y + 32, title, COLOR_BLACK, STYLE_BOLD)
gui.drawText(FONT_UI_12, x + 12, y + math.floor(height / 2), table.concat(values), COLOR_BLACK, STYLE_NORMAL)
end
local function render()
local width = gui.width()
local height = gui.height()
local margin = 20
local gap = 12
local panelWidth = math.floor((width - margin * 2 - gap) / 2)
local panelTop = 185
local panelHeight = math.min(220, height - panelTop - 100)
local leftHalf, rightHalf = halves()
gui.clear()
gui.drawCenteredText(FONT_UI_12, 24, "Binary Keyboard", COLOR_BLACK, STYLE_BOLD)
gui.drawRoundedRect(margin, 65, width - margin * 2, 82, 2, 8, COLOR_BLACK)
local visibleText = text == "" and "Type with the side buttons" or text:sub(-38)
gui.drawText(FONT_UI_12, margin + 12, 96, visibleText, COLOR_BLACK, STYLE_NORMAL)
local progress = string.rep("", #history)
gui.drawCenteredText(FONT_UI_10, 155, progress == "" and "Choose a half" or progress, COLOR_BLACK, STYLE_NORMAL)
drawPanel(margin, panelTop, panelWidth, panelHeight, "LEFT SIDE", leftHalf)
drawPanel(margin + panelWidth + gap, panelTop, panelWidth, panelHeight, "RIGHT SIDE", rightHalf)
gui.drawButtonHints(#history == 0 and "Exit" or "Undo", "Space", "Delete", uppercase and "lower" or "UPPER")
gui.refresh(firstDraw and REFRESH_HALF or REFRESH_FAST)
firstDraw = false
needsDraw = false
end
function init()
text = ""
uppercase = false
firstDraw = true
needsDraw = true
resetCandidates()
end
function draw()
if input.wasPressed("up") then
chooseHalf(true)
elseif input.wasPressed("down") then
chooseHalf(false)
elseif input.wasPressed("back") then
undo()
elseif input.wasPressed("confirm") then
append(" ")
resetCandidates()
needsDraw = true
elseif input.wasPressed("left") then
if #text > 0 then text = text:sub(1, -2) end
resetCandidates()
needsDraw = true
elseif input.wasPressed("right") then
uppercase = not uppercase
needsDraw = true
end
if needsDraw then render() end
end
+2
View File
@@ -0,0 +1,2 @@
XTEINK-MANIFEST|1
main.lua|3720|8e275a05358e32b0187a7e2c00a93eabe658a32c10112c5451e2d8a78f3d3932
+2 -1
View File
@@ -1,4 +1,5 @@
XTEINK-CATALOG|1
AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|50e6f06ac3476b9df9caf78fdfe226f295532168c441df0d2be9289d8b7d7c1c
AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|26527e5526d5b30195e70313347d75779e7d66f474b5f31f8e4c633ee2e150f7
BinaryKeyboard|Two-button recursive keyboard experiment|apps/BinaryKeyboard/manifest.txt|97|b72b6250bb3124fb1639c9c1eb0c1b7730d62356189f450e26035dde288f9f2b
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