Wenn „Einfügen“ im Kontextmenü nichts tut: Monaco in einer WKWebView Blazor Hybrid, .NET MAUI, Mac C

Das Symptom Ein SQL-Editor auf Basis von .NET MAUI und Blazor Hybrid. Die Oberfläche ist Blazor, der Editor ist Monaco, gehostet in einem BlazorWebView. Unter Windows läuft das seit Monaten. Der erste Durchlauf auf dem Mac zeigt ein Fenster, das korrekt aussieht und sich korrekt bedienen lässt — mit einer Ausnahme: Rechtsklick → „Einfügen“ bewirkt nichts. Kein Fehler, keine Meldung, kein Text. Cmd+V dagegen funktioniert einwandfrei. Dieselbe Codebasis, dieselbe Monaco-Version. Nur eine andere WebView. Zwei Sackgassen vorweg Bevor ich zur Ursache komme, zwei Fehlannahmen, die Zeit gekostet haben — vielleicht spart es jemandem denselben Umweg. Erstens: „Einfügen geht gar nicht.“ Die ersten Versuche schlugen mit Ctrl+V fehl. Auf macOS ist das schlicht keine Einfügen-Taste, in keiner App. Wer von Windows kommt, hat den Griff in den Fingern. Erst ein Protokoll der Tastatur-Ereignisse hat das auseinandergehalten:

keydown key=v meta=false ctrl=true   → kein paste-Ereignis
keydown key=v meta=true  ctrl=false  → PASTE len=24 text="SELECT 1 AS Test"

Zweitens: „Monaco nimmt seinen iOS-Pfad.“ Mac Catalyst meldet sich als iPad-Idiom (UIDeviceFamily = 2), und Monaco behandelt Touch-Plattformen bei der Eingabe anders. Naheliegender Verdacht, aber falsch. Die WebView meldet sich als Mac:

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

Monaco nimmt also den Desktop-Pfad mit dem klassischen versteckten <textarea>. Alles normal. Der Fehler liegt woanders. Die Ursache Zwei Dinge treffen aufeinander. WebKit gibt einer Seite die Zwischenablage nicht zum Lesen. navigator.clipboard.readText() existiert in der WKWebView — die Funktion ist da, sie lässt sich aufrufen — aber WebKit lehnt sie für Webinhalte ab. Das ist kein Bug, sondern Absicht: eine Seite soll nicht ungefragt mitlesen können, was man zuletzt kopiert hat. Cmd+V funktioniert deshalb, weil dabei nicht die Seite liest. WebKit führt das Einfügen selbst aus und stellt dem Dokument ein fertiges paste-Ereignis mit clipboardData zu. Der Browser entscheidet, die Seite bekommt das Ergebnis. Monaco bietet den Menüeintrag trotzdem an. Im gebündelten Monaco (hier über BlazorMonaco 3.4.0) steht an einer Stelle:

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

Sobald navigator.clipboard existiert und man nicht in Firefox ist, gilt supportsPaste = true — bedingungslos. Monaco registriert den Eintrag also, und dessen Implementierung ruft intern genau das readText(), das WebKit verweigert. Ergebnis: ein Menüpunkt, der zuverlässig nichts tut. Dasselbe erlebt man übrigens in VS Code im Browser; es ist keine Eigenheit dieser App. Und: von außen lässt sich dieser Eintrag weder entfernen noch neu implementieren. Er ist ein MultiCommand aus Monacos Innerem, ohne öffentliche Schnittstelle. Die Lösung, Teil 1: nativ lesen Der Weg aus der Sackgasse: Die Seite darf die Zwischenablage nicht lesen — der Host-Prozess schon. Eine MAUI-App ist nativ, UIPasteboard steht offen, und MAUI kapselt das plattformübergreifend. Also eine Plattform-Brücke, wie man sie in Blazor Hybrid ohnehin für Dateidialoge braucht:

// im geteilten Projekt
public interface IClipboardService
{
    Task<string?> GetTextAsync();
}

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

In der Web-Variante derselben Anwendung bleibt es beim Browser-API — dort funktioniert readText() regulär, die Einschränkung gilt nur für die eingebettete WebView. Dazu eine eigene Monaco-Aktion, die den Text an der Auswahl einsetzt:

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;

Ein Detail, das beim ersten Anlauf schiefging: Man ist versucht, die Zwischenablage direkt im JavaScript-Handler der Aktion zu lesen, um die Benutzeraktion nicht zu verlieren — readText() ist ja nur innerhalb einer solchen erlaubt. Das ist hier gegenstandslos. Der native Zugriff braucht keine Benutzeraktion, der Umweg über .NET ist also unbedenklich. Und der Versuch, es doch in JavaScript zu lösen, scheitert an derselben Sperre wie Monacos eigener Eintrag. Die Lösung, Teil 2: den toten Eintrag loswerden Jetzt stehen zwei „Einfügen“ untereinander — Monacos wirkungsloses und das eigene. Das ist schlechter als vorher. Der Hebel steckt im Ausdruck oben: Ist navigator.clipboard beim Laden nicht vorhanden, fällt Monaco auf document.queryCommandSupported("paste") zurück, und das meldet WebKit für Webinhalte false. Dann entsteht der Eintrag gar nicht erst. Also wird die Eigenschaft nur über Monacos Modulauswertung verdeckt und sofort danach zurückgegeben:

(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);
    })();
})();

Das Skript muss vor Monacos Loader stehen. Die Notbremse über deadline verhindert, dass die Zwischenablage dauerhaft verdeckt bleibt, falls Monaco nicht lädt. Warum das gefahrlos ist: Im gesamten Bundle liest genau eine Stelle navigator.clipboard beim Laden, nämlich die oben gezeigte. Alle übrigen Zugriffe (readText, writeText) stehen in Funktionskörpern und sehen beim Aufruf längst wieder das Original. Auch eigene Kopier-Funktionen der Anwendung laufen erst auf Benutzeraktion, also lange nach dem Zurückgeben. Was davon bleibt Ehrlich gesagt: Teil 2 ist ein Eingriff in fremde Interna. Ändert ein Monaco-Update die Bedingung, kommt der tote Eintrag stillschweigend zurück — kein Test schlägt deswegen an. Der Zustand ist dann derselbe wie vorher, es wird also nichts schlimmer. Wer das nicht mag, lässt beide Einträge stehen und beschriftet den eigenen eindeutig. Teil 1 dagegen ist solide und übertragbar. Die allgemeine Lehre: In Blazor Hybrid liegen Web- und Host-Prozess unterschiedlich weit auseinander, je nach Plattform. Was unter WebView2 durchgeht, kann unter WKWebView gesperrt sein. Wenn die WebView an eine Grenze stößt, lohnt der Blick, ob der native Host dieselbe Grenze hat — meistens nicht. Die Plattform-Brücke ist in Blazor Hybrid kein Notbehelf, sondern der vorgesehene Weg.