diff --git a/apps/AppStore/README.md b/apps/AppStore/README.md index 9c751ea..f3600bb 100644 --- a/apps/AppStore/README.md +++ b/apps/AppStore/README.md @@ -13,6 +13,8 @@ XTEINK-REPOSITORIES|1 Name|https://host/path/catalog.txt ``` -AppStore downloads each package into `/.apps/.install-`, verifies declared sizes and SHA-256 hashes, requires `main.lua`, then renames the completed directory into place. Existing apps are left unchanged; updates, including self-update, are not supported yet. +AppStore caches each validated catalog on the SD card. Select opens the cache when available; use Refresh to fetch the latest catalog. + +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. 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 34d990e..0ff4e4f 100644 --- a/apps/AppStore/main.lua +++ b/apps/AppStore/main.lua @@ -3,7 +3,6 @@ local ROOT = "/.apps/AppStore" local REPOSITORIES_PATH = ROOT .. "/repositories.txt" local DEFAULT_REPOSITORIES_PATH = ROOT .. "/repositories.default.txt" -local CATALOG_PATH = ROOT .. "/.catalog.txt" local MAX_CATALOG_BYTES = 32768 local MAX_MANIFEST_BYTES = 16384 local MAX_APP_BYTES = 16 * 1024 * 1024 @@ -16,6 +15,8 @@ local apps = {} local selected = 1 local message = "" local pendingRepository = nil +local pendingApp = nil +local pendingRefresh = false local needsDraw = true local function removeFile(path) @@ -79,7 +80,12 @@ local function loadRepositories() return nil, "Invalid repository entry" end if #result >= 8 then return nil, "Too many repositories" end - result[#result + 1] = { name = name, url = url } + 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 @@ -96,6 +102,8 @@ 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, @@ -103,19 +111,12 @@ local function download(url, destination, maxBytes, expectedSize, hash) }) end -local function loadCatalog(repository) - apps = {} - collectgarbage("collect") - renderStatus("Downloading catalog...") - local bytes, err = download(repository.url, CATALOG_PATH, MAX_CATALOG_BYTES) - if not bytes then return nil, err 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 - ok, err = readLines(CATALOG_PATH, "XTEINK-CATALOG|1", function(line) + 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) @@ -134,10 +135,107 @@ local function loadCatalog(repository) } return true end) - removeFile(CATALOG_PATH) 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 @@ -176,12 +274,14 @@ local function parseManifest(path) 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 @@ -202,6 +302,7 @@ local function install(app) 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("^(.*)/[^/]+$") @@ -219,17 +320,19 @@ local function install(app) 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) +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)) @@ -245,13 +348,13 @@ local function drawList(title, items, label, description) 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", "") + 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) + 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 @@ -259,7 +362,7 @@ local function render() 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.drawButtonHints("Back", "OK", "", "") gui.refresh(REFRESH_HALF) end needsDraw = false @@ -271,53 +374,101 @@ local function moveSelection(delta, items) 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 - selected = 1 - screen = "repositories" + pendingApp = nil + pendingRefresh = false + if returnScreen == "repositories" then selected = 1 end + screen = returnScreen needsDraw = true else local status = net.wifiStatus() if status == "connected" then - local repository = pendingRepository - pendingRepository = nil - local ok, err = loadCatalog(repository) - if ok then - selected = 1 - screen = "apps" - needsDraw = true + log.info("APPSTORE WIFI_CONNECTED") + if pendingApp then + local app = pendingApp + pendingApp = nil + local ok, result = install(app) + showMessage(result, "apps") else - showMessage(err, "repositories") + 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() - showMessage("Wi-Fi connection failed", "repositories") + 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 - pendingRepository = repositories[selected] - net.wifiConnect() - screen = "connecting" - needsDraw = true + openRepository(false) elseif screen == "apps" then - local ok, result = install(apps[selected]) - showMessage(result, "apps") + 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() diff --git a/apps/AppStore/manifest.txt b/apps/AppStore/manifest.txt index 1082b37..0c1b65c 100644 --- a/apps/AppStore/manifest.txt +++ b/apps/AppStore/manifest.txt @@ -1,3 +1,3 @@ XTEINK-MANIFEST|1 -main.lua|12516|4e21087da632730d82049e405e5d86209cfb707c3c3b5fb574dc2f602d904961 +main.lua|18026|d324b6ffd2ca9b2e4f8cf6ff4676a114b754dd300d054d4f8c80c9d6bddcdd9a repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df diff --git a/apps/BinaryKeyboard/README.md b/apps/BinaryKeyboard/README.md new file mode 100644 index 0000000..133ff4d --- /dev/null +++ b/apps/BinaryKeyboard/README.md @@ -0,0 +1,12 @@ +# Binary Keyboard + +Proof of concept for entering text by recursively bisecting a QWERTY-ordered character set. + +- Left side button selects the left group. +- Right side button selects the right group. +- Back undoes one choice, or exits from the root. +- Confirm inserts a space. +- Left front button deletes a character. +- Right front button toggles case. + +A character is committed automatically when only one candidate remains. This is a standalone interaction experiment, not yet a reusable text-entry component. diff --git a/apps/BinaryKeyboard/main.lua b/apps/BinaryKeyboard/main.lua new file mode 100644 index 0000000..a7292e1 --- /dev/null +++ b/apps/BinaryKeyboard/main.lua @@ -0,0 +1,122 @@ +-- DESCRIPTION: Two-button recursive keyboard experiment + +local KEYS = { + "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", + "A", "S", "D", "F", "G", "H", "J", "K", "L", + "Z", "X", "C", "V", "B", "N", "M" +} +local MAX_TEXT_BYTES = 80 + +local text = "" +local candidates = {} +local history = {} +local uppercase = false +local needsDraw = true +local firstDraw = true + +local function copyKeys(source, first, last) + local result = {} + for index = first, last do result[#result + 1] = source[index] end + return result +end + +local function resetCandidates() + candidates = copyKeys(KEYS, 1, #KEYS) + history = {} +end + +local function halves() + local midpoint = math.ceil(#candidates / 2) + return copyKeys(candidates, 1, midpoint), copyKeys(candidates, midpoint + 1, #candidates) +end + +local function append(value) + if #text + #value <= MAX_TEXT_BYTES then text = text .. value end +end + +local function chooseHalf(left) + local leftHalf, rightHalf = halves() + history[#history + 1] = candidates + candidates = left and leftHalf or rightHalf + + if #candidates == 1 then + local letter = candidates[1] + append(uppercase and letter or letter:lower()) + resetCandidates() + end + needsDraw = true +end + +local function undo() + if #history == 0 then + sys.exit() + return + end + candidates = history[#history] + history[#history] = nil + needsDraw = true +end + +local function drawPanel(x, y, width, height, title, values) + gui.drawRoundedRect(x, y, width, height, 2, 8, COLOR_BLACK) + gui.drawText(FONT_UI_10, x + 12, y + 32, title, COLOR_BLACK, STYLE_BOLD) + gui.drawText(FONT_UI_12, x + 12, y + math.floor(height / 2), table.concat(values), COLOR_BLACK, STYLE_NORMAL) +end + +local function render() + local width = gui.width() + local height = gui.height() + local margin = 20 + local gap = 12 + local panelWidth = math.floor((width - margin * 2 - gap) / 2) + local panelTop = 185 + local panelHeight = math.min(220, height - panelTop - 100) + local leftHalf, rightHalf = halves() + + gui.clear() + gui.drawCenteredText(FONT_UI_12, 24, "Binary Keyboard", COLOR_BLACK, STYLE_BOLD) + gui.drawRoundedRect(margin, 65, width - margin * 2, 82, 2, 8, COLOR_BLACK) + local visibleText = text == "" and "Type with the side buttons" or text:sub(-38) + gui.drawText(FONT_UI_12, margin + 12, 96, visibleText, COLOR_BLACK, STYLE_NORMAL) + + local progress = string.rep("•", #history) + gui.drawCenteredText(FONT_UI_10, 155, progress == "" and "Choose a half" or progress, COLOR_BLACK, STYLE_NORMAL) + drawPanel(margin, panelTop, panelWidth, panelHeight, "LEFT SIDE", leftHalf) + drawPanel(margin + panelWidth + gap, panelTop, panelWidth, panelHeight, "RIGHT SIDE", rightHalf) + + gui.drawButtonHints(#history == 0 and "Exit" or "Undo", "Space", "Delete", uppercase and "lower" or "UPPER") + gui.refresh(firstDraw and REFRESH_HALF or REFRESH_FAST) + firstDraw = false + needsDraw = false +end + +function init() + text = "" + uppercase = false + firstDraw = true + needsDraw = true + resetCandidates() +end + +function draw() + if input.wasPressed("up") then + chooseHalf(true) + elseif input.wasPressed("down") then + chooseHalf(false) + elseif input.wasPressed("back") then + undo() + elseif input.wasPressed("confirm") then + append(" ") + resetCandidates() + needsDraw = true + elseif input.wasPressed("left") then + if #text > 0 then text = text:sub(1, -2) end + resetCandidates() + needsDraw = true + elseif input.wasPressed("right") then + uppercase = not uppercase + needsDraw = true + end + + if needsDraw then render() end +end diff --git a/apps/BinaryKeyboard/manifest.txt b/apps/BinaryKeyboard/manifest.txt new file mode 100644 index 0000000..57a77ca --- /dev/null +++ b/apps/BinaryKeyboard/manifest.txt @@ -0,0 +1,2 @@ +XTEINK-MANIFEST|1 +main.lua|3720|8e275a05358e32b0187a7e2c00a93eabe658a32c10112c5451e2d8a78f3d3932 diff --git a/catalog.txt b/catalog.txt index 68bb16a..b63d83f 100644 --- a/catalog.txt +++ b/catalog.txt @@ -1,4 +1,5 @@ XTEINK-CATALOG|1 -AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|50e6f06ac3476b9df9caf78fdfe226f295532168c441df0d2be9289d8b7d7c1c +AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|26527e5526d5b30195e70313347d75779e7d66f474b5f31f8e4c633ee2e150f7 +BinaryKeyboard|Two-button recursive keyboard experiment|apps/BinaryKeyboard/manifest.txt|97|b72b6250bb3124fb1639c9c1eb0c1b7730d62356189f450e26035dde288f9f2b 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