Skip to content

Mobile Builds

PizzaPi ships a thin Capacitor wrapper around the existing React/Vite PWA. The web app remains the product; native projects provide packaging, app-store paths, and future native integration points.

The mobile app keeps the user inside the bundled UI after setup. On first launch the bootstrap page captures the server URL (manually or via QR), completes mobile-link approval in the web UI, then loads the bundled PWA locally and authenticates it with a short-lived API key minted during approval. The bundled UI talks to the relay through native HTTP (CapacitorHttp) and Socket.IO using absolute URLs and the injected key, avoiding cross-origin WebView cookie issues.

  • capacitor.config.ts points Capacitor at mobile/, which contains both the bootstrap page (index.html) and the bundled PWA (app/).
  • mobile/index.html handles first-run server URL entry, QR scanning, and mobile-link redemption.
  • scripts/copy-mobile-ui.ts copies the built packages/ui/dist assets into mobile/app/ during bun run build:mobile.
  • android/ contains the committed Android project. ios/ contains the committed iOS project.
  • bun run build:mobile* always builds the UI with VITE_MOBILE=1 VITE_BASE_URL=./, which disables the Vite PWA plugin (enablePWA in packages/ui/vite.config.ts) — a service worker registered inside the Capacitor WebView precaches by absolute scope and can serve stale HTML after a rebuild (black screen). The bundled app should therefore never contain sw.js, registerSW.js, workbox-*.js, or manifest.webmanifest.
  • android/app/src/main/assets/public/ and ios/App/App/public/ (the assets cap sync copies into each native project) are build output, not tracked in git — both are ignored wholesale by android/.gitignore and ios/.gitignore. Nothing under either path should ever be committed; run bun run build:mobile, build:mobile:android, or build:mobile:ios to (re)generate them locally.
  • CI builds the Android debug APK on pull requests and pushes, and guards both bundle trees against stale service-worker artifacts (see CI coverage below).
  • CapacitorHttp is enabled so fetch/XHR run through native networking (the API key is only injected for relay-origin requests).
Terminal window
# Build the PWA and sync both native projects
bun run build:mobile
# Build the Android debug APK
bun run build:mobile:android
# Build a SIGNED Android release APK + AAB (requires the PIZZAPI_KEYSTORE_* vars below)
bun run build:mobile:android:release
# Build the PWA and sync iOS project files
bun run build:mobile:ios

.github/workflows/ci.yml includes a mobile-android job that runs on every pull request and push. It installs Java, Android SDK tooling, and Bun dependencies, then runs:

Terminal window
bun run build:mobile:android

That intentionally builds a debug APK only — signed with Android’s throwaway debug key, fine for smoke-testing but not distributable.

The same job then runs bunx cap sync ios (a plain Node/Bun file-copy + Swift Package manifest step — no CocoaPods, no Xcode, so it runs fine on the ubuntu-latest runner) to populate ios/App/App/public/ from the same VITE_MOBILE bundle, and finishes with a guard step that fails the build if sw.js, registerSW.js, workbox-*, or manifest.webmanifest shows up anywhere under android/app/src/main/assets/public/ or ios/App/App/public/. That’s the regression this whole page exists to prevent: a stale PWA build (pre-VITE_MOBILE, PWA plugin still enabled) sitting in the native shell and black-screening the app on launch.

android/app/build.gradle defines a release signing config whose keystore path and credentials come from env vars (or gradle properties) — never hardcoded. If no keystore is configured the release build is left unsigned, so debug/local builds keep working without secrets.

  1. Generate a keystore once, and guard it. This file is the identity of your app on the Play Store — back it up securely; if you lose it you can never ship an update to the same listing.

    Terminal window
    keytool -genkeypair -v \
    -keystore pizzapi-release.jks \
    -alias pizzapi \
    -keyalg RSA -keysize 2048 -validity 10000
  2. Build a signed release by providing the four vars at build time:

    Terminal window
    PIZZAPI_KEYSTORE_FILE=/abs/path/pizzapi-release.jks \
    PIZZAPI_KEYSTORE_PASSWORD=… \
    PIZZAPI_KEY_ALIAS=pizzapi \
    PIZZAPI_KEY_PASSWORD=… \
    bun run build:mobile:android:release

    This runs assembleRelease bundleRelease, then scripts/verify-apk-signed.ts, which fails the build if the APK came out unsigned or if apksigner is not found under $ANDROID_HOME/build-tools. Outputs land in android/app/build/outputs/{apk,bundle}/release/. Bump the build number with -PversionCode=<n> -PversionName=<x.y.z>.

  3. Verify a signature manually any time with the SDK’s apksigner:

    Terminal window
    $ANDROID_HOME/build-tools/<ver>/apksigner verify --print-certs app-release.apk

The repo ships two Android workflows:

  • .github/workflows/android-debug-apk.yml — unsigned debug APK, no secrets; runs on workflow_dispatch and v* tags, uploads app-debug.apk.
  • .github/workflows/android-release.yml — signed release (APK + AAB), runs on workflow_dispatch and v* tags. It never runs on pull requests, so secrets are never exposed to forks, and it fails fast with a clear message if any secret is missing.

To set up the signed workflow, add these under Settings → Secrets and variables → Actions (never commit the .jks):

SecretValue
PIZZAPI_KEYSTORE_BASE64base64 -i pizzapi-release.jks (the whole keystore, base64-encoded)
PIZZAPI_KEYSTORE_PASSWORDkeystore password
PIZZAPI_KEY_ALIASkey alias (e.g. pizzapi)
PIZZAPI_KEY_PASSWORDkey password
Terminal window
# macOS: copy the base64 keystore straight to the clipboard
base64 -i pizzapi-release.jks | pbcopy

The workflow decodes the keystore into $RUNNER_TEMP, sets the four PIZZAPI_KEYSTORE_* vars, and runs bun run build:mobile:android:release (which verifies the APK is signed before uploading). Trigger it from the Actions tab (optionally passing versionName / versionCode) or by pushing a v* tag — on a tag the versionName defaults to the tag minus its leading v.

iOS is committed and syncable, and CI now sanity-checks its generated bundle (see CI coverage above), but nothing ever compiles the Xcode project in CI. Add macOS/Xcode CI only when iOS distribution is active; it needs signing/provisioning setup and is slower than the Android smoke check.

ios/App/App/public/ (the web assets cap sync copies in) is generated and gitignored, exactly like Android’s android/app/src/main/assets/public/ — run bun run build:mobile:ios (or bun run build:mobile) to regenerate it locally; never hand-edit or commit it.

The mobile bootstrap page stores the relay URL in localStorage and hands the API key to the bundled UI via a URL fragment, which the UI persists to native secure storage (iOS Keychain / Android Keystore). The bundled UI reads those values and:

  • Rewrites relative fetch URLs to the configured relay server.
  • Adds the x-api-key header to REST calls and passes the API key in the Socket.IO auth payload.
  • Connects to /hub, /viewer, /runners, and /terminal namespaces using an absolute server URL and the API key.

Because the app uses API-key auth, the server trusts the Capacitor origins (capacitor://localhost and https://localhost) by default. Disable this with PIZZAPI_TRUST_MOBILE_ORIGINS=false if your deployment requires explicit origin allowlisting.

QR-based setup is the recommended flow. Manual URL entry still redirects to the server’s web sign-in page for now; full in-app sign-in for manual entry is planned follow-up work.

Keep integrations service-driven:

  • Push notifications should map native device tokens into the existing push subscription model.
  • Deep links should route to sessions or service panels, not native-only screens.
  • Share/camera/secure-storage plugins should be added only when the web API is insufficient.
  • Runner services should stay server-side; mobile can expose small typed bridge capabilities when needed.