Hybrid, .NET MAUI, Mac Catalyst — and a clipboard nobody can reach.

When “Paste” does nothing: Monaco inside a WKWebView Blazor 

The symptom A SQL editor built on .NET MAUI and Blazor Hybrid. The UI is Blazor, the editor is Monaco, hosted in a BlazorWebView. It has been running on Windows for months. The first run on macOS produces a window that looks right and behaves right — with one exception: Right-click → “Paste” does nothing. No error, no message, no text. Cmd+V, on the other hand, works perfectly. Same codebase, same Monaco. Only a different WebView. Two dead ends first Before the actual cause, two wrong assumptions that cost time — maybe this saves someone the same detour. First: “Paste is broken entirely.” The initial attempts used Ctrl+V. On macOS that simply isn’t a paste shortcut, in any app. Coming from Windows, it sits in your fingers. Only a log of the keyboard events separated the two:

keydown key=v meta=false ctrl=true   → no paste event
keydown key=v meta=true  ctrl=false  → PASTE len=24 text="SELECT 1 AS Test"

Second: “Monaco is taking its iOS path.” Mac Catalyst reports an iPad idiom (UIDeviceFamily = 2), and Monaco handles input differently on touch platforms. A reasonable suspicion, but wrong. The WebView identifies as a Mac:

UA = Mozilla/5.0 (Macintosh; Intel Mac OS X 10\_15\_7) AppleWebKit/605.1.15
EditContext = undefined
monaco-textarea = true, readOnly = false

So Monaco uses the desktop path with the classic hidden <textarea>. All normal. The problem lies elsewhere. The cause Two things meet. WebKit does not let a page read the clipboard. navigator.clipboard.readText() exists inside the WKWebView — the function is there, you can call it — but WebKit refuses it for web content. That is not a bug, it is the design: a page should not be able to read what you last copied without being asked. Cmd+V works precisely because the page isn’t the one reading. WebKit performs the paste itself and hands the document a ready-made paste event carrying clipboardData. The browser decides; the page receives the result. Monaco offers the menu entry anyway. The bundled Monaco (here via BlazorMonaco 3.4.0) contains this:

const supportsCut   = isNative || document.queryCommandSupported("cut"),
      supportsCopy  = isNative || document.queryCommandSupported("copy"),
      supportsPaste = typeof navigator.clipboard > "u" || isFirefox
                    ? document.queryCommandSupported("paste")
                    : true;

As soon as navigator.clipboard exists and you are not in Firefox, supportsPaste is true — unconditionally. So Monaco registers the entry, and its implementation calls exactly the readText() that WebKit refuses. The result is a menu item that reliably does nothing. You can see the same in VS Code for the Web; it is not specific to this app. And the entry cannot be removed or reimplemented from the outside. It is a MultiCommand from Monaco’s internals, with no public surface. The fix, part 1: read natively The way out: the page may not read the clipboard — the host process may. A MAUI app is native, UIPasteboard is right there, and MAUI wraps it cross-platform. So you add a platform bridge, the same kind you already need for file dialogs in Blazor Hybrid:

// in the shared project
public interface IClipboardService
{
    Task<string?> GetTextAsync();
}

// in the MAUI head
public class MauiClipboardService : IClipboardService
{
    public async Task<string?> GetTextAsync()
    {
        try
        {
            return Clipboard.Default.HasText
                ? await Clipboard.Default.GetTextAsync()
                : null;
        }
        catch { return null; }
    }
}

The web variant of the same application keeps the browser API — readText() works normally there; the restriction only applies to the embedded WebView. Plus a custom Monaco action that inserts the text at the selection:

window.\_\_novaSqlInsertText = function (editorId, text) {
    var ed = findEditor(editorId);
    if (!ed || !text) return false;
    ed.executeEdits('clipboard',
        \[{ range: ed.getSelection(), text: text, forceMoveMarkers: true }]);
    ed.focus();
    return true;
};
case "clipboard-paste":
    var clip = await Clipboard.GetTextAsync();
    if (!string.IsNullOrEmpty(clip) \&\& \_editor is not null)
        await JsMonacoActions.InsertTextAsync(JsRuntime, \_editor.Id, clip);
    break;

One detail that went wrong on the first attempt: you are tempted to read the clipboard directly in the action’s JavaScript handler so as not to lose the user activation — readText() is only allowed inside one, after all. That concern is moot here. Native access needs no user activation, so the round trip through .NET is harmless. And trying to solve it in JavaScript after all fails on exactly the same wall as Monaco’s own entry. The fix, part 2: getting rid of the dead entry Now two “Paste” items sit on top of each other — Monaco’s inert one and your working one. That is worse than before. The lever is in the expression above: if navigator.clipboard is not present at load time, Monaco falls back to document.queryCommandSupported("paste"), and WebKit reports false for web content. The entry is then never created. So the property is hidden only across Monaco’s module evaluation and restored right afterwards:

(function () {
    var real = navigator.clipboard;
    if (!real) return;
    var set = function (value) {
        try {
            Object.defineProperty(navigator, 'clipboard', {
                configurable: true, get: function () { return value; }
            });
            return true;
        } catch (e) { return false; }
    };
    if (!set(undefined)) return;

    var deadline = Date.now() + 15000;
    (function wait() {
        if ((window.monaco \&\& window.monaco.editor) || Date.now() > deadline) {
            set(real);
            return;
        }
        setTimeout(wait, 25);
    })();
})();

The script has to run before Monaco’s loader. The deadline guard keeps the clipboard from staying hidden forever should Monaco fail to load. Why this is safe: across the whole bundle, exactly one place reads navigator.clipboard at load time — the one shown above. Every other access (readText, writeText) lives inside a function body and sees the original again by the time it runs. The application’s own copy features also only run on user interaction, long after the property is restored. What to take away To be honest: part 2 reaches into someone else’s internals. If a Monaco update changes that condition, the dead entry quietly comes back — and no test will catch it. The state is then the same as before, so nothing gets worse. If that bothers you, keep both entries and give yours an unambiguous label. Part 1, by contrast, is solid and transferable. The general lesson: in Blazor Hybrid, the distance between the web context and the host process varies by platform. What passes under WebView2 may be blocked under WKWebView. When the WebView hits a wall, it is worth checking whether the native host hits the same one — usually it does not. The platform bridge is not a workaround in Blazor Hybrid; it is the intended path.