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