Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/firmware.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ jobs:

- name: Run Python tooling selftests
run: |
python -m py_compile scripts/check_tdeck_budget.py scripts/fetch_tdeck_artifact.py scripts/serial_harness.py scripts/tdeck_smoke.py scripts/tdm_airtime_smoke.py
python -m py_compile scripts/build_app_package.py scripts/check_tdeck_budget.py scripts/fetch_tdeck_artifact.py scripts/serial_harness.py scripts/tdeck_smoke.py scripts/tdm_airtime_smoke.py
python scripts/tdm_airtime_smoke.py --selftest
python scripts/build_app_package.py examples/local-apps/weather-mesh --out /tmp/weather.mesh.zip --device-path /sd/limitlezz/packages/weather.mesh.zip
- name: Validate local app samples
run: |
python -m py_compile scripts/validate_local_app_samples.py
Expand Down
25 changes: 22 additions & 3 deletions docs/tdeck-app-catalog-schema.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# T-Deck App Catalog Schema

This is the first Network App Store increment. It defines and validates the
future `index.json` shape, but it does not fetch, download, install, update, or
uninstall packages yet.
future `index.json` shape. Catalog URL refresh and App Store UI install buttons
remain separate work, but the lower-level package transaction can already
install a verified package file that is present on SD/appfs.

Catalog indexes are expected at:

Expand Down Expand Up @@ -52,14 +53,32 @@ Validation rules:
- `hue`, if present, must be `-1` or `0..359`.
- The catalog can list up to `24` apps.

Package archives:

- The first firmware-native archive path is a `.zip` using ZIP method `0`
only, meaning stored/uncompressed entries.
- Whole-package `sha256` and exact byte size must match before extraction.
- The package must include root `manifest.json`; the embedded manifest `id`
must match the requested install id.
- File names must be relative, must not contain `..`, backslashes, colons,
absolute roots, hidden path segments, or a top-level `data/` tree.
- Each file is capped at `256 KB`, each package at `2 MB`, and each package at
`24` files.
- Extraction happens into a hidden staging directory, then promotion validates
the manifest and rolls back on failure.

Serial diagnostics:

```text
app catalog status
app catalog test
app package test
app package install <id> <path> <sha256> <bytes>
```

