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:
@@ -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
@@ -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,3 +1,3 @@
|
||||
XTEINK-MANIFEST|1
|
||||
main.lua|12516|4e21087da632730d82049e405e5d86209cfb707c3c3b5fb574dc2f602d904961
|
||||
main.lua|18026|d324b6ffd2ca9b2e4f8cf6ff4676a114b754dd300d054d4f8c80c9d6bddcdd9a
|
||||
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
XTEINK-MANIFEST|1
|
||||
main.lua|3720|8e275a05358e32b0187a7e2c00a93eabe658a32c10112c5451e2d8a78f3d3932
|
||||
Reference in New Issue
Block a user