diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2f9815a..b91221f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -14,13 +14,14 @@ Dark is the default theme. A light theme is available through the navigation the ## App Store Descriptions and Navigation -- The `/apps/` app store's descriptions are generated from the first sentence of each app's `README.md` in `badger/home` (same logic as `badger/badgerfactory` PR #19). +- The `/apps/` app store reads `public/app-catalog.json` from this website, never GitHub's API in the browser. App descriptions are generated from the first sentence of each app's `README.md` in `badger/home`. - When adding an installable app to the store, give its README a plain-text first sentence. Keys are the exact `badger/home/badge/apps/` folder names and must match `^[a-z0-9][a-z0-9_-]*$`. -- The `Update app descriptions` workflow runs nightly (and on demand), reads `badger/home` `main`, and runs `npm run sync:app-descriptions` and opens a PR. You can also run it on a website branch. -- `src/data/app-descriptions.json` is generated; do not edit it by hand or maintain a second description list. +- The `Deploy` workflow checks out public `badger/home/main` and runs `npm run sync:app-catalog -- .catalog-source` before building. App changes reaching `badger/home/main` trigger it through the companion GitHub App workflow; no releases, generated-data commits, or data-update PRs are needed. +- `public/app-catalog.json` and `src/data/app-descriptions.json` are generated together; do not edit them by hand or maintain a separate description source. For local updates run `npm run sync:app-catalog -- /path/to/home`. - Do not depend on `badger/badgerfactory` for descriptions; the sync reads only public `badger/home` content. - Preserve existing description wording, including exclamation marks; app descriptions are an exception to the punctuation rule for labels and instructions. -- Keep app downloads in `badger/home`; builds and browser code must not need private-repository access. +- Keep app downloads in `badger/home`, pinned to the catalog's source commit; builds and browser code must not need private-repository access. +- Keep the cross-repository App private key in badger/home's main-only `catalog-sync` environment secret, never in code, logs, browser files, or build artifacts. The App is installed only on this website repository with Actions write permission; source changes still require PRs. - The store and description coverage exclude `menu`, `startup`, and `quest` through the shared catalog helper. - Local app content descriptions still serve `/browse-apps/` and individual app pages. - The global **Apps** tab opens the app store and USB/disk tools at `/apps`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fbc5c5..56b6239 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: push: pull_request: +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -26,6 +29,8 @@ jobs: run: npm ci - name: Check app description updates run: npm run test:app-descriptions + - name: Check app catalog updates + run: npm run test:app-catalog - name: Check editor projects run: npm run test:editor - name: Verify Doom runtime and shareware assets diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 84632e0..20807b0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,11 +4,10 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: read - pages: write - id-token: write concurrency: group: pages @@ -16,12 +15,29 @@ concurrency: jobs: build: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + permissions: + contents: read + pages: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Read public app source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: badger/home + ref: main + path: .catalog-source + persist-credentials: false + sparse-checkout-cone-mode: false + sparse-checkout: | + /badge/apps/*/README.md + /badge/apps/*/readme.md - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 cache: npm @@ -29,9 +45,13 @@ jobs: run: npm ci - name: Configure Pages id: pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - name: Verify Doom runtime and shareware assets run: npm run verify:doom-assets -- --require + - name: Generate app catalog and descriptions + run: npm run sync:app-catalog -- .catalog-source + - name: Check app metadata + run: npm run test:app-descriptions && npm run test:app-catalog - name: Build and verify site URLs run: | npm run build @@ -40,17 +60,20 @@ jobs: SITE_URL: ${{ github.repository == 'badger/badger.github.io' && 'https://badger.github.io' || steps.pages.outputs.origin }} BASE_PATH: ${{ steps.pages.outputs.base_path }} - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: dist deploy: needs: build runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/update-app-descriptions.yml b/.github/workflows/update-app-descriptions.yml deleted file mode 100644 index 70e61f8..0000000 --- a/.github/workflows/update-app-descriptions.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Update app descriptions - -on: - schedule: - - cron: "0 3 * * *" - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - update: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - name: Regenerate app descriptions - env: - APPS_REF: main - GITHUB_TOKEN: ${{ github.token }} - run: npm run sync:app-descriptions - # PRs created with GITHUB_TOKEN do not trigger ci.yml, so validate here. - - name: Install dependencies - run: npm ci - - name: Check app description updates - run: npm run test:app-descriptions - - name: Build - run: npm run build - - name: Open pull request - uses: peter-evans/create-pull-request@v7 - with: - branch: automation/update-app-descriptions - commit-message: Update app descriptions from badger/home READMEs - title: Update app descriptions from badger/home READMEs - body: Regenerated `src/data/app-descriptions.json` from the first sentence of each app README in badger/home. - add-paths: src/data/app-descriptions.json diff --git a/.gitignore b/.gitignore index dfc37c4..0186eef 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ dist/ # generated types .astro/ +# public app source used by deployment +.catalog-source/ # dependencies node_modules/ diff --git a/README.md b/README.md index b44df53..64eacdb 100644 --- a/README.md +++ b/README.md @@ -69,36 +69,70 @@ npm run preview # Preview the production build npm run astro # Run Astro CLI commands ``` -## App store descriptions - -The `/apps/` app store uses descriptions generated from the first sentence of -each app's `README.md` in `badger/home`. App downloads also come from -`badger/home`; descriptions on `/browse-apps/` and individual app -pages still come from local app content. -The store excludes the `menu` and `startup` system apps and the `quest` app. - -To update descriptions: - -1. Edit the app's `README.md` in `badger/home` so its first paragraph starts with - one plain-text sentence. -2. After the change merges to `main`, the nightly `Update app descriptions` - workflow reads `badger/home` and opens a pull request here. To run it by hand, use `workflow_dispatch` or run - `npm run sync:app-descriptions` on a website working branch. -3. Run `npm run test:app-descriptions` and `npm run build`, then submit the saved - website changes through a pull request. - -The update command requires Node.js 22.6.0 or newer. It reads the file tree and -READMEs from the public `badger/home` repository (`APPS_REF` selects a branch or -commit, default `main`). Apps without a usable README keep their saved -description. Folders that do not match the lowercase key rule -(`^[a-z0-9][a-z0-9_-]*$`, such as `Copilot`) get no description. Invalid or -incomplete updates fail with a clear error and leave the last good copy -unchanged. - -Do not edit `src/data/app-descriptions.json` by hand. It is the generated website -copy used by normal builds and deployment. A newly added store app remains -installable and is listed without a description until the descriptions are -updated. +## App store catalog and descriptions + +The `/apps/` app store reads `/app-catalog.json` from this website, not GitHub's +REST API. Reload reads the currently published catalog; it does not start a +workflow. App downloads still come from public `badger/home`, using the exact +commit recorded in the catalog so file lists and downloaded code match. + +Each deployment reads the latest `badger/home/main` file tree and app READMEs, +then generates the catalog and `src/data/app-descriptions.json` in its build +workspace. The latter also supplies descriptions in the Developer Simulator. +Descriptions use the first sentence of each app's README. Descriptions on +`/browse-apps/` and individual app pages still come from local app content. +The store excludes `menu`, `startup`, and `quest`. + +After the companion workflow is configured in `badger/home`, a push to its +`main` branch that changes `badge/apps/**` starts this site's `Deploy` workflow. +This includes fixes to existing apps, new or removed files, and README changes. +It does not require a release or tag. Catalog refreshes publish build output +without data commits or data-update PRs. Website source changes still use PRs. +Until that setup is complete, an authorized maintainer can run `Deploy` manually +on `main`. A failed generation or build does not replace the published site. + +### Cross-repository setup + +Use a dedicated organization-owned GitHub App installed only on +`badger/badger.github.io`, with repository **Actions: read and write** permission. +The companion workflow creates a short-lived installation token only to start +`deploy.yml` on `main`; it does not check out or execute badge-app code. + +An administrator must configure a `catalog-sync` environment in `badger/home`, +restricted to the `main` branch, and protect workflow changes through review. +Add the App's non-secret Client ID as the environment variable +`CATALOG_APP_CLIENT_ID` and its private key as the environment secret +`CATALOG_APP_PRIVATE_KEY`. Do not add content-write or administration permissions, +install the App across all repositories, or put the key in code, logs, artifacts, +or browser files. Restrict the website's Pages environment to deployments from +`main` too. Follow the organization's approval requirements. + +The private key remains sensitive even though each installation token is +temporary. Keep it protected and replace it if exposed. To stop automatic +updates, disable the companion workflow or revoke the App's access. + +### Local catalog updates + +Normal local builds use the saved catalog and do not need the GitHub App. +To refresh it, use a checkout of public `badger/home`: + +```bash +npm run sync:app-catalog -- /path/to/home +npm run test:app-descriptions +npm run test:app-catalog +npm run build +``` + +The generator reads committed files at that checkout's `HEAD`, not uncommitted +edits. Only the Git file tree and app READMEs are needed; a sparse checkout is +sufficient. It does not download or execute app code. Apps without a usable +README keep their saved description. Existing folders outside the lowercase +description-key rule (`^[a-z0-9][a-z0-9_-]*$`, such as `Copilot`) remain +installable without a generated description. + +Do not edit `public/app-catalog.json` or `src/data/app-descriptions.json` by hand. +Both are generated from the same public source commit. Invalid or incomplete +updates fail before replacing the generated files. ## šŸ“ Project Structure diff --git a/package.json b/package.json index dac5855..3aa44d5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "preview": "astro preview", "sync:app-descriptions": "node --experimental-strip-types scripts/sync-app-descriptions.mjs", "test:app-descriptions": "node --experimental-strip-types --test scripts/sync-app-descriptions.test.mjs", + "sync:app-catalog": "node --experimental-strip-types scripts/sync-app-catalog.mjs", + "test:app-catalog": "node --experimental-strip-types --test scripts/sync-app-catalog.test.mjs", "astro": "astro", "sync:editor-apps": "node --experimental-strip-types scripts/sync-editor-apps.mjs", "sync:editor-examples": "node scripts/sync-editor-examples.mjs", diff --git a/public/app-catalog.json b/public/app-catalog.json new file mode 100644 index 0000000..f909b13 --- /dev/null +++ b/public/app-catalog.json @@ -0,0 +1,562 @@ +{ + "revision": "dd686d841f3653e7bef5e0ba78f288b0cc03c303", + "apps": [ + { + "name": "30_minutes_to_alpha_centauri", + "files": [ + "README.md", + "__init__.py", + "assets/cargo.png", + "assets/game_over.png", + "assets/hud.png", + "assets/hyperion_bg.png", + "assets/hyperion_obst.png", + "assets/hyperion_wall.png", + "assets/intercom.png", + "assets/level_hyperion.png", + "assets/package_load.png", + "assets/player.png", + "assets/title.png", + "assets/win.png", + "cutscene.py", + "icon.png" + ], + "description": "Pilot a cargo ship through obstacles to complete a space delivery." + }, + { + "name": "badge", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "Display your GitHub profile and contribution graph." + }, + { + "name": "bee_amazed", + "files": [ + "README.md", + "__init__.py", + "assets/background.png", + "assets/bee-down.png", + "assets/bee-left.png", + "assets/bee-right.png", + "assets/bee-up.png", + "assets/flower.png", + "assets/hedge.png", + "icon.png" + ], + "description": "Guide a bee through mazes that get harder with each level." + }, + { + "name": "brattal", + "files": [ + "README.md", + "__init__.py" + ], + "description": "A two-lane arcade shooter." + }, + { + "name": "camera", + "files": [ + "README.md", + "__init__.py", + "assets/LICENSE-MDI.txt", + "assets/NOTICE.txt", + "assets/camera-wireless.png", + "assets/camera-wireless.svg", + "assets/camera.svg", + "assets/mona.png", + "camera_ble.py", + "camera_core.py", + "camera_launcher_icon.py", + "camera_pairing.py", + "icon.png", + "screenshot.png" + ], + "description": "Use the badge as a live viewfinder and shutter for a Wi-Fi ESP32-CAM, then review the photos you take." + }, + { + "name": "clock", + "files": [ + "README.md", + "__init__.py", + "assets/digital_dots.png", + "assets/digital_dots_invert.png", + "assets/digital_num.png", + "assets/digital_num_invert.png", + "assets/icons.png", + "assets/nixie_bg.png", + "assets/nixie_fg.png", + "assets/nixie_num.png", + "daylightsaving.py", + "icon.png", + "usermessage.py" + ], + "description": "Display the time with selectable clock styles and time zones." + }, + { + "name": "commits", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "Break GitHub-colored bricks with a paddle." + }, + { + "name": "contacts", + "files": [ + "README.md", + "__init__.py" + ], + "description": "Exchange contact cards with other badges." + }, + { + "name": "Copilot", + "files": [ + "README.md", + "__init__.py", + "assets/LICENSE.txt", + "assets/attention-00.png", + "assets/attention-01.png", + "assets/attention-02.png", + "assets/attention-03.png", + "assets/attention-04.png", + "assets/attention-05.png", + "assets/attention-06.png", + "assets/attention-07.png", + "assets/attention-08.png", + "assets/attention-09.png", + "assets/attention-10.png", + "assets/attention-11.png", + "assets/blink-1.png", + "assets/blink-2.png", + "assets/blink-3.png", + "assets/blink-4.png", + "assets/complete-00.png", + "assets/complete-01.png", + "assets/complete-02.png", + "assets/complete-03.png", + "assets/complete-04.png", + "assets/complete-05.png", + "assets/complete-06.png", + "assets/complete-07.png", + "assets/complete-08.png", + "assets/complete-09.png", + "assets/complete-10.png", + "assets/complete-11.png", + "assets/down-00.png", + "assets/down-01.png", + "assets/down-02.png", + "assets/down-03.png", + "assets/down-04.png", + "assets/down-05.png", + "assets/down-06.png", + "assets/down-07.png", + "assets/down-08.png", + "assets/down-09.png", + "assets/down-10.png", + "assets/down-11.png", + "assets/left-00.png", + "assets/left-01.png", + "assets/left-02.png", + "assets/left-03.png", + "assets/left-04.png", + "assets/left-05.png", + "assets/left-06.png", + "assets/left-07.png", + "assets/left-08.png", + "assets/left-09.png", + "assets/left-10.png", + "assets/left-11.png", + "assets/neutral.png", + "assets/right-00.png", + "assets/right-01.png", + "assets/right-02.png", + "assets/right-03.png", + "assets/right-04.png", + "assets/right-05.png", + "assets/right-06.png", + "assets/right-07.png", + "assets/right-08.png", + "assets/right-09.png", + "assets/right-10.png", + "assets/right-11.png", + "assets/sources.json", + "assets/surprise-00.png", + "assets/surprise-01.png", + "assets/surprise-02.png", + "assets/surprise-03.png", + "assets/surprise-04.png", + "assets/surprise-05.png", + "assets/surprise-06.png", + "assets/surprise-07.png", + "assets/surprise-08.png", + "assets/surprise-09.png", + "assets/surprise-10.png", + "assets/surprise-11.png", + "assets/up-00.png", + "assets/up-01.png", + "assets/up-02.png", + "assets/up-03.png", + "assets/up-04.png", + "assets/up-05.png", + "assets/up-06.png", + "assets/up-07.png", + "assets/up-08.png", + "assets/up-09.png", + "assets/up-10.png", + "assets/up-11.png", + "assets/working-00.png", + "assets/working-01.png", + "assets/working-02.png", + "assets/working-03.png", + "assets/working-04.png", + "assets/working-05.png", + "assets/working-06.png", + "assets/working-07.png", + "assets/working-08.png", + "assets/working-09.png", + "assets/working-10.png", + "assets/working-11.png", + "copilot_monitor.py", + "icon.png" + ] + }, + { + "name": "demos", + "files": [ + "README.md", + "__init__.py", + "demos/__init__.py", + "demos/blur.py", + "demos/blur_brush.py", + "demos/circles.py", + "demos/dither.py", + "demos/erase_brush.py", + "demos/fill_rules.py", + "demos/gradient.py", + "demos/image_brush.py", + "demos/intersect.py", + "demos/joins_caps.py", + "demos/lighten_darken.py", + "demos/line_clip.py", + "demos/lines.py", + "demos/pattern.py", + "demos/pixelate.py", + "demos/raycast.py", + "demos/shape.py", + "demos/sprites.py", + "demos/stroke.py", + "demos/text.py", + "demos/text_align.py", + "demos/transform_sprite.py", + "demos/triangles.py", + "demos/vector_shapes.py", + "demos/vector_text.py", + "icon.png" + ], + "description": "Browse graphics demos showing shapes, text, sprites, and other display effects." + }, + { + "name": "extend_a_squirrel", + "files": [ + "README.md", + "__init__.py", + "assets/acorn10.png", + "assets/acorn15.png", + "assets/acorn_multi.png", + "assets/bg.png", + "assets/gameover.png", + "assets/sqirl.png", + "assets/title.png", + "draw_tufty.py", + "icon.png" + ], + "description": "Guide a growing squirrel through a Snake-style game without hitting its tail." + }, + { + "name": "flappy_mona", + "files": [ + "README.md", + "__init__.py", + "assets/background.png", + "assets/cloud.png", + "assets/grass.png", + "assets/mona.png", + "assets/obstacles.png", + "icon.png", + "mona.py", + "obstacle.py" + ], + "description": "Keep Mona safe and defying gravity." + }, + { + "name": "gallery", + "files": [ + "README.md", + "__init__.py", + "icon.png", + "images/duck.png" + ], + "description": "Browse images with a thumbnail carousel." + }, + { + "name": "ghost_signal", + "files": [ + "README.md", + "__init__.py" + ], + "description": "Match a moving wave pattern to get the highest accuracy possible." + }, + { + "name": "hydrate", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "Track your water intake against a daily goal." + }, + { + "name": "input_test", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "Test the badge's touch controls, buttons, and motion input." + }, + { + "name": "iss", + "files": [ + "README.md", + "__init__.py", + "world.geo.json" + ], + "description": "Track the International Space Station on a live world map." + }, + { + "name": "life", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "Watch cell patterns evolve in Conway's Game of Life." + }, + { + "name": "mass_storage", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "Restart the badge in USB storage mode to manage its files." + }, + { + "name": "merge_conflict", + "files": [ + "README.md", + "__init__.py", + "games.py", + "mona-code.png", + "mona-dance.png", + "mona-dead.png", + "mona-default.png", + "mona-eating.png", + "mona-heart.png", + "mona-notify.png", + "replay.py", + "ui.py" + ], + "description": "A collection of short 2 player games, including badge-link play." + }, + { + "name": "mona_paint", + "files": [ + "README.md", + "__init__.py", + "ui.py" + ], + "description": "A tilt-aware paint program with drawing tools and undo." + }, + { + "name": "mona_pet", + "files": [ + "README.md", + "__init__.py", + "assets/background/background_day.png", + "assets/background/background_dusk.png", + "assets/background/background_night.png", + "assets/mona-sprites/mona-code.png", + "assets/mona-sprites/mona-dance.png", + "assets/mona-sprites/mona-dead.png", + "assets/mona-sprites/mona-default.png", + "assets/mona-sprites/mona-eating.png", + "assets/mona-sprites/mona-heart.png", + "assets/mona-sprites/mona-love.png", + "assets/mona-sprites/mona-notify.png", + "assets/mona-sprites/mona-sleep.png", + "assets/ui/buttons.png", + "assets/ui/frame.png", + "clock.py", + "food.py", + "icons.py", + "match.py", + "tufty.py", + "ui.py" + ], + "description": "A virtual pet with feeding, cleaning, and matching games." + }, + { + "name": "monapet", + "files": [ + "README.md", + "__init__.py", + "assets/arrows.png", + "assets/icons.png", + "assets/mona-sprites/mona-code.png", + "assets/mona-sprites/mona-dance.png", + "assets/mona-sprites/mona-dead.png", + "assets/mona-sprites/mona-default.png", + "assets/mona-sprites/mona-eating.png", + "assets/mona-sprites/mona-heart.png", + "assets/mona-sprites/mona-love.png", + "assets/mona-sprites/mona-notify.png", + "assets/portrait.png", + "icon.png", + "mona.py", + "ui.py" + ], + "description": "Care for your very own Mona!" + }, + { + "name": "pong", + "files": [ + "README.md", + "__init__.py" + ], + "description": "Badge-link multiplayer Pong." + }, + { + "name": "rogue", + "files": [ + "README.md", + "__init__.py", + "build.py", + "content.py", + "game.py", + "items.py", + "monsters.py", + "render.py", + "rings.py", + "save.py", + "scrolls.py", + "world.py" + ], + "description": "Explore dungeons and fight monsters in a turn-based game." + }, + { + "name": "sense", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "View temperature, humidity, pressure, light, and motion sensor readings." + }, + { + "name": "settings", + "files": [ + "README.md", + "__init__.py" + ], + "description": "Edit network, profile, and backlight settings." + }, + { + "name": "show", + "files": [ + "README.md", + "__init__.py" + ], + "description": "Start the firmware's local Sparkle light show." + }, + { + "name": "sketchy_sketch", + "files": [ + "README.md", + "__init__.py", + "assets/nope.ppf", + "icon.png", + "ui.py" + ], + "description": "Draw on the badge by moving a cursor with the directional controls." + }, + { + "name": "snake", + "files": [ + "README.md", + "__init__.py" + ], + "description": "A continuously moving snake game." + }, + { + "name": "snarky_sciuridae", + "files": [ + "README.md", + "__init__.py", + "assets/background/background_day.png", + "assets/background/background_dusk.png", + "assets/background/background_night.png", + "assets/squirrel-sprites/clean.png", + "assets/squirrel-sprites/dance.png", + "assets/squirrel-sprites/dead.png", + "assets/squirrel-sprites/dig.png", + "assets/squirrel-sprites/eat.png", + "assets/squirrel-sprites/idle.png", + "assets/squirrel-sprites/lick.png", + "assets/squirrel-sprites/notify.png", + "assets/squirrel-sprites/run.png", + "assets/squirrel-sprites/sleep.png", + "assets/squirrel-sprites/tail.png", + "assets/ui/buttons_48x14.png", + "assets/ui/icons.png", + "assets/ui/overlay_scanlines.png", + "assets/ui/tufty_frame_beige.png", + "icon.png", + "ui.py", + "vpet.py" + ], + "description": "Care for your very own Squirrel!" + }, + { + "name": "system", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "View the badge's time and battery status and sync its clock over Wi-Fi." + }, + { + "name": "tennis", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "Play a Pong-style game against a computer-controlled paddle." + }, + { + "name": "tomato", + "files": [ + "README.md", + "__init__.py", + "icon.png" + ], + "description": "Your very own Pomodoro Timer!" + } + ] +} diff --git a/scripts/sync-app-catalog.mjs b/scripts/sync-app-catalog.mjs new file mode 100644 index 0000000..891b156 --- /dev/null +++ b/scripts/sync-app-catalog.mjs @@ -0,0 +1,67 @@ +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { getInstallableAppFolders, validateAppCatalog } from '../src/lib/github-app-catalog.ts' +import { APP_DESCRIPTIONS_PATH, descriptionsForCatalog, writeJsonFile } from './sync-app-descriptions.mjs' + +const exec = promisify(execFile) +const OUTPUT_PATH = fileURLToPath(new URL('../public/app-catalog.json', import.meta.url)) + +export async function syncAppCatalog({ + sourcePath = process.env.BADGER_HOME_PATH, + outputPath = OUTPUT_PATH, + descriptionsPath = APP_DESCRIPTIONS_PATH, +} = {}) { + if (!sourcePath) throw new Error('Provide the badger/home checkout path as an argument or BADGER_HOME_PATH.') + const options = { cwd: resolve(sourcePath), maxBuffer: 16 * 1024 * 1024 } + const git = async (...args) => (await exec('git', args, options)).stdout + const revision = (await git('rev-parse', '--verify', 'HEAD')).trim() + const tree = (await git('ls-tree', '-r', '-z', revision, '--', 'badge/apps')) + .split('\0').filter(Boolean).map((entry) => { + const separator = entry.indexOf('\t') + if (separator < 0 || !/^100(?:644|755) blob [a-f0-9]{40}$/.test(entry.slice(0, separator))) { + throw new Error('App files must be regular files, not symlinks or submodules.') + } + return { path: entry.slice(separator + 1), type: 'blob' } + }) + const paths = new Set(tree.map((entry) => entry.path)) + let existing = {} + try { + existing = JSON.parse(await readFile(descriptionsPath, 'utf8')) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + const descriptions = await descriptionsForCatalog({ tree, truncated: false }, { + ref: revision, + existing, + readReadme: async (ref, name, file) => { + const path = `badge/apps/${name}/${file}` + return paths.has(path) ? git('show', `${ref}:${path}`) : null + }, + }) + const catalog = validateAppCatalog({ + revision, + apps: getInstallableAppFolders(tree) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, files]) => ({ + name, + files: files.sort(), + ...(Object.hasOwn(descriptions, name) ? { description: descriptions[name] } : {}), + })), + }) + const descriptionsChanged = await writeJsonFile(descriptionsPath, descriptions) + const changed = await writeJsonFile(outputPath, catalog) + return { changed: changed || descriptionsChanged, count: catalog.apps.length, revision } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const result = await syncAppCatalog({ sourcePath: process.argv[2] || process.env.BADGER_HOME_PATH }) + console.log(`${result.changed ? 'Updated' : 'Unchanged'}: ${result.count} apps from badger/home at ${result.revision}.`) + } catch (error) { + console.error(`App catalog update failed: ${error.message}`) + process.exitCode = 1 + } +} diff --git a/scripts/sync-app-catalog.test.mjs b/scripts/sync-app-catalog.test.mjs new file mode 100644 index 0000000..3ac9b0f --- /dev/null +++ b/scripts/sync-app-catalog.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { execFile } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import test from 'node:test' +import { promisify } from 'node:util' +import { appFileUrl, fetchAppCatalog, validateAppCatalog } from '../src/lib/github-app-catalog.ts' +import { syncAppCatalog } from './sync-app-catalog.mjs' + +const exec = promisify(execFile) +const published = () => ({ + revision: 'a'.repeat(40), + apps: [{ name: 'snake', files: ['__init__.py', 'icon.png'], description: 'Play Snake!' }], +}) + +async function fixture(t, files) { + const directory = await mkdtemp(join(tmpdir(), 'badger-app-catalog-')) + t.after(() => rm(directory, { recursive: true, force: true })) + const sourcePath = join(directory, 'home') + await mkdir(sourcePath) + const git = async (...args) => (await exec('git', args, { cwd: sourcePath })).stdout.trim() + await git('init', '-q', '--initial-branch=catalog-test') + const write = async (path, contents) => { + const target = join(sourcePath, path) + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, contents) + } + const commit = async () => { + await git('add', '.') + await git('-c', 'user.name=Catalog Tests', '-c', 'user.email=catalog-tests@example.invalid', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'Catalog fixture') + return git('rev-parse', 'HEAD') + } + for (const [path, contents] of Object.entries(files)) await write(path, contents) + await commit() + return { + sourcePath, write, commit, + outputPath: join(directory, 'app-catalog.json'), + descriptionsPath: join(directory, 'app-descriptions.json'), + } +} + +test('publishes committed app files and descriptions without requiring a release', async (t) => { + const options = await fixture(t, { + 'badge/apps/snake/__init__.py': 'print("first")\n', + 'badge/apps/snake/README.md': '# Snake\n\nPlay Snake! More details.\n', + 'badge/apps/snake/assets/leaf #1.png': 'image', + 'badge/apps/snake/__pycache__/cached.pyc': 'cached', + 'badge/apps/Copilot/__init__.py': 'pass\n', + 'badge/apps/menu/__init__.py': 'pass\n', + 'badge/apps/startup/__init__.py': 'pass\n', + 'badge/apps/quest/__init__.py': 'pass\n', + }) + await options.write('badge/apps/snake/README.md', 'Uncommitted description.\n') + const first = await syncAppCatalog(options) + const catalog = JSON.parse(await readFile(options.outputPath, 'utf8')) + assert.equal(catalog.revision, first.revision) + assert.deepEqual(catalog.apps, [ + { name: 'Copilot', files: ['__init__.py'] }, + { name: 'snake', files: ['__init__.py', 'assets/leaf #1.png', 'README.md'].sort(), description: 'Play Snake!' }, + ]) + assert.deepEqual(JSON.parse(await readFile(options.descriptionsPath, 'utf8')), { snake: 'Play Snake!' }) + assert.equal((await syncAppCatalog(options)).changed, false) + + await options.write('badge/apps/snake/README.md', '# Snake\n\nPlay Snake! More details.\n') + await options.write('badge/apps/snake/__init__.py', 'print("fixed")\n') + const nextRevision = await options.commit() + await syncAppCatalog(options) + const updated = JSON.parse(await readFile(options.outputPath, 'utf8')) + assert.equal(updated.revision, nextRevision) + assert.notEqual(updated.revision, first.revision) + assert.deepEqual(updated.apps, catalog.apps) + assert.notEqual(appFileUrl(updated.revision, 'snake', '__init__.py'), appFileUrl(first.revision, 'snake', '__init__.py')) +}) + +test('failed catalog validation preserves both last good generated files', async (t) => { + const options = await fixture(t, { + 'badge/apps/snake/__init__.py': 'pass\n', + 'badge/apps/snake/README.md': 'Play Snake.\n', + }) + await syncAppCatalog(options) + const catalog = await readFile(options.outputPath, 'utf8') + const descriptions = await readFile(options.descriptionsPath, 'utf8') + await options.write('badge/apps/bad name/__init__.py', 'pass\n') + await options.commit() + await assert.rejects(syncAppCatalog(options), /invalid app/) + assert.equal(await readFile(options.outputPath, 'utf8'), catalog) + assert.equal(await readFile(options.descriptionsPath, 'utf8'), descriptions) +}) + +test('rejects symlinks and checkouts without installable apps', async (t) => { + const options = await fixture(t, { 'badge/apps/docs/README.md': 'Documentation only.\n' }) + await assert.rejects(syncAppCatalog(options), /no installable apps/) + await options.write('badge/apps/snake/__init__.py', 'pass\n') + await options.write('badge/apps/snake/README.md', 'Play Snake.\n') + await symlink('README.md', join(options.sourcePath, 'badge/apps/snake/link')) + await options.commit() + await assert.rejects(syncAppCatalog(options), /symlinks or submodules/) +}) + +test('loads the same-site catalog even when GitHub API requests would be rate limited', async (t) => { + const requests = [] + let apiStatus = 403 + t.mock.method(globalThis, 'fetch', async (url, options) => { + requests.push({ url, options }) + return String(url).startsWith('https://api.github.com/') + ? new Response('', { status: apiStatus }) + : Response.json(published()) + }) + assert.deepEqual(await fetchAppCatalog(), published()) + apiStatus = 429 + assert.deepEqual(await fetchAppCatalog({ refresh: true }), published()) + assert.deepEqual(requests.map(({ url }) => url), ['/app-catalog.json', '/app-catalog.json']) + assert.equal(requests[1].options.cache, 'no-store') +}) + +test('rejects failed requests and incomplete or unsafe published catalogs', async (t) => { + t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 503 })) + await assert.rejects(fetchAppCatalog(), /503/) + for (const catalog of [ + null, {}, { ...published(), revision: 'main' }, { ...published(), revision: [published().revision] }, { ...published(), apps: [] }, + { ...published(), apps: [...published().apps, ...published().apps] }, + ...['../snake', 'menu', 'startup', 'quest', 'constructor'].map((name) => ({ + ...published(), apps: [{ ...published().apps[0], name }], + })), + ...[[], ['icon.png'], ['__init__.py', '../other.py'], ['__init__.py', '/other.py'], + ['__init__.py', 'assets\\other.py'], ['__init__.py', '__init__.py']].map((files) => ({ + ...published(), apps: [{ ...published().apps[0], files }], + })), + ]) { + assert.throws(() => validateAppCatalog(catalog), /catalog/) + } +}) + +test('downloads from the catalog commit and encodes each file path segment', () => { + assert.equal(appFileUrl(published().revision, 'Copilot', 'assets/leaf #1.png'), + `https://raw.githubusercontent.com/badger/home/${published().revision}/badge/apps/Copilot/assets/leaf%20%231.png`) +}) diff --git a/scripts/sync-app-descriptions.mjs b/scripts/sync-app-descriptions.mjs index ec675cb..989bf43 100644 --- a/scripts/sync-app-descriptions.mjs +++ b/scripts/sync-app-descriptions.mjs @@ -14,7 +14,7 @@ export function readmeUrl(ref, name, file) { return `https://raw.githubusercontent.com/${HOME_REPOSITORY}/${ref}/badge/apps/${name}/${file}` } -const OUTPUT_PATH = fileURLToPath(new URL('../src/data/app-descriptions.json', import.meta.url)) +export const APP_DESCRIPTIONS_PATH = fileURLToPath(new URL('../src/data/app-descriptions.json', import.meta.url)) const README_NAMES = ['README.md', 'readme.md'] const BLOCK_PREFIXES = ['#', '>', '|', '-', '!', '[!', '```', '<'] @@ -97,21 +97,11 @@ export function validateDescriptions(descriptions, catalog) { )) } -export async function syncAppDescriptions({ - outputPath = OUTPUT_PATH, - ref = process.env.APPS_REF || 'main', - readCatalog = readHomeCatalog, +export async function descriptionsForCatalog(catalog, { + ref, + existing = {}, readReadme = readHomeReadme, } = {}) { - let previous - try { - previous = await readFile(outputPath, 'utf8') - } catch (error) { - if (error.code !== 'ENOENT') throw error - } - const existing = previous ? JSON.parse(previous) : {} - - const catalog = await readCatalog(ref) const source = { ...existing } const folders = Array.isArray(catalog?.tree) ? describableFolders(catalog.tree) : [] for (const name of folders) { @@ -124,10 +114,16 @@ export async function syncAppDescriptions({ } } - const descriptions = validateDescriptions(source, catalog) - const contents = `${JSON.stringify(descriptions, null, 2)}\n` - if (previous === contents) return { changed: false, count: Object.keys(descriptions).length } + return validateDescriptions(source, catalog) +} +export async function writeJsonFile(outputPath, value) { + const contents = `${JSON.stringify(value, null, 2)}\n` + try { + if (await readFile(outputPath, 'utf8') === contents) return false + } catch (error) { + if (error.code !== 'ENOENT') throw error + } await mkdir(dirname(outputPath), { recursive: true }) const temporaryPath = `${outputPath}.${randomUUID()}.tmp` try { @@ -136,7 +132,24 @@ export async function syncAppDescriptions({ } finally { await rm(temporaryPath, { force: true }) } - return { changed: true, count: Object.keys(descriptions).length } + return true +} + +export async function syncAppDescriptions({ + outputPath = APP_DESCRIPTIONS_PATH, + ref = process.env.APPS_REF || 'main', + readCatalog = readHomeCatalog, + readReadme = readHomeReadme, +} = {}) { + let existing = {} + try { + existing = JSON.parse(await readFile(outputPath, 'utf8')) + } catch (error) { + if (error.code !== 'ENOENT') throw error + } + const catalog = await readCatalog(ref) + const descriptions = await descriptionsForCatalog(catalog, { ref, existing, readReadme }) + return { changed: await writeJsonFile(outputPath, descriptions), count: Object.keys(descriptions).length } } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { diff --git a/src/components/app-count.tsx b/src/components/app-count.tsx index 69bfc06..a2edebc 100644 --- a/src/components/app-count.tsx +++ b/src/components/app-count.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { fetchInstallableAppFolders } from '@/lib/github-app-catalog' +import { fetchAppCatalog } from '@/lib/github-app-catalog' export interface AppCountProps { fallback: number @@ -11,9 +11,9 @@ export function AppCount({ fallback }: AppCountProps) { useEffect(() => { let active = true - fetchInstallableAppFolders() - .then((folders) => { - if (active) setCount(folders.length) + fetchAppCatalog() + .then((catalog) => { + if (active) setCount(catalog.apps.length) }) .catch(() => { if (active) setCount(fallback) diff --git a/src/components/badge-manager.tsx b/src/components/badge-manager.tsx index 56a0145..0f74dda 100644 --- a/src/components/badge-manager.tsx +++ b/src/components/badge-manager.tsx @@ -5,7 +5,7 @@ import { BadgeSetupGuide } from '@/components/badge-setup-guide' import { StoreAppCard } from '@/components/store-app-card' import { SERIAL_CONNECTION_MODE_ENABLED } from '@/config/features' import { PREINSTALLED_APPS_BY_STORE_ID } from '@/data/preinstalled-apps' -import { fetchInstallableAppFolders } from '@/lib/github-app-catalog' +import { appFileUrl, fetchAppCatalog } from '@/lib/github-app-catalog' declare module 'react' { interface InputHTMLAttributes { @@ -60,6 +60,7 @@ type StoreApp = { name: string title: string files: string[] + revision: string iconUrl?: string iconColor?: string description?: string @@ -176,7 +177,7 @@ async function collectDroppedFiles(dataTransfer: DataTransfer) { return (await Promise.all(entries.map((entry) => visit(entry)))).flat() } -export function BadgeManager({ appDescriptions = {} }: { appDescriptions?: Record }) { +export function BadgeManager() { const [port, setPort] = useState(null) const portRef = useRef(null) const [diskRoot, setDiskRoot] = useState(null) @@ -187,7 +188,7 @@ export function BadgeManager({ appDescriptions = {} }: { appDescriptions?: Recor const [apps, setApps] = useState([]) const [legacyApps, setLegacyApps] = useState([]) const [storeApps, setStoreApps] = useState([]) - const [storeMessage, setStoreMessage] = useState('Loading apps from badger/home…') + const [storeMessage, setStoreMessage] = useState('Loading apps…') const [storeLoading, setStoreLoading] = useState(false) const [pendingApps, setPendingApps] = useState([]) const [removeQueue, setRemoveQueue] = useState([]) @@ -276,27 +277,29 @@ export function BadgeManager({ appDescriptions = {} }: { appDescriptions?: Recor async function loadStore(refresh = false) { setStoreLoading(true) - setStoreMessage(`${refresh ? 'Reloading' : 'Loading'} apps from badger/home…`) + setStoreMessage(`${refresh ? 'Reloading' : 'Loading'} apps…`) try { - const folders = await fetchInstallableAppFolders({ refresh }) - const loadedApps = folders - .map(([name, files]) => { + const catalog = await fetchAppCatalog({ refresh }) + const loadedApps = catalog.apps + .map(({ name, files, description }) => { const preinstalled = PREINSTALLED_APPS_BY_STORE_ID[name] return { name, title: preinstalled?.title ?? titleForApp(name), files: files.sort(), - iconUrl: preinstalled?.iconSrc ?? (files.includes('icon.png') ? `https://raw.githubusercontent.com/badger/home/main/badge/apps/${name}/icon.png` : undefined), + revision: catalog.revision, + iconUrl: preinstalled?.iconSrc ?? (files.includes('icon.png') ? appFileUrl(catalog.revision, name, 'icon.png') : undefined), iconColor: preinstalled?.color, - description: Object.hasOwn(appDescriptions, name) ? appDescriptions[name] : preinstalled?.description, + description: description ?? preinstalled?.description, } }) .sort((left, right) => left.title.localeCompare(right.title)) setStoreApps(loadedApps) setStoreMessage(`${loadedApps.length} apps${refresh ? ' reloaded' : ''} Ā· source: badger/home`) - } catch (error) { - const message = error instanceof Error ? error.message : 'The app catalog request failed.' - setStoreMessage(`Could not ${refresh ? 'reload' : 'load'} the app catalog: ${message}`) + } catch { + setStoreMessage(storeApps.length + ? 'Could not reload the app list. The previous list is still available.' + : 'Could not load the app list. Select Reload to try again.') } finally { setStoreLoading(false) } @@ -613,7 +616,7 @@ export function BadgeManager({ appDescriptions = {} }: { appDescriptions?: Recor } setStatus(`Getting ${app.title} from badger/home…`) const files = await Promise.all(app.files.map(async (path) => { - const response = await fetch(`https://raw.githubusercontent.com/badger/home/main/badge/apps/${app.name}/${path}`) + const response = await fetch(appFileUrl(app.revision, app.name, path)) if (!response.ok) throw new Error(`Could not get ${app.title} from the app store.`) const contents = await response.blob() return { file: new File([contents], path.split('/').at(-1) ?? path), relativePath: `${app.name}/${path}` } diff --git a/src/data/app-descriptions.json b/src/data/app-descriptions.json index be47170..b6ea1aa 100644 --- a/src/data/app-descriptions.json +++ b/src/data/app-descriptions.json @@ -2,24 +2,35 @@ "30_minutes_to_alpha_centauri": "Pilot a cargo ship through obstacles to complete a space delivery.", "badge": "Display your GitHub profile and contribution graph.", "bee_amazed": "Guide a bee through mazes that get harder with each level.", + "brattal": "A two-lane arcade shooter.", "camera": "Use the badge as a live viewfinder and shutter for a Wi-Fi ESP32-CAM, then review the photos you take.", "clock": "Display the time with selectable clock styles and time zones.", "commits": "Break GitHub-colored bricks with a paddle.", + "contacts": "Exchange contact cards with other badges.", "demos": "Browse graphics demos showing shapes, text, sprites, and other display effects.", "extend_a_squirrel": "Guide a growing squirrel through a Snake-style game without hitting its tail.", "flappy": "Guide Mona past obstacles in a Flappy Bird-style game.", + "flappy_mona": "Keep Mona safe and defying gravity.", "gallery": "Browse images with a thumbnail carousel.", "ghost_signal": "Match a moving wave pattern to get the highest accuracy possible.", "hydrate": "Track your water intake against a daily goal.", "input_test": "Test the badge's touch controls, buttons, and motion input.", + "iss": "Track the International Space Station on a live world map.", "iss_tracker": "View the International Space Station's location on a map and check its crew.", "life": "Watch cell patterns evolve in Conway's Game of Life.", "mass_storage": "Restart the badge in USB storage mode to manage its files.", + "merge_conflict": "A collection of short 2 player games, including badge-link play.", + "mona_paint": "A tilt-aware paint program with drawing tools and undo.", + "mona_pet": "A virtual pet with feeding, cleaning, and matching games.", "monapet": "Care for your very own Mona!", "plucky_cluck": "Guide a chicken past obstacles in a Flappy Bird-style game.", + "pong": "Badge-link multiplayer Pong.", "rogue": "Explore dungeons and fight monsters in a turn-based game.", "sense": "View temperature, humidity, pressure, light, and motion sensor readings.", + "settings": "Edit network, profile, and backlight settings.", + "show": "Start the firmware's local Sparkle light show.", "sketchy_sketch": "Draw on the badge by moving a cursor with the directional controls.", + "snake": "A continuously moving snake game.", "snarky_sciuridae": "Care for your very own Squirrel!", "system": "View the badge's time and battery status and sync its clock over Wi-Fi.", "tennis": "Play a Pong-style game against a computer-controlled paddle.", diff --git a/src/lib/github-app-catalog.ts b/src/lib/github-app-catalog.ts index d70f410..82d9919 100644 --- a/src/lib/github-app-catalog.ts +++ b/src/lib/github-app-catalog.ts @@ -1,10 +1,22 @@ +import { sitePath } from './site-path.ts' + export const GITHUB_APPS_TREE_URL = 'https://api.github.com/repos/badger/home/git/trees/main?recursive=1' +export const APP_CATALOG_URL = '/app-catalog.json' export type GitHubTreeEntry = { path: string type: string } +export type AppCatalog = { + revision: string + apps: { + name: string + files: string[] + description?: string + }[] +} + export function getInstallableAppFolders(tree: GitHubTreeEntry[]) { const folders = new Map() @@ -19,9 +31,36 @@ export function getInstallableAppFolders(tree: GitHubTreeEntry[]) { return [...folders.entries()].filter(([, files]) => files.includes('__init__.py')) } -export async function fetchInstallableAppFolders({ refresh = false }: { refresh?: boolean } = {}) { - const response = await fetch(GITHUB_APPS_TREE_URL, refresh ? { cache: 'no-store' } : undefined) - if (!response.ok) throw new Error(`GitHub app catalog request failed with ${response.status}`) - const payload = await response.json() as { tree?: GitHubTreeEntry[] } - return getInstallableAppFolders(payload.tree ?? []) +export function validateAppCatalog(value: unknown): AppCatalog { + const catalog = value as AppCatalog | null + if (!catalog || typeof catalog !== 'object' || typeof catalog.revision !== 'string' || !/^[a-f0-9]{40}$/.test(catalog.revision) + || !Array.isArray(catalog.apps) || !catalog.apps.length) { + throw new Error('The published app catalog is incomplete or invalid.') + } + + const names = new Set() + for (const app of catalog.apps) { + if (!app || typeof app.name !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/i.test(app.name) + || ['menu', 'startup', 'quest', 'constructor', 'prototype'].includes(app.name) + || names.has(app.name) || !Array.isArray(app.files) || !app.files.includes('__init__.py') + || new Set(app.files).size !== app.files.length + || app.files.some((path) => typeof path !== 'string' || /[\\\u0000-\u001f\u007f]/.test(path) + || path.split('/').some((part) => !part || part === '.' || part === '..')) + || (app.description !== undefined && (typeof app.description !== 'string' || !app.description.trim() + || app.description !== app.description.trim() || /[\u0000-\u001f\u007f]/.test(app.description)))) { + throw new Error('The published app catalog contains an invalid app.') + } + names.add(app.name) + } + return catalog +} + +export async function fetchAppCatalog({ refresh = false }: { refresh?: boolean } = {}) { + const response = await fetch(sitePath(APP_CATALOG_URL), { cache: refresh ? 'no-store' : 'no-cache' }) + if (!response.ok) throw new Error(`App catalog request failed with ${response.status}`) + return validateAppCatalog(await response.json()) +} + +export function appFileUrl(revision: string, name: string, path: string) { + return `https://raw.githubusercontent.com/badger/home/${revision}/badge/apps/${encodeURIComponent(name)}/${path.split('/').map(encodeURIComponent).join('/')}` } diff --git a/src/pages/apps.astro b/src/pages/apps.astro index 62f0146..77cabba 100644 --- a/src/pages/apps.astro +++ b/src/pages/apps.astro @@ -2,7 +2,6 @@ import Layout from '@/layouts/Layout.astro' import ListingHero from '@/components/listing-hero.astro' import { BadgeManager } from '@/components/badge-manager' -import appDescriptions from '@/data/app-descriptions.json' --- @@ -13,7 +12,7 @@ import appDescriptions from '@/data/app-descriptions.json' />
- +
diff --git a/tests/site-paths.test.mjs b/tests/site-paths.test.mjs index 439deca..077cbb3 100644 --- a/tests/site-paths.test.mjs +++ b/tests/site-paths.test.mjs @@ -29,6 +29,26 @@ for (const base of ['/', '/badger.github.io/']) { } }) + test(`published app catalog respects the deployment base ${base}`, async t => { + const { fetchAppCatalog } = await compileModule('../src/lib/github-app-catalog.ts', base) + const catalog = { + revision: 'a'.repeat(40), + apps: [{ name: 'snake', files: ['__init__.py'], description: 'Play Snake.' }], + } + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, options) => { + requests.push({ url, cache: options.cache }) + return Response.json(catalog) + }) + + assert.deepEqual(await fetchAppCatalog(), catalog) + assert.deepEqual(await fetchAppCatalog({ refresh: true }), catalog) + assert.deepEqual(requests, [ + { url: `${base}app-catalog.json`, cache: 'no-cache' }, + { url: `${base}app-catalog.json`, cache: 'no-store' }, + ]) + }) + test(`Doom fetches manifest, WAD, and manifest artifacts under ${base}`, async () => { const { loadDoomRuntime } = await compileModule('../src/lib/doom-runtime.ts', base) const manifest = JSON.parse(await readFile(