-- 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 screen = "repositories" local repositories = {} local apps = {} 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 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) removeFile(destination) collectgarbage("collect") log.debug("APPSTORE DOWNLOAD " .. destination) return net.download(url, destination, { maxBytes = maxBytes, expectedSize = expectedSize, sha256 = hash }) end local function parseCatalog(repository, path) local baseUrl = repository.url:match("^(.*)/[^/]+$") if not baseUrl 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 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()) } 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 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) removeFile(repository.downloadPath) if not ok then return nil, err end return true 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 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) if not ok then return nil, err end ok, err = fs.mkdir(stage) if not ok then return nil, err end local manifestUrl = app.baseUrl .. "/" .. 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 app.state = "current" 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 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) 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(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(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 openRepository() pendingRepository = repositories[selected] 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() local returnScreen = pendingApp and "apps" or "repositories" pendingRepository = nil pendingApp = nil if returnScreen == "repositories" then selected = 1 end screen = returnScreen needsDraw = true 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) showMessage(result, "apps") 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() local returnScreen = pendingApp and "apps" or "repositories" pendingApp = nil showMessage("Wi-Fi connection failed", returnScreen) end end elseif input.wasPressed("up") then moveSelection(-1, screen == "apps" and apps or repositories) elseif input.wasPressed("down") then moveSelection(1, screen == "apps" and apps or repositories) 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 elseif screen == "message" then if pendingRepository == "exit" then net.wifiDisconnect() sys.exit() return end screen = pendingRepository or "repositories" pendingRepository = nil needsDraw = true 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 needsDraw = true else net.wifiDisconnect() sys.exit() return end end if needsDraw then render() end end