Environment Detection
How the platform is resolved — synchronously, and not as a message.
The web frequently needs to know which platform it is running on — iOS, macOS, Android, or plain web — to branch rendering, gate features, or pick a layout. This is not a message capability. It is resolved synchronously, at SDK creation, with no round-trip to native.
type Platform = 'ios' | 'android' | 'macos' | 'windows' | 'web'
bridge.platform // resolved once, synchronouslyWhy not a message
Platform is static and needed before first paint. Modelling it as
await postMessage({ type: 'environment' }) would be the wrong tool: it would
force a branch after mount (a flash), and it cannot run before the channel is
even confirmed. The information is statically knowable, so the SDK reads it
synchronously instead.
The web value is meaningful: it means "no native host" — desktop browser,
Android Chrome, any non-app WebView. It is the same condition as
isAvailable() === false.
Keep the two questions separate.
isAvailable()answers "am I in a native host?".platformanswers "which one?".platform === 'web'exactly whenisAvailable()isfalse.
Resolution order
The SDK resolves platform in this order. A conforming host only needs to inject
the tag (step 1) to disambiguate the two platform pairs; the rest is SDK-side
detection.
- Host-injected tag —
window.__todayWebView.platform, if present, is authoritative. This is the only reliable way to split the two pairs that share a transport: iOS vs macOS (bothWKWebView) and Android vs Windows (both inject a global channel object). - Global channel object + WebView2 marker — an injected
window.todayWebViewBridgeobject alongsidewindow.chrome.webview⇒windows. - Global channel object without that marker ⇒
android. - Apple
webkithandler — awebkit.messageHandlers.todayWebViewBridgechannel ⇒macosorios, split by user-agent as a fallback. - None ⇒
web.
What the host MUST do
A host whose platform shares a transport with another MUST inject a synchronous tag at document start, before page scripts run:
window.__todayWebView = { platform: 'ios' } // 'ios' | 'macos' | 'android' | 'windows'- iOS:
WKUserScriptat.atDocumentStartwith'ios'. - macOS: the same, from the AppKit
WKWebViewhost, with'macos'. Required — iOS and macOS are otherwise indistinguishable. - Windows:
AddScriptToExecuteOnDocumentCreatedAsyncwith'windows'. Required — Windows (WebView2) and Android share the global-object channel shape. - Android: injecting the tag is optional — the channel object plus the absence of the WebView2 marker already identifies it — but recommended for symmetry.
The tag value MUST be one of the four native values ios / android /
macos / windows. web is an SDK-only sentinel for "no native host" and
MUST NOT be injected: a host present with platform === 'web' would break
the isAvailable() ⟺ platform === 'web' invariant. An injected web is
ignored. These native values map to the cloud client_plat field (the
track base field); where cloud has no
matching value the host owns the mapping. Any other unrecognised value is also
ignored and the SDK falls back to channel-shape detection.
Not a security boundary
isAvailable() and platform are environment / UX hints, not a trust
signal. Both read page-visible globals — window.todayWebViewBridge,
window.webkit.messageHandlers, window.__todayWebView — that page JS can set
or overwrite. Use them to branch rendering and gate features; never to make a
trust or authorization decision. The real gates live on the native side
(main-frame + HTTPS trusted surface — see Capabilities).
To narrow the spoofing surface, a host SHOULD define the channel and tag globals as non-writable, non-configurable properties at document start:
Object.defineProperty(window, 'todayWebViewBridge', {
value: channel,
writable: false,
configurable: false,
})User-agent fallback (non-normative)
When no tag is injected, the SDK splits Apple platforms by user-agent: a Mac
user-agent with no touch points ⇒ macos; otherwise ⇒ ios. iPadOS reports a
Mac user-agent but is touch-capable, so touch-capable Macs are treated as ios.
This heuristic is best-effort — the injected tag is the contract; the UA
fallback only covers hosts that have not yet adopted it.