detached 2a3f82d

fix(clipboard): harden bridges and align engine behavior

commit
2a3f82dde03f28279c2e6824c6e0e60d636942d4
author
brice · · 2026-04-17 15:12 UTC
parent
ee4aed5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
20 files · +499 -185
M go.mod +1 -3
@@ -4,7 +4,7 @@ go 1.26.1
44
55 require (
66 github.com/bnema/purego v0.11.0-bnema.2
7- github.com/bnema/purego-cef v0.8.2
7+ github.com/bnema/purego-cef v0.9.0
88 github.com/bnema/purego-ffmpeg v0.3.1
99 github.com/bnema/purego-pipewire v0.1.2
1010 github.com/bnema/purego-sqlite v0.1.2
@@ -69,5 +69,3 @@ require (
6969 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
7070 modernc.org/sqlite v1.47.0 // indirect
7171 )
72-
73-replace github.com/bnema/purego-cef => ../purego-cef
@@ -4,7 +4,7 @@ go 1.26.1
4 4
5 require (5 require (
6 github.com/bnema/purego v0.11.0-bnema.26 github.com/bnema/purego v0.11.0-bnema.2
7- github.com/bnema/purego-cef v0.8.27+ github.com/bnema/purego-cef v0.9.0
8 github.com/bnema/purego-ffmpeg v0.3.18 github.com/bnema/purego-ffmpeg v0.3.1
9 github.com/bnema/purego-pipewire v0.1.29 github.com/bnema/purego-pipewire v0.1.2
10 github.com/bnema/purego-sqlite v0.1.210 github.com/bnema/purego-sqlite v0.1.2
@@ -69,5 +69,3 @@ require (
69 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect69 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
70 modernc.org/sqlite v1.47.0 // indirect70 modernc.org/sqlite v1.47.0 // indirect
71 )71 )
72-
73-replace github.com/bnema/purego-cef => ../purego-cef
M go.sum +2 -2 generated file: collapsed
M internal/application/port/clipboard_events.go +5 -4
@@ -21,10 +21,11 @@ type SelectionClipboardInput struct {
2121
2222 // ExplicitClipboardInput carries explicit copy requests.
2323 type ExplicitClipboardInput struct {
24- Text string
25- SourceEngine ClipboardSource
26- ViewID WebViewID
27- Action string
24+ Text string
25+ SourceEngine ClipboardSource
26+ ViewID WebViewID
27+ Action string
28+ NativeHandled bool
2829 }
2930
3031 // ClipboardTextOrchestrator coordinates shared clipboard behavior.
@@ -21,10 +21,11 @@ type SelectionClipboardInput struct {
21 21
22 // ExplicitClipboardInput carries explicit copy requests.22 // ExplicitClipboardInput carries explicit copy requests.
23 type ExplicitClipboardInput struct {23 type ExplicitClipboardInput struct {
24- Text string24+ Text string
25- SourceEngine ClipboardSource25+ SourceEngine ClipboardSource
26- ViewID WebViewID26+ ViewID WebViewID
27- Action string27+ Action string
28+ NativeHandled bool
28 }29 }
29 30
30 // ClipboardTextOrchestrator coordinates shared clipboard behavior.31 // ClipboardTextOrchestrator coordinates shared clipboard behavior.
M internal/application/usecase/clipboard_text_orchestrator.go +19 -13
@@ -165,19 +165,21 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
165165 uc.mu.Unlock()
166166 return nil
167167 }
168- if uc.clipboard == nil {
168+ if !input.NativeHandled && uc.clipboard == nil {
169169 uc.mu.Unlock()
170170 return fmt.Errorf("clipboard not available")
171171 }
172- if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
173- uc.mu.Unlock()
174- logging.FromContext(ctx).Debug().
175- Err(err).
176- Int("text_len", textLen).
177- Str("source_engine", string(input.SourceEngine)).
178- Str("action", input.Action).
179- Msg("clipboard explicit write failed")
180- return fmt.Errorf("clipboard write failed: %w", err)
172+ if !input.NativeHandled {
173+ if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
174+ uc.mu.Unlock()
175+ logging.FromContext(ctx).Debug().
176+ Err(err).
177+ Int("text_len", textLen).
178+ Str("source_engine", string(input.SourceEngine)).
179+ Str("action", input.Action).
180+ Msg("clipboard explicit write failed")
181+ return fmt.Errorf("clipboard write failed: %w", err)
182+ }
181183 }
182184
183185 uc.lastExplicit[scope] = explicitCopyState{text: input.Text, at: currentTime}
@@ -186,11 +188,15 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
186188 if uc.toast != nil {
187189 uc.toast(textLen)
188190 }
189- logging.FromContext(ctx).Debug().
191+ logEntry := logging.FromContext(ctx).Debug().
190192 Int("text_len", textLen).
191193 Str("source_engine", string(input.SourceEngine)).
192- Str("action", input.Action).
193- Msg("clipboard explicit copied")
194+ Str("action", input.Action)
195+ if input.NativeHandled {
196+ logEntry.Bool("native_handled", true).Msg("clipboard explicit copied (native handled)")
197+ return nil
198+ }
199+ logEntry.Msg("clipboard explicit copied")
194200 return nil
195201 }
196202
@@ -165,19 +165,21 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
165 uc.mu.Unlock()165 uc.mu.Unlock()
166 return nil166 return nil
167 }167 }
168- if uc.clipboard == nil {168+ if !input.NativeHandled && uc.clipboard == nil {
169 uc.mu.Unlock()169 uc.mu.Unlock()
170 return fmt.Errorf("clipboard not available")170 return fmt.Errorf("clipboard not available")
171 }171 }
172- if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {172+ if !input.NativeHandled {
173- uc.mu.Unlock()173+ if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
174- logging.FromContext(ctx).Debug().174+ uc.mu.Unlock()
175- Err(err).175+ logging.FromContext(ctx).Debug().
176- Int("text_len", textLen).176+ Err(err).
177- Str("source_engine", string(input.SourceEngine)).177+ Int("text_len", textLen).
178- Str("action", input.Action).178+ Str("source_engine", string(input.SourceEngine)).
179- Msg("clipboard explicit write failed")179+ Str("action", input.Action).
180- return fmt.Errorf("clipboard write failed: %w", err)180+ Msg("clipboard explicit write failed")
181+ return fmt.Errorf("clipboard write failed: %w", err)
182+ }
181 }183 }
182 184
183 uc.lastExplicit[scope] = explicitCopyState{text: input.Text, at: currentTime}185 uc.lastExplicit[scope] = explicitCopyState{text: input.Text, at: currentTime}
@@ -186,11 +188,15 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
186 if uc.toast != nil {188 if uc.toast != nil {
187 uc.toast(textLen)189 uc.toast(textLen)
188 }190 }
189- logging.FromContext(ctx).Debug().191+ logEntry := logging.FromContext(ctx).Debug().
190 Int("text_len", textLen).192 Int("text_len", textLen).
191 Str("source_engine", string(input.SourceEngine)).193 Str("source_engine", string(input.SourceEngine)).
192- Str("action", input.Action).194+ Str("action", input.Action)
193- Msg("clipboard explicit copied")195+ if input.NativeHandled {
196+ logEntry.Bool("native_handled", true).Msg("clipboard explicit copied (native handled)")
197+ return nil
198+ }
199+ logEntry.Msg("clipboard explicit copied")
194 return nil200 return nil
195 }201 }
196 202
M internal/application/usecase/clipboard_text_orchestrator_test.go +48 -0
@@ -19,6 +19,11 @@ type blockingClipboard struct {
1919 writes []string
2020 }
2121
22+type countingClipboard struct {
23+ mu sync.Mutex
24+ writes int
25+}
26+
2227 type staticAutoCopyConfig struct {
2328 enabled bool
2429 }
@@ -52,6 +57,24 @@ func (b *blockingClipboard) writeCount() int {
5257 return len(b.writes)
5358 }
5459
60+func (c *countingClipboard) WriteText(_ context.Context, _ string) error {
61+ c.mu.Lock()
62+ c.writes++
63+ c.mu.Unlock()
64+ return nil
65+}
66+
67+func (*countingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil }
68+func (*countingClipboard) ReadText(context.Context) (string, error) { return "", nil }
69+func (*countingClipboard) Clear(context.Context) error { return nil }
70+func (*countingClipboard) HasText(context.Context) (bool, error) { return false, nil }
71+
72+func (c *countingClipboard) writeCount() int {
73+ c.mu.Lock()
74+ defer c.mu.Unlock()
75+ return c.writes
76+}
77+
5578 func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) {
5679 ctx := context.Background()
5780 clipboard := portmocks.NewMockClipboard(t)
@@ -170,6 +193,31 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPaylo
170193 }
171194 }
172195
196+func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendWrite(t *testing.T) {
197+ ctx := context.Background()
198+ clipboard := &countingClipboard{}
199+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
200+ var toastCalls []int
201+
202+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
203+ toastCalls = append(toastCalls, textLen)
204+ })
205+
206+ if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil {
207+ t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
208+ }
209+
210+ if got := clipboard.writeCount(); got != 0 {
211+ t.Fatalf("clipboard writes = %d, want 0", got)
212+ }
213+ if len(toastCalls) != 1 || toastCalls[0] != 6 {
214+ t.Fatalf("toast calls = %v, want [6]", toastCalls)
215+ }
216+ if got := len(uc.lastExplicit); got != 1 {
217+ t.Fatalf("lastExplicit entries = %d, want 1", got)
218+ }
219+}
220+
173221 func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) {
174222 ctx := context.Background()
175223 clipboard := portmocks.NewMockClipboard(t)
@@ -19,6 +19,11 @@ type blockingClipboard struct {
19 writes []string19 writes []string
20 }20 }
21 21
22+type countingClipboard struct {
23+ mu sync.Mutex
24+ writes int
25+}
26+
22 type staticAutoCopyConfig struct {27 type staticAutoCopyConfig struct {
23 enabled bool28 enabled bool
24 }29 }
@@ -52,6 +57,24 @@ func (b *blockingClipboard) writeCount() int {
52 return len(b.writes)57 return len(b.writes)
53 }58 }
54 59
60+func (c *countingClipboard) WriteText(_ context.Context, _ string) error {
61+ c.mu.Lock()
62+ c.writes++
63+ c.mu.Unlock()
64+ return nil
65+}
66+
67+func (*countingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil }
68+func (*countingClipboard) ReadText(context.Context) (string, error) { return "", nil }
69+func (*countingClipboard) Clear(context.Context) error { return nil }
70+func (*countingClipboard) HasText(context.Context) (bool, error) { return false, nil }
71+
72+func (c *countingClipboard) writeCount() int {
73+ c.mu.Lock()
74+ defer c.mu.Unlock()
75+ return c.writes
76+}
77+
55 func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) {78 func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) {
56 ctx := context.Background()79 ctx := context.Background()
57 clipboard := portmocks.NewMockClipboard(t)80 clipboard := portmocks.NewMockClipboard(t)
@@ -170,6 +193,31 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPaylo
170 }193 }
171 }194 }
172 195
196+func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendWrite(t *testing.T) {
197+ ctx := context.Background()
198+ clipboard := &countingClipboard{}
199+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
200+ var toastCalls []int
201+
202+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
203+ toastCalls = append(toastCalls, textLen)
204+ })
205+
206+ if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil {
207+ t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
208+ }
209+
210+ if got := clipboard.writeCount(); got != 0 {
211+ t.Fatalf("clipboard writes = %d, want 0", got)
212+ }
213+ if len(toastCalls) != 1 || toastCalls[0] != 6 {
214+ t.Fatalf("toast calls = %v, want [6]", toastCalls)
215+ }
216+ if got := len(uc.lastExplicit); got != 1 {
217+ t.Fatalf("lastExplicit entries = %d, want 1", got)
218+ }
219+}
220+
173 func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) {221 func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) {
174 ctx := context.Background()222 ctx := context.Background()
175 clipboard := portmocks.NewMockClipboard(t)223 clipboard := portmocks.NewMockClipboard(t)
M internal/infrastructure/cef/context_menu.go +9 -16
@@ -82,15 +82,11 @@ func (h *handlerSet) RunContextMenu(
8282 x,
8383 y,
8484 func(item port.MenuItem) {
85- var explicitCopyCallback func(string)
8685 var copiedCallback func(string)
8786 if h.wv.engine != nil {
88- explicitCopyCallback = func(text string) {
89- h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, "copy", text)
90- }
9187 copiedCallback = h.wv.engine.notifyClipboardCopied
9288 }
93- dispatchContextMenuSelection(h.wv.ctx, executor, callback, explicitCopyCallback, copiedCallback, commandIDByAction, item, menuContext)
89+ dispatchContextMenuSelection(h.wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext)
9490 },
9591 func() {
9692 callback.Cancel()
@@ -170,7 +166,6 @@ func dispatchContextMenuSelection(
170166 ctx context.Context,
171167 executor port.ContextMenuActionExecutor,
172168 callback purecef.RunContextMenuCallback,
173- explicitCopyCallback func(text string),
174169 copiedCallback func(text string),
175170 commandIDByAction map[port.MenuAction]int32,
176171 item port.MenuItem,
@@ -181,9 +176,13 @@ func dispatchContextMenuSelection(
181176 return
182177 }
183178 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
184- if item.Action == port.MenuActionCopySelection && menuContext.SelectionText != "" && explicitCopyCallback != nil {
185- explicitCopyCallback(menuContext.SelectionText)
186- log.Debug().Str("action", string(item.Action)).Msg("cef: context menu selection copied via shared explicit path")
179+ if item.Action == port.MenuActionCopySelection {
180+ if cmdID, ok := commandIDByAction[item.Action]; ok {
181+ log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
182+ callback.Cont(cmdID, 0)
183+ return
184+ }
185+ log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
187186 callback.Cancel()
188187 return
189188 }
@@ -213,11 +212,6 @@ func dispatchContextMenuSelection(
213212
214213 func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {
215214 switch action {
216- case port.MenuActionCopySelection:
217- if menuContext.SelectionText != "" {
218- return menuContext.SelectionText
219- }
220- return ""
221215 case port.MenuActionCopyLink:
222216 return menuContext.LinkURI
223217 default:
@@ -233,8 +227,7 @@ func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
233227 port.MenuActionOpenLinkNewTab,
234228 port.MenuActionCopyLink,
235229 port.MenuActionCopyImage,
236- port.MenuActionInspectElement,
237- port.MenuActionCopySelection:
230+ port.MenuActionInspectElement:
238231 return true
239232 default:
240233 return false
@@ -82,15 +82,11 @@ func (h *handlerSet) RunContextMenu(
82 x,82 x,
83 y,83 y,
84 func(item port.MenuItem) {84 func(item port.MenuItem) {
85- var explicitCopyCallback func(string)
86 var copiedCallback func(string)85 var copiedCallback func(string)
87 if h.wv.engine != nil {86 if h.wv.engine != nil {
88- explicitCopyCallback = func(text string) {
89- h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, "copy", text)
90- }
91 copiedCallback = h.wv.engine.notifyClipboardCopied87 copiedCallback = h.wv.engine.notifyClipboardCopied
92 }88 }
93- dispatchContextMenuSelection(h.wv.ctx, executor, callback, explicitCopyCallback, copiedCallback, commandIDByAction, item, menuContext)89+ dispatchContextMenuSelection(h.wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext)
94 },90 },
95 func() {91 func() {
96 callback.Cancel()92 callback.Cancel()
@@ -170,7 +166,6 @@ func dispatchContextMenuSelection(
170 ctx context.Context,166 ctx context.Context,
171 executor port.ContextMenuActionExecutor,167 executor port.ContextMenuActionExecutor,
172 callback purecef.RunContextMenuCallback,168 callback purecef.RunContextMenuCallback,
173- explicitCopyCallback func(text string),
174 copiedCallback func(text string),169 copiedCallback func(text string),
175 commandIDByAction map[port.MenuAction]int32,170 commandIDByAction map[port.MenuAction]int32,
176 item port.MenuItem,171 item port.MenuItem,
@@ -181,9 +176,13 @@ func dispatchContextMenuSelection(
181 return176 return
182 }177 }
183 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")178 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
184- if item.Action == port.MenuActionCopySelection && menuContext.SelectionText != "" && explicitCopyCallback != nil {179+ if item.Action == port.MenuActionCopySelection {
185- explicitCopyCallback(menuContext.SelectionText)180+ if cmdID, ok := commandIDByAction[item.Action]; ok {
186- log.Debug().Str("action", string(item.Action)).Msg("cef: context menu selection copied via shared explicit path")181+ log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
182+ callback.Cont(cmdID, 0)
183+ return
184+ }
185+ log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
187 callback.Cancel()186 callback.Cancel()
188 return187 return
189 }188 }
@@ -213,11 +212,6 @@ func dispatchContextMenuSelection(
213 212
214 func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {213 func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {
215 switch action {214 switch action {
216- case port.MenuActionCopySelection:
217- if menuContext.SelectionText != "" {
218- return menuContext.SelectionText
219- }
220- return ""
221 case port.MenuActionCopyLink:215 case port.MenuActionCopyLink:
222 return menuContext.LinkURI216 return menuContext.LinkURI
223 default:217 default:
@@ -233,8 +227,7 @@ func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
233 port.MenuActionOpenLinkNewTab,227 port.MenuActionOpenLinkNewTab,
234 port.MenuActionCopyLink,228 port.MenuActionCopyLink,
235 port.MenuActionCopyImage,229 port.MenuActionCopyImage,
236- port.MenuActionInspectElement,230+ port.MenuActionInspectElement:
237- port.MenuActionCopySelection:
238 return true231 return true
239 default:232 default:
240 return false233 return false
M internal/infrastructure/cef/context_menu_adapter_test.go +19 -29
@@ -105,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() {
105105 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
106106 callback := &stubRunContextMenuCallback{}
107107
108- dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{
108+ dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{
109109 port.MenuActionReload: 102,
110110 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
111111
@@ -116,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
116116 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
117117 callback := &stubRunContextMenuCallback{}
118118
119- dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{
119+ dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{
120120 port.MenuActionInspectElement: 204,
121121 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
122122
@@ -135,7 +135,6 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin
135135 executor,
136136 callback,
137137 nil,
138- nil,
139138 map[port.MenuAction]int32{port.MenuActionReload: 102},
140139 port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},
141140 menuContext,
@@ -159,7 +158,6 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
159158 executor,
160159 callback,
161160 nil,
162- nil,
163161 map[port.MenuAction]int32{},
164162 port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
165163 menuContext,
@@ -173,34 +171,26 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
173171 require.Equal(t, 1, callback.cancelCalls)
174172 }
175173
176-func TestContextMenuSelectionRoutesSelectionCopyThroughSharedExplicitPath(t *testing.T) {
174+func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) {
177175 callback := &stubRunContextMenuCallback{}
178176 executor := &stubContextMenuExecutor{}
179177 var copiedLens []int
180- var explicitCopies []string
181-
182- for _, tc := range []struct {
183- action port.MenuAction
184- text string
185- }{
186- {action: port.MenuActionCopySelection, text: "selected text"},
187- {action: port.MenuActionCopySelection, text: ""},
188- {action: port.MenuActionCopyLink, text: "https://example.com/link"},
189- } {
190- dispatchContextMenuSelection(
191- context.Background(),
192- executor,
193- callback,
194- func(text string) { explicitCopies = append(explicitCopies, text) },
195- func(text string) { copiedLens = append(copiedLens, len(text)) },
196- map[port.MenuAction]int32{},
197- port.MenuItem{Action: tc.action, Label: "Copy"},
198- port.MenuContext{SelectionText: tc.text, LinkURI: tc.text},
199- )
200- }
201-
202- require.Equal(t, []string{"selected text"}, explicitCopies)
203- require.Equal(t, []int{24}, copiedLens)
178+
179+ dispatchContextMenuSelection(
180+ context.Background(),
181+ executor,
182+ callback,
183+ func(text string) { copiedLens = append(copiedLens, len(text)) },
184+ map[port.MenuAction]int32{port.MenuActionCopySelection: 333},
185+ port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},
186+ port.MenuContext{SelectionText: "selected text", HasSelection: true},
187+ )
188+
189+ require.Equal(t, 1, callback.contCalls)
190+ require.Zero(t, callback.cancelCalls)
191+ require.Equal(t, int32(333), callback.commandID)
192+ require.Zero(t, executor.executeCalls)
193+ require.Empty(t, copiedLens)
204194 }
205195
206196 func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
@@ -105,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() {
105 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {105 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
106 callback := &stubRunContextMenuCallback{}106 callback := &stubRunContextMenuCallback{}
107 107
108- dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{108+ dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{
109 port.MenuActionReload: 102,109 port.MenuActionReload: 102,
110 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})110 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
111 111
@@ -116,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
116 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {116 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
117 callback := &stubRunContextMenuCallback{}117 callback := &stubRunContextMenuCallback{}
118 118
119- dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{119+ dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{
120 port.MenuActionInspectElement: 204,120 port.MenuActionInspectElement: 204,
121 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})121 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
122 122
@@ -135,7 +135,6 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin
135 executor,135 executor,
136 callback,136 callback,
137 nil,137 nil,
138- nil,
139 map[port.MenuAction]int32{port.MenuActionReload: 102},138 map[port.MenuAction]int32{port.MenuActionReload: 102},
140 port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},139 port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},
141 menuContext,140 menuContext,
@@ -159,7 +158,6 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
159 executor,158 executor,
160 callback,159 callback,
161 nil,160 nil,
162- nil,
163 map[port.MenuAction]int32{},161 map[port.MenuAction]int32{},
164 port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},162 port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
165 menuContext,163 menuContext,
@@ -173,34 +171,26 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
173 require.Equal(t, 1, callback.cancelCalls)171 require.Equal(t, 1, callback.cancelCalls)
174 }172 }
175 173
176-func TestContextMenuSelectionRoutesSelectionCopyThroughSharedExplicitPath(t *testing.T) {174+func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) {
177 callback := &stubRunContextMenuCallback{}175 callback := &stubRunContextMenuCallback{}
178 executor := &stubContextMenuExecutor{}176 executor := &stubContextMenuExecutor{}
179 var copiedLens []int177 var copiedLens []int
180- var explicitCopies []string178+
181-179+ dispatchContextMenuSelection(
182- for _, tc := range []struct {180+ context.Background(),
183- action port.MenuAction181+ executor,
184- text string182+ callback,
185- }{183+ func(text string) { copiedLens = append(copiedLens, len(text)) },
186- {action: port.MenuActionCopySelection, text: "selected text"},184+ map[port.MenuAction]int32{port.MenuActionCopySelection: 333},
187- {action: port.MenuActionCopySelection, text: ""},185+ port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},
188- {action: port.MenuActionCopyLink, text: "https://example.com/link"},186+ port.MenuContext{SelectionText: "selected text", HasSelection: true},
189- } {187+ )
190- dispatchContextMenuSelection(188+
191- context.Background(),189+ require.Equal(t, 1, callback.contCalls)
192- executor,190+ require.Zero(t, callback.cancelCalls)
193- callback,191+ require.Equal(t, int32(333), callback.commandID)
194- func(text string) { explicitCopies = append(explicitCopies, text) },192+ require.Zero(t, executor.executeCalls)
195- func(text string) { copiedLens = append(copiedLens, len(text)) },193+ require.Empty(t, copiedLens)
196- map[port.MenuAction]int32{},
197- port.MenuItem{Action: tc.action, Label: "Copy"},
198- port.MenuContext{SelectionText: tc.text, LinkURI: tc.text},
199- )
200- }
201-
202- require.Equal(t, []string{"selected text"}, explicitCopies)
203- require.Equal(t, []int{24}, copiedLens)
204 }194 }
205 195
206 func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {196 func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
M internal/infrastructure/cef/engine.go +5 -8
@@ -286,10 +286,6 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
286286 return nil
287287 }
288288
289-func (e *Engine) handleClipboardBridgeText(viewID port.WebViewID, text string) {
290- e.handleExplicitClipboardBridgeText(viewID, "copy", text)
291-}
292-
293289 func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) {
294290 if e == nil || text == "" {
295291 return
@@ -302,10 +298,11 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
302298 }
303299 textLen := utf8.RuneCountInString(text)
304300 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{
305- Text: text,
306- Action: action,
307- SourceEngine: port.ClipboardSourceCEF,
308- ViewID: viewID,
301+ Text: text,
302+ Action: action,
303+ SourceEngine: port.ClipboardSourceCEF,
304+ ViewID: viewID,
305+ NativeHandled: true,
309306 }); err != nil {
310307 logging.FromContext(e.currentContext()).Debug().
311308 Err(err).
@@ -286,10 +286,6 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
286 return nil286 return nil
287 }287 }
288 288
289-func (e *Engine) handleClipboardBridgeText(viewID port.WebViewID, text string) {
290- e.handleExplicitClipboardBridgeText(viewID, "copy", text)
291-}
292-
293 func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) {289 func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) {
294 if e == nil || text == "" {290 if e == nil || text == "" {
295 return291 return
@@ -302,10 +298,11 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
302 }298 }
303 textLen := utf8.RuneCountInString(text)299 textLen := utf8.RuneCountInString(text)
304 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{300 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{
305- Text: text,301+ Text: text,
306- Action: action,302+ Action: action,
307- SourceEngine: port.ClipboardSourceCEF,303+ SourceEngine: port.ClipboardSourceCEF,
308- ViewID: viewID,304+ ViewID: viewID,
305+ NativeHandled: true,
309 }); err != nil {306 }); err != nil {
310 logging.FromContext(e.currentContext()).Debug().307 logging.FromContext(e.currentContext()).Debug().
311 Err(err).308 Err(err).
M internal/infrastructure/cef/engine_init.go +0 -5
@@ -252,11 +252,6 @@ func wireEngine(
252252 }
253253 schemeHandler.setAssets(assets.WebUIAssets)
254254
255- // Route scheme-handler clipboard/focus callbacks through the same engine-side
256- // bridge handlers used by the native renderer bridge.
257- schemeHandler.onClipboardSet = func(text string) {
258- eng.handleClipboardBridgeText(0, text)
259- }
260255 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
261256
262257 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
@@ -252,11 +252,6 @@ func wireEngine(
252 }252 }
253 schemeHandler.setAssets(assets.WebUIAssets)253 schemeHandler.setAssets(assets.WebUIAssets)
254 254
255- // Route scheme-handler clipboard/focus callbacks through the same engine-side
256- // bridge handlers used by the native renderer bridge.
257- schemeHandler.onClipboardSet = func(text string) {
258- eng.handleClipboardBridgeText(0, text)
259- }
260 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge255 schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
261 256
262 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)257 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
M internal/infrastructure/cef/engine_runtime_test.go +1 -1
@@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
8585
8686 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
8787
88- require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit)
88+ require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: true}, orchestrator.explicit)
8989 }
9090
9191 type stubClipboardTextOrchestrator struct{}
@@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
85 85
86 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")86 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
87 87
88- require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit)88+ require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: true}, orchestrator.explicit)
89 }89 }
90 90
91 type stubClipboardTextOrchestrator struct{}91 type stubClipboardTextOrchestrator struct{}
M internal/infrastructure/cef/renderer_bridge.go +27 -6
@@ -13,13 +13,16 @@ import (
1313 )
1414
1515 const (
16- rendererBridgeMessageName = "dumber.renderer_bridge"
17- rendererBridgeActionExplicitTextCopy = "explicit_text_copy"
18- rendererBridgeActionFocusSync = "focus_sync"
19- rendererBridgeActionReady = "bridge_ready"
20- rendererBridgeExtensionName = "dumber.renderer_bridge"
16+ rendererBridgeMessageName = "dumber.renderer_bridge"
17+ rendererBridgeActionExplicitTextCopy = "explicit_text_copy"
18+ rendererBridgeActionFocusSync = "focus_sync"
19+ rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
20+ rendererBridgeActionReady = "bridge_ready"
21+ rendererBridgeExtensionName = "dumber.renderer_bridge"
2122 )
2223
24+var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate
25+
2326 const rendererBridgeExtensionJS = `
2427 (function() {
2528 native function Dispatch(action, payload);
@@ -252,7 +255,25 @@ func (*rendererBridgeProcessHandler) OnUncaughtException(
252255 _ purecef.Browser, _ purecef.Frame, _ purecef.V8Context, _ purecef.V8Exception, _ purecef.V8StackTrace,
253256 ) {
254257 }
255-func (*rendererBridgeProcessHandler) OnFocusedNodeChanged(_ purecef.Browser, _ purecef.Frame, _ purecef.Domnode) {
258+func (*rendererBridgeProcessHandler) OnFocusedNodeChanged(_ purecef.Browser, frame purecef.Frame, node purecef.Domnode) {
259+ if frame == nil {
260+ return
261+ }
262+ message := newRendererBridgeProcessMessage(rendererBridgeMessageName)
263+ if message == nil {
264+ return
265+ }
266+ args := message.GetArgumentList()
267+ if args == nil {
268+ return
269+ }
270+ args.SetString(0, rendererBridgeActionEditableFocusChanged)
271+ if node != nil && node.IsEditable() {
272+ args.SetString(1, "1")
273+ } else {
274+ args.SetString(1, "0")
275+ }
276+ frame.SendProcessMessage(purecef.ProcessIDPidBrowser, message)
256277 }
257278 func (*rendererBridgeProcessHandler) OnProcessMessageReceived(
258279 _ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage,
@@ -13,13 +13,16 @@ import (
13 )13 )
14 14
15 const (15 const (
16- rendererBridgeMessageName = "dumber.renderer_bridge"16+ rendererBridgeMessageName = "dumber.renderer_bridge"
17- rendererBridgeActionExplicitTextCopy = "explicit_text_copy"17+ rendererBridgeActionExplicitTextCopy = "explicit_text_copy"
18- rendererBridgeActionFocusSync = "focus_sync"18+ rendererBridgeActionFocusSync = "focus_sync"
19- rendererBridgeActionReady = "bridge_ready"19+ rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
20- rendererBridgeExtensionName = "dumber.renderer_bridge"20+ rendererBridgeActionReady = "bridge_ready"
21+ rendererBridgeExtensionName = "dumber.renderer_bridge"
21 )22 )
22 23
24+var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate
25+
23 const rendererBridgeExtensionJS = `26 const rendererBridgeExtensionJS = `
24 (function() {27 (function() {
25 native function Dispatch(action, payload);28 native function Dispatch(action, payload);
@@ -252,7 +255,25 @@ func (*rendererBridgeProcessHandler) OnUncaughtException(
252 _ purecef.Browser, _ purecef.Frame, _ purecef.V8Context, _ purecef.V8Exception, _ purecef.V8StackTrace,255 _ purecef.Browser, _ purecef.Frame, _ purecef.V8Context, _ purecef.V8Exception, _ purecef.V8StackTrace,
253 ) {256 ) {
254 }257 }
255-func (*rendererBridgeProcessHandler) OnFocusedNodeChanged(_ purecef.Browser, _ purecef.Frame, _ purecef.Domnode) {258+func (*rendererBridgeProcessHandler) OnFocusedNodeChanged(_ purecef.Browser, frame purecef.Frame, node purecef.Domnode) {
259+ if frame == nil {
260+ return
261+ }
262+ message := newRendererBridgeProcessMessage(rendererBridgeMessageName)
263+ if message == nil {
264+ return
265+ }
266+ args := message.GetArgumentList()
267+ if args == nil {
268+ return
269+ }
270+ args.SetString(0, rendererBridgeActionEditableFocusChanged)
271+ if node != nil && node.IsEditable() {
272+ args.SetString(1, "1")
273+ } else {
274+ args.SetString(1, "0")
275+ }
276+ frame.SendProcessMessage(purecef.ProcessIDPidBrowser, message)
256 }277 }
257 func (*rendererBridgeProcessHandler) OnProcessMessageReceived(278 func (*rendererBridgeProcessHandler) OnProcessMessageReceived(
258 _ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage,279 _ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage,
M internal/infrastructure/cef/renderer_bridge_test.go +90 -0
@@ -4,9 +4,81 @@ import (
44 "os"
55 "testing"
66
7+ purecef "github.com/bnema/purego-cef/cef"
8+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
9+ "github.com/stretchr/testify/mock"
710 "github.com/stretchr/testify/require"
811 )
912
13+type testBridgeProcessMessage struct {
14+ name string
15+ args *testBridgeListValue
16+}
17+
18+type testBridgeListValue struct {
19+ values map[int]string
20+}
21+
22+func newTestBridgeProcessMessage(name string, editable bool) *testBridgeProcessMessage {
23+ msg := &testBridgeProcessMessage{name: name, args: &testBridgeListValue{values: map[int]string{}}}
24+ msg.args.SetString(0, rendererBridgeActionEditableFocusChanged)
25+ if editable {
26+ msg.args.SetString(1, "1")
27+ } else {
28+ msg.args.SetString(1, "0")
29+ }
30+ return msg
31+}
32+
33+func (m *testBridgeProcessMessage) IsValid() bool { return true }
34+func (m *testBridgeProcessMessage) IsReadOnly() bool { return false }
35+func (m *testBridgeProcessMessage) Copy() purecef.ProcessMessage { return m }
36+func (m *testBridgeProcessMessage) GetName() string { return m.name }
37+func (m *testBridgeProcessMessage) GetArgumentList() purecef.ListValue { return m.args }
38+func (m *testBridgeProcessMessage) GetSharedMemoryRegion() purecef.SharedMemoryRegion { return nil }
39+
40+func (v *testBridgeListValue) IsValid() bool { return true }
41+func (v *testBridgeListValue) IsOwned() bool { return true }
42+func (v *testBridgeListValue) IsReadOnly() bool { return false }
43+func (v *testBridgeListValue) IsSame(that purecef.ListValue) bool { return v == that }
44+func (v *testBridgeListValue) IsEqual(that purecef.ListValue) bool {
45+ return v == that
46+}
47+func (v *testBridgeListValue) Copy() purecef.ListValue { return v }
48+func (v *testBridgeListValue) SetSize(_ int) int32 { return 0 }
49+func (v *testBridgeListValue) GetSize() int { return len(v.values) }
50+func (v *testBridgeListValue) Clear() int32 { v.values = map[int]string{}; return 0 }
51+func (v *testBridgeListValue) Remove(index int) int32 { delete(v.values, index); return 0 }
52+func (v *testBridgeListValue) GetType(_ int) purecef.ValueType { return 0 }
53+func (v *testBridgeListValue) GetValue(_ int) purecef.Value { return nil }
54+func (v *testBridgeListValue) GetBool(_ int) int32 { return 0 }
55+func (v *testBridgeListValue) GetInt(_ int) int32 { return 0 }
56+func (v *testBridgeListValue) GetDouble(_ int) float64 { return 0 }
57+func (v *testBridgeListValue) GetString(index int) string {
58+ if v.values == nil {
59+ return ""
60+ }
61+ return v.values[index]
62+}
63+func (v *testBridgeListValue) GetBinary(_ int) purecef.BinaryValue { return nil }
64+func (v *testBridgeListValue) GetDictionary(_ int) purecef.DictionaryValue { return nil }
65+func (v *testBridgeListValue) GetList(_ int) purecef.ListValue { return nil }
66+func (v *testBridgeListValue) SetValue(_ int, _ purecef.Value) int32 { return 0 }
67+func (v *testBridgeListValue) SetNull(_ int) int32 { return 0 }
68+func (v *testBridgeListValue) SetBool(_ int, _ int32) int32 { return 0 }
69+func (v *testBridgeListValue) SetInt(_ int, _ int32) int32 { return 0 }
70+func (v *testBridgeListValue) SetDouble(_ int, _ float64) int32 { return 0 }
71+func (v *testBridgeListValue) SetString(index int, value string) int32 {
72+ if v.values == nil {
73+ v.values = map[int]string{}
74+ }
75+ v.values[index] = value
76+ return 0
77+}
78+func (v *testBridgeListValue) SetBinary(_ int, _ purecef.BinaryValue) int32 { return 0 }
79+func (v *testBridgeListValue) SetDictionary(_ int, _ purecef.DictionaryValue) int32 { return 0 }
80+func (v *testBridgeListValue) SetList(_ int, _ purecef.ListValue) int32 { return 0 }
81+
1082 func TestRendererBridgeExtensionJS_DoesNotExposeWritableGlobalDispatch(t *testing.T) {
1183 require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberNativeBridge")
1284 require.NotContains(t, rendererBridgeExtensionJS, "DumberBridgeDispatch =")
@@ -48,3 +120,21 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
48120 require.Equal(t, "copied text", req.Text)
49121 require.Equal(t, "cut", req.Action)
50122 }
123+
124+func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) {
125+ oldFactory := newRendererBridgeProcessMessage
126+ t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
127+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
128+ return newTestBridgeProcessMessage(name, true)
129+ }
130+
131+ frame := cefmocks.NewMockFrame(t)
132+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
133+ require.Equal(t, rendererBridgeMessageName, message.GetName())
134+ args := message.GetArgumentList()
135+ require.Equal(t, "editable_focus_changed", args.GetString(0))
136+ require.Equal(t, "1", args.GetString(1))
137+ }).Once()
138+
139+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
140+}
@@ -4,9 +4,81 @@ import (
4 "os"4 "os"
5 "testing"5 "testing"
6 6
7+ purecef "github.com/bnema/purego-cef/cef"
8+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
9+ "github.com/stretchr/testify/mock"
7 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
8 )11 )
9 12
13+type testBridgeProcessMessage struct {
14+ name string
15+ args *testBridgeListValue
16+}
17+
18+type testBridgeListValue struct {
19+ values map[int]string
20+}
21+
22+func newTestBridgeProcessMessage(name string, editable bool) *testBridgeProcessMessage {
23+ msg := &testBridgeProcessMessage{name: name, args: &testBridgeListValue{values: map[int]string{}}}
24+ msg.args.SetString(0, rendererBridgeActionEditableFocusChanged)
25+ if editable {
26+ msg.args.SetString(1, "1")
27+ } else {
28+ msg.args.SetString(1, "0")
29+ }
30+ return msg
31+}
32+
33+func (m *testBridgeProcessMessage) IsValid() bool { return true }
34+func (m *testBridgeProcessMessage) IsReadOnly() bool { return false }
35+func (m *testBridgeProcessMessage) Copy() purecef.ProcessMessage { return m }
36+func (m *testBridgeProcessMessage) GetName() string { return m.name }
37+func (m *testBridgeProcessMessage) GetArgumentList() purecef.ListValue { return m.args }
38+func (m *testBridgeProcessMessage) GetSharedMemoryRegion() purecef.SharedMemoryRegion { return nil }
39+
40+func (v *testBridgeListValue) IsValid() bool { return true }
41+func (v *testBridgeListValue) IsOwned() bool { return true }
42+func (v *testBridgeListValue) IsReadOnly() bool { return false }
43+func (v *testBridgeListValue) IsSame(that purecef.ListValue) bool { return v == that }
44+func (v *testBridgeListValue) IsEqual(that purecef.ListValue) bool {
45+ return v == that
46+}
47+func (v *testBridgeListValue) Copy() purecef.ListValue { return v }
48+func (v *testBridgeListValue) SetSize(_ int) int32 { return 0 }
49+func (v *testBridgeListValue) GetSize() int { return len(v.values) }
50+func (v *testBridgeListValue) Clear() int32 { v.values = map[int]string{}; return 0 }
51+func (v *testBridgeListValue) Remove(index int) int32 { delete(v.values, index); return 0 }
52+func (v *testBridgeListValue) GetType(_ int) purecef.ValueType { return 0 }
53+func (v *testBridgeListValue) GetValue(_ int) purecef.Value { return nil }
54+func (v *testBridgeListValue) GetBool(_ int) int32 { return 0 }
55+func (v *testBridgeListValue) GetInt(_ int) int32 { return 0 }
56+func (v *testBridgeListValue) GetDouble(_ int) float64 { return 0 }
57+func (v *testBridgeListValue) GetString(index int) string {
58+ if v.values == nil {
59+ return ""
60+ }
61+ return v.values[index]
62+}
63+func (v *testBridgeListValue) GetBinary(_ int) purecef.BinaryValue { return nil }
64+func (v *testBridgeListValue) GetDictionary(_ int) purecef.DictionaryValue { return nil }
65+func (v *testBridgeListValue) GetList(_ int) purecef.ListValue { return nil }
66+func (v *testBridgeListValue) SetValue(_ int, _ purecef.Value) int32 { return 0 }
67+func (v *testBridgeListValue) SetNull(_ int) int32 { return 0 }
68+func (v *testBridgeListValue) SetBool(_ int, _ int32) int32 { return 0 }
69+func (v *testBridgeListValue) SetInt(_ int, _ int32) int32 { return 0 }
70+func (v *testBridgeListValue) SetDouble(_ int, _ float64) int32 { return 0 }
71+func (v *testBridgeListValue) SetString(index int, value string) int32 {
72+ if v.values == nil {
73+ v.values = map[int]string{}
74+ }
75+ v.values[index] = value
76+ return 0
77+}
78+func (v *testBridgeListValue) SetBinary(_ int, _ purecef.BinaryValue) int32 { return 0 }
79+func (v *testBridgeListValue) SetDictionary(_ int, _ purecef.DictionaryValue) int32 { return 0 }
80+func (v *testBridgeListValue) SetList(_ int, _ purecef.ListValue) int32 { return 0 }
81+
10 func TestRendererBridgeExtensionJS_DoesNotExposeWritableGlobalDispatch(t *testing.T) {82 func TestRendererBridgeExtensionJS_DoesNotExposeWritableGlobalDispatch(t *testing.T) {
11 require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberNativeBridge")83 require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberNativeBridge")
12 require.NotContains(t, rendererBridgeExtensionJS, "DumberBridgeDispatch =")84 require.NotContains(t, rendererBridgeExtensionJS, "DumberBridgeDispatch =")
@@ -48,3 +120,21 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
48 require.Equal(t, "copied text", req.Text)120 require.Equal(t, "copied text", req.Text)
49 require.Equal(t, "cut", req.Action)121 require.Equal(t, "cut", req.Action)
50 }122 }
123+
124+func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) {
125+ oldFactory := newRendererBridgeProcessMessage
126+ t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
127+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
128+ return newTestBridgeProcessMessage(name, true)
129+ }
130+
131+ frame := cefmocks.NewMockFrame(t)
132+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
133+ require.Equal(t, rendererBridgeMessageName, message.GetName())
134+ args := message.GetArgumentList()
135+ require.Equal(t, "editable_focus_changed", args.GetString(0))
136+ require.Equal(t, "1", args.GetString(1))
137+ }).Once()
138+
139+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
140+}
M internal/infrastructure/cef/scheme_handler.go +0 -60
@@ -56,10 +56,6 @@ type dumbSchemeHandler struct {
5656 defaultConfigPayload func() ([]byte, error)
5757 mu sync.RWMutex
5858
59- // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
60- // Set by the engine to mirror copied text to the real system clipboard.
61- onClipboardSet func(text string)
62-
6359 // onEditableFocus is called when JS reports that an editable element gained
6460 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
6561 onEditableFocus func(browser purecef.Browser)
@@ -166,9 +162,6 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
166162 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
167163 return h.handleTranscodeAPI(request)
168164
169- case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
170- return h.handleClipboardSet(request)
171-
172165 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
173166 return h.handleFocusSync(browser)
174167
@@ -245,59 +238,6 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece
245238 return h.newRawResourceHandler(http.StatusOK, "application/json", data)
246239 }
247240
248-// handleClipboardSet receives copied text from JS copy/cut events and writes
249-// it to the system clipboard via the engine callback.
250-const maxClipboardBytes = 10 << 20 // 10 MB
251-
252-func decodeClipboardSetBody(body []byte) (string, error) {
253- if body == nil {
254- return "", fmt.Errorf("empty body")
255- }
256- if len(body) > maxClipboardBytes {
257- return "", fmt.Errorf("payload too large")
258- }
259-
260- var payload struct {
261- Text string `json:"text"`
262- }
263- if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
264- return "", fmt.Errorf("invalid payload")
265- }
266- return payload.Text, nil
267-}
268-
269-func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
270- h.logger.Debug().Msg("cef: /api/clipboard-set request received")
271-
272- body := readBodyFromHeader(request)
273- text, err := decodeClipboardSetBody(body)
274- if err != nil {
275- switch err.Error() {
276- case "empty body":
277- h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
278- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
279- case "payload too large":
280- h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
281- return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": err.Error()})
282- default:
283- h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
284- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
285- }
286- }
287-
288- h.logger.Debug().
289- Int("text_len", len(text)).
290- Msg("cef: clipboard-set request reached backend")
291-
292- if h.onClipboardSet != nil {
293- h.onClipboardSet(text)
294- } else {
295- h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
296- }
297-
298- return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
299-}
300-
301241 func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler {
302242 if browser == nil {
303243 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
@@ -56,10 +56,6 @@ type dumbSchemeHandler struct {
56 defaultConfigPayload func() ([]byte, error)56 defaultConfigPayload func() ([]byte, error)
57 mu sync.RWMutex57 mu sync.RWMutex
58 58
59- // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
60- // Set by the engine to mirror copied text to the real system clipboard.
61- onClipboardSet func(text string)
62-
63 // onEditableFocus is called when JS reports that an editable element gained59 // onEditableFocus is called when JS reports that an editable element gained
64 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.60 // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
65 onEditableFocus func(browser purecef.Browser)61 onEditableFocus func(browser purecef.Browser)
@@ -166,9 +162,6 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri
166 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):162 case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")):
167 return h.handleTranscodeAPI(request)163 return h.handleTranscodeAPI(request)
168 164
169- case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
170- return h.handleClipboardSet(request)
171-
172 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):165 case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
173 return h.handleFocusSync(browser)166 return h.handleFocusSync(browser)
174 167
@@ -245,59 +238,6 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece
245 return h.newRawResourceHandler(http.StatusOK, "application/json", data)238 return h.newRawResourceHandler(http.StatusOK, "application/json", data)
246 }239 }
247 240
248-// handleClipboardSet receives copied text from JS copy/cut events and writes
249-// it to the system clipboard via the engine callback.
250-const maxClipboardBytes = 10 << 20 // 10 MB
251-
252-func decodeClipboardSetBody(body []byte) (string, error) {
253- if body == nil {
254- return "", fmt.Errorf("empty body")
255- }
256- if len(body) > maxClipboardBytes {
257- return "", fmt.Errorf("payload too large")
258- }
259-
260- var payload struct {
261- Text string `json:"text"`
262- }
263- if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
264- return "", fmt.Errorf("invalid payload")
265- }
266- return payload.Text, nil
267-}
268-
269-func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {
270- h.logger.Debug().Msg("cef: /api/clipboard-set request received")
271-
272- body := readBodyFromHeader(request)
273- text, err := decodeClipboardSetBody(body)
274- if err != nil {
275- switch err.Error() {
276- case "empty body":
277- h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
278- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
279- case "payload too large":
280- h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
281- return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": err.Error()})
282- default:
283- h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
284- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
285- }
286- }
287-
288- h.logger.Debug().
289- Int("text_len", len(text)).
290- Msg("cef: clipboard-set request reached backend")
291-
292- if h.onClipboardSet != nil {
293- h.onClipboardSet(text)
294- } else {
295- h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
296- }
297-
298- return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
299-}
300-
301 func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler {241 func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler {
302 if browser == nil {242 if browser == nil {
303 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")243 h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
M internal/infrastructure/cef/scheme_handler_test.go +6 -19
@@ -4,7 +4,7 @@ import (
44 "context"
55 "encoding/base64"
66 "errors"
7- "strings"
7+ "os"
88 "testing"
99
1010 cefmocks "github.com/bnema/purego-cef/cef/mocks"
@@ -57,23 +57,10 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
5757 require.JSONEq(t, `{"text":"copied from js"}`, string(body))
5858 }
5959
60-func TestDecodeClipboardSetBody_ReturnsText(t *testing.T) {
61- text, err := decodeClipboardSetBody([]byte(`{"text":"copied from js"}`))
60+func TestSchemeHandlerSourceDoesNotKeepClipboardSetPath(t *testing.T) {
61+ src, err := os.ReadFile("scheme_handler.go")
6262 require.NoError(t, err)
63- require.Equal(t, "copied from js", text)
64-}
65-
66-func TestDecodeClipboardSetBody_RejectsOversizePayload(t *testing.T) {
67- body := []byte(`{"text":"` + strings.Repeat("x", maxClipboardBytes) + `"}`)
68-
69- _, err := decodeClipboardSetBody(body)
70-
71- require.Error(t, err)
72- require.Contains(t, err.Error(), "payload too large")
73-}
74-
75-func TestDecodeClipboardSetBody_RejectsInvalidPayload(t *testing.T) {
76- _, err := decodeClipboardSetBody([]byte(`{"text":""}`))
77- require.Error(t, err)
78- require.Contains(t, err.Error(), "invalid payload")
63+ require.NotContains(t, string(src), "/api/clipboard-set")
64+ require.NotContains(t, string(src), "onClipboardSet")
65+ require.NotContains(t, string(src), "decodeClipboardSetBody")
7966 }
@@ -4,7 +4,7 @@ import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6 "errors"6 "errors"
7- "strings"7+ "os"
8 "testing"8 "testing"
9 9
10 cefmocks "github.com/bnema/purego-cef/cef/mocks"10 cefmocks "github.com/bnema/purego-cef/cef/mocks"
@@ -57,23 +57,10 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
57 require.JSONEq(t, `{"text":"copied from js"}`, string(body))57 require.JSONEq(t, `{"text":"copied from js"}`, string(body))
58 }58 }
59 59
60-func TestDecodeClipboardSetBody_ReturnsText(t *testing.T) {60+func TestSchemeHandlerSourceDoesNotKeepClipboardSetPath(t *testing.T) {
61- text, err := decodeClipboardSetBody([]byte(`{"text":"copied from js"}`))61+ src, err := os.ReadFile("scheme_handler.go")
62 require.NoError(t, err)62 require.NoError(t, err)
63- require.Equal(t, "copied from js", text)63+ require.NotContains(t, string(src), "/api/clipboard-set")
64-}64+ require.NotContains(t, string(src), "onClipboardSet")
65-65+ require.NotContains(t, string(src), "decodeClipboardSetBody")
66-func TestDecodeClipboardSetBody_RejectsOversizePayload(t *testing.T) {
67- body := []byte(`{"text":"` + strings.Repeat("x", maxClipboardBytes) + `"}`)
68-
69- _, err := decodeClipboardSetBody(body)
70-
71- require.Error(t, err)
72- require.Contains(t, err.Error(), "payload too large")
73-}
74-
75-func TestDecodeClipboardSetBody_RejectsInvalidPayload(t *testing.T) {
76- _, err := decodeClipboardSetBody([]byte(`{"text":""}`))
77- require.Error(t, err)
78- require.Contains(t, err.Error(), "invalid payload")
79 }66 }
M internal/infrastructure/cef/webview.go +84 -7
@@ -8,6 +8,7 @@ import (
88 "math"
99 "sync"
1010 "sync/atomic"
11+ "time"
1112
1213 purecef "github.com/bnema/purego-cef/cef"
1314 "github.com/bnema/puregotk/v4/glib"
@@ -30,6 +31,8 @@ var errDestroyed = errors.New("cef: webview is destroyed")
3031 // errNoBrowser is returned when the browser has not been created yet.
3132 var errNoBrowser = errors.New("cef: browser not yet created")
3233
34+const clipboardSelectionDebounceDelay = 300 * time.Millisecond
35+
3336 // WebView implements port.WebView using a CEF off-screen browser rendered
3437 // through a renderPipeline and driven by an inputBridge.
3538 type WebView struct {
@@ -65,13 +68,16 @@ type WebView struct {
6568 callbacks *port.WebViewCallbacks
6669
6770 // State cache (mutex-protected).
68- uri string
69- title string
70- progress float64
71- canGoBack bool
72- canGoFwd bool
73- isLoading bool
74- selectedText string
71+ uri string
72+ title string
73+ progress float64
74+ canGoBack bool
75+ canGoFwd bool
76+ isLoading bool
77+ selectedText string
78+ focusedEditable bool
79+ selectionDebounceTimer *time.Timer
80+ selectionDebounceSeq uint64
7581
7682 // Last known hover URI for middle-click → new tab.
7783 lastHoverURI string
@@ -474,6 +480,7 @@ func (wv *WebView) Destroy() {
474480 if !wv.destroyed.CompareAndSwap(false, true) {
475481 return
476482 }
483+ wv.cancelSelectionDebounce()
477484 if wv.resizeReconciler != nil {
478485 wv.resizeReconciler.stop()
479486 }
@@ -575,6 +582,76 @@ func (wv *WebView) setSelectedText(text string) (string, bool) {
575582 return previous, changed
576583 }
577584
585+func (wv *WebView) setEditableFocus(editable bool) {
586+ if wv == nil {
587+ return
588+ }
589+ wv.mu.Lock()
590+ wv.focusedEditable = editable
591+ if editable {
592+ wv.selectionDebounceSeq++
593+ timer := wv.selectionDebounceTimer
594+ wv.selectionDebounceTimer = nil
595+ wv.mu.Unlock()
596+ if timer != nil {
597+ timer.Stop()
598+ }
599+ return
600+ }
601+ wv.mu.Unlock()
602+}
603+
604+func (wv *WebView) cancelSelectionDebounce() {
605+ if wv == nil {
606+ return
607+ }
608+ wv.mu.Lock()
609+ wv.selectionDebounceSeq++
610+ timer := wv.selectionDebounceTimer
611+ wv.selectionDebounceTimer = nil
612+ wv.mu.Unlock()
613+ if timer != nil {
614+ timer.Stop()
615+ }
616+}
617+
618+func (wv *WebView) scheduleSelectionUpdate(text string) {
619+ if wv == nil || wv.destroyed.Load() {
620+ return
621+ }
622+ wv.mu.Lock()
623+ if wv.destroyed.Load() || wv.focusedEditable || wv.engine == nil {
624+ wv.mu.Unlock()
625+ return
626+ }
627+ wv.selectionDebounceSeq++
628+ seq := wv.selectionDebounceSeq
629+ if timer := wv.selectionDebounceTimer; timer != nil {
630+ timer.Stop()
631+ }
632+ wv.selectionDebounceTimer = time.AfterFunc(clipboardSelectionDebounceDelay, func() {
633+ wv.flushSelectionUpdate(seq, text)
634+ })
635+ wv.mu.Unlock()
636+}
637+
638+func (wv *WebView) flushSelectionUpdate(seq uint64, text string) {
639+ if wv == nil || wv.destroyed.Load() {
640+ return
641+ }
642+ wv.mu.Lock()
643+ if wv.destroyed.Load() || wv.focusedEditable || seq != wv.selectionDebounceSeq {
644+ wv.mu.Unlock()
645+ return
646+ }
647+ engine := wv.engine
648+ viewID := wv.id
649+ wv.mu.Unlock()
650+ if engine != nil {
651+ engine.handleClipboardSelectionUpdate(viewID, text)
652+ }
653+}
654+
578655 func (wv *WebView) selectedTextSnapshot() string {
579656 wv.mu.RLock()
580657 defer wv.mu.RUnlock()
@@ -8,6 +8,7 @@ import (
8 "math"8 "math"
9 "sync"9 "sync"
10 "sync/atomic"10 "sync/atomic"
11+ "time"
11 12
12 purecef "github.com/bnema/purego-cef/cef"13 purecef "github.com/bnema/purego-cef/cef"
13 "github.com/bnema/puregotk/v4/glib"14 "github.com/bnema/puregotk/v4/glib"
@@ -30,6 +31,8 @@ var errDestroyed = errors.New("cef: webview is destroyed")
30 // errNoBrowser is returned when the browser has not been created yet.31 // errNoBrowser is returned when the browser has not been created yet.
31 var errNoBrowser = errors.New("cef: browser not yet created")32 var errNoBrowser = errors.New("cef: browser not yet created")
32 33
34+const clipboardSelectionDebounceDelay = 300 * time.Millisecond
35+
33 // WebView implements port.WebView using a CEF off-screen browser rendered36 // WebView implements port.WebView using a CEF off-screen browser rendered
34 // through a renderPipeline and driven by an inputBridge.37 // through a renderPipeline and driven by an inputBridge.
35 type WebView struct {38 type WebView struct {
@@ -65,13 +68,16 @@ type WebView struct {
65 callbacks *port.WebViewCallbacks68 callbacks *port.WebViewCallbacks
66 69
67 // State cache (mutex-protected).70 // State cache (mutex-protected).
68- uri string71+ uri string
69- title string72+ title string
70- progress float6473+ progress float64
71- canGoBack bool74+ canGoBack bool
72- canGoFwd bool75+ canGoFwd bool
73- isLoading bool76+ isLoading bool
74- selectedText string77+ selectedText string
78+ focusedEditable bool
79+ selectionDebounceTimer *time.Timer
80+ selectionDebounceSeq uint64
75 81
76 // Last known hover URI for middle-click → new tab.82 // Last known hover URI for middle-click → new tab.
77 lastHoverURI string83 lastHoverURI string
@@ -474,6 +480,7 @@ func (wv *WebView) Destroy() {
474 if !wv.destroyed.CompareAndSwap(false, true) {480 if !wv.destroyed.CompareAndSwap(false, true) {
475 return481 return
476 }482 }
483+ wv.cancelSelectionDebounce()
477 if wv.resizeReconciler != nil {484 if wv.resizeReconciler != nil {
478 wv.resizeReconciler.stop()485 wv.resizeReconciler.stop()
479 }486 }
@@ -575,6 +582,76 @@ func (wv *WebView) setSelectedText(text string) (string, bool) {
575 return previous, changed582 return previous, changed
576 }583 }
577 584
585+func (wv *WebView) setEditableFocus(editable bool) {
586+ if wv == nil {
587+ return
588+ }
589+ wv.mu.Lock()
590+ wv.focusedEditable = editable
591+ if editable {
592+ wv.selectionDebounceSeq++
593+ timer := wv.selectionDebounceTimer
594+ wv.selectionDebounceTimer = nil
595+ wv.mu.Unlock()
596+ if timer != nil {
597+ timer.Stop()
598+ }
599+ return
600+ }
601+ wv.mu.Unlock()
602+}
603+
604+func (wv *WebView) cancelSelectionDebounce() {
605+ if wv == nil {
606+ return
607+ }
608+ wv.mu.Lock()
609+ wv.selectionDebounceSeq++
610+ timer := wv.selectionDebounceTimer
611+ wv.selectionDebounceTimer = nil
612+ wv.mu.Unlock()
613+ if timer != nil {
614+ timer.Stop()
615+ }
616+}
617+
618+func (wv *WebView) scheduleSelectionUpdate(text string) {
619+ if wv == nil || wv.destroyed.Load() {
620+ return
621+ }
622+ wv.mu.Lock()
623+ if wv.destroyed.Load() || wv.focusedEditable || wv.engine == nil {
624+ wv.mu.Unlock()
625+ return
626+ }
627+ wv.selectionDebounceSeq++
628+ seq := wv.selectionDebounceSeq
629+ if timer := wv.selectionDebounceTimer; timer != nil {
630+ timer.Stop()
631+ }
632+ wv.selectionDebounceTimer = time.AfterFunc(clipboardSelectionDebounceDelay, func() {
633+ wv.flushSelectionUpdate(seq, text)
634+ })
635+ wv.mu.Unlock()
636+}
637+
638+func (wv *WebView) flushSelectionUpdate(seq uint64, text string) {
639+ if wv == nil || wv.destroyed.Load() {
640+ return
641+ }
642+ wv.mu.Lock()
643+ if wv.destroyed.Load() || wv.focusedEditable || seq != wv.selectionDebounceSeq {
644+ wv.mu.Unlock()
645+ return
646+ }
647+ engine := wv.engine
648+ viewID := wv.id
649+ wv.mu.Unlock()
650+ if engine != nil {
651+ engine.handleClipboardSelectionUpdate(viewID, text)
652+ }
653+}
654+
578 func (wv *WebView) selectedTextSnapshot() string {655 func (wv *WebView) selectedTextSnapshot() string {
579 wv.mu.RLock()656 wv.mu.RLock()
580 defer wv.mu.RUnlock()657 defer wv.mu.RUnlock()
M internal/infrastructure/cef/webview_handlers.go +3 -1
@@ -97,6 +97,8 @@ func (h *handlerSet) OnProcessMessageReceived(
9797 return 1
9898 }
9999 h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text)
100+ case rendererBridgeActionEditableFocusChanged:
101+ h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
100102 case rendererBridgeActionFocusSync:
101103 h.wv.engine.handleEditableFocusBridge(browser)
102104 case rendererBridgeActionReady:
@@ -388,7 +390,7 @@ func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText stri
388390 Msg("cef: text selection changed")
389391 }
390392 if h.wv.engine != nil {
391- h.wv.engine.handleClipboardSelectionUpdate(h.wv.id, selectedText)
393+ h.wv.scheduleSelectionUpdate(selectedText)
392394 }
393395 }
394396
@@ -97,6 +97,8 @@ func (h *handlerSet) OnProcessMessageReceived(
97 return 197 return 1
98 }98 }
99 h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text)99 h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text)
100+ case rendererBridgeActionEditableFocusChanged:
101+ h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
100 case rendererBridgeActionFocusSync:102 case rendererBridgeActionFocusSync:
101 h.wv.engine.handleEditableFocusBridge(browser)103 h.wv.engine.handleEditableFocusBridge(browser)
102 case rendererBridgeActionReady:104 case rendererBridgeActionReady:
@@ -388,7 +390,7 @@ func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText stri
388 Msg("cef: text selection changed")390 Msg("cef: text selection changed")
389 }391 }
390 if h.wv.engine != nil {392 if h.wv.engine != nil {
391- h.wv.engine.handleClipboardSelectionUpdate(h.wv.id, selectedText)393+ h.wv.scheduleSelectionUpdate(selectedText)
392 }394 }
393 }395 }
394 396
M internal/infrastructure/cef/webview_handlers_test.go +164 -5
@@ -2,22 +2,31 @@ package cef
22
33 import (
44 "context"
5+ "sync"
56 "testing"
7+ "time"
68
79 purecef "github.com/bnema/purego-cef/cef"
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
811 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
913 "github.com/stretchr/testify/require"
1014
1115 "github.com/bnema/dumber/internal/application/port"
1216 )
1317
1418 type recordingClipboardOrchestrator struct {
15- selection port.SelectionClipboardInput
16- explicit port.ExplicitClipboardInput
19+ mu sync.Mutex
20+ selection port.SelectionClipboardInput
21+ selectionCalls int
22+ explicit port.ExplicitClipboardInput
1723 }
1824
1925 func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {
26+ r.mu.Lock()
2027 r.selection = input
28+ r.selectionCalls++
29+ r.mu.Unlock()
2130 return nil
2231 }
2332
@@ -117,11 +126,161 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test
117126
118127 h.OnTextSelectionChanged(nil, "selected text", nil)
119128
120- require.Equal(t, "selected text", orchestrator.selection.Text)
121- require.Equal(t, port.ClipboardSourceCEF, orchestrator.selection.SourceEngine)
122- require.Equal(t, port.WebViewID(42), orchestrator.selection.ViewID)
129+ require.Eventually(t, func() bool {
130+ orchestrator.mu.Lock()
131+ defer orchestrator.mu.Unlock()
132+ return orchestrator.selectionCalls == 1 &&
133+ orchestrator.selection.Text == "selected text" &&
134+ orchestrator.selection.SourceEngine == port.ClipboardSourceCEF &&
135+ orchestrator.selection.ViewID == port.WebViewID(42)
136+ }, 2*time.Second, 10*time.Millisecond)
123137 }
124138
139+func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
140+ orchestrator := &recordingClipboardOrchestrator{}
141+ wv := &WebView{
142+ ctx: context.Background(),
143+ id: 42,
144+ engine: &Engine{
145+ clipboardTextOrchestrator: orchestrator,
146+ },
147+ }
148+ h := &handlerSet{wv: wv}
149+
150+ h.OnTextSelectionChanged(nil, "first selection", nil)
151+ h.OnTextSelectionChanged(nil, "second selection", nil)
152+
153+ orchestrator.mu.Lock()
154+ gotCalls := orchestrator.selectionCalls
155+ orchestrator.mu.Unlock()
156+ require.Zero(t, gotCalls)
157+
158+ require.Eventually(t, func() bool {
159+ orchestrator.mu.Lock()
160+ defer orchestrator.mu.Unlock()
161+ return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "second selection"
162+ }, 2*time.Second, 10*time.Millisecond)
163+ require.Equal(t, "second selection", wv.selectedTextSnapshot())
164+}
165+
166+func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
167+ orchestrator := &recordingClipboardOrchestrator{}
168+ wv := &WebView{
169+ ctx: context.Background(),
170+ id: 42,
171+ engine: &Engine{
172+ clipboardTextOrchestrator: orchestrator,
173+ },
174+ }
175+ h := &handlerSet{wv: wv}
176+ frame := cefmocks.NewMockFrame(t)
177+ oldFactory := newRendererBridgeProcessMessage
178+ t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
179+
180+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
181+ return newTestBridgeProcessMessage(name, true)
182+ }
183+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
184+ require.Equal(t, rendererBridgeMessageName, message.GetName())
185+ args := message.GetArgumentList()
186+ require.Equal(t, "editable_focus_changed", args.GetString(0))
187+ require.Equal(t, "1", args.GetString(1))
188+ h.OnProcessMessageReceived(nil, nil, 0, message)
189+ }).Once()
190+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
191+
192+ h.OnTextSelectionChanged(nil, "editable selection", nil)
193+ require.Equal(t, "editable selection", wv.selectedTextSnapshot())
194+
195+ orchestrator.mu.Lock()
196+ gotCalls := orchestrator.selectionCalls
197+ orchestrator.mu.Unlock()
198+ require.Zero(t, gotCalls)
199+ time.Sleep(350 * time.Millisecond)
200+ orchestrator.mu.Lock()
201+ gotCalls = orchestrator.selectionCalls
202+ orchestrator.mu.Unlock()
203+ require.Zero(t, gotCalls)
204+
205+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
206+ return newTestBridgeProcessMessage(name, false)
207+ }
208+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
209+ require.Equal(t, rendererBridgeMessageName, message.GetName())
210+ args := message.GetArgumentList()
211+ require.Equal(t, "editable_focus_changed", args.GetString(0))
212+ require.Equal(t, "0", args.GetString(1))
213+ h.OnProcessMessageReceived(nil, nil, 0, message)
214+ }).Once()
215+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false})
216+
217+ h.OnTextSelectionChanged(nil, "free selection", nil)
218+ require.Equal(t, "free selection", wv.selectedTextSnapshot())
219+ require.Zero(t, orchestrator.selectionCalls)
220+ require.Eventually(t, func() bool {
221+ orchestrator.mu.Lock()
222+ defer orchestrator.mu.Unlock()
223+ return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "free selection"
224+ }, 2*time.Second, 10*time.Millisecond)
225+}
226+
227+func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
228+ orchestrator := &recordingClipboardOrchestrator{}
229+ wv := &WebView{
230+ ctx: context.Background(),
231+ id: 42,
232+ engine: &Engine{
233+ clipboardTextOrchestrator: orchestrator,
234+ },
235+ }
236+ h := &handlerSet{wv: wv}
237+
238+ h.OnTextSelectionChanged(nil, "selected text", nil)
239+ wv.Destroy()
240+
241+ orchestrator.mu.Lock()
242+ gotCalls := orchestrator.selectionCalls
243+ orchestrator.mu.Unlock()
244+ require.Zero(t, gotCalls)
245+ time.Sleep(350 * time.Millisecond)
246+ orchestrator.mu.Lock()
247+ gotCalls = orchestrator.selectionCalls
248+ orchestrator.mu.Unlock()
249+ require.Zero(t, gotCalls)
250+ require.Equal(t, "selected text", wv.selectedTextSnapshot())
251+}
252+
253+type stubEditableDomnode struct {
254+ editable bool
255+}
256+
257+func (n stubEditableDomnode) GetType() purecef.DomNodeType { return 0 }
258+func (n stubEditableDomnode) IsText() bool { return false }
259+func (n stubEditableDomnode) IsElement() bool { return true }
260+func (n stubEditableDomnode) IsEditable() bool { return n.editable }
261+func (n stubEditableDomnode) IsFormControlElement() bool { return false }
262+func (n stubEditableDomnode) GetFormControlElementType() purecef.DomFormControlType { return 0 }
263+func (n stubEditableDomnode) IsSame(that purecef.Domnode) bool { return n == that }
264+func (n stubEditableDomnode) GetName() string { return "" }
265+func (n stubEditableDomnode) GetValue() string { return "" }
266+func (n stubEditableDomnode) SetValue(string) int32 { return 0 }
267+func (n stubEditableDomnode) GetAsMarkup() string { return "" }
268+func (n stubEditableDomnode) GetDocument() purecef.Domdocument { return nil }
269+func (n stubEditableDomnode) GetParent() purecef.Domnode { return nil }
270+func (n stubEditableDomnode) GetPreviousSibling() purecef.Domnode { return nil }
271+func (n stubEditableDomnode) GetNextSibling() purecef.Domnode { return nil }
272+func (n stubEditableDomnode) HasChildren() bool { return false }
273+func (n stubEditableDomnode) GetFirstChild() purecef.Domnode { return nil }
274+func (n stubEditableDomnode) GetLastChild() purecef.Domnode { return nil }
275+func (n stubEditableDomnode) GetElementTagName() string { return "" }
276+func (n stubEditableDomnode) HasElementAttributes() bool { return false }
277+func (n stubEditableDomnode) HasElementAttribute(string) bool { return false }
278+func (n stubEditableDomnode) GetElementAttribute(string) string { return "" }
279+func (n stubEditableDomnode) GetElementAttributes(uintptr) {}
280+func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return 0 }
281+func (n stubEditableDomnode) GetElementInnerText() string { return "" }
282+func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 }
283+
125284 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
126285 rect := &purecef.Rect{}
127286 h := &handlerSet{
@@ -2,22 +2,31 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "sync"
5 "testing"6 "testing"
7+ "time"
6 8
7 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
10 14
11 "github.com/bnema/dumber/internal/application/port"15 "github.com/bnema/dumber/internal/application/port"
12 )16 )
13 17
14 type recordingClipboardOrchestrator struct {18 type recordingClipboardOrchestrator struct {
15- selection port.SelectionClipboardInput19+ mu sync.Mutex
16- explicit port.ExplicitClipboardInput20+ selection port.SelectionClipboardInput
21+ selectionCalls int
22+ explicit port.ExplicitClipboardInput
17 }23 }
18 24
19 func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {25 func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {
26+ r.mu.Lock()
20 r.selection = input27 r.selection = input
28+ r.selectionCalls++
29+ r.mu.Unlock()
21 return nil30 return nil
22 }31 }
23 32
@@ -117,11 +126,161 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test
117 126
118 h.OnTextSelectionChanged(nil, "selected text", nil)127 h.OnTextSelectionChanged(nil, "selected text", nil)
119 128
120- require.Equal(t, "selected text", orchestrator.selection.Text)129+ require.Eventually(t, func() bool {
121- require.Equal(t, port.ClipboardSourceCEF, orchestrator.selection.SourceEngine)130+ orchestrator.mu.Lock()
122- require.Equal(t, port.WebViewID(42), orchestrator.selection.ViewID)131+ defer orchestrator.mu.Unlock()
132+ return orchestrator.selectionCalls == 1 &&
133+ orchestrator.selection.Text == "selected text" &&
134+ orchestrator.selection.SourceEngine == port.ClipboardSourceCEF &&
135+ orchestrator.selection.ViewID == port.WebViewID(42)
136+ }, 2*time.Second, 10*time.Millisecond)
123 }137 }
124 138
139+func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
140+ orchestrator := &recordingClipboardOrchestrator{}
141+ wv := &WebView{
142+ ctx: context.Background(),
143+ id: 42,
144+ engine: &Engine{
145+ clipboardTextOrchestrator: orchestrator,
146+ },
147+ }
148+ h := &handlerSet{wv: wv}
149+
150+ h.OnTextSelectionChanged(nil, "first selection", nil)
151+ h.OnTextSelectionChanged(nil, "second selection", nil)
152+
153+ orchestrator.mu.Lock()
154+ gotCalls := orchestrator.selectionCalls
155+ orchestrator.mu.Unlock()
156+ require.Zero(t, gotCalls)
157+
158+ require.Eventually(t, func() bool {
159+ orchestrator.mu.Lock()
160+ defer orchestrator.mu.Unlock()
161+ return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "second selection"
162+ }, 2*time.Second, 10*time.Millisecond)
163+ require.Equal(t, "second selection", wv.selectedTextSnapshot())
164+}
165+
166+func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
167+ orchestrator := &recordingClipboardOrchestrator{}
168+ wv := &WebView{
169+ ctx: context.Background(),
170+ id: 42,
171+ engine: &Engine{
172+ clipboardTextOrchestrator: orchestrator,
173+ },
174+ }
175+ h := &handlerSet{wv: wv}
176+ frame := cefmocks.NewMockFrame(t)
177+ oldFactory := newRendererBridgeProcessMessage
178+ t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
179+
180+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
181+ return newTestBridgeProcessMessage(name, true)
182+ }
183+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
184+ require.Equal(t, rendererBridgeMessageName, message.GetName())
185+ args := message.GetArgumentList()
186+ require.Equal(t, "editable_focus_changed", args.GetString(0))
187+ require.Equal(t, "1", args.GetString(1))
188+ h.OnProcessMessageReceived(nil, nil, 0, message)
189+ }).Once()
190+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
191+
192+ h.OnTextSelectionChanged(nil, "editable selection", nil)
193+ require.Equal(t, "editable selection", wv.selectedTextSnapshot())
194+
195+ orchestrator.mu.Lock()
196+ gotCalls := orchestrator.selectionCalls
197+ orchestrator.mu.Unlock()
198+ require.Zero(t, gotCalls)
199+ time.Sleep(350 * time.Millisecond)
200+ orchestrator.mu.Lock()
201+ gotCalls = orchestrator.selectionCalls
202+ orchestrator.mu.Unlock()
203+ require.Zero(t, gotCalls)
204+
205+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
206+ return newTestBridgeProcessMessage(name, false)
207+ }
208+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
209+ require.Equal(t, rendererBridgeMessageName, message.GetName())
210+ args := message.GetArgumentList()
211+ require.Equal(t, "editable_focus_changed", args.GetString(0))
212+ require.Equal(t, "0", args.GetString(1))
213+ h.OnProcessMessageReceived(nil, nil, 0, message)
214+ }).Once()
215+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false})
216+
217+ h.OnTextSelectionChanged(nil, "free selection", nil)
218+ require.Equal(t, "free selection", wv.selectedTextSnapshot())
219+ require.Zero(t, orchestrator.selectionCalls)
220+ require.Eventually(t, func() bool {
221+ orchestrator.mu.Lock()
222+ defer orchestrator.mu.Unlock()
223+ return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "free selection"
224+ }, 2*time.Second, 10*time.Millisecond)
225+}
226+
227+func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
228+ orchestrator := &recordingClipboardOrchestrator{}
229+ wv := &WebView{
230+ ctx: context.Background(),
231+ id: 42,
232+ engine: &Engine{
233+ clipboardTextOrchestrator: orchestrator,
234+ },
235+ }
236+ h := &handlerSet{wv: wv}
237+
238+ h.OnTextSelectionChanged(nil, "selected text", nil)
239+ wv.Destroy()
240+
241+ orchestrator.mu.Lock()
242+ gotCalls := orchestrator.selectionCalls
243+ orchestrator.mu.Unlock()
244+ require.Zero(t, gotCalls)
245+ time.Sleep(350 * time.Millisecond)
246+ orchestrator.mu.Lock()
247+ gotCalls = orchestrator.selectionCalls
248+ orchestrator.mu.Unlock()
249+ require.Zero(t, gotCalls)
250+ require.Equal(t, "selected text", wv.selectedTextSnapshot())
251+}
252+
253+type stubEditableDomnode struct {
254+ editable bool
255+}
256+
257+func (n stubEditableDomnode) GetType() purecef.DomNodeType { return 0 }
258+func (n stubEditableDomnode) IsText() bool { return false }
259+func (n stubEditableDomnode) IsElement() bool { return true }
260+func (n stubEditableDomnode) IsEditable() bool { return n.editable }
261+func (n stubEditableDomnode) IsFormControlElement() bool { return false }
262+func (n stubEditableDomnode) GetFormControlElementType() purecef.DomFormControlType { return 0 }
263+func (n stubEditableDomnode) IsSame(that purecef.Domnode) bool { return n == that }
264+func (n stubEditableDomnode) GetName() string { return "" }
265+func (n stubEditableDomnode) GetValue() string { return "" }
266+func (n stubEditableDomnode) SetValue(string) int32 { return 0 }
267+func (n stubEditableDomnode) GetAsMarkup() string { return "" }
268+func (n stubEditableDomnode) GetDocument() purecef.Domdocument { return nil }
269+func (n stubEditableDomnode) GetParent() purecef.Domnode { return nil }
270+func (n stubEditableDomnode) GetPreviousSibling() purecef.Domnode { return nil }
271+func (n stubEditableDomnode) GetNextSibling() purecef.Domnode { return nil }
272+func (n stubEditableDomnode) HasChildren() bool { return false }
273+func (n stubEditableDomnode) GetFirstChild() purecef.Domnode { return nil }
274+func (n stubEditableDomnode) GetLastChild() purecef.Domnode { return nil }
275+func (n stubEditableDomnode) GetElementTagName() string { return "" }
276+func (n stubEditableDomnode) HasElementAttributes() bool { return false }
277+func (n stubEditableDomnode) HasElementAttribute(string) bool { return false }
278+func (n stubEditableDomnode) GetElementAttribute(string) string { return "" }
279+func (n stubEditableDomnode) GetElementAttributes(uintptr) {}
280+func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return 0 }
281+func (n stubEditableDomnode) GetElementInnerText() string { return "" }
282+func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 }
283+
125 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {284 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
126 rect := &purecef.Rect{}285 rect := &purecef.Rect{}
127 h := &handlerSet{286 h := &handlerSet{
M internal/infrastructure/handlers/clipboard.go +5 -4
@@ -70,10 +70,11 @@ func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler {
7070 return nil, nil
7171 }
7272 if err := h.orchestrator.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{
73- Text: req.Text,
74- Action: req.Action,
75- SourceEngine: port.ClipboardSourceWebKit,
76- ViewID: webviewID,
73+ Text: req.Text,
74+ Action: req.Action,
75+ SourceEngine: port.ClipboardSourceWebKit,
76+ ViewID: webviewID,
77+ NativeHandled: true,
7778 }); err != nil {
7879 log.Debug().Err(err).Msg("clipboard explicit handling failed")
7980 }
@@ -70,10 +70,11 @@ func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler {
70 return nil, nil70 return nil, nil
71 }71 }
72 if err := h.orchestrator.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{72 if err := h.orchestrator.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{
73- Text: req.Text,73+ Text: req.Text,
74- Action: req.Action,74+ Action: req.Action,
75- SourceEngine: port.ClipboardSourceWebKit,75+ SourceEngine: port.ClipboardSourceWebKit,
76- ViewID: webviewID,76+ ViewID: webviewID,
77+ NativeHandled: true,
77 }); err != nil {78 }); err != nil {
78 log.Debug().Err(err).Msg("clipboard explicit handling failed")79 log.Debug().Err(err).Msg("clipboard explicit handling failed")
79 }80 }
M internal/infrastructure/handlers/clipboard_test.go +3 -0
@@ -76,4 +76,7 @@ func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.
7676 if orchestrator.explicit.ViewID != 42 {
7777 t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42)
7878 }
79+ if !orchestrator.explicit.NativeHandled {
80+ t.Fatal("explicit native handled = false, want true")
81+ }
7982 }
@@ -76,4 +76,7 @@ func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.
76 if orchestrator.explicit.ViewID != 42 {76 if orchestrator.explicit.ViewID != 42 {
77 t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42)77 t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42)
78 }78 }
79+ if !orchestrator.explicit.NativeHandled {
80+ t.Fatal("explicit native handled = false, want true")
81+ }
79 }82 }
M internal/infrastructure/webkit/injector.go +8 -2
@@ -96,11 +96,17 @@ const explicitCopyScript = `(function() {
9696 return action;
9797 }
9898
99- document.addEventListener('copy', function() {
99+ document.addEventListener('copy', function(event) {
100+ if (event && event.isTrusted === false) {
101+ return;
102+ }
100103 postExplicitCopy(consumePendingAction('copy'), selectedText());
101104 }, true);
102105
103- document.addEventListener('cut', function() {
106+ document.addEventListener('cut', function(event) {
107+ if (event && event.isTrusted === false) {
108+ return;
109+ }
104110 postExplicitCopy(consumePendingAction('cut'), selectedText());
105111 }, true);
106112
@@ -96,11 +96,17 @@ const explicitCopyScript = `(function() {
96 return action;96 return action;
97 }97 }
98 98
99- document.addEventListener('copy', function() {99+ document.addEventListener('copy', function(event) {
100+ if (event && event.isTrusted === false) {
101+ return;
102+ }
100 postExplicitCopy(consumePendingAction('copy'), selectedText());103 postExplicitCopy(consumePendingAction('copy'), selectedText());
101 }, true);104 }, true);
102 105
103- document.addEventListener('cut', function() {106+ document.addEventListener('cut', function(event) {
107+ if (event && event.isTrusted === false) {
108+ return;
109+ }
104 postExplicitCopy(consumePendingAction('cut'), selectedText());110 postExplicitCopy(consumePendingAction('cut'), selectedText());
105 }, true);111 }, true);
106 112

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close