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', surface: 'embedded', capabilities: ['native_dialog.v1', 'live_widget_creation.v1', 'today_page_revalidate_feedback.v1'] };",
  injectionTime: .atDocumentStart,
  forMainFrameOnly: true
)
config.userContentController.addUserScript(tag)

See Environment detection.

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-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.
  • Clear the cookie on sign-out. The embed_bearer cookie is HttpOnly and 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 valid exp; reject an expired one (matches the BFF).
  • 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.

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.

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