feat: add Lua AppStore
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
XTEINK-MANIFEST|1
|
||||
main.lua|12522|cd61c00facba80e7c0a350e69d2e8701b1e4e4baaac5a6dc26d949225f00747f
|
||||
repositories.default.txt|108|70e442650788dd8d8632142ef3ff43a4aaee9855da89283facc72a8942e0a7df
|
||||
@@ -0,0 +1,2 @@
|
||||
XTEINK-REPOSITORIES|1
|
||||
Xteink Apps|https://gitea.va.reichard.io/evan/xteink-apps/raw/branch/main/catalog.txt
|
||||
@@ -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.
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user