`app catalog status` validates a cached index if one exists and otherwise
reports that no cached catalog is present. `app catalog test` runs a built-in
valid/invalid schema selftest so hardware smoke can prove the parser without
requiring Wi-Fi or SD setup.
requiring Wi-Fi or SD setup. `app package test` creates and installs a small
stored-ZIP package on-device and proves hash mismatch, id mismatch, unsafe path,
unsupported compression, rollback, and update behavior.
20 changes: 19 additions & 1 deletion docs/tdeck-app-developer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Not implemented yet:
- arbitrary Lua/script execution
- background tasks
- network catalog download/update
- app package signatures or SHA verification
- app package signatures
- mesh send/receive APIs
- notifications API behavior
- raw hardware, radio, filesystem, or kernel access
Expand Down Expand Up @@ -61,6 +61,23 @@ data/ optional; prepared automatically for storage apps
assets/ optional; reserved for later richer runtimes
```

Installable archive packages use a stored-only `.zip` subset for the first
firmware installer:

```sh
python scripts/build_app_package.py examples/local-apps/weather-mesh \
--out weather.mesh.zip --device-path /sd/limitlezz/packages/weather.mesh.zip
```

The script prints the exact package byte count and SHA256 digest for:

```text
app package install <id> <path> <sha256> <bytes>
```

Archives must include root `manifest.json`, use relative file paths only, avoid
hidden path segments and top-level `data/`, and store entries uncompressed.

## Minimal App

Create a package directory:
Expand Down Expand Up @@ -259,6 +276,7 @@ The native selftest creates sample local app packages and checks:
- over-quota blocking
- token permission gating
- appfs-only discovery
- stored-ZIP package install/update rollback

For release or PR validation, use the project workflow:

Expand Down
1 change: 1 addition & 0 deletions docs/tdeck-feature-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Status labels:
| Network app catalog | Partial | `docs/tdeck-network-app-catalog.md`, `docs/examples/app-catalog-index.json`, and `scripts/validate_app_catalog.py` define and CI-validate the first HTTPS `index.json` contract with SDK compatibility, permissions, package hash/size, and screenshots | Fetch `index.json` over Wi-Fi, verify TLS/metadata on-device, and cache results. |
| App download/install/update | Planned | App Store prototype only | SHA256 verify, extract, version updates, rollback failed installs. |
| App download/install/update | Planned/Partial | App Store prototype plus bounded package-file SHA256 helper | Wire the verifier into download/staging, then extract, version updates, and rollback failed installs. |
| App download/install/update | Partial | Stored-only ZIP package installer verifies exact SHA256/byte count, extracts into hidden staging, validates embedded manifest id, promotes atomically, and exposes serial `app package test` / `app package install`; native selftest covers rollback for hash mismatch, id mismatch, unsafe path, unsupported compression, and update | Wire catalog download/cache entries and App Store GET/UPDATE actions to the package installer. |
| Optional map app | Planned | Store data includes maps; maintainer notes prefer maps as optional | Keep maps out of the base firmware. |
| APRS/weather/BBS/scope/game apps | Planned/Prototype catalog entries | Static `LZ_STORE` rows | Implement as sandboxed apps once runtime exists. |

Expand Down
11 changes: 9 additions & 2 deletions docs/tdeck-firmware-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -374,12 +374,19 @@ Deliverables:
catalog fetch transport gated on connected Wi-Fi, with native simulator stub
coverage for URL/buffer errors.
- Cache catalog for offline browsing.
- Download app zip/package.
- Download app zip/package. Initial package transaction: firmware can install a
package file that is already present on SD/appfs; catalog-driven Wi-Fi
download and App Store button wiring remain TODO.
- Verify SHA256 before install. Initial foundation: reusable package-file SHA256
hashing and expected-hash verification helpers with native simulator coverage.
Package install now requires exact expected hash and byte count before any
extraction.
- Extract to app staging directory, then atomically promote to installed directory.
Initial foundation: hidden staging directories, manifest-id validation, live
app backup/rollback promotion, and staging discard helpers.
app backup/rollback promotion, and staging discard helpers. Package install
now extracts stored-only ZIP entries into staging, rejects unsafe paths and
unsupported compression, validates the embedded manifest before promotion, and
selftests rollback/update behavior through serial `app package test`.
- Show update badges on installed apps.
- Show update badges on installed apps. Initial implementation: local App Store
rows compare manifest versions with catalog metadata and display update chips
Expand Down
20 changes: 14 additions & 6 deletions docs/tdeck-network-app-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,20 @@ field, but when present it must be a compact version string such as `0.95.0`.

## Package Rules

The package URL must be HTTPS. The future installer should download to a staging
directory, verify `package_sha256` and `package_bytes`, extract the package,
validate its embedded `manifest.json` with the local manifest rules, and then
atomically promote the staged package into the app directory. Any failed
download, hash mismatch, manifest rejection, or extraction error must leave the
previous installed package intact.
The package URL must be HTTPS. The lower-level firmware installer accepts a
package file that has already been downloaded or copied to SD/appfs, verifies
`package_sha256` and `package_bytes`, extracts a stored-only `.zip` package into
a hidden staging directory, validates its embedded `manifest.json` with the
local manifest rules, and then atomically promotes the staged package into the
app directory. Any hash mismatch, manifest rejection, unsupported compression,
unsafe path, or extraction error leaves the previous installed package intact.

The first firmware package subset deliberately avoids a decompressor: ZIP
method `0` is accepted, and compressed ZIP entries are rejected until flash/RAM
budget work says otherwise. Packages may contain up to 24 files, each file may
be up to 256 KB, and the complete archive must fit in the catalog's 2 MB
package limit. Packages must not ship a top-level `data/` tree; runtime data is
prepared by the OS for apps that request the `storage` permission.

## Validation

Expand Down
110 changes: 110 additions & 0 deletions scripts/build_app_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Build a LimitlezzOS app package as a deterministic ZIP_STORED archive."""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import sys
import zipfile
from pathlib import Path


PACKAGE_MAX_BYTES = 2 * 1024 * 1024
FILE_MAX_BYTES = 256 * 1024
SAFE_ID = re.compile(r"^[A-Za-z0-9_.-]{1,23}$")


def fail(message: str) -> None:
raise SystemExit(f"[package] {message}")


def safe_package_path(rel: str) -> bool:
if not rel or rel.startswith("/") or "\\" in rel or ":" in rel:
return False
parts = rel.split("/")
if any(part in {"", ".", ".."} or part.startswith(".") for part in parts):
return False
if parts[0] == "data":
return False
return True


def collect_files(root: Path) -> list[Path]:
files = sorted(path for path in root.rglob("*") if path.is_file())
if not (root / "manifest.json").is_file():
fail("manifest.json is required at the package root")
if len(files) > 24:
fail("too many files; firmware accepts at most 24")
for path in files:
rel = path.relative_to(root).as_posix()
if not safe_package_path(rel):
fail(f"unsafe package path: {rel}")
if path.stat().st_size > FILE_MAX_BYTES:
fail(f"file too large for firmware package: {rel}")
return files


def read_manifest(root: Path) -> dict:
try:
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
fail(f"manifest.json is not valid JSON: {exc}")
app_id = manifest.get("id")
if not isinstance(app_id, str) or not SAFE_ID.fullmatch(app_id):
fail("manifest id is missing or unsafe")
return manifest


def build_package(root: Path, out: Path, files: list[Path]) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_STORED) as zf:
for path in files:
rel = path.relative_to(root).as_posix()
info = zipfile.ZipInfo(rel, date_time=(2026, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_STORED
info.external_attr = 0o100644 << 16
zf.writestr(info, path.read_bytes())
if out.stat().st_size > PACKAGE_MAX_BYTES:
out.unlink(missing_ok=True)
fail("package exceeds firmware 2 MB limit")


def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("app_dir", type=Path, help="directory containing manifest.json")
parser.add_argument("-o", "--out", type=Path, help="output .zip path")
parser.add_argument("--device-path", help="path the package will have on the T-Deck")
args = parser.parse_args()

root = args.app_dir.resolve()
if not root.is_dir():
fail(f"not a directory: {root}")
manifest = read_manifest(root)
app_id = manifest["id"]
out = args.out or (Path.cwd() / f"{app_id}.zip")
files = collect_files(root)
build_package(root, out, files)

digest = sha256(out)
size = out.stat().st_size
install_path = args.device_path or out.as_posix()
print(f"package={out}")
print(f"id={app_id}")
print(f"bytes={size}")
print(f"sha256={digest}")
print(f"serial=app package install {app_id} {install_path} {digest} {size}")
return 0


if __name__ == "__main__":
sys.exit(main())
12 changes: 6 additions & 6 deletions scripts/tdeck_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,16 @@ def nostub_upload(project_dir: Path, env_name: str, port: str, baud: int, artifa
"--baud",
str(baud),
"--before",
"default-reset",
"default_reset",
"--after",
"hard-reset",
"hard_reset",
"--no-stub",
"write-flash",
"--flash-mode",
"write_flash",
"--flash_mode",
"dio",
"--flash-freq",
"--flash_freq",
"80m",
"--flash-size",
"--flash_size",
"16MB",
"0x0",
str(bootloader),
Expand Down
15 changes: 15 additions & 0 deletions sim/main_sim.c
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,21 @@ static int codec_selftest(void)
CHECK(lz_store_discard_app_install("weather.mesh", err, sizeof err),
"app install staging discard succeeds");
}
/* 10b. app package installer: a stored-ZIP package verifies size/hash,
* extracts into staging, promotes atomically, and rolls back on bad
* packages without replacing the live app. */
{
extern void lz_store_init(const char *datadir);
extern int lz_store_app_package_selftest(char *buf, int n);
sim_reset_dir("lzdata_apppackage");
lz_store_init("lzdata_apppackage");
char pkg[180];
lz_store_app_package_selftest(pkg, sizeof pkg);
CHECK(strstr(pkg, "PASS") != NULL,
"app package stored-ZIP transaction selftest");
lz_store_init(NULL);
sim_reset_dir("lzdata_apppackage");
}
/* 10. local app uninstall: users can delete a package while either
* retaining scoped data for reinstall or deleting everything. */
{
Expand Down
31 changes: 30 additions & 1 deletion src/serial_cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#ifdef LZ_TARGET_TDECK

#include <Arduino.h>
#include <string.h>
#include "services/emergency_guard.h"
#include "services/mesh.h"
#include "services/feedback.h"
Expand Down Expand Up @@ -77,6 +78,7 @@ static void cmd_help(void)
" feedback status|test feedback/app-notification diagnostics\n"
" app notify test request a test app notification\n"
" app catalog status|test app catalog schema diagnostics\n"
" app package test|install <id> <path> <sha256> <bytes>\n"
" touch [cal|debug|S X Y] touch: 'cal' runs on-screen calibration, 'debug' logs taps, 'S X Y' sets transform\n"
" feedback show DND/priority feedback policy\n"
" emergency [arm|confirm|cancel] diagnostic emergency trigger guard\n"
Expand Down Expand Up @@ -464,14 +466,41 @@ static void cmd_app(char *args)
Serial.print(b);
return;
}
if(args && strcmp(args, "package test") == 0) {
char b[180];
lz_svc_app_package_selftest(b, sizeof b);
Serial.print(b);
return;
}
if(args && strncmp(args, "package install ", 16) == 0) {
char id[24] = {0}, path[80] = {0}, sha[65] = {0};
unsigned long bytes = 0;
if(sscanf(args + 16, "%23s %79s %64s %lu", id, path, sha, &bytes) != 4 ||
bytes == 0 || bytes > LZ_APP_PACKAGE_MAX_BYTES) {
Serial.println("usage: app package install <id> <path> <sha256> <bytes>");
return;
}
lz_app_package_install_t r;
memset(&r, 0, sizeof r);
if(lz_svc_install_app_package(id, path, sha, (uint32_t)bytes, &r)) {
Serial.printf("[ok] app package install id=%s version=%s files=%u package=%lu extracted=%lu\n",
r.id, r.version, (unsigned)r.file_count,
(unsigned long)r.package_bytes,
(unsigned long)r.extracted_bytes);
} else {
Serial.printf("[err] app package install: %s\n",
r.error[0] ? r.error : "install failed");
}
return;
}
if(!args || !args[0] || strcmp(args, "catalog") == 0 ||
strcmp(args, "catalog status") == 0) {
char b[180];
lz_svc_app_catalog_diag(b, sizeof b);
Serial.print(b);
return;
}
Serial.println("usage: app notify test | app catalog status | app catalog test");
Serial.println("usage: app notify test | app catalog status | app catalog test | app package test");
}

static void cmd_nodes(char *args)
Expand Down
Loading