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 message = ""
local pendingRepository = nil local pendingRepository = nil
local pendingApp = nil local pendingApp = nil
local pendingRefresh = false
local needsDraw = true local needsDraw = true
local lastScreen = nil
local function removeFile(path) local function removeFile(path)
if fs.exists(path) then fs.remove(path) end 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 return value and #value == 64 and value:match("^[0-9a-fA-F]+$") ~= nil
end 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 function readLines(path, expectedHeader, onLine)
local offset = 0 local offset = 0
local first = true local first = true
@@ -83,8 +91,7 @@ local function loadRepositories()
result[#result + 1] = { result[#result + 1] = {
name = name, name = name,
url = url, url = url,
cachePath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt", downloadPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt"
cacheUrlPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".url"
} }
return true return true
end) end)
@@ -98,6 +105,7 @@ local function renderStatus(text)
gui.clear() gui.clear()
gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), text, COLOR_BLACK, STYLE_BOLD) gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), text, COLOR_BLACK, STYLE_BOLD)
gui.refresh(REFRESH_HALF) gui.refresh(REFRESH_HALF)
lastScreen = "status"
end end
local function download(url, destination, maxBytes, expectedSize, hash) 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 if not baseUrl then return nil, "Invalid catalog URL" end
local result = {} 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 = local id, description, manifestPath, sizeText, hash =
line:match("^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$") line:match("^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$")
local size = tonumber(sizeText) local size = tonumber(sizeText)
@@ -131,7 +139,8 @@ local function parseCatalog(repository, path)
manifestPath = manifestPath, manifestPath = manifestPath,
manifestSize = size, manifestSize = size,
manifestHash = hash:lower(), manifestHash = hash:lower(),
baseUrl = baseUrl baseUrl = baseUrl,
state = installState(id, hash:lower())
} }
return true return true
end) end)
@@ -142,100 +151,19 @@ local function parseCatalog(repository, path)
return true return true
end end
local function hasCatalogCache(repository) local function loadCatalog(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 = {} apps = {}
collectgarbage("collect") 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...") renderStatus("Downloading catalog...")
local temporary = repository.cachePath .. ".download" removeFile(repository.downloadPath)
local bytes, err = download(repository.url, temporary, MAX_CATALOG_BYTES) local bytes, err = download(repository.url, repository.downloadPath, MAX_CATALOG_BYTES)
if not bytes then return nil, err end if not bytes then return nil, err end
local ok local ok
ok, err = parseCatalog(repository, temporary) ok, err = parseCatalog(repository, repository.downloadPath)
if not ok then removeFile(temporary) return nil, err end removeFile(repository.downloadPath)
if not ok then 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 return true
end end
@@ -283,7 +211,7 @@ end
local function install(app) local function install(app)
log.info("APPSTORE INSTALL_START " .. app.id) log.info("APPSTORE INSTALL_START " .. app.id)
local target = "/.apps/" .. 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 stage = "/.apps/.install-" .. app.id
local ok, err = clearTree(stage) local ok, err = clearTree(stage)
@@ -317,11 +245,31 @@ local function install(app)
end end
ok, err = fs.remove(manifestPath) ok, err = fs.remove(manifestPath)
if not ok then return failInstall(stage, err) end 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) 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) log.info("APPSTORE INSTALL_DONE " .. app.id)
return true, app.id .. " installed" return true, app.id .. (replacing and " updated" or " installed")
end end
local function showMessage(text, returnScreen) local function showMessage(text, returnScreen)
@@ -332,6 +280,14 @@ local function showMessage(text, returnScreen)
needsDraw = true needsDraw = true
end 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) local function drawList(title, items, label, description, rightHint)
gui.clear() gui.clear()
gui.drawCenteredText(FONT_UI_12, 28, title, COLOR_BLACK, STYLE_BOLD) 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) gui.drawCenteredText(FONT_UI_10, gui.height() - 82, description(items[selected]), COLOR_BLACK, STYLE_NORMAL)
end end
gui.drawButtonHints("Back", "Select", "", rightHint or "") gui.drawButtonHints("Back", "Select", "", rightHint or "")
gui.refresh(REFRESH_HALF) gui.refresh(refreshMode())
end end
local APP_STATE_SUFFIX = { current = " (installed)", update = " (update)" }
local function render() local function render()
if screen == "repositories" then 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 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 elseif screen == "connecting" then
renderStatus("Connecting to Wi-Fi...") renderStatus("Connecting to Wi-Fi...")
else else
gui.clear() gui.clear()
gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), message:sub(1, 100), COLOR_BLACK, STYLE_BOLD) 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) gui.refresh(refreshMode())
end end
needsDraw = false needsDraw = false
end end
@@ -374,26 +333,8 @@ local function moveSelection(delta, items)
needsDraw = true needsDraw = true
end end
local function openRepository(refresh) local function openRepository()
local repository = repositories[selected] pendingRepository = 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() net.wifiConnect()
screen = "connecting" screen = "connecting"
needsDraw = true needsDraw = true
@@ -415,7 +356,6 @@ function draw()
local returnScreen = pendingApp and "apps" or "repositories" local returnScreen = pendingApp and "apps" or "repositories"
pendingRepository = nil pendingRepository = nil
pendingApp = nil pendingApp = nil
pendingRefresh = false
if returnScreen == "repositories" then selected = 1 end if returnScreen == "repositories" then selected = 1 end
screen = returnScreen screen = returnScreen
needsDraw = true needsDraw = true
@@ -430,10 +370,8 @@ function draw()
showMessage(result, "apps") showMessage(result, "apps")
else else
local repository = pendingRepository local repository = pendingRepository
local refresh = pendingRefresh
pendingRepository = nil pendingRepository = nil
pendingRefresh = false local ok, err = loadCatalog(repository)
local ok, err = loadCatalog(repository, refresh)
if ok then if ok then
selected = 1 selected = 1
screen = "apps" screen = "apps"
@@ -454,13 +392,13 @@ function draw()
moveSelection(-1, screen == "apps" and apps or repositories) moveSelection(-1, screen == "apps" and apps or repositories)
elseif input.wasPressed("down") then elseif input.wasPressed("down") then
moveSelection(1, screen == "apps" and apps or repositories) moveSelection(1, screen == "apps" and apps or repositories)
elseif input.wasPressed("right") and screen == "repositories" then
openRepository(true)
elseif input.wasPressed("confirm") then elseif input.wasPressed("confirm") then
if screen == "repositories" then if screen == "repositories" then
openRepository(false) openRepository()
elseif screen == "apps" then 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]) local ok, result = install(apps[selected])
showMessage(result, "apps") showMessage(result, "apps")
else else
+1 -1
View File
@@ -1,3 +1,3 @@
XTEINK-MANIFEST|1 XTEINK-MANIFEST|1
main.lua|18026|d324b6ffd2ca9b2e4f8cf6ff4676a114b754dd300d054d4f8c80c9d6bddcdd9a main.lua|16109|48e6396b98a0ff24d1af2325f7e4431354ac07b851364cb4e0aa3a2b28d09355
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
+1 -1
View File
@@ -1,5 +1,5 @@
XTEINK-CATALOG|1 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 BinaryKeyboard|Two-button recursive keyboard experiment|apps/BinaryKeyboard/manifest.txt|97|b72b6250bb3124fb1639c9c1eb0c1b7730d62356189f450e26035dde288f9f2b
HomeAssistant|Home Assistant status cards|apps/HomeAssistant/manifest.txt|177|b38ca49804444fd383a505ce9c51680ad9b7443950d682ca1b9ef3047bff98a5 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 Wordle|Guess the five-letter word in six tries|apps/Wordle/manifest.txt|177|199fafdf0d0f6e77916befc011f6e8466c254867800e1d4a9e67e95a06d0366b