# mHub Browser API

When a web page is opened inside an mHub-compatible browser, the browser
exposes the **mHub Browser API**. `window.mhub` is simply the namespace it
lives in, the way `navigator` is for the web platform. It lets a page do
things a normal browser cannot:

1. **`mhub.fetch`**: fetch any URL without CORS, with request headers the
   browser would never let a page set.
2. **`mhub.storage`**: key/value storage that follows the *site*, not the
   domain it was written on.
3. **`mhub.openStream`**: play media that needs mandatory request headers.
4. **`mhub.setLinks`**: add entries ("addon links") to the browser home screen.
5. **`mhub.setBackHandler`**: own the back press while the page runs its own
   navigation.
6. **`mhub.setSearch`** / **`mhub.openSearch`**: receive searches the user
   types into the host's own search UI, so your search page needs no input
   field, and open that UI from the page.
7. **`mhub.setImmersive`** / **`mhub.exit`**: run as an app, not a
   document: hide the host's chrome for a player, and leave for the host's
   home.
8. **The meta tag**: declare in the HTML what a host cannot see from the
   outside — that this is an mhub page, and that a D-pad reaches everything
   on it, so a TV can hand it the remote's keys instead of a pointer aid.
9. **`mhub.device`** / **`mhub.capabilities`**: read what the page can't
   detect on its own.
