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 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,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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user