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' };",
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
config.userContentController.addUserScript(tag)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
}
}
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.- 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.
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.