10. **Mirrors**: one small file on each of your domains, and your site
   survives a dead one. No API call involved; see
   [Mirrors](#mirrors-survive-a-dead-domain).

Everything here is **progressive enhancement**: outside an mHub-compatible
browser the API simply doesn't exist, so guard every use with feature detection.
A page that ignores the API keeps working as a plain website.

## The core guarantee

If `window.mhub` exists, **every member documented in the Core API below MUST
exist and work**. A conforming host has no partial surface and no members that
silently do nothing. What legitimately varies between hosts are the *optional
powers* (a loopback stream proxy, the mirror system, the task protocol, the
signed identity), and each of those is announced in
[`mhub.capabilities`](#windowmhubcapabilities). Check `window.mhub` to know the
API is there; check a capability to know an optional power is there. So
`if (window.mhub)` is the whole check. Probing a core member
(`window.mhub?.fetch`) says the opposite of this guarantee, and once a page
does it for one member it does it for all of them.

The API is the same on **every platform** the host apps run on: mobile, TV,
desktop, web. A platform difference is always expressed through
`capabilities` (or `device`), never through a missing member.

The API is only present when the app has the page-API feature enabled, and only
in the **top document**, never inside embedded (third-party) iframes.

---

## Readiness: the boot script

`window.mhub` is injected **synchronously at document start**, before your
first script, so normally the whole API is simply there:

```js
if (window.mhub) {
  const res = await window.mhub.fetch("https://example.com/api");
}
```

Two things make raw feature-detection awkward: on some hosts the API attaches
a few milliseconds late, and a page often wants to *tell the user* when it is
not running in an mHub browser at all. The **boot script** handles both; load
it as the first script in `<head>`:

```html
<script src="/mhub.js" data-require></script>
```

It gives the page four things:

1. **The queuing stub, installed synchronously.** `mhub.fetch`,
   `mhub.setLinks`, `mhub.setSearch`, `mhub.setBackHandler`,
   `mhub.setImmersive`, `mhub.openSearch` and
   `mhub.exit` are callable from the very first line; on a late-attaching host the calls queue and
   replay in order. (`mhub.storage`, `mhub.openStream`, `mhub.capabilities`
   and `mhub.device` cannot be queued meaningfully; while the stub is active
   they are `undefined`/`null`, so touch them after `mhub.ready`.)
2. **Detection.** `mhub.ready` is a `Promise<boolean>`: `true` once a real
   host has attached, `false` when this is a plain browser (decided shortly
   after the document is complete). After it settles, `mhub.hosted` carries
   the same answer synchronously.
3. **A browser fallback** when there is no host: `mhub.fetch` becomes the
   browser's own `fetch` and the queued calls go through it (the other queued
   calls had no one to reach and are dropped). It carries less, and says so:
   see [below](#without-a-host-the-browser-fallback).
4. **A warning banner** when there is no host, only with the `data-require`
   attribute (override the text with `data-message`). Leave it off and handle
   `mhub.ready === false` yourself; a page built as progressive enhancement
   needs neither.

```js
const hosted = await mhub.ready;
if (!hosted) showDownloadHint();       // or data-require does it for you
```

With the boot script loaded, `window.mhub` is **always** there, because the
stub puts it there in a plain browser too. `if (window.mhub)` then detects
nothing and `mhub.ready` is the only honest answer. Without the boot script it
is the other way round: the namespace exists only where a host injected it,
and testing it is the whole detection.

`mhub.ready` and `mhub.hosted` are provided by the **boot script, not by
hosts**; never feature-detect the host through them.

### Tell the host what you are: the meta tag

Two things a host cannot see from the outside, declared in the HTML so they
are known before any script runs — and, for a host that reads a page's HTML
ahead of a visit, before the page is even opened:

```html
<meta name="mhub" content="app dpad">
```

- `app`: this document is an mhub page. On a TV the host clears its chrome
  and treats the page as the app on screen; it never turns it away as a
  plain website.
- `dpad`: the page is **fully operable with a D-pad**: the directional keys
  move a visible focus to every interactive element, OK/Enter activates it,
  nothing needs a pointer. A TV host then hands the remote's keys straight to
  the page instead of putting a pointer aid (a virtual cursor driven by the
  remote) in front of it. A page that declares only `dpad` is a website that
  works with the remote: a TV host will not turn it away, but it is not an
  mhub page.

Put it in `<head>`, above your scripts. Tokens are separated by spaces or
commas; unknown tokens are ignored, so the list can grow. Per document, like
everything a page declares: the next document starts from nothing.

Without the tag a host still recognises an mhub page, by its use of the API:
the first `window.mhub` **call** tells it. That is the fallback for pages that
have not added the line, not a second way to declare it, and it arrives
whenever the page first calls, which on a TV can be too late. A mere read of
`window.mhub` does not count: a feature check by any script on any website
would otherwise pass a plain page off as an app. `dpad` has no fallback at
all; it lives in the HTML only.

### Without a host: the browser fallback

In a plain browser the page keeps calling `mhub.fetch`, and the boot script
answers with the browser's `fetch`: CORS applies, there is no proxy and no
signature, credentials are omitted, and a request is aborted after 20 s where
the browser can (`AbortController`). `mhub.storage`, `mhub.openStream`,
`mhub.capabilities` and `mhub.device` stay `undefined`/`null`; check
`mhub.hosted` before reaching for them.

What a browser cannot do is **refused with a `code`** instead of failing
obscurely, so the page can tell the user that an mhub-compatible app would
help (see [Errors](#errors-codes-and-what-to-tell-the-user)):

- `identity: "required"` is refused up front (`identity_required`); nothing is
  sent.
- A header a browser reserves for itself (`Referer`, `Origin`, `Cookie`, ...)
  is refused up front (`header_blocked`). `User-Agent` is dropped quietly, the
  server sees the browser's own.
- A cross-origin request that died without a status is either CORS or the
  network, and the browser words both the same. The fallback asks once more in
  `no-cors` mode: an opaque answer means the server is reachable and the first
  attempt was refused by CORS (`cors_blocked`); no answer means the network,
  and the original error is rethrown without a code.

Self-hosting or inlining the script is fine and recommended for
availability-critical pages; it has no server-side coupling. Pages that cannot
load it can still hand-roll the stub (see the script source; the stub shape
is the compatibility contract and stays stable).

---

## Errors: codes and what to tell the user

A rejected `mhub.fetch` carries a stable **`code`** on the error, so a page
can tell "a host would have helped" from an ordinary network failure without
matching message text. The `message` stays English and one line, for the
console; the `code` is what page code branches on. Whoever answers
`mhub.fetch` sets it: the host, or the boot script's
[browser fallback](#without-a-host-the-browser-fallback).

| `code` | Set by | When |
|---|---|---|
| `identity_required` | browser fallback | The request asked for `identity: "required"` and there is no host to sign it. Nothing was sent. |
| `header_blocked` | browser fallback | The request sets a header a browser refuses to send (`Referer`, `Origin`, `Cookie`, ...). Nothing was sent. |
| `cors_blocked` | browser fallback | The request went out and the browser refused to show the answer; the server is reachable. |
| `permission_denied` | host | The user blocked web access for this site; nothing was sent. |

The first three also carry **`hint`**, a URL that explains where an
mhub-compatible app comes from (`https://mhub.mx/browser`). Everything else a
rejection can be (network, timeout, an HTTP status the page treats as an
error) has no `code`; it is the ordinary failure it looks like.

`mhub.hosted === false` is a state, not an error: a feature that cannot start
without a host should stay quiet or sleep, and only a call that actually
fails should show a message.

### Wording

The user cannot fix any of the three "no host" causes, and the reason behind
them (a signature, a header) means nothing to them. So they share **one**
sentence, and only `permission_denied` gets its own; the codes stay distinct
for the console and for pages that know more than the request does. Say it in
the page's own language and tone; the boot script ships no translations.

| `code` | Recommended (EN) | (DE) |
|---|---|---|
| `identity_required`, `header_blocked`, `cors_blocked` | This needs an mhub-compatible app. | Dafür brauchst du eine mhub-kompatible App. |
| same, when the page still works without a host and only something is missing | Tip: this works in an mhub-compatible app. | Tipp: In einer mhub-kompatiblen App funktioniert das. |
| `permission_denied` | Web access for this site is blocked. Allow it in the address bar. | Der Webzugriff für diese Seite ist blockiert. Erlaube ihn in der Adresszeile. |

How to place it:

- **At the point of failure**, in the page's own style: the empty row, the
  tile, the player's error state. Not a banner, not a dialog; the moment
  carries the message. (`data-require` on the boot script is for pages that
  are nothing without a host; those show the banner and stop.)
- **`hint` is a link behind the sentence**, not a button and not a second
  line.
- **Once per sentence and session.** A screen that fans out ten requests
  hits the same wall ten times; show it once and let the page's own fallback
  (empty state, cached data) do the rest.
- Never guess the subject. The code does not know whether it was a stream, a
  catalog or a settings read, so the sentence says "this"; a page that knows
  may name it.

```js
try {
  const res = await mhub.fetch(url, { identity: "required" });
} catch (err) {
  if (err.code === "permission_denied") showBlocked();
  else if (err.hint) showNeedsApp(err.hint);   // identity_required, header_blocked, cors_blocked
  else showFailed(err);                         // an ordinary network failure
}
```

---

## Events

Live host-state changes arrive through a single **`mhubupdate`** event on
`window`, discriminated by `detail.kind`:

```js
addEventListener("mhubupdate", (e) => {
  switch (e.detail.kind) {
    case "device":     applyLayout(e.detail.device); break;
    case "permission": onPermission(e.detail.granted); break;
    case "identity":   refetchUserState(); break;
  }
});
```

| `detail.kind` | Fired when | `detail` |
|---|---|---|
| `device`     | on load (device known), and whenever `insets` change — every time the host's bar tucks away or returns, and on every immersive flip | `{ device: { isTV, platform, insets? } }` |
| `permission` | on load, and when a permission changes        | `{ name: "fetch", state: "allow"/"deny"/"ask", granted }` |
| `identity`   | when the signed identity / entitlement changes (only on hosts with the `"identity"` capability, never elsewhere) | `{}` (a trigger only) |

- **`permission`** reports the effective per-site decision, so a page can show a
  "grant access" affordance instead of a dead button. `granted` is
  `state === "allow"`; `ask` means the next `mhub.fetch` will prompt. `name`
  identifies which permission it is, today only `"fetch"` (CORS-free web
  access, also covering proxied `openStream`); branch on it so your code keeps
  working if more permissions are added later.
- **`identity`** carries no data on purpose: the signed identity is never exposed
  to page JavaScript. Read it as *"your entitlement may have changed, re-fetch
  your own endpoint"*; the browser attaches the fresh signature to that request.

A second event, **`mhubback`**, is not a state change but a command: the host
handing the back press to the page. It is documented with
[`mhub.setBackHandler`](#windowmhubsetbackhandlerdepth).

---

## `window.mhub.capabilities`

A **synchronous** array of strings naming the optional host powers. Core
members are never listed; they are always present (see the core guarantee).

```js
if (window.mhub?.capabilities.includes("streamProxy")) {
  // openStream can inject mandatory headers here
}
```

| Capability | Meaning |
|---|---|
| `"streamProxy"` | `openStream` can proxy streams with mandatory request headers |
| `"mirrors"`     | the host runs the [mirror system](#mirrors-survive-a-dead-domain): site-file discovery, page-load failover, `fetch` failover, adaptive order |
| `"clientFetch"` | `fetch` transparently resolves the addon's client-fetch requests (a request made from the client, with the user's own IP) |
| `"identity"`    | `fetch` attaches the signed client identity (`identity: true`, or by default to confirmed site endpoints) |
| `"search"`      | the host surfaces a site-search entry point (e.g. the address bar) and delivers queries to [`setSearch`](#windowmhubsetsearchconfig) |
| `"cast"`        | *planned, never announced yet*; see [Casting](#planned-casting) |

Unknown strings may appear as the API grows; ignore what you don't know.
The list is **fixed per document load**: a capability never appears or
disappears while your page is running.

---

## `window.mhub.fetch(url, options?)`

A CORS-free, `fetch`-compatible request that runs on the native side.

```js
const res = await window.mhub.fetch("https://example.com/data.json");
const data = await res.json();
```

### Parameters

| Argument  | Type                | Notes                                              |
| --------- | ------------------- | -------------------------------------------------- |
| `url`     | `string`            | Resolved relative to the current page.             |
| `options` | `object` (optional) | A subset of `fetch`'s `RequestInit`, see below.    |

Supported `options`:

- `method`: e.g. `"GET"`, `"POST"`.
- `headers`: a plain object or a `Headers` instance. Because the request is
  built **natively**, this includes headers a browser reserves for itself
  (`Referer`, `Origin`, `User-Agent`, …); set them like any other.
- `body`: **string only** (JSON, form-encoded, …). Blobs/FormData are not
  transferred.
- `redirect`: best-effort. `"follow"` (the default) works everywhere; hosts
  built on a native fetch may not honor `"manual"`/`"error"` and follow anyway.
- `identity`: whether the signed client identity rides along, **whatever
  origin the request goes to**. `true` = attach it if this host can (a host
  without the `"identity"` capability sends the request unsigned);
  `"required"` = the request only makes sense signed, a host that cannot sign
  rejects the promise instead of sending it; `false` = never. Left out, the
  host signs requests to the site's own confirmed endpoints only. See the
  [binding](#signed-identity-capability-identity).

Credentials are always omitted; the host never attaches its own cookies.

### Return value

A Promise resolving to a **`Response`-compatible** object. On mobile it is a real
`Response` (so `arrayBuffer()` and `blob()` also work); on desktop it is a
lightweight object with the common members. For portability, rely on:

- `status` (number), `ok` (boolean), `url` (string), `headers`
- `text()` → `Promise<string>`
- `json()` → `Promise<any>`

Binary responses are transferred transparently (base64 under the hood) and
reconstructed for you.

The promise **rejects** on network failure, on denied permission, and on
timeout: a request that neither answers nor fails is aborted by the host
(currently after 20 s), so a hanging upstream can never leave your page waiting
forever.

### What it does beyond a plain fetch

- **No CORS.** The request runs natively, so cross-origin responses are readable
  regardless of the target's CORS headers.
- **Forbidden headers.** A browser silently strips `Referer`, `Origin`,
  `User-Agent` and friends from page-initiated requests; here they go through.
  Many stream and API endpoints are unusable without exactly this.
- With the **`"mirrors"`** capability, requests to the site's own endpoints fail
  over to the next mirror on a network error, a timeout or an HTTP status
  `>= 500`; see [Mirrors](#mirrors-survive-a-dead-domain).
- With the **`"clientFetch"`** capability, client-fetch requests in the response
  are resolved transparently; see the [binding](#mediahubmx-binding).
- With the **`"identity"`** capability, requests carry the signed client
  identity: on request (`identity: true`) to any origin, by default to the
  site's own confirmed endpoints; see the [binding](#mediahubmx-binding).

### Permission

The **first** `mhub.fetch` for a given site shows a one-time dialog
("Allow web access?") naming the host the page is served from, with a
*Remember for this site* option.

- **Allow** + remember → persisted; no further prompts.
- **Allow** without remember → granted for the session.
- **Block** → the call rejects; a red indicator appears in the address bar and
  tapping it re-opens the dialog. A block is session-only and never persisted.

Several `mhub.fetch` calls made before the user answers share the one dialog and
are all resolved by the single decision. The same permission covers proxied
[`openStream`](#windowmhubopenstreamurl-headers-cors) calls; it does **not**
gate `mhub.storage`.

---

## `window.mhub.storage`

Key/value storage that follows your **site**, not the domain it was written on.
All methods return promises; values are strings, so you decide the format.

```js
await window.mhub.storage.set("watchlist", JSON.stringify(items));
const raw = await window.mhub.storage.get("watchlist");   // string | null
await window.mhub.storage.remove("watchlist");
const keys = await window.mhub.storage.keys();            // string[]

const all  = await window.mhub.storage.entries();         // { key: value }
const seen = await window.mhub.storage.entries("watched/");// only that prefix
const q    = await window.mhub.storage.quota();           // see Limits
```

Storage needs **no permission** and never prompts: nothing leaves the device,
exactly like `localStorage`.

### `entries([prefix])`

Every value in one call, as an object, optionally narrowed to the keys that
start with `prefix`. Each `get` is a round trip; a page that keeps many small
keys — which is the shape that makes writes cheap, since only the value you
touch is written — would otherwise pay one per key at startup. Prefer this
over `keys()` whenever you want the values anyway, and name a prefix so you
read the one store you need rather than everything you have.

### Why not `localStorage`

Browsers partition `localStorage` per origin. A site reachable under several
mirror domains therefore gets a separate, empty store on each one, and that
hits exactly when a mirror is used, i.e. when the usual domain is unreachable.
The host is the only side that knows which domains are the same site, so it is
the only side that can bridge this.

### Scope

The site part of the key comes from the **host**, derived from the same verified
identity that governs permission and the signature (see *Site identity*). You
cannot choose it, and no other site can name it to reach your data. Inside your
own scope the keys are yours; namespace them per addon if one origin serves
several (`"arte/watchlist"`, `"nasa/watchlist"`).

A site that has not published a site file still gets storage, keyed by its
origin: same isolation, it just cannot span mirrors, because as far as the host
knows there are none.

### Limits

They differ per host, so ask rather than assume:

```js
const { total, used, perValue, keys, keysUsed } = await window.mhub.storage.quota();
```

All five are numbers; `used` counts keys and values together, the same way the
host counts them against `total`. Current hosts offer **20 MB** per site,
**512 KB** per value and **50 000** keys — room for a memory, not just a
watchlist. `perValue` counts characters, not bytes.

The **minimum** any host offers is **512 KB** in total, **256 KB** per value
and **200** keys. Older hosts, and hosts on a platform that cannot store more,
stay at that; `quota` is how you tell. On a host too old to know `quota` at
all, the call rejects — then assume the minimum.

`set` rejects when a limit is hit rather than dropping data silently, so you
can react. With many keys, prefer `entries(prefix)` over `keys()`: 50 000 keys
is a large array to hand across for nothing.

`entries` has a size limit of its own, separate from storage: its answer
crosses to your page in one piece, so a host refuses one that would be
several megabytes and tells you to narrow it with a prefix. Read a store at a
time, not the lot.

The store lives on the device; several tabs of the same site share it, writes
are last-write-wins. There is no cross-device sync; it follows the site's
*identity*, not the user.

### Availability

Outside a host `window.mhub` doesn't exist at all, so fall back to
`localStorage`; the data then simply stays on the current domain:

```js
const store = window.mhub?.storage;
const raw = store
  ? await store.get("watchlist")
  : localStorage.getItem("watchlist");
```

---

## `window.mhub.openStream({url, headers?, cors?})`

**The one way to start playback of an external stream.** Media that needs
mandatory request headers (a `Referer`, a token) cannot be played by handing
the URL to a `<video>` tag or a native player: those fetch without your
headers. `openStream` hands the URL to the host, and the host decides what
comes back: the original URL (direct playback) or a loopback-proxied one that
injects the headers natively. The page always plays whatever it gets and never
needs to know the difference.

```js
const s = await window.mhub.openStream({
  url: "https://cdn.example/master.m3u8",
  headers: { Referer: "https://site.example/" },
});
video.src = s.url;         // hls.js / <video>
// nativePlayer.load(s.entry) for native HLS players (path form)
// … playback …
window.mhub.closeStream(s.url);
```

### Parameters

| Field | Type | Notes |
|---|---|---|
| `url` | `string` | Resolved relative to the current page. |
| `headers` | `object?` | Mandatory request headers the stream needs (`Referer`, tokens, …). |
| `cors` | `boolean?` | Set `true` when the page will **read** the stream with XHR (hls.js and every MSE player do); cross-origin CDNs reject those reads, so the host must proxy even though there are no headers. Leave it off for a plain `<video src>`, which is not a CORS request. |

### Return value

`{ url, entry, proxied }`:

| Field | Meaning |
|---|---|
| `url` | Query-form URL. Every sub-request (playlists, segments) is mapped individually; right for hls.js and `<video>`. |
| `entry` | Path-form URL. Relative playlist entries resolve against it and stay on the proxy; right for native HLS players. |
| `proxied` | `true` when the host routed the stream through its loopback proxy. Diagnostic only. |

For a direct stream, `url === entry === ` the input URL and `proxied` is
`false`.

### Rules

The host proxies when the page **needs** it: `headers`, `cors: true`, or
cleartext `http://` media (the loopback clears mixed content). Everything else
comes back direct: no permission prompt, no detour.

- **Every proxied stream** is gated by the same per-site permission as
  `mhub.fetch`: a loopback URL makes the bytes page-readable, which is exactly
  the power that dialog is about. Direct streams never prompt.
- **`headers` given, host has no `"streamProxy"`** → the stream comes back
  direct with the headers dropped (`proxied: false`), exactly what a plain
  browser would do. Whether it plays is up to the source; check
  `capabilities` for `"streamProxy"` before you pick a header-bound source
  when a header-free one exists.
- **`cors`/`http://` without a proxy** degrade to direct instead, the same
  behavior the page would get in a plain browser.

Header hygiene is enforced by the host: a blocklist (including
`mediahubmx-signature`; a page can never make the host send its identity), an
SSRF guard, and per-token origin scoping.

### `window.mhub.closeStream(urlOrEntry)`

Release a proxied stream when playback ends. Accepts the `url`, the `entry` or
the bare token; a no-op for direct URLs. There is no permission gate; a page
can only close a stream whose unguessable token it holds.

The host also releases all of a page's streams **itself** when the document
unloads or the tab closes, so `closeStream` is for ending playback early. A
page that forgets it leaks nothing past its own lifetime.

---

## `window.mhub.setLinks(links)`

Declare the site's entries on the browser home screen (mHub calls these *addon
links*). Returns `true` when accepted.

```js
window.mhub.setLinks([
  { id: "tmdb", name: "TMDB", icon: "/icons/tmdb.png", url: "/tmdb" },
  {
    id: "live",
    name: "Live TV",
    endpoints: ["https://a.mx/live", "https://b.mx/live"],
  },
]);
```

**The list you pass is the site's complete set.** Entries the site declared on
an earlier visit and no longer lists are removed; `setLinks([])` removes them
all. Call it with the full list on every load; it is a declaration, not an
append.

### Entry fields

| Field       | Type       | Notes                                                          |
| ----------- | ---------- | -------------------------------------------------------------- |
| `id`        | `string`   | Stable id for the entry (namespaced per site internally).      |
| `name`      | `string?`  | Label shown on the tile. Falls back to `id`.                   |
| `icon`      | `string?`  | Image URL, resolved relative to the page. Falls back to a monogram. |
| `url`       | `string?`  | The single target the tile opens.                              |
| `endpoints` | `string[]?`| Mirror list for the target; use instead of `url` for HA.       |

Give either `url` **or** `endpoints`. Both are resolved relative to the page, so
relative paths work.

### Behaviour

- Tiles open the target as a normal web page; if the target turns out to be an
  mHub addon, the app switches to addon mode automatically.
- Entries persist between visits (a tile is the way *back* to the site) until
  the site itself replaces them or the user removes the tile. They stay
  attributed to the declaring site.
- A site's set holds at most **8** entries; excess entries are dropped.
- The declared `endpoints` do **not** seed the target site's mirror set: that
  would be one site speaking for another. The target declares its own mirrors
  when it is opened; until then the tile uses `endpoints[0]`, and the remaining
  entries serve as load fallbacks for the tile itself.

---

## `window.mhub.setBackHandler(depth)`

Claim the hardware/browser back press while the page is deeper than its own
entry point. Returns `true` when the call was accepted.

The host's back button walks the **tab history**, a list of URLs. A page that
navigates inside itself is invisible to it, so back would jump straight out of
the site (on some hosts even a URL-carrying `pushState` only gets the previous
URL *reloaded*, losing all page state). Instead, the page reports how many
levels deep it is in its **own** navigation:

```js
mhub.setBackHandler(2);                 // I am 2 levels deep
addEventListener("mhubback", () => {
  popOneLevel();
  mhub.setBackHandler(currentDepth);    // report the new depth
});
```

While the reported depth is `> 0`, a back press is dispatched to the page as a
**`mhubback`** event instead of touching the tab history. The page pops one
level and reports its new depth; once that reaches `0`, back presses fall
through to the host again (tab history → start page → out of the browser).

- There is no acknowledgement round-trip: **the depth is the claim.**
- The depth resets to `0` on every document load; a claim never survives a
  navigation, so a page cannot trap the user in a tab.
- **Host fallback:** if the page does not call `setBackHandler` within a short
  timeout (~300 ms) after `mhubback`, the host assumes the page stopped
  listening, clears the claim and handles back presses itself again. **Any**
  `setBackHandler` call counts as the sign of life, also one reporting the
  same depth (a press may legitimately leave the depth unchanged, e.g. closing
  a modal that replaced a level). Always re-report after handling `mhubback`,
  and keep the listener alive as long as you claim a depth.
- A page that drives real browser history (`pushState` + `popstate`) may not
  need this on hosts whose back maps to the WebView's own navigation, but
  claiming the depth is the only behaviour that works on **every** host.

---

## `window.mhub.setSearch(config)`

Let the user search **your site** through the host's own search UI; on mobile
and desktop that is the address bar. The page declares that it handles search
and what to do with a query; your search page then needs no input field of its
own. Returns `true` when accepted.

```js
window.mhub.setSearch({
  placeholder: "Search movies & shows",
  onQuery: (query) => {
    location.href = "/search?q=" + encodeURIComponent(query);
  },
  onSuggest: async (query) => {
    const res = await window.mhub.fetch(
      "/api/suggest?q=" + encodeURIComponent(query)
    );
    return (await res.json()).map((s) => ({ text: s.title, url: s.href }));
  },
});
```

### Config fields

| Field | Type | Notes |
|---|---|---|
| `onQuery` | `(query: string) => void` | Required. The user submitted a search scoped to your site. What happens next is yours: navigate to your results page, filter in place. |
| `onSuggest` | `(query: string) => Suggestion[] \| Promise<Suggestion[]>` | Optional. Called while the user types (debounced by the host). |
| `placeholder` | `string?` | Hint the host may show in its search field. |
| `query` | `string?` | The term your page is currently showing results for, `""` (or absent) elsewhere. The host names it in its search entry point and pre-fills its field with it when the user comes back to edit it. Re-declare whenever it changes (your results route is the natural place). |

A `Suggestion` is `{ text, url? }`. Picking one **with** `url` opens that page
directly (resolved relative to the current page); one **without** is submitted
as a query via `onQuery(text)`.

### Behaviour

- **Capability `"search"`** announces that the host actually surfaces an entry
  point. `setSearch` is core and accepted everywhere, but on a host without
  the capability nothing will ever call your handlers; check it at render
  time to decide whether the page shows its own search box.
- **The declaration is dynamic.** Each call replaces the previous one, and
  `setSearch(null)` withdraws it entirely: the host stops offering the site
  search. Register and withdraw freely as your UI state changes, e.g. offer
  search only in sections that have one.
- The declaration is also **per document**: your callbacks live in the page's
  JS and die with it, so register on every load. How and where the host
  surfaces the search (an in-site mode of the address bar, a search affordance
  on TV) is host UX; the contract is only *query in, handlers called*.
- **Suggestion budget:** the host debounces while the user types, shows at
  most **8** suggestions, and stops waiting after roughly a second; a slow or
  throwing `onSuggest` is dropped silently and never blocks the host UI.
- **Clearing:** an empty submission from the host's field calls
  `onQuery("")`: that is how the user clears a search from the host, so treat
  it as "back to no term", not as an error.
- **Privacy:** input reaches your page only while the host's search UI is
  visibly in its site-search state (an input labeled with your site, or an
  explicit mode the user entered), and a URL is never forwarded. Input typed
  anywhere else never reaches you.

### `window.mhub.openSearch()`

Put the host's search UI in front of the user, in its site-search state, from
the page: your search screen shows a button (or focuses on open) instead of an
input field of its own. Only honoured on the page currently on screen and only
after a `setSearch` declaration; without one there is nowhere to send the
query. Hosts without the `"search"` capability accept the call and do nothing,
so keep your own input as the fallback there. Returns `true` when accepted.

---

## `window.mhub.setImmersive(on)`

The page is showing a full-screen surface, typically its video player. The
host slides its chrome away and **keeps it away**: scrolling inside the player
must not summon the address bar, and on phones the system bars may go too.
`setImmersive(false)` ends it. Per document, like the other declarations; a
document load always starts non-immersive. Returns `true` when accepted.

```js
player.addEventListener("open",  () => mhub.setImmersive(true));
player.addEventListener("close", () => mhub.setImmersive(false));
```

---

## `window.mhub.exit()`

Leave the page for the **host's own home**: the app's start page with its
tiles. Meant for the one place a page has room for a door, such as the bottom
entry of a TV rail. One press, and the user is out of the site; the page is
not consulted again. Only the page on screen may call it. Returns `true`.

---

## `window.mhub.device`

A small, **synchronous** object describing the host; read it directly, no
`await`, so you can branch on it at first render:

```js
if (window.mhub?.device.isTV) renderTvLayout();
```

| Field      | Type      | Notes                                        |
|------------|-----------|----------------------------------------------|
| `isTV`     | `boolean` | `true` on a TV / remote-driven device.       |
| `platform` | `string`  | `"android"`, `"ios"`, `"electron"` or `"web"` (`"web"` = the host itself renders as a web app, e.g. a TV web runtime). |
| `canPlay`  | `object?` | Optional: `{ hls?, dash?, drm? }` booleans. An **absent field means unknown**, not unsupported; probe with a source trial then. |
| `insets`   | `object?` | `{ top, bottom }` in CSS px. The host's bar is a band above the page: **the page starts below `top` as a whole** — a spacer at the top of its scroller, not a stepped header (a phone host may let the scrolled page shimmer through the band). `bottom`: how far the page hangs past the screen's foot — the system bar, plus the band's height while the bar is up on a host whose page rides up with the bar; `0` once the bar has tucked away. Both `0` without a bar (TV, desktop). **Immersive** (`setImmersive(true)`): the page is the whole screen and `top` is the display cutout it now runs under — keep controls (a player's title) out of it; it is not a spacer, the video fills the screen. |
| `chrome`   | `string?` | Where the host's bar stands, and therefore **who steps below it**. `"none"`: no bar (TV, desktop) — nothing covers the page. `"inset"`: the bar's band is reported in `insets.top` and the PAGE steps below it (a spacer at the top of its scroller). `"framed"`: the host places the page below its own bar, so `insets.top` is `0` and the page steps by nothing. Absent on hosts written before this field: read `insets.top > 0` as `"inset"` and fall back to `"framed"` where a bar is known to be there. Say it even when the number is `0`, because `0` alone cannot tell "the host framed me" from "there is no bar at all" — that guess is what made a page lay out for a bar nobody had. |
| `theme`    | `string?` | Optional: the look the host wears — `"light"`, `"dark"` or `"oled"` (a TV on an OLED panel: paint true black and let accents glow). Absent means the host has no say; follow `prefers-color-scheme`. Hosts: the jbl TV/phone app sends it, the VYPN app does not. |

`isTV` and `platform` are independent axes: an Android TV reports
`{ isTV: true, platform: "android" }`, a webOS/Tizen TV
`{ isTV: true, platform: "web" }`. Branch layout on `isTV`, never on
`platform`.

It deliberately carries **only what a page cannot derive from standard web
APIs**. For screen size and pixel density use `window.screen` and
`window.devicePixelRatio`; to check whether an app capability exists, use
[`mhub.capabilities`](#windowmhubcapabilities) rather than branching on a
version number.

---

## Mirrors: survive a dead domain

*Capability: `"mirrors"`.*

Media sites lose domains. On a host with the `"mirrors"` capability your site
doesn't go down with one, and the whole integration is **one small file, no
API call**:

Serve `/mhub-site.json` from **every** domain of your site:

```json
{ "id": "your-site-id", "endpoints": ["https://a.example", "https://b.example"] }
```

- **`id`** names the site. Free-form, no relation to any domain; pick any
  stable string. It is not a global namespace either: two unrelated sites may
  use the same id without ever being mixed up.
- **`endpoints`** lists all your domains, including the one serving the file.
  Sub-path sites (`https://host.example/mysite`) are allowed; serve the file
  under that base.

That's it. There is nothing to call: the **host discovers the file by itself**
the first time your page uses any `mhub.*` member (which is what marks it as
an mHub-aware site; ordinary pages never cost a request). It looks in the
**page's directory** first and at the **origin root**, so sub-path sites work
the same way. From then on:

- **Page load:** if loading the current page fails (network error or HTTP
  `>= 500`), the browser reloads it from the next mirror, keeping the path and
  query. The address bar shows whichever mirror actually served the page.
- **`mhub.fetch`:** requests to your endpoints fail over the same way.
- **Adaptive order:** a mirror that works is remembered and tried first next
  time (persisted per site), so a dead primary is skipped on later visits.
- **Permission, storage, signature follow the site**, not the domain; a user
  who granted access once is not asked again when a mirror takes over.

Publish a fresh file any time; new endpoints are picked up and merged on the
next visit.

### How verification works (you don't need this to use it)

A file is a claim, not proof: a page could otherwise name any origin as its
mirror and hand it the site's permission and signature. So the host asks every
listed endpoint itself, over HTTP, and only groups two of them when **each one
names the other** in its own file. Nobody can arrange that for domains they do
not run.

- **An endpoint that does not answer is not rejected: it is kept** and checked
  again on the next occasion. Losing a mirror that is merely down would defeat
  the point of having mirrors. Only an endpoint that answers with a
  *different* `id` is dropped. Rolling a new mirror out one server at a time
  is therefore safe: it joins once both ends list it.
- Until an endpoint is confirmed it may still serve a page (loading transfers
  no trust), but it does not share the site's permission and receives no
  signature.
- Verification re-runs whenever a page of the site is online, so your other
  endpoints are checked without the user ever visiting them.

> **Caveat: origins vs. sub-paths.** Declaring mirrors as bare **origins**
> (`https://a.mx`, `https://b.mx`) is always safe: on failover the path resolves
> identically on every mirror. Mirrors that host the same site under *different*
> sub-paths work for `mhub.fetch`, but a page loaded from them may break on
> root-relative assets; that is the site's responsibility (use `<base>` or
> relative URLs).

---

## Site identity

A site is the **set of its confirmed endpoints**, grouped under the `id` they
publish in [their site file](#mirrors-survive-a-dead-domain). Permission,
storage scope and cache follow that set, not the endpoint that happens to serve
right now, so a user who grants access once is not asked again when a mirror
takes over.

- A site that publishes nothing is simply its own single endpoint, keyed by the
  page's normalised host (`www.` stripped, lowercased).
- Serve several logical sites from one host by giving each its own `id` and its
  own sub-path endpoints, e.g. `https://mhub.mx/tmdb` vs. `https://mhub.mx/live`.

The permission dialog always shows the **host** the page is served from, never
the `id`: an id is free-form, so it is not something a user could rely on.

---

## Permission & privacy

- The user grants web access **per site**, once, via the permission dialog. One
  grant covers the site across all its mirrors, for `mhub.fetch` and for
  proxied `openStream`. `mhub.storage` needs no permission (nothing leaves the
  device).
- The signed `mediahubmx-signature` header is a bearer credential (a pseudonymous
  user id, subscription status, ~15 min validity). It is attached by the app
  and is **never exposed to page JavaScript**: a page can neither read it nor
  forward it, and the `openStream` header blocklist keeps a page from making
  the host send it. Where it goes is the page's call: by default only to the
  site's own confirmed endpoints, and to any origin the page asks for with
  `identity: true` on `mhub.fetch` (an addon page talking to third-party
  addons needs exactly that, the same way the native addon client signs every
  addon it talks to).
- Requests carry no cookies (`credentials` are omitted).

---

## MediaHubMX binding

Everything above is protocol-agnostic: it works for any page in any
conforming browser. The powers in this section tie a host to the **mHub Addon
Protocol** (v1, "MediaHubMX"); each is announced by a capability, and the
coming **Addon Protocol v2** binding will dock here the same way without
touching the core API.

### Site file fallback: `mediahubmx.json`

For [the mirror system](#mirrors-survive-a-dead-domain), a **MediaHubMX addon
needs no extra file**: the host also accepts the `id` + `endpoints` fields in
the `mediahubmx.json` every addon endpoint already serves. `/mhub-site.json`
wins when both exist; a plain website only ever needs `/mhub-site.json`.

### Client-fetch: capability `"clientFetch"`

If a `mhub.fetch` response is a client-fetch request (the MediaHubMX v1
`taskRequest` of kind **fetch**, which is what a v1 client and every v2 addon
behind the v1 bridge speak), the host runs that request from the client itself
(with the user's own IP, which is the point: geo checks, IP-bound tokens, rate
limits), POSTs the `taskResponse` back to the mirror that actually answered, and resolves the
page's promise with the final result. Any other task kind is answered with an
error response.

Without the capability, the page receives the raw client-fetch request and can
fall back to handling it itself.

### Signed identity: capability `"identity"`

The host attaches the signed `mediahubmx-signature` header (the same client
identity the mHub addon client sends), so an addon server can authorize the
user. Without an `identity` option it goes to the site's **own confirmed
endpoints** only. The page decides per request:

| `identity` | With the capability | Without it |
|---|---|---|
| *(absent)* | signed on own confirmed endpoints | unsigned |
| `true` | signed, any origin | unsigned |
| `"required"` | signed, any origin | the promise rejects with [`code: "identity_required"`](#errors-codes-and-what-to-tell-the-user), nothing is sent |
| `false` | unsigned | unsigned |

`true` is for requests that work either way and are merely better signed (a
public addon that personalises for known users); `"required"` for requests
whose answer is worthless unsigned (a paid catalog, an account endpoint), so
the failure is immediate and named rather than a 403 from far away. A page
speaking to third-party addons (mhub.mx and friends, v1 and v2 alike) uses
`true`. A client-fetch round trip keeps the identity setting of the request
that started it. See [Permission & privacy](#permission--privacy). Which
addons get it is not the page's guess: a v2 addon declares `identity` in its
manifest (`none`, `optional`, `required`) and the v2 client maps that onto
this option.

---

## Planned: Casting

> **Planned, not implemented anywhere. Do not build against this section;
> everything in it may change.** Availability will be announced by the
> `"cast"` capability when it ships.

The idea: a phone controls the user's **own TV app** in the same LAN,
YouTube-style. The page on the phone discovers the TV, hands playback over and
becomes the remote. Video control (play/pause/seek) is mandatory; the phone
never tunnels the TV's traffic; the TV plays the stream itself.

---

## Platform notes

- The API exists on **every platform**: mobile (Android, iOS), desktop
  (Electron), and TV (Android TV, webOS, Tizen). What differs is how a page
  gets there: mobile and desktop have a free in-app browser; on TV there is no
  free browsing, pages arrive as the packaged runtime or through their home
  tiles; the API they see is the same.
- On mobile, `mhub.fetch` resolves to a genuine `Response`. Elsewhere it may
  resolve to a lightweight object exposing `status`, `ok`, `url`, `headers`,
  `text()` and `json()`; code that sticks to those members is portable
  everywhere.
- The whole API is gated by a server-side feature flag; treat its presence as
  optional and always feature-detect `window.mhub`.
- A conforming host implements the **full core** (the guarantee above) and
  announces its optional powers in `mhub.capabilities`.

---

## Full example

```html
<script>
// No boot script here, so window.mhub exists only if a host injected it:
// the namespace IS the check, and every core member below is guaranteed.
// With /mhub.js loaded this would be wrong, because the stub installs a
// window.mhub in a plain browser too; there the check is `await mhub.ready`.
// Mirrors need no code at all: serving /mhub-site.json on every domain is
// the whole integration; the host discovers it on our first mhub.* call.
if (window.mhub) (async () => {
  // 1) Declare our home-screen entries (the full set, every load).
  window.mhub.setLinks([
    { id: "tmdb", name: "TMDB", icon: "/icons/tmdb.png", url: "/tmdb" },
    { id: "live", name: "Live TV",
      endpoints: ["https://a.mx/live", "https://b.mx/live"] },
  ]);

  // 2) A CORS-free request to our own backend. With "identity", the app attaches
  //    the client identity header; our backend can trust it.
  const res = await window.mhub.fetch("https://mhub.mx/api/catalog");
  if (res.ok) render(await res.json());

  // 2b) A third-party addon that wants the same identity: say so. `true` still
  //     sends on a host that cannot sign; "required" would reject instead.
  const other = await window.mhub.fetch("https://tv.mhub.mx/mediahubmx.json",
    { method: "POST", body: "{}", identity: true });

  // 3) Play a stream that needs a Referer; one code path for every host.
  const s = await window.mhub.openStream({
    url: item.streamUrl,
    headers: { Referer: "https://mhub.mx/" },
  });
  video.src = s.url;

  // 4) Own the address-bar search: our search page needs no input field.
  window.mhub.setSearch({
    placeholder: "Search movies & shows",
    onQuery: (q) => (location.href = "/search?q=" + encodeURIComponent(q)),
  });
})();

// React to live host-state changes (device known, permission, identity).
addEventListener("mhubupdate", (e) => { /* … */ });
</script>
```
