diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index a187b3b..8730164 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -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 diff --git a/README.md b/README.md index bfcf95d..4417f44 100644 --- a/README.md +++ b/README.md @@ -142,24 +142,18 @@ iPhone-style dark look (status bar, battery glyph, grouped settings cards). terminates on exit. Storage-enabled actions can increment a safe counter in the app's scoped `data/` directory, unsupported action effects fail closed, and apps with matching permissions can use read-only `{time}` / `{battery}` - tokens in foreground text. Loaded entry source plus app-controlled foreground - metadata are charged against a 704-byte resident runtime budget. SDK - apps with matching permissions can use read-only `{time}` / `{battery}` - tokens in foreground text, and apps with `notifications` can request a - feedback-service notification through a bounded `notify:` action effect. SDK + tokens in foreground text. Apps with `notifications` can request a + feedback-service notification through a bounded `notify:` action effect. + Loaded entry source plus app-controlled foreground metadata are charged + against a 704-byte resident runtime budget. SDK `api_version` and permission metadata are parsed fail-closed, with rejected package diagnostics visible in Developer Mode. Apps that request `storage` get a scoped package `data/` directory prepared with a 64 KB launch-time quota guard, and the App Store detail screen can clear only that app's scoped data. - Script execution, richer API injection, downloads, and updates are still TODO. - tokens in foreground text. SDK `api_version` and permission metadata are - parsed fail-closed, with rejected package diagnostics visible in Developer - Mode. Apps that request `storage` get a scoped package `data/` directory - prepared with a 64 KB launch-time quota guard, and the App Store detail screen - can clear only that app's scoped data. The future network catalog now has a - bounded `index.json` schema validator and serial `app catalog status|test` - diagnostics. Script execution, richer API injection, catalog fetch, downloads, - and updates are still TODO. + The network catalog path validates cached `limitlezz.app.catalog.v1` rows and + can install/update verified stored-ZIP packages from catalog metadata. Script + execution, richer API injection, signatures, and polished async progress are + still TODO. - **App flash (`appfs`)** - T-Deck builds mount the FAT `appfs` partition at `/appfs` without formatting, expose it beside SD/local storage in Files, and scan `/appfs/apps` even when the SD card is absent. @@ -399,23 +393,14 @@ for local apps and read-only inspection when present. local apps into the SDK 0.1 foreground shell with bounded app-provided actions and scoped storage counters plus read-only `{time}` / `{battery}` tokens; Close/Esc terminates the foreground session instead of leaving it resident; - unsupported action effects launch-block instead of being ignored; the static - catalog remains a prototype (GET -> "..." -> OPEN). + unsupported action effects launch-block instead of being ignored; cached + catalog rows now show real GET/UPDATE/OPEN state and route installs/updates + through the verified package transaction. - **Local app sample pack** - `examples/local-apps/` contains copyable SDK 0.1 packages for Calculator, Field Notes, Offline Maps, Weather Mesh, Mesh BBS, Signal Scope, LoRa Chess, and APRS Bridge; CI validates that each package stays inside the firmware's bounded manifest, permission, token, action, and scoped-storage rules. - and scoped storage counters plus read-only `{time}` / `{battery}` tokens. The - foreground shell reports and enforces the 704-byte resident runtime metadata - budget; unsupported action effects launch-block instead of being ignored; the - static catalog remains a prototype (GET -> "..." -> OPEN). - unsupported action effects launch-block instead of being ignored; network - catalog schema validation exists, while fetch/download/install remains ahead; - the static catalog remains a prototype (GET -> "..." -> OPEN). - unsupported action effects launch-block instead of being ignored; the network - catalog has a CI-validated `index.json` schema, while fetch/download/install - are still prototype/future work (GET -> "..." -> OPEN). - **Contacts / detail** — unified directory with network dots; detail page with Message (jumps into the bound conversation) and spec table. - **Settings** — airtime scheduler bar that rebalances live when the diff --git a/docs/tdeck-app-catalog-schema.md b/docs/tdeck-app-catalog-schema.md index af15dd8..fcd421b 100644 --- a/docs/tdeck-app-catalog-schema.md +++ b/docs/tdeck-app-catalog-schema.md @@ -1,65 +1,46 @@ # 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. +This page is kept as a compatibility pointer for older roadmap links. -Catalog indexes are expected at: +The canonical network App Store contract is now: -- `/sd/limitlezz/catalog/index.json` -- `/appfs/catalog/index.json` +- `docs/tdeck-network-app-catalog.md` +- `docs/examples/app-catalog-index.json` +- `scripts/validate_app_catalog.py` -The index must fit in `4096` bytes and use this top-level shape: +Package archives: -```json -{ - "schema": "limitlezz.app_catalog.v1", - "updated": "2026-06-18T00:00:00Z", - "apps": [] -} -``` - -Each app entry is bounded and fail-closed: - -```json -{ - "id": "weather.mesh", - "name": "Weather Mesh", - "version": "0.1.0", - "author": "Limitless", - "description": "Local weather reports", - "icon": "weather", - "hue": 48, - "api_version": "0.1", - "compatibility": "tdeck", - "permissions": ["display", "network_wifi"], - "download_url": "https://apps.example.invalid/weather.mesh.zip", - "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "size": 32768, - "screenshots": ["https://apps.example.invalid/weather.bmp"] -} -``` - -Validation rules: - -- `id` uses the same safe token rules as local app manifests. -- `api_version` must be supported by the local SDK compatibility gate. -- `permissions` must use the existing allowlist. -- `download_url` and optional `screenshots` must be `http://` or `https://` - URLs without whitespace or control characters. -- `sha256` must be exactly 64 hex characters. -- `size` must be nonzero and no more than `2 MB` for this first package path. -- `hue`, if present, must be `-1` or `0..359`. -- The catalog can list up to `24` apps. +- 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: +Firmware validates and loads the canonical `limitlezz.app.catalog.v1` schema. +The lower-level package transaction can install a verified package file already +present on SD/appfs. ```text app catalog status app catalog test +app catalog install-test +app catalog install [package_path] +app package test +app package install ``` `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 catalog install-test` proves catalog metadata +drives install/update and that a bad catalog hash preserves the prior app. +`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. diff --git a/docs/tdeck-app-developer-guide.md b/docs/tdeck-app-developer-guide.md index c2ccc58..76b6264 100644 --- a/docs/tdeck-app-developer-guide.md +++ b/docs/tdeck-app-developer-guide.md @@ -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 @@ -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 +``` + +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: @@ -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: diff --git a/docs/tdeck-feature-inventory.md b/docs/tdeck-feature-inventory.md index ad3fb6e..83143fe 100644 --- a/docs/tdeck-feature-inventory.md +++ b/docs/tdeck-feature-inventory.md @@ -96,12 +96,7 @@ Status labels: | Settings | Functional/Partial | network toggles, Wi-Fi, in-place brightness slider updates, time, system, touch calibration, Developer Mode, versioned `settings.cfg` persistence with v1/v2/v3 selftest coverage | Hardware latency pass still needed. | | Wi-Fi setup | Functional, needs validation | async scan/connect, saved SSID/password, auto-connect | Credentials are plaintext on SD; only one saved network. | | System/battery page | Functional/Partial | live stats and battery arc | Hardware values need calibration/validation. | -| App Store | Prototype/Partial | `LZ_STORE` static catalog remains; local manifests from SD/appfs are scanned, listed as installed local apps, open a manifest detail shell, show storage quota usage, clear scoped app data on request, launch into the SDK 0.1 foreground shell, explicitly terminate foreground sessions on Close/Esc, expose bounded foreground actions with scoped storage counters plus read-only `{time}`/`{battery}` tokens, reject unsupported action effects, and show rejected package diagnostics in Developer Mode | Network catalog, download, install/update, script execution/richer API injection, and runtime crash capture are still missing. | -| App Store | Prototype/Partial | `LZ_STORE` static catalog remains; local manifests from SD/appfs are scanned, listed as installed local apps, open a manifest detail shell, show storage quota usage, clear scoped app data on request, launch into the SDK 0.1 foreground shell, expose bounded foreground actions with scoped storage counters plus read-only `{time}`/`{battery}` tokens, enforce a 704-byte resident source/metadata runtime budget, reject unsupported action effects, and show rejected package diagnostics in Developer Mode | Network catalog, download, install/update, script execution/richer API injection, and runtime crash capture are still missing. | -| App Store | Prototype/Partial | `LZ_STORE` static catalog remains; local manifests from SD/appfs are scanned, listed as installed local apps, open a manifest detail shell, show storage quota usage, clear scoped app data on request, launch into the SDK 0.1 foreground shell, expose bounded foreground actions with scoped storage counters plus read-only `{time}`/`{battery}` tokens and permission-gated `notify:` feedback requests, reject unsupported action effects, and show rejected package diagnostics in Developer Mode | Network catalog, download, install/update, script execution/richer API injection, and runtime crash capture are still missing. | -| App Store | Prototype/Partial | `LZ_STORE` static catalog remains; local manifests from SD/appfs are scanned, listed as installed local apps, open a manifest detail shell, show storage quota usage, clear scoped app data on request, uninstall local apps with keep-data or delete-data choices, launch into the SDK 0.1 foreground shell, expose bounded foreground actions with scoped storage counters plus read-only `{time}`/`{battery}` tokens, reject unsupported action effects, and show rejected package diagnostics in Developer Mode | Network catalog, download, install/update, script execution/richer API injection, and runtime crash capture are still missing. | -| App Store | Prototype/Partial | `LZ_STORE` static catalog remains with prototype versions; local manifests from SD/appfs are scanned, listed as installed local apps, show catalog-version update chips when newer metadata exists, open a manifest detail shell, show storage quota usage, clear scoped app data on request, launch into the SDK 0.1 foreground shell, expose bounded foreground actions with scoped storage counters plus read-only `{time}`/`{battery}` tokens, reject unsupported action effects, and show rejected package diagnostics in Developer Mode | Network catalog, download, install/update, script execution/richer API injection, and runtime crash capture are still missing. | -| App Store | Prototype/Partial | `LZ_STORE` static catalog remains; local manifests from SD/appfs are scanned, listed as installed local apps, open a manifest detail shell, show storage quota usage, clear scoped app data on request, launch into the SDK 0.1 foreground shell, expose bounded foreground actions with scoped storage counters plus read-only `{time}`/`{battery}` tokens, capture bounded launch/action faults, reject unsupported action effects, and show rejected package diagnostics in Developer Mode | Network catalog, download, install/update, script execution/richer API injection, and full VM crash capture are still missing. | +| App Store | Prototype/Partial | Local manifests from SD/appfs are scanned, listed as installed local apps, open a manifest detail shell, show storage quota usage, clear scoped app data on request, uninstall local apps with keep-data or delete-data choices, launch into the SDK 0.1 foreground shell, expose bounded foreground actions with scoped storage counters plus read-only `{time}`/`{battery}` tokens and permission-gated `notify:` feedback requests, capture bounded launch/action faults, reject unsupported action effects, show rejected package diagnostics in Developer Mode, and render cached canonical network catalog entries as browse-only metadata rows with local update chips | Download/install/update, script execution/richer API injection, and full VM crash capture are still missing. | | Terminal | Functional/Partial | interactive UI terminal behind Developer Mode; serial CLI always available over USB | Expand diagnostics once Developer Mode grows into a full power-user surface. | | Files | Functional/Partial | read-only bounded filesystem browser rooted at mounted SD/local store or mounted FAT appfs; when both are present it starts at a Storage root picker | Add gated file actions later. | @@ -117,16 +112,10 @@ Status labels: | Local app scanner | Partial | `lz_store_scan_apps` scans `/sd/limitlezz/apps`, `/sd/apps`, `/appfs/apps`, simulator `/apps`, and simulator `/appfs/apps`; accepted apps appear in the paged Home launcher and App Store; rejected packages are exposed through Developer Mode diagnostics; simulator selftest covers appfs-only discovery, valid metadata, storage sandbox prep, quota usage, clear-data behavior, foreground launch metadata/actions, runtime memory-budget enforcement, storage counter persistence, read-only time/battery token gating, unsupported action-effect blocking, oversized entry blocking, and rejected unsafe packages | Add script execution, richer app lifecycle hooks, and broader user-facing data actions once memory profiling picks a runtime. | | App permissions | Partial | Local manifests can declare allowlisted SDK namespaces (`display`, `input`, `storage`, mesh, time, battery, notifications, Wi-Fi); unknown permission names reject the package before Home/App Store; `storage` prepares a scoped package `data/` directory with a 64 KB launch-time quota guard, SDK action counters require both `input` and `storage`, and `{time}`/`{battery}` tokens require matching `system_time`/`battery` permission before launch | Implement least-privilege API injection when the runtime is selected. | | Local app scanner | Partial | `lz_store_scan_apps` scans `/sd/limitlezz/apps`, `/sd/apps`, `/appfs/apps`, simulator `/apps`, and simulator `/appfs/apps`; accepted apps appear in the paged Home launcher and App Store; rejected packages are exposed through Developer Mode diagnostics; simulator selftest covers appfs-only discovery, valid metadata, storage sandbox prep, quota usage, clear-data behavior, foreground launch metadata/actions, storage counter persistence, bounded launch/action fault snapshots, read-only time/battery token gating, unsupported action-effect blocking, oversized entry blocking, and rejected unsafe packages | Add script execution, richer app lifecycle hooks, and broader user-facing data actions once memory profiling picks a runtime. | -| Network app catalog | Planned | Wi-Fi service notes; design spec | Fetch `index.json`, verify TLS/metadata, cache results. | -| Network app catalog | Partial | `docs/tdeck-app-catalog-schema.md`; bounded `limitlezz.app_catalog.v1` validator plus serial `app catalog status\|test` diagnostics | Fetch `index.json` over Wi-Fi, verify TLS/source metadata, cache results, and feed validated entries into App Store state. | -| Network app catalog | Planned/Partial | Wi-Fi service notes; design spec; Settings persists an App source selector with Official, Community, and Local only modes, and App Store reflects the selected source while keeping catalog examples hidden in local-only mode. | Fetch `index.json`, verify TLS/metadata, cache results. | -| Network app catalog | Planned/Partial | Wi-Fi service notes; bounded catalog JSON cache APIs | Fetch `index.json`, verify TLS/metadata, parse schema, and render cached results. | -| Network app catalog | Planned/Partial | Wi-Fi service notes; bounded T-Deck HTTP/HTTPS fetch transport foundation | Fetch `index.json` into the parser/cache flow, verify TLS/metadata, and render cached results. | -| 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. | +| 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 `limitlezz.app.catalog.v1`; firmware validates the same canonical fields, loads typed cached entries, exposes serial `app catalog status\|test\|install-test`, and feeds cached rows into App Store GET/UPDATE/OPEN state | Wire source URL settings, source trust metadata, and async progress polish. | +| 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`; catalog installs can stream HTTPS packages on T-Deck Wi-Fi or use a supplied local package path for deterministic smoke; native selftest covers install, update, and rollback for bad hashes and bad packages | Add package signatures, pinned TLS/source verification, and background progress UI. | | 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. | +| APRS/weather/BBS/scope/game apps | Planned/Prototype sample apps | `examples/local-apps/` sample packages and canonical catalog example metadata | Implement richer sandboxed behavior once runtime APIs exist. | ## Security, Updates, And Feedback diff --git a/docs/tdeck-firmware-roadmap.md b/docs/tdeck-firmware-roadmap.md index e04f738..5d8e3d9 100644 --- a/docs/tdeck-firmware-roadmap.md +++ b/docs/tdeck-firmware-roadmap.md @@ -364,37 +364,38 @@ Goal: let users install and update apps from a repository. Deliverables: -- Define catalog `index.json` schema: app id, name, version, author, description, icon id/color, permissions, download URL, SHA256, size, compatibility, screenshots if desired. Implemented: a bounded `limitlezz.app_catalog.v1` validator rejects unsafe IDs, unsupported permissions/SDK versions, non-HTTP package URLs, bad SHA256 values, oversize packages, and malformed optional screenshots; serial `app catalog status|test` exposes the result without requiring Wi-Fi. -- Define catalog `index.json` schema: app id, name, version, author, description, icon id/color, permissions, download URL, SHA256, size, compatibility, screenshots if desired. Implemented in `docs/tdeck-network-app-catalog.md` with `docs/examples/app-catalog-index.json`, `scripts/validate_app_catalog.py`, and a Firmware CI validation step. -- Fetch catalog over Wi-Fi. -- Cache catalog for offline browsing. Initial implementation: bounded atomic - catalog JSON cache save/load/clear service APIs with native simulator coverage. -- Define catalog `index.json` schema: app id, name, version, author, description, icon id/color, permissions, download URL, SHA256, size, compatibility, screenshots if desired. +- Define catalog `index.json` schema: app id, name, version, author, summary/description, icon id/color, permissions, package URL, SHA256, size, compatibility, and screenshots. Implemented in `docs/tdeck-network-app-catalog.md` as `limitlezz.app.catalog.v1`, with `docs/examples/app-catalog-index.json`, `scripts/validate_app_catalog.py`, and a Firmware CI validation step. - Fetch catalog over Wi-Fi. Initial implementation: bounded T-Deck HTTP/HTTPS 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. +- Cache catalog for offline browsing. Initial implementation: bounded atomic + catalog JSON cache save/load/clear service APIs with native simulator coverage; + canonical cached entries are parsed into typed firmware rows and rendered in + App Store browsing/update metadata. +- Download app zip/package. Initial implementation: catalog installs stream + HTTPS package URLs to a bounded temp file on T-Deck when Wi-Fi is connected, + with a serial local-package override for deterministic smoke tests. - 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. -- 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 - plus detail status when a newer catalog version exists. -- Support uninstall/delete with data retention choice. + 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. Initial implementation: App Store rows + compare installed manifest versions with catalog metadata, display + GET/UPDATE/OPEN states, and route GET/UPDATE through the verified catalog + install transaction. +- Support uninstall/delete with data retention choice. Initial implementation: + local App Store details expose keep-data and delete-data removal paths; retained + data is hidden from app scanning and restored on reinstall. - Add plain-language permission prompts. Initial implementation: local app detail and foreground metadata now show a bounded `Access` summary generated from the manifest permission bits, while keeping raw permission IDs visible for diagnostics. -- Support uninstall/delete with data retention choice. Initial implementation: - local App Store details expose keep-data and delete-data removal paths; retained - data is hidden from app scanning and restored on reinstall. -- Add plain-language permission prompts. -- Add catalog/source settings suitable for community repos without confusing first-time users. -- Add plain-language permission prompts. - Add catalog/source settings suitable for community repos without confusing first-time users. Initial implementation: Settings persists an App source selector with `Official`, `Community`, and `Local only`; App Store reflects diff --git a/docs/tdeck-network-app-catalog.md b/docs/tdeck-network-app-catalog.md index 73bd1c1..3da6db4 100644 --- a/docs/tdeck-network-app-catalog.md +++ b/docs/tdeck-network-app-catalog.md @@ -1,9 +1,9 @@ # Network App Catalog This is the V0.95/V0.96 bridge contract for turning the App Store from local -manifest scanning into a downloadable catalog. It defines the first -`index.json` shape only; firmware download, install, update, and rollback are -still later work. +manifest scanning into a downloadable catalog. Firmware now validates cached +catalog rows, renders them in App Store, and can install or update a catalog +entry through the verified stored-ZIP package transaction. The catalog is intentionally stricter than a web store feed. Every app entry must expose enough metadata for the T-Deck to show permissions, check SDK @@ -33,9 +33,10 @@ Each app entry must include: - `hue`: tile hue hint, `-1` for neutral or `0..359` - `package_url`: HTTPS URL for the app package - `package_sha256`: lowercase 64-character SHA256 digest of the package -- `package_bytes`: positive package size in bytes +- `package_bytes`: positive package size in bytes, no more than `2 MB` for + the first firmware install path - `compatibility`: object with `api_versions`, `targets`, and optional `min_os` -- `screenshots`: optional array of HTTPS screenshot metadata +- `screenshots`: optional array of up to `4` HTTPS screenshot metadata objects The firmware should treat unknown fields as forward-compatible display metadata only after the required fields pass validation. Required-field failures, @@ -77,12 +78,22 @@ 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. On T-Deck builds, catalog installs stream the +package URL to a bounded temp file under local storage when Wi-Fi is connected; +serial diagnostics may also provide an already-copied package path for +deterministic smoke tests. In both cases the installer 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 download, hash, manifest, 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 @@ -94,3 +105,16 @@ python scripts/validate_app_catalog.py docs/examples/app-catalog-index.json Firmware CI runs the same validator against the checked-in example catalog so schema drift is caught with the normal simulator and T-Deck build gates. + +Serial hardware diagnostics: + +```text +app catalog status +app catalog test +app catalog install-test +app catalog install [package_path] +``` + +`app catalog install-test` creates a synthetic catalog and stored-ZIP package on +device, proves install/update, and proves a bad catalog hash preserves the +previous live app. diff --git a/scripts/build_app_package.py b/scripts/build_app_package.py new file mode 100644 index 0000000..96ecdb4 --- /dev/null +++ b/scripts/build_app_package.py @@ -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()) diff --git a/scripts/tdeck_smoke.py b/scripts/tdeck_smoke.py index 06d493d..4bb5e35 100644 --- a/scripts/tdeck_smoke.py +++ b/scripts/tdeck_smoke.py @@ -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), diff --git a/scripts/validate_app_catalog.py b/scripts/validate_app_catalog.py index 9dca0ef..3cd3a3e 100644 --- a/scripts/validate_app_catalog.py +++ b/scripts/validate_app_catalog.py @@ -24,6 +24,8 @@ "network_wifi", } TARGETS = {"tdeck", "sim"} +MAX_PACKAGE_BYTES = 2 * 1024 * 1024 +MAX_SCREENSHOTS = 4 SAFE_ID = re.compile(r"^[A-Za-z0-9_.-]{1,23}$") SAFE_VERSION = re.compile(r"^[0-9][0-9A-Za-z_.+-]{0,15}$") @@ -119,6 +121,8 @@ def validate_screenshots(app: dict, path: str, errors: list[str]) -> None: if not isinstance(screenshots, list): add_error(errors, f"{path}.screenshots", "must be an array") return + if len(screenshots) > MAX_SCREENSHOTS: + add_error(errors, f"{path}.screenshots", f"too many screenshots (max {MAX_SCREENSHOTS})") for i, shot in enumerate(screenshots): spath = f"{path}.screenshots[{i}]" if not isinstance(shot, dict): @@ -146,7 +150,7 @@ def validate_app(app: object, index: int, ids: set[str], errors: list[str]) -> N app_id = require_string(app, "id", path, errors, 23) if app_id: - if not SAFE_ID.match(app_id): + if not SAFE_ID.match(app_id) or app_id == "." or ".." in app_id: add_error(errors, f"{path}.id", "unsafe id") if app_id in ids: add_error(errors, f"{path}.id", f"duplicate id {app_id!r}") @@ -181,6 +185,8 @@ def validate_app(app: object, index: int, ids: set[str], errors: list[str]) -> N package_size = require_int(app, "package_bytes", path, errors) if package_size is not None and package_size <= 0: add_error(errors, f"{path}.package_bytes", "must be positive") + if package_size is not None and package_size > MAX_PACKAGE_BYTES: + add_error(errors, f"{path}.package_bytes", f"must be <= {MAX_PACKAGE_BYTES}") validate_compat(app, path, api_version, errors) validate_screenshots(app, path, errors) diff --git a/sim/main_sim.c b/sim/main_sim.c index 3073a12..668e744 100644 --- a/sim/main_sim.c +++ b/sim/main_sim.c @@ -1412,6 +1412,35 @@ 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"); + } + /* 10c. catalog-driven package install: catalog SHA/size metadata drives the + * verified installer and preserves the live app on failed updates. */ + { + extern void lz_store_init(const char *datadir); + extern int lz_store_app_catalog_install_selftest(char *buf, int n); + sim_reset_dir("lzdata_appcataloginstall"); + lz_store_init("lzdata_appcataloginstall"); + char catpkg[180]; + lz_store_app_catalog_install_selftest(catpkg, sizeof catpkg); + CHECK(strstr(catpkg, "PASS") != NULL, + "app catalog package install/update selftest"); + lz_store_init(NULL); + sim_reset_dir("lzdata_appcataloginstall"); + } /* 10. local app uninstall: users can delete a package while either * retaining scoped data for reinstall or deleting everything. */ { @@ -1848,30 +1877,60 @@ static int codec_selftest(void) * before fetch/download/install code trusts them. */ { static const char valid_catalog[] = - "{\"schema\":\"limitlezz.app_catalog.v1\",\"updated\":\"2026-06-18T00:00:00Z\"," + "{\"schema\":\"limitlezz.app.catalog.v1\",\"generated_at\":\"2026-06-18T00:00:00Z\"," "\"apps\":[" "{\"id\":\"weather.mesh\",\"name\":\"Weather Mesh\",\"version\":\"0.1.0\"," - "\"author\":\"Limitless\",\"description\":\"Local weather reports\"," + "\"author\":\"Limitless\",\"summary\":\"Local weather reports\"," + "\"description\":\"Local weather reports from nearby mesh stations\"," "\"icon\":\"weather\",\"hue\":48,\"api_version\":\"0.1\"," - "\"compatibility\":\"tdeck\",\"permissions\":[\"display\",\"network_wifi\"]," - "\"download_url\":\"https://apps.example.invalid/weather.mesh.zip\"," - "\"sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," - "\"size\":32768,\"screenshots\":[\"https://apps.example.invalid/weather.bmp\"]}," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\",\"sim\"]," + "\"min_os\":\"0.95.0\"}," + "\"permissions\":[\"display\",\"network_wifi\"]," + "\"package_url\":\"https://apps.example.invalid/weather.mesh.zip\"," + "\"package_sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," + "\"package_bytes\":32768," + "\"screenshots\":[{\"url\":\"https://apps.example.invalid/weather.bmp\"," + "\"width\":320,\"height\":240}]}," "{\"id\":\"notes.local\",\"name\":\"Field Notes\",\"version\":\"0.1.0\"," - "\"author\":\"Limitless\",\"description\":\"Simple local notes\"," - "\"icon\":\"notes\",\"api_version\":\"0.1\",\"compatibility\":\"tdeck\"," + "\"author\":\"Limitless\",\"summary\":\"Simple local notes\"," + "\"description\":\"Simple local notes with scoped storage\"," + "\"icon\":\"notes\",\"hue\":95,\"api_version\":\"0.1\"," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\"]}," "\"permissions\":[\"display\",\"input\",\"storage\"]," - "\"download_url\":\"https://apps.example.invalid/notes.local.zip\"," - "\"sha256\":\"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"," - "\"size\":49152}]}"; + "\"package_url\":\"https://apps.example.invalid/notes.local.zip\"," + "\"package_sha256\":\"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"," + "\"package_bytes\":49152}]}"; static const char bad_catalog[] = - "{\"schema\":\"limitlezz.app_catalog.v1\",\"apps\":[" + "{\"schema\":\"limitlezz.app.catalog.v1\",\"apps\":[" "{\"id\":\"weather.mesh\",\"name\":\"Weather Mesh\",\"version\":\"0.1.0\"," - "\"author\":\"Limitless\",\"description\":\"Bad catalog\",\"icon\":\"weather\"," - "\"api_version\":\"0.1\",\"compatibility\":\"tdeck\"," + "\"author\":\"Limitless\",\"summary\":\"Bad catalog\"," + "\"description\":\"Bad catalog\",\"icon\":\"weather\",\"hue\":48," + "\"api_version\":\"0.1\"," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\"]}," "\"permissions\":[\"display\",\"raw_radio\"]," - "\"download_url\":\"file:///sd/apps/weather.zip\"," - "\"sha256\":\"bad\",\"size\":0}]}"; + "\"package_url\":\"https://apps.example.invalid/weather.zip\"," + "\"package_sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," + "\"package_bytes\":1024}]}"; + static const char duplicate_catalog[] = + "{\"schema\":\"limitlezz.app.catalog.v1\",\"apps\":[" + "{\"id\":\"weather.mesh\",\"name\":\"Weather Mesh\",\"version\":\"0.1.0\"," + "\"author\":\"Limitless\",\"summary\":\"Weather\"," + "\"description\":\"Weather\",\"icon\":\"weather\",\"hue\":48," + "\"api_version\":\"0.1\"," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\"]}," + "\"permissions\":[\"display\"]," + "\"package_url\":\"https://apps.example.invalid/weather.mesh.zip\"," + "\"package_sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," + "\"package_bytes\":1024}," + "{\"id\":\"weather.mesh\",\"name\":\"Weather Mesh 2\",\"version\":\"0.1.1\"," + "\"author\":\"Limitless\",\"summary\":\"Weather\"," + "\"description\":\"Weather\",\"icon\":\"weather\",\"hue\":48," + "\"api_version\":\"0.1\"," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\"]}," + "\"permissions\":[\"display\"]," + "\"package_url\":\"https://apps.example.invalid/weather2.zip\"," + "\"package_sha256\":\"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"," + "\"package_bytes\":1024}]}"; lz_app_catalog_report_t report; CHECK(lz_svc_validate_app_catalog_json(valid_catalog, &report) && @@ -1879,8 +1938,19 @@ static int codec_selftest(void) "app catalog schema accepts valid bounded index"); CHECK(!lz_svc_validate_app_catalog_json(bad_catalog, &report) && !report.ok && report.rejected_count == 1 && - strcmp(report.first_error, "bad download_url") == 0, - "app catalog schema rejects unsafe download URL"); + strcmp(report.first_error, "bad permissions") == 0, + "app catalog schema rejects unsupported permissions"); + CHECK(!lz_svc_validate_app_catalog_json(duplicate_catalog, &report) && + !report.ok && report.rejected_count == 1 && + strcmp(report.first_error, "duplicate id") == 0, + "app catalog schema rejects duplicate app ids"); + lz_app_catalog_entry_t parsed[4]; + CHECK(lz_svc_parse_app_catalog_json(valid_catalog, parsed, 4, &report) && + report.ok && report.app_count == 2 && + strcmp(parsed[0].package_url, "https://apps.example.invalid/weather.mesh.zip") == 0 && + parsed[0].target_tdeck && parsed[0].target_sim && + parsed[0].package_bytes == 32768, + "app catalog parser extracts typed package metadata"); char self[160]; lz_svc_app_catalog_selftest(self, sizeof self); CHECK(strstr(self, "PASS") != NULL, @@ -1888,14 +1958,16 @@ static int codec_selftest(void) extern void lz_store_init(const char *datadir); sim_reset_dir("lzdata_catalog"); - sim_mkdirs("lzdata_catalog/catalog"); - FILE *cf = fopen("lzdata_catalog/catalog/index.json", "wb"); + FILE *cf = fopen("lzdata_catalog/app_catalog.json", "wb"); if(cf) { fputs(valid_catalog, cf); fclose(cf); } lz_store_init("lzdata_catalog"); char diag[160]; lz_svc_app_catalog_diag(diag, sizeof diag); CHECK(strstr(diag, "ready apps=2") != NULL, "app catalog diagnostics report cached index"); + CHECK(lz_svc_load_app_catalog(parsed, 4, &report) == 2 && + report.ok && strcmp(parsed[1].id, "notes.local") == 0, + "app catalog cache loads typed entries"); lz_store_init(NULL); sim_reset_dir("lzdata_catalog"); } diff --git a/src/app_package_fetch_sim.c b/src/app_package_fetch_sim.c new file mode 100644 index 0000000..e3978b9 --- /dev/null +++ b/src/app_package_fetch_sim.c @@ -0,0 +1,36 @@ +#ifdef LZ_TARGET_SIM + +#include "services/app_package_fetch.h" +#include +#include + +static void package_fetch_err(char *err, int err_cap, const char *msg) +{ + if(err && err_cap > 0) snprintf(err, (size_t)err_cap, "%s", msg); +} + +static bool package_fetch_url_ok(const char *url) +{ + return url && + (strncmp(url, "http://", 7) == 0 || strncmp(url, "https://", 8) == 0); +} + +bool lz_app_package_fetch(const char *url, const char *dest_path, + uint32_t expected_bytes, uint32_t max_bytes, + uint32_t *out_bytes, char *err, int err_cap) +{ + if(out_bytes) *out_bytes = 0; + package_fetch_err(err, err_cap, ""); + if(!package_fetch_url_ok(url)) { + package_fetch_err(err, err_cap, "bad package url"); + return false; + } + if(!dest_path || !dest_path[0] || expected_bytes == 0 || expected_bytes > max_bytes) { + package_fetch_err(err, err_cap, "bad package request"); + return false; + } + package_fetch_err(err, err_cap, "package fetch unavailable"); + return false; +} + +#endif diff --git a/src/app_package_fetch_tdeck.cpp b/src/app_package_fetch_tdeck.cpp new file mode 100644 index 0000000..c0633c1 --- /dev/null +++ b/src/app_package_fetch_tdeck.cpp @@ -0,0 +1,145 @@ +#ifdef LZ_TARGET_TDECK + +#include +#include +#include +#include +#include "services/app_package_fetch.h" +#include "services/wifi.h" +#include +#include + +static void package_fetch_err(char *err, int err_cap, const char *msg) +{ + if(err && err_cap > 0) snprintf(err, (size_t)err_cap, "%s", msg); +} + +static bool package_fetch_url_ok(const char *url) +{ + return url && + (strncmp(url, "http://", 7) == 0 || strncmp(url, "https://", 8) == 0); +} + +static bool package_fetch_read_body(HTTPClient &http, const char *dest_path, + uint32_t expected_bytes, uint32_t max_bytes, + uint32_t *out_bytes, char *err, int err_cap) +{ + int declared = http.getSize(); + if(declared >= 0 && (uint32_t)declared != expected_bytes) { + package_fetch_err(err, err_cap, "size mismatch"); + return false; + } + if(expected_bytes == 0 || expected_bytes > max_bytes) { + package_fetch_err(err, err_cap, "package too large"); + return false; + } + + FILE *f = fopen(dest_path, "wb"); + if(!f) { + package_fetch_err(err, err_cap, "package write failed"); + return false; + } + + WiFiClient *stream = http.getStreamPtr(); + uint32_t total = 0; + uint32_t idle_deadline = millis() + 8000u; + bool ok = true; + while(http.connected() || stream->available()) { + int avail = stream->available(); + if(avail <= 0) { + if((int32_t)(millis() - idle_deadline) >= 0) break; + delay(1); + continue; + } + idle_deadline = millis() + 8000u; + while(avail-- > 0) { + int c = stream->read(); + if(c < 0) break; + if(total >= expected_bytes || total >= max_bytes) { + package_fetch_err(err, err_cap, "package too large"); + ok = false; + break; + } + if(fputc(c, f) == EOF) { + package_fetch_err(err, err_cap, "package write failed"); + ok = false; + break; + } + total++; + } + if(!ok) break; + } + if(fclose(f) != 0 && ok) { + package_fetch_err(err, err_cap, "package write failed"); + ok = false; + } + if(!ok) { + remove(dest_path); + return false; + } + if(total != expected_bytes) { + remove(dest_path); + package_fetch_err(err, err_cap, "size mismatch"); + return false; + } + if(out_bytes) *out_bytes = total; + return true; +} + +static bool package_fetch_with_client(WiFiClient &client, const char *url, + const char *dest_path, + uint32_t expected_bytes, uint32_t max_bytes, + uint32_t *out_bytes, char *err, int err_cap) +{ + HTTPClient http; + http.setTimeout(10000); + if(!http.begin(client, url)) { + package_fetch_err(err, err_cap, "http begin failed"); + return false; + } + int code = http.GET(); + if(code != HTTP_CODE_OK) { + char msg[32]; + snprintf(msg, sizeof msg, "http %d", code); + package_fetch_err(err, err_cap, msg); + http.end(); + return false; + } + bool ok = package_fetch_read_body(http, dest_path, expected_bytes, max_bytes, + out_bytes, err, err_cap); + http.end(); + return ok; +} + +bool lz_app_package_fetch(const char *url, const char *dest_path, + uint32_t expected_bytes, uint32_t max_bytes, + uint32_t *out_bytes, char *err, int err_cap) +{ + if(out_bytes) *out_bytes = 0; + package_fetch_err(err, err_cap, ""); + if(!package_fetch_url_ok(url)) { + package_fetch_err(err, err_cap, "bad package url"); + return false; + } + if(!dest_path || !dest_path[0] || expected_bytes == 0 || expected_bytes > max_bytes) { + package_fetch_err(err, err_cap, "bad package request"); + return false; + } + if(lz_wifi_status() != LZ_WIFI_CONNECTED || WiFi.status() != WL_CONNECTED) { + package_fetch_err(err, err_cap, "wifi offline"); + return false; + } + + if(strncmp(url, "https://", 8) == 0) { + WiFiClientSecure client; + client.setInsecure(); /* TODO: pin package host certificate before broad release. */ + return package_fetch_with_client(client, url, dest_path, expected_bytes, max_bytes, + out_bytes, err, err_cap); + } + + WiFiClient client; + return package_fetch_with_client(client, url, dest_path, expected_bytes, max_bytes, + out_bytes, err, err_cap); +} + +#endif diff --git a/src/serial_cli.cpp b/src/serial_cli.cpp index 26510bc..77a6eb7 100644 --- a/src/serial_cli.cpp +++ b/src/serial_cli.cpp @@ -10,6 +10,7 @@ #ifdef LZ_TARGET_TDECK #include +#include #include "services/emergency_guard.h" #include "services/mesh.h" #include "services/feedback.h" @@ -76,7 +77,9 @@ static void cmd_help(void) " companion test loopback-verify the companion protocol\n" " 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 catalog status|test|install-test app catalog diagnostics\n" + " app catalog install [package_path]\n" + " app package test|install \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" @@ -464,6 +467,60 @@ static void cmd_app(char *args) Serial.print(b); return; } + if(args && strcmp(args, "catalog install-test") == 0) { + char b[180]; + lz_svc_app_catalog_install_selftest(b, sizeof b); + Serial.print(b); + return; + } + if(args && strncmp(args, "catalog install ", 16) == 0) { + char id[24] = {0}, path[112] = {0}; + int parsed = sscanf(args + 16, "%23s %111s", id, path); + if(parsed < 1) { + Serial.println("usage: app catalog install [package_path]"); + return; + } + lz_app_package_install_t r; + memset(&r, 0, sizeof r); + if(lz_svc_install_app_catalog_entry(id, parsed >= 2 ? path : NULL, &r)) { + Serial.printf("[ok] app catalog install id=%s version=%s files=%u source=%s package=%lu extracted=%lu\n", + r.id, r.version, (unsigned)r.file_count, + r.fetched ? "download" : "local", + (unsigned long)r.package_bytes, + (unsigned long)r.extracted_bytes); + } else { + Serial.printf("[err] app catalog install: %s\n", + r.error[0] ? r.error : "install failed"); + } + 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 "); + 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]; @@ -471,7 +528,7 @@ static void cmd_app(char *args) 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|test|install-test|install [package_path] | app package test"); } static void cmd_nodes(char *args) diff --git a/src/services/app_package_fetch.h b/src/services/app_package_fetch.h new file mode 100644 index 0000000..26466fd --- /dev/null +++ b/src/services/app_package_fetch.h @@ -0,0 +1,19 @@ +#ifndef LZ_APP_PACKAGE_FETCH_H +#define LZ_APP_PACKAGE_FETCH_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +bool lz_app_package_fetch(const char *url, const char *dest_path, + uint32_t expected_bytes, uint32_t max_bytes, + uint32_t *out_bytes, char *err, int err_cap); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/services/mesh.h b/src/services/mesh.h index 81cb0b1..694111e 100644 --- a/src/services/mesh.h +++ b/src/services/mesh.h @@ -40,6 +40,10 @@ extern "C" { #define LZ_FEEDBACK_BODY_MAX 96 #define LZ_APP_CATALOG_JSON_MAX 4096u #define LZ_APP_CATALOG_MAX_APPS 24 +#define LZ_APP_PACKAGE_MAX_BYTES (2u * 1024u * 1024u) +#define LZ_APP_CATALOG_SCHEMA "limitlezz.app.catalog.v1" +#define LZ_APP_CATALOG_PACKAGE_MAX_BYTES (2u * 1024u * 1024u) +#define LZ_APP_CATALOG_SCREENSHOT_MAX 4 #define LZ_OTA_MANIFEST_SCHEMA "limitlezz.ota_manifest.v1" #define LZ_OTA_BOARD_TDECK "tdeck" #define LZ_OTA_SLOT_MAX_BYTES 0x500000u @@ -237,6 +241,37 @@ typedef struct { char first_error[64]; } lz_app_catalog_report_t; +typedef struct { + bool ok; + char id[24]; + char version[16]; + char error[48]; + char package_path[112]; + uint32_t package_bytes; + uint32_t extracted_bytes; + uint16_t file_count; + bool fetched; +} lz_app_package_install_t; + +typedef struct { + char id[24]; /* canonical package id */ + char name[32]; + char version[16]; + char author[28]; + char summary[72]; + char api_version[12]; + char icon[20]; + char package_url[181]; + char package_sha256[65]; + char min_os[16]; + uint32_t package_bytes; + uint16_t permissions; + int hue; + uint8_t screenshot_count; + bool target_tdeck; + bool target_sim; +} lz_app_catalog_entry_t; + typedef struct { char label[24]; /* app-provided foreground control label */ char status[48]; /* bounded status shown after activation */ @@ -304,9 +339,20 @@ bool lz_svc_app_data_usage(const lz_local_app_t *app, uint32_t *used, uint32_t * bool lz_svc_clear_app_data(const lz_local_app_t *app, char *err, int err_cap); bool lz_svc_uninstall_local_app(const lz_local_app_t *app, bool keep_data, char *err, int err_cap); +bool lz_svc_install_app_package(const char *id, const char *package_path, + const char *sha256, uint32_t package_bytes, + lz_app_package_install_t *out); +int lz_svc_app_package_selftest(char *buf, int n); +bool lz_svc_install_app_catalog_entry(const char *id, const char *package_path, + lz_app_package_install_t *out); +int lz_svc_app_catalog_install_selftest(char *buf, int n); bool lz_svc_save_app_catalog_cache(const char *json, int len, char *err, int err_cap); bool lz_svc_load_app_catalog_cache(char *out, int cap, int *out_len, char *err, int err_cap); bool lz_svc_clear_app_catalog_cache(char *err, int err_cap); +bool lz_svc_parse_app_catalog_json(const char *json, lz_app_catalog_entry_t *out, + int cap, lz_app_catalog_report_t *report); +int lz_svc_load_app_catalog(lz_app_catalog_entry_t *out, int cap, + lz_app_catalog_report_t *report); bool lz_svc_start_local_app(const lz_local_app_t *app, lz_local_app_session_t *out); bool lz_svc_local_app_action(lz_local_app_session_t *session, int idx); void lz_svc_stop_local_app(lz_local_app_session_t *session); diff --git a/src/services/mesh_core.c b/src/services/mesh_core.c index afd12ea..f3aca48 100644 --- a/src/services/mesh_core.c +++ b/src/services/mesh_core.c @@ -35,6 +35,17 @@ bool lz_store_clear_app_catalog_cache(char *err, int err_cap); bool lz_store_start_local_app(const lz_local_app_t *app, lz_local_app_session_t *out); bool lz_store_local_app_action(lz_local_app_session_t *session, int idx); void lz_store_stop_local_app(lz_local_app_session_t *session); +bool lz_store_install_app_package(const char *id, const char *package_path, + const char *sha256, uint32_t package_bytes, + lz_app_package_install_t *out); +int lz_store_app_package_selftest(char *buf, int n); +bool lz_store_install_app_catalog_entry(const char *id, const char *package_path, + lz_app_package_install_t *out); +int lz_store_app_catalog_install_selftest(char *buf, int n); +bool lz_store_parse_app_catalog_json(const char *json, lz_app_catalog_entry_t *out, + int cap, lz_app_catalog_report_t *report); +int lz_store_load_app_catalog(lz_app_catalog_entry_t *out, int cap, + lz_app_catalog_report_t *report); bool lz_store_validate_app_catalog_json(const char *json, lz_app_catalog_report_t *out); int lz_store_app_catalog_diag(char *buf, int n); int lz_store_app_catalog_selftest(char *buf, int n); @@ -436,6 +447,18 @@ bool lz_svc_clear_app_catalog_cache(char *err, int err_cap) return lz_store_clear_app_catalog_cache(err, err_cap); } +bool lz_svc_parse_app_catalog_json(const char *json, lz_app_catalog_entry_t *out, + int cap, lz_app_catalog_report_t *report) +{ + return lz_store_parse_app_catalog_json(json, out, cap, report); +} + +int lz_svc_load_app_catalog(lz_app_catalog_entry_t *out, int cap, + lz_app_catalog_report_t *report) +{ + return lz_store_load_app_catalog(out, cap, report); +} + bool lz_svc_start_local_app(const lz_local_app_t *app, lz_local_app_session_t *out) { if(!lz_store_start_local_app(app, out)) return false; @@ -492,6 +515,29 @@ int lz_svc_app_catalog_selftest(char *buf, int n) return lz_store_app_catalog_selftest(buf, n); } +bool lz_svc_install_app_package(const char *id, const char *package_path, + const char *sha256, uint32_t package_bytes, + lz_app_package_install_t *out) +{ + return lz_store_install_app_package(id, package_path, sha256, package_bytes, out); +} + +int lz_svc_app_package_selftest(char *buf, int n) +{ + return lz_store_app_package_selftest(buf, n); +} + +bool lz_svc_install_app_catalog_entry(const char *id, const char *package_path, + lz_app_package_install_t *out) +{ + return lz_store_install_app_catalog_entry(id, package_path, out); +} + +int lz_svc_app_catalog_install_selftest(char *buf, int n) +{ + return lz_store_app_catalog_install_selftest(buf, n); +} + bool lz_svc_ota_manifest_status(lz_ota_manifest_t *out) { return lz_store_ota_manifest_status(out); diff --git a/src/services/store.c b/src/services/store.c index adff88e..100b76f 100644 --- a/src/services/store.c +++ b/src/services/store.c @@ -18,6 +18,7 @@ */ #include "mesh.h" #include "mc_crypto.h" +#include "app_package_fetch.h" #include #include #include @@ -706,9 +707,6 @@ static bool safe_entry(const char *s) return true; } -#define LZ_APP_CATALOG_PACKAGE_MAX_BYTES (2u * 1024u * 1024u) -#define LZ_APP_CATALOG_SCREENSHOT_MAX 4 - static bool catalog_fail(lz_app_catalog_report_t *r, const char *id, const char *msg) { if(r) { @@ -722,10 +720,57 @@ static bool catalog_fail(lz_app_catalog_report_t *r, const char *id, const char return false; } +static bool json_string_present_max(const char *json, const char *key, size_t max_len) +{ + const char *p = json_value_for(json, key); + if(!p || *p != '"') return false; + p++; + size_t j = 0; + while(*p && *p != '"') { + char c = *p++; + if(c == '\\' && *p) { + char e = *p++; + if(e == 'n') c = '\n'; + else if(e == 'r') c = '\r'; + else if(e == 't') c = '\t'; + else c = e; + } + if(c < 32) continue; + if(++j > max_len) return false; + } + return *p == '"' && j > 0; +} + +static bool catalog_version_ok(const char *s) +{ + if(!s || !(s[0] >= '0' && s[0] <= '9')) return false; + for(int i = 0; s[i]; i++) { + char c = s[i]; + bool ok = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || c == '_' || c == '.' || + c == '+' || c == '-'; + if(!ok) return false; + } + return true; +} + +static bool catalog_icon_ok(const char *s) +{ + if(!s || !s[0]) return false; + for(int i = 0; s[i]; i++) { + char c = s[i]; + bool ok = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || c == '_' || c == '-'; + if(!ok) return false; + } + return true; +} + static bool catalog_url_ok(const char *url) { if(!url || !url[0]) return false; - if(strncmp(url, "https://", 8) != 0 && strncmp(url, "http://", 7) != 0) return false; + if(strncmp(url, "https://", 8) != 0) return false; + if(!url[8] || url[8] == '/') return false; for(int i = 0; url[i]; i++) { char c = url[i]; if(c <= 32 || c == '"' || c == '<' || c == '>') return false; @@ -738,8 +783,7 @@ static bool catalog_sha256_ok(const char *s) if(!s) return false; for(int i = 0; i < 64; i++) { char c = s[i]; - bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || - (c >= 'A' && c <= 'F'); + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); if(!hex) return false; } return s[64] == 0; @@ -752,32 +796,106 @@ static const char *json_array_for(const char *json, const char *key) return p; } -static bool catalog_string_array_ok(const char *p, bool urls) +static bool catalog_parse_permissions_value(const char *p, uint16_t *out) { - if(!p || *p != '[') return false; + if(!p || !out) return false; + p = skip_ws(p); + if(*p != '[') return false; p = skip_ws(p + 1); + uint16_t bits = 0; int count = 0; - if(*p == ']') return true; + if(*p == ']') return false; for(;;) { - if(++count > LZ_APP_CATALOG_SCREENSHOT_MAX) return false; if(*p != '"') return false; p++; - char item[128]; + char name[24]; size_t j = 0; - bool too_long = false; while(*p && *p != '"') { char c = *p++; if(c == '\\' && *p) c = *p++; - if(c < 32) continue; - if(j + 1 < sizeof item) item[j++] = c; - else too_long = true; + if(j + 1 < sizeof name && c >= 32) name[j++] = c; } - if(*p != '"' || too_long) return false; - item[j] = 0; - if(urls && !catalog_url_ok(item)) return false; + if(*p != '"' || j == 0) return false; + name[j] = 0; + uint16_t bit = app_permission_bit(name); + if(!bit || (bits & bit)) return false; + bits |= bit; + count++; p = skip_ws(p + 1); if(*p == ',') { p = skip_ws(p + 1); continue; } - if(*p == ']') return true; + if(*p == ']') { + if(count == 0 || !(bits & LZ_APP_PERM_DISPLAY)) return false; + *out = bits; + return true; + } + return false; + } +} + +static bool catalog_api_versions_ok(const char *p, const char *api) +{ + if(!p || *skip_ws(p) != '[' || !api || !api[0]) return false; + p = skip_ws(p) + 1; + p = skip_ws(p); + bool found = false; + int count = 0; + if(*p == ']') return false; + for(;;) { + if(*p != '"') return false; + p++; + char value[12]; + size_t j = 0; + while(*p && *p != '"') { + char c = *p++; + if(c == '\\' && *p) c = *p++; + if(j + 1 < sizeof value && c >= 32) value[j++] = c; + else return false; + } + if(*p != '"' || j == 0) return false; + value[j] = 0; + if(!api_version_supported(value)) return false; + if(strcmp(value, api) == 0) found = true; + count++; + p = skip_ws(p + 1); + if(*p == ',') { p = skip_ws(p + 1); continue; } + if(*p == ']') return count > 0 && found; + return false; + } +} + +static bool catalog_targets_ok(const char *p, bool *target_tdeck, bool *target_sim) +{ + if(target_tdeck) *target_tdeck = false; + if(target_sim) *target_sim = false; + if(!p || *skip_ws(p) != '[') return false; + p = skip_ws(p) + 1; + p = skip_ws(p); + int count = 0; + if(*p == ']') return false; + for(;;) { + if(*p != '"') return false; + p++; + char value[12]; + size_t j = 0; + while(*p && *p != '"') { + char c = *p++; + if(c == '\\' && *p) c = *p++; + if(j + 1 < sizeof value && c >= 32) value[j++] = c; + else return false; + } + if(*p != '"' || j == 0) return false; + value[j] = 0; + if(strcmp(value, "tdeck") == 0) { + if(target_tdeck) *target_tdeck = true; + } else if(strcmp(value, "sim") == 0) { + if(target_sim) *target_sim = true; + } else { + return false; + } + count++; + p = skip_ws(p + 1); + if(*p == ',') { p = skip_ws(p + 1); continue; } + if(*p == ']') return count > 0; return false; } } @@ -824,125 +942,225 @@ static const char *catalog_next_object(const char *p, char *out, size_t cap, return NULL; } -static bool catalog_validate_app(const char *obj, lz_app_catalog_report_t *r) +static bool catalog_screenshots_ok(const char *p, uint8_t *count_out) +{ + if(count_out) *count_out = 0; + if(!p) return true; + p = skip_ws(p); + if(*p != '[') return false; + p = skip_ws(p + 1); + int count = 0; + if(*p == ']') return true; + for(;;) { + if(++count > LZ_APP_CATALOG_SCREENSHOT_MAX) return false; + char obj[360]; + bool done = false, too_big = false; + p = catalog_next_object(p, obj, sizeof obj, &done, &too_big); + if(done || !p || too_big) return false; + char url[181]; + uint32_t width = 0, height = 0; + if(!json_get_string_bounded(obj, "url", url, sizeof url) || + !catalog_url_ok(url) || + !json_get_u32(obj, "width", &width) || width == 0 || + !json_get_u32(obj, "height", &height) || height == 0) { + return false; + } + const char *sha_p = json_value_for(obj, "sha256"); + if(sha_p) { + char sha[65]; + if(!json_get_string_bounded(obj, "sha256", sha, sizeof sha) || + !catalog_sha256_ok(sha)) { + return false; + } + } + p = skip_ws(p); + if(*p == ',') { p = skip_ws(p + 1); continue; } + if(*p == ']') { + if(count_out) *count_out = (uint8_t)count; + return true; + } + return false; + } +} + +static bool catalog_id_seen(const char seen[][24], int count, const char *id) +{ + for(int i = 0; i < count; i++) + if(strcmp(seen[i], id) == 0) return true; + return false; +} + +static bool catalog_validate_app(const char *obj, lz_app_catalog_report_t *r, + lz_app_catalog_entry_t *entry, + char seen_ids[][24], int seen_count) { - char id[24], name[32], version[16], author[28], desc[96], icon[20]; - char api[12], compat[32], url[128], sha[65]; - uint32_t size = 0; - int hue = -1; + lz_app_catalog_entry_t e; + memset(&e, 0, sizeof e); + e.hue = -1; - if(!json_get_string_bounded(obj, "id", id, sizeof id)) + if(!json_get_string_bounded(obj, "id", e.id, sizeof e.id)) return catalog_fail(r, NULL, "missing id"); - if(!safe_id(id)) return catalog_fail(r, id, "unsafe id"); - if(!json_get_string_bounded(obj, "name", name, sizeof name)) - return catalog_fail(r, id, "missing name"); - if(!json_get_string_bounded(obj, "version", version, sizeof version)) - return catalog_fail(r, id, "missing version"); - if(!json_get_string_bounded(obj, "author", author, sizeof author)) - return catalog_fail(r, id, "missing author"); - if(!json_get_string_bounded(obj, "description", desc, sizeof desc)) - return catalog_fail(r, id, "missing description"); - if(!json_get_string_bounded(obj, "icon", icon, sizeof icon)) - return catalog_fail(r, id, "missing icon"); - if(!json_get_string_bounded(obj, "api_version", api, sizeof api)) - return catalog_fail(r, id, "missing api_version"); - if(!api_version_supported(api)) return catalog_fail(r, id, "unsupported SDK"); - if(!json_get_string_bounded(obj, "compatibility", compat, sizeof compat)) - return catalog_fail(r, id, "missing compatibility"); - if(!json_get_string_bounded(obj, "download_url", url, sizeof url)) - return catalog_fail(r, id, "missing download_url"); - if(!catalog_url_ok(url)) return catalog_fail(r, id, "bad download_url"); - if(!json_get_string_bounded(obj, "sha256", sha, sizeof sha)) - return catalog_fail(r, id, "missing sha256"); - if(!catalog_sha256_ok(sha)) return catalog_fail(r, id, "bad sha256"); - if(!json_get_u32(obj, "size", &size)) - return catalog_fail(r, id, "missing size"); - if(size == 0 || size > LZ_APP_CATALOG_PACKAGE_MAX_BYTES) - return catalog_fail(r, id, "bad size"); - if(json_get_int(obj, "hue", &hue) && (hue < -1 || hue > 359)) - return catalog_fail(r, id, "bad hue"); + if(!safe_id(e.id)) return catalog_fail(r, e.id, "unsafe id"); + if(catalog_id_seen((const char (*)[24])seen_ids, seen_count, e.id)) + return catalog_fail(r, e.id, "duplicate id"); + if(!json_get_string_bounded(obj, "name", e.name, sizeof e.name)) + return catalog_fail(r, e.id, "missing name"); + if(!json_get_string_bounded(obj, "version", e.version, sizeof e.version)) + return catalog_fail(r, e.id, "missing version"); + if(!catalog_version_ok(e.version)) + return catalog_fail(r, e.id, "bad version"); + if(!json_get_string_bounded(obj, "author", e.author, sizeof e.author)) + return catalog_fail(r, e.id, "missing author"); + if(!json_get_string_bounded(obj, "summary", e.summary, sizeof e.summary)) + return catalog_fail(r, e.id, "missing summary"); + if(!json_string_present_max(obj, "description", 240)) + return catalog_fail(r, e.id, "missing description"); + if(!json_get_string_bounded(obj, "icon", e.icon, sizeof e.icon)) + return catalog_fail(r, e.id, "missing icon"); + if(!catalog_icon_ok(e.icon)) return catalog_fail(r, e.id, "bad icon"); + if(!json_get_string_bounded(obj, "api_version", e.api_version, sizeof e.api_version)) + return catalog_fail(r, e.id, "missing api_version"); + if(!api_version_supported(e.api_version)) return catalog_fail(r, e.id, "unsupported SDK"); + if(!json_get_string_bounded(obj, "package_url", e.package_url, sizeof e.package_url)) + return catalog_fail(r, e.id, "missing package_url"); + if(!catalog_url_ok(e.package_url)) return catalog_fail(r, e.id, "bad package_url"); + if(!json_get_string_bounded(obj, "package_sha256", e.package_sha256, sizeof e.package_sha256)) + return catalog_fail(r, e.id, "missing package_sha256"); + if(!catalog_sha256_ok(e.package_sha256)) return catalog_fail(r, e.id, "bad package_sha256"); + if(!json_get_u32(obj, "package_bytes", &e.package_bytes)) + return catalog_fail(r, e.id, "missing package_bytes"); + if(e.package_bytes == 0 || e.package_bytes > LZ_APP_CATALOG_PACKAGE_MAX_BYTES) + return catalog_fail(r, e.id, "bad package_bytes"); + if(!json_get_int(obj, "hue", &e.hue) || e.hue < -1 || e.hue > 359) + return catalog_fail(r, e.id, "bad hue"); uint16_t perms = 0; const char *p = json_value_for(obj, "permissions"); - if(!p || !json_parse_permissions_value(p, &perms)) - return catalog_fail(r, id, "bad permissions"); + if(!p || !catalog_parse_permissions_value(p, &perms)) + return catalog_fail(r, e.id, "bad permissions"); + e.permissions = perms; + + const char *compat = json_value_for(obj, "compatibility"); + if(!compat || *skip_ws(compat) != '{') + return catalog_fail(r, e.id, "missing compatibility"); + if(!catalog_api_versions_ok(json_value_for(compat, "api_versions"), e.api_version)) + return catalog_fail(r, e.id, "bad compatibility"); + if(!catalog_targets_ok(json_value_for(compat, "targets"), &e.target_tdeck, &e.target_sim)) + return catalog_fail(r, e.id, "bad compatibility"); + const char *min_os = json_value_for(compat, "min_os"); + if(min_os) { + if(!json_get_string_bounded(compat, "min_os", e.min_os, sizeof e.min_os) || + !catalog_version_ok(e.min_os)) + return catalog_fail(r, e.id, "bad compatibility"); + } const char *shots = json_value_for(obj, "screenshots"); - if(shots && !catalog_string_array_ok(shots, true)) - return catalog_fail(r, id, "bad screenshots"); + if(!catalog_screenshots_ok(shots, &e.screenshot_count)) + return catalog_fail(r, e.id, "bad screenshots"); - if(r) r->app_count++; + if(entry) *entry = e; return true; } -bool lz_store_validate_app_catalog_json(const char *json, lz_app_catalog_report_t *out) +bool lz_store_parse_app_catalog_json(const char *json, lz_app_catalog_entry_t *out, + int cap, lz_app_catalog_report_t *report) { lz_app_catalog_report_t r; memset(&r, 0, sizeof r); r.ok = false; if(!json || !json[0]) { catalog_fail(&r, NULL, "empty catalog"); - if(out) *out = r; + if(report) *report = r; + return false; + } + if(*skip_ws(json) != '{') { + catalog_fail(&r, NULL, "catalog root"); + if(report) *report = r; return false; } if(strlen(json) > LZ_APP_CATALOG_JSON_MAX) { catalog_fail(&r, NULL, "catalog too large"); - if(out) *out = r; + if(report) *report = r; return false; } char schema[32]; if(!json_get_string_bounded(json, "schema", schema, sizeof schema) || - strcmp(schema, "limitlezz.app_catalog.v1") != 0) { + strcmp(schema, LZ_APP_CATALOG_SCHEMA) != 0) { catalog_fail(&r, NULL, "bad schema"); - if(out) *out = r; + if(report) *report = r; + return false; + } + const char *generated_at = json_value_for(json, "generated_at"); + if(generated_at && *generated_at != '"') { + catalog_fail(&r, NULL, "bad generated_at"); + if(report) *report = r; return false; } const char *apps = json_array_for(json, "apps"); if(!apps) { catalog_fail(&r, NULL, "missing apps"); - if(out) *out = r; + if(report) *report = r; return false; } const char *p = skip_ws(apps + 1); if(*p == ']') { - catalog_fail(&r, NULL, "empty apps"); - if(out) *out = r; - return false; + r.ok = true; + r.app_count = 0; + if(report) *report = r; + return true; } - char obj[1536]; + char *obj = (char *)malloc(1536); + char (*seen_ids)[24] = (char (*)[24])calloc(LZ_APP_CATALOG_MAX_APPS, sizeof(*seen_ids)); + if(!obj || !seen_ids) { + free(obj); + free(seen_ids); + catalog_fail(&r, NULL, "catalog buffer unavailable"); + if(report) *report = r; + return false; + } + bool result = false; for(;;) { bool done = false, too_big = false; - p = catalog_next_object(p, obj, sizeof obj, &done, &too_big); + p = catalog_next_object(p, obj, 1536, &done, &too_big); if(done) break; if(!p) { catalog_fail(&r, NULL, "bad apps array"); - if(out) *out = r; - return false; + goto done; } if(too_big) { catalog_fail(&r, NULL, "app entry too large"); - if(out) *out = r; - return false; + goto done; } if(r.app_count + r.rejected_count >= LZ_APP_CATALOG_MAX_APPS) { catalog_fail(&r, NULL, "too many apps"); - if(out) *out = r; - return false; + goto done; } - if(!catalog_validate_app(obj, &r)) { - if(out) *out = r; - return false; + lz_app_catalog_entry_t entry; + if(!catalog_validate_app(obj, &r, &entry, seen_ids, r.app_count)) { + goto done; } + snprintf(seen_ids[r.app_count], sizeof seen_ids[r.app_count], "%s", entry.id); + if(out && cap > 0 && r.app_count < cap) out[r.app_count] = entry; + r.app_count++; } - r.ok = r.app_count > 0 && r.rejected_count == 0; + r.ok = r.rejected_count == 0; if(!r.ok && !r.first_error[0]) snprintf(r.first_error, sizeof r.first_error, "invalid catalog"); - if(out) *out = r; - return r.ok; + result = r.ok; +done: + free(obj); + free(seen_ids); + if(report) *report = r; + return result; +} + +bool lz_store_validate_app_catalog_json(const char *json, lz_app_catalog_report_t *out) +{ + return lz_store_parse_app_catalog_json(json, NULL, 0, out); } static int catalog_report_line(char *buf, int n, const char *prefix, @@ -959,40 +1177,84 @@ static int catalog_report_line(char *buf, int n, const char *prefix, prefix, r->app_count, r->rejected_count, r->first_error); } -static bool catalog_read_file(const char *path, lz_app_catalog_report_t *r) +static bool catalog_read_file(const char *path, lz_app_catalog_entry_t *out, int cap, + lz_app_catalog_report_t *r, int *loaded) { + if(loaded) *loaded = 0; FILE *f = fopen(path, "rb"); if(!f) return false; - char json[LZ_APP_CATALOG_JSON_MAX + 2]; - size_t n = fread(json, 1, sizeof json - 1, f); + size_t cap_json = LZ_APP_CATALOG_JSON_MAX + 2; + char *json = (char *)malloc(cap_json); + if(!json) { + fclose(f); + lz_app_catalog_report_t tmp; + memset(&tmp, 0, sizeof tmp); + tmp.ok = false; + catalog_fail(&tmp, NULL, "catalog buffer unavailable"); + if(r) *r = tmp; + return true; + } + size_t n = fread(json, 1, cap_json - 1, f); fclose(f); json[n] = 0; - if(n >= sizeof json - 1) { + if(n >= cap_json - 1) { lz_app_catalog_report_t tmp; memset(&tmp, 0, sizeof tmp); tmp.ok = false; catalog_fail(&tmp, NULL, "catalog too large"); if(r) *r = tmp; + free(json); return true; } - lz_store_validate_app_catalog_json(json, r); + lz_store_parse_app_catalog_json(json, out, cap, r); + free(json); + if(loaded && r && r->ok) *loaded = r->app_count < cap ? r->app_count : cap; return true; } +int lz_store_load_app_catalog(lz_app_catalog_entry_t *out, int cap, + lz_app_catalog_report_t *report) +{ + if(!out || cap <= 0) return 0; + if(report) memset(report, 0, sizeof *report); + char path[160]; + int loaded = 0; + if(g_persist) { + path_for(path, sizeof path, "app_catalog.json"); + if(catalog_read_file(path, out, cap, report, &loaded)) return loaded; + + path_join(path, sizeof path, g_dir, "catalog/index.json"); + if(catalog_read_file(path, out, cap, report, &loaded)) return loaded; + } + if(g_appfs_dir[0]) { + path_join(path, sizeof path, g_appfs_dir, "catalog/index.json"); + if(catalog_read_file(path, out, cap, report, &loaded)) return loaded; + } + if(report) { + report->ok = false; + snprintf(report->first_error, sizeof report->first_error, "catalog missing"); + } + return 0; +} + int lz_store_app_catalog_diag(char *buf, int n) { if(!buf || n <= 0) return 0; char path[160]; if(g_persist) { - path_join(path, sizeof path, g_dir, "catalog/index.json"); + path_for(path, sizeof path, "app_catalog.json"); lz_app_catalog_report_t r; - if(catalog_read_file(path, &r)) + if(catalog_read_file(path, NULL, 0, &r, NULL)) + return catalog_report_line(buf, n, "app catalog", &r); + + path_join(path, sizeof path, g_dir, "catalog/index.json"); + if(catalog_read_file(path, NULL, 0, &r, NULL)) return catalog_report_line(buf, n, "app catalog", &r); } if(g_appfs_dir[0]) { path_join(path, sizeof path, g_appfs_dir, "catalog/index.json"); lz_app_catalog_report_t r; - if(catalog_read_file(path, &r)) + if(catalog_read_file(path, NULL, 0, &r, NULL)) return catalog_report_line(buf, n, "app catalog", &r); } return snprintf(buf, (size_t)n, "app catalog: no cached index\n"); @@ -1001,35 +1263,47 @@ int lz_store_app_catalog_diag(char *buf, int n) int lz_store_app_catalog_selftest(char *buf, int n) { static const char valid[] = - "{\"schema\":\"limitlezz.app_catalog.v1\",\"updated\":\"2026-06-18T00:00:00Z\"," + "{\"schema\":\"limitlezz.app.catalog.v1\",\"generated_at\":\"2026-06-18T00:00:00Z\"," "\"apps\":[" "{\"id\":\"weather.mesh\",\"name\":\"Weather Mesh\",\"version\":\"0.1.0\"," - "\"author\":\"Limitless\",\"description\":\"Local weather reports\"," + "\"author\":\"Limitless\",\"summary\":\"Local weather reports\"," + "\"description\":\"Local weather reports from nearby mesh stations\"," "\"icon\":\"weather\",\"hue\":48,\"api_version\":\"0.1\"," - "\"compatibility\":\"tdeck\",\"permissions\":[\"display\",\"network_wifi\"]," - "\"download_url\":\"https://apps.example.invalid/weather.mesh.zip\"," - "\"sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," - "\"size\":32768,\"screenshots\":[\"https://apps.example.invalid/weather.bmp\"]}," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\",\"sim\"]}," + "\"permissions\":[\"display\",\"network_wifi\"]," + "\"package_url\":\"https://apps.example.invalid/weather.mesh.zip\"," + "\"package_sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," + "\"package_bytes\":32768," + "\"screenshots\":[{\"url\":\"https://apps.example.invalid/weather.bmp\"," + "\"width\":320,\"height\":240}]}," "{\"id\":\"notes.local\",\"name\":\"Field Notes\",\"version\":\"0.1.0\"," - "\"author\":\"Limitless\",\"description\":\"Simple local notes\"," - "\"icon\":\"notes\",\"api_version\":\"0.1\",\"compatibility\":\"tdeck\"," + "\"author\":\"Limitless\",\"summary\":\"Simple local notes\"," + "\"description\":\"Simple local notes with scoped storage\"," + "\"icon\":\"notes\",\"hue\":95,\"api_version\":\"0.1\"," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\"]}," "\"permissions\":[\"display\",\"input\",\"storage\"]," - "\"download_url\":\"https://apps.example.invalid/notes.local.zip\"," - "\"sha256\":\"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"," - "\"size\":49152}]}"; + "\"package_url\":\"https://apps.example.invalid/notes.local.zip\"," + "\"package_sha256\":\"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd\"," + "\"package_bytes\":49152}]}"; static const char invalid[] = - "{\"schema\":\"limitlezz.app_catalog.v1\",\"apps\":[" + "{\"schema\":\"limitlezz.app.catalog.v1\",\"apps\":[" "{\"id\":\"bad.local\",\"name\":\"Bad\",\"version\":\"0.1.0\"," - "\"author\":\"Limitless\",\"description\":\"Bad checksum\",\"icon\":\"bug\"," - "\"api_version\":\"0.1\",\"compatibility\":\"tdeck\"," - "\"permissions\":[\"display\"],\"download_url\":\"https://apps.example.invalid/bad.zip\"," - "\"sha256\":\"not-a-sha\",\"size\":1024}]}"; + "\"author\":\"Limitless\",\"summary\":\"Bad checksum\"," + "\"description\":\"Bad checksum\",\"icon\":\"bug\",\"hue\":20," + "\"api_version\":\"0.1\"," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\"]}," + "\"permissions\":[\"display\"]," + "\"package_url\":\"https://apps.example.invalid/bad.zip\"," + "\"package_sha256\":\"not-a-sha\",\"package_bytes\":1024}]}"; lz_app_catalog_report_t ok, bad; - bool valid_ok = lz_store_validate_app_catalog_json(valid, &ok); + lz_app_catalog_entry_t entries[2]; + bool valid_ok = lz_store_parse_app_catalog_json(valid, entries, 2, &ok); bool invalid_ok = lz_store_validate_app_catalog_json(invalid, &bad); const char *result = (valid_ok && ok.app_count == 2 && !invalid_ok && - strcmp(bad.first_error, "bad sha256") == 0) ? "PASS" : "FAIL"; + strcmp(bad.first_error, "bad package_sha256") == 0 && + strcmp(entries[0].package_url, + "https://apps.example.invalid/weather.mesh.zip") == 0) ? "PASS" : "FAIL"; return snprintf(buf, (size_t)n, "App catalog selftest: %s valid=%d invalid_error=\"%s\"\n", result, ok.app_count, bad.first_error); @@ -1052,6 +1326,20 @@ static bool app_retained_name(const char *name) return name && strncmp(name, LZ_APP_RETAINED_PREFIX, strlen(LZ_APP_RETAINED_PREFIX)) == 0; } +static bool app_install_root(char *root, size_t root_cap, char *err, int err_cap) +{ + if(g_persist) { + snprintf(root, root_cap, "%s", g_dir); + return true; + } + if(g_appfs_dir[0]) { + snprintf(root, root_cap, "%s", g_appfs_dir); + return true; + } + set_err(err, err_cap, "storage unavailable"); + return false; +} + static bool app_retained_paths(const char *id, char *root, size_t root_cap, char *data, size_t data_cap, char *err, int err_cap) @@ -1377,12 +1665,24 @@ static bool load_app_manifest(const char *pkg_dir, lz_local_app_t *app, FILE *f = fopen(manifest, "rb"); if(!f) return manifest_fail(reason, reason_cap, "missing manifest"); - char json[1537]; - size_t n = fread(json, 1, sizeof json - 1, f); + size_t cap_json = 1537; + char *json = (char *)malloc(cap_json); + if(!json) { + fclose(f); + return manifest_fail(reason, reason_cap, "manifest buffer unavailable"); + } + bool ok = false; + size_t n = fread(json, 1, cap_json - 1, f); fclose(f); json[n] = 0; - if(n == 0) return manifest_fail(reason, reason_cap, "empty manifest"); - if(n >= sizeof json - 1) return manifest_fail(reason, reason_cap, "manifest too large"); + if(n == 0) { + manifest_fail(reason, reason_cap, "empty manifest"); + goto done; + } + if(n >= cap_json - 1) { + manifest_fail(reason, reason_cap, "manifest too large"); + goto done; + } memset(app, 0, sizeof *app); app->hue = -1; @@ -1392,12 +1692,18 @@ static bool load_app_manifest(const char *pkg_dir, lz_local_app_t *app, snprintf(app->icon, sizeof app->icon, "description"); app->permissions = LZ_APP_PERM_DISPLAY | LZ_APP_PERM_INPUT; - if(!json_get_string(json, "id", app->id, sizeof app->id)) - return manifest_fail(reason, reason_cap, "missing id"); - if(!json_get_string(json, "name", app->name, sizeof app->name)) - return manifest_fail(reason, reason_cap, "missing name"); - if(!json_get_string(json, "entry", app->entry, sizeof app->entry)) - return manifest_fail(reason, reason_cap, "missing entry"); + if(!json_get_string(json, "id", app->id, sizeof app->id)) { + manifest_fail(reason, reason_cap, "missing id"); + goto done; + } + if(!json_get_string(json, "name", app->name, sizeof app->name)) { + manifest_fail(reason, reason_cap, "missing name"); + goto done; + } + if(!json_get_string(json, "entry", app->entry, sizeof app->entry)) { + manifest_fail(reason, reason_cap, "missing entry"); + goto done; + } json_get_string(json, "version", app->version, sizeof app->version); json_get_string(json, "author", app->author, sizeof app->author); json_get_string(json, "api_version", app->api_version, sizeof app->api_version); @@ -1407,22 +1713,37 @@ static bool load_app_manifest(const char *pkg_dir, lz_local_app_t *app, json_get_int(json, "hue", &app->hue); const char *perms = json_value_for(json, "permissions"); - if(perms && !json_parse_permissions_value(perms, &app->permissions)) - return manifest_fail(reason, reason_cap, "bad permissions"); + if(perms && !json_parse_permissions_value(perms, &app->permissions)) { + manifest_fail(reason, reason_cap, "bad permissions"); + goto done; + } - if(!safe_id(app->id)) return manifest_fail(reason, reason_cap, "unsafe id"); - if(!safe_entry(app->entry)) return manifest_fail(reason, reason_cap, "unsafe entry"); - if(!api_version_supported(app->api_version)) - return manifest_fail(reason, reason_cap, "unsupported SDK"); + if(!safe_id(app->id)) { + manifest_fail(reason, reason_cap, "unsafe id"); + goto done; + } + if(!safe_entry(app->entry)) { + manifest_fail(reason, reason_cap, "unsafe entry"); + goto done; + } + if(!api_version_supported(app->api_version)) { + manifest_fail(reason, reason_cap, "unsupported SDK"); + goto done; + } if(app->hue < -1 || app->hue > 359) app->hue = -1; char entry_path[160]; path_join(entry_path, sizeof entry_path, pkg_dir, app->entry); - if(!path_is_file(entry_path)) - return manifest_fail(reason, reason_cap, "missing entry file"); + if(!path_is_file(entry_path)) { + manifest_fail(reason, reason_cap, "missing entry file"); + goto done; + } snprintf(app->path, sizeof app->path, "%s", pkg_dir); - return true; + ok = true; +done: + free(json); + return ok; } static bool app_install_paths(const char *id, char *apps, size_t apps_cap, @@ -1431,10 +1752,6 @@ static bool app_install_paths(const char *id, char *apps, size_t apps_cap, char *backup, size_t backup_cap, char *err, int err_cap) { - if(!g_persist) { - set_err(err, err_cap, "storage unavailable"); - return false; - } if(!safe_id(id) || strlen(id) > LZ_APP_INSTALL_ID_MAX) { set_err(err, err_cap, "unsafe id"); return false; @@ -1449,7 +1766,9 @@ static bool app_install_paths(const char *id, char *apps, size_t apps_cap, return false; } - path_join(apps, apps_cap, g_dir, "apps"); + char root[128]; + if(!app_install_root(root, sizeof root, err, err_cap)) return false; + path_join(apps, apps_cap, root, "apps"); path_join(live, live_cap, apps, id); path_join(staging, staging_cap, apps, staging_name); path_join(backup, backup_cap, apps, backup_name); @@ -1557,6 +1876,803 @@ bool lz_store_promote_app_install(const char *id, char *err, int err_cap) return true; } +#define LZ_ZIP_LOCAL_SIG 0x04034b50u +#define LZ_ZIP_CENTRAL_SIG 0x02014b50u +#define LZ_ZIP_EOCD_SIG 0x06054b50u +#define LZ_ZIP_METHOD_STORED 0u +#define LZ_ZIP_FLAG_ENCRYPTED 0x0001u +#define LZ_ZIP_FLAG_DATA_DESCRIPTOR 0x0008u +#define LZ_APP_PACKAGE_MAX_FILES 24 +#define LZ_APP_PACKAGE_MAX_FILE_BYTES (256u * 1024u) + +static bool app_package_file_path_ok(const char *path) +{ + if(!safe_entry(path)) return false; + if(path[0] == '.' || path[0] == 0) return false; + if(strcmp(path, "data") == 0 || strncmp(path, "data/", 5) == 0) return false; + const char *seg = path; + while(*seg) { + if(*seg == '/' || *seg == '.') return false; + const char *slash = strchr(seg, '/'); + size_t len = slash ? (size_t)(slash - seg) : strlen(seg); + if(len == 0 || (len == 1 && seg[0] == '.')) return false; + seg = slash ? slash + 1 : seg + len; + } + return true; +} + +static bool app_package_dir_path_ok(const char *path) +{ + if(!path || !path[0]) return false; + char tmp[80]; + if(strlen(path) >= sizeof tmp) return false; + snprintf(tmp, sizeof tmp, "%s", path); + size_t len = strlen(tmp); + while(len > 0 && tmp[len - 1] == '/') tmp[--len] = 0; + return len > 0 && app_package_file_path_ok(tmp); +} + +static uint16_t zip_u16(const uint8_t *p) +{ + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +static uint32_t zip_u32(const uint8_t *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | + ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static bool app_package_read_exact(FILE *f, void *dst, size_t len, + char *err, int err_cap) +{ + if(len == 0) return true; + if(!f || !dst || fread(dst, 1, len, f) != len) { + set_err(err, err_cap, "package read failed"); + return false; + } + return true; +} + +static bool app_package_skip_bytes(FILE *f, uint32_t bytes, char *err, int err_cap) +{ + uint8_t buf[128]; + uint32_t left = bytes; + while(left > 0) { + size_t want = left < sizeof buf ? (size_t)left : sizeof buf; + if(fread(buf, 1, want, f) != want) { + set_err(err, err_cap, "package truncated"); + return false; + } + left -= (uint32_t)want; + } + return true; +} + +static bool app_package_mkdir_parents(const char *path, char *err, int err_cap) +{ + char tmp[180]; + snprintf(tmp, sizeof tmp, "%s", path); + for(char *p = tmp; *p; p++) { + if(*p != '/' && *p != '\\') continue; + char saved = *p; + *p = 0; + if(tmp[0] && !path_mkdir(tmp)) { + set_err(err, err_cap, "mkdir failed"); + return false; + } + *p = saved; + } + return true; +} + +static bool app_package_copy_bytes(FILE *in, const char *dest, uint32_t bytes, + uint32_t *total, char *err, int err_cap) +{ + if(!app_package_mkdir_parents(dest, err, err_cap)) return false; + FILE *out = fopen(dest, "wb"); + if(!out) { + set_err(err, err_cap, "file create failed"); + return false; + } + uint8_t buf[256]; + uint32_t left = bytes; + while(left > 0) { + size_t want = left < sizeof buf ? (size_t)left : sizeof buf; + size_t got = fread(buf, 1, want, in); + if(got != want) { + fclose(out); + set_err(err, err_cap, "package truncated"); + return false; + } + if(fwrite(buf, 1, got, out) != got) { + fclose(out); + set_err(err, err_cap, "file write failed"); + return false; + } + left -= (uint32_t)got; + if(total) *total += (uint32_t)got; + } + fclose(out); + return true; +} + +static bool lz_store_extract_app_package(const char *package_path, const char *staging, + lz_app_package_install_t *out, + char *err, int err_cap) +{ + FILE *f = fopen(package_path, "rb"); + if(!f) { + set_err(err, err_cap, "package missing"); + return false; + } + + int files = 0; + uint32_t total = 0; + bool saw_manifest = false; + for(;;) { + uint8_t sigb[4]; + size_t got = fread(sigb, 1, sizeof sigb, f); + if(got == 0) break; + if(got != sizeof sigb) { + fclose(f); + set_err(err, err_cap, "package truncated"); + return false; + } + uint32_t sig = zip_u32(sigb); + if(sig == LZ_ZIP_CENTRAL_SIG || sig == LZ_ZIP_EOCD_SIG) break; + if(sig != LZ_ZIP_LOCAL_SIG) { + fclose(f); + set_err(err, err_cap, "bad zip header"); + return false; + } + + uint8_t hdr[26]; + if(!app_package_read_exact(f, hdr, sizeof hdr, err, err_cap)) { + fclose(f); + return false; + } + uint16_t flags = zip_u16(hdr + 2); + uint16_t method = zip_u16(hdr + 4); + uint32_t comp_size = zip_u32(hdr + 14); + uint32_t uncomp_size = zip_u32(hdr + 18); + uint16_t name_len = zip_u16(hdr + 22); + uint16_t extra_len = zip_u16(hdr + 24); + + if((flags & (LZ_ZIP_FLAG_ENCRYPTED | LZ_ZIP_FLAG_DATA_DESCRIPTOR)) != 0) { + fclose(f); + set_err(err, err_cap, "unsupported zip flags"); + return false; + } + if(method != LZ_ZIP_METHOD_STORED) { + fclose(f); + set_err(err, err_cap, "unsupported zip method"); + return false; + } + if(comp_size != uncomp_size || uncomp_size > LZ_APP_PACKAGE_MAX_FILE_BYTES) { + fclose(f); + set_err(err, err_cap, "bad file size"); + return false; + } + if(name_len == 0 || name_len >= 80 || extra_len > 1024) { + fclose(f); + set_err(err, err_cap, "bad zip name"); + return false; + } + + char rel[80]; + if(!app_package_read_exact(f, rel, name_len, err, err_cap)) { + fclose(f); + return false; + } + rel[name_len] = 0; + if(!app_package_skip_bytes(f, extra_len, err, err_cap)) { + fclose(f); + return false; + } + + bool is_dir = rel[name_len - 1] == '/'; + if(is_dir) { + if(comp_size != 0 || !app_package_dir_path_ok(rel)) { + fclose(f); + set_err(err, err_cap, "bad file path"); + return false; + } + char clean[80]; + snprintf(clean, sizeof clean, "%s", rel); + size_t clen = strlen(clean); + while(clen > 0 && clean[clen - 1] == '/') clean[--clen] = 0; + char dir[180]; + path_join(dir, sizeof dir, staging, clean); + if(!app_package_mkdir_parents(dir, err, err_cap) || !path_mkdir(dir)) { + fclose(f); + set_err(err, err_cap, "mkdir failed"); + return false; + } + continue; + } + + if(!app_package_file_path_ok(rel)) { + fclose(f); + set_err(err, err_cap, "bad file path"); + return false; + } + if(++files > LZ_APP_PACKAGE_MAX_FILES) { + fclose(f); + set_err(err, err_cap, "too many files"); + return false; + } + if(total > LZ_APP_PACKAGE_MAX_BYTES || comp_size > LZ_APP_PACKAGE_MAX_BYTES - total) { + fclose(f); + set_err(err, err_cap, "package too large"); + return false; + } + char dest[180]; + path_join(dest, sizeof dest, staging, rel); + if(!app_package_copy_bytes(f, dest, comp_size, &total, err, err_cap)) { + fclose(f); + return false; + } + if(strcmp(rel, "manifest.json") == 0) saw_manifest = true; + } + fclose(f); + if(!saw_manifest) { + set_err(err, err_cap, "missing manifest"); + return false; + } + if(out) { + out->file_count = (uint16_t)files; + out->extracted_bytes = total; + } + return true; +} + +bool lz_store_install_app_package(const char *id, const char *package_path, + const char *sha256, uint32_t package_bytes, + lz_app_package_install_t *out) +{ + lz_app_package_install_t r; + memset(&r, 0, sizeof r); + if(id) snprintf(r.id, sizeof r.id, "%s", id); + r.package_bytes = package_bytes; + if(package_path) snprintf(r.package_path, sizeof r.package_path, "%s", package_path); + char err[48] = ""; + + if(!id || !package_path || !sha256 || package_bytes == 0) { + set_err(err, sizeof err, "missing package args"); + goto fail; + } + if(package_bytes > LZ_APP_PACKAGE_MAX_BYTES) { + set_err(err, sizeof err, "package too large"); + goto fail; + } + struct stat st; + if(stat(package_path, &st) != 0 || S_ISDIR(st.st_mode)) { + set_err(err, sizeof err, "package missing"); + goto fail; + } + if(st.st_size < 0 || (uint32_t)st.st_size != package_bytes) { + set_err(err, sizeof err, "size mismatch"); + goto fail; + } + if(!lz_store_verify_file_sha256(package_path, sha256, err, sizeof err)) + goto fail; + + char staging[160], live[160]; + if(!lz_store_prepare_app_install(id, staging, sizeof staging, + live, sizeof live, err, sizeof err)) + goto fail; + if(!lz_store_extract_app_package(package_path, staging, &r, err, sizeof err)) { + char discard[32]; + lz_store_discard_app_install(id, discard, sizeof discard); + goto fail; + } + if(!lz_store_promote_app_install(id, err, sizeof err)) { + char discard[32]; + lz_store_discard_app_install(id, discard, sizeof discard); + goto fail; + } + + lz_local_app_t app; + char reason[48] = ""; + if(load_app_manifest(live, &app, reason, sizeof reason)) { + snprintf(r.version, sizeof r.version, "%s", app.version); + } + r.ok = true; + if(out) *out = r; + return true; + +fail: + snprintf(r.error, sizeof r.error, "%s", err[0] ? err : "install failed"); + if(out) *out = r; + return false; +} + +static bool app_catalog_find_entry(const char *id, lz_app_catalog_entry_t *entry, + char *err, int err_cap) +{ + if(!id || !id[0] || !safe_id(id)) { + set_err(err, err_cap, "bad catalog id"); + return false; + } + lz_app_catalog_entry_t *catalog = + (lz_app_catalog_entry_t *)calloc(LZ_APP_CATALOG_MAX_APPS, sizeof(lz_app_catalog_entry_t)); + if(!catalog) { + set_err(err, err_cap, "catalog buffer unavailable"); + return false; + } + lz_app_catalog_report_t report; + memset(&report, 0, sizeof report); + int n = lz_store_load_app_catalog(catalog, LZ_APP_CATALOG_MAX_APPS, &report); + if(n <= 0) { + set_err(err, err_cap, report.first_error[0] ? report.first_error : "catalog missing"); + free(catalog); + return false; + } + for(int i = 0; i < n; i++) { + if(strcmp(catalog[i].id, id) == 0) { + if(entry) *entry = catalog[i]; + free(catalog); + return true; + } + } + free(catalog); + set_err(err, err_cap, "catalog app missing"); + return false; +} + +static bool app_catalog_package_paths(const lz_app_catalog_entry_t *entry, + char *path, int path_cap, + char *tmp, int tmp_cap, + char *err, int err_cap) +{ + if(!g_persist) { + set_err(err, err_cap, "storage unavailable"); + return false; + } + char dir[128]; + path_join(dir, sizeof dir, g_dir, "packages"); + if(!path_mkdir(dir)) { + set_err(err, err_cap, "package cache mkdir failed"); + return false; + } + char name[40]; + snprintf(name, sizeof name, "%s.zip", entry->id); + if((int)strlen(name) >= (int)sizeof name) { + set_err(err, err_cap, "package name too long"); + return false; + } + path_join(path, (size_t)path_cap, dir, name); + if((int)strlen(path) + 4 >= tmp_cap) { + set_err(err, err_cap, "package path too long"); + return false; + } + snprintf(tmp, (size_t)tmp_cap, "%s.tmp", path); + return true; +} + +bool lz_store_install_app_catalog_entry(const char *id, const char *package_path, + lz_app_package_install_t *out) +{ + lz_app_package_install_t *r = (lz_app_package_install_t *)calloc(1, sizeof *r); + lz_app_catalog_entry_t *entry = (lz_app_catalog_entry_t *)malloc(sizeof *entry); + char *selected = (char *)malloc(160); + char *tmp = (char *)malloc(168); + char err[48] = ""; + bool result = false; + if(!r || !entry || !selected || !tmp) { + set_err(err, sizeof err, "catalog install buffer unavailable"); + goto fail; + } + if(id) snprintf(r->id, sizeof r->id, "%s", id); + selected[0] = 0; + tmp[0] = 0; + + if(!app_catalog_find_entry(id, entry, err, sizeof err)) + goto fail; + + bool fetched = false; + if(package_path && package_path[0]) { + if(snprintf(selected, 160, "%s", package_path) >= 160) { + set_err(err, sizeof err, "package path too long"); + goto fail; + } + } else { + if(!app_catalog_package_paths(entry, selected, 160, tmp, 168, err, sizeof err)) + goto fail; + remove(tmp); + uint32_t fetched_bytes = 0; + if(!lz_app_package_fetch(entry->package_url, tmp, entry->package_bytes, + LZ_APP_PACKAGE_MAX_BYTES, &fetched_bytes, + err, sizeof err)) { + remove(tmp); + goto fail; + } + if(fetched_bytes != entry->package_bytes) { + remove(tmp); + set_err(err, sizeof err, "size mismatch"); + goto fail; + } + remove(selected); + if(rename(tmp, selected) != 0) { + remove(tmp); + set_err(err, sizeof err, "package commit failed"); + goto fail; + } + fetched = true; + } + + if(!lz_store_install_app_package(entry->id, selected, entry->package_sha256, + entry->package_bytes, r)) { + r->fetched = fetched; + snprintf(r->package_path, sizeof r->package_path, "%s", selected); + goto done; + } + r->fetched = fetched; + snprintf(r->package_path, sizeof r->package_path, "%s", selected); + result = true; + goto done; + +fail: + if(r) snprintf(r->error, sizeof r->error, "%s", + err[0] ? err : "catalog install failed"); +done: + if(out && r) *out = *r; + free(tmp); + free(selected); + free(entry); + free(r); + return result; +} + +static bool app_package_write_u16(FILE *f, uint16_t v) +{ + return fputc(v & 0xff, f) != EOF && fputc((v >> 8) & 0xff, f) != EOF; +} + +static bool app_package_write_u32(FILE *f, uint32_t v) +{ + return fputc(v & 0xff, f) != EOF && fputc((v >> 8) & 0xff, f) != EOF && + fputc((v >> 16) & 0xff, f) != EOF && fputc((v >> 24) & 0xff, f) != EOF; +} + +static bool app_package_write_local(FILE *f, const char *name, const char *body, + uint16_t method) +{ + size_t name_len = name ? strlen(name) : 0; + size_t body_len = body ? strlen(body) : 0; + if(!f || name_len == 0 || name_len > 0xffffu || body_len > 0xffffffffu) return false; + if(!app_package_write_u32(f, LZ_ZIP_LOCAL_SIG) || + !app_package_write_u16(f, 20) || !app_package_write_u16(f, 0) || + !app_package_write_u16(f, method) || + !app_package_write_u16(f, 0) || !app_package_write_u16(f, 0) || + !app_package_write_u32(f, 0) || + !app_package_write_u32(f, (uint32_t)body_len) || + !app_package_write_u32(f, (uint32_t)body_len) || + !app_package_write_u16(f, (uint16_t)name_len) || + !app_package_write_u16(f, 0)) + return false; + if(fwrite(name, 1, name_len, f) != name_len) return false; + if(body_len > 0 && fwrite(body, 1, body_len, f) != body_len) return false; + return true; +} + +static bool app_package_selftest_write_zip(const char *path, const char *id, + const char *version, + const char *extra_path, + uint16_t main_method) +{ + FILE *f = fopen(path, "wb"); + if(!f) return false; + char manifest[360]; + snprintf(manifest, sizeof manifest, + "{\"id\":\"%s\",\"name\":\"Package Selftest\",\"version\":\"%s\"," + "\"author\":\"Limitless\",\"entry\":\"main.lua\"," + "\"icon\":\"package\",\"hue\":84,\"api_version\":\"0.1\"," + "\"permissions\":[\"display\"],\"summary\":\"Installer selftest\"}", + id, version); + bool ok = app_package_write_local(f, "manifest.json", manifest, LZ_ZIP_METHOD_STORED) && + app_package_write_local(f, "main.lua", + "-- title: Package Selftest\n" + "-- body: Installed from package\n" + "return true\n", + main_method); + if(ok && extra_path) { + ok = app_package_write_local(f, extra_path, "extra package payload\n", + LZ_ZIP_METHOD_STORED); + } + ok = ok && app_package_write_u32(f, LZ_ZIP_EOCD_SIG) && + app_package_write_u16(f, 0) && app_package_write_u16(f, 0) && + app_package_write_u16(f, 0) && app_package_write_u16(f, 0) && + app_package_write_u32(f, 0) && app_package_write_u32(f, 0) && + app_package_write_u16(f, 0); + fclose(f); + if(!ok) remove(path); + return ok; +} + +static bool app_package_file_size_u32(const char *path, uint32_t *out) +{ + struct stat st; + if(stat(path, &st) != 0 || S_ISDIR(st.st_mode) || st.st_size < 0 || + st.st_size > (long)LZ_APP_PACKAGE_MAX_BYTES) + return false; + if(out) *out = (uint32_t)st.st_size; + return true; +} + +static bool app_package_hash_and_size(const char *path, char *sha, int sha_cap, + uint32_t *bytes, char *err, int err_cap) +{ + if(!app_package_file_size_u32(path, bytes)) { + set_err(err, err_cap, "size failed"); + return false; + } + return lz_store_file_sha256(path, sha, sha_cap, err, err_cap); +} + +static bool app_package_selftest_live_version(const char *id, const char *version) +{ + char apps[128], live[160], staging[160], backup[160], err[48]; + if(!app_install_paths(id, apps, sizeof apps, live, sizeof live, + staging, sizeof staging, backup, sizeof backup, + err, sizeof err)) + return false; + lz_local_app_t app; + char reason[48]; + return load_app_manifest(live, &app, reason, sizeof reason) && + strcmp(app.id, id) == 0 && strcmp(app.version, version) == 0; +} + +static void app_package_selftest_cleanup(const char *id, const char *package_path) +{ + if(package_path && package_path[0]) remove(package_path); + char apps[128], live[160], staging[160], backup[160], err[48]; + if(app_install_paths(id, apps, sizeof apps, live, sizeof live, + staging, sizeof staging, backup, sizeof backup, + err, sizeof err)) { + remove_tree(staging, err, sizeof err); + remove_tree(backup, err, sizeof err); + remove_tree(live, err, sizeof err); + } +} + +static bool app_catalog_selftest_save_catalog(const char *id, const char *version, + const char *sha, uint32_t bytes, + char *err, int err_cap) +{ + size_t cap_json = 1024; + char *json = (char *)malloc(cap_json); + if(!json) { + set_err(err, err_cap, "catalog buffer unavailable"); + return false; + } + int n = snprintf(json, cap_json, + "{\"schema\":\"%s\",\"apps\":[{" + "\"id\":\"%s\",\"name\":\"Package Selftest\",\"version\":\"%s\"," + "\"author\":\"Limitless\",\"summary\":\"Catalog installer selftest\"," + "\"description\":\"Catalog installer selftest\",\"icon\":\"package\"," + "\"hue\":84,\"api_version\":\"0.1\"," + "\"package_url\":\"https://apps.example.invalid/%s.zip\"," + "\"package_sha256\":\"%s\",\"package_bytes\":%lu," + "\"compatibility\":{\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\",\"sim\"]}," + "\"permissions\":[\"display\"]}]}", + LZ_APP_CATALOG_SCHEMA, id, version, id, sha, (unsigned long)bytes); + if(n <= 0 || n >= (int)cap_json) { + set_err(err, err_cap, "catalog too large"); + free(json); + return false; + } + bool ok = lz_store_save_app_catalog_cache(json, n, err, err_cap); + free(json); + return ok; +} + +int lz_store_app_catalog_install_selftest(char *buf, int n) +{ + if(!buf || n <= 0) return 0; + const char *id = "lz.cat.selftest"; + char root[128], err[48] = ""; + if(!app_install_root(root, sizeof root, err, sizeof err)) + return snprintf(buf, (size_t)n, "App catalog install selftest: SKIP %s\n", + err[0] ? err : "storage unavailable"); + if(!path_mkdir(root)) + return snprintf(buf, (size_t)n, "App catalog install selftest: SKIP root unavailable\n"); + + char *prev_catalog = (char *)malloc(LZ_APP_CATALOG_CACHE_MAX + 1); + if(!prev_catalog) + return snprintf(buf, (size_t)n, + "App catalog install selftest: SKIP catalog buffer unavailable\n"); + int prev_len = 0; + char prev_err[48] = ""; + bool had_prev_catalog = lz_store_load_app_catalog_cache(prev_catalog, + LZ_APP_CATALOG_CACHE_MAX + 1, + &prev_len, prev_err, sizeof prev_err); + + char package_path[160]; + path_join(package_path, sizeof package_path, root, ".lz-cat-selftest.zip"); + app_package_selftest_cleanup(id, package_path); + + bool ok = true; + char detail[64] = ""; + char sha[65]; + uint32_t bytes = 0; + lz_app_package_install_t r; + + #define CAT_INSTALL_CHECK(cond, msg) do { \ + if(ok && !(cond)) { ok = false; snprintf(detail, sizeof detail, "%s", msg); } \ + } while(0) + + CAT_INSTALL_CHECK(app_package_selftest_write_zip(package_path, id, "1.0.0", NULL, + LZ_ZIP_METHOD_STORED), + "write v1"); + CAT_INSTALL_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash v1"); + CAT_INSTALL_CHECK(app_catalog_selftest_save_catalog(id, "1.0.0", sha, bytes, + err, sizeof err), + "catalog v1"); + memset(&r, 0, sizeof r); + CAT_INSTALL_CHECK(lz_store_install_app_catalog_entry(id, package_path, &r) && + r.ok && strcmp(r.version, "1.0.0") == 0 && !r.fetched && + app_package_selftest_live_version(id, "1.0.0"), + "install v1"); + + static const char zero_sha[] = + "0000000000000000000000000000000000000000000000000000000000000000"; + CAT_INSTALL_CHECK(app_package_selftest_write_zip(package_path, id, "1.1.0", NULL, + LZ_ZIP_METHOD_STORED), + "write bad update"); + CAT_INSTALL_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash bad update"); + CAT_INSTALL_CHECK(app_catalog_selftest_save_catalog(id, "1.1.0", zero_sha, bytes, + err, sizeof err), + "catalog bad hash"); + memset(&r, 0, sizeof r); + CAT_INSTALL_CHECK(!lz_store_install_app_catalog_entry(id, package_path, &r) && + strcmp(r.error, "sha mismatch") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "bad hash rollback"); + + CAT_INSTALL_CHECK(app_package_selftest_write_zip(package_path, id, "2.0.0", + "assets/readme.txt", + LZ_ZIP_METHOD_STORED), + "write v2"); + CAT_INSTALL_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash v2"); + CAT_INSTALL_CHECK(app_catalog_selftest_save_catalog(id, "2.0.0", sha, bytes, + err, sizeof err), + "catalog v2"); + memset(&r, 0, sizeof r); + CAT_INSTALL_CHECK(lz_store_install_app_catalog_entry(id, package_path, &r) && + r.ok && strcmp(r.version, "2.0.0") == 0 && r.file_count == 3 && + app_package_selftest_live_version(id, "2.0.0"), + "update v2"); + + #undef CAT_INSTALL_CHECK + + app_package_selftest_cleanup(id, package_path); + if(had_prev_catalog) + lz_store_save_app_catalog_cache(prev_catalog, prev_len, err, sizeof err); + else + lz_store_clear_app_catalog_cache(err, sizeof err); + free(prev_catalog); + + return snprintf(buf, (size_t)n, + "App catalog install selftest: %s version=%s files=%u%s%s\n", + ok ? "PASS" : "FAIL", + ok ? "2.0.0" : "-", + ok ? (unsigned)r.file_count : 0u, + detail[0] ? " error=" : "", + detail); +} + +int lz_store_app_package_selftest(char *buf, int n) +{ + if(!buf || n <= 0) return 0; + const char *id = "lz.pkg.selftest"; + char root[128], err[48] = ""; + if(!app_install_root(root, sizeof root, err, sizeof err)) + return snprintf(buf, (size_t)n, "App package selftest: SKIP %s\n", + err[0] ? err : "storage unavailable"); + if(!path_mkdir(root)) + return snprintf(buf, (size_t)n, "App package selftest: SKIP root unavailable\n"); + + char package_path[160]; + path_join(package_path, sizeof package_path, root, ".lz-pkg-selftest.zip"); + app_package_selftest_cleanup(id, package_path); + + bool ok = true; + char detail[64] = ""; + char sha[65]; + uint32_t bytes = 0; + lz_app_package_install_t r; + + #define PKG_CHECK(cond, msg) do { \ + if(ok && !(cond)) { ok = false; snprintf(detail, sizeof detail, "%s", msg); } \ + } while(0) + + PKG_CHECK(app_package_selftest_write_zip(package_path, id, "1.0.0", NULL, + LZ_ZIP_METHOD_STORED), + "write v1"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash v1"); + PKG_CHECK(lz_store_install_app_package(id, package_path, sha, bytes, &r) && + r.ok && strcmp(r.version, "1.0.0") == 0 && r.file_count == 2, + "install v1"); + PKG_CHECK(app_package_selftest_live_version(id, "1.0.0"), + "live v1"); + + static const char zero_sha[] = + "0000000000000000000000000000000000000000000000000000000000000000"; + memset(&r, 0, sizeof r); + PKG_CHECK(!lz_store_install_app_package(id, package_path, zero_sha, bytes, &r) && + strcmp(r.error, "sha mismatch") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "bad hash rollback"); + + PKG_CHECK(app_package_selftest_write_zip(package_path, "lz.pkg.wrong", "9.9.9", + NULL, LZ_ZIP_METHOD_STORED), + "write wrong id"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash wrong id"); + memset(&r, 0, sizeof r); + PKG_CHECK(!lz_store_install_app_package(id, package_path, sha, bytes, &r) && + strcmp(r.error, "id mismatch") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "id mismatch rollback"); + + PKG_CHECK(app_package_selftest_write_zip(package_path, id, "1.1.0", + "data/cache.bin", LZ_ZIP_METHOD_STORED), + "write bad path"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash bad path"); + memset(&r, 0, sizeof r); + PKG_CHECK(!lz_store_install_app_package(id, package_path, sha, bytes, &r) && + strcmp(r.error, "bad file path") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "path rollback"); + + PKG_CHECK(app_package_selftest_write_zip(package_path, id, "1.2.0", NULL, 8), + "write deflated"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash deflated"); + memset(&r, 0, sizeof r); + PKG_CHECK(!lz_store_install_app_package(id, package_path, sha, bytes, &r) && + strcmp(r.error, "unsupported zip method") == 0 && + app_package_selftest_live_version(id, "1.0.0"), + "method rollback"); + + PKG_CHECK(app_package_selftest_write_zip(package_path, id, "2.0.0", + "assets/readme.txt", LZ_ZIP_METHOD_STORED), + "write v2"); + PKG_CHECK(app_package_hash_and_size(package_path, sha, sizeof sha, &bytes, + err, sizeof err), + "hash v2"); + memset(&r, 0, sizeof r); + PKG_CHECK(lz_store_install_app_package(id, package_path, sha, bytes, &r) && + r.ok && strcmp(r.version, "2.0.0") == 0 && r.file_count == 3 && + app_package_selftest_live_version(id, "2.0.0"), + "update v2"); + + #undef PKG_CHECK + + app_package_selftest_cleanup(id, package_path); + return snprintf(buf, (size_t)n, + "App package selftest: %s version=%s files=%u%s%s\n", + ok ? "PASS" : "FAIL", + ok ? "2.0.0" : "-", + ok ? (unsigned)r.file_count : 0u, + detail[0] ? " error=" : "", + detail); +} + static void scan_app_root(const char *apps_dir, lz_local_app_t *out, int cap, int *count) { if(!apps_dir || !out || !count || *count >= cap) return; diff --git a/src/ui/data.c b/src/ui/data.c index d080451..34d71c8 100644 --- a/src/ui/data.c +++ b/src/ui/data.c @@ -8,7 +8,7 @@ const lz_app_t LZ_APPS[8] = { { "meshtastic", "Meshtastic", LZ_I_HUB, "JESS - 7 nodes", 205 }, { "meshcore", "MeshCore", LZ_I_LAN, "Companion - 5", 72 }, { "contacts", "Contacts", LZ_I_GROUP, "9 nodes", 318 }, - { "appstore", "App Store", LZ_I_STOREFRONT, "4 updates", 280 }, + { "appstore", "App Store", LZ_I_STOREFRONT, "Local & catalog", 280 }, { "terminal", "Terminal", LZ_I_TERMINAL, "115200 baud", -1 }, { "files", "Files", LZ_I_FOLDER, "/sd", 242 }, { "settings", "Settings", LZ_I_SETTINGS, "Networks - Radio", -1 }, @@ -56,17 +56,6 @@ const lz_node_t LZ_NODES[9] = { { "Weather-Sensor", "WX", LZ_NET_MC, "MC-sens", "Sensor", 1.2f, 88, "+1.2", "1m", "RAK Sensor", "3.0 km" }, }; -lz_store_app_t LZ_STORE[8] = { - { "calc", "Calculator", "0.1.0", "Utilities", "0.1 MB", "4.5", LZ_I_CALCULATE, 40, LZ_ST_GET }, - { "notes", "Notes", "0.2.0", "Productivity", "0.2 MB", "4.4", LZ_I_NOTE, 95, LZ_ST_GET }, - { "aprs", "APRS Bridge", "0.1.0", "Connectivity", "0.4 MB", "4.6", LZ_I_SATELLITE, 200, LZ_ST_GET }, - { "weather", "Weather Mesh", "0.2.0", "Sensors", "0.6 MB", "4.3", LZ_I_THERMOSTAT, 48, LZ_ST_GET }, - { "bbs", "Mesh BBS", "0.2.0", "Messaging", "0.3 MB", "4.1", LZ_I_DNS, 280, LZ_ST_UPDATE }, - { "scope", "Signal Scope", "0.2.0", "Utilities", "0.9 MB", "4.7", LZ_I_GRAPHIC_EQ, 330, LZ_ST_GET }, - { "chess", "LoRa Chess", "0.1.0", "Games", "0.5 MB", "4.2", LZ_I_GAMEPAD, 18, LZ_ST_GET }, - { "maps", "Offline Maps", "0.2.0", "Navigation", "1.2 MB", "4.8", LZ_I_MAP, 150, LZ_ST_GET }, -}; - const char *LZ_TERM_LINES[12] = { "LimitlezzOS Beta 0.6 - serial console", "limitlezz:~$ mesh --info", diff --git a/src/ui/data.h b/src/ui/data.h index 532d26e..82076ac 100644 --- a/src/ui/data.h +++ b/src/ui/data.h @@ -48,14 +48,6 @@ typedef struct { const char *snr_s, *last, *hw, *dist; } lz_node_t; -typedef struct { - const char *id, *name, *version, *cat, *size, *rating, *icon; - int hue; - int state; /* see LZ_ST_* */ -} lz_store_app_t; - -enum { LZ_ST_GET, LZ_ST_UPDATE, LZ_ST_INSTALLING, LZ_ST_OPEN }; - typedef struct { const char *label, *value; int pct; /* bar fill %; bar color chosen by row index */ @@ -67,7 +59,6 @@ extern const lz_msg_t LZ_MSGS_AVA[5]; extern const lz_msg_t LZ_MSGS_DMITRI[4]; extern const lz_chan_t LZ_CHANS[4]; extern const lz_node_t LZ_NODES[9]; -extern lz_store_app_t LZ_STORE[8]; /* mutable: install state */ extern const char *LZ_TERM_LINES[12]; extern const int LZ_TERM_KIND[12]; /* 0 dim, 1 cmd, 2 out */ extern const lz_sys_stat_t LZ_SYS_STATS[5]; diff --git a/src/ui/screens/scr_apps.c b/src/ui/screens/scr_apps.c index 77d08a8..f4c3dd4 100644 --- a/src/ui/screens/scr_apps.c +++ b/src/ui/screens/scr_apps.c @@ -47,11 +47,18 @@ static void fmt_telemetry(const lz_node_rt *n, char *out, size_t cap) #define STORE_LOCAL_MAX LZ_MAX_LOCAL_APPS /* match Home; was 4 — hid local apps 5..12 */ #define STORE_ISSUE_MAX LZ_MAX_LOCAL_APP_ISSUES +#define STORE_CATALOG_MAX LZ_APP_CATALOG_MAX_APPS static int store_local_n; static int store_issue_n; +static int store_catalog_n; +static lz_local_app_t store_local_rows[STORE_LOCAL_MAX]; +static lz_local_app_issue_t store_issue_rows[STORE_ISSUE_MAX]; +static lz_app_catalog_entry_t store_catalog_rows[STORE_CATALOG_MAX]; static char local_app_note_id[24]; static char local_app_note[64]; +static char store_install_note_id[24]; +static char store_install_note[80]; static void local_app_note_clear(void) { @@ -72,6 +79,19 @@ static const char *local_app_note_for(const lz_local_app_t *app) return local_app_note[0] ? local_app_note : NULL; } +static void store_install_note_clear(void) +{ + store_install_note_id[0] = 0; + store_install_note[0] = 0; +} + +static void store_install_note_set(const char *id, const char *note) +{ + if(!id || !id[0] || !note || !note[0]) { store_install_note_clear(); return; } + snprintf(store_install_note_id, sizeof store_install_note_id, "%s", id); + snprintf(store_install_note, sizeof store_install_note, "%s", note); +} + static int app_version_part(const char **p) { int v = 0; @@ -96,45 +116,96 @@ static int app_version_cmp(const char *a, const char *b) return 0; } -static bool catalog_matches_local(const lz_store_app_t *cat, const lz_local_app_t *app) +static bool catalog_matches_local(const lz_app_catalog_entry_t *cat, const lz_local_app_t *app) { - if(!cat || !app || !cat->id || !app->id[0]) return false; - char local_id[24]; - size_t n = 0; - while(app->id[n] && app->id[n] != '.' && n + 1 < sizeof local_id) { - local_id[n] = app->id[n]; - n++; - } - local_id[n] = 0; - return local_id[0] && strcmp(cat->id, local_id) == 0; + return cat && app && cat->id[0] && app->id[0] && strcmp(cat->id, app->id) == 0; } -static const lz_store_app_t *local_app_update_for(const lz_local_app_t *app) +static const lz_local_app_t *catalog_installed_app(const lz_app_catalog_entry_t *cat, + const lz_local_app_t *local, + int local_n) { - for(int i = 0; i < 8; i++) { - if(catalog_matches_local(&LZ_STORE[i], app) && - app_version_cmp(LZ_STORE[i].version, app->version) > 0) - return &LZ_STORE[i]; + for(int i = 0; i < local_n; i++) + if(catalog_matches_local(cat, &local[i])) return &local[i]; + return NULL; +} + +static const lz_app_catalog_entry_t *local_app_update_for(const lz_local_app_t *app, + const lz_app_catalog_entry_t *catalog, + int catalog_n) +{ + for(int i = 0; i < catalog_n; i++) { + if(catalog_matches_local(&catalog[i], app) && + app_version_cmp(catalog[i].version, app->version) > 0) + return &catalog[i]; } return NULL; } -static void store_timer_cb(lv_timer_t *tm) +static void catalog_size_label(uint32_t bytes, char *out, size_t cap) +{ + if(!out || cap == 0) return; + if(bytes < 1024u) { + snprintf(out, cap, "%lu B", (unsigned long)bytes); + } else if(bytes < 1024u * 1024u) { + snprintf(out, cap, "%lu KB", (unsigned long)((bytes + 512u) / 1024u)); + } else { + unsigned long mb10 = ((unsigned long)bytes * 10u + 524288u) / (1024u * 1024u); + snprintf(out, cap, "%lu.%lu MB", mb10 / 10u, mb10 % 10u); + } +} + +static bool store_install_catalog_entry(const lz_app_catalog_entry_t *entry) +{ + if(!entry || !entry->id[0]) return false; + lz_app_package_install_t r; + memset(&r, 0, sizeof r); + if(lz_svc_install_app_catalog_entry(entry->id, NULL, &r)) { + char note[80]; + snprintf(note, sizeof note, "Installed v%s from catalog", r.version[0] ? r.version : entry->version); + store_install_note_set(entry->id, note); + return true; + } + char note[80]; + snprintf(note, sizeof note, "Install failed: %s", r.error[0] ? r.error : "unknown"); + store_install_note_set(entry->id, note); + return false; +} + +static bool store_open_installed_app(const char *id) { - int idx = (int)(intptr_t)tm->user_data; - LZ_STORE[idx].state = LZ_ST_OPEN; - if(S.view == LZ_V_APPSTORE) lz_rebuild(); + int n = lz_svc_scan_apps(store_local_rows, STORE_LOCAL_MAX); + for(int i = 0; i < n; i++) { + if(strcmp(store_local_rows[i].id, id) == 0) { + S.local_app_sel = store_local_rows[i]; + const char *note = strcmp(store_install_note_id, id) == 0 ? store_install_note : NULL; + if(note && note[0]) local_app_note_set(&S.local_app_sel, note); + else local_app_note_clear(); + lz_go(LZ_V_LOCALAPP); + return true; + } + } + return false; } static void store_activate(int idx) { if(idx < 0) return; bool show_catalog = S.settings.app_source != LZ_APP_SOURCE_LOCAL_ONLY; + int n = lz_svc_scan_apps(store_local_rows, STORE_LOCAL_MAX); + lz_app_catalog_report_t catalog_report; + memset(&catalog_report, 0, sizeof catalog_report); + int catalog_n = show_catalog ? lz_svc_load_app_catalog(store_catalog_rows, STORE_CATALOG_MAX, + &catalog_report) : 0; if(idx < store_local_n) { - lz_local_app_t local[STORE_LOCAL_MAX]; - int n = lz_svc_scan_apps(local, STORE_LOCAL_MAX); - if(idx < n) { - S.local_app_sel = local[idx]; + if(idx >= n) return; + const lz_app_catalog_entry_t *update = local_app_update_for(&store_local_rows[idx], + store_catalog_rows, catalog_n); + if(update) { + if(store_install_catalog_entry(update) && store_open_installed_app(update->id)) return; + lz_rebuild(); + } else { + S.local_app_sel = store_local_rows[idx]; local_app_note_clear(); lz_go(LZ_V_LOCALAPP); } @@ -142,20 +213,29 @@ static void store_activate(int idx) } idx -= store_local_n; if(!show_catalog) return; - if(idx >= 8) return; - if(LZ_STORE[idx].state == LZ_ST_OPEN || LZ_STORE[idx].state == LZ_ST_INSTALLING) return; - LZ_STORE[idx].state = LZ_ST_INSTALLING; - lv_timer_t *tm = lv_timer_create(store_timer_cb, 1100, (void *)(intptr_t)idx); - lv_timer_set_repeat_count(tm, 1); + if(idx >= catalog_n) return; + lz_app_catalog_entry_t *entry = &store_catalog_rows[idx]; + const lz_local_app_t *installed = catalog_installed_app(entry, store_local_rows, n); + bool needs_install = !installed || app_version_cmp(entry->version, installed->version) > 0; + if(!needs_install) { + S.local_app_sel = *installed; + local_app_note_clear(); + lz_go(LZ_V_LOCALAPP); + return; + } + if(store_install_catalog_entry(entry) && store_open_installed_app(entry->id)) return; lz_rebuild(); } void lz_scr_appstore(lv_obj_t *root) { - lz_local_app_t local[STORE_LOCAL_MAX]; - store_local_n = lz_svc_scan_apps(local, STORE_LOCAL_MAX); - lz_local_app_issue_t issues[STORE_ISSUE_MAX]; - store_issue_n = S.settings.developer ? lz_svc_scan_app_issues(issues, STORE_ISSUE_MAX) : 0; + store_local_n = lz_svc_scan_apps(store_local_rows, STORE_LOCAL_MAX); + store_issue_n = S.settings.developer ? lz_svc_scan_app_issues(store_issue_rows, STORE_ISSUE_MAX) : 0; + bool show_catalog = S.settings.app_source != LZ_APP_SOURCE_LOCAL_ONLY; + lz_app_catalog_report_t catalog_report; + memset(&catalog_report, 0, sizeof catalog_report); + store_catalog_n = show_catalog ? lz_svc_load_app_catalog(store_catalog_rows, STORE_CATALOG_MAX, + &catalog_report) : 0; lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); lv_obj_t *bar = lz_navbar(root, "App Store", NULL); @@ -169,14 +249,21 @@ void lz_scr_appstore(lv_obj_t *root) lv_obj_set_style_pad_row(body, 3, 0); lz_nav_set_scroll(body); - bool show_catalog = S.settings.app_source != LZ_APP_SOURCE_LOCAL_ONLY; char source_line[48]; snprintf(source_line, sizeof source_line, "Source: %s", lz_app_source_label(S.settings.app_source)); lv_obj_t *src = lz_text(body, source_line, LZ_F_SMALL, LZ_TEXT_META); lv_obj_set_style_pad_left(src, 4, 0); lv_obj_set_style_pad_bottom(src, 3, 0); + if(store_install_note[0]) { + lv_color_t nc = strstr(store_install_note, "failed") ? lv_color_hex(0xE9B05F) : LZ_STORE_BTN; + lv_obj_t *note = lz_text(body, store_install_note, LZ_F_SMALL, nc); + lv_obj_set_width(note, lv_pct(100)); + lv_label_set_long_mode(note, LV_LABEL_LONG_DOT); + lv_obj_set_style_pad_left(note, 4, 0); + } - if(show_catalog) { + if(show_catalog && store_catalog_n > 0) { + const lz_app_catalog_entry_t *featured = &store_catalog_rows[0]; /* featured card (flattened to solid fill per rendering constraints) */ lv_obj_t *feat = lz_box(body); lv_obj_set_width(feat, lv_pct(100)); @@ -197,18 +284,17 @@ void lz_scr_appstore(lv_obj_t *root) lv_obj_t *ftile = lz_box(frow); lv_obj_set_size(ftile, 42, 42); lv_obj_set_style_radius(ftile, 11, 0); - lv_obj_set_style_bg_color(ftile, lz_tile_color(150), 0); + lv_obj_set_style_bg_color(ftile, lz_tile_color(featured->hue), 0); lv_obj_set_style_bg_opa(ftile, LV_OPA_COVER, 0); - lv_obj_t *fic = lz_icon(ftile, LZ_I_MAP, &lz_icons_24, lv_color_white()); + lv_obj_t *fic = lz_icon(ftile, lz_app_icon_glyph(featured->icon), &lz_icons_24, lv_color_white()); lv_obj_center(fic); lv_obj_t *fcol = lz_box(frow); lv_obj_set_flex_grow(fcol, 1); lv_obj_set_height(fcol, LV_SIZE_CONTENT); lv_obj_set_flex_flow(fcol, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(fcol, 1, 0); - lz_text(fcol, "Node Mapper", LZ_F_HEAD, lv_color_white()); - lv_obj_t *fd = lz_text(fcol, "Live mesh topology & GPS positions on an offline map", - LZ_F_SMALL, lv_color_hex(0xCFD0E4)); + lz_text(fcol, featured->name, LZ_F_HEAD, lv_color_white()); + lv_obj_t *fd = lz_text(fcol, featured->summary, LZ_F_SMALL, lv_color_hex(0xCFD0E4)); lv_label_set_long_mode(fd, LV_LABEL_LONG_WRAP); lv_obj_set_width(fd, lv_pct(100)); } @@ -219,8 +305,9 @@ void lz_scr_appstore(lv_obj_t *root) lv_obj_set_style_pad_bottom(lh, 3, 0); for(int i = 0; i < store_local_n; i++) { - lz_local_app_t *a = &local[i]; - const lz_store_app_t *update = local_app_update_for(a); + lz_local_app_t *a = &store_local_rows[i]; + const lz_app_catalog_entry_t *update = local_app_update_for(a, store_catalog_rows, + store_catalog_n); lv_obj_t *row = lz_row(body, i == S.focus); lv_obj_set_style_radius(row, 11, 0); @@ -275,7 +362,7 @@ void lz_scr_appstore(lv_obj_t *root) lv_obj_set_style_pad_bottom(ih, 3, 0); for(int i = 0; i < store_issue_n; i++) { - lz_local_app_issue_t *it = &issues[i]; + lz_local_app_issue_t *it = &store_issue_rows[i]; lv_obj_t *row = lz_row(body, false); lv_obj_set_style_radius(row, 11, 0); lv_obj_set_style_border_color(row, lv_color_hex(0x4B3320), 0); @@ -303,13 +390,26 @@ void lz_scr_appstore(lv_obj_t *root) } if(show_catalog) { - lv_obj_t *hd = lz_text(body, store_local_n > 0 ? "Catalog examples" : "Apps & utilities", + lv_obj_t *hd = lz_text(body, store_local_n > 0 ? "Catalog" : "Apps & utilities", LZ_F_BODY, lv_color_hex(0xCFD4DA)); lv_obj_set_style_pad_bottom(hd, 3, 0); - for(int i = 0; i < 8; i++) { + if(store_catalog_n == 0) { + const char *empty = catalog_report.first_error[0] && + strcmp(catalog_report.first_error, "catalog missing") != 0 + ? "Catalog unavailable" : "No catalog cache"; + lv_obj_t *msg = lz_text(body, empty, LZ_F_SMALL, LZ_TEXT_META); + lv_obj_set_width(msg, lv_pct(100)); + lv_obj_set_style_pad_left(msg, 4, 0); + } + + for(int i = 0; i < store_catalog_n; i++) { int nav_idx = store_local_n + i; - lz_store_app_t *a = &LZ_STORE[i]; + lz_app_catalog_entry_t *a = &store_catalog_rows[i]; + const lz_local_app_t *installed = catalog_installed_app(a, store_local_rows, + store_local_n); + bool update = installed && app_version_cmp(a->version, installed->version) > 0; + const char *label = !installed ? "GET" : (update ? "UPDATE" : "OPEN"); lv_obj_t *row = lz_row(body, nav_idx == S.focus); lv_obj_set_style_radius(row, 11, 0); @@ -318,7 +418,7 @@ void lz_scr_appstore(lv_obj_t *root) lv_obj_set_style_radius(tile, 10, 0); lv_obj_set_style_bg_color(tile, lz_tile_color(a->hue), 0); lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); - lv_obj_t *ic = lz_icon(tile, a->icon, &lz_icons_18, lv_color_white()); + lv_obj_t *ic = lz_icon(tile, lz_app_icon_glyph(a->icon), &lz_icons_18, lv_color_white()); lv_obj_center(ic); lv_obj_t *cl = lz_box(row); @@ -326,39 +426,38 @@ void lz_scr_appstore(lv_obj_t *root) lv_obj_set_height(cl, LV_SIZE_CONTENT); lv_obj_set_flex_flow(cl, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(cl, 1, 0); - lz_text(cl, a->name, LZ_F_BODY, LZ_TEXT); + lv_obj_t *nm = lz_text(cl, a->name, LZ_F_BODY, LZ_TEXT); + lv_obj_set_width(nm, 156); + lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); lv_obj_t *meta = lz_box(cl); lv_obj_set_size(meta, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_flex_flow(meta, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(meta, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(meta, 4, 0); - lz_icon(meta, LZ_I_STAR, &lz_icons_14, LZ_SNR_MID); - lz_text(meta, a->rating, LZ_F_SMALL, LZ_TEXT_VALUE); - char cs[44]; snprintf(cs, sizeof cs, "- v%s - %s - %s", a->version, a->cat, a->size); + lz_icon(meta, LZ_I_DESCRIPTION, &lz_icons_14, LZ_TEXT_3); + char size[16]; + catalog_size_label(a->package_bytes, size, sizeof size); + char cs[72]; snprintf(cs, sizeof cs, "v%s - %s - %s", a->version, a->author, size); lz_text(meta, cs, LZ_F_SMALL, LZ_TEXT_3); - const char *lbl = a->state == LZ_ST_INSTALLING ? "..." - : a->state == LZ_ST_OPEN ? "OPEN" - : a->state == LZ_ST_UPDATE ? "UPDATE" : "GET"; - bool open = a->state == LZ_ST_OPEN; lv_obj_t *btn = lz_box(row); lv_obj_set_size(btn, LV_SIZE_CONTENT, 21); - lv_obj_set_style_min_width(btn, 52, 0); + lv_obj_set_style_min_width(btn, update ? 66 : 52, 0); lv_obj_set_style_radius(btn, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(btn, open ? lv_color_hex(0x222A33) : LZ_STORE_BTN, 0); + lv_obj_set_style_bg_color(btn, (!installed || update) ? LZ_STORE_BTN : lv_color_hex(0x222A33), 0); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - if(open) { + if(installed && !update) { lv_obj_set_style_border_width(btn, 1, 0); lv_obj_set_style_border_color(btn, lv_color_hex(0x3A414B), 0); } lv_obj_set_style_pad_hor(btn, 11, 0); - lv_obj_t *bl = lz_text(btn, lbl, LZ_F_SMALL, - open ? lv_color_hex(0xCFD4DA) : LZ_ON_MINT); + lv_obj_t *bl = lz_text(btn, label, LZ_F_SMALL, + (!installed || update) ? LZ_ON_MINT : lv_color_hex(0xCFD4DA)); lv_obj_center(bl); lz_nav_track(row, nav_idx); } } - lz_nav_set(1, store_local_n + (show_catalog ? 8 : 0), store_activate); + lz_nav_set(1, store_local_n + (show_catalog ? store_catalog_n : 0), store_activate); } static void app_data_quota_label(uint32_t used, uint32_t quota, char *out, size_t cap) @@ -539,7 +638,12 @@ void lz_scr_local_app(lv_obj_t *root) snprintf(api, sizeof api, "SDK %s", a->api_version); lz_app_permissions_list(a->permissions, perms, sizeof perms); lz_app_permissions_summary(a->permissions, access, sizeof access); - const lz_store_app_t *update = local_app_update_for(a); + lz_app_catalog_report_t catalog_report; + memset(&catalog_report, 0, sizeof catalog_report); + int catalog_n = S.settings.app_source != LZ_APP_SOURCE_LOCAL_ONLY + ? lz_svc_load_app_catalog(store_catalog_rows, STORE_CATALOG_MAX, &catalog_report) + : 0; + const lz_app_catalog_entry_t *update = local_app_update_for(a, store_catalog_rows, catalog_n); if(update) snprintf(status, sizeof status, "Update available: v%s", update->version); else snprintf(status, sizeof status, "Manifest ready"); if(a->permissions & LZ_APP_PERM_STORAGE) {