diff --git a/docs/tdeck-app-catalog-schema.md b/docs/tdeck-app-catalog-schema.md index af15dd8..0dd21f0 100644 --- a/docs/tdeck-app-catalog-schema.md +++ b/docs/tdeck-app-catalog-schema.md @@ -1,65 +1,17 @@ # 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: - -```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. - -Serial diagnostics: +Firmware validates and loads the canonical `limitlezz.app.catalog.v1` schema. +Serial diagnostics remain: ```text app catalog status app catalog test ``` - -`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. diff --git a/docs/tdeck-feature-inventory.md b/docs/tdeck-feature-inventory.md index ad3fb6e..0f4eb16 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,11 @@ 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. | +| 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`, and feeds cached rows into App Store browsing/update metadata | Wire Wi-Fi fetch into source selection, verify TLS/source metadata, then add package download/install/update. | | 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. | | 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..6c85196 100644 --- a/docs/tdeck-firmware-roadmap.md +++ b/docs/tdeck-firmware-roadmap.md @@ -364,8 +364,7 @@ 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. +- 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. - Cache catalog for offline browsing. Initial implementation: bounded atomic catalog JSON cache save/load/clear service APIs with native simulator coverage. @@ -373,7 +372,7 @@ Deliverables: - 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. +- Cache catalog for offline browsing. Initial implementation: canonical cached catalog entries are parsed into typed firmware rows and rendered in App Store browsing/update metadata without fake install state. - Download app zip/package. - Verify SHA256 before install. Initial foundation: reusable package-file SHA256 hashing and expected-hash verification helpers with native simulator coverage. diff --git a/docs/tdeck-network-app-catalog.md b/docs/tdeck-network-app-catalog.md index 73bd1c1..d7c1486 100644 --- a/docs/tdeck-network-app-catalog.md +++ b/docs/tdeck-network-app-catalog.md @@ -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, 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..3775bcd 100644 --- a/sim/main_sim.c +++ b/sim/main_sim.c @@ -1848,30 +1848,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 +1909,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 +1929,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/services/mesh.h b/src/services/mesh.h index 81cb0b1..c76f7f3 100644 --- a/src/services/mesh.h +++ b/src/services/mesh.h @@ -40,6 +40,9 @@ 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_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 +240,25 @@ typedef struct { char first_error[64]; } lz_app_catalog_report_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 */ @@ -307,6 +329,10 @@ bool lz_svc_uninstall_local_app(const lz_local_app_t *app, bool keep_data, 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..f8ea172 100644 --- a/src/services/mesh_core.c +++ b/src/services/mesh_core.c @@ -35,6 +35,10 @@ 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_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 +440,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; diff --git a/src/services/store.c b/src/services/store.c index adff88e..5a6fc59 100644 --- a/src/services/store.c +++ b/src/services/store.c @@ -706,9 +706,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 +719,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 +782,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 +795,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(*p != '"') return false; + p++; + char name[24]; + size_t j = 0; + while(*p && *p != '"') { + char c = *p++; + if(c == '\\' && *p) c = *p++; + if(j + 1 < sizeof name && c >= 32) name[j++] = c; + } + 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 == ']') { + 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(++count > LZ_APP_CATALOG_SCREENSHOT_MAX) return false; if(*p != '"') return false; p++; - char item[128]; + char value[12]; 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 value && c >= 32) value[j++] = c; + else return false; } - if(*p != '"' || too_long) return false; - item[j] = 0; - if(urls && !catalog_url_ok(item)) 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 true; + if(*p == ']') return count > 0; return false; } } @@ -824,127 +941,220 @@ 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) { - 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; + 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; + } +} - if(!json_get_string_bounded(obj, "id", id, sizeof id)) +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) +{ + lz_app_catalog_entry_t e; + memset(&e, 0, sizeof e); + e.hue = -1; + + 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 seen_ids[LZ_APP_CATALOG_MAX_APPS][24]; + memset(seen_ids, 0, sizeof seen_ids); for(;;) { bool done = false, too_big = false; p = catalog_next_object(p, obj, sizeof obj, &done, &too_big); if(done) break; if(!p) { catalog_fail(&r, NULL, "bad apps array"); - if(out) *out = r; + if(report) *report = r; return false; } if(too_big) { catalog_fail(&r, NULL, "app entry too large"); - if(out) *out = r; + if(report) *report = r; return false; } if(r.app_count + r.rejected_count >= LZ_APP_CATALOG_MAX_APPS) { catalog_fail(&r, NULL, "too many apps"); - if(out) *out = r; + if(report) *report = r; return false; } - if(!catalog_validate_app(obj, &r)) { - if(out) *out = r; + lz_app_catalog_entry_t entry; + if(!catalog_validate_app(obj, &r, &entry, seen_ids, r.app_count)) { + if(report) *report = r; return false; } + 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; + if(report) *report = r; return r.ok; } +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, const lz_app_catalog_report_t *r) { @@ -959,8 +1169,10 @@ 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]; @@ -975,24 +1187,54 @@ static bool catalog_read_file(const char *path, lz_app_catalog_report_t *r) if(r) *r = tmp; return true; } - lz_store_validate_app_catalog_json(json, r); + lz_store_parse_app_catalog_json(json, out, cap, r); + 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 +1243,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); 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..570c6f5 100644 --- a/src/ui/screens/scr_apps.c +++ b/src/ui/screens/scr_apps.c @@ -47,9 +47,11 @@ 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 char local_app_note_id[24]; static char local_app_note[64]; @@ -96,34 +98,34 @@ 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_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 < 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 < 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) { - int idx = (int)(intptr_t)tm->user_data; - LZ_STORE[idx].state = LZ_ST_OPEN; - if(S.view == LZ_V_APPSTORE) lz_rebuild(); + 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 void store_activate(int idx) @@ -142,12 +144,7 @@ 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); - lz_rebuild(); + if(idx >= store_catalog_n) return; } void lz_scr_appstore(lv_obj_t *root) @@ -156,6 +153,12 @@ void lz_scr_appstore(lv_obj_t *root) 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; + bool show_catalog = S.settings.app_source != LZ_APP_SOURCE_LOCAL_ONLY; + lz_app_catalog_entry_t catalog[STORE_CATALOG_MAX]; + lz_app_catalog_report_t catalog_report; + memset(&catalog_report, 0, sizeof catalog_report); + store_catalog_n = show_catalog ? lz_svc_load_app_catalog(catalog, 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 +172,14 @@ 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(show_catalog) { + if(show_catalog && store_catalog_n > 0) { + const lz_app_catalog_entry_t *featured = &catalog[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 +200,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)); } @@ -220,7 +222,7 @@ void lz_scr_appstore(lv_obj_t *root) 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); + const lz_app_catalog_entry_t *update = local_app_update_for(a, catalog, store_catalog_n); lv_obj_t *row = lz_row(body, i == S.focus); lv_obj_set_style_radius(row, 11, 0); @@ -303,13 +305,22 @@ 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 = &catalog[i]; lv_obj_t *row = lz_row(body, nav_idx == S.focus); lv_obj_set_style_radius(row, 11, 0); @@ -318,7 +329,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 +337,35 @@ 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_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, lv_color_hex(0x222A33), 0); lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); - if(open) { - 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_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, "INFO", LZ_F_SMALL, 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 +546,13 @@ 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_entry_t catalog[STORE_CATALOG_MAX]; + 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(catalog, STORE_CATALOG_MAX, &catalog_report) + : 0; + const lz_app_catalog_entry_t *update = local_app_update_for(a, catalog, 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) {