commit 4326878282a3a86ab0a3bad63082cd6962f6f770 Author: Evan Reichard Date: Sat Jul 25 22:13:35 2026 -0400 feat: add initial AppStore catalog diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/HomeAssistant/config.txt b/HomeAssistant/config.txt new file mode 100644 index 0000000..8d57072 --- /dev/null +++ b/HomeAssistant/config.txt @@ -0,0 +1,11 @@ +# Home Assistant connection +url=https://home-assistant.example.com +token=replace-with-a-long-lived-access-token + +# One card per entity, up to 12 +entity=weather.home +entity=climate.home +entity=person.example +entity=lock.front_door +entity=cover.garage_door +entity=light.living_room diff --git a/HomeAssistant/main.lua b/HomeAssistant/main.lua new file mode 100644 index 0000000..c633941 --- /dev/null +++ b/HomeAssistant/main.lua @@ -0,0 +1,184 @@ +-- DESCRIPTION: Home Assistant status cards + +local CONFIG_PATH = "/plugins/HomeAssistant/config.txt" +local config = { entities = {} } +local cards = {} +local screen = nil +local connecting = false +local connected = false +local needsDraw = true + +local function trim(value) + return value:match("^%s*(.-)%s*$") +end + +local function loadConfig() + local content = fs.readFile(CONFIG_PATH) + if not content then return "Missing " .. CONFIG_PATH end + + local lineNumber = 0 + for rawLine in (content:gsub("\r", "") .. "\n"):gmatch("(.-)\n") do + lineNumber = lineNumber + 1 + local line = trim(rawLine) + if line ~= "" and line:sub(1, 1) ~= "#" then + local key, value = line:match("^([%w_]+)%s*=%s*(.-)%s*$") + if not key or value == "" then return "Invalid config line " .. lineNumber end + + if key == "url" then + config.url = value:gsub("/+$", "") + elseif key == "token" then + config.token = value + elseif key == "entity" then + if not value:match("^[%w_]+%.[%w_]+$") then return "Invalid entity on line " .. lineNumber end + config.entities[#config.entities + 1] = value + else + return "Unknown config key: " .. key + end + end + end + + if not config.url then return "Config is missing url" end + if not config.token then return "Config is missing token" end + if #config.entities == 0 then return "Config has no entities" end + if #config.entities > 12 then return "Config supports up to 12 entities" end +end + +local function jsonString(value) + local escapes = { ["\\"] = "\\\\", ["\""] = "\\\"", ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" } + return '"' .. value:gsub('[\\"\n\r\t]', escapes) .. '"' +end + +local function templateRequest() + local lines = {} + for _, entity in ipairs(config.entities) do + lines[#lines + 1] = "{{ (state_attr('" .. entity .. "','friendly_name') or '" .. entity .. "') | replace('|','/') }}|" .. + entity .. "|{{ states('" .. entity .. "') | replace('|','/') }}|{{ (state_attr('" .. entity .. + "','unit_of_measurement') or '') | replace('|','/') }}" + end + return '{"template":' .. jsonString(table.concat(lines, "\n")) .. "}" +end + +local function titleCase(value) + return value:gsub("_", " "):gsub("(%a)([%w']*)", function(first, rest) + return first:upper() .. rest + end) +end + +local function parseCards(body) + local result = {} + for line in (body .. "\n"):gmatch("(.-)\n") do + local title, entity, value, unit = line:match("^([^|]*)|([^|]*)|([^|]*)|(.*)$") + if title then + result[#result + 1] = { + title = title, + entity = entity, + value = titleCase(value), + detail = unit + } + end + end + return result +end + +local function fetchCards() + screen = "Loading Home Assistant..." + needsDraw = true + + local body, status = net.post(config.url .. "/api/template", templateRequest(), { + Authorization = "Bearer " .. config.token, + ["Content-Type"] = "application/json" + }) + + if body and status == 200 then + cards = parseCards(body) + if #cards == #config.entities then + screen = nil + else + screen = "Invalid Home Assistant response" + end + else + screen = "Home Assistant error " .. tostring(status) + end + needsDraw = true +end + +local function drawCardText(font, x, y, width, text, style) + local textWidth = gui.getTextWidth(font, text, style) + gui.drawText(font, x + math.floor((width - textWidth) / 2), y, text, COLOR_BLACK, style) +end + +local function drawCard(card, x, y, width, height) + gui.drawRoundedRect(x, y, width, height, 2, 10, COLOR_BLACK) + gui.drawText(FONT_UI_10, x + 12, y + 10, card.title, COLOR_BLACK, STYLE_BOLD) + drawCardText(FONT_NOTOSANS_16, x, y + math.floor(height / 2) - 8, width, card.value, STYLE_BOLD) + if card.detail ~= "" then + drawCardText(FONT_SMALL, x, y + height - 28, width, card.detail, STYLE_REGULAR) + end +end + +local function render() + local width, height = gui.width(), gui.height() + gui.clear() + gui.drawText(FONT_NOTOSANS_16, 20, 18, "Home Assistant", COLOR_BLACK, STYLE_BOLD) + gui.drawLine(20, 58, width - 20, 58, 2, COLOR_BLACK) + + if screen then + gui.drawCenteredText(FONT_UI_12, math.floor(height / 2), screen, COLOR_BLACK, STYLE_REGULAR) + else + local columns = width >= 700 and 4 or 2 + local rows = math.ceil(#cards / columns) + local gap = 10 + local margin = 16 + local top = 72 + local bottom = 58 + local cardWidth = math.floor((width - margin * 2 - gap * (columns - 1)) / columns) + local cardHeight = math.floor((height - top - bottom - gap * (rows - 1)) / rows) + + for index, card in ipairs(cards) do + local column = (index - 1) % columns + local row = math.floor((index - 1) / columns) + drawCard(card, margin + column * (cardWidth + gap), top + row * (cardHeight + gap), cardWidth, cardHeight) + end + end + + gui.drawButtonHints("<<", connected and "Refresh" or "", "", "") + gui.refresh(REFRESH_HALF) + needsDraw = false +end + +function init() + local configError = loadConfig() + if configError then + screen = configError + return + end + + screen = "Connecting to Wi-Fi..." + connecting = true + net.wifiConnect() +end + +function draw() + if input.wasPressed("back") then + if connecting or connected then net.wifiDisconnect() end + sys.exit() + return + end + + if connecting then + local wifi = net.wifiStatus() + if wifi == "connected" then + connecting = false + connected = true + fetchCards() + elseif wifi == "failed" then + connecting = false + screen = "Wi-Fi connection failed" + needsDraw = true + end + elseif connected and input.wasPressed("confirm") then + fetchCards() + end + + if needsDraw then render() end +end diff --git a/HomeAssistant/manifest.txt b/HomeAssistant/manifest.txt new file mode 100644 index 0000000..ad6459d --- /dev/null +++ b/HomeAssistant/manifest.txt @@ -0,0 +1,3 @@ +XTEINK-MANIFEST|1 +config.txt|280|db80ede9c74ccd83b76f1bb64ca7193cc6477de905bad6d153730ec66768bfa7 +main.lua|6144|d7f0b42895c3fb5fef02c8932019276f97392e79a8b9db538165aaa2699de4c3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c0ceaba --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Xteink Apps + +App repository for CrossPoint Reader's Lua AppStore. + +Catalog URL: + +```text +https://gitea.va.reichard.io/evan/xteink-apps/raw/branch/main/catalog.txt +``` + +## Layout + +```text +catalog.txt +/ + manifest.txt + main.lua + ... +scripts/update-manifests.py +``` + +`catalog.txt` is line-oriented: + +```text +XTEINK-CATALOG|1 +|||| +``` + +Each generated `manifest.txt` contains every installable file in that app: + +```text +XTEINK-MANIFEST|1 +|| +``` + +App IDs and file paths cannot contain `|`. App IDs use only letters, numbers, `_`, and `-`. Descriptions come from the `-- DESCRIPTION:` line in each `main.lua`. + +## Adding or changing an app + +1. Add or edit an app directory containing `main.lua`. +2. Regenerate metadata: + + ```bash + scripts/update-manifests.py + ``` + +3. Verify generated files are current: + + ```bash + 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`. diff --git a/Wordle/main.lua b/Wordle/main.lua new file mode 100644 index 0000000..fe6b3e7 --- /dev/null +++ b/Wordle/main.lua @@ -0,0 +1,249 @@ +-- DESCRIPTION: Guess the five-letter word in six tries + +local WORDS_PATH = "/plugins/Wordle/words.txt" +local WORD_LENGTH = 5 +local MAX_GUESSES = 6 + +local keyboard = { + { "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P" }, + { "A", "S", "D", "F", "G", "H", "J", "K", "L" }, + { "OK", "Z", "X", "C", "V", "B", "N", "M", "DEL" } +} + +local answer = nil +local current = "" +local guesses = {} +local results = {} +local keyStates = {} +local cursorRow = 1 +local cursorColumn = 1 +local message = "" +local gameOver = false +local firstDraw = true +local needsDraw = true + +-- Word Source - Replace only this function to fetch answers from another source. +local function getRandomWord() + local size = fs.fileSize(WORDS_PATH) + if not size then return nil, "Missing " .. WORDS_PATH end + if size == 0 then return nil, "Word list is empty" end + + for _ = 1, 8 do + local word = fs.readLineAt(WORDS_PATH, math.random(0, size - 1)) or fs.readLineAt(WORDS_PATH, 0) + if word then + word = word:match("^%s*(.-)%s*$") + if #word == WORD_LENGTH and word:match("^%a+$") then return word:upper() end + end + end + + return nil, "Word list has no five-letter words" +end + +local function startGame() + local errorMessage + answer, errorMessage = getRandomWord() + current = "" + guesses = {} + results = {} + keyStates = {} + cursorRow = 1 + cursorColumn = 1 + gameOver = not answer + message = errorMessage or "Pick a letter" + firstDraw = true + needsDraw = true +end + +local function evaluateGuess(guess) + local status = {} + local remaining = {} + + for index = 1, WORD_LENGTH do + local letter = answer:sub(index, index) + if guess:sub(index, index) == letter then + status[index] = 2 + else + remaining[letter] = (remaining[letter] or 0) + 1 + end + end + + for index = 1, WORD_LENGTH do + if not status[index] then + local letter = guess:sub(index, index) + if (remaining[letter] or 0) > 0 then + status[index] = 1 + remaining[letter] = remaining[letter] - 1 + else + status[index] = 0 + end + end + end + + return status +end + +local function submitGuess() + if #current ~= WORD_LENGTH then + message = "Enter five letters" + return + end + + local status = evaluateGuess(current) + guesses[#guesses + 1] = current + results[#results + 1] = status + + for index = 1, WORD_LENGTH do + local letter = current:sub(index, index) + keyStates[letter] = math.max(keyStates[letter] or 0, status[index]) + end + + if current == answer then + message = "Solved! Press OK for a new word" + gameOver = true + elseif #guesses == MAX_GUESSES then + message = answer .. " - Press OK for a new word" + gameOver = true + else + message = tostring(MAX_GUESSES - #guesses) .. " guesses left" + end + current = "" +end + +local function useKey(key) + if key == "OK" then + submitGuess() + elseif key == "DEL" then + current = current:sub(1, -2) + message = "Pick a letter" + elseif #current < WORD_LENGTH then + current = current .. key + message = "Pick a letter" + end + needsDraw = true +end + +local function centeredText(font, x, y, width, text, color, style) + local textWidth = gui.getTextWidth(font, text, style) + gui.drawText(font, x + math.floor((width - textWidth) / 2), y, text, color, style) +end + +local function drawTile(letter, status, x, y, size) + local fill = COLOR_WHITE + local textColor = COLOR_BLACK + + if status == 2 then + fill = COLOR_BLACK + textColor = COLOR_WHITE + elseif status == 1 then + fill = COLOR_LIGHT_GRAY + elseif status == 0 then + fill = COLOR_DARK_GRAY + textColor = COLOR_WHITE + end + + if status ~= nil then gui.fillRoundedRect(x, y, size, size, 5, fill) end + gui.drawRoundedRect(x, y, size, size, 2, 5, COLOR_BLACK) + if letter ~= "" then + centeredText(FONT_NOTOSANS_16, x, y + math.floor(size / 2) - 10, size, letter, textColor, STYLE_BOLD) + end +end + +local function drawGrid(width, height) + local gap = 5 + local size = math.min(62, math.floor((width - 60 - gap * (WORD_LENGTH - 1)) / WORD_LENGTH), + math.floor((height * 0.53 - gap * (MAX_GUESSES - 1)) / MAX_GUESSES)) + local gridWidth = size * WORD_LENGTH + gap * (WORD_LENGTH - 1) + local startX = math.floor((width - gridWidth) / 2) + local startY = 72 + + for row = 1, MAX_GUESSES do + local word = guesses[row] or (row == #guesses + 1 and current or "") + local status = results[row] + for column = 1, WORD_LENGTH do + drawTile(word:sub(column, column), status and status[column] or nil, + startX + (column - 1) * (size + gap), startY + (row - 1) * (size + gap), size) + end + end + + return startY + MAX_GUESSES * size + (MAX_GUESSES - 1) * gap +end + +local function drawKeyboard(width, height, top) + local gap = 4 + local margin = 12 + local keyWidth = math.floor((width - margin * 2 - gap * 9) / 10) + local keyHeight = math.min(48, math.floor((height - top - 70 - gap * 2) / 3)) + + for rowIndex, row in ipairs(keyboard) do + local rowWidth = #row * keyWidth + (#row - 1) * gap + local startX = math.floor((width - rowWidth) / 2) + local y = top + (rowIndex - 1) * (keyHeight + gap) + + for columnIndex, key in ipairs(row) do + local x = startX + (columnIndex - 1) * (keyWidth + gap) + local status = keyStates[key] + local fill = status == 2 and COLOR_BLACK or status == 1 and COLOR_LIGHT_GRAY or + status == 0 and COLOR_DARK_GRAY or COLOR_WHITE + local textColor = (status == 2 or status == 0) and COLOR_WHITE or COLOR_BLACK + + gui.fillRoundedRect(x, y, keyWidth, keyHeight, 4, fill) + gui.drawRoundedRect(x, y, keyWidth, keyHeight, 1, 4, COLOR_BLACK) + if rowIndex == cursorRow and columnIndex == cursorColumn then + gui.drawRoundedRect(x + 2, y + 2, keyWidth - 4, keyHeight - 4, 3, 3, textColor) + end + centeredText(FONT_SMALL, x, y + math.floor(keyHeight / 2) - 7, keyWidth, key, textColor, STYLE_BOLD) + end + end +end + +local function render() + local width, height = gui.width(), gui.height() + gui.clear() + gui.drawCenteredText(FONT_NOTOSANS_16, 16, "WORDLE", COLOR_BLACK, STYLE_BOLD) + gui.drawCenteredText(FONT_SMALL, 48, message, COLOR_BLACK, STYLE_REGULAR) + + if answer then + local gridBottom = drawGrid(width, height) + drawKeyboard(width, height, gridBottom + 14) + end + + gui.drawButtonHints("<<", "Select", "<", ">") + gui.refresh(firstDraw and REFRESH_HALF or REFRESH_FAST) + firstDraw = false + needsDraw = false +end + +function init() + startGame() +end + +function draw() + if input.wasPressed("back") then + sys.exit() + return + end + + if gameOver and answer then + if input.wasPressed("confirm") then startGame() end + elseif answer then + if input.wasPressed("left") then + cursorColumn = cursorColumn == 1 and #keyboard[cursorRow] or cursorColumn - 1 + needsDraw = true + elseif input.wasPressed("right") then + cursorColumn = cursorColumn == #keyboard[cursorRow] and 1 or cursorColumn + 1 + needsDraw = true + elseif input.wasPressed("up") then + cursorRow = cursorRow == 1 and #keyboard or cursorRow - 1 + cursorColumn = math.min(cursorColumn, #keyboard[cursorRow]) + needsDraw = true + elseif input.wasPressed("down") then + cursorRow = cursorRow == #keyboard and 1 or cursorRow + 1 + cursorColumn = math.min(cursorColumn, #keyboard[cursorRow]) + needsDraw = true + elseif input.wasPressed("confirm") then + useKey(keyboard[cursorRow][cursorColumn]) + end + end + + if needsDraw then render() end +end diff --git a/Wordle/manifest.txt b/Wordle/manifest.txt new file mode 100644 index 0000000..3c54b93 --- /dev/null +++ b/Wordle/manifest.txt @@ -0,0 +1,3 @@ +XTEINK-MANIFEST|1 +main.lua|8027|2337650ccbbce8b7f2a75c8025e3fe70ee794c81e9af956d9be94f2126e7cc90 +words.txt|2814|057281f29400005492e9c891aeda0fbe9a3d3e6060304b71e7083ac02d9b3692 diff --git a/Wordle/words.txt b/Wordle/words.txt new file mode 100644 index 0000000..31e10d2 --- /dev/null +++ b/Wordle/words.txt @@ -0,0 +1,469 @@ +about +above +abuse +actor +acute +admit +adopt +adult +after +again +agent +agree +ahead +alarm +album +alert +alien +align +alike +alive +allow +alone +along +alter +among +angel +anger +angle +angry +apart +apple +apply +arena +argue +arise +array +aside +asset +audio +audit +avoid +award +aware +badly +baker +bases +basic +beach +began +begin +being +below +bench +birth +black +blame +blind +block +blood +board +brain +brand +bread +break +breed +brief +bring +broad +broke +brown +build +buyer +cable +carry +catch +cause +chain +chair +chart +chase +cheap +check +chest +chief +child +china +chose +civil +claim +class +clean +clear +clerk +click +clock +close +coach +coast +could +count +court +cover +craft +crash +cream +crime +cross +crowd +crown +curve +cycle +daily +dance +dealt +death +debut +delay +depth +doing +doubt +dozen +draft +drama +drawn +dream +dress +drill +drink +drive +drove +dying +eager +early +earth +eight +elite +empty +enemy +enjoy +enter +entry +equal +error +event +every +exact +exist +extra +faith +false +fault +fiber +field +fifth +fifty +fight +final +first +fixed +flash +fleet +floor +fluid +focus +force +forth +forty +forum +found +frame +frank +fraud +fresh +front +fruit +fully +funny +giant +given +glass +globe +going +grace +grade +grand +grant +grass +great +green +gross +group +grown +guard +guess +guest +guide +happy +heart +heavy +hence +horse +hotel +house +human +ideal +image +index +inner +input +issue +joint +judge +known +label +large +laser +later +laugh +layer +learn +lease +least +leave +legal +level +light +limit +local +logic +loose +lower +lucky +lunch +major +maker +march +match +maybe +mayor +media +metal +might +minor +model +money +month +moral +motor +mount +mouse +mouth +movie +music +needs +never +newly +night +noise +north +noted +novel +nurse +occur +ocean +offer +often +order +other +ought +paint +panel +paper +party +peace +phase +phone +photo +piece +pilot +pitch +place +plain +plane +plant +plate +point +pound +power +press +price +pride +prime +print +prior +prize +proof +proud +prove +queen +quick +quiet +quite +radio +raise +range +rapid +ratio +reach +ready +refer +right +rival +river +robot +rough +round +route +royal +rural +scale +scene +scope +score +sense +serve +seven +shall +shape +share +sharp +sheet +shelf +shell +shift +shirt +shock +shoot +short +shown +sight +since +sixth +sixty +sized +skill +sleep +slide +small +smart +smile +solid +solve +sorry +sound +south +space +spare +speak +speed +spend +spent +split +spoke +sport +staff +stage +stake +stand +start +state +steam +steel +stick +still +stock +stone +stood +store +storm +story +strip +study +stuff +style +sugar +suite +super +sweet +table +taken +taste +taxes +teach +teeth +thank +their +theme +there +these +thick +thing +think +third +those +three +throw +tight +times +tired +title +today +topic +total +touch +tough +tower +track +trade +train +treat +trend +trial +tried +tries +truck +truly +trust +truth +twice +under +union +unity +until +upper +upset +urban +usage +usual +valid +value +video +virus +visit +vital +voice +waste +watch +water +wheel +where +which +while +white +whole +whose +woman +women +world +worry +worse +worst +worth +would +write +wrong +wrote +yield +young +youth diff --git a/catalog.txt b/catalog.txt new file mode 100644 index 0000000..cc3870d --- /dev/null +++ b/catalog.txt @@ -0,0 +1,3 @@ +XTEINK-CATALOG|1 +HomeAssistant|Home Assistant status cards|HomeAssistant/manifest.txt|177|27aa1f0edc78a5aeded413caf0508788fa597ee8f76a5b8515ec0c40c955d5be +Wordle|Guess the five-letter word in six tries|Wordle/manifest.txt|177|9977b49ffdbd67a254701b4fa38407fc9128a017673e3ce73560dea08a6a125e diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..076045e --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784796856, + "narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e2587caef70cea85dd97d7daab492899902dbf5d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..342a083 --- /dev/null +++ b/flake.nix @@ -0,0 +1,15 @@ +{ + description = "Xteink AppStore repository"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: { + devShells.default = nixpkgs.legacyPackages.${system}.mkShell { + packages = with nixpkgs.legacyPackages.${system}; [ git uv ]; + }; + }); +} diff --git a/scripts/update-manifests.py b/scripts/update-manifests.py new file mode 100755 index 0000000..d21cfd6 --- /dev/null +++ b/scripts/update-manifests.py @@ -0,0 +1,101 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// + +import argparse +import hashlib +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") +DESCRIPTION_PATTERN = re.compile(r"^--\s*DESCRIPTION:\s*(.+?)\s*$") + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +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": + yield path + + +def description(app_dir: Path) -> str: + with (app_dir / "main.lua").open(encoding="utf-8") as source: + for line in source: + match = DESCRIPTION_PATTERN.match(line) + if match: + value = match.group(1) + if "|" in value: + raise ValueError(f"description contains '|': {app_dir.name}") + return value + raise ValueError(f"missing DESCRIPTION in {app_dir.name}/main.lua") + + +def build_manifest(app_dir: Path) -> bytes: + lines = ["XTEINK-MANIFEST|1"] + for path in payload_files(app_dir): + relative = path.relative_to(app_dir).as_posix() + if "|" in relative: + raise ValueError(f"path contains '|': {relative}") + data = path.read_bytes() + lines.append(f"{relative}|{len(data)}|{sha256(data)}") + return ("\n".join(lines) + "\n").encode() + + +def generated_files(): + apps = [path for path in ROOT.iterdir() if path.is_dir() and (path / "main.lua").is_file()] + apps.sort(key=lambda path: path.name.casefold()) + + manifests = {} + catalog = ["XTEINK-CATALOG|1"] + 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}") + manifest = build_manifest(app_dir) + manifest_path = f"{app_id}/manifest.txt" + manifests[ROOT / manifest_path] = manifest + catalog.append( + f"{app_id}|{description(app_dir)}|{manifest_path}|{len(manifest)}|{sha256(manifest)}" + ) + + manifests[ROOT / "catalog.txt"] = ("\n".join(catalog) + "\n").encode() + return manifests + + +def main() -> int: + parser = argparse.ArgumentParser(description="Regenerate app manifests and catalog.txt") + parser.add_argument("--check", action="store_true", help="fail instead of updating stale files") + args = parser.parse_args() + + stale = [] + for path, expected in generated_files().items(): + if not path.exists() or path.read_bytes() != expected: + stale.append(path) + if not args.check: + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_bytes(expected) + temporary.replace(path) + print(f"updated {path.relative_to(ROOT)}") + + if args.check and stale: + for path in stale: + print(f"stale: {path.relative_to(ROOT)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ValueError as error: + print(error, file=sys.stderr) + raise SystemExit(1)