fix: bridge Wayland clipboard for CEF OSR (paste + copy)
CEF in OSR mode cannot access the Wayland/X11 system clipboard. Paste (Ctrl+V): intercept in input bridge, read text from GDK clipboard via ReadTextAsync, inject into CEF via ImeCommitText. Copy (Ctrl+C/X): inject JS copy/cut event listeners on all pages that send selected text to Go via fetch /api/clipboard-set, then write to GDK clipboard via glib.IdleAddOnce on the GTK main thread.
- commit
- c3fc1baafc9164008dbdc627aa4c972956572e7a
- author
- brice · · 2026-03-25 06:07 UTC
- committer
- brice · · 2026-03-28 04:39 UTC
- parent
- 37f0ef7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +135 -0
M internal/infrastructure/cef/content_injector.go +30 -0
| @@ -379,6 +379,33 @@ const redditDirectVideoJS = `(function(){ | |||
| 379 | 379 | }, 1000); | |
| 380 | 380 | })();` | |
| 381 | 381 | ||
| 382 | + | // clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text | |
| 383 | + | // back to Go via the message bridge so it can be written to the GDK clipboard. | |
| 384 | + | // Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but | |
| 385 | + | // not the Wayland/X11 system clipboard. | |
| 386 | + | const clipboardCopyBridgeJS = `(function(){ | |
| 387 | + | if (window.__dumberClipboardBridge) return; | |
| 388 | + | window.__dumberClipboardBridge = true; | |
| 389 | + | function sendToClipboard(text) { | |
| 390 | + | if (!text) return; | |
| 391 | + | var body = JSON.stringify({text: text}); | |
| 392 | + | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| 393 | + | if (!encoded) return; | |
| 394 | + | fetch('dumb:///api/clipboard-set', { | |
| 395 | + | method: 'POST', | |
| 396 | + | headers: {'X-Dumber-Body': encoded} | |
| 397 | + | }).catch(function(){}); | |
| 398 | + | } | |
| 399 | + | document.addEventListener('copy', function() { | |
| 400 | + | var sel = window.getSelection(); | |
| 401 | + | if (sel) sendToClipboard(sel.toString()); | |
| 402 | + | }); | |
| 403 | + | document.addEventListener('cut', function() { | |
| 404 | + | var sel = window.getSelection(); | |
| 405 | + | if (sel) sendToClipboard(sel.toString()); | |
| 406 | + | }); | |
| 407 | + | })();` | |
| 408 | + | ||
| 382 | 409 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 383 | 410 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 384 | 411 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -503,6 +530,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 503 | 530 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | |
| 504 | 531 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 505 | 532 | ||
| 533 | + | // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard). | |
| 534 | + | wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS) | |
| 535 | + | ||
| 506 | 536 | // Video playback diagnostic — logs video element state changes. | |
| 507 | 537 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | |
| 508 | 538 | if ci.videoDiagnosticsEnabled { | |
| @@ -379,6 +379,33 @@ const redditDirectVideoJS = `(function(){ | |||||
| 379 | }, 1000); | 379 | }, 1000); | ||
| 380 | })();` | 380 | })();` | ||
| 381 | 381 | ||||
| 382 | + | // clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text | |||
| 383 | + | // back to Go via the message bridge so it can be written to the GDK clipboard. | |||
| 384 | + | // Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but | |||
| 385 | + | // not the Wayland/X11 system clipboard. | |||
| 386 | + | const clipboardCopyBridgeJS = `(function(){ | |||
| 387 | + | if (window.__dumberClipboardBridge) return; | |||
| 388 | + | window.__dumberClipboardBridge = true; | |||
| 389 | + | function sendToClipboard(text) { | |||
| 390 | + | if (!text) return; | |||
| 391 | + | var body = JSON.stringify({text: text}); | |||
| 392 | + | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |||
| 393 | + | if (!encoded) return; | |||
| 394 | + | fetch('dumb:///api/clipboard-set', { | |||
| 395 | + | method: 'POST', | |||
| 396 | + | headers: {'X-Dumber-Body': encoded} | |||
| 397 | + | }).catch(function(){}); | |||
| 398 | + | } | |||
| 399 | + | document.addEventListener('copy', function() { | |||
| 400 | + | var sel = window.getSelection(); | |||
| 401 | + | if (sel) sendToClipboard(sel.toString()); | |||
| 402 | + | }); | |||
| 403 | + | document.addEventListener('cut', function() { | |||
| 404 | + | var sel = window.getSelection(); | |||
| 405 | + | if (sel) sendToClipboard(sel.toString()); | |||
| 406 | + | }); | |||
| 407 | + | })();` | |||
| 408 | + | ||||
| 382 | // contentInjector implements port.ContentInjector for the CEF engine. | 409 | // contentInjector implements port.ContentInjector for the CEF engine. | ||
| 383 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | 410 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | ||
| 384 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | 411 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | ||
| @@ -503,6 +530,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 503 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | 530 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | ||
| 504 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | 531 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | ||
| 505 | 532 | ||||
| 533 | + | // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard). | |||
| 534 | + | wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS) | |||
| 535 | + | ||||
| 506 | // Video playback diagnostic — logs video element state changes. | 536 | // Video playback diagnostic — logs video element state changes. | ||
| 507 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | 537 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | ||
| 508 | if ci.videoDiagnosticsEnabled { | 538 | if ci.videoDiagnosticsEnabled { | ||
M internal/infrastructure/cef/engine_init.go +15 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/gdk" | |
| 14 | + | "github.com/bnema/puregotk/v4/glib" | |
| 14 | 15 | "github.com/rs/zerolog" | |
| 15 | 16 | ||
| 16 | 17 | "github.com/bnema/dumber/assets" | |
| @@ -181,6 +182,20 @@ func wireEngine( | |||
| 181 | 182 | schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder) | |
| 182 | 183 | schemeHandler.setAssets(assets.WebUIAssets) | |
| 183 | 184 | ||
| 185 | + | // Bridge clipboard writes from CEF JS → GDK system clipboard. | |
| 186 | + | // The callback is invoked on the CEF IO thread, so we schedule the | |
| 187 | + | // GDK write on the GTK main loop via glib.IdleAddOnce. | |
| 188 | + | schemeHandler.onClipboardSet = func(text string) { | |
| 189 | + | fn := glib.SourceOnceFunc(func(_ uintptr) { | |
| 190 | + | if display := gdk.DisplayGetDefault(); display != nil { | |
| 191 | + | if cb := display.GetClipboard(); cb != nil { | |
| 192 | + | cb.SetText(text) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | }) | |
| 196 | + | glib.IdleAddOnce(&fn, 0) | |
| 197 | + | } | |
| 198 | + | ||
| 184 | 199 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | |
| 185 | 200 | ||
| 186 | 201 | result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory) | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | purecef "github.com/bnema/purego-cef/cef" | 12 | purecef "github.com/bnema/purego-cef/cef" | ||
| 13 | "github.com/bnema/puregotk/v4/gdk" | 13 | "github.com/bnema/puregotk/v4/gdk" | ||
| 14 | + | "github.com/bnema/puregotk/v4/glib" | |||
| 14 | "github.com/rs/zerolog" | 15 | "github.com/rs/zerolog" | ||
| 15 | 16 | ||||
| 16 | "github.com/bnema/dumber/assets" | 17 | "github.com/bnema/dumber/assets" | ||
| @@ -181,6 +182,20 @@ func wireEngine( | |||||
| 181 | schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder) | 182 | schemeHandler := newDumbSchemeHandler(ctx, messageRouter, mediaTranscoder) | ||
| 182 | schemeHandler.setAssets(assets.WebUIAssets) | 183 | schemeHandler.setAssets(assets.WebUIAssets) | ||
| 183 | 184 | ||||
| 185 | + | // Bridge clipboard writes from CEF JS → GDK system clipboard. | |||
| 186 | + | // The callback is invoked on the CEF IO thread, so we schedule the | |||
| 187 | + | // GDK write on the GTK main loop via glib.IdleAddOnce. | |||
| 188 | + | schemeHandler.onClipboardSet = func(text string) { | |||
| 189 | + | fn := glib.SourceOnceFunc(func(_ uintptr) { | |||
| 190 | + | if display := gdk.DisplayGetDefault(); display != nil { | |||
| 191 | + | if cb := display.GetClipboard(); cb != nil { | |||
| 192 | + | cb.SetText(text) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | }) | |||
| 196 | + | glib.IdleAddOnce(&fn, 0) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 184 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | 199 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | ||
| 185 | 200 | ||||
| 186 | result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory) | 201 | result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory) | ||
M internal/infrastructure/cef/input_bridge.go +61 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | ||
| 6 | 6 | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | 7 | "github.com/bnema/puregotk/v4/gdk" | |
| 8 | + | "github.com/bnema/puregotk/v4/gio" | |
| 8 | 9 | "github.com/bnema/puregotk/v4/gtk" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -45,6 +46,11 @@ type inputBridge struct { | |||
| 45 | 46 | // carry their own coordinates from GDK. | |
| 46 | 47 | lastX, lastY float64 | |
| 47 | 48 | ||
| 49 | + | // GDK clipboard for paste support. CEF in OSR mode cannot access the | |
| 50 | + | // Wayland clipboard directly, so we read from GDK and inject via | |
| 51 | + | // ImeCommitText. Set once during attachTo, read-only afterwards. | |
| 52 | + | clipboard *gdk.Clipboard | |
| 53 | + | ||
| 48 | 54 | // IMContext for dead key / compose sequence support. Held as a field | |
| 49 | 55 | // to prevent garbage collection while the controller is alive. | |
| 50 | 56 | imContext *gtk.IMContextSimple | |
| @@ -70,6 +76,12 @@ func (ib *inputBridge) setHost(host purecef.BrowserHost) { | |||
| 70 | 76 | ||
| 71 | 77 | // attachTo creates GDK event controllers and connects them to the given GLArea. | |
| 72 | 78 | func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | |
| 79 | + | // Acquire GDK clipboard for paste support. CEF OSR can't access the | |
| 80 | + | // Wayland clipboard, so we bridge it via GDK → ImeCommitText. | |
| 81 | + | if display := gdk.DisplayGetDefault(); display != nil { | |
| 82 | + | ib.clipboard = display.GetClipboard() | |
| 83 | + | } | |
| 84 | + | ||
| 73 | 85 | // Motion (mouse move + enter/leave) | |
| 74 | 86 | motion := gtk.NewEventControllerMotion() | |
| 75 | 87 | motionCb := func(g gtk.EventControllerMotion, x, y float64) { | |
| @@ -162,6 +174,16 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 162 | 174 | ||
| 163 | 175 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 164 | 176 | mods := uint(state) | |
| 177 | + | ||
| 178 | + | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | |
| 179 | + | // Wayland/X11 clipboard, so we read from GDK and inject via | |
| 180 | + | // ImeCommitText instead of letting CEF handle the paste. | |
| 181 | + | if mods&uint(gdk.ControlMaskValue) != 0 && | |
| 182 | + | (keyval == gdkKeyLowercaseV || keyval == gdkKeyUppercaseV) { | |
| 183 | + | ib.pasteFromClipboard() | |
| 184 | + | return true | |
| 185 | + | } | |
| 186 | + | ||
| 165 | 187 | ib.onKeyPress(keyval, keycode, mods) | |
| 166 | 188 | ||
| 167 | 189 | // Let modifier combos and function keys propagate to the window's | |
| @@ -354,6 +376,41 @@ func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) { | |||
| 354 | 376 | host.SendKeyEvent(&evt) | |
| 355 | 377 | } | |
| 356 | 378 | ||
| 379 | + | // pasteFromClipboard reads text from the GDK clipboard asynchronously and | |
| 380 | + | // injects it into the focused CEF input via ImeCommitText. This bridges the | |
| 381 | + | // gap where CEF OSR mode cannot read the Wayland/X11 system clipboard. | |
| 382 | + | func (ib *inputBridge) pasteFromClipboard() { | |
| 383 | + | cb := ib.clipboard | |
| 384 | + | if cb == nil { | |
| 385 | + | return | |
| 386 | + | } | |
| 387 | + | ib.mu.Lock() | |
| 388 | + | host := ib.host | |
| 389 | + | ib.mu.Unlock() | |
| 390 | + | if host == nil { | |
| 391 | + | return | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | asyncCb := gio.AsyncReadyCallback(func(_, resultPtr, _ uintptr) { | |
| 395 | + | result := &gio.AsyncResultBase{} | |
| 396 | + | result.SetGoPointer(resultPtr) | |
| 397 | + | text, err := cb.ReadTextFinish(result) | |
| 398 | + | if err != nil || text == "" { | |
| 399 | + | return | |
| 400 | + | } | |
| 401 | + | // The GLib async callback runs on the main loop (UI thread), | |
| 402 | + | // which is the correct thread for CEF ImeCommitText. | |
| 403 | + | ib.mu.Lock() | |
| 404 | + | h := ib.host | |
| 405 | + | ib.mu.Unlock() | |
| 406 | + | if h != nil { | |
| 407 | + | h.ImeCommitText(text, nil, 0) | |
| 408 | + | } | |
| 409 | + | }) | |
| 410 | + | ||
| 411 | + | cb.ReadTextAsync(nil, &asyncCb, 0) | |
| 412 | + | } | |
| 413 | + | ||
| 357 | 414 | // --------------------------------------------------------------------------- | |
| 358 | 415 | // Translation helpers | |
| 359 | 416 | // --------------------------------------------------------------------------- | |
| @@ -436,6 +493,10 @@ const ( | |||
| 436 | 493 | gdkKeyEnd = 0xff57 | |
| 437 | 494 | gdkKeyPageUp = 0xff55 | |
| 438 | 495 | gdkKeyPageDown = 0xff56 | |
| 496 | + | ||
| 497 | + | // Clipboard shortcut keyvals. | |
| 498 | + | gdkKeyLowercaseV = 0x076 | |
| 499 | + | gdkKeyUppercaseV = 0x056 | |
| 439 | 500 | ) | |
| 440 | 501 | ||
| 441 | 502 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | 5 | ||||
| 6 | purecef "github.com/bnema/purego-cef/cef" | 6 | purecef "github.com/bnema/purego-cef/cef" | ||
| 7 | "github.com/bnema/puregotk/v4/gdk" | 7 | "github.com/bnema/puregotk/v4/gdk" | ||
| 8 | + | "github.com/bnema/puregotk/v4/gio" | |||
| 8 | "github.com/bnema/puregotk/v4/gtk" | 9 | "github.com/bnema/puregotk/v4/gtk" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -45,6 +46,11 @@ type inputBridge struct { | |||||
| 45 | // carry their own coordinates from GDK. | 46 | // carry their own coordinates from GDK. | ||
| 46 | lastX, lastY float64 | 47 | lastX, lastY float64 | ||
| 47 | 48 | ||||
| 49 | + | // GDK clipboard for paste support. CEF in OSR mode cannot access the | |||
| 50 | + | // Wayland clipboard directly, so we read from GDK and inject via | |||
| 51 | + | // ImeCommitText. Set once during attachTo, read-only afterwards. | |||
| 52 | + | clipboard *gdk.Clipboard | |||
| 53 | + | ||||
| 48 | // IMContext for dead key / compose sequence support. Held as a field | 54 | // IMContext for dead key / compose sequence support. Held as a field | ||
| 49 | // to prevent garbage collection while the controller is alive. | 55 | // to prevent garbage collection while the controller is alive. | ||
| 50 | imContext *gtk.IMContextSimple | 56 | imContext *gtk.IMContextSimple | ||
| @@ -70,6 +76,12 @@ func (ib *inputBridge) setHost(host purecef.BrowserHost) { | |||||
| 70 | 76 | ||||
| 71 | // attachTo creates GDK event controllers and connects them to the given GLArea. | 77 | // attachTo creates GDK event controllers and connects them to the given GLArea. | ||
| 72 | func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | 78 | func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | ||
| 79 | + | // Acquire GDK clipboard for paste support. CEF OSR can't access the | |||
| 80 | + | // Wayland clipboard, so we bridge it via GDK → ImeCommitText. | |||
| 81 | + | if display := gdk.DisplayGetDefault(); display != nil { | |||
| 82 | + | ib.clipboard = display.GetClipboard() | |||
| 83 | + | } | |||
| 84 | + | ||||
| 73 | // Motion (mouse move + enter/leave) | 85 | // Motion (mouse move + enter/leave) | ||
| 74 | motion := gtk.NewEventControllerMotion() | 86 | motion := gtk.NewEventControllerMotion() | ||
| 75 | motionCb := func(g gtk.EventControllerMotion, x, y float64) { | 87 | motionCb := func(g gtk.EventControllerMotion, x, y float64) { | ||
| @@ -162,6 +174,16 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||||
| 162 | 174 | ||||
| 163 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | 175 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 164 | mods := uint(state) | 176 | mods := uint(state) | ||
| 177 | + | ||||
| 178 | + | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | |||
| 179 | + | // Wayland/X11 clipboard, so we read from GDK and inject via | |||
| 180 | + | // ImeCommitText instead of letting CEF handle the paste. | |||
| 181 | + | if mods&uint(gdk.ControlMaskValue) != 0 && | |||
| 182 | + | (keyval == gdkKeyLowercaseV || keyval == gdkKeyUppercaseV) { | |||
| 183 | + | ib.pasteFromClipboard() | |||
| 184 | + | return true | |||
| 185 | + | } | |||
| 186 | + | ||||
| 165 | ib.onKeyPress(keyval, keycode, mods) | 187 | ib.onKeyPress(keyval, keycode, mods) | ||
| 166 | 188 | ||||
| 167 | // Let modifier combos and function keys propagate to the window's | 189 | // Let modifier combos and function keys propagate to the window's | ||
| @@ -354,6 +376,41 @@ func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) { | |||||
| 354 | host.SendKeyEvent(&evt) | 376 | host.SendKeyEvent(&evt) | ||
| 355 | } | 377 | } | ||
| 356 | 378 | ||||
| 379 | + | // pasteFromClipboard reads text from the GDK clipboard asynchronously and | |||
| 380 | + | // injects it into the focused CEF input via ImeCommitText. This bridges the | |||
| 381 | + | // gap where CEF OSR mode cannot read the Wayland/X11 system clipboard. | |||
| 382 | + | func (ib *inputBridge) pasteFromClipboard() { | |||
| 383 | + | cb := ib.clipboard | |||
| 384 | + | if cb == nil { | |||
| 385 | + | return | |||
| 386 | + | } | |||
| 387 | + | ib.mu.Lock() | |||
| 388 | + | host := ib.host | |||
| 389 | + | ib.mu.Unlock() | |||
| 390 | + | if host == nil { | |||
| 391 | + | return | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | asyncCb := gio.AsyncReadyCallback(func(_, resultPtr, _ uintptr) { | |||
| 395 | + | result := &gio.AsyncResultBase{} | |||
| 396 | + | result.SetGoPointer(resultPtr) | |||
| 397 | + | text, err := cb.ReadTextFinish(result) | |||
| 398 | + | if err != nil || text == "" { | |||
| 399 | + | return | |||
| 400 | + | } | |||
| 401 | + | // The GLib async callback runs on the main loop (UI thread), | |||
| 402 | + | // which is the correct thread for CEF ImeCommitText. | |||
| 403 | + | ib.mu.Lock() | |||
| 404 | + | h := ib.host | |||
| 405 | + | ib.mu.Unlock() | |||
| 406 | + | if h != nil { | |||
| 407 | + | h.ImeCommitText(text, nil, 0) | |||
| 408 | + | } | |||
| 409 | + | }) | |||
| 410 | + | ||||
| 411 | + | cb.ReadTextAsync(nil, &asyncCb, 0) | |||
| 412 | + | } | |||
| 413 | + | ||||
| 357 | // --------------------------------------------------------------------------- | 414 | // --------------------------------------------------------------------------- | ||
| 358 | // Translation helpers | 415 | // Translation helpers | ||
| 359 | // --------------------------------------------------------------------------- | 416 | // --------------------------------------------------------------------------- | ||
| @@ -436,6 +493,10 @@ const ( | |||||
| 436 | gdkKeyEnd = 0xff57 | 493 | gdkKeyEnd = 0xff57 | ||
| 437 | gdkKeyPageUp = 0xff55 | 494 | gdkKeyPageUp = 0xff55 | ||
| 438 | gdkKeyPageDown = 0xff56 | 495 | gdkKeyPageDown = 0xff56 | ||
| 496 | + | ||||
| 497 | + | // Clipboard shortcut keyvals. | |||
| 498 | + | gdkKeyLowercaseV = 0x076 | |||
| 499 | + | gdkKeyUppercaseV = 0x056 | |||
| 439 | ) | 500 | ) | ||
| 440 | 501 | ||||
| 441 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | 502 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | ||
M internal/infrastructure/cef/scheme_handler.go +29 -0
| @@ -47,6 +47,10 @@ type dumbSchemeHandler struct { | |||
| 47 | 47 | logger zerolog.Logger | |
| 48 | 48 | hwSurveyor *env.HardwareSurveyor | |
| 49 | 49 | mu sync.RWMutex | |
| 50 | + | ||
| 51 | + | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | |
| 52 | + | // Set by the engine to write to the GDK system clipboard. | |
| 53 | + | onClipboardSet func(text string) | |
| 50 | 54 | } | |
| 51 | 55 | ||
| 52 | 56 | // newDumbSchemeHandler creates a handler for internal CEF pages. | |
| @@ -136,6 +140,9 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||
| 136 | 140 | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | |
| 137 | 141 | return h.handleTranscodeAPI(request) | |
| 138 | 142 | ||
| 143 | + | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | |
| 144 | + | return h.handleClipboardSet(request) | |
| 145 | + | ||
| 139 | 146 | default: | |
| 140 | 147 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | |
| 141 | 148 | } | |
| @@ -207,6 +214,28 @@ func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.Resource | |||
| 207 | 214 | return h.newRawResourceHandler(http.StatusOK, "application/json", data) | |
| 208 | 215 | } | |
| 209 | 216 | ||
| 217 | + | // handleClipboardSet receives copied text from JS copy/cut events and writes | |
| 218 | + | // it to the system clipboard via the engine callback. | |
| 219 | + | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler { | |
| 220 | + | body := readBodyFromHeader(request) | |
| 221 | + | if body == nil { | |
| 222 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | var payload struct { | |
| 226 | + | Text string `json:"text"` | |
| 227 | + | } | |
| 228 | + | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | |
| 229 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | if h.onClipboardSet != nil { | |
| 233 | + | h.onClipboardSet(payload.Text) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | return h.newJSONResourceHandler(http.StatusOK, map[string]string{"ok": "true"}) | |
| 237 | + | } | |
| 238 | + | ||
| 210 | 239 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | |
| 211 | 240 | return purecef.NewResourceHandler(&staticResourceHandler{ | |
| 212 | 241 | contentType: "text/plain; charset=utf-8", | |
| @@ -47,6 +47,10 @@ type dumbSchemeHandler struct { | |||||
| 47 | logger zerolog.Logger | 47 | logger zerolog.Logger | ||
| 48 | hwSurveyor *env.HardwareSurveyor | 48 | hwSurveyor *env.HardwareSurveyor | ||
| 49 | mu sync.RWMutex | 49 | mu sync.RWMutex | ||
| 50 | + | ||||
| 51 | + | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | |||
| 52 | + | // Set by the engine to write to the GDK system clipboard. | |||
| 53 | + | onClipboardSet func(text string) | |||
| 50 | } | 54 | } | ||
| 51 | 55 | ||||
| 52 | // newDumbSchemeHandler creates a handler for internal CEF pages. | 56 | // newDumbSchemeHandler creates a handler for internal CEF pages. | ||
| @@ -136,6 +140,9 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||||
| 136 | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | 140 | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | ||
| 137 | return h.handleTranscodeAPI(request) | 141 | return h.handleTranscodeAPI(request) | ||
| 138 | 142 | ||||
| 143 | + | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | |||
| 144 | + | return h.handleClipboardSet(request) | |||
| 145 | + | ||||
| 139 | default: | 146 | default: | ||
| 140 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | 147 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | ||
| 141 | } | 148 | } | ||
| @@ -207,6 +214,28 @@ func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.Resource | |||||
| 207 | return h.newRawResourceHandler(http.StatusOK, "application/json", data) | 214 | return h.newRawResourceHandler(http.StatusOK, "application/json", data) | ||
| 208 | } | 215 | } | ||
| 209 | 216 | ||||
| 217 | + | // handleClipboardSet receives copied text from JS copy/cut events and writes | |||
| 218 | + | // it to the system clipboard via the engine callback. | |||
| 219 | + | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler { | |||
| 220 | + | body := readBodyFromHeader(request) | |||
| 221 | + | if body == nil { | |||
| 222 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | var payload struct { | |||
| 226 | + | Text string `json:"text"` | |||
| 227 | + | } | |||
| 228 | + | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | |||
| 229 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | if h.onClipboardSet != nil { | |||
| 233 | + | h.onClipboardSet(payload.Text) | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | return h.newJSONResourceHandler(http.StatusOK, map[string]string{"ok": "true"}) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 210 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | 239 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | ||
| 211 | return purecef.NewResourceHandler(&staticResourceHandler{ | 240 | return purecef.NewResourceHandler(&staticResourceHandler{ | ||
| 212 | contentType: "text/plain; charset=utf-8", | 241 | contentType: "text/plain; charset=utf-8", | ||