iOS
Implementing the contract with WKScriptMessageHandlerWithReply.
iOS gets the promise semantics for free. WKScriptMessageHandlerWithReply
already makes window.webkit.messageHandlers.todayWebViewBridge.postMessage(...)
return a JS Promise on the web side; the handler resolves or rejects it through
the replyHandler.
Register the channel
import WebKit
let config = WKWebViewConfiguration()
config.userContentController.addScriptMessageHandler(
TodayWebViewBridge(),
contentWorld: .page,
name: "todayWebViewBridge"
)
let webView = WKWebView(frame: .zero, configuration: config)The name MUST be exactly todayWebViewBridge — that is what the web SDK looks
for on window.webkit.messageHandlers.
Inject the platform tag
Because iOS and macOS both present an identical WKWebView channel, the SDK
cannot tell them apart from the channel alone. Inject the authoritative platform
tag with a WKUserScript at document start (use 'macos' from the AppKit host):
let tag = WKUserScript(
source: "window.__todayWebView = { platform: 'ios', surface: 'embedded', capabilities: ['native_dialog.v1', 'live_widget_creation.v1', 'today_page_revalidate_feedback.v1'] };",
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
config.userContentController.addUserScript(tag)Only advertise today_page_revalidate_feedback.v1 on the Today Page surface
after its event parser and native header renderer are installed. See the
iOS Today Page Revalidation guide
for the current today-platform-ios edit map and state machine.
Handle messages
final class TodayWebViewBridge: NSObject, WKScriptMessageHandlerWithReply {
func userContentController(
_ controller: WKUserContentController,
didReceive message: WKScriptMessage,
replyHandler: @escaping (Any?, String?) -> Void
) {
guard let body = message.body as? [String: Any],
let type = body["type"] as? String else {
// Malformed message — resolve undefined rather than reject.
replyHandler(nil, nil)
return
}
switch type {
case "track":
let ename = body["ename"] as? String ?? ""
let parameters = (body["parameters"] as? [String: Any]) ?? [:]
analytics.track(ename, enrich(scalarsOnly(parameters)))
replyHandler(nil, nil) // fire-and-forget → resolve undefined
case "headers":
replyHandler(currentInjectedHeaders(), nil) // [String: String], maybe empty
case "refreshToken":
Task {
do {
let token = try await tokenStore.refreshCoalesced()
replyHandler(["authorization": token.authorization], nil) // ready-to-use value only, never the raw token
} catch {
replyHandler(nil, error.localizedDescription) // reject with a string
}
}
case "establishSession":
let refresh = body["refresh"] as? Bool ?? false
Task {
do {
let token = refresh
? try await tokenStore.refreshCoalesced()
: try await tokenStore.current()
// Mint the cookie into the WebView's own store — never return the bearer.
let cookie = HTTPCookie(properties: [
.name: "embed_bearer", .value: token.rawValue,
.domain: trustedHost, .path: "/api/",
.secure: true, .init(rawValue: "HttpOnly"): true,
])!
await webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie)
replyHandler(["ok": true], nil)
} catch {
replyHandler(["ok": false], nil) // resolve { ok: false }, never reject
}
}
case "nativeDialog.present":
guard message.frameInfo.isMainFrame else {
replyHandler(nil, nil)
return
}
Task { @MainActor in
do {
// Parse the v1 union before entering the coordinator.
// The coordinator owns one UIAlertController and a sessionId-keyed registry.
let result = await dialogCoordinator.present(try NativeDialogRequest(body))
replyHandler(result.dictionary, nil) // every normal result echoes sessionId
} catch {
replyHandler(nil, "invalid native dialog request")
}
}
case "liveWidgetCreation.present":
guard message.frameInfo.isMainFrame,
body["schemaVersion"] as? Int == 1,
isTrustedEmbeddedURL(webView.url) else {
replyHandler(nil, nil)
return
}
Task { @MainActor in
let presented = liveWidgetCoordinator.presentCreationFlowIfAvailable()
replyHandler(["presented": presented], nil)
}
case "nativeDialog.dismiss":
guard message.frameInfo.isMainFrame else {
replyHandler(nil, nil)
return
}
Task { @MainActor in
do {
let result = dialogCoordinator.dismiss(sessionId: try requiredSessionId(body))
replyHandler(result.dictionary, nil)
} catch {
replyHandler(nil, "invalid native dialog session")
}
}
default:
replyHandler(nil, nil) // unknown type → resolve undefined
}
}
}Contract notes specific to iOS
- Resolve vs reject.
replyHandler(value, nil)resolves;replyHandler(nil, someString)rejects with that string. For fire-and-forgettrackand for unknown types, resolve withnil(undefinedon the web). trackenrichment. Inject the base fields (eid,sid,ts,tz,uid,did,client_plat,ctx) here, and strip non-scalarparametersbefore forwarding to PostHog. Seetrack.headerstrust domain (HTTPS). ReturnAuthorizationonly on an HTTPS trusted surface (feed / embed / auth-whitelisted scope) with a live session; cleartexthttp, an untrusted domain, or a logged-out session yields a map without it (or{}). Non-secretfeedExtraHeadersare gated on the surface but not HTTPS-restricted. The web is built to handle the bearer's absence.- Main-frame + trusted surface.
headersandrefreshTokenare token-bearing — serve them to the main document frame only; a cross-origin sub-frame MUST receiveundefined.refreshTokenconsults the sameFeedWebHeaders.isTokenTrustedSurfacegate as theheadersbearer (wired viasetTokenAccessPolicy), so an untrusted or cleartext main-frame URL also resolvesundefined. See Capabilities. refreshTokencoalescing. Route through the same coalescing path as the native API client's own401handling so concurrent web401s collapse into one refresh. SeerefreshToken.establishSessionmints the cookie, never returns the bearer. Write theembed_bearercookie (HttpOnly,Secure,Path=/api/) into the WebView'sWKHTTPCookieStoreviahttpCookieStore.setCookie(_:)and reply{ ok: true }; on failure reply{ ok: false }— never reject, never put the bearer in the reply.refresh: trueforces arefreshCoalesced()first; same trust gate asheaders. SeeestablishSession.- Clear the cookie on sign-out. The
embed_bearercookie isHttpOnlyand outlives the in-memory session, so the host MUST delete it from the cookie store on sign-out / account switch — otherwise a stale bearer authenticates the next embed load as the previous user until the JWT expires. Mint only a token that carries a validexp; reject an expired one (matches the BFF). - Content world. Register in
.pageso the channel is visible to the page's own scripts. If you isolate the bridge in a separate content world, the web SDK will not find it.
Native dialog mapping
Advertise native_dialog.v1 only after both request types are installed. Map alert, confirm,
and prompt to UIAlertController.Style.alert; map actionSheet to .actionSheet. Action roles
map to .default, .cancel, and .destructive. Text field input mode, secure entry, and
autocapitalization map to the corresponding UITextField properties.
The coordinator is main-actor-owned and allows one active dialog per WebView. It must return
notPresented/busy instead of queueing or replacing an existing controller. On iPad, convert the
optional CSS viewport anchor into WebView coordinates and configure the popover source rect; use
a safe centered source rect when absent. A missing presenting view controller returns
notPresented/unavailable rather than attempting presentation from a detached controller.
On navigation or WebView teardown, dismiss the controller and remove its registry entry. If the
old reply handler is still deliverable, settle it with dismissed/navigation; never carry that
callback or result into the next document. See the normative
Native Dialog contract.
Live Widget creation mapping
Advertise live_widget_creation.v1 only on the embedded Today WebView after
liveWidgetCreation.present is installed. Validate the same main-frame and
trusted App-Bound URL boundary used by native dialogs, then route the request to
the app's existing Live Widget creation sheet. Return { presented: false }
when the WebView is offscreen, another controller is already presented, or no
presenter is available. Do not queue or replace UI, and do not wait for the
creation flow to finish before replying. See the normative
Live Widget Creation contract.
Navigation-request injection is not enough
WKNavigationDelegate.decidePolicyFor(navigationAction:) only fires for
top-level (and sub-frame) document navigations. It cannot see the SPA's fetch /
XHR, so you cannot inject Authorization there for the web app's own requests.
That limitation is the entire reason headers and refreshToken exist — see the
Introduction.