-- 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 pendingRefresh = false local needsDraw = true 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 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, cachePath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt", cacheUrlPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".url" } 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) 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 or repository.cachePath, "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 } 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 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) 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) 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 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 fs.exists(target) then return nil, "App is already installed" 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 ok, err = fs.rename(stage, target) if not ok then return failInstall(stage, err) end log.info("APPSTORE INSTALL_DONE " .. app.id) return true, app.id .. " installed" end local function showMessage(text, returnScreen) log.info("APPSTORE MESSAGE " .. tostring(text)) message = text screen = "message" pendingRepository = returnScreen 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(REFRESH_HALF) end local function render() if screen == "repositories" then drawList("Repositories", repositories, function(item) return item.name end, nil, "Refresh") elseif screen == "apps" then drawList("Apps", apps, function(item) return item.id 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) 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(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 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 pendingRefresh = false 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 local refresh = pendingRefresh pendingRepository = nil pendingRefresh = false local ok, err = loadCatalog(repository, refresh) 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("right") and screen == "repositories" then openRepository(true) elseif input.wasPressed("confirm") then if screen == "repositories" then openRepository(false) elseif screen == "apps" then if 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