feat(appstore): support reinstalling apps as updates

Track the manifest hash in the installed tree so the catalog can mark apps
as installed or updatable, and swap trees via a backup on reinstall.

Drop the catalog cache: it doubled the failure paths for little gain now
that every open needs a fresh catalog to detect updates.

Use a FAST waveform when redrawing the same screen.
This commit is contained in:
2026-07-26 20:04:59 -04:00
parent 39f51ccd48
commit 219795cc41
3 changed files with 67 additions and 129 deletions
+65 -127
View File
@@ -16,8 +16,8 @@ local selected = 1
local message = ""
local pendingRepository = nil
local pendingApp = nil
local pendingRefresh = false
local needsDraw = true
local lastScreen = nil
local function removeFile(path)
if fs.exists(path) then fs.remove(path) end
@@ -43,6 +43,14 @@ 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
@@ -83,8 +91,7 @@ local function loadRepositories()
result[#result + 1] = {
name = name,
url = url,
cachePath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt",
cacheUrlPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".url"
downloadPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt"
}
return true
end)
@@ -98,6 +105,7 @@ 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)
lastScreen = "status"
end
local function download(url, destination, maxBytes, expectedSize, hash)
@@ -116,7 +124,7 @@ local function parseCatalog(repository, path)
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 ok, err = readLines(path, "XTEINK-CATALOG|1", function(line)
local id, description, manifestPath, sizeText, hash =
line:match("^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$")
local size = tonumber(sizeText)
@@ -131,7 +139,8 @@ local function parseCatalog(repository, path)
manifestPath = manifestPath,
manifestSize = size,
manifestHash = hash:lower(),
baseUrl = baseUrl
baseUrl = baseUrl,
state = installState(id, hash:lower())
}
return true
end)
@@ -142,100 +151,19 @@ local function parseCatalog(repository, path)
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)
local function loadCatalog(repository)
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)
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, 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
ok, err = parseCatalog(repository, repository.downloadPath)
removeFile(repository.downloadPath)
if not ok then return nil, err end
return true
end
@@ -283,7 +211,7 @@ 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
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)
@@ -317,11 +245,31 @@ local function install(app)
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 return failInstall(stage, err) end
if not ok then
if replacing then fs.rename(backup, target) end
return failInstall(stage, err)
end
if replacing then clearTree(backup) end
app.state = "current"
log.info("APPSTORE INSTALL_DONE " .. app.id)
return true, app.id .. " installed"
return true, app.id .. (replacing and " updated" or " installed")
end
local function showMessage(text, returnScreen)
@@ -332,6 +280,14 @@ local function showMessage(text, returnScreen)
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
end
local function drawList(title, items, label, description, rightHint)
gui.clear()
gui.drawCenteredText(FONT_UI_12, 28, title, COLOR_BLACK, STYLE_BOLD)
@@ -349,21 +305,24 @@ local function drawList(title, items, label, description, rightHint)
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)
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")
drawList("Repositories", repositories, function(item) return item.name end)
elseif screen == "apps" then
drawList("Apps", apps, function(item) return item.id end, function(item) return item.description end)
drawList("Apps", apps, function(item) return item.id .. (APP_STATE_SUFFIX[item.state] or "") 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)
gui.refresh(refreshMode())
end
needsDraw = false
end
@@ -374,26 +333,8 @@ 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
local function openRepository()
pendingRepository = repositories[selected]
net.wifiConnect()
screen = "connecting"
needsDraw = true
@@ -415,7 +356,6 @@ function draw()
local returnScreen = pendingApp and "apps" or "repositories"
pendingRepository = nil
pendingApp = nil
pendingRefresh = false
if returnScreen == "repositories" then selected = 1 end
screen = returnScreen
needsDraw = true
@@ -430,10 +370,8 @@ function draw()
showMessage(result, "apps")
else
local repository = pendingRepository
local refresh = pendingRefresh
pendingRepository = nil
pendingRefresh = false
local ok, err = loadCatalog(repository, refresh)
local ok, err = loadCatalog(repository)
if ok then
selected = 1
screen = "apps"
@@ -454,13 +392,13 @@ function draw()
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)
openRepository()
elseif screen == "apps" then
if net.wifiStatus() == "connected" 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
+1 -1
View File
@@ -1,3 +1,3 @@
XTEINK-MANIFEST|1
main.lua|18026|d324b6ffd2ca9b2e4f8cf6ff4676a114b754dd300d054d4f8c80c9d6bddcdd9a
main.lua|16109|48e6396b98a0ff24d1af2325f7e4431354ac07b851364cb4e0aa3a2b28d09355
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
+1 -1
View File
@@ -1,5 +1,5 @@
XTEINK-CATALOG|1
AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|26527e5526d5b30195e70313347d75779e7d66f474b5f31f8e4c633ee2e150f7
AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|2eb608b92de6bdeff4fc8ed447b9319f45c0d79d5d73b14db1ab5a92f91e42d4
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