# Beacon Scanning Recipe (dev-only)

Canonical write-up for `beacon.html` / `beacon.ts`. Register a background beacon watcher via the WebBLE companion app.

## Preconditions

- **iOS 15.0+** Safari with the WebBLE Safari Web Extension installed.
- **Companion app installed and launched at least once.** Beacon scanning runs in the companion app process (`BeaconScanManager` in `Shared (App)/`), not in Safari.
- **Extension must be in `.ipcRelay` routing mode.** In `.standalone` mode the extension handles BLE inside Safari, and Safari cannot observe raw advertisements while foregrounded — iOS only delivers advertisement callbacks to apps that own a `CBCentralManager`, which the Safari Web Extension process does not.
- Notification authorization granted to the companion app.
- Per-site permission: **"Always Allow on Every Website"** — otherwise the origin cannot register any background sync intents.
- HTTPS origin. `OriginValidation.swift` rejects non-HTTPS `template.url` values.

## Spec citation

- **Vendor surface** on `window.webbleIOS.backgroundSync` (also reachable as `navigator.webble.backgroundSync` — see `src/types/background-sync.ts:171`). There is **no W3C standard** for beacon scanning.
- Implementation verified in `src/webble/api/background-sync.ts:340` — method is `registerBeaconScanning(options)`, not `scan()`.
- Underlying iOS API: `CBCentralManager.scanForPeripherals(withServices:options:)` — Apple's *CoreBluetooth Programming Guide* §*Performing Common Central Role Tasks*. At least one service UUID is required when the app is backgrounded.
- Filter semantics (services + name prefix) mirror W3C Web Bluetooth CG spec §6.4 *RequestDeviceOptions*.

## Exact call

```js
await window.webbleIOS.backgroundSync.registerBeaconScanning({
  filters: [{ services: ['heart_rate'] }],
  cooldownSeconds: 30,
  template: {
    title: 'Beacon in range',
    body: 'Detected {{deviceName}} at {{timestamp}}',
    url: 'https://example.com/beacon-hit'
  }
});
```

At least one filter must declare at least one service UUID. `TypeError` is thrown otherwise (see `src/webble/api/background-sync.ts:276`).

## Flow

1. Companion app user grants notification permission (handled by `NotificationPermissionManager`).
2. Page calls `registerBeaconScanning(...)`. The manager sends a `REGISTER_BEACON_SCAN` IPC message to the companion app.
3. `BeaconScanManager` starts an actor-isolated `CBCentralManager` scan for the configured service UUIDs.
4. When a matching advertisement appears, `BLENotificationBridge` interpolates the template (`{{deviceName}}`, `{{timestamp}}`, `{{value.utf8}}`, etc.) and schedules a `UNNotificationRequest`.
5. Per-beacon `cooldownSeconds` prevents notification spam.
6. `registration.unregister()` ends the scan; `registration.update(template)` replaces the template atomically.

## Template placeholders

Documented at `docs-src/recipes.md:128`. Supported tokens:

| Placeholder | Source |
|---|---|
| `{{deviceName}}` | Advertising local name or CoreBluetooth `peripheral.name`. |
| `{{device.id}}` | Peripheral identifier UUID (opaque). |
| `{{value.hex}}` | Advertisement manufacturer-data bytes, uppercase hex. |
| `{{value.utf8}}` | Same bytes, interpreted as UTF-8 (truncated to 200 chars per `OriginValidation`). |
| `{{value.int16be}}` / `{{value.int32be}}` | First bytes, big-endian signed int. |
| `{{timestamp}}` | ISO-8601 timestamp of the advertisement. |

## Expected output

- `registration.id` is a UUID string (matches `BackgroundRegistrationImpl` at `src/webble/api/background-sync.ts:96`).
- A matching beacon in range produces an iOS banner notification. Tapping it opens `template.url`.
- Subsequent matches within `cooldownSeconds` are suppressed.

## Common errors

