Today WebView Bridge
Native Reference

Windows

Implementing the contract on WebView2 with a promise-returning shim.

Windows uses WebView2 (the Chromium-based Microsoft.Web.WebView2 control). Like Android, its native interop is not promise-shaped out of the box, so a conforming host injects a thin JS shim that presents the postMessage(message): Promise surface the web SDK expects on window.todayWebViewBridge.

Two mechanisms work. Prefer the first.

Option A — host object (preferred)

AddHostObjectToScript exposes a native object under window.chrome.webview.hostObjects.<name>, and its async methods return JS promises automatically. Back the channel with a single Handle(json) method.

[ClassInterface(ClassInterfaceType.AutoDual)]
[ComVisible(true)]
public class TodayWebViewBridgeNative
{
    public async Task<string?> Handle(string json)
    {
        var request = JsonNode.Parse(json)!.AsObject();
        switch ((string?)request["type"])
        {
            case "track":
                _analytics.Track((string?)request["ename"] ?? "",
                    Enrich(ScalarsOnly(request["parameters"])));
                return null; // fire-and-forget → resolve undefined

            case "headers":
                return CurrentInjectedHeaders().ToJsonString(); // "{}" when nothing to inject

            case "refreshToken":
                var token = await _tokenStore.RefreshCoalescedAsync(); // throw → JS reject
                return new JsonObject
                {
                    ["authorization"] = token.Authorization, // ready-to-use value only, never the raw token
                }.ToJsonString();

            case "establishSession":
                try
                {
                    var t = (bool?)request["refresh"] == true
                        ? await _tokenStore.RefreshCoalescedAsync()
                        : await _tokenStore.CurrentAsync();
                    // Mint the cookie into WebView2's own store — never return the bearer.
                    var mgr = _webView.CoreWebView2.CookieManager;
                    var cookie = mgr.CreateCookie("embed_bearer", t.RawValue, _trustedHost, "/api/");
                    cookie.IsSecure = true; cookie.IsHttpOnly = true;
                    mgr.AddOrUpdateCookie(cookie);
                    return new JsonObject { ["ok"] = true }.ToJsonString();
                }
                catch
                {
                    return new JsonObject { ["ok"] = false }.ToJsonString(); // never reject
                }

            default:
                return null; // unknown type → resolve undefined
        }
    }
}

Register the object and inject the shim + platform tag at document creation:

await webView.EnsureCoreWebView2Async();
webView.CoreWebView2.AddHostObjectToScript("todayWebViewBridgeNative", new TodayWebViewBridgeNative());
await webView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(@"
  window.__todayWebView = { platform: 'windows' };
  window.todayWebViewBridge = {
    postMessage(message) {
      return window.chrome.webview.hostObjects.todayWebViewBridgeNative
        .Handle(JSON.stringify(message))
        .then(function (r) { return r == null ? undefined : JSON.parse(r); });
    },
  };
");

A thrown exception in Handle rejects the JS promise — that is how refreshToken reports terminal failure.

Option B — chrome.webview.postMessage + request-id

Where you prefer message passing over host objects, correlate requests and replies by id over chrome.webview.postMessage (JS→host) and PostWebMessageAsString (host→JS). This mirrors the Android listener pattern.

webView.CoreWebView2.WebMessageReceived += async (sender, e) =>
{
    var request = JsonNode.Parse(e.WebMessageAsJson)!.AsObject();
    var id = (string?)request["id"];
    var (result, error) = await HandleAsync(request); // same dispatch as Option A
    var reply = new JsonObject { ["id"] = id };
    if (error != null) reply["error"] = error; else reply["result"] = result;
    webView.CoreWebView2.PostWebMessageAsString(reply.ToJsonString());
};
// Injected at document creation.
;(() => {
  const pending = new Map()
  window.chrome.webview.addEventListener('message', (e) => {
    const { id, result, error } = JSON.parse(e.data)
    const p = pending.get(id)
    if (!p) return
    pending.delete(id)
    error == null ? p.resolve(result) : p.reject(new Error(error))
  })
  window.__todayWebView = { platform: 'windows' }
  window.todayWebViewBridge = {
    postMessage(message) {
      const id = crypto.randomUUID()
      return new Promise((resolve, reject) => {
        pending.set(id, { resolve, reject })
        window.chrome.webview.postMessage({ id, ...message })
      })
    },
  }
})()

Contract notes specific to Windows

  • Channel name. The web SDK looks for window.todayWebViewBridge. Inject the shim with AddScriptToExecuteOnDocumentCreatedAsync so the channel exists before page scripts run.
  • Platform tag is required. WebView2 injects the same global-object channel shape as Android, so the SDK cannot tell them apart from the channel alone. It disambiguates via the WebView2 marker window.chrome.webview plus the injected window.__todayWebView = { platform: 'windows' } — always inject the tag. See Environment detection.
  • Resolve vs reject. Return a value (or null for track / unknown types) to resolve; throw (Option A) or send an error field (Option B) to reject. Only refreshToken rejects under normal operation.
  • client_plat. The bridge platform tag (windows) is distinct from the X-Client-Platform request header (web-client) the web app sends on its own API calls — different fields, different layers. When enriching track, the host maps the bridge tag into the analytics client_plat base field (client_plat: "windows"). If the cloud client_plat enum does not yet accept windows, map it host-side at the device boundary.
  • Coalescing. RefreshCoalescedAsync() MUST collapse concurrent refreshes into one real round-trip, same as the other platforms.
  • establishSession is the recommended path. Mint the embed_bearer cookie (IsSecure, IsHttpOnly, Path=/api/) into WebView2's own store via CoreWebView2.CookieManager.AddOrUpdateCookie(...) and return { ok: true }; return { ok: false } on failure — never throw, never return the bearer. refresh: true forces a RefreshCoalescedAsync() first; same trust gate as headers. The contract is defined and the web side already prefers it; the native implementation is a per-platform follow-up — until it lands, the host falls through to headers and the web's /api/auth/embed-session exchange. See establishSession.

On this page