-- DESCRIPTION: Install apps from public Xteink repositories local ROOT = "/.apps/AppStore" local REPOSITORIES_PATH = ROOT .. "/repositories.txt" local DEFAULT_REPOSITORIES_PATH = ROOT .. "/repositories.default.txt" local MAX_CATALOG_BYTES = 32768 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 local pendingApp = nil local needsDraw = true local lastScreen = nil local function removeFile(path) if fs.exists(path) then fs.remove(path) end end local function clearTree(path) if not fs.exists(path) then return true end local ok, err = fs.removeTree(path) if not ok or fs.exists(path) then return nil, err or "Could not remove " .. path end return true end local function validRelativePath(path) if not path or path == "" or path:sub(1, 1) == "/" or path:find("\\", 1, true) then return false end if not path:match("^[%w%._/%-]+$") then return false end for component in path:gmatch("[^/]+") do if component == "." or component == ".." then return false end end return not path:find("//", 1, true) and path:sub(-1) ~= "/" end 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 while true do local line, nextOffset = fs.readLineAt(path, offset) if not line then if nextOffset then return nil, "Line exceeds 192 bytes" end break end offset = nextOffset if first then first = false if line ~= expectedHeader then return nil, "Unsupported format" end elseif line ~= "" and line:sub(1, 1) ~= "#" then local ok, err = onLine(line) if not ok then return nil, err end end end if first then return nil, "Empty file" end return true end local function loadRepositories() if not fs.exists(REPOSITORIES_PATH) then local defaults = fs.readFile(DEFAULT_REPOSITORIES_PATH) if not defaults or not fs.writeFile(REPOSITORIES_PATH, defaults) then return nil, "Missing repositories.txt" end end local result = {} local ok, err = readLines(REPOSITORIES_PATH, "XTEINK-REPOSITORIES|1", function(line) local name, url = line:match("^([^|]+)|(https://[^/|]+/[^|]+)$") if not name or #name > 48 or #url > 160 or url:find("[?#]") then return nil, "Invalid repository entry" end if #result >= 8 then return nil, "Too many repositories" end result[#result + 1] = { name = name, url = url, downloadPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt" } return true end) if not ok then return nil, err end if #result == 0 then return nil, "No repositories configured" end repositories = result 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) -- 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 local function download(url, destination, maxBytes, expectedSize, hash) removeFile(destination) collectgarbage("collect") log.debug("APPSTORE DOWNLOAD " .. destination) return net.download(url, destination, { maxBytes = maxBytes, expectedSize = expectedSize, sha256 = 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) 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(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] = line return true end) if not ok then return nil, err end if #result == 0 then return nil, "Repository has no apps" end apps = result log.info("APPSTORE CATALOG_READY " .. #apps) 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") renderStatus("Downloading catalog...") 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, repository.downloadPath) 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 = {} local seenDirectories = {} local total = 0 local hasMain = false local ok, err = readLines(path, "XTEINK-MANIFEST|1", function(line) local relative, sizeText, hash = line:match("^([^|]+)|(%d+)|([0-9a-fA-F]+)$") local size = tonumber(sizeText) local lower = relative and relative:lower() if not validRelativePath(relative) or lower == ".manifest.txt" or not size or size < 1 or size > MAX_FILE_BYTES or not validHash(hash) or seenFiles[lower] or seenDirectories[lower] then return nil, "Invalid manifest entry" end local parent = "" for component in lower:gmatch("[^/]+") do if parent ~= "" then if seenFiles[parent] then return nil, "Manifest path collision" end seenDirectories[parent] = true end parent = parent == "" and component or parent .. "/" .. component end if #files >= MAX_FILES or total + size > MAX_APP_BYTES then return nil, "App is too large" end seenFiles[lower] = true total = total + size hasMain = hasMain or relative == "main.lua" files[#files + 1] = { path = relative, size = size, hash = hash:lower() } return true end) if not ok then return nil, err end if not hasMain then return nil, "Manifest has no main.lua" end return files end local function failInstall(stage, installError) log.error("APPSTORE INSTALL_FAILED " .. tostring(installError)) local ok, cleanupError = clearTree(stage) if not ok then return nil, tostring(installError) .. "; cleanup failed: " .. tostring(cleanupError) end return nil, installError end local function install(app) log.info("APPSTORE INSTALL_START " .. app.id) local target = "/.apps/" .. app.id local stage = "/.apps/.install-" .. app.id local ok, err = clearTree(stage) if not ok then return nil, err end ok, err = fs.mkdir(stage) if not ok then return nil, err end local manifestUrl = catalogBaseUrl .. "/" .. app.manifestPath local manifestBase = manifestUrl:match("^(.*)/[^/]+$") local manifestPath = stage .. "/.manifest.txt" renderStatus("Downloading " .. app.id .. " manifest...") local bytes bytes, err = download(manifestUrl, manifestPath, MAX_MANIFEST_BYTES, app.manifestSize, app.manifestHash) if not bytes then return failInstall(stage, err) end local files files, err = parseManifest(manifestPath) if not files then return failInstall(stage, err) end log.info("APPSTORE MANIFEST_READY " .. app.id .. " " .. #files) for index, file in ipairs(files) do local parent = file.path:match("^(.*)/[^/]+$") if parent then ok, err = fs.mkdir(stage .. "/" .. parent) if not ok then return failInstall(stage, err) end end renderStatus("Installing " .. app.id .. " " .. index .. "/" .. #files) bytes, err = download(manifestBase .. "/" .. file.path, stage .. "/" .. file.path, file.size, file.size, file.hash) if not bytes then return failInstall(stage, err) end 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 if replacing then fs.rename(backup, target) end return failInstall(stage, err) end if replacing then clearTree(backup) end log.info("APPSTORE INSTALL_DONE " .. app.id) return true, app.id .. (replacing and " updated" or " installed") end local function showMessage(text, returnScreen) log.info("APPSTORE MESSAGE " .. tostring(text)) message = text screen = "message" pendingRepository = returnScreen if returnScreen == "repositories" then selected = 1 end needsDraw = true end 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) gui.clear() gui.drawCenteredText(FONT_UI_12, 28, title, COLOR_BLACK, STYLE_BOLD) local visible = math.max(1, math.floor((gui.height() - 130) / 38)) local first = math.max(1, math.min(selected - math.floor(visible / 2), #items - visible + 1)) local last = math.min(#items, first + visible - 1) local y = 72 for index = first, last do local prefix = index == selected and "> " or " " gui.drawText(FONT_UI_12, 36, y, prefix .. label(items[index]), COLOR_BLACK, index == selected and STYLE_BOLD or STYLE_NORMAL) y = y + 38 end if items[selected] and description then gui.drawCenteredText(FONT_UI_10, gui.height() - 82, description(items[selected]), COLOR_BLACK, STYLE_NORMAL) end gui.drawButtonHints("Back", "Select", "", rightHint or "") 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) elseif screen == "apps" then 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 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(refreshMode()) end needsDraw = false end local function moveSelection(delta, items) if #items == 0 then return end selected = ((selected - 1 + delta) % #items) + 1 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() 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 end function init() local ok, err = loadRepositories() if not ok then showMessage(err, "exit") return end log.info("APPSTORE READY") end function draw() if screen == "connecting" then if input.wasPressed("back") then net.wifiDisconnect() pendingRepository = nil if pendingApp then pendingApp = nil finishInstall(nil) else selected = 1 screen = "repositories" needsDraw = true end else local status = net.wifiStatus() if status == "connected" then log.info("APPSTORE WIFI_CONNECTED") if pendingApp then local app = pendingApp pendingApp = nil local ok, result = install(app) finishInstall(result) else local repository = pendingRepository pendingRepository = nil local ok, err = loadCatalog(repository) if ok then selected = 1 screen = "apps" needsDraw = true else showMessage(err, "repositories") end end elseif status == "failed" then log.error("APPSTORE WIFI_FAILED") net.wifiDisconnect() if pendingApp then pendingApp = nil finishInstall("Wi-Fi connection failed") else showMessage("Wi-Fi connection failed", "repositories") end end end elseif input.wasPressed("up") and (screen == "apps" or screen == "repositories") then moveSelection(-1, screen == "apps" and apps or repositories) 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 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 leaveMessage() end elseif input.wasPressed("back") then if screen == "apps" then selected = 1 screen = "repositories" needsDraw = true 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() return end end if needsDraw then render() end end