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:
+65
-127
@@ -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,3 +1,3 @@
|
||||
XTEINK-MANIFEST|1
|
||||
main.lua|18026|d324b6ffd2ca9b2e4f8cf6ff4676a114b754dd300d054d4f8c80c9d6bddcdd9a
|
||||
main.lua|16109|48e6396b98a0ff24d1af2325f7e4431354ac07b851364cb4e0aa3a2b28d09355
|
||||
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user