fix(clipboard): harden bridges and align engine behavior
- commit
- 2a3f82dde03f28279c2e6824c6e0e60d636942d4
- author
- brice · · 2026-04-17 15:12 UTC
- parent
- ee4aed5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
20 files · +499 -185
M go.mod +1 -3
| @@ -4,7 +4,7 @@ go 1.26.1 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | 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 | |
| 8 | 8 | github.com/bnema/purego-ffmpeg v0.3.1 | |
| 9 | 9 | github.com/bnema/purego-pipewire v0.1.2 | |
| 10 | 10 | github.com/bnema/purego-sqlite v0.1.2 | |
| @@ -69,5 +69,3 @@ require ( | |||
| 69 | 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | |
| 70 | 70 | modernc.org/sqlite v1.47.0 // indirect | |
| 71 | 71 | ) | |
| 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.2 | 6 | 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 |
| 8 | github.com/bnema/purego-ffmpeg v0.3.1 | 8 | github.com/bnema/purego-ffmpeg v0.3.1 | ||
| 9 | github.com/bnema/purego-pipewire v0.1.2 | 9 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 10 | github.com/bnema/purego-sqlite v0.1.2 | 10 | github.com/bnema/purego-sqlite v0.1.2 | ||
| @@ -69,5 +69,3 @@ require ( | |||||
| 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | 69 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | ||
| 70 | modernc.org/sqlite v1.47.0 // indirect | 70 | 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 { | |||
| 21 | 21 | ||
| 22 | 22 | // ExplicitClipboardInput carries explicit copy requests. | |
| 23 | 23 | 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 | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | // 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 string | 24 | + | Text string |
| 25 | - | SourceEngine ClipboardSource | 25 | + | SourceEngine ClipboardSource |
| 26 | - | ViewID WebViewID | 26 | + | ViewID WebViewID |
| 27 | - | Action string | 27 | + | 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( | |||
| 165 | 165 | uc.mu.Unlock() | |
| 166 | 166 | return nil | |
| 167 | 167 | } | |
| 168 | - | if uc.clipboard == nil { | |
| 168 | + | if !input.NativeHandled && uc.clipboard == nil { | |
| 169 | 169 | uc.mu.Unlock() | |
| 170 | 170 | return fmt.Errorf("clipboard not available") | |
| 171 | 171 | } | |
| 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 | + | } | |
| 181 | 183 | } | |
| 182 | 184 | ||
| 183 | 185 | uc.lastExplicit[scope] = explicitCopyState{text: input.Text, at: currentTime} | |
| @@ -186,11 +188,15 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||
| 186 | 188 | if uc.toast != nil { | |
| 187 | 189 | uc.toast(textLen) | |
| 188 | 190 | } | |
| 189 | - | logging.FromContext(ctx).Debug(). | |
| 191 | + | logEntry := logging.FromContext(ctx).Debug(). | |
| 190 | 192 | Int("text_len", textLen). | |
| 191 | 193 | 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") | |
| 194 | 200 | return nil | |
| 195 | 201 | } | |
| 196 | 202 | ||
| @@ -165,19 +165,21 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||||
| 165 | uc.mu.Unlock() | 165 | uc.mu.Unlock() | ||
| 166 | return nil | 166 | 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 nil | 200 | return nil | ||
| 195 | } | 201 | } | ||
| 196 | 202 | ||||
M internal/application/usecase/clipboard_text_orchestrator_test.go +48 -0
| @@ -19,6 +19,11 @@ type blockingClipboard struct { | |||
| 19 | 19 | writes []string | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | + | type countingClipboard struct { | |
| 23 | + | mu sync.Mutex | |
| 24 | + | writes int | |
| 25 | + | } | |
| 26 | + | ||
| 22 | 27 | type staticAutoCopyConfig struct { | |
| 23 | 28 | enabled bool | |
| 24 | 29 | } | |
| @@ -52,6 +57,24 @@ func (b *blockingClipboard) writeCount() int { | |||
| 52 | 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 | 78 | func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) { | |
| 56 | 79 | ctx := context.Background() | |
| 57 | 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 | 221 | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) { | |
| 174 | 222 | ctx := context.Background() | |
| 175 | 223 | clipboard := portmocks.NewMockClipboard(t) | |
| @@ -19,6 +19,11 @@ type blockingClipboard struct { | |||||
| 19 | writes []string | 19 | 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 bool | 28 | 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( | |||
| 82 | 82 | x, | |
| 83 | 83 | y, | |
| 84 | 84 | func(item port.MenuItem) { | |
| 85 | - | var explicitCopyCallback func(string) | |
| 86 | 85 | var copiedCallback func(string) | |
| 87 | 86 | if h.wv.engine != nil { | |
| 88 | - | explicitCopyCallback = func(text string) { | |
| 89 | - | h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, "copy", text) | |
| 90 | - | } | |
| 91 | 87 | 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 | 91 | func() { | |
| 96 | 92 | callback.Cancel() | |
| @@ -170,7 +166,6 @@ func dispatchContextMenuSelection( | |||
| 170 | 166 | ctx context.Context, | |
| 171 | 167 | executor port.ContextMenuActionExecutor, | |
| 172 | 168 | callback purecef.RunContextMenuCallback, | |
| 173 | - | explicitCopyCallback func(text string), | |
| 174 | 169 | copiedCallback func(text string), | |
| 175 | 170 | commandIDByAction map[port.MenuAction]int32, | |
| 176 | 171 | item port.MenuItem, | |
| @@ -181,9 +176,13 @@ func dispatchContextMenuSelection( | |||
| 181 | 176 | return | |
| 182 | 177 | } | |
| 183 | 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 { | |
| 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") | |
| 187 | 186 | callback.Cancel() | |
| 188 | 187 | return | |
| 189 | 188 | } | |
| @@ -213,11 +212,6 @@ func dispatchContextMenuSelection( | |||
| 213 | 212 | ||
| 214 | 213 | func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string { | |
| 215 | 214 | switch action { | |
| 216 | - | case port.MenuActionCopySelection: | |
| 217 | - | if menuContext.SelectionText != "" { | |
| 218 | - | return menuContext.SelectionText | |
| 219 | - | } | |
| 220 | - | return "" | |
| 221 | 215 | case port.MenuActionCopyLink: | |
| 222 | 216 | return menuContext.LinkURI | |
| 223 | 217 | default: | |
| @@ -233,8 +227,7 @@ func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | |||
| 233 | 227 | port.MenuActionOpenLinkNewTab, | |
| 234 | 228 | port.MenuActionCopyLink, | |
| 235 | 229 | port.MenuActionCopyImage, | |
| 236 | - | port.MenuActionInspectElement, | |
| 237 | - | port.MenuActionCopySelection: | |
| 230 | + | port.MenuActionInspectElement: | |
| 238 | 231 | return true | |
| 239 | 232 | default: | |
| 240 | 233 | 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.notifyClipboardCopied | 87 | 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 | return | 176 | 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 | return | 187 | 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.LinkURI | 216 | 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 true | 231 | return true | ||
| 239 | default: | 232 | default: | ||
| 240 | return false | 233 | return false | ||
M internal/infrastructure/cef/context_menu_adapter_test.go +19 -29
| @@ -105,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() { | |||
| 105 | 105 | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |
| 106 | 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 | 109 | port.MenuActionReload: 102, | |
| 110 | 110 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | |
| 111 | 111 | ||
| @@ -116,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||
| 116 | 116 | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | |
| 117 | 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 | 120 | port.MenuActionInspectElement: 204, | |
| 121 | 121 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | |
| 122 | 122 | ||
| @@ -135,7 +135,6 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||
| 135 | 135 | executor, | |
| 136 | 136 | callback, | |
| 137 | 137 | nil, | |
| 138 | - | nil, | |
| 139 | 138 | map[port.MenuAction]int32{port.MenuActionReload: 102}, | |
| 140 | 139 | port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}, | |
| 141 | 140 | menuContext, | |
| @@ -159,7 +158,6 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||
| 159 | 158 | executor, | |
| 160 | 159 | callback, | |
| 161 | 160 | nil, | |
| 162 | - | nil, | |
| 163 | 161 | map[port.MenuAction]int32{}, | |
| 164 | 162 | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | |
| 165 | 163 | menuContext, | |
| @@ -173,34 +171,26 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||
| 173 | 171 | require.Equal(t, 1, callback.cancelCalls) | |
| 174 | 172 | } | |
| 175 | 173 | ||
| 176 | - | func TestContextMenuSelectionRoutesSelectionCopyThroughSharedExplicitPath(t *testing.T) { | |
| 174 | + | func TestContextMenuSelectionUsesNativeCommandForCopySelection(t *testing.T) { | |
| 177 | 175 | callback := &stubRunContextMenuCallback{} | |
| 178 | 176 | executor := &stubContextMenuExecutor{} | |
| 179 | 177 | 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) | |
| 204 | 194 | } | |
| 205 | 195 | ||
| 206 | 196 | 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 []int | 177 | var copiedLens []int | ||
| 180 | - | var explicitCopies []string | 178 | + | |
| 181 | - | 179 | + | dispatchContextMenuSelection( | |
| 182 | - | for _, tc := range []struct { | 180 | + | context.Background(), |
| 183 | - | action port.MenuAction | 181 | + | executor, |
| 184 | - | text string | 182 | + | 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) | |||
| 286 | 286 | 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 | 289 | func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) { | |
| 294 | 290 | if e == nil || text == "" { | |
| 295 | 291 | return | |
| @@ -302,10 +298,11 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action | |||
| 302 | 298 | } | |
| 303 | 299 | textLen := utf8.RuneCountInString(text) | |
| 304 | 300 | 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, | |
| 309 | 306 | }); err != nil { | |
| 310 | 307 | logging.FromContext(e.currentContext()).Debug(). | |
| 311 | 308 | Err(err). | |
| @@ -286,10 +286,6 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) | |||||
| 286 | return nil | 286 | 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 | return | 291 | 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( | |||
| 252 | 252 | } | |
| 253 | 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 | 255 | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |
| 261 | 256 | ||
| 262 | 257 | 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.handleEditableFocusBridge | 255 | 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) { | |||
| 85 | 85 | ||
| 86 | 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 | 91 | 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 ( | |||
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | 15 | 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" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 24 | + | var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate | |
| 25 | + | ||
| 23 | 26 | const rendererBridgeExtensionJS = ` | |
| 24 | 27 | (function() { | |
| 25 | 28 | native function Dispatch(action, payload); | |
| @@ -252,7 +255,25 @@ func (*rendererBridgeProcessHandler) OnUncaughtException( | |||
| 252 | 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 | 278 | func (*rendererBridgeProcessHandler) OnProcessMessageReceived( | |
| 258 | 279 | _ 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 ( | |||
| 4 | 4 | "os" | |
| 5 | 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 | 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 | 82 | func TestRendererBridgeExtensionJS_DoesNotExposeWritableGlobalDispatch(t *testing.T) { | |
| 11 | 83 | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberNativeBridge") | |
| 12 | 84 | require.NotContains(t, rendererBridgeExtensionJS, "DumberBridgeDispatch =") | |
| @@ -48,3 +120,21 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | |||
| 48 | 120 | require.Equal(t, "copied text", req.Text) | |
| 49 | 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 | + | } | |
| @@ -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 { | |||
| 56 | 56 | defaultConfigPayload func() ([]byte, error) | |
| 57 | 57 | 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 | 59 | // onEditableFocus is called when JS reports that an editable element gained | |
| 64 | 60 | // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode. | |
| 65 | 61 | onEditableFocus func(browser purecef.Browser) | |
| @@ -166,9 +162,6 @@ func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path stri | |||
| 166 | 162 | case path == "/api/transcode" && (method == "" || strings.EqualFold(method, "GET")): | |
| 167 | 163 | return h.handleTranscodeAPI(request) | |
| 168 | 164 | ||
| 169 | - | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | |
| 170 | - | return h.handleClipboardSet(request) | |
| 171 | - | ||
| 172 | 165 | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | |
| 173 | 166 | return h.handleFocusSync(browser) | |
| 174 | 167 | ||
| @@ -245,59 +238,6 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece | |||
| 245 | 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 | 241 | func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler { | |
| 302 | 242 | if browser == nil { | |
| 303 | 243 | 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.RWMutex | 57 | 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 gained | 59 | // 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "errors" | |
| 7 | - | "strings" | |
| 7 | + | "os" | |
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | 10 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| @@ -57,23 +57,10 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |||
| 57 | 57 | require.JSONEq(t, `{"text":"copied from js"}`, string(body)) | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 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") | |
| 62 | 62 | 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") | |
| 79 | 66 | } | |
| @@ -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 ( | |||
| 8 | 8 | "math" | |
| 9 | 9 | "sync" | |
| 10 | 10 | "sync/atomic" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | 14 | "github.com/bnema/puregotk/v4/glib" | |
| @@ -30,6 +31,8 @@ var errDestroyed = errors.New("cef: webview is destroyed") | |||
| 30 | 31 | // errNoBrowser is returned when the browser has not been created yet. | |
| 31 | 32 | var errNoBrowser = errors.New("cef: browser not yet created") | |
| 32 | 33 | ||
| 34 | + | const clipboardSelectionDebounceDelay = 300 * time.Millisecond | |
| 35 | + | ||
| 33 | 36 | // WebView implements port.WebView using a CEF off-screen browser rendered | |
| 34 | 37 | // through a renderPipeline and driven by an inputBridge. | |
| 35 | 38 | type WebView struct { | |
| @@ -65,13 +68,16 @@ type WebView struct { | |||
| 65 | 68 | callbacks *port.WebViewCallbacks | |
| 66 | 69 | ||
| 67 | 70 | // 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 | |
| 75 | 81 | ||
| 76 | 82 | // Last known hover URI for middle-click → new tab. | |
| 77 | 83 | lastHoverURI string | |
| @@ -474,6 +480,7 @@ func (wv *WebView) Destroy() { | |||
| 474 | 480 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 475 | 481 | return | |
| 476 | 482 | } | |
| 483 | + | wv.cancelSelectionDebounce() | |
| 477 | 484 | if wv.resizeReconciler != nil { | |
| 478 | 485 | wv.resizeReconciler.stop() | |
| 479 | 486 | } | |
| @@ -575,6 +582,76 @@ func (wv *WebView) setSelectedText(text string) (string, bool) { | |||
| 575 | 582 | 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 | 655 | func (wv *WebView) selectedTextSnapshot() string { | |
| 579 | 656 | wv.mu.RLock() | |
| 580 | 657 | 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 rendered | 36 | // 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.WebViewCallbacks | 68 | callbacks *port.WebViewCallbacks | ||
| 66 | 69 | ||||
| 67 | // State cache (mutex-protected). | 70 | // State cache (mutex-protected). | ||
| 68 | - | uri string | 71 | + | uri string |
| 69 | - | title string | 72 | + | title string |
| 70 | - | progress float64 | 73 | + | progress float64 |
| 71 | - | canGoBack bool | 74 | + | canGoBack bool |
| 72 | - | canGoFwd bool | 75 | + | canGoFwd bool |
| 73 | - | isLoading bool | 76 | + | isLoading bool |
| 74 | - | selectedText string | 77 | + | 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 string | 83 | 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 | return | 481 | 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, changed | 582 | 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( | |||
| 97 | 97 | return 1 | |
| 98 | 98 | } | |
| 99 | 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 | 102 | case rendererBridgeActionFocusSync: | |
| 101 | 103 | h.wv.engine.handleEditableFocusBridge(browser) | |
| 102 | 104 | case rendererBridgeActionReady: | |
| @@ -388,7 +390,7 @@ func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText stri | |||
| 388 | 390 | Msg("cef: text selection changed") | |
| 389 | 391 | } | |
| 390 | 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 | ||
| @@ -97,6 +97,8 @@ func (h *handlerSet) OnProcessMessageReceived( | |||||
| 97 | return 1 | 97 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "sync" | |
| 5 | 6 | "testing" | |
| 7 | + | "time" | |
| 6 | 8 | ||
| 7 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 9 | 13 | "github.com/stretchr/testify/require" | |
| 10 | 14 | ||
| 11 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 16 | ) | |
| 13 | 17 | ||
| 14 | 18 | 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 | |
| 17 | 23 | } | |
| 18 | 24 | ||
| 19 | 25 | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |
| 26 | + | r.mu.Lock() | |
| 20 | 27 | r.selection = input | |
| 28 | + | r.selectionCalls++ | |
| 29 | + | r.mu.Unlock() | |
| 21 | 30 | return nil | |
| 22 | 31 | } | |
| 23 | 32 | ||
| @@ -117,11 +126,161 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test | |||
| 117 | 126 | ||
| 118 | 127 | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 119 | 128 | ||
| 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) | |
| 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 | 284 | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | |
| 126 | 285 | rect := &purecef.Rect{} | |
| 127 | 286 | 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.SelectionClipboardInput | 19 | + | mu sync.Mutex |
| 16 | - | explicit port.ExplicitClipboardInput | 20 | + | 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 = input | 27 | r.selection = input | ||
| 28 | + | r.selectionCalls++ | |||
| 29 | + | r.mu.Unlock() | |||
| 21 | return nil | 30 | 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 { | |||
| 70 | 70 | return nil, nil | |
| 71 | 71 | } | |
| 72 | 72 | 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, | |
| 77 | 78 | }); err != nil { | |
| 78 | 79 | log.Debug().Err(err).Msg("clipboard explicit handling failed") | |
| 79 | 80 | } | |
| @@ -70,10 +70,11 @@ func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler { | |||||
| 70 | return nil, nil | 70 | 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. | |||
| 76 | 76 | if orchestrator.explicit.ViewID != 42 { | |
| 77 | 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 | } | |
| @@ -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() { | |||
| 96 | 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 | 103 | postExplicitCopy(consumePendingAction('copy'), selectedText()); | |
| 101 | 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 | 110 | postExplicitCopy(consumePendingAction('cut'), selectedText()); | |
| 105 | 111 | }, true); | |
| 106 | 112 | ||
| @@ -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 | ||||