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 withAddScriptToExecuteOnDocumentCreatedAsyncso 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.webviewplus the injectedwindow.__todayWebView = { platform: 'windows' }— always inject the tag. See Environment detection. - Resolve vs reject. Return a value (or
nullfortrack/ unknown types) to resolve; throw (Option A) or send anerrorfield (Option B) to reject. OnlyrefreshTokenrejects under normal operation. client_plat. The bridgeplatformtag (windows) is distinct from theX-Client-Platformrequest header (web-client) the web app sends on its own API calls — different fields, different layers. When enrichingtrack, the host maps the bridge tag into the analyticsclient_platbase field (client_plat: "windows"). If the cloudclient_platenum does not yet acceptwindows, map it host-side at the device boundary.- Coalescing.
RefreshCoalescedAsync()MUST collapse concurrent refreshes into one real round-trip, same as the other platforms. establishSessionis the recommended path. Mint theembed_bearercookie (IsSecure,IsHttpOnly,Path=/api/) into WebView2's own store viaCoreWebView2.CookieManager.AddOrUpdateCookie(...)and return{ ok: true }; return{ ok: false }on failure — never throw, never return the bearer.refresh: trueforces aRefreshCoalescedAsync()first; same trust gate asheaders. 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 toheadersand the web's/api/auth/embed-sessionexchange. SeeestablishSession.