DEV Community

Cover image for Cross-boundary communication between desktop and web
Eelco Los
Eelco Los

Posted on

Cross-boundary communication between desktop and web

We have a desktop product that customers actively use, and we want to migrate toward a SaaS offering. In practice, that means we need backwards compatibility while we ship new features.

During that transition you often end up in a "hybrid" state: a desktop shell still exists, but more and more UI and logic moves into web technology (for example hosted inside WebView2).

That hybrid state introduces a core challenge: how do you preserve everyday interactions across boundaries? Drag & drop, keyboard copy/paste, focus, selection, and other "it just works" behaviors tend to break the moment parts of the UI live in different browsing contexts (iframes/windows) or even in a host process.

Demo repo (reference implementation): https://github.com/EelcoLos/iframe-dnd-demo
Live demo: https://eelcolos.github.io/iframe-dnd-demo/

A pragmatic way to do incremental delivery is to introduce new modules behind explicit boundaries:

  • embed legacy/new pieces via IFrame
  • split experiences into separate windows when the host is desktop (or when multi-monitor workflows help)
  • use Web Components to build reusable UI pieces without betting on one framework

The next question becomes: how do those pieces communicate so interactions (drag & drop, keyboard copy/paste) still work across boundaries. And how does it bridge to the desktop host?

This post outlines a simple architecture: message passing all the way down, but with clear layers:

  • DOM messaging for iframe ↔ parent (window.postMessage)
  • Cross-window transport when needed (BroadcastChannel with fallbacks)
  • WebView2 web messaging for web ↔ host (window.chrome.webview.postMessage)
+------------------------ Desktop host (WebView2) -------------------------+
| WebMessageReceived  <---  chrome.webview.postMessage(...)                |
|        ^                                                                 |
|        |  CoreWebView2.PostWebMessageAsJson/String(...)                  |
|        |                                                                 |
|  +---------------- Parent web shell (coordinator) --------------------+  |
|  | iframe <-> parent: window.postMessage                              |  |
|  | cross-window (optional): BroadcastChannel                          |  |
|  |   fallback: postMessage relay via coordinator (Firefox ETP)        |  |
|  +--------------------------------------------------------------------+  |
+--------------------------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

In the web layer, the parent shell acts as a coordinator:

  • routes messages between iframes
  • handles cross-iframe pointer interactions (coordinate conversion, hit-testing)
  • holds shared state (e.g., clipboard-like state for keyboard copy/paste)

Then the parent shell optionally bridges certain messages to the desktop host.

Layer 1: iframe ↔ parent (DOM postMessage)

This is regular browser messaging:

  • set an explicit targetOrigin (avoid '*')
  • validate the sender origin on receive

In the demo, this routing enables:

  • pointer-based drag & drop across iframes
  • keyboard copy/paste across iframes

Layer 2: parent ↔ desktop host (WebView2 WebMessage)

WebView2 provides a separate channel:

  • JS → host: window.chrome.webview.postMessage(...)
  • host → JS: CoreWebView2.PostWebMessageAsJson/String(...)
  • JS receive: window.chrome.webview.addEventListener('message', ...)

Microsoft’s docs emphasize treating web content as untrusted and validating origins / message payloads.

Message contracts: be explicit (the “action/type” field)

The implementation choice I like most in this demo: every message carries a clear discriminator so the receiver can route behavior.

Web ↔ web (iframes/windows): type

In the browser-to-browser layer, the repo uses type values like dragStart, parentDrop, itemCopied, requestPaste, etc.

Example shapes from the repo’s API.md:

{ "type": "dragStart", "text": "Item 1", "id": "1", "source": "frame-a" }
Enter fullscreen mode Exit fullscreen mode
{ "type": "parentDrop", "x": 123, "y": 456, "dragData": { "id": "1" } }
Enter fullscreen mode Exit fullscreen mode

If you want versioning too, you can wrap that idea:

{
  "v": 1,
  "type": "itemCopied",
  "source": "frame-a",
  "payload": { "itemData": { "id": "1" } }
}
Enter fullscreen mode Exit fullscreen mode

Web ↔ host (WebView2): action

For the WebView2 host bridge, the demo uses an action field for the same purpose (route on action in WebMessageReceived).

Example shape:

{ "action": "copy", "description": "Item", "quantity": "12", "unitPrice": "450" }
Enter fullscreen mode Exit fullscreen mode

Contract in action (from the demo repo)

JS side (from public/webcomponent-table-source-html5.html) posts JSON strings to the WebView2 host:

// If running in WebView2 (C# host), also send copy to native code
if (window.chrome && window.chrome.webview) {
  window.chrome.webview.postMessage(JSON.stringify({
    action: 'copy',
    description: selectedRow.dataset.desc,
    quantity: selectedRow.dataset.qty,
    unitPrice: selectedRow.dataset.price
  }));
}
Enter fullscreen mode Exit fullscreen mode

The same page also posts other actions like dragstart, dragend, and (on double-click) drop.

C# side (from WebView2App/HybridModeWindow.xaml.cs) receives and routes based on action:

WebViewSource.CoreWebView2.WebMessageReceived += CoreWebView2_WebMessageReceived;

private void CoreWebView2_WebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
{
    string? message = null;

    try
    {
        message = e.TryGetWebMessageAsString();
    }
    catch (ArgumentException)
    {
        // Message is not a string, try getting it as JSON
        message = e.WebMessageAsJson;
    }

    // Message format examples:
    // {"action":"drop","description":"...","quantity":12,"unitPrice":450}
    // {"action":"copy","description":"...","quantity":"12","unitPrice":"450"}
    if (string.IsNullOrEmpty(message)) return;

    using var json = System.Text.Json.JsonDocument.Parse(message);
    var root = json.RootElement;

    if (!root.TryGetProperty("action", out var actionProp)) return;
    var action = actionProp.GetString();

    if (action == "drop")
    {
        // parse description/quantity/unitPrice and add to the target DataGridView
    }
    else if (action == "copy")
    {
        // store the copied data so Ctrl+V in the target window can paste it
    }
}
Enter fullscreen mode Exit fullscreen mode

Why it matters:

  • you can be backwards compatible across modules
  • you can implement request/response via correlationId
  • you can validate shape (and reject unknown/untrusted messages)

Testing strategy

  • In-web behavior: Playwright is a good fit, treating iframes and windows as first-class.
    • Testing iframe drag and drop with Playwright
  • Cross-window behavior: add tests that open child pages from a coordinator and assert keyboard interactions.
  • Host bridge behavior: test separately at the desktop integration layer (WebMessageReceived handlers, navigation/origin checks).

Security checklist (practical)

  • Validate origins for DOM postMessage: set a strict targetOrigin on send, and on receive validate event.origin (allowlist) and optionally event.source before trusting event.data.
  • In WebView2, always check the current document origin before trusting messages.
  • Prefer JSON messages and validate schema.
  • Disable features you don’t need (host objects, web messaging, scripts).

Sources

Top comments (0)