--- name: hz-store-pwa license: Apache-2.0 description: Guides shipping a web app to the Meta VR and Horizon OS Store as a PWA/TWA — both 2D windowed panels and immersive WebXR/VR. Covers building the web app (IWSDK for WebXR, any responsive PWA for 2D), Vercel deploy, web app manifest + icons, the WebXR-only auto-enter-session step, choosing 2D vs immersive mode in @meta-quest/bubblewrap-cli, keystore/Digital-Asset-Links, and ovr-platform-util Store upload. Use before any IWSDK/WebXR build, PWA packaging, bubblewrap, or Horizon Store upload work. allowed-tools: Bash(npx:*), Bash(npm:*), Bash(curl:*), Bash(bubblewrap:*) --- # Store PWA/TWA Skill Guide the end-to-end process of wrapping a web app as a Meta VR app and shipping it to the Meta Horizon Store. This skill covers both delivery modes — a **2D windowed panel** and an **immersive WebXR/VR** experience — through the same pipeline: build the web app, deploy to Vercel, add a PWA manifest + icons, package as a signed Meta VR APK with `@meta-quest/bubblewrap-cli`, and upload with `ovr-platform-util`. Commands use `<…>` tokens (e.g. ``, ``, ``, ``) — substitute your own values before running. ## When to Use This Skill Use this skill when you need to: - Ship a web app (2D or WebXR) to the Meta Horizon Store as a PWA/TWA - Decide whether an app should run as a 2D panel or an immersive WebXR session - Build a WebXR app with IWSDK and wire up auto-enter-session for the installed PWA - Deploy a PWA to Vercel and produce a valid, installable web app manifest + icons - Package a live PWA into a signed Meta VR APK with `@meta-quest/bubblewrap-cli` - Configure the signing keystore and Digital Asset Links so the TWA will launch - Upload a build to the Store with `ovr-platform-util` - Troubleshoot a 2D app stuck loading, an immersive app showing a URL bar, a TWA that won't launch, or an upload that's blocked For deeper IWSDK app-building guidance, see the `hz-iwsdk-webxr` skill. For the broader Store submission process (VRC compliance, store assets, review tracking), see the `hz-store-submit` skill. ## Pipeline Overview The full pipeline follows this order. The two mode-specific deltas are flagged; all other steps are identical for 2D and immersive. ``` 0. Pick app mode → 2D panel vs immersive WebXR (sets steps 1 + 4) 1. Build the web app → IWSDK WebXR app (immersive) OR any responsive PWA (2D) 2. Deploy to Vercel → public HTTPS origin = 3. Manifest + icons → installable web app manifest, PNG icons, live on 4. Package as APK → bubblewrap: keystore, twa-manifest, build, asset links 5. Upload to the Store → ovr-platform-util upload-quest-build ``` Dependencies between steps matter — see [Order of Operations](#order-of-operations) at the end. ## Step 0: Pick the App Mode First The app mode is the single most important decision, chosen once. It changes exactly two things downstream: 1. Whether the web app auto-enters a WebXR session on launch (immersive only). 2. The `horizonOSAppMode` value in `twa-manifest.json` (`"immersive"` vs `"2D"`). | | **2D PWA** | **Immersive WebXR PWA** | |---|---|---| | Runs as | windowed 2D panel on Horizon | enters a full VR/WebXR session | | Web app | any responsive PWA (IWSDK optional) | WebXR app (IWSDK is the easy path) | | Auto-enter `requestSession` | **NO — do not add it** (Step 1) | **YES — built into the app** (Step 1) | | `horizonOSAppMode` | `"2D"` (Step 4) | `"immersive"` (Step 4) | A wrong `horizonOSAppMode` value is the classic failure mode: a 2D app set to `immersive` is stuck loading; an immersive app set to `2D` shows a browser URL bar. See [`references/app-modes.md`](references/app-modes.md) for the full decision guide. ## Step 1: Build the Web App ### Immersive WebXR app (IWSDK) Scaffold with `@iwsdk/create` (the only supported scaffolder): ```bash npx @iwsdk/create@latest --yes --mode vr --no-metaspatial \ --no-physics --no-locomotion --grabbing ``` Toggle `--physics` (Havok gravity/collisions), `--locomotion` (roam a large space), and `--grabbing` (hands/controllers pick objects up) to fit the app. For arcade-style apps prefer deterministic manual motion over physics. **Don't reinvent IWSDK app code.** The template's bundled `CLAUDE.md`, `.claude/skills/iwsdk-*` skills, and the `iwsdk-rag` MCP are the source of truth for imports, ECS, XR input, physics, UI, and debugging. Query those rather than guessing. **Build auto-enter into the immersive app from the start.** An installed immersive PWA opens with no 2D page, so the app itself must start the session on load (the app-icon tap is the user activation). Gate it on `getDigitalGoodsService` so it runs only in the installed PWA, never a browser tab: ```ts const nav = navigator as Navigator & { xr?: { isSessionSupported?: (m:string)=>Promise } }; if ("getDigitalGoodsService" in window && nav.xr?.isSessionSupported) { nav.xr.isSessionSupported("immersive-vr") .then(s => { if (s) world.launchXR(); }) // IWSDK launchXR == requestSession + setup .catch(() => {}); } ``` `getDigitalGoodsService` is device-only — validate this path on the headset. ### 2D windowed app Any responsive web app/PWA works — IWSDK is not required. It runs as a single- instance standalone panel with its own Library entry. Make sure it's a valid installable PWA (Step 3) and build/deploy it like any static/SPA site (Step 2). Do **NOT** add the auto-enter code above. Full scaffolding flags, project layout, and the auto-enter rationale are in [`references/app-modes.md`](references/app-modes.md). ## Step 2: Deploy to Vercel The web app must be live on a public HTTPS origin before packaging — `bubblewrap` fetches the manifest and icons from it. Set `base: "./"` in your Vite config, then: ```bash npx -y vercel@latest whoami npx -y vercel@latest teams ls npx -y vercel@latest deploy --prod --yes --scope ``` Two URLs result: - **Canonical alias** `https://.vercel.app` → **public (200)**. Use this as `` everywhere downstream. - Hashed per-deploy URL `…-.vercel.app` → **401** under deployment protection. Not for sharing, not usable as ``. Verify the root and manifest both return 200, and that the manifest is served as `application/manifest+json`: ```bash curl -s -o /dev/null -w "%{http_code}\n" https:///manifest.webmanifest ``` See [`references/vercel-deploy.md`](references/vercel-deploy.md) for details and the redeploy-vs-rebuild rule. ## Step 3: PWA Manifest + Icons Both modes need a valid, installable manifest and PNG icons, live on `` before `bubblewrap update` runs. Place `public/manifest.webmanifest`: ```json { "name":"…","short_name":"…","description":"…","start_url":"/","scope":"/", "display":"standalone","orientation":"landscape", "background_color":"#06010f","theme_color":"#0a0418", "icons":[ {"src":"/icons/icon-192.png","type":"image/png","sizes":"192x192","purpose":"any"}, {"src":"/icons/icon-512.png","type":"image/png","sizes":"512x512","purpose":"any"}, {"src":"/icons/icon-512-maskable.png","type":"image/png","sizes":"512x512","purpose":"maskable"}]} ``` Link it in `index.html` `` (`` + `` + ``). For multi-origin 2D apps, add `additional_trusted_origins` and host asset links on each origin. There's no ImageMagick/PIL here — generate icons with `sharp` (`npm i -D sharp`) from an SVG. The maskable icon must be full-bleed and opaque (no transparency or rounded corners). Vite copies `public/` into `dist/`. See [`references/manifest-and-icons.md`](references/manifest-and-icons.md) for the icon script and the full manifest reference. ## Step 4: Package as a Meta VR APK (bubblewrap) `bubblewrap` wraps the live PWA into a signed Android APK (a Trusted Web Activity). ```bash npm i -g @meta-quest/bubblewrap-cli # bin: bubblewrap ``` Prereqs are pre-provisioned in `~/.bubblewrap` (its own JDK 17 + Android SDK). Find the tools dynamically: ```bash KT=$(find ~/.bubblewrap/jdk -path '*/bin/keytool' | head -1) BT=$(ls -d ~/.bubblewrap/android_sdk/build-tools/* | sort -V | tail -1) ``` `bubblewrap init` uses an interactive `inquirer` wizard that needs a real TTY. A non-TTY caller (an agent driving Bash) can't answer it and there are no value flags to bypass it — **use the scripted path** below. 1. **Signing keystore** — **pause and ask the developer which key to use** before building. The key is permanent: every future update must reuse it. Either reuse an existing keystore (ask for its path, alias, and passwords — required when updating a published app) or generate a new one **outside** the deployable web tree. 2. **`twa-manifest.json`** (scripted path) — author it from the authoritative `TwaManifest` schema. The critical field: ```json "horizonOSAppMode": "immersive" // ← "immersive" for WebXR | "2D" for a 2D panel app ``` A wrong value is the classic failure mode (Step 0). `applicationId` = numeric Horizon App ID (`"0"` builds & sideloads without IAP; set the real id before Store work). 3. **Build** — `update` regenerates gradle and bumps version; passwords go via env vars (no password CLI flags exist): ```bash cd export BUBBLEWRAP_KEYSTORE_PASSWORD= BUBBLEWRAP_KEY_PASSWORD= bubblewrap update && bubblewrap build # → app-release-signed.apk + app-release-bundle.aab "$BT/apksigner" verify --print-certs app-release-signed.apk | grep -i SHA-256 # must == keystore ``` 4. **Digital Asset Links** — a TWA "will not launch" if this fails. Host `public/.well-known/assetlinks.json` on the same domain (and every trusted origin), with the package name and the colon-hex cert SHA-256. Redeploy, then `curl https:///.well-known/assetlinks.json` to confirm. **Security:** the keystore and app secret NEVER go to the public host — verify with `curl -o /dev/null -w "%{http_code}" https:///android.keystore` (expect 404). Back up the keystore. Full keystore handling, the complete `twa-manifest.json` template, build verification, and asset-link details are in [`references/bubblewrap-packaging.md`](references/bubblewrap-packaging.md). ## Step 5: Upload to the Meta Horizon Store `hzdb` / `metavr` are device-only and cannot upload. Use `ovr-platform-util` — the same command works for 2D and WebXR builds. Before uploading, sanity-check the signed APK on a real headset (`metavr app install`, launch it, skim `metavr log`) — Store review is a slow place to discover a launch crash: ```bash ./ovr-platform-util upload-quest-build \ --app-id --app-secret \ --apk app-release-signed.apk \ --channel ALPHA --age-group MIXED_AGES \ --notes "…" --disable-progress-bar ``` Required: `--app-id`, `--apk`, `--channel`, `--age-group` (`TEENS_AND_ADULTS | MIXED_AGES | CHILDREN`), and `--app-secret` or `--token`. Channels: `ALPHA`/`BETA`/`RC` for testing, `STORE` for production. Auth is the app's **App Secret** (Dashboard → app → API tab) or a user token — ask the user, never invent it. **Likely first-time blocker:** `must first agree to our Developer Distribution Agreement` — an org admin must sign it once at `https://developers.meta.com/horizon/manage/organizations//legal-documents/`. Pause, ask the user, then retry the same command. See [`references/store-upload.md`](references/store-upload.md) for tool download, auth, and the DDA blocker. ## Order of Operations - **Decide 2D vs immersive up front** (Step 0) — it sets the auto-enter step (Step 1, WebXR-only) and the `horizonOSAppMode` value (Step 4). - **Manifest + icons must be LIVE before `bubblewrap update`** — it fetches them from ``. - **Asset links must be live before the TWA will launch** — on every trusted origin. - **Web-only fixes need only a Vercel redeploy** — the installed TWA picks them up on the next launch. Rebuild and re-upload the APK only for native changes (id, name, icon, version, **app mode**, packaging). ## Gotchas - **`horizonOSAppMode` mismatch is the #1 failure** — `"2D"` set to `immersive` hangs on a loading screen; `"immersive"` set to `2D` shows a URL bar. Fix the value and rebuild. - **The signing key is permanent** — every update must reuse the same keystore, alias, and `packageId`. A lost keystore means a new app entry. Back it up, and keep it outside the deployable web tree. - **`bubblewrap init` needs a TTY** — agents must use the scripted path (hand-written `twa-manifest.json` + `update` + `build`). - **Auto-enter is immersive-only** — never add the `launchXR()` snippet to a 2D app, and always gate it on `getDigitalGoodsService` so it doesn't fire in a browser tab. - **`getDigitalGoodsService` is device-only** — auto-enter can't be validated in a desktop browser or emulator; test on the headset. - **Use the canonical Vercel alias** — the hashed per-deploy URL returns 401 under deployment protection and can't be used as ``. - **The maskable icon must be full-bleed and opaque** — transparency or rounded corners produce visible artifacts after the platform applies its mask. - **Asset links gate launch** — a TWA "will not launch" until `/.well-known/assetlinks.json` is live with a matching package name and cert SHA-256. - **DDA blocks the first upload** — only an org admin can sign it; it's the most common first-time upload failure. ## References - [App Modes: 2D vs Immersive](references/app-modes.md) — decision guide, IWSDK scaffolding, project layout, and the auto-enter-session rationale. - [Vercel Deployment](references/vercel-deploy.md) — Vite config, deploy commands, the canonical-vs-hashed URL distinction, and the redeploy-vs-rebuild rule. - [PWA Manifest + Icons](references/manifest-and-icons.md) — full manifest reference, multi-origin setup, and the `sharp` icon-generation script. - [Bubblewrap Packaging](references/bubblewrap-packaging.md) — keystore handling, `twa-manifest.json` template, build verification, asset links, and security. - [Store Upload](references/store-upload.md) — `ovr-platform-util` download, auth, the upload command, and the Developer Distribution Agreement blocker. - [Troubleshooting](references/troubleshooting.md) — common failure modes across the whole pipeline and how to fix them.