Today WebView Bridge
Native Reference

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)

See Environment detection.

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-forget track and for unknown types, resolve with nil (undefined on the web).
  • track enrichment. Inject the base fields (eid, sid, ts, tz, uid, did, client_plat, ctx) here, and strip non-scalar parameters before forwarding to PostHog. See track.
  • headers trust domain (HTTPS). Return Authorization only on an HTTPS trusted surface (feed / embed / auth-whitelisted scope) with a live session; cleartext http, an untrusted domain, or a logged-out session yields a map without it (or {}). Non-secret feedExtraHeaders are gated on the surface but not HTTPS-restricted. The web is built to handle the bearer's absence.
  • Main-frame + trusted surface. headers and refreshToken are token-bearing — serve them to the main document frame only; a cross-origin sub-frame MUST receive undefined. refreshToken consults the same FeedWebHeaders.isTokenTrustedSurface gate as the headers bearer (wired via setTokenAccessPolicy), so an untrusted or cleartext main-frame URL also resolves undefined. See Capabilities.
  • refreshToken coalescing. Route through the same coalescing path as the native API client's own 401 handling so concurrent web 401s collapse into one refresh. See refreshToken.
  • establishSession mints the cookie, never returns the bearer. Write the embed_bearer cookie (HttpOnly, Secure, Path=/api/) into the WebView's WKHTTPCookieStore via httpCookieStore.setCookie(_:) and reply { ok: true }; on failure reply { ok: false } — never reject, never put the bearer in the reply. refresh: true forces a refreshCoalesced() first; same trust gate as headers. See establishSession.
  • Content world. Register in .page so 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.

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.

On this page