feat: add initial AppStore catalog
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
XTEINK-MANIFEST|1
|
||||||
|
config.txt|280|db80ede9c74ccd83b76f1bb64ca7193cc6477de905bad6d153730ec66768bfa7
|
||||||
|
main.lua|6144|d7f0b42895c3fb5fef02c8932019276f97392e79a8b9db538165aaa2699de4c3
|
||||||
@@ -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
|
||||||
|
<AppId>/
|
||||||
|
manifest.txt
|
||||||
|
main.lua
|
||||||
|
...
|
||||||
|
scripts/update-manifests.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`catalog.txt` is line-oriented:
|
||||||
|
|
||||||
|
```text
|
||||||
|
XTEINK-CATALOG|1
|
||||||
|
<AppId>|<description>|<manifest path>|<manifest bytes>|<manifest SHA-256>
|
||||||
|
```
|
||||||
|
|
||||||
|
Each generated `manifest.txt` contains every installable file in that app:
|
||||||
|
|
||||||
|
```text
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
+249
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
XTEINK-MANIFEST|1
|
||||||
|
main.lua|8027|2337650ccbbce8b7f2a75c8025e3fe70ee794c81e9af956d9be94f2126e7cc90
|
||||||
|
words.txt|2814|057281f29400005492e9c891aeda0fbe9a3d3e6060304b71e7083ac02d9b3692
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+61
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Executable
+101
@@ -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)
|
||||||
Reference in New Issue
Block a user