| Condition | Symptom | Remediation |
|---|---|---|
| Companion app not installed | `window.webbleIOS` undefined. | Install from App Store, open once. |
| Extension in `.standalone` mode | `NetworkError` / IPC timeout (30 s). | Switch to `.ipcRelay` in companion-app settings. |
| No service UUIDs in filter | `TypeError`. | Supply at least one service. |
| `template.url` not HTTPS | `SecurityError`. | Use `https://` URL. |
| Too many registrations | `QuotaExceededError` (per-origin cap 50, global 200 per `BackgroundIntentStore`). | Unregister stale entries via `getRegistrations()`. |
| Notification permission denied | Registration succeeds, delivery silenced. | Re-request via `backgroundSync.requestPermission()`. |
| Companion app suspended long enough for iOS to reclaim its BLE session | Notifications stop. | Background budget is finite; advise users to keep the companion app in recent apps or enable Background App Refresh. |

## Verbatim HTML snippet

```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Beacon scanning (dev demo) — Web Bluetooth on iPhone</title>
</head>
<body>
  <aside data-agent-notes>
    <strong>Preconditions</strong>
    <ul>
      <li>iOS 15.0+ Safari with the WebBLE extension installed.</li>
      <li>WebBLE <strong>companion app installed and launched at least once</strong> (beacon scans run in the app process).</li>
      <li>Extension in <code>.ipcRelay</code> routing mode (Settings → WebBLE → Routing). Foreground Safari cannot see raw BLE advertisements — only the companion app's <code>CBCentralManager</code> can.</li>
      <li>Notifications allowed for the WebBLE companion app.</li>
      <li>Per-site permission: "Always Allow on Every Website".</li>
    </ul>
    <strong>Spec</strong>: Vendor surface (<code>navigator.webble.backgroundSync</code> / <code>window.webbleIOS.backgroundSync</code>). The underlying scan uses <em>CoreBluetooth <code>scanForPeripherals(withServices:)</code></em> with a service-UUID filter — Apple requires at least one service UUID per scan in the background. Filter semantics match Web Bluetooth CG spec §6.4 service filters.
    <p>Canonical write-up: <a href="./beacon.md">beacon.md</a>. Dev-only — requires companion app.</p>
  </aside>

  <h1>Beacon scanning (dev demo)</h1>
  <button id="register">Register beacon watcher</button>
  <button id="unregister" disabled>Unregister</button>
  <pre id="log" aria-live="polite"></pre>

  <script type="module">
    import '@ios-web-bluetooth/core/auto';

    const log = (line) => {
      const pre = document.getElementById('log');
      pre.textContent += line + '\n';
    };

    let registration = null;

    document.getElementById('register').addEventListener('click', async () => {
      try {
        if (!('webbleIOS' in window)) {
          throw new Error('Beacon scanning requires the WebBLE companion app');
        }

        registration = await window.webbleIOS.backgroundSync.registerBeaconScanning({
          filters: [{ services: ['heart_rate'] }],
          cooldownSeconds: 30,
          template: {
            title: 'Beacon in range',
            body: 'Detected {{deviceName}} at {{timestamp}}',
            url: 'https://example.com/beacon-hit'
          }
        });

        log(`registered id=${registration.id} type=${registration.type}`);
        document.getElementById('unregister').disabled = false;
      } catch (err) {
        log(`error: ${err.name}: ${err.message}`);
      }
    });

    document.getElementById('unregister').addEventListener('click', async () => {
      try {
        if (!registration) return;
        await registration.unregister();
        log(`unregistered id=${registration.id}`);
        registration = null;
        document.getElementById('unregister').disabled = true;
      } catch (err) {
        log(`error: ${err.name}: ${err.message}`);
      }
    });
  </script>
</body>
</html>
```

## Canonical JS snippet (byte-identical to `docs-src/recipes.md`)

```js
if (!('webbleIOS' in window)) {
  throw new Error('Beacon scanning requires the WebBLE companion app');
}

const registration = await window.webbleIOS.backgroundSync.registerBeaconScanning({
  filters: [{ services: ['heart_rate'] }],
  cooldownSeconds: 30,
  template: {
    title: 'Beacon in range',
    body: 'Detected {{deviceName}} at {{timestamp}}',
    url: 'https://example.com/beacon-hit'
  }
});

console.log('registered', registration.id);
// later: registration.unregister();
```

Template placeholders: `{{deviceName}}`, `{{device.id}}`, `{{value.hex}}`, `{{value.utf8}}`, `{{value.int16be}}`, `{{value.int32be}}`, `{{timestamp}}`.
