diff --git a/apps/AppStore/README.md b/apps/AppStore/README.md index f3600bb..6bd9ca5 100644 --- a/apps/AppStore/README.md +++ b/apps/AppStore/README.md @@ -13,8 +13,28 @@ XTEINK-REPOSITORIES|1 Name|https://host/path/catalog.txt ``` -AppStore caches each validated catalog on the SD card. Select opens the cache when available; use Refresh to fetch the latest catalog. +## Screens -Packages download into `/.apps/.install-`, 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. +Repositories → Apps → app detail. Opening a repository always fetches its catalog over Wi-Fi, so +the list is current on every launch. The validated catalog is then kept on the SD card and re-read +when returning to the Apps list, which avoids a second fetch after an install. + +The detail screen shows the app name, install state, and description, with `Back` / `Install` +(`Update` or `Reinstall` when already present) / `Delete`. Install progress replaces the button +hints in that same window rather than taking over the screen. Delete asks for confirmation and +refuses to remove AppStore itself. + +## Memory + +Catalog entries are held as raw lines and expanded only for the rows being drawn — expanding all 64 +into tables costs roughly 30 KB, which has to coexist with the Wi-Fi and TLS stacks on a 380 KB +device. The catalog is released entirely before `net.wifiConnect()` and rebuilt from the SD cache +afterwards. + +## Installation + +Packages download into `/.apps/.install-`, verify declared sizes and SHA-256 hashes, require +`main.lua`, then rename into place. Updates rename the existing tree aside first and restore it if +the swap fails. Install state comes from the `.manifest.hash` marker written at install time. HTTPS is encrypted without peer-certificate authentication. An active network attacker can replace both metadata and payload hashes. diff --git a/apps/AppStore/main.lua b/apps/AppStore/main.lua index 2b02fae..6968115 100644 --- a/apps/AppStore/main.lua +++ b/apps/AppStore/main.lua @@ -8,10 +8,15 @@ local MAX_MANIFEST_BYTES = 16384 local MAX_APP_BYTES = 16 * 1024 * 1024 local MAX_FILE_BYTES = 8 * 1024 * 1024 local MAX_FILES = 64 +local CATALOG_PATTERN = "^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$" local screen = "repositories" local repositories = {} local apps = {} +local catalogBaseUrl = nil +local currentRepository = nil +local detailApp = nil +local detailMessage = nil local selected = 1 local message = "" local pendingRepository = nil @@ -101,10 +106,77 @@ local function loadRepositories() return 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 wrapText(fontId, text, maxWidth) + local lines = {} + local line = "" + for word in text:gmatch("%S+") do + local candidate = line == "" and word or line .. " " .. word + if line ~= "" and gui.getTextWidth(fontId, candidate) > maxWidth then + lines[#lines + 1] = line + line = word + else + line = candidate + end + end + if line ~= "" then lines[#lines + 1] = line end + return lines +end + +local DETAIL_STATE_LABEL = { none = "Not installed", current = "Installed", update = "Update available" } +local DETAIL_ACTION_LABEL = { none = "Install", current = "Reinstall", update = "Update" } + +-- Progress Lives In The Detail Window - install status replaces the button hints rather than +-- taking over the screen, so the app being installed stays visible throughout. +local function renderDetail(status) + local state = installState(detailApp.id, detailApp.manifestHash) + local margin = 40 + gui.clear() + gui.drawCenteredText(FONT_UI_12, 44, detailApp.id, COLOR_BLACK, STYLE_BOLD) + gui.drawCenteredText(FONT_UI_10, 78, DETAIL_STATE_LABEL[state] or state) + gui.drawLine(margin, 100, gui.width() - margin, 100) + + local y = 134 + for _, line in ipairs(wrapText(FONT_UI_10, detailApp.description, gui.width() - margin * 2)) do + gui.drawText(FONT_UI_10, margin, y, line) + y = y + 28 + end + + -- The hint row owns the bottom 40 px, so status and result text stack upwards from above it. + if status then + gui.drawCenteredText(FONT_UI_12, gui.height() - 90, status, COLOR_BLACK, STYLE_BOLD) + else + if detailMessage then + local lines = wrapText(FONT_UI_10, detailMessage, gui.width() - margin * 2) + local y = gui.height() - 114 + for index = 1, math.min(#lines, 2) do + gui.drawCenteredText(FONT_UI_10, y, lines[index]) + y = y + 26 + end + end + gui.drawButtonHints("Back", DETAIL_ACTION_LABEL[state] or "Install", "", + state ~= "none" and "Delete" or "") + end + gui.refresh(refreshMode()) +end + local function renderStatus(text) + if detailApp then + renderDetail(text) + return + end gui.clear() gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), text, COLOR_BLACK, STYLE_BOLD) - gui.refresh(REFRESH_HALF) + -- Consecutive status updates only change the centred line, so FAST's differential drive is + -- valid; only the first entry from a list screen needs HALF to clear the previous frame. + gui.refresh(lastScreen == "status" and REFRESH_FAST or REFRESH_HALF) lastScreen = "status" end @@ -119,29 +191,22 @@ local function download(url, destination, maxBytes, expectedSize, hash) }) end +-- Entries Stay As Raw Lines - expanding 64 catalog rows into tables costs ~30KB that has to +-- coexist with the Wi-Fi and TLS stacks. Re-matching the handful of visible rows is far cheaper. local function parseCatalog(repository, path) - local baseUrl = repository.url:match("^(.*)/[^/]+$") - if not baseUrl then return nil, "Invalid catalog URL" end + catalogBaseUrl = repository.url:match("^(.*)/[^/]+$") + if not catalogBaseUrl then return nil, "Invalid catalog URL" end local result = {} 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 id, description, manifestPath, sizeText, hash = line:match(CATALOG_PATTERN) local size = tonumber(sizeText) if not id or #id > 48 or #description > 80 or not validRelativePath(manifestPath) or not size or size < 1 or size > MAX_MANIFEST_BYTES or not validHash(hash) then return nil, "Invalid catalog entry" end if #result >= 64 then return nil, "Too many apps" end - result[#result + 1] = { - id = id, - description = description, - manifestPath = manifestPath, - manifestSize = size, - manifestHash = hash:lower(), - baseUrl = baseUrl, - state = installState(id, hash:lower()) - } + result[#result + 1] = line return true end) if not ok then return nil, err end @@ -151,6 +216,17 @@ local function parseCatalog(repository, path) return true end +local function catalogEntry(line) + local id, description, manifestPath, sizeText, hash = line:match(CATALOG_PATTERN) + return { + id = id, + description = description, + manifestPath = manifestPath, + manifestSize = tonumber(sizeText), + manifestHash = hash:lower() + } +end + local function loadCatalog(repository) apps = {} collectgarbage("collect") @@ -162,11 +238,24 @@ local function loadCatalog(repository) local ok ok, err = parseCatalog(repository, repository.downloadPath) - removeFile(repository.downloadPath) - if not ok then return nil, err end + if not ok then + removeFile(repository.downloadPath) + return nil, err + end return true end +-- Rehydrate From The Downloaded File - the catalog is dropped from RAM before Wi-Fi comes up, so +-- returning to the Apps list re-parses the file rather than re-fetching it. Install state is +-- derived per row at draw time, so a freshly installed app shows correctly without a refetch. +local function ensureCatalog() + if #apps > 0 then return true end + if not currentRepository or not fs.exists(currentRepository.downloadPath) then + return nil, "Catalog unavailable" + end + return parseCatalog(currentRepository, currentRepository.downloadPath) +end + local function parseManifest(path) local files = {} local seenFiles = {} @@ -211,7 +300,6 @@ end local function install(app) log.info("APPSTORE INSTALL_START " .. app.id) local target = "/.apps/" .. app.id - 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) @@ -219,7 +307,7 @@ local function install(app) ok, err = fs.mkdir(stage) if not ok then return nil, err end - local manifestUrl = app.baseUrl .. "/" .. app.manifestPath + local manifestUrl = catalogBaseUrl .. "/" .. app.manifestPath local manifestBase = manifestUrl:match("^(.*)/[^/]+$") local manifestPath = stage .. "/.manifest.txt" renderStatus("Downloading " .. app.id .. " manifest...") @@ -267,7 +355,6 @@ local function install(app) end if replacing then clearTree(backup) end - app.state = "current" log.info("APPSTORE INSTALL_DONE " .. app.id) return true, app.id .. (replacing and " updated" or " installed") end @@ -277,15 +364,28 @@ local function showMessage(text, returnScreen) message = text screen = "message" pendingRepository = returnScreen + if returnScreen == "repositories" then selected = 1 end 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 +local function leaveMessage() + screen = pendingRepository or "repositories" + pendingRepository = nil + needsDraw = true +end + +-- Returning To The Apps List - the catalog is dropped from RAM before every install, so the +-- list has to be rebuilt from the cached file; falling back to Repositories keeps it honest. +local function leaveDetail() + detailApp = nil + detailMessage = nil + if ensureCatalog() then + screen = "apps" + else + selected = 1 + screen = "repositories" + end + needsDraw = true end local function drawList(title, items, label, description, rightHint) @@ -314,8 +414,20 @@ local function render() if screen == "repositories" then drawList("Repositories", repositories, function(item) return item.name end) elseif screen == "apps" then - drawList("Apps", apps, function(item) return item.id .. (APP_STATE_SUFFIX[item.state] or "") end, - function(item) return item.description end) + drawList("Apps", apps, function(line) + local app = catalogEntry(line) + return app.id .. (APP_STATE_SUFFIX[installState(app.id, app.manifestHash)] or "") + end, function(line) return catalogEntry(line).description end) + elseif screen == "detail" then + renderDetail(nil) + elseif screen == "confirmDelete" then + gui.clear() + gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2) - 24, + "Delete " .. detailApp.id .. "?", COLOR_BLACK, STYLE_BOLD) + gui.drawCenteredText(FONT_UI_10, math.floor(gui.height() / 2) + 18, + "Removes the installed app from this device.") + gui.drawButtonHints("Cancel", "Delete", "", "") + gui.refresh(refreshMode()) elseif screen == "connecting" then renderStatus("Connecting to Wi-Fi...") else @@ -333,8 +445,50 @@ local function moveSelection(delta, items) needsDraw = true end +local function finishInstall(result) + detailMessage = result + screen = "detail" + needsDraw = true +end + +local function startInstall() + detailMessage = nil + -- Shed The Catalog Before The Radio - install downloads run with the Wi-Fi and TLS stacks + -- resident, and 64 held entries are what tips this into OOM. Rebuilt from cache on the way out. + apps = {} + collectgarbage("collect") + if net.wifiStatus() == "connected" then + local ok, result = install(detailApp) + finishInstall(result) + else + pendingApp = detailApp + net.wifiConnect() + screen = "connecting" + needsDraw = true + end +end + +local function deleteApp() + -- Never Delete Ourselves - the running chunk is already in RAM so this would not crash, but it + -- would silently uninstall the store mid-session. + if "/.apps/" .. detailApp.id == ROOT then + finishInstall("Cannot delete the running app") + return + end + local ok, err = clearTree("/.apps/" .. detailApp.id) + log.info("APPSTORE DELETE " .. detailApp.id .. " " .. tostring(ok)) + finishInstall(ok and (detailApp.id .. " deleted") or tostring(err)) +end + local function openRepository() - pendingRepository = repositories[selected] + detailApp = nil + detailMessage = nil + currentRepository = repositories[selected] + pendingRepository = currentRepository + -- Shed The Stale Catalog Before The Radio - bringing up Wi-Fi allocates tens of KB, and the + -- previous repository's entries are dead weight from the moment we leave the list. + apps = {} + collectgarbage("collect") net.wifiConnect() screen = "connecting" needsDraw = true @@ -353,12 +507,15 @@ function draw() if screen == "connecting" then if input.wasPressed("back") then net.wifiDisconnect() - local returnScreen = pendingApp and "apps" or "repositories" pendingRepository = nil - pendingApp = nil - if returnScreen == "repositories" then selected = 1 end - screen = returnScreen - needsDraw = true + if pendingApp then + pendingApp = nil + finishInstall(nil) + else + selected = 1 + screen = "repositories" + needsDraw = true + end else local status = net.wifiStatus() if status == "connected" then @@ -367,7 +524,7 @@ function draw() local app = pendingApp pendingApp = nil local ok, result = install(app) - showMessage(result, "apps") + finishInstall(result) else local repository = pendingRepository pendingRepository = nil @@ -383,49 +540,56 @@ function draw() elseif status == "failed" then log.error("APPSTORE WIFI_FAILED") net.wifiDisconnect() - local returnScreen = pendingApp and "apps" or "repositories" - pendingApp = nil - showMessage("Wi-Fi connection failed", returnScreen) + if pendingApp then + pendingApp = nil + finishInstall("Wi-Fi connection failed") + else + showMessage("Wi-Fi connection failed", "repositories") + end end end - elseif input.wasPressed("up") then + elseif input.wasPressed("up") and (screen == "apps" or screen == "repositories") then moveSelection(-1, screen == "apps" and apps or repositories) - elseif input.wasPressed("down") then + elseif input.wasPressed("down") and (screen == "apps" or screen == "repositories") then moveSelection(1, screen == "apps" and apps or repositories) + elseif input.wasPressed("right") then + if screen == "detail" and installState(detailApp.id, detailApp.manifestHash) ~= "none" then + detailMessage = nil + screen = "confirmDelete" + needsDraw = true + end elseif input.wasPressed("confirm") then if screen == "repositories" then openRepository() elseif screen == "apps" 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 - pendingApp = apps[selected] - net.wifiConnect() - screen = "connecting" - needsDraw = true - end + detailApp = catalogEntry(apps[selected]) + detailMessage = nil + screen = "detail" + needsDraw = true + elseif screen == "detail" then + startInstall() + elseif screen == "confirmDelete" then + deleteApp() elseif screen == "message" then if pendingRepository == "exit" then net.wifiDisconnect() sys.exit() return end - screen = pendingRepository or "repositories" - pendingRepository = nil - needsDraw = true + leaveMessage() end elseif input.wasPressed("back") then if screen == "apps" then selected = 1 screen = "repositories" needsDraw = true - elseif screen == "message" and pendingRepository ~= "exit" then - screen = pendingRepository or "repositories" - pendingRepository = nil + elseif screen == "detail" then + leaveDetail() + elseif screen == "confirmDelete" then + screen = "detail" needsDraw = true + elseif screen == "message" and pendingRepository ~= "exit" then + leaveMessage() else net.wifiDisconnect() sys.exit() diff --git a/apps/AppStore/manifest.txt b/apps/AppStore/manifest.txt index 428ec8d..939a8cf 100644 --- a/apps/AppStore/manifest.txt +++ b/apps/AppStore/manifest.txt @@ -1,3 +1,3 @@ XTEINK-MANIFEST|1 -main.lua|16109|48e6396b98a0ff24d1af2325f7e4431354ac07b851364cb4e0aa3a2b28d09355 +main.lua|22282|91eb39358d50a89ac9cc345fe0ef9d84ff595f7a0db60bae84127dac1368f3cc repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df diff --git a/catalog.txt b/catalog.txt index 5a1b243..c3d1b2e 100644 --- a/catalog.txt +++ b/catalog.txt @@ -1,6 +1,6 @@ XTEINK-CATALOG|1 2048|Slide and merge tiles to reach 2048|apps/2048/manifest.txt|97|7cc1eee488186698bf294a649fe850164af23e32aaee1cd86754555e282bce04 -AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|2eb608b92de6bdeff4fc8ed447b9319f45c0d79d5d73b14db1ab5a92f91e42d4 +AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|1497912a4b9e58e95b4d8b0745a03c98ddea8ef9b47479bb47b5c89635c0b166 BinaryKeyboard|Two-button recursive keyboard experiment|apps/BinaryKeyboard/manifest.txt|97|893867dba0ae2725caca1eb56375c98c4dd00d351134aa129698c51f97e25690 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