MobiLauncher

Remote Config

Tier: Enterprise · feature flag remoteConfig

Remote Config lets a kiosk fleet pull its launcher config from a URL instead of getting a new one every time you republish through the Portal. Devices poll the endpoint on a schedule; when the served config changes, the launcher repaints on the next poll.

Use it when:

  • You need to push a change to thousands of devices without asking your MDM to redeploy a profile.
  • Different sites need different configs off the same MDM profile (your endpoint returns a different body per device based on request headers).
  • You want to A/B test a layout — flip the served config on the fly, watch the fleet pick it up within a poll cycle.

1. Enable the feature

In the Portal Builder → Features tab → Enterprise → toggle Remote config. The flag must also be present in the device's license; talk to us if you don't see it after upgrading.

2. Build the endpoint

The launcher does:

GET https://your-server.example.com/launcher.json
If-None-Match: "<last-known etag>"

You return:

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "v42"
Cache-Control: no-store

{ ... a complete, schema-valid MobiLauncherConfig ... }

When the content hasn't changed, return 304 Not Modified (no body) — the launcher fast-paths out and doesn't re-parse.

The body must validate through the full schema (same Zod validator used by the Portal). Schema failures don't crash the launcher; the last good config keeps running and a pollParseError event fires.

Minimum endpoint (FastAPI sample)

from fastapi import FastAPI, Header, Response
import hashlib, json, pathlib

app = FastAPI()

def current_config() -> str:
    # Replace with your storage — DB row, S3 object, etc.
    return pathlib.Path("/srv/launcher.json").read_text()

@app.get("/launcher.json")
def get_config(if_none_match: str | None = Header(None)):
    body = current_config()
    etag = '"' + hashlib.sha256(body.encode()).hexdigest()[:16] + '"'
    if if_none_match == etag:
        return Response(status_code=304)
    return Response(
        content=body,
        media_type="application/json",
        headers={"ETag": etag, "Cache-Control": "no-store"},
    )

Minimum endpoint (Cloudflare Worker sample)

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    const obj = await env.CONFIG_BUCKET.get('launcher.json');
    if (!obj) return new Response('not found', { status: 404 });
    const etag = `"${obj.etag}"`; // R2 gives you an etag for free
    if (req.headers.get('if-none-match') === etag) {
      return new Response(null, { status: 304 });
    }
    return new Response(obj.body, {
      headers: {
        'content-type': 'application/json',
        etag,
        'cache-control': 'no-store',
      },
    });
  },
};

3. Configure in the Builder

Portal → Launcher tab → Remote config (Ent) section:

Field Notes
Endpoint The full URL (https://…/launcher.json). Validated as a URL by the schema.
Poll interval Seconds. Min 60, max 86400 (1 day). Default 300 (5 min). Pick the longest interval your customer-care SLA can tolerate — every poll counts against your endpoint's budget.
Use ETag Default true. Set false only if your endpoint can't serve 304s.

The corresponding config block looks like:

{
  "remoteConfig": {
    "enabled": true,
    "endpoint": "https://config.acme.test/launcher.json",
    "pollIntervalSeconds": 300,
    "useETag": true
  }
}

4. What runs at boot

The runtime's RemoteConfigPoller (src/runtime/features/remote-config.ts) does this on every poll:

  1. Build the request — GET, with If-None-Match: <stored etag> when ETag is enabled and we have one.
  2. Up to 3 attempts on transient failure (network error / 408 / 429 / 5xx). Backoff between attempts: 200ms → 500ms → 1500ms (±50% jitter). 4xx other than 408/429 aren't retried — those are client-side problems that won't fix themselves in 1.5s.
  3. 304 → emit pollNotModified, done.
  4. 200 + schema-valid body → emit pollSucceeded, replace the live config, persist body + etag to the device's last-known-good slot (localStorage on Android WebView).
  5. 200 but schema fails → emit pollParseError with the Zod issue. The current config keeps running.
  6. 5xx after all retries → emit pollServerError. Current config keeps running.
  7. Network error after all retries → emit pollNetworkError. Current config keeps running.

Cold start: if a persisted LKG is present, the launcher renders it immediately while the first network poll is in flight. A successful poll replaces it; a pollServerError / pollNetworkError lets the LKG stay live.

5. Diagnostics

  • Diagnostic overlay (Pro diagnosticOverlay flag) — long-press a corner of the launcher to see the most recent poll outcome + ETag.
  • Telemetry — every poll emits a remoteConfigPoll telemetry event with { status, etag, changed } if you have telemetry export enabled. Great for proving "fleet picked up the config change at HH:MM:SS."
  • Portal Devices panellast_seen_at is bumped on every config fetch (whether 200 or 304), so the panel doubles as a "this fleet is polling like it should" check.

6. Gotchas

  • Don't change the tenant id mid-flight. The endpoint must serve a config whose tenantId matches the tenant the device booted into. A mismatch surfaces as pollParseError (since the field is required) and devices stay on their LKG.
  • Cache headers matter. Some CDNs strip ETag headers on Cache-Control: public. Use no-store (recommended) or test your CDN's preserved-headers behavior. The launcher now tolerates a missing ETag header on a 200 (preserves the previously-stored ETag), but you lose bandwidth savings if every response is treated as new.
  • 5xx behavior on the device is benign — the launcher won't replace a working config with a broken response. You can take the endpoint offline for maintenance; devices keep running their last good config until the endpoint comes back.

7. Test fire

The Portal → Launcher → Remote config block has a "Test endpoint" button. It sends a single GET to your endpoint (with no If-None-Match) and reports HTTP status + whether the body parses through the schema. Use this immediately after deploying a new endpoint to catch CORS / TLS / schema bugs before pushing to the fleet.