feat: add Lua AppStore

This commit is contained in:
2026-07-25 22:34:38 -04:00
parent 3dcadd7702
commit 4c939eb196
9 changed files with 449 additions and 15 deletions
+4 -7
View File
@@ -34,7 +34,9 @@ XTEINK-MANIFEST|1
<relative path>|<bytes>|<SHA-256>
```
App IDs and file paths cannot contain `|`. App IDs use only letters, numbers, `_`, and `-`. Descriptions come from the `-- DESCRIPTION:` line in each `main.lua`.
App IDs use only letters, numbers, `_`, and `-`. Payload paths use ASCII letters, numbers, `.`, `_`, `/`, and `-`; empty files and traversal components are rejected. Descriptions come from the `-- DESCRIPTION:` line in each `main.lua`.
Compatible catalogs contain up to 64 apps, with at most 64 files, 8 MB per file, and 16 MB total per app. Catalogs are limited to 32 KB and manifests to 16 KB. Downloads are size- and SHA-256-checked. HTTPS is encrypted but the firmware does not authenticate peer certificates, so repository metadata is not protected against an active network attacker.
## Adding or changing an app
@@ -51,9 +53,4 @@ App IDs and file paths cannot contain `|`. App IDs use only letters, numbers, `_
scripts/update-manifests.py --check
```
Commit app payloads, manifests, and `catalog.txt` together.
## Included apps
- **HomeAssistant** — Edit its installed `config.txt` with your URL, long-lived access token, and entity IDs. The repository contains placeholders only.
- **Wordle** — Includes its five-letter candidate list in `words.txt`.
Commit app payloads, manifests, and `catalog.txt` together. An app's root `README.md` is repository documentation and is intentionally excluded from its install manifest.
+18
View File
@@ -0,0 +1,18 @@
# AppStore
Installs new Lua apps from one or more public Xteink repositories.
## Bootstrap
Copy this directory to `/plugins/AppStore` on the SD card. On first launch, AppStore creates `repositories.txt` from `repositories.default.txt`.
Repository entries use this format:
```text
XTEINK-REPOSITORIES|1
Name|https://host/path/catalog.txt
```
AppStore downloads each package into `/plugins/.install-<AppId>`, 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.
HTTPS is encrypted without peer-certificate authentication. An active network attacker can replace both metadata and payload hashes.
+348
View File
@@ -0,0 +1,348 @@
-- DESCRIPTION: Install apps from public Xteink repositories
local ROOT = "/plugins/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
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 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 }
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)
return net.download(url, destination, {
maxBytes = maxBytes,
expectedSize = expectedSize,
sha256 = 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 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 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)
removeFile(CATALOG_PATH)
if not ok then return nil, err end
if #result == 0 then return nil, "Repository has no apps" end
apps = result
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)
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)
local target = "/plugins/" .. app.id
if fs.exists(target) then return nil, "App is already installed" end
local stage = "/plugins/.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
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
return true, app.id .. " installed"
end
local function showMessage(text, returnScreen)
message = text
screen = "message"
pendingRepository = returnScreen
needsDraw = true
end
local function drawList(title, items, label, description)
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", "")
gui.refresh(REFRESH_HALF)
end
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 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
function init()
local ok, err = loadRepositories()
if not ok then
showMessage(err, "exit")
return
end
end
function draw()
if screen == "connecting" then
if input.wasPressed("back") then
net.wifiDisconnect()
pendingRepository = nil
selected = 1
screen = "repositories"
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
else
showMessage(err, "repositories")
end
elseif status == "failed" then
net.wifiDisconnect()
showMessage("Wi-Fi connection failed", "repositories")
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
pendingRepository = repositories[selected]
net.wifiConnect()
screen = "connecting"
needsDraw = true
elseif screen == "apps" then
local ok, result = install(apps[selected])
showMessage(result, "apps")
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
+3
View File
@@ -0,0 +1,3 @@
XTEINK-MANIFEST|1
main.lua|12522|cd61c00facba80e7c0a350e69d2e8701b1e4e4baaac5a6dc26d949225f00747f
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
+2
View File
@@ -0,0 +1,2 @@
XTEINK-REPOSITORIES|1
Xteink Apps|https://gitea.va.reichard.io/evan/xteink-apps/raw/branch/main/catalog.txt
+14
View File
@@ -0,0 +1,14 @@
# HomeAssistant
Displays up to 12 Home Assistant entities as status cards.
After installation, edit `config.txt`:
```text
url=https://home-assistant.example.com
token=replace-with-a-long-lived-access-token
entity=weather.home
entity=light.living_room
```
Create a long-lived access token from your Home Assistant profile. Keep `config.txt` private; it contains credentials. The repository version contains placeholders only.
+3
View File
@@ -0,0 +1,3 @@
# Wordle
Guess a five-letter word in six tries. The bundled `words.txt` file contains the candidate answers used by the app.
+1
View File
@@ -1,3 +1,4 @@
XTEINK-CATALOG|1
AppStore|Install apps from public Xteink repositories|apps/AppStore/manifest.txt|192|71d3be6bdaec97b268bb9b7bf4fe553c23a74587251676fe88d90024fa8c703c
HomeAssistant|Home Assistant status cards|apps/HomeAssistant/manifest.txt|177|27aa1f0edc78a5aeded413caf0508788fa597ee8f76a5b8515ec0c40c955d5be
Wordle|Guess the five-letter word in six tries|apps/Wordle/manifest.txt|177|9977b49ffdbd67a254701b4fa38407fc9128a017673e3ce73560dea08a6a125e
+56 -8
View File
@@ -13,7 +13,14 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
APPS_DIR = ROOT / "apps"
ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$")
DESCRIPTION_PATTERN = re.compile(r"^--\s*DESCRIPTION:\s*(.+?)\s*$")
MAX_APPS = 64
MAX_FILES = 64
MAX_FILE_BYTES = 8 * 1024 * 1024
MAX_APP_BYTES = 16 * 1024 * 1024
MAX_MANIFEST_BYTES = 16384
MAX_CATALOG_BYTES = 32768
def sha256(data: bytes) -> str:
@@ -24,7 +31,7 @@ def payload_files(app_dir: Path):
for path in sorted(app_dir.rglob("*")):
if path.is_symlink():
raise ValueError(f"symlinks are not supported: {path.relative_to(ROOT)}")
if path.is_file() and path.name != "manifest.txt":
if path.is_file() and path.relative_to(app_dir).as_posix() not in {"manifest.txt", "README.md"}:
yield path
@@ -40,35 +47,76 @@ def description(app_dir: Path) -> str:
raise ValueError(f"missing DESCRIPTION in {app_dir.name}/main.lua")
def encode_lines(lines: list[str]) -> bytes:
for line in lines:
if len(line.encode()) > 192:
raise ValueError(f"generated line exceeds 192 bytes: {line[:40]}")
return ("\n".join(lines) + "\n").encode()
def build_manifest(app_dir: Path) -> bytes:
lines = ["XTEINK-MANIFEST|1"]
seen_files = set()
seen_directories = set()
total = 0
for path in payload_files(app_dir):
relative = path.relative_to(app_dir).as_posix()
if "|" in relative:
raise ValueError(f"path contains '|': {relative}")
lower = relative.lower()
if (not PATH_PATTERN.fullmatch(relative) or lower == ".manifest.txt" or
any(part in ("", ".", "..") for part in relative.split("/"))):
raise ValueError(f"unsupported app path: {relative}")
parent = ""
for component in lower.split("/"):
if parent:
if parent in seen_files:
raise ValueError(f"path collision: {relative}")
seen_directories.add(parent)
parent = component if not parent else f"{parent}/{component}"
if lower in seen_files or lower in seen_directories:
raise ValueError(f"path collision: {relative}")
data = path.read_bytes()
if not data or len(data) > MAX_FILE_BYTES:
raise ValueError(f"unsupported app file size: {relative}")
total += len(data)
if len(lines) > MAX_FILES or total > MAX_APP_BYTES:
raise ValueError(f"app exceeds package limits: {app_dir.name}")
seen_files.add(lower)
lines.append(f"{relative}|{len(data)}|{sha256(data)}")
return ("\n".join(lines) + "\n").encode()
manifest = encode_lines(lines)
if len(manifest) > MAX_MANIFEST_BYTES:
raise ValueError(f"manifest exceeds {MAX_MANIFEST_BYTES} bytes: {app_dir.name}")
return manifest
def generated_files():
apps = [path for path in APPS_DIR.iterdir() if path.is_dir() and (path / "main.lua").is_file()]
apps.sort(key=lambda path: path.name.casefold())
if len(apps) > MAX_APPS:
raise ValueError(f"catalog exceeds {MAX_APPS} apps")
manifests = {}
catalog = ["XTEINK-CATALOG|1"]
seen_ids = set()
for app_dir in apps:
app_id = app_dir.name
if not ID_PATTERN.fullmatch(app_id):
raise ValueError(f"invalid app id: {app_id}")
lower_id = app_id.lower()
if not ID_PATTERN.fullmatch(app_id) or len(app_id) > 48 or lower_id in seen_ids:
raise ValueError(f"invalid or duplicate app id: {app_id}")
seen_ids.add(lower_id)
app_description = description(app_dir)
if len(app_description.encode()) > 80:
raise ValueError(f"description exceeds 80 bytes: {app_id}")
manifest = build_manifest(app_dir)
manifest_path = f"apps/{app_id}/manifest.txt"
manifests[ROOT / manifest_path] = manifest
catalog.append(
f"{app_id}|{description(app_dir)}|{manifest_path}|{len(manifest)}|{sha256(manifest)}"
f"{app_id}|{app_description}|{manifest_path}|{len(manifest)}|{sha256(manifest)}"
)
manifests[ROOT / "catalog.txt"] = ("\n".join(catalog) + "\n").encode()
catalog_data = encode_lines(catalog)
if len(catalog_data) > MAX_CATALOG_BYTES:
raise ValueError(f"catalog exceeds {MAX_CATALOG_BYTES} bytes")
manifests[ROOT / "catalog.txt"] = catalog_data
return manifests