diff --git a/README.md b/README.md index bfcf95d..64b4c44 100644 --- a/README.md +++ b/README.md @@ -137,29 +137,13 @@ iPhone-style dark look (status bar, battery glyph, grouped settings cards). - **Local app platform** - scan local app manifests from `/sd/limitlezz/apps`, `/sd/apps`, `/appfs/apps`, and simulator data dirs, then show accepted apps across paged Home launcher screens and App Store detail shells. Home can open - accepted apps in a safe SDK 0.1 foreground shell that reads bounded display - metadata plus up to two bounded foreground actions from the entry file and - 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 - `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. + accepted apps in a safe SDK 0.1 foreground shell with bounded actions, + permission-gated `{time}` / `{battery}` tokens, scoped storage, notification + effects, rejected-package diagnostics, and a 704-byte resident runtime budget. + The network catalog path now validates the documented `index.json` schema and + can refresh a bounded metadata cache with `app catalog fetch`; script + execution, richer API injection, package downloads, installs, and updates 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. @@ -406,16 +390,10 @@ for local apps and read-only inspection when present. 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). +- **Network app catalog** - CI validates the documented HTTPS `index.json` + schema, and serial `app catalog fetch|status|clear|test` can refresh and + inspect a bounded metadata cache. Package download/install/update remains + future work, so the static catalog UI is still prototype-only. - **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..5a2eaf3 100644 --- a/docs/tdeck-app-catalog-schema.md +++ b/docs/tdeck-app-catalog-schema.md @@ -1,20 +1,21 @@ # 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. +`index.json` shape and can refresh a bounded catalog cache over Wi-Fi, but it +does not download, install, update, or uninstall packages yet. -Catalog indexes are expected at: +Catalog indexes can be loaded from: - `/sd/limitlezz/catalog/index.json` - `/appfs/catalog/index.json` +- the refreshed cache file, `app_catalog.json`, written by `app catalog fetch` 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", + "schema": "limitlezz.app.catalog.v1", + "generated_at": "2026-06-18T00:00:00Z", "apps": [] } ``` @@ -27,16 +28,28 @@ Each app entry is bounded and fail-closed: "name": "Weather Mesh", "version": "0.1.0", "author": "Limitless", + "summary": "Local weather dashboard", "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"] + "package_url": "https://apps.example.invalid/weather.mesh.zip", + "package_sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "package_bytes": 32768, + "compatibility": { + "min_os": "0.95.0", + "api_versions": ["0.1"], + "targets": ["tdeck", "sim"] + }, + "screenshots": [ + { + "url": "https://apps.example.invalid/weather.bmp", + "width": 320, + "height": 240, + "sha256": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + } + ] } ``` @@ -45,10 +58,12 @@ 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. +- `package_url` and screenshot URLs must be HTTPS in published catalogs. +- `package_sha256` must be exactly 64 lowercase hex characters. +- `package_bytes` must be nonzero and no more than `2 MB` for this first + package path. +- `compatibility.api_versions` must include the entry's `api_version`, and + `compatibility.targets` must include `tdeck` or `sim`. - `hue`, if present, must be `-1` or `0..359`. - The catalog can list up to `24` apps. @@ -57,9 +72,12 @@ Serial diagnostics: ```text app catalog status app catalog test +app catalog fetch https://example.invalid/limitlezz/catalog/index.json +app catalog clear ``` -`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. +`app catalog fetch` downloads bounded JSON over Wi-Fi, validates it, and only +then writes the refreshed cache. `app catalog status` validates that refreshed +cache first, then legacy `/catalog/index.json` files if 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..a8f31c4 100644 --- a/docs/tdeck-feature-inventory.md +++ b/docs/tdeck-feature-inventory.md @@ -117,12 +117,7 @@ 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`, `scripts/validate_app_catalog.py`, firmware validator/cache APIs, T-Deck HTTP/HTTPS fetch transport, and serial `app catalog fetch\|status\|clear\|test` diagnostics | Feed refreshed catalog metadata into App Store browsing, pin/verify production catalog TLS/source metadata, then download/install/update packages. | | 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. | diff --git a/docs/tdeck-firmware-roadmap.md b/docs/tdeck-firmware-roadmap.md index e04f738..87009f6 100644 --- a/docs/tdeck-firmware-roadmap.md +++ b/docs/tdeck-firmware-roadmap.md @@ -364,16 +364,18 @@ 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, + description, icon id/color, permissions, package 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`, Firmware CI validation, and matching + firmware validation for `limitlezz.app.catalog.v1`. - 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. + catalog fetch transport gated on connected Wi-Fi, plus serial + `app catalog fetch ` / `refresh `. +- Cache catalog for offline browsing. Implemented: validated refresh writes a + bounded atomic metadata cache, `app catalog status` validates that refreshed + cache first, and `app catalog clear` removes it. - 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..4e1bb5b 100644 --- a/docs/tdeck-network-app-catalog.md +++ b/docs/tdeck-network-app-catalog.md @@ -2,8 +2,8 @@ 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. +`index.json` shape and the firmware can refresh a validated cache over Wi-Fi; +package download, install, update, and rollback are still later work. 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 @@ -94,3 +94,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. + +On device, the serial console exposes the first refresh path: + +```text +app catalog fetch https://example.invalid/limitlezz/catalog/index.json +app catalog status +app catalog clear +``` + +The refresh command fetches a bounded JSON body, runs the same fail-closed +firmware validator, and saves the cache only after validation succeeds. The +cache is for browsing metadata; package download and installation remain future +work. diff --git a/sim/main_sim.c b/sim/main_sim.c index 3073a12..eaf0cfe 100644 --- a/sim/main_sim.c +++ b/sim/main_sim.c @@ -1507,7 +1507,7 @@ static int codec_selftest(void) /* 10. app catalog fetch transport: native keeps this as a deterministic * no-network stub; T-Deck provides the Wi-Fi HTTP implementation. */ { - char body[96]; + static char body[LZ_APP_CATALOG_FETCH_MAX + 1]; char err[48]; int body_len = 7; CHECK(!lz_app_catalog_fetch("ftp://example.invalid/index.json", body, sizeof body, @@ -1524,6 +1524,10 @@ static int codec_selftest(void) &body_len, err, sizeof err) && body_len == 0 && strcmp(err, "fetch unavailable") == 0, "app catalog fetch native stub is explicit"); + CHECK(lz_app_catalog_fetch("https://example.invalid/limitlezz/app-catalog-valid.json", + body, sizeof body, &body_len, err, sizeof err) && + body_len > 0 && strstr(body, "limitlezz.app.catalog.v1") != NULL, + "app catalog fetch native fixture returns bounded JSON"); } /* 10. OTA boot policy: fail closed while a new image is pending verify. */ { @@ -1848,30 +1852,36 @@ 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\"," "\"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\":{\"min_os\":\"0.95.0\",\"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," + "\"sha256\":\"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\"}]}," "{\"id\":\"notes.local\",\"name\":\"Field Notes\",\"version\":\"0.1.0\"," "\"author\":\"Limitless\",\"description\":\"Simple local notes\"," - "\"icon\":\"notes\",\"api_version\":\"0.1\",\"compatibility\":\"tdeck\"," + "\"icon\":\"notes\",\"api_version\":\"0.1\"," + "\"compatibility\":{\"min_os\":\"0.95.0\",\"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\"," + "\"api_version\":\"0.1\"," + "\"compatibility\":{\"min_os\":\"0.95.0\",\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\"]}," "\"permissions\":[\"display\",\"raw_radio\"]," - "\"download_url\":\"file:///sd/apps/weather.zip\"," - "\"sha256\":\"bad\",\"size\":0}]}"; + "\"package_url\":\"file:///sd/apps/weather.zip\"," + "\"package_sha256\":\"bad\",\"package_bytes\":0}]}"; lz_app_catalog_report_t report; CHECK(lz_svc_validate_app_catalog_json(valid_catalog, &report) && @@ -1896,6 +1906,17 @@ static int codec_selftest(void) lz_svc_app_catalog_diag(diag, sizeof diag); CHECK(strstr(diag, "ready apps=2") != NULL, "app catalog diagnostics report cached index"); + lz_app_catalog_report_t fetched; + char fetch_err[64]; + CHECK(lz_svc_fetch_app_catalog("https://example.invalid/limitlezz/app-catalog-valid.json", + &fetched, fetch_err, sizeof fetch_err) && + fetched.ok && fetched.app_count == 1, + "app catalog service fetch validates and caches index"); + lz_svc_app_catalog_diag(diag, sizeof diag); + CHECK(strstr(diag, "app catalog cache: ready apps=1") != NULL, + "app catalog diagnostics prefer refreshed cache"); + CHECK(lz_svc_clear_app_catalog_cache(fetch_err, sizeof fetch_err), + "app catalog service clears refreshed cache"); lz_store_init(NULL); sim_reset_dir("lzdata_catalog"); } diff --git a/src/app_catalog_fetch_sim.c b/src/app_catalog_fetch_sim.c index fcf7ae7..66bc12c 100644 --- a/src/app_catalog_fetch_sim.c +++ b/src/app_catalog_fetch_sim.c @@ -18,6 +18,21 @@ static bool fetch_url_ok(const char *url) bool lz_app_catalog_fetch(const char *url, char *out_json, int out_cap, int *out_len, char *err, int err_cap) { + static const char fixture_url[] = "https://example.invalid/limitlezz/app-catalog-valid.json"; + static const char fixture_json[] = + "{\"schema\":\"limitlezz.app.catalog.v1\",\"generated_at\":\"2026-06-20T00:00:00Z\"," + "\"apps\":[{\"id\":\"weather.mesh\",\"name\":\"Weather Mesh\",\"version\":\"0.1.0\"," + "\"author\":\"Limitless\",\"summary\":\"Local weather dashboard\"," + "\"description\":\"Shows a compact local weather panel.\"," + "\"icon\":\"weather\",\"hue\":48,\"api_version\":\"0.1\"," + "\"permissions\":[\"display\",\"input\",\"storage\",\"network_wifi\"]," + "\"package_url\":\"https://example.invalid/limitlezz/apps/weather.mesh.zip\"," + "\"package_sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," + "\"package_bytes\":4096," + "\"compatibility\":{\"min_os\":\"0.95.0\",\"api_versions\":[\"0.1\"],\"targets\":[\"tdeck\",\"sim\"]}," + "\"screenshots\":[{\"url\":\"https://example.invalid/limitlezz/apps/weather.bmp\"," + "\"width\":320,\"height\":240," + "\"sha256\":\"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\"}]}]}"; if(out_json && out_cap > 0) out_json[0] = 0; if(out_len) *out_len = 0; fetch_err(err, err_cap, ""); @@ -29,6 +44,16 @@ bool lz_app_catalog_fetch(const char *url, char *out_json, int out_cap, fetch_err(err, err_cap, "bad url"); return false; } + if(strcmp(url, fixture_url) == 0) { + int len = (int)strlen(fixture_json); + if(len >= LZ_APP_CATALOG_FETCH_MAX || len + 1 >= out_cap) { + fetch_err(err, err_cap, "catalog too large"); + return false; + } + memcpy(out_json, fixture_json, (size_t)len + 1); + if(out_len) *out_len = len; + return true; + } fetch_err(err, err_cap, "fetch unavailable"); return false; } diff --git a/src/serial_cli.cpp b/src/serial_cli.cpp index 26510bc..b05afcf 100644 --- a/src/serial_cli.cpp +++ b/src/serial_cli.cpp @@ -76,7 +76,7 @@ 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|fetch |refresh |clear\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 +464,42 @@ static void cmd_app(char *args) Serial.print(b); return; } + if(args && strncmp(args, "catalog fetch ", 14) == 0) { + const char *url = args + 14; + while(*url == ' ' || *url == '\t') url++; + lz_app_catalog_report_t report; + char err[80]; + if(lz_svc_fetch_app_catalog(url, &report, err, sizeof err)) { + Serial.printf("[ok] app catalog cached apps=%d\n", report.app_count); + } else { + Serial.printf("[err] app catalog fetch failed: %s\n", err[0] ? err : "failed"); + if(report.first_id[0]) + Serial.printf(" app=%s reason=%s\n", report.first_id, report.first_error); + } + return; + } + if(args && strncmp(args, "catalog refresh ", 16) == 0) { + const char *url = args + 16; + while(*url == ' ' || *url == '\t') url++; + lz_app_catalog_report_t report; + char err[80]; + if(lz_svc_fetch_app_catalog(url, &report, err, sizeof err)) { + Serial.printf("[ok] app catalog cached apps=%d\n", report.app_count); + } else { + Serial.printf("[err] app catalog fetch failed: %s\n", err[0] ? err : "failed"); + if(report.first_id[0]) + Serial.printf(" app=%s reason=%s\n", report.first_id, report.first_error); + } + return; + } + if(args && strcmp(args, "catalog clear") == 0) { + char err[64]; + if(lz_svc_clear_app_catalog_cache(err, sizeof err)) + Serial.println("[ok] app catalog cache cleared"); + else + Serial.printf("[err] app catalog cache clear failed: %s\n", err[0] ? err : "failed"); + return; + } if(!args || !args[0] || strcmp(args, "catalog") == 0 || strcmp(args, "catalog status") == 0) { char b[180]; @@ -471,7 +507,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|fetch |refresh |clear"); } static void cmd_nodes(char *args) diff --git a/src/services/mesh.h b/src/services/mesh.h index 81cb0b1..eb74d5e 100644 --- a/src/services/mesh.h +++ b/src/services/mesh.h @@ -307,6 +307,8 @@ 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_fetch_app_catalog(const char *url, lz_app_catalog_report_t *out, + char *err, int err_cap); 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..a72c73e 100644 --- a/src/services/mesh_core.c +++ b/src/services/mesh_core.c @@ -4,6 +4,7 @@ * real SX1262 driver both call the lz_core_on_* hooks below. */ #include "mesh.h" +#include "app_catalog_fetch.h" #include #include #include @@ -436,6 +437,31 @@ 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_fetch_app_catalog(const char *url, lz_app_catalog_report_t *out, + char *err, int err_cap) +{ + if(out) memset(out, 0, sizeof *out); + if(err && err_cap > 0) err[0] = 0; + static char json[LZ_APP_CATALOG_FETCH_MAX + 1]; + int len = 0; + if(!lz_app_catalog_fetch(url, json, sizeof json, &len, err, err_cap)) + return false; + + lz_app_catalog_report_t report; + if(!lz_store_validate_app_catalog_json(json, &report)) { + if(out) *out = report; + if(err && err_cap > 0) + snprintf(err, (size_t)err_cap, "%s", report.first_error[0] ? report.first_error : "invalid catalog"); + return false; + } + if(!lz_store_save_app_catalog_cache(json, len, err, err_cap)) { + if(out) *out = report; + return false; + } + if(out) *out = report; + return true; +} + 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..1880d2a 100644 --- a/src/services/store.c +++ b/src/services/store.c @@ -725,7 +725,7 @@ static bool catalog_fail(lz_app_catalog_report_t *r, const char *id, const char 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; for(int i = 0; url[i]; i++) { char c = url[i]; if(c <= 32 || c == '"' || c == '<' || c == '>') return false; @@ -738,8 +738,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,17 +751,15 @@ 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_string_array_contains(const char *p, const char *needle) { - if(!p || *p != '[') return false; + if(!p || *p != '[' || !needle || !needle[0]) return false; p = skip_ws(p + 1); - 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 item[32]; size_t j = 0; bool too_long = false; while(*p && *p != '"') { @@ -774,10 +771,10 @@ static bool catalog_string_array_ok(const char *p, bool urls) } if(*p != '"' || too_long) return false; item[j] = 0; - if(urls && !catalog_url_ok(item)) return false; + if(strcmp(item, needle) == 0) return true; p = skip_ws(p + 1); if(*p == ',') { p = skip_ws(p + 1); continue; } - if(*p == ']') return true; + if(*p == ']') return false; return false; } } @@ -824,10 +821,85 @@ static const char *catalog_next_object(const char *p, char *out, size_t cap, return NULL; } +static bool catalog_compat_ok(const char *obj, const char *api) +{ + const char *p = json_value_for(obj, "compatibility"); + if(!p) return false; + if(*p == '"') { + char compat[32]; + if(!json_get_string_bounded(obj, "compatibility", compat, sizeof compat)) return false; +#ifdef LZ_TARGET_TDECK + return strcmp(compat, "tdeck") == 0; +#else + return strcmp(compat, "tdeck") == 0 || strcmp(compat, "sim") == 0; +#endif + } + if(*p != '{') return false; + + char compat_obj[384]; + bool done = false, too_big = false; + const char *end = catalog_next_object(p, compat_obj, sizeof compat_obj, &done, &too_big); + if(!end || done || too_big) return false; + + const char *apis = json_array_for(compat_obj, "api_versions"); + const char *targets = json_array_for(compat_obj, "targets"); + if(!catalog_string_array_contains(apis, api)) return false; +#ifdef LZ_TARGET_TDECK + return catalog_string_array_contains(targets, "tdeck"); +#else + return catalog_string_array_contains(targets, "tdeck") || + catalog_string_array_contains(targets, "sim"); +#endif +} + +static bool catalog_screenshots_ok(const char *p) +{ + if(!p || *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; + if(*p == '"') { + p++; + char url[128]; + 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 url) url[j++] = c; + else too_long = true; + } + if(*p != '"' || too_long) return false; + url[j] = 0; + if(!catalog_url_ok(url)) return false; + p = skip_ws(p + 1); + } else if(*p == '{') { + char shot[512]; + bool done = false, too_big = false; + const char *end = catalog_next_object(p, shot, sizeof shot, &done, &too_big); + if(!end || done || too_big) return false; + char url[128], sha[65]; + if(!json_get_string_bounded(shot, "url", url, sizeof url) || !catalog_url_ok(url)) + return false; + if(json_get_string_bounded(shot, "sha256", sha, sizeof sha) && !catalog_sha256_ok(sha)) + return false; + p = skip_ws(end); + } else { + return false; + } + if(*p == ',') { p = skip_ws(p + 1); continue; } + if(*p == ']') return true; + return false; + } +} + static bool catalog_validate_app(const char *obj, lz_app_catalog_report_t *r) { char id[24], name[32], version[16], author[28], desc[96], icon[20]; - char api[12], compat[32], url[128], sha[65]; + char api[12], url[128], sha[65]; uint32_t size = 0; int hue = -1; @@ -847,16 +919,25 @@ static bool catalog_validate_app(const char *obj, lz_app_catalog_report_t *r) 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)) + if(!catalog_compat_ok(obj, api)) + return catalog_fail(r, id, "bad compatibility"); + bool current_package = json_get_string_bounded(obj, "package_url", url, sizeof url); + if(!current_package && !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)) + if(current_package) { + if(!json_get_string_bounded(obj, "package_sha256", sha, sizeof sha)) + return catalog_fail(r, id, "missing sha256"); + } else 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)) + if(current_package) { + if(!json_get_u32(obj, "package_bytes", &size)) + return catalog_fail(r, id, "missing size"); + } else 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)) @@ -868,7 +949,7 @@ static bool catalog_validate_app(const char *obj, lz_app_catalog_report_t *r) return catalog_fail(r, id, "bad permissions"); const char *shots = json_value_for(obj, "screenshots"); - if(shots && !catalog_string_array_ok(shots, true)) + if(shots && !catalog_screenshots_ok(shots)) return catalog_fail(r, id, "bad screenshots"); if(r) r->app_count++; @@ -893,7 +974,8 @@ bool lz_store_validate_app_catalog_json(const char *json, lz_app_catalog_report_ char schema[32]; if(!json_get_string_bounded(json, "schema", schema, sizeof schema) || - strcmp(schema, "limitlezz.app_catalog.v1") != 0) { + (strcmp(schema, "limitlezz.app.catalog.v1") != 0 && + strcmp(schema, "limitlezz.app_catalog.v1") != 0)) { catalog_fail(&r, NULL, "bad schema"); if(out) *out = r; return false; @@ -984,6 +1066,10 @@ int lz_store_app_catalog_diag(char *buf, int n) if(!buf || n <= 0) return 0; char path[160]; if(g_persist) { + path_for(path, sizeof path, "app_catalog.json"); + lz_app_catalog_report_t cached; + if(catalog_read_file(path, &cached)) + return catalog_report_line(buf, n, "app catalog cache", &cached); path_join(path, sizeof path, g_dir, "catalog/index.json"); lz_app_catalog_report_t r; if(catalog_read_file(path, &r)) @@ -1001,29 +1087,35 @@ 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\"," "\"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\":{\"min_os\":\"0.95.0\",\"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," + "\"sha256\":\"abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789\"}]}," "{\"id\":\"notes.local\",\"name\":\"Field Notes\",\"version\":\"0.1.0\"," "\"author\":\"Limitless\",\"description\":\"Simple local notes\"," - "\"icon\":\"notes\",\"api_version\":\"0.1\",\"compatibility\":\"tdeck\"," + "\"icon\":\"notes\",\"api_version\":\"0.1\"," + "\"compatibility\":{\"min_os\":\"0.95.0\",\"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}]}"; + "\"api_version\":\"0.1\"," + "\"compatibility\":{\"min_os\":\"0.95.0\",\"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);