refactor(clipboard): unify engine clipboard handling
- commit
- ee4aed57df635aa19ffd1753fbd8428fe1374222
- author
- brice · · 2026-04-17 14:45 UTC
- parent
- 2dc133d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
36 files · +2219 -178
M cmd/cef-helper/main.go +4 -4
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "os" | |
| 14 | 14 | "runtime" | |
| 15 | 15 | ||
| 16 | + | infracef "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 16 | 17 | cef "github.com/bnema/purego-cef/cef" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -22,10 +23,9 @@ func main() { | |||
| 22 | 23 | ||
| 23 | 24 | // cef_execute_process will handle the subprocess lifecycle and call | |
| 24 | 25 | // os.Exit internally if this is a valid CEF subprocess invocation. | |
| 25 | - | // If it returns (code < 0), this is not a subprocess — shouldn't | |
| 26 | - | // happen in normal operation since CEF only launches this binary | |
| 27 | - | // with --type= flags. | |
| 28 | - | cef.MaybeExitSubprocess() | |
| 26 | + | // We pass the lightweight subprocess app so renderer-process handlers | |
| 27 | + | // (native JS bridge, custom schemes, etc.) are available in helpers. | |
| 28 | + | cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp()) | |
| 29 | 29 | ||
| 30 | 30 | // If we get here, something went wrong. | |
| 31 | 31 | os.Exit(1) | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "os" | 13 | "os" | ||
| 14 | "runtime" | 14 | "runtime" | ||
| 15 | 15 | ||||
| 16 | + | infracef "github.com/bnema/dumber/internal/infrastructure/cef" | |||
| 16 | cef "github.com/bnema/purego-cef/cef" | 17 | cef "github.com/bnema/purego-cef/cef" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -22,10 +23,9 @@ func main() { | |||||
| 22 | 23 | ||||
| 23 | // cef_execute_process will handle the subprocess lifecycle and call | 24 | // cef_execute_process will handle the subprocess lifecycle and call | ||
| 24 | // os.Exit internally if this is a valid CEF subprocess invocation. | 25 | // os.Exit internally if this is a valid CEF subprocess invocation. | ||
| 25 | - | // If it returns (code < 0), this is not a subprocess — shouldn't | 26 | + | // We pass the lightweight subprocess app so renderer-process handlers |
| 26 | - | // happen in normal operation since CEF only launches this binary | 27 | + | // (native JS bridge, custom schemes, etc.) are available in helpers. |
| 27 | - | // with --type= flags. | 28 | + | cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp()) |
| 28 | - | cef.MaybeExitSubprocess() | |||
| 29 | 29 | ||||
| 30 | // If we get here, something went wrong. | 30 | // If we get here, something went wrong. | ||
| 31 | os.Exit(1) | 31 | os.Exit(1) | ||
M go.mod +2 -0
| @@ -69,3 +69,5 @@ 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 | |
| @@ -69,3 +69,5 @@ 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 | |||
A internal/application/port/clipboard_events.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package port | |
| 2 | + | ||
| 3 | + | import "context" | |
| 4 | + | ||
| 5 | + | // ClipboardSource identifies the originating engine for clipboard events. | |
| 6 | + | type ClipboardSource string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | // ClipboardSourceCEF marks events from the CEF engine. | |
| 10 | + | ClipboardSourceCEF ClipboardSource = "cef" | |
| 11 | + | // ClipboardSourceWebKit marks events from the WebKit engine. | |
| 12 | + | ClipboardSourceWebKit ClipboardSource = "webkit" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // SelectionClipboardInput carries auto-copy selection updates. | |
| 16 | + | type SelectionClipboardInput struct { | |
| 17 | + | Text string | |
| 18 | + | SourceEngine ClipboardSource | |
| 19 | + | ViewID WebViewID | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // ExplicitClipboardInput carries explicit copy requests. | |
| 23 | + | type ExplicitClipboardInput struct { | |
| 24 | + | Text string | |
| 25 | + | SourceEngine ClipboardSource | |
| 26 | + | ViewID WebViewID | |
| 27 | + | Action string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // ClipboardTextOrchestrator coordinates shared clipboard behavior. | |
| 31 | + | type ClipboardTextOrchestrator interface { | |
| 32 | + | HandleSelectionUpdate(ctx context.Context, input SelectionClipboardInput) error | |
| 33 | + | HandleExplicitCopy(ctx context.Context, input ExplicitClipboardInput) error | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import "context" | |||
| 4 | + | ||||
| 5 | + | // ClipboardSource identifies the originating engine for clipboard events. | |||
| 6 | + | type ClipboardSource string | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | // ClipboardSourceCEF marks events from the CEF engine. | |||
| 10 | + | ClipboardSourceCEF ClipboardSource = "cef" | |||
| 11 | + | // ClipboardSourceWebKit marks events from the WebKit engine. | |||
| 12 | + | ClipboardSourceWebKit ClipboardSource = "webkit" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // SelectionClipboardInput carries auto-copy selection updates. | |||
| 16 | + | type SelectionClipboardInput struct { | |||
| 17 | + | Text string | |||
| 18 | + | SourceEngine ClipboardSource | |||
| 19 | + | ViewID WebViewID | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // ExplicitClipboardInput carries explicit copy requests. | |||
| 23 | + | type ExplicitClipboardInput struct { | |||
| 24 | + | Text string | |||
| 25 | + | SourceEngine ClipboardSource | |||
| 26 | + | ViewID WebViewID | |||
| 27 | + | Action string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // ClipboardTextOrchestrator coordinates shared clipboard behavior. | |||
| 31 | + | type ClipboardTextOrchestrator interface { | |||
| 32 | + | HandleSelectionUpdate(ctx context.Context, input SelectionClipboardInput) error | |||
| 33 | + | HandleExplicitCopy(ctx context.Context, input ExplicitClipboardInput) error | |||
| 34 | + | } | |||
M internal/application/port/context_menu.go +10 -9
| @@ -19,15 +19,16 @@ const ( | |||
| 19 | 19 | ||
| 20 | 20 | // MenuContext captures the state needed to build and execute a context menu. | |
| 21 | 21 | type MenuContext struct { | |
| 22 | - | PageURI string | |
| 23 | - | LinkURI string | |
| 24 | - | ImageURI string | |
| 25 | - | HasSelection bool | |
| 26 | - | IsEditable bool | |
| 27 | - | CanGoBack bool | |
| 28 | - | CanGoForward bool | |
| 29 | - | X int | |
| 30 | - | Y int | |
| 22 | + | PageURI string | |
| 23 | + | LinkURI string | |
| 24 | + | ImageURI string | |
| 25 | + | SelectionText string | |
| 26 | + | HasSelection bool | |
| 27 | + | IsEditable bool | |
| 28 | + | CanGoBack bool | |
| 29 | + | CanGoForward bool | |
| 30 | + | X int | |
| 31 | + | Y int | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | // MenuItem is a normalized context menu entry. | |
| @@ -19,15 +19,16 @@ const ( | |||||
| 19 | 19 | ||||
| 20 | // MenuContext captures the state needed to build and execute a context menu. | 20 | // MenuContext captures the state needed to build and execute a context menu. | ||
| 21 | type MenuContext struct { | 21 | type MenuContext struct { | ||
| 22 | - | PageURI string | 22 | + | PageURI string |
| 23 | - | LinkURI string | 23 | + | LinkURI string |
| 24 | - | ImageURI string | 24 | + | ImageURI string |
| 25 | - | HasSelection bool | 25 | + | SelectionText string |
| 26 | - | IsEditable bool | 26 | + | HasSelection bool |
| 27 | - | CanGoBack bool | 27 | + | IsEditable bool |
| 28 | - | CanGoForward bool | 28 | + | CanGoBack bool |
| 29 | - | X int | 29 | + | CanGoForward bool |
| 30 | - | Y int | 30 | + | X int |
| 31 | + | Y int | |||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| 33 | // MenuItem is a normalized context menu entry. | 34 | // MenuItem is a normalized context menu entry. | ||
M internal/application/port/engine.go +6 -5
| @@ -193,11 +193,12 @@ type WebUIMessage struct { | |||
| 193 | 193 | ||
| 194 | 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | |
| 195 | 195 | type HandlerDependencies struct { | |
| 196 | - | HistoryUC HomepageHistory | |
| 197 | - | FavoritesUC HomepageFavorites | |
| 198 | - | Clipboard Clipboard | |
| 199 | - | AutoCopyConfig AutoCopyConfig | |
| 200 | - | OnClipboardCopied func(textLen int) | |
| 196 | + | HistoryUC HomepageHistory | |
| 197 | + | FavoritesUC HomepageFavorites | |
| 198 | + | Clipboard Clipboard | |
| 199 | + | AutoCopyConfig AutoCopyConfig | |
| 200 | + | ClipboardTextOrchestrator ClipboardTextOrchestrator | |
| 201 | + | OnClipboardCopied func(textLen int) | |
| 201 | 202 | HandlerDeps | |
| 202 | 203 | } | |
| 203 | 204 | ||
| @@ -193,11 +193,12 @@ type WebUIMessage struct { | |||||
| 193 | 193 | ||||
| 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | ||
| 195 | type HandlerDependencies struct { | 195 | type HandlerDependencies struct { | ||
| 196 | - | HistoryUC HomepageHistory | 196 | + | HistoryUC HomepageHistory |
| 197 | - | FavoritesUC HomepageFavorites | 197 | + | FavoritesUC HomepageFavorites |
| 198 | - | Clipboard Clipboard | 198 | + | Clipboard Clipboard |
| 199 | - | AutoCopyConfig AutoCopyConfig | 199 | + | AutoCopyConfig AutoCopyConfig |
| 200 | - | OnClipboardCopied func(textLen int) | 200 | + | ClipboardTextOrchestrator ClipboardTextOrchestrator |
| 201 | + | OnClipboardCopied func(textLen int) | |||
| 201 | HandlerDeps | 202 | HandlerDeps | ||
| 202 | } | 203 | } | ||
| 203 | 204 | ||||
A internal/application/usecase/clipboard_text_orchestrator.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | "unicode/utf8" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const explicitClipboardDedupWindow = 250 * time.Millisecond | |
| 16 | + | ||
| 17 | + | // ClipboardTextOrchestratorUseCase applies shared clipboard business rules. | |
| 18 | + | type ClipboardTextOrchestratorUseCase struct { | |
| 19 | + | clipboard port.Clipboard | |
| 20 | + | autoCopyConfig port.AutoCopyConfig | |
| 21 | + | toast func(textLen int) | |
| 22 | + | mu sync.Mutex | |
| 23 | + | lastSelection map[clipboardSelectionScope]string | |
| 24 | + | lastExplicit map[clipboardExplicitScope]explicitCopyState | |
| 25 | + | now func() time.Time | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type clipboardSelectionScope struct { | |
| 29 | + | source port.ClipboardSource | |
| 30 | + | viewID port.WebViewID | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type clipboardExplicitScope struct { | |
| 34 | + | source port.ClipboardSource | |
| 35 | + | viewID port.WebViewID | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | type explicitCopyState struct { | |
| 39 | + | text string | |
| 40 | + | at time.Time | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil) | |
| 44 | + | ||
| 45 | + | // NewClipboardTextOrchestrator creates a shared clipboard orchestrator. | |
| 46 | + | func NewClipboardTextOrchestrator( | |
| 47 | + | clipboard port.Clipboard, | |
| 48 | + | autoCopyConfig port.AutoCopyConfig, | |
| 49 | + | toast func(textLen int), | |
| 50 | + | ) *ClipboardTextOrchestratorUseCase { | |
| 51 | + | return &ClipboardTextOrchestratorUseCase{ | |
| 52 | + | clipboard: clipboard, | |
| 53 | + | autoCopyConfig: autoCopyConfig, | |
| 54 | + | toast: toast, | |
| 55 | + | lastSelection: make(map[clipboardSelectionScope]string), | |
| 56 | + | lastExplicit: make(map[clipboardExplicitScope]explicitCopyState), | |
| 57 | + | now: time.Now, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // HandleSelectionUpdate applies auto-copy selection rules. | |
| 62 | + | func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |
| 63 | + | ctx context.Context, | |
| 64 | + | input port.SelectionClipboardInput, | |
| 65 | + | ) error { | |
| 66 | + | if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() { | |
| 67 | + | if uc != nil { | |
| 68 | + | uc.mu.Lock() | |
| 69 | + | if uc.lastSelection != nil { | |
| 70 | + | delete(uc.lastSelection, selectionScope(input)) | |
| 71 | + | } | |
| 72 | + | uc.mu.Unlock() | |
| 73 | + | } | |
| 74 | + | return nil | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | if input.Text == "" { | |
| 78 | + | uc.mu.Lock() | |
| 79 | + | if uc.lastSelection == nil { | |
| 80 | + | uc.lastSelection = make(map[clipboardSelectionScope]string) | |
| 81 | + | } | |
| 82 | + | delete(uc.lastSelection, selectionScope(input)) | |
| 83 | + | uc.mu.Unlock() | |
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | if utf8.RuneCountInString(strings.TrimSpace(input.Text)) < 2 { | |
| 88 | + | return nil | |
| 89 | + | } | |
| 90 | + | textLen := utf8.RuneCountInString(input.Text) | |
| 91 | + | scope := selectionScope(input) | |
| 92 | + | now := uc.now | |
| 93 | + | if now == nil { | |
| 94 | + | now = time.Now | |
| 95 | + | } | |
| 96 | + | currentTime := now() | |
| 97 | + | ||
| 98 | + | uc.mu.Lock() | |
| 99 | + | if uc.lastSelection == nil { | |
| 100 | + | uc.lastSelection = make(map[clipboardSelectionScope]string) | |
| 101 | + | } | |
| 102 | + | if input.Text == uc.lastSelection[scope] { | |
| 103 | + | uc.mu.Unlock() | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | if uc.clipboard == nil { | |
| 107 | + | uc.mu.Unlock() | |
| 108 | + | return fmt.Errorf("clipboard not available") | |
| 109 | + | } | |
| 110 | + | if err := uc.clipboard.WriteText(ctx, input.Text); err != nil { | |
| 111 | + | uc.mu.Unlock() | |
| 112 | + | logging.FromContext(ctx).Debug(). | |
| 113 | + | Err(err). | |
| 114 | + | Int("text_len", textLen). | |
| 115 | + | Str("source_engine", string(input.SourceEngine)). | |
| 116 | + | Msg("clipboard selection write failed") | |
| 117 | + | return fmt.Errorf("clipboard write failed: %w", err) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | uc.lastSelection[scope] = input.Text | |
| 121 | + | if uc.lastExplicit == nil { | |
| 122 | + | uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState) | |
| 123 | + | } | |
| 124 | + | explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID} | |
| 125 | + | uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{ | |
| 126 | + | text: input.Text, | |
| 127 | + | at: currentTime, | |
| 128 | + | } | |
| 129 | + | uc.mu.Unlock() | |
| 130 | + | ||
| 131 | + | if uc.toast != nil { | |
| 132 | + | uc.toast(textLen) | |
| 133 | + | } | |
| 134 | + | logging.FromContext(ctx).Debug(). | |
| 135 | + | Int("text_len", textLen). | |
| 136 | + | Str("source_engine", string(input.SourceEngine)). | |
| 137 | + | Msg("clipboard selection copied") | |
| 138 | + | return nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // HandleExplicitCopy applies explicit copy rules. | |
| 142 | + | func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |
| 143 | + | ctx context.Context, | |
| 144 | + | input port.ExplicitClipboardInput, | |
| 145 | + | ) error { | |
| 146 | + | if uc == nil || input.Text == "" { | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | now := uc.now | |
| 151 | + | if now == nil { | |
| 152 | + | now = time.Now | |
| 153 | + | } | |
| 154 | + | currentTime := now() | |
| 155 | + | textLen := utf8.RuneCountInString(input.Text) | |
| 156 | + | scope := explicitScope(input) | |
| 157 | + | ||
| 158 | + | uc.mu.Lock() | |
| 159 | + | if uc.lastExplicit == nil { | |
| 160 | + | uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState) | |
| 161 | + | } | |
| 162 | + | if state, ok := uc.lastExplicit[scope]; ok && | |
| 163 | + | input.Text == state.text && | |
| 164 | + | currentTime.Sub(state.at) < explicitClipboardDedupWindow { | |
| 165 | + | uc.mu.Unlock() | |
| 166 | + | return nil | |
| 167 | + | } | |
| 168 | + | if uc.clipboard == nil { | |
| 169 | + | uc.mu.Unlock() | |
| 170 | + | return fmt.Errorf("clipboard not available") | |
| 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) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | uc.lastExplicit[scope] = explicitCopyState{text: input.Text, at: currentTime} | |
| 184 | + | uc.mu.Unlock() | |
| 185 | + | ||
| 186 | + | if uc.toast != nil { | |
| 187 | + | uc.toast(textLen) | |
| 188 | + | } | |
| 189 | + | logging.FromContext(ctx).Debug(). | |
| 190 | + | Int("text_len", textLen). | |
| 191 | + | Str("source_engine", string(input.SourceEngine)). | |
| 192 | + | Str("action", input.Action). | |
| 193 | + | Msg("clipboard explicit copied") | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func selectionScope(input port.SelectionClipboardInput) clipboardSelectionScope { | |
| 198 | + | return clipboardSelectionScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func explicitScope(input port.ExplicitClipboardInput) clipboardExplicitScope { | |
| 202 | + | return clipboardExplicitScope{source: input.SourceEngine, viewID: input.ViewID} | |
| 203 | + | } | |
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | "unicode/utf8" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const explicitClipboardDedupWindow = 250 * time.Millisecond | |||
| 16 | + | ||||
| 17 | + | // ClipboardTextOrchestratorUseCase applies shared clipboard business rules. | |||
| 18 | + | type ClipboardTextOrchestratorUseCase struct { | |||
| 19 | + | clipboard port.Clipboard | |||
| 20 | + | autoCopyConfig port.AutoCopyConfig | |||
| 21 | + | toast func(textLen int) | |||
| 22 | + | mu sync.Mutex | |||
| 23 | + | lastSelection map[clipboardSelectionScope]string | |||
| 24 | + | lastExplicit map[clipboardExplicitScope]explicitCopyState | |||
| 25 | + | now func() time.Time | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type clipboardSelectionScope struct { | |||
| 29 | + | source port.ClipboardSource | |||
| 30 | + | viewID port.WebViewID | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type clipboardExplicitScope struct { | |||
| 34 | + | source port.ClipboardSource | |||
| 35 | + | viewID port.WebViewID | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | type explicitCopyState struct { | |||
| 39 | + | text string | |||
| 40 | + | at time.Time | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil) | |||
| 44 | + | ||||
| 45 | + | // NewClipboardTextOrchestrator creates a shared clipboard orchestrator. | |||
| 46 | + | func NewClipboardTextOrchestrator( | |||
| 47 | + | clipboard port.Clipboard, | |||
| 48 | + | autoCopyConfig port.AutoCopyConfig, | |||
| 49 | + | toast func(textLen int), | |||
| 50 | + | ) *ClipboardTextOrchestratorUseCase { | |||
| 51 | + | return &ClipboardTextOrchestratorUseCase{ | |||
| 52 | + | clipboard: clipboard, | |||
| 53 | + | autoCopyConfig: autoCopyConfig, | |||
| 54 | + | toast: toast, | |||
| 55 | + | lastSelection: make(map[clipboardSelectionScope]string), | |||
| 56 | + | lastExplicit: make(map[clipboardExplicitScope]explicitCopyState), | |||
| 57 | + | now: time.Now, | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // HandleSelectionUpdate applies auto-copy selection rules. | |||
| 62 | + | func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||
| 63 | + | ctx context.Context, | |||
| 64 | + | input port.SelectionClipboardInput, | |||
| 65 | + | ) error { | |||
| 66 | + | if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() { | |||
| 67 | + | if uc != nil { | |||
| 68 | + | uc.mu.Lock() | |||
| 69 | + | if uc.lastSelection != nil { | |||
| 70 | + | delete(uc.lastSelection, selectionScope(input)) | |||
| 71 | + | } | |||
| 72 | + | uc.mu.Unlock() | |||
| 73 | + | } | |||
| 74 | + | return nil | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | if input.Text == "" { | |||
| 78 | + | uc.mu.Lock() | |||
| 79 | + | if uc.lastSelection == nil { | |||
| 80 | + | uc.lastSelection = make(map[clipboardSelectionScope]string) | |||
| 81 | + | } | |||
| 82 | + | delete(uc.lastSelection, selectionScope(input)) | |||
| 83 | + | uc.mu.Unlock() | |||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | if utf8.RuneCountInString(strings.TrimSpace(input.Text)) < 2 { | |||
| 88 | + | return nil | |||
| 89 | + | } | |||
| 90 | + | textLen := utf8.RuneCountInString(input.Text) | |||
| 91 | + | scope := selectionScope(input) | |||
| 92 | + | now := uc.now | |||
| 93 | + | if now == nil { | |||
| 94 | + | now = time.Now | |||
| 95 | + | } | |||
| 96 | + | currentTime := now() | |||
| 97 | + | ||||
| 98 | + | uc.mu.Lock() | |||
| 99 | + | if uc.lastSelection == nil { | |||
| 100 | + | uc.lastSelection = make(map[clipboardSelectionScope]string) | |||
| 101 | + | } | |||
| 102 | + | if input.Text == uc.lastSelection[scope] { | |||
| 103 | + | uc.mu.Unlock() | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 106 | + | if uc.clipboard == nil { | |||
| 107 | + | uc.mu.Unlock() | |||
| 108 | + | return fmt.Errorf("clipboard not available") | |||
| 109 | + | } | |||
| 110 | + | if err := uc.clipboard.WriteText(ctx, input.Text); err != nil { | |||
| 111 | + | uc.mu.Unlock() | |||
| 112 | + | logging.FromContext(ctx).Debug(). | |||
| 113 | + | Err(err). | |||
| 114 | + | Int("text_len", textLen). | |||
| 115 | + | Str("source_engine", string(input.SourceEngine)). | |||
| 116 | + | Msg("clipboard selection write failed") | |||
| 117 | + | return fmt.Errorf("clipboard write failed: %w", err) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | uc.lastSelection[scope] = input.Text | |||
| 121 | + | if uc.lastExplicit == nil { | |||
| 122 | + | uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState) | |||
| 123 | + | } | |||
| 124 | + | explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID} | |||
| 125 | + | uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{ | |||
| 126 | + | text: input.Text, | |||
| 127 | + | at: currentTime, | |||
| 128 | + | } | |||
| 129 | + | uc.mu.Unlock() | |||
| 130 | + | ||||
| 131 | + | if uc.toast != nil { | |||
| 132 | + | uc.toast(textLen) | |||
| 133 | + | } | |||
| 134 | + | logging.FromContext(ctx).Debug(). | |||
| 135 | + | Int("text_len", textLen). | |||
| 136 | + | Str("source_engine", string(input.SourceEngine)). | |||
| 137 | + | Msg("clipboard selection copied") | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // HandleExplicitCopy applies explicit copy rules. | |||
| 142 | + | func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||
| 143 | + | ctx context.Context, | |||
| 144 | + | input port.ExplicitClipboardInput, | |||
| 145 | + | ) error { | |||
| 146 | + | if uc == nil || input.Text == "" { | |||
| 147 | + | return nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | now := uc.now | |||
| 151 | + | if now == nil { | |||
| 152 | + | now = time.Now | |||
| 153 | + | } | |||
| 154 | + | currentTime := now() | |||
| 155 | + | textLen := utf8.RuneCountInString(input.Text) | |||
| 156 | + | scope := explicitScope(input) | |||
| 157 | + | ||||
| 158 | + | uc.mu.Lock() | |||
| 159 | + | if uc.lastExplicit == nil { | |||
| 160 | + | uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState) | |||
| 161 | + | } | |||
| 162 | + | if state, ok := uc.lastExplicit[scope]; ok && | |||
| 163 | + | input.Text == state.text && | |||
| 164 | + | currentTime.Sub(state.at) < explicitClipboardDedupWindow { | |||
| 165 | + | uc.mu.Unlock() | |||
| 166 | + | return nil | |||
| 167 | + | } | |||
| 168 | + | if uc.clipboard == nil { | |||
| 169 | + | uc.mu.Unlock() | |||
| 170 | + | return fmt.Errorf("clipboard not available") | |||
| 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) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | uc.lastExplicit[scope] = explicitCopyState{text: input.Text, at: currentTime} | |||
| 184 | + | uc.mu.Unlock() | |||
| 185 | + | ||||
| 186 | + | if uc.toast != nil { | |||
| 187 | + | uc.toast(textLen) | |||
| 188 | + | } | |||
| 189 | + | logging.FromContext(ctx).Debug(). | |||
| 190 | + | Int("text_len", textLen). | |||
| 191 | + | Str("source_engine", string(input.SourceEngine)). | |||
| 192 | + | Str("action", input.Action). | |||
| 193 | + | Msg("clipboard explicit copied") | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func selectionScope(input port.SelectionClipboardInput) clipboardSelectionScope { | |||
| 198 | + | return clipboardSelectionScope{source: input.SourceEngine, viewID: input.ViewID} | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func explicitScope(input port.ExplicitClipboardInput) clipboardExplicitScope { | |||
| 202 | + | return clipboardExplicitScope{source: input.SourceEngine, viewID: input.ViewID} | |||
| 203 | + | } | |||
A internal/application/usecase/clipboard_text_orchestrator_test.go +391 -0
| @@ -0,0 +1,391 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type blockingClipboard struct { | |
| 16 | + | started chan struct{} | |
| 17 | + | release chan struct{} | |
| 18 | + | mu sync.Mutex | |
| 19 | + | writes []string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type staticAutoCopyConfig struct { | |
| 23 | + | enabled bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (s *staticAutoCopyConfig) IsAutoCopyEnabled() bool { return s.enabled } | |
| 27 | + | ||
| 28 | + | func newBlockingClipboard() *blockingClipboard { | |
| 29 | + | return &blockingClipboard{ | |
| 30 | + | started: make(chan struct{}, 2), | |
| 31 | + | release: make(chan struct{}), | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (b *blockingClipboard) WriteText(_ context.Context, text string) error { | |
| 36 | + | b.mu.Lock() | |
| 37 | + | b.writes = append(b.writes, text) | |
| 38 | + | b.mu.Unlock() | |
| 39 | + | b.started <- struct{}{} | |
| 40 | + | <-b.release | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (*blockingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |
| 45 | + | func (*blockingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |
| 46 | + | func (*blockingClipboard) Clear(context.Context) error { return nil } | |
| 47 | + | func (*blockingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |
| 48 | + | ||
| 49 | + | func (b *blockingClipboard) writeCount() int { | |
| 50 | + | b.mu.Lock() | |
| 51 | + | defer b.mu.Unlock() | |
| 52 | + | return len(b.writes) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) { | |
| 56 | + | ctx := context.Background() | |
| 57 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 58 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: false} | |
| 59 | + | var toastCalls []int | |
| 60 | + | ||
| 61 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 62 | + | toastCalls = append(toastCalls, textLen) | |
| 63 | + | }) | |
| 64 | + | ||
| 65 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "hello", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |
| 66 | + | t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | if len(toastCalls) != 0 { | |
| 70 | + | t.Fatalf("toast calls = %v, want none", toastCalls) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_WritesOncePerUniqueSelectionAndResetsAfterEmptySelection(t *testing.T) { | |
| 75 | + | ctx := context.Background() | |
| 76 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 77 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 78 | + | var toastCalls []int | |
| 79 | + | ||
| 80 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2) | |
| 81 | + | ||
| 82 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 83 | + | toastCalls = append(toastCalls, textLen) | |
| 84 | + | }) | |
| 85 | + | ||
| 86 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "é", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |
| 87 | + | t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err) | |
| 88 | + | } | |
| 89 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |
| 90 | + | t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err) | |
| 91 | + | } | |
| 92 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |
| 93 | + | t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err) | |
| 94 | + | } | |
| 95 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |
| 96 | + | t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err) | |
| 97 | + | } | |
| 98 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |
| 99 | + | t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 { | |
| 103 | + | t.Fatalf("toast calls = %v, want [2 2]", toastCalls) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossEngines(t *testing.T) { | |
| 108 | + | ctx := context.Background() | |
| 109 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 110 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 111 | + | ||
| 112 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 113 | + | ||
| 114 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 115 | + | ||
| 116 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit}); err != nil { | |
| 117 | + | t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err) | |
| 118 | + | } | |
| 119 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |
| 120 | + | t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossViews(t *testing.T) { | |
| 125 | + | ctx := context.Background() | |
| 126 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 127 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 128 | + | ||
| 129 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 130 | + | ||
| 131 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 132 | + | ||
| 133 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 134 | + | t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err) | |
| 135 | + | } | |
| 136 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 2}); err != nil { | |
| 137 | + | t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPayloadsWithinWindow(t *testing.T) { | |
| 142 | + | ctx := context.Background() | |
| 143 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 144 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 145 | + | var toastCalls []int | |
| 146 | + | ||
| 147 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2) | |
| 148 | + | ||
| 149 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 150 | + | toastCalls = append(toastCalls, textLen) | |
| 151 | + | }) | |
| 152 | + | ||
| 153 | + | now := time.Unix(0, 0) | |
| 154 | + | uc.now = func() time.Time { return now } | |
| 155 | + | ||
| 156 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |
| 157 | + | t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err) | |
| 158 | + | } | |
| 159 | + | now = now.Add(100 * time.Millisecond) | |
| 160 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |
| 161 | + | t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err) | |
| 162 | + | } | |
| 163 | + | now = now.Add(151 * time.Millisecond) | |
| 164 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |
| 165 | + | t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 { | |
| 169 | + | t.Fatalf("toast calls = %v, want [2 2]", toastCalls) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) { | |
| 174 | + | ctx := context.Background() | |
| 175 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 176 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 177 | + | var toastCalls []int | |
| 178 | + | ||
| 179 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Once() | |
| 180 | + | ||
| 181 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 182 | + | toastCalls = append(toastCalls, textLen) | |
| 183 | + | }) | |
| 184 | + | ||
| 185 | + | now := time.Unix(0, 0) | |
| 186 | + | uc.now = func() time.Time { return now } | |
| 187 | + | ||
| 188 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 189 | + | t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err) | |
| 190 | + | } | |
| 191 | + | now = now.Add(100 * time.Millisecond) | |
| 192 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil { | |
| 193 | + | t.Fatalf("HandleExplicitCopy() error = %v, want nil", err) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | if len(toastCalls) != 1 || toastCalls[0] != 6 { | |
| 197 | + | t.Fatalf("toast calls = %v, want [6]", toastCalls) | |
| 198 | + | } | |
| 199 | + | if got := len(uc.lastExplicit); got != 1 { | |
| 200 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossEngines(t *testing.T) { | |
| 205 | + | ctx := context.Background() | |
| 206 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 207 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 208 | + | ||
| 209 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 210 | + | ||
| 211 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 212 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| 213 | + | ||
| 214 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |
| 215 | + | t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err) | |
| 216 | + | } | |
| 217 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, Action: "copy"}); err != nil { | |
| 218 | + | t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossViews(t *testing.T) { | |
| 223 | + | ctx := context.Background() | |
| 224 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 225 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 226 | + | ||
| 227 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |
| 228 | + | ||
| 229 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 230 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| 231 | + | ||
| 232 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil { | |
| 233 | + | t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err) | |
| 234 | + | } | |
| 235 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil { | |
| 236 | + | t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err) | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(t *testing.T) { | |
| 241 | + | ctx := context.Background() | |
| 242 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 243 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 244 | + | ||
| 245 | + | clipboard.EXPECT().WriteText(ctx, "one").Return(nil).Once() | |
| 246 | + | clipboard.EXPECT().WriteText(ctx, "two").Return(nil).Once() | |
| 247 | + | clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once() | |
| 248 | + | ||
| 249 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 250 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| 251 | + | ||
| 252 | + | for _, text := range []string{"one", "two", "three"} { | |
| 253 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil { | |
| 254 | + | t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err) | |
| 255 | + | } | |
| 256 | + | uc.now = func() time.Time { return time.Unix(0, 0).Add(time.Second) } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | if got := len(uc.lastExplicit); got != 1 { | |
| 260 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_ReenableClearsDedupState(t *testing.T) { | |
| 265 | + | ctx := context.Background() | |
| 266 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 267 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 268 | + | ||
| 269 | + | clipboard.EXPECT().WriteText(ctx, "toggle").Return(nil).Times(2) | |
| 270 | + | ||
| 271 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |
| 272 | + | ||
| 273 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 274 | + | t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err) | |
| 275 | + | } | |
| 276 | + | autoCopyConfig.enabled = false | |
| 277 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 278 | + | t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err) | |
| 279 | + | } | |
| 280 | + | autoCopyConfig.enabled = true | |
| 281 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |
| 282 | + | t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_ClipboardFailureDoesNotToast(t *testing.T) { | |
| 287 | + | ctx := context.Background() | |
| 288 | + | clipboard := portmocks.NewMockClipboard(t) | |
| 289 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(errors.New("clipboard failed")).Once() | |
| 290 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 291 | + | ||
| 292 | + | var toastCalls []int | |
| 293 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 294 | + | toastCalls = append(toastCalls, textLen) | |
| 295 | + | }) | |
| 296 | + | ||
| 297 | + | err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF, Action: "copy"}) | |
| 298 | + | if err == nil { | |
| 299 | + | t.Fatal("HandleExplicitCopy() error = nil, want non-nil") | |
| 300 | + | } | |
| 301 | + | if len(toastCalls) != 0 { | |
| 302 | + | t.Fatalf("toast calls = %v, want none", toastCalls) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testing.T) { | |
| 307 | + | ctx := context.Background() | |
| 308 | + | clipboard := newBlockingClipboard() | |
| 309 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 310 | + | var toastCalls []int | |
| 311 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 312 | + | toastCalls = append(toastCalls, textLen) | |
| 313 | + | }) | |
| 314 | + | ||
| 315 | + | done1 := make(chan error, 1) | |
| 316 | + | go func() { | |
| 317 | + | done1 <- uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}) | |
| 318 | + | }() | |
| 319 | + | <-clipboard.started | |
| 320 | + | ||
| 321 | + | done2 := make(chan error, 1) | |
| 322 | + | go func() { | |
| 323 | + | done2 <- uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}) | |
| 324 | + | }() | |
| 325 | + | ||
| 326 | + | select { | |
| 327 | + | case <-clipboard.started: | |
| 328 | + | t.Fatal("second selection write started before first completed") | |
| 329 | + | default: | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | close(clipboard.release) | |
| 333 | + | ||
| 334 | + | if err := <-done1; err != nil { | |
| 335 | + | t.Fatalf("first HandleSelectionUpdate() error = %v, want nil", err) | |
| 336 | + | } | |
| 337 | + | if err := <-done2; err != nil { | |
| 338 | + | t.Fatalf("second HandleSelectionUpdate() error = %v, want nil", err) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | if got := clipboard.writeCount(); got != 1 { | |
| 342 | + | t.Fatalf("clipboard writes = %d, want 1", got) | |
| 343 | + | } | |
| 344 | + | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |
| 345 | + | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |
| 346 | + | } | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T) { | |
| 350 | + | ctx := context.Background() | |
| 351 | + | clipboard := newBlockingClipboard() | |
| 352 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |
| 353 | + | var toastCalls []int | |
| 354 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |
| 355 | + | toastCalls = append(toastCalls, textLen) | |
| 356 | + | }) | |
| 357 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |
| 358 | + | ||
| 359 | + | done1 := make(chan error, 1) | |
| 360 | + | go func() { | |
| 361 | + | done1 <- uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}) | |
| 362 | + | }() | |
| 363 | + | <-clipboard.started | |
| 364 | + | ||
| 365 | + | done2 := make(chan error, 1) | |
| 366 | + | go func() { | |
| 367 | + | done2 <- uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}) | |
| 368 | + | }() | |
| 369 | + | ||
| 370 | + | select { | |
| 371 | + | case <-clipboard.started: | |
| 372 | + | t.Fatal("second explicit write started before first completed") | |
| 373 | + | default: | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | close(clipboard.release) | |
| 377 | + | ||
| 378 | + | if err := <-done1; err != nil { | |
| 379 | + | t.Fatalf("first HandleExplicitCopy() error = %v, want nil", err) | |
| 380 | + | } | |
| 381 | + | if err := <-done2; err != nil { | |
| 382 | + | t.Fatalf("second HandleExplicitCopy() error = %v, want nil", err) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | if got := clipboard.writeCount(); got != 1 { | |
| 386 | + | t.Fatalf("clipboard writes = %d, want 1", got) | |
| 387 | + | } | |
| 388 | + | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |
| 389 | + | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |
| 390 | + | } | |
| 391 | + | } | |
| @@ -0,0 +1,391 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type blockingClipboard struct { | |||
| 16 | + | started chan struct{} | |||
| 17 | + | release chan struct{} | |||
| 18 | + | mu sync.Mutex | |||
| 19 | + | writes []string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type staticAutoCopyConfig struct { | |||
| 23 | + | enabled bool | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (s *staticAutoCopyConfig) IsAutoCopyEnabled() bool { return s.enabled } | |||
| 27 | + | ||||
| 28 | + | func newBlockingClipboard() *blockingClipboard { | |||
| 29 | + | return &blockingClipboard{ | |||
| 30 | + | started: make(chan struct{}, 2), | |||
| 31 | + | release: make(chan struct{}), | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (b *blockingClipboard) WriteText(_ context.Context, text string) error { | |||
| 36 | + | b.mu.Lock() | |||
| 37 | + | b.writes = append(b.writes, text) | |||
| 38 | + | b.mu.Unlock() | |||
| 39 | + | b.started <- struct{}{} | |||
| 40 | + | <-b.release | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (*blockingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |||
| 45 | + | func (*blockingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |||
| 46 | + | func (*blockingClipboard) Clear(context.Context) error { return nil } | |||
| 47 | + | func (*blockingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |||
| 48 | + | ||||
| 49 | + | func (b *blockingClipboard) writeCount() int { | |||
| 50 | + | b.mu.Lock() | |||
| 51 | + | defer b.mu.Unlock() | |||
| 52 | + | return len(b.writes) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) { | |||
| 56 | + | ctx := context.Background() | |||
| 57 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 58 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: false} | |||
| 59 | + | var toastCalls []int | |||
| 60 | + | ||||
| 61 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 62 | + | toastCalls = append(toastCalls, textLen) | |||
| 63 | + | }) | |||
| 64 | + | ||||
| 65 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "hello", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |||
| 66 | + | t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | if len(toastCalls) != 0 { | |||
| 70 | + | t.Fatalf("toast calls = %v, want none", toastCalls) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_WritesOncePerUniqueSelectionAndResetsAfterEmptySelection(t *testing.T) { | |||
| 75 | + | ctx := context.Background() | |||
| 76 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 77 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 78 | + | var toastCalls []int | |||
| 79 | + | ||||
| 80 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2) | |||
| 81 | + | ||||
| 82 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 83 | + | toastCalls = append(toastCalls, textLen) | |||
| 84 | + | }) | |||
| 85 | + | ||||
| 86 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "é", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |||
| 87 | + | t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err) | |||
| 88 | + | } | |||
| 89 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |||
| 90 | + | t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err) | |||
| 91 | + | } | |||
| 92 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |||
| 93 | + | t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err) | |||
| 94 | + | } | |||
| 95 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |||
| 96 | + | t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err) | |||
| 97 | + | } | |||
| 98 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |||
| 99 | + | t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 { | |||
| 103 | + | t.Fatalf("toast calls = %v, want [2 2]", toastCalls) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossEngines(t *testing.T) { | |||
| 108 | + | ctx := context.Background() | |||
| 109 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 110 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 111 | + | ||||
| 112 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 113 | + | ||||
| 114 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 115 | + | ||||
| 116 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit}); err != nil { | |||
| 117 | + | t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err) | |||
| 118 | + | } | |||
| 119 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF}); err != nil { | |||
| 120 | + | t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossViews(t *testing.T) { | |||
| 125 | + | ctx := context.Background() | |||
| 126 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 127 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 128 | + | ||||
| 129 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 130 | + | ||||
| 131 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 132 | + | ||||
| 133 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 134 | + | t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err) | |||
| 135 | + | } | |||
| 136 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 2}); err != nil { | |||
| 137 | + | t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPayloadsWithinWindow(t *testing.T) { | |||
| 142 | + | ctx := context.Background() | |||
| 143 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 144 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 145 | + | var toastCalls []int | |||
| 146 | + | ||||
| 147 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2) | |||
| 148 | + | ||||
| 149 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 150 | + | toastCalls = append(toastCalls, textLen) | |||
| 151 | + | }) | |||
| 152 | + | ||||
| 153 | + | now := time.Unix(0, 0) | |||
| 154 | + | uc.now = func() time.Time { return now } | |||
| 155 | + | ||||
| 156 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |||
| 157 | + | t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err) | |||
| 158 | + | } | |||
| 159 | + | now = now.Add(100 * time.Millisecond) | |||
| 160 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |||
| 161 | + | t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err) | |||
| 162 | + | } | |||
| 163 | + | now = now.Add(151 * time.Millisecond) | |||
| 164 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |||
| 165 | + | t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 { | |||
| 169 | + | t.Fatalf("toast calls = %v, want [2 2]", toastCalls) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) { | |||
| 174 | + | ctx := context.Background() | |||
| 175 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 176 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 177 | + | var toastCalls []int | |||
| 178 | + | ||||
| 179 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Once() | |||
| 180 | + | ||||
| 181 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 182 | + | toastCalls = append(toastCalls, textLen) | |||
| 183 | + | }) | |||
| 184 | + | ||||
| 185 | + | now := time.Unix(0, 0) | |||
| 186 | + | uc.now = func() time.Time { return now } | |||
| 187 | + | ||||
| 188 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 189 | + | t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err) | |||
| 190 | + | } | |||
| 191 | + | now = now.Add(100 * time.Millisecond) | |||
| 192 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil { | |||
| 193 | + | t.Fatalf("HandleExplicitCopy() error = %v, want nil", err) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | if len(toastCalls) != 1 || toastCalls[0] != 6 { | |||
| 197 | + | t.Fatalf("toast calls = %v, want [6]", toastCalls) | |||
| 198 | + | } | |||
| 199 | + | if got := len(uc.lastExplicit); got != 1 { | |||
| 200 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossEngines(t *testing.T) { | |||
| 205 | + | ctx := context.Background() | |||
| 206 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 207 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 208 | + | ||||
| 209 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 210 | + | ||||
| 211 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 212 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |||
| 213 | + | ||||
| 214 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |||
| 215 | + | t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err) | |||
| 216 | + | } | |||
| 217 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, Action: "copy"}); err != nil { | |||
| 218 | + | t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err) | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossViews(t *testing.T) { | |||
| 223 | + | ctx := context.Background() | |||
| 224 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 225 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 226 | + | ||||
| 227 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 228 | + | ||||
| 229 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 230 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |||
| 231 | + | ||||
| 232 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil { | |||
| 233 | + | t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err) | |||
| 234 | + | } | |||
| 235 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "shared", SourceEngine: port.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil { | |||
| 236 | + | t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err) | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(t *testing.T) { | |||
| 241 | + | ctx := context.Background() | |||
| 242 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 243 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 244 | + | ||||
| 245 | + | clipboard.EXPECT().WriteText(ctx, "one").Return(nil).Once() | |||
| 246 | + | clipboard.EXPECT().WriteText(ctx, "two").Return(nil).Once() | |||
| 247 | + | clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once() | |||
| 248 | + | ||||
| 249 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 250 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |||
| 251 | + | ||||
| 252 | + | for _, text := range []string{"one", "two", "three"} { | |||
| 253 | + | if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil { | |||
| 254 | + | t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err) | |||
| 255 | + | } | |||
| 256 | + | uc.now = func() time.Time { return time.Unix(0, 0).Add(time.Second) } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | if got := len(uc.lastExplicit); got != 1 { | |||
| 260 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_ReenableClearsDedupState(t *testing.T) { | |||
| 265 | + | ctx := context.Background() | |||
| 266 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 267 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 268 | + | ||||
| 269 | + | clipboard.EXPECT().WriteText(ctx, "toggle").Return(nil).Times(2) | |||
| 270 | + | ||||
| 271 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 272 | + | ||||
| 273 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 274 | + | t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err) | |||
| 275 | + | } | |||
| 276 | + | autoCopyConfig.enabled = false | |||
| 277 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 278 | + | t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err) | |||
| 279 | + | } | |||
| 280 | + | autoCopyConfig.enabled = true | |||
| 281 | + | if err := uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "toggle", SourceEngine: port.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 282 | + | t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err) | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_ClipboardFailureDoesNotToast(t *testing.T) { | |||
| 287 | + | ctx := context.Background() | |||
| 288 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 289 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(errors.New("clipboard failed")).Once() | |||
| 290 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 291 | + | ||||
| 292 | + | var toastCalls []int | |||
| 293 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 294 | + | toastCalls = append(toastCalls, textLen) | |||
| 295 | + | }) | |||
| 296 | + | ||||
| 297 | + | err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF, Action: "copy"}) | |||
| 298 | + | if err == nil { | |||
| 299 | + | t.Fatal("HandleExplicitCopy() error = nil, want non-nil") | |||
| 300 | + | } | |||
| 301 | + | if len(toastCalls) != 0 { | |||
| 302 | + | t.Fatalf("toast calls = %v, want none", toastCalls) | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testing.T) { | |||
| 307 | + | ctx := context.Background() | |||
| 308 | + | clipboard := newBlockingClipboard() | |||
| 309 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 310 | + | var toastCalls []int | |||
| 311 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 312 | + | toastCalls = append(toastCalls, textLen) | |||
| 313 | + | }) | |||
| 314 | + | ||||
| 315 | + | done1 := make(chan error, 1) | |||
| 316 | + | go func() { | |||
| 317 | + | done1 <- uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}) | |||
| 318 | + | }() | |||
| 319 | + | <-clipboard.started | |||
| 320 | + | ||||
| 321 | + | done2 := make(chan error, 1) | |||
| 322 | + | go func() { | |||
| 323 | + | done2 <- uc.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceCEF}) | |||
| 324 | + | }() | |||
| 325 | + | ||||
| 326 | + | select { | |||
| 327 | + | case <-clipboard.started: | |||
| 328 | + | t.Fatal("second selection write started before first completed") | |||
| 329 | + | default: | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | close(clipboard.release) | |||
| 333 | + | ||||
| 334 | + | if err := <-done1; err != nil { | |||
| 335 | + | t.Fatalf("first HandleSelectionUpdate() error = %v, want nil", err) | |||
| 336 | + | } | |||
| 337 | + | if err := <-done2; err != nil { | |||
| 338 | + | t.Fatalf("second HandleSelectionUpdate() error = %v, want nil", err) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | if got := clipboard.writeCount(); got != 1 { | |||
| 342 | + | t.Fatalf("clipboard writes = %d, want 1", got) | |||
| 343 | + | } | |||
| 344 | + | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |||
| 345 | + | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |||
| 346 | + | } | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T) { | |||
| 350 | + | ctx := context.Background() | |||
| 351 | + | clipboard := newBlockingClipboard() | |||
| 352 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 353 | + | var toastCalls []int | |||
| 354 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 355 | + | toastCalls = append(toastCalls, textLen) | |||
| 356 | + | }) | |||
| 357 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |||
| 358 | + | ||||
| 359 | + | done1 := make(chan error, 1) | |||
| 360 | + | go func() { | |||
| 361 | + | done1 <- uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}) | |||
| 362 | + | }() | |||
| 363 | + | <-clipboard.started | |||
| 364 | + | ||||
| 365 | + | done2 := make(chan error, 1) | |||
| 366 | + | go func() { | |||
| 367 | + | done2 <- uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: "éé", SourceEngine: port.ClipboardSourceWebKit, Action: "copy"}) | |||
| 368 | + | }() | |||
| 369 | + | ||||
| 370 | + | select { | |||
| 371 | + | case <-clipboard.started: | |||
| 372 | + | t.Fatal("second explicit write started before first completed") | |||
| 373 | + | default: | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | close(clipboard.release) | |||
| 377 | + | ||||
| 378 | + | if err := <-done1; err != nil { | |||
| 379 | + | t.Fatalf("first HandleExplicitCopy() error = %v, want nil", err) | |||
| 380 | + | } | |||
| 381 | + | if err := <-done2; err != nil { | |||
| 382 | + | t.Fatalf("second HandleExplicitCopy() error = %v, want nil", err) | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | if got := clipboard.writeCount(); got != 1 { | |||
| 386 | + | t.Fatalf("clipboard writes = %d, want 1", got) | |||
| 387 | + | } | |||
| 388 | + | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |||
| 389 | + | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |||
| 390 | + | } | |||
| 391 | + | } | |||
M internal/application/usecase/execute_context_menu_action.go +18 -8
| @@ -105,16 +105,26 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||
| 105 | 105 | return fmt.Errorf("save image: %w", err) | |
| 106 | 106 | } | |
| 107 | 107 | return nil | |
| 108 | - | default: | |
| 109 | - | // All other normalized actions are delegated to the engine/UI layer. | |
| 110 | - | if uc.delegator == nil { | |
| 111 | - | return fmt.Errorf("delegate action %s: menu action delegator not available", action) | |
| 108 | + | case port.MenuActionCopySelection: | |
| 109 | + | if menuContext.SelectionText != "" { | |
| 110 | + | if uc.clipboard == nil { | |
| 111 | + | return fmt.Errorf("copy selection: clipboard not available") | |
| 112 | + | } | |
| 113 | + | if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil { | |
| 114 | + | return fmt.Errorf("copy selection: %w", err) | |
| 115 | + | } | |
| 116 | + | return nil | |
| 112 | 117 | } | |
| 113 | - | if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil { | |
| 114 | - | return fmt.Errorf("delegate action %s: %w", action, err) | |
| 115 | - | } | |
| 116 | - | return nil | |
| 117 | 118 | } | |
| 119 | + | ||
| 120 | + | // All other normalized actions are delegated to the engine/UI layer. | |
| 121 | + | if uc.delegator == nil { | |
| 122 | + | return fmt.Errorf("delegate action %s: menu action delegator not available", action) | |
| 123 | + | } | |
| 124 | + | if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil { | |
| 125 | + | return fmt.Errorf("delegate action %s: %w", action, err) | |
| 126 | + | } | |
| 127 | + | return nil | |
| 118 | 128 | } | |
| 119 | 129 | ||
| 120 | 130 | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | |
| @@ -105,16 +105,26 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||||
| 105 | return fmt.Errorf("save image: %w", err) | 105 | return fmt.Errorf("save image: %w", err) | ||
| 106 | } | 106 | } | ||
| 107 | return nil | 107 | return nil | ||
| 108 | - | default: | 108 | + | case port.MenuActionCopySelection: |
| 109 | - | // All other normalized actions are delegated to the engine/UI layer. | 109 | + | if menuContext.SelectionText != "" { |
| 110 | - | if uc.delegator == nil { | 110 | + | if uc.clipboard == nil { |
| 111 | - | return fmt.Errorf("delegate action %s: menu action delegator not available", action) | 111 | + | return fmt.Errorf("copy selection: clipboard not available") |
| 112 | + | } | |||
| 113 | + | if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil { | |||
| 114 | + | return fmt.Errorf("copy selection: %w", err) | |||
| 115 | + | } | |||
| 116 | + | return nil | |||
| 112 | } | 117 | } | ||
| 113 | - | if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil { | |||
| 114 | - | return fmt.Errorf("delegate action %s: %w", action, err) | |||
| 115 | - | } | |||
| 116 | - | return nil | |||
| 117 | } | 118 | } | ||
| 119 | + | ||||
| 120 | + | // All other normalized actions are delegated to the engine/UI layer. | |||
| 121 | + | if uc.delegator == nil { | |||
| 122 | + | return fmt.Errorf("delegate action %s: menu action delegator not available", action) | |||
| 123 | + | } | |||
| 124 | + | if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil { | |||
| 125 | + | return fmt.Errorf("delegate action %s: %w", action, err) | |||
| 126 | + | } | |||
| 127 | + | return nil | |||
| 118 | } | 128 | } | ||
| 119 | 129 | ||||
| 120 | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | 130 | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | ||
M internal/application/usecase/execute_context_menu_action_test.go +21 -0
| @@ -217,6 +217,27 @@ func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) { | |||
| 217 | 217 | require.Contains(t, err.Error(), "boom") | |
| 218 | 218 | } | |
| 219 | 219 | ||
| 220 | + | func TestExecuteContextMenuActionUseCase_CopySelectionWritesSelectedText(t *testing.T) { | |
| 221 | + | clipboard := &fakeClipboard{} | |
| 222 | + | resolver := &fakeImageResolver{} | |
| 223 | + | saver := &fakeResolvedImageSaver{} | |
| 224 | + | delegator := &fakeMenuActionDelegator{} | |
| 225 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 226 | + | ||
| 227 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |
| 228 | + | Action: port.MenuActionCopySelection, | |
| 229 | + | Context: port.MenuContext{ | |
| 230 | + | HasSelection: true, | |
| 231 | + | SelectionText: "selected text", | |
| 232 | + | }, | |
| 233 | + | }) | |
| 234 | + | ||
| 235 | + | require.NoError(t, err) | |
| 236 | + | require.Equal(t, 1, clipboard.writeTextCalls) | |
| 237 | + | require.Equal(t, "selected text", clipboard.text) | |
| 238 | + | require.Zero(t, delegator.delegateCalls) | |
| 239 | + | } | |
| 240 | + | ||
| 220 | 241 | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | |
| 221 | 242 | clipboard := &fakeClipboard{} | |
| 222 | 243 | resolver := &fakeImageResolver{} | |
| @@ -217,6 +217,27 @@ func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) { | |||||
| 217 | require.Contains(t, err.Error(), "boom") | 217 | require.Contains(t, err.Error(), "boom") | ||
| 218 | } | 218 | } | ||
| 219 | 219 | ||||
| 220 | + | func TestExecuteContextMenuActionUseCase_CopySelectionWritesSelectedText(t *testing.T) { | |||
| 221 | + | clipboard := &fakeClipboard{} | |||
| 222 | + | resolver := &fakeImageResolver{} | |||
| 223 | + | saver := &fakeResolvedImageSaver{} | |||
| 224 | + | delegator := &fakeMenuActionDelegator{} | |||
| 225 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 226 | + | ||||
| 227 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 228 | + | Action: port.MenuActionCopySelection, | |||
| 229 | + | Context: port.MenuContext{ | |||
| 230 | + | HasSelection: true, | |||
| 231 | + | SelectionText: "selected text", | |||
| 232 | + | }, | |||
| 233 | + | }) | |||
| 234 | + | ||||
| 235 | + | require.NoError(t, err) | |||
| 236 | + | require.Equal(t, 1, clipboard.writeTextCalls) | |||
| 237 | + | require.Equal(t, "selected text", clipboard.text) | |||
| 238 | + | require.Zero(t, delegator.delegateCalls) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 220 | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | 241 | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | ||
| 221 | clipboard := &fakeClipboard{} | 242 | clipboard := &fakeClipboard{} | ||
| 222 | resolver := &fakeImageResolver{} | 243 | resolver := &fakeImageResolver{} | ||
M internal/infrastructure/cef/app.go +9 -6
| @@ -45,6 +45,7 @@ func configureCommandLine(commandLine purecef.CommandLine) { | |||
| 45 | 45 | type dumberApp struct { | |
| 46 | 46 | engine *Engine | |
| 47 | 47 | bph purecef.BrowserProcessHandler | |
| 48 | + | rph purecef.RenderProcessHandler | |
| 48 | 49 | } | |
| 49 | 50 | ||
| 50 | 51 | // newDumberApp creates an App whose BrowserProcessHandler handles context | |
| @@ -53,13 +54,15 @@ type dumberApp struct { | |||
| 53 | 54 | func newDumberApp(engine *Engine) purecef.App { | |
| 54 | 55 | app := &dumberApp{engine: engine} | |
| 55 | 56 | app.bph = &dumberBPH{engine: engine} | |
| 57 | + | app.rph = newRendererBridgeProcessHandler() | |
| 56 | 58 | return app | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | - | // NewSubprocessApp returns a lightweight App for helper processes so CEF sees | |
| 60 | - | // the same custom scheme registration in renderer/GPU/utility processes. | |
| 61 | + | // NewSubprocessApp returns a lightweight raw App implementation for helper | |
| 62 | + | // processes so cef_execute_process can wrap it itself. Do not pre-wrap with | |
| 63 | + | // purecef.NewApp here: helper subprocess startup happens before full cef.Init. | |
| 61 | 64 | func NewSubprocessApp() purecef.App { | |
| 62 | - | return purecef.NewApp(&subprocessApp{}) | |
| 65 | + | return &subprocessApp{rph: newRendererBridgeProcessHandler()} | |
| 63 | 66 | } | |
| 64 | 67 | ||
| 65 | 68 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | |
| @@ -89,9 +92,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) { | |||
| 89 | 92 | } | |
| 90 | 93 | func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 91 | 94 | func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph } | |
| 92 | - | func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil } | |
| 95 | + | func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph } | |
| 93 | 96 | ||
| 94 | - | type subprocessApp struct{} | |
| 97 | + | type subprocessApp struct{ rph purecef.RenderProcessHandler } | |
| 95 | 98 | ||
| 96 | 99 | func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) { | |
| 97 | 100 | configureCommandLine(commandLine) | |
| @@ -101,7 +104,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra | |||
| 101 | 104 | } | |
| 102 | 105 | func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 103 | 106 | func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil } | |
| 104 | - | func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil } | |
| 107 | + | func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph } | |
| 105 | 108 | ||
| 106 | 109 | // dumberBPH implements purecef.BrowserProcessHandler for context initialization, | |
| 107 | 110 | // child process launch tracking, and diagnostic logging. | |
| @@ -45,6 +45,7 @@ func configureCommandLine(commandLine purecef.CommandLine) { | |||||
| 45 | type dumberApp struct { | 45 | type dumberApp struct { | ||
| 46 | engine *Engine | 46 | engine *Engine | ||
| 47 | bph purecef.BrowserProcessHandler | 47 | bph purecef.BrowserProcessHandler | ||
| 48 | + | rph purecef.RenderProcessHandler | |||
| 48 | } | 49 | } | ||
| 49 | 50 | ||||
| 50 | // newDumberApp creates an App whose BrowserProcessHandler handles context | 51 | // newDumberApp creates an App whose BrowserProcessHandler handles context | ||
| @@ -53,13 +54,15 @@ type dumberApp struct { | |||||
| 53 | func newDumberApp(engine *Engine) purecef.App { | 54 | func newDumberApp(engine *Engine) purecef.App { | ||
| 54 | app := &dumberApp{engine: engine} | 55 | app := &dumberApp{engine: engine} | ||
| 55 | app.bph = &dumberBPH{engine: engine} | 56 | app.bph = &dumberBPH{engine: engine} | ||
| 57 | + | app.rph = newRendererBridgeProcessHandler() | |||
| 56 | return app | 58 | return app | ||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| 59 | - | // NewSubprocessApp returns a lightweight App for helper processes so CEF sees | 61 | + | // NewSubprocessApp returns a lightweight raw App implementation for helper |
| 60 | - | // the same custom scheme registration in renderer/GPU/utility processes. | 62 | + | // processes so cef_execute_process can wrap it itself. Do not pre-wrap with |
| 63 | + | // purecef.NewApp here: helper subprocess startup happens before full cef.Init. | |||
| 61 | func NewSubprocessApp() purecef.App { | 64 | func NewSubprocessApp() purecef.App { | ||
| 62 | - | return purecef.NewApp(&subprocessApp{}) | 65 | + | return &subprocessApp{rph: newRendererBridgeProcessHandler()} |
| 63 | } | 66 | } | ||
| 64 | 67 | ||||
| 65 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | 68 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | ||
| @@ -89,9 +92,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) { | |||||
| 89 | } | 92 | } | ||
| 90 | func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | 93 | func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | ||
| 91 | func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph } | 94 | func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph } | ||
| 92 | - | func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil } | 95 | + | func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph } |
| 93 | 96 | ||||
| 94 | - | type subprocessApp struct{} | 97 | + | type subprocessApp struct{ rph purecef.RenderProcessHandler } |
| 95 | 98 | ||||
| 96 | func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) { | 99 | func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) { | ||
| 97 | configureCommandLine(commandLine) | 100 | configureCommandLine(commandLine) | ||
| @@ -101,7 +104,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra | |||||
| 101 | } | 104 | } | ||
| 102 | func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | 105 | func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | ||
| 103 | func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil } | 106 | func (a *subprocessApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return nil } | ||
| 104 | - | func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil } | 107 | + | func (a *subprocessApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return a.rph } |
| 105 | 108 | ||||
| 106 | // dumberBPH implements purecef.BrowserProcessHandler for context initialization, | 109 | // dumberBPH implements purecef.BrowserProcessHandler for context initialization, | ||
| 107 | // child process launch tracking, and diagnostic logging. | 110 | // child process launch tracking, and diagnostic logging. | ||
M internal/infrastructure/cef/content_injector.go +3 -29
| @@ -385,33 +385,6 @@ const redditDirectVideoJS = `(function(){ | |||
| 385 | 385 | }, 1000); | |
| 386 | 386 | })();` | |
| 387 | 387 | ||
| 388 | - | // clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text | |
| 389 | - | // back to Go via the message bridge so it can be written to the GDK clipboard. | |
| 390 | - | // Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but | |
| 391 | - | // not the Wayland/X11 system clipboard. | |
| 392 | - | const clipboardCopyBridgeJS = `(function(){ | |
| 393 | - | if (window.__dumberClipboardBridge) return; | |
| 394 | - | window.__dumberClipboardBridge = true; | |
| 395 | - | function sendToClipboard(text) { | |
| 396 | - | if (!text) return; | |
| 397 | - | var body = JSON.stringify({text: text}); | |
| 398 | - | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| 399 | - | if (!encoded) return; | |
| 400 | - | fetch('dumb:///api/clipboard-set', { | |
| 401 | - | method: 'POST', | |
| 402 | - | headers: {'X-Dumber-Body': encoded} | |
| 403 | - | }).catch(function(){}); | |
| 404 | - | } | |
| 405 | - | document.addEventListener('copy', function() { | |
| 406 | - | var sel = window.getSelection(); | |
| 407 | - | if (sel) sendToClipboard(sel.toString()); | |
| 408 | - | }); | |
| 409 | - | document.addEventListener('cut', function() { | |
| 410 | - | var sel = window.getSelection(); | |
| 411 | - | if (sel) sendToClipboard(sel.toString()); | |
| 412 | - | }); | |
| 413 | - | })();` | |
| 414 | - | ||
| 415 | 388 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 416 | 389 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 417 | 390 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -532,8 +505,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 532 | 505 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | |
| 533 | 506 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 534 | 507 | ||
| 535 | - | // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard). | |
| 536 | - | wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS) | |
| 508 | + | // Focus sync, selection auto-copy, and clipboard API bridging are installed by | |
| 509 | + | // the native render-process bridge at V8 context creation time. Do not inject | |
| 510 | + | // fetch-based shims here: external site CSP can block them. | |
| 537 | 511 | ||
| 538 | 512 | // Video playback diagnostic — logs video element state changes. | |
| 539 | 513 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | |
| @@ -385,33 +385,6 @@ const redditDirectVideoJS = `(function(){ | |||||
| 385 | }, 1000); | 385 | }, 1000); | ||
| 386 | })();` | 386 | })();` | ||
| 387 | 387 | ||||
| 388 | - | // clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text | |||
| 389 | - | // back to Go via the message bridge so it can be written to the GDK clipboard. | |||
| 390 | - | // Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but | |||
| 391 | - | // not the Wayland/X11 system clipboard. | |||
| 392 | - | const clipboardCopyBridgeJS = `(function(){ | |||
| 393 | - | if (window.__dumberClipboardBridge) return; | |||
| 394 | - | window.__dumberClipboardBridge = true; | |||
| 395 | - | function sendToClipboard(text) { | |||
| 396 | - | if (!text) return; | |||
| 397 | - | var body = JSON.stringify({text: text}); | |||
| 398 | - | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |||
| 399 | - | if (!encoded) return; | |||
| 400 | - | fetch('dumb:///api/clipboard-set', { | |||
| 401 | - | method: 'POST', | |||
| 402 | - | headers: {'X-Dumber-Body': encoded} | |||
| 403 | - | }).catch(function(){}); | |||
| 404 | - | } | |||
| 405 | - | document.addEventListener('copy', function() { | |||
| 406 | - | var sel = window.getSelection(); | |||
| 407 | - | if (sel) sendToClipboard(sel.toString()); | |||
| 408 | - | }); | |||
| 409 | - | document.addEventListener('cut', function() { | |||
| 410 | - | var sel = window.getSelection(); | |||
| 411 | - | if (sel) sendToClipboard(sel.toString()); | |||
| 412 | - | }); | |||
| 413 | - | })();` | |||
| 414 | - | ||||
| 415 | // contentInjector implements port.ContentInjector for the CEF engine. | 388 | // contentInjector implements port.ContentInjector for the CEF engine. | ||
| 416 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | 389 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | ||
| 417 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | 390 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | ||
| @@ -532,8 +505,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 532 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | 505 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | ||
| 533 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | 506 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | ||
| 534 | 507 | ||||
| 535 | - | // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard). | 508 | + | // Focus sync, selection auto-copy, and clipboard API bridging are installed by |
| 536 | - | wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS) | 509 | + | // the native render-process bridge at V8 context creation time. Do not inject |
| 510 | + | // fetch-based shims here: external site CSP can block them. | |||
| 537 | 511 | ||||
| 538 | // Video playback diagnostic — logs video element state changes. | 512 | // Video playback diagnostic — logs video element state changes. | ||
| 539 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | 513 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | ||
M internal/infrastructure/cef/context_menu.go +39 -3
| @@ -82,7 +82,15 @@ func (h *handlerSet) RunContextMenu( | |||
| 82 | 82 | x, | |
| 83 | 83 | y, | |
| 84 | 84 | func(item port.MenuItem) { | |
| 85 | - | dispatchContextMenuSelection(h.wv.ctx, executor, callback, commandIDByAction, item, menuContext) | |
| 85 | + | var explicitCopyCallback func(string) | |
| 86 | + | var copiedCallback func(string) | |
| 87 | + | 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 | |
| 92 | + | } | |
| 93 | + | dispatchContextMenuSelection(h.wv.ctx, executor, callback, explicitCopyCallback, copiedCallback, commandIDByAction, item, menuContext) | |
| 86 | 94 | }, | |
| 87 | 95 | func() { | |
| 88 | 96 | callback.Cancel() | |
| @@ -162,6 +170,8 @@ func dispatchContextMenuSelection( | |||
| 162 | 170 | ctx context.Context, | |
| 163 | 171 | executor port.ContextMenuActionExecutor, | |
| 164 | 172 | callback purecef.RunContextMenuCallback, | |
| 173 | + | explicitCopyCallback func(text string), | |
| 174 | + | copiedCallback func(text string), | |
| 165 | 175 | commandIDByAction map[port.MenuAction]int32, | |
| 166 | 176 | item port.MenuItem, | |
| 167 | 177 | menuContext port.MenuContext, | |
| @@ -171,14 +181,25 @@ func dispatchContextMenuSelection( | |||
| 171 | 181 | return | |
| 172 | 182 | } | |
| 173 | 183 | 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") | |
| 187 | + | callback.Cancel() | |
| 188 | + | return | |
| 189 | + | } | |
| 174 | 190 | if shouldExecuteDirectCEFAction(item.Action) && executor != nil { | |
| 175 | 191 | if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil { | |
| 176 | 192 | log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed") | |
| 177 | 193 | callback.Cancel() | |
| 178 | 194 | return | |
| 179 | 195 | } | |
| 196 | + | if copiedCallback != nil { | |
| 197 | + | if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" { | |
| 198 | + | copiedCallback(copiedText) | |
| 199 | + | } | |
| 200 | + | } | |
| 180 | 201 | log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly") | |
| 181 | - | callback.Cont(0, 0) | |
| 202 | + | callback.Cancel() | |
| 182 | 203 | return | |
| 183 | 204 | } | |
| 184 | 205 | if cmdID, ok := commandIDByAction[item.Action]; ok { | |
| @@ -190,6 +211,20 @@ func dispatchContextMenuSelection( | |||
| 190 | 211 | callback.Cancel() | |
| 191 | 212 | } | |
| 192 | 213 | ||
| 214 | + | func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string { | |
| 215 | + | switch action { | |
| 216 | + | case port.MenuActionCopySelection: | |
| 217 | + | if menuContext.SelectionText != "" { | |
| 218 | + | return menuContext.SelectionText | |
| 219 | + | } | |
| 220 | + | return "" | |
| 221 | + | case port.MenuActionCopyLink: | |
| 222 | + | return menuContext.LinkURI | |
| 223 | + | default: | |
| 224 | + | return "" | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 193 | 228 | func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | |
| 194 | 229 | switch action { | |
| 195 | 230 | case port.MenuActionBack, | |
| @@ -296,7 +331,8 @@ func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuCo | |||
| 296 | 331 | if params.HasImageContents() { | |
| 297 | 332 | menuContext.ImageURI = params.GetSourceURL() | |
| 298 | 333 | } | |
| 299 | - | menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != "" | |
| 334 | + | menuContext.SelectionText = params.GetSelectionText() | |
| 335 | + | menuContext.HasSelection = strings.TrimSpace(menuContext.SelectionText) != "" | |
| 300 | 336 | menuContext.IsEditable = params.IsEditable() | |
| 301 | 337 | menuContext.X = int(params.GetXcoord()) | |
| 302 | 338 | menuContext.Y = int(params.GetYcoord()) | |
| @@ -82,7 +82,15 @@ func (h *handlerSet) RunContextMenu( | |||||
| 82 | x, | 82 | x, | ||
| 83 | y, | 83 | y, | ||
| 84 | func(item port.MenuItem) { | 84 | func(item port.MenuItem) { | ||
| 85 | - | dispatchContextMenuSelection(h.wv.ctx, executor, callback, commandIDByAction, item, menuContext) | 85 | + | var explicitCopyCallback func(string) |
| 86 | + | var copiedCallback func(string) | |||
| 87 | + | 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 | |||
| 92 | + | } | |||
| 93 | + | dispatchContextMenuSelection(h.wv.ctx, executor, callback, explicitCopyCallback, copiedCallback, commandIDByAction, item, menuContext) | |||
| 86 | }, | 94 | }, | ||
| 87 | func() { | 95 | func() { | ||
| 88 | callback.Cancel() | 96 | callback.Cancel() | ||
| @@ -162,6 +170,8 @@ func dispatchContextMenuSelection( | |||||
| 162 | ctx context.Context, | 170 | ctx context.Context, | ||
| 163 | executor port.ContextMenuActionExecutor, | 171 | executor port.ContextMenuActionExecutor, | ||
| 164 | callback purecef.RunContextMenuCallback, | 172 | callback purecef.RunContextMenuCallback, | ||
| 173 | + | explicitCopyCallback func(text string), | |||
| 174 | + | copiedCallback func(text string), | |||
| 165 | commandIDByAction map[port.MenuAction]int32, | 175 | commandIDByAction map[port.MenuAction]int32, | ||
| 166 | item port.MenuItem, | 176 | item port.MenuItem, | ||
| 167 | menuContext port.MenuContext, | 177 | menuContext port.MenuContext, | ||
| @@ -171,14 +181,25 @@ func dispatchContextMenuSelection( | |||||
| 171 | return | 181 | return | ||
| 172 | } | 182 | } | ||
| 173 | log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected") | 183 | 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") | |||
| 187 | + | callback.Cancel() | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 174 | if shouldExecuteDirectCEFAction(item.Action) && executor != nil { | 190 | if shouldExecuteDirectCEFAction(item.Action) && executor != nil { | ||
| 175 | if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil { | 191 | if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil { | ||
| 176 | log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed") | 192 | log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed") | ||
| 177 | callback.Cancel() | 193 | callback.Cancel() | ||
| 178 | return | 194 | return | ||
| 179 | } | 195 | } | ||
| 196 | + | if copiedCallback != nil { | |||
| 197 | + | if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" { | |||
| 198 | + | copiedCallback(copiedText) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 180 | log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly") | 201 | log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly") | ||
| 181 | - | callback.Cont(0, 0) | 202 | + | callback.Cancel() |
| 182 | return | 203 | return | ||
| 183 | } | 204 | } | ||
| 184 | if cmdID, ok := commandIDByAction[item.Action]; ok { | 205 | if cmdID, ok := commandIDByAction[item.Action]; ok { | ||
| @@ -190,6 +211,20 @@ func dispatchContextMenuSelection( | |||||
| 190 | callback.Cancel() | 211 | callback.Cancel() | ||
| 191 | } | 212 | } | ||
| 192 | 213 | ||||
| 214 | + | func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string { | |||
| 215 | + | switch action { | |||
| 216 | + | case port.MenuActionCopySelection: | |||
| 217 | + | if menuContext.SelectionText != "" { | |||
| 218 | + | return menuContext.SelectionText | |||
| 219 | + | } | |||
| 220 | + | return "" | |||
| 221 | + | case port.MenuActionCopyLink: | |||
| 222 | + | return menuContext.LinkURI | |||
| 223 | + | default: | |||
| 224 | + | return "" | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 193 | func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | 228 | func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | ||
| 194 | switch action { | 229 | switch action { | ||
| 195 | case port.MenuActionBack, | 230 | case port.MenuActionBack, | ||
| @@ -296,7 +331,8 @@ func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuCo | |||||
| 296 | if params.HasImageContents() { | 331 | if params.HasImageContents() { | ||
| 297 | menuContext.ImageURI = params.GetSourceURL() | 332 | menuContext.ImageURI = params.GetSourceURL() | ||
| 298 | } | 333 | } | ||
| 299 | - | menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != "" | 334 | + | menuContext.SelectionText = params.GetSelectionText() |
| 335 | + | menuContext.HasSelection = strings.TrimSpace(menuContext.SelectionText) != "" | |||
| 300 | menuContext.IsEditable = params.IsEditable() | 336 | menuContext.IsEditable = params.IsEditable() | ||
| 301 | menuContext.X = int(params.GetXcoord()) | 337 | menuContext.X = int(params.GetXcoord()) | ||
| 302 | menuContext.Y = int(params.GetYcoord()) | 338 | menuContext.Y = int(params.GetYcoord()) | ||
M internal/infrastructure/cef/context_menu_adapter_test.go +41 -6
| @@ -64,6 +64,7 @@ func TestBuildMenuContextFromCEFParams(t *testing.T) { | |||
| 64 | 64 | require.Equal(t, "https://example.com/page", ctx.PageURI) | |
| 65 | 65 | require.Equal(t, "https://example.com/link", ctx.LinkURI) | |
| 66 | 66 | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | |
| 67 | + | require.Equal(t, "selected text", ctx.SelectionText) | |
| 67 | 68 | require.True(t, ctx.HasSelection) | |
| 68 | 69 | require.True(t, ctx.IsEditable) | |
| 69 | 70 | require.True(t, ctx.CanGoBack) | |
| @@ -104,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() { | |||
| 104 | 105 | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |
| 105 | 106 | callback := &stubRunContextMenuCallback{} | |
| 106 | 107 | ||
| 107 | - | dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{ | |
| 108 | + | dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{ | |
| 108 | 109 | port.MenuActionReload: 102, | |
| 109 | 110 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | |
| 110 | 111 | ||
| @@ -115,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||
| 115 | 116 | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | |
| 116 | 117 | callback := &stubRunContextMenuCallback{} | |
| 117 | 118 | ||
| 118 | - | dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{ | |
| 119 | + | dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{ | |
| 119 | 120 | port.MenuActionInspectElement: 204, | |
| 120 | 121 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | |
| 121 | 122 | ||
| @@ -133,6 +134,8 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||
| 133 | 134 | context.Background(), | |
| 134 | 135 | executor, | |
| 135 | 136 | callback, | |
| 137 | + | nil, | |
| 138 | + | nil, | |
| 136 | 139 | map[port.MenuAction]int32{port.MenuActionReload: 102}, | |
| 137 | 140 | port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}, | |
| 138 | 141 | menuContext, | |
| @@ -141,9 +144,9 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||
| 141 | 144 | require.Equal(t, 1, executor.executeCalls) | |
| 142 | 145 | require.Equal(t, port.MenuActionReload, executor.action) | |
| 143 | 146 | require.Equal(t, menuContext, executor.menuContext) | |
| 144 | - | require.Equal(t, 1, callback.contCalls) | |
| 147 | + | require.Zero(t, callback.contCalls) | |
| 145 | 148 | require.Zero(t, callback.commandID) | |
| 146 | - | require.Zero(t, callback.cancelCalls) | |
| 149 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 147 | 150 | } | |
| 148 | 151 | ||
| 149 | 152 | func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) { | |
| @@ -155,6 +158,8 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||
| 155 | 158 | context.Background(), | |
| 156 | 159 | executor, | |
| 157 | 160 | callback, | |
| 161 | + | nil, | |
| 162 | + | nil, | |
| 158 | 163 | map[port.MenuAction]int32{}, | |
| 159 | 164 | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | |
| 160 | 165 | menuContext, | |
| @@ -163,9 +168,39 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||
| 163 | 168 | require.Equal(t, 1, executor.executeCalls) | |
| 164 | 169 | require.Equal(t, port.MenuActionCopyImage, executor.action) | |
| 165 | 170 | require.Equal(t, menuContext, executor.menuContext) | |
| 166 | - | require.Equal(t, 1, callback.contCalls) | |
| 171 | + | require.Zero(t, callback.contCalls) | |
| 167 | 172 | require.Zero(t, callback.commandID) | |
| 168 | - | require.Zero(t, callback.cancelCalls) | |
| 173 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestContextMenuSelectionRoutesSelectionCopyThroughSharedExplicitPath(t *testing.T) { | |
| 177 | + | callback := &stubRunContextMenuCallback{} | |
| 178 | + | executor := &stubContextMenuExecutor{} | |
| 179 | + | 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) | |
| 169 | 204 | } | |
| 170 | 205 | ||
| 171 | 206 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | |
| @@ -64,6 +64,7 @@ func TestBuildMenuContextFromCEFParams(t *testing.T) { | |||||
| 64 | require.Equal(t, "https://example.com/page", ctx.PageURI) | 64 | require.Equal(t, "https://example.com/page", ctx.PageURI) | ||
| 65 | require.Equal(t, "https://example.com/link", ctx.LinkURI) | 65 | require.Equal(t, "https://example.com/link", ctx.LinkURI) | ||
| 66 | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | 66 | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | ||
| 67 | + | require.Equal(t, "selected text", ctx.SelectionText) | |||
| 67 | require.True(t, ctx.HasSelection) | 68 | require.True(t, ctx.HasSelection) | ||
| 68 | require.True(t, ctx.IsEditable) | 69 | require.True(t, ctx.IsEditable) | ||
| 69 | require.True(t, ctx.CanGoBack) | 70 | require.True(t, ctx.CanGoBack) | ||
| @@ -104,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() { | |||||
| 104 | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | 105 | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | ||
| 105 | callback := &stubRunContextMenuCallback{} | 106 | callback := &stubRunContextMenuCallback{} | ||
| 106 | 107 | ||||
| 107 | - | dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{ | 108 | + | dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{ |
| 108 | port.MenuActionReload: 102, | 109 | port.MenuActionReload: 102, | ||
| 109 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | 110 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | ||
| 110 | 111 | ||||
| @@ -115,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||||
| 115 | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | 116 | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | ||
| 116 | callback := &stubRunContextMenuCallback{} | 117 | callback := &stubRunContextMenuCallback{} | ||
| 117 | 118 | ||||
| 118 | - | dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{ | 119 | + | dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{ |
| 119 | port.MenuActionInspectElement: 204, | 120 | port.MenuActionInspectElement: 204, | ||
| 120 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | 121 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | ||
| 121 | 122 | ||||
| @@ -133,6 +134,8 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||||
| 133 | context.Background(), | 134 | context.Background(), | ||
| 134 | executor, | 135 | executor, | ||
| 135 | callback, | 136 | callback, | ||
| 137 | + | nil, | |||
| 138 | + | nil, | |||
| 136 | map[port.MenuAction]int32{port.MenuActionReload: 102}, | 139 | map[port.MenuAction]int32{port.MenuActionReload: 102}, | ||
| 137 | port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}, | 140 | port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}, | ||
| 138 | menuContext, | 141 | menuContext, | ||
| @@ -141,9 +144,9 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||||
| 141 | require.Equal(t, 1, executor.executeCalls) | 144 | require.Equal(t, 1, executor.executeCalls) | ||
| 142 | require.Equal(t, port.MenuActionReload, executor.action) | 145 | require.Equal(t, port.MenuActionReload, executor.action) | ||
| 143 | require.Equal(t, menuContext, executor.menuContext) | 146 | require.Equal(t, menuContext, executor.menuContext) | ||
| 144 | - | require.Equal(t, 1, callback.contCalls) | 147 | + | require.Zero(t, callback.contCalls) |
| 145 | require.Zero(t, callback.commandID) | 148 | require.Zero(t, callback.commandID) | ||
| 146 | - | require.Zero(t, callback.cancelCalls) | 149 | + | require.Equal(t, 1, callback.cancelCalls) |
| 147 | } | 150 | } | ||
| 148 | 151 | ||||
| 149 | func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) { | 152 | func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) { | ||
| @@ -155,6 +158,8 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||||
| 155 | context.Background(), | 158 | context.Background(), | ||
| 156 | executor, | 159 | executor, | ||
| 157 | callback, | 160 | callback, | ||
| 161 | + | nil, | |||
| 162 | + | nil, | |||
| 158 | map[port.MenuAction]int32{}, | 163 | map[port.MenuAction]int32{}, | ||
| 159 | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | 164 | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | ||
| 160 | menuContext, | 165 | menuContext, | ||
| @@ -163,9 +168,39 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||||
| 163 | require.Equal(t, 1, executor.executeCalls) | 168 | require.Equal(t, 1, executor.executeCalls) | ||
| 164 | require.Equal(t, port.MenuActionCopyImage, executor.action) | 169 | require.Equal(t, port.MenuActionCopyImage, executor.action) | ||
| 165 | require.Equal(t, menuContext, executor.menuContext) | 170 | require.Equal(t, menuContext, executor.menuContext) | ||
| 166 | - | require.Equal(t, 1, callback.contCalls) | 171 | + | require.Zero(t, callback.contCalls) |
| 167 | require.Zero(t, callback.commandID) | 172 | require.Zero(t, callback.commandID) | ||
| 168 | - | require.Zero(t, callback.cancelCalls) | 173 | + | require.Equal(t, 1, callback.cancelCalls) |
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestContextMenuSelectionRoutesSelectionCopyThroughSharedExplicitPath(t *testing.T) { | |||
| 177 | + | callback := &stubRunContextMenuCallback{} | |||
| 178 | + | executor := &stubContextMenuExecutor{} | |||
| 179 | + | 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) | |||
| 169 | } | 204 | } | ||
| 170 | 205 | ||||
| 171 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | 206 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | ||
M internal/infrastructure/cef/engine.go +63 -1
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "sync" | |
| 6 | 6 | "sync/atomic" | |
| 7 | 7 | "time" | |
| 8 | + | "unicode/utf8" | |
| 8 | 9 | ||
| 9 | 10 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | 11 | ||
| @@ -35,6 +36,8 @@ type Engine struct { | |||
| 35 | 36 | ctxMenuBuilder port.ContextMenuBuilder | |
| 36 | 37 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 37 | 38 | clipboard port.Clipboard | |
| 39 | + | clipboardTextOrchestrator port.ClipboardTextOrchestrator | |
| 40 | + | onClipboardCopied func(textLen int) | |
| 38 | 41 | resolver port.ImageDataResolver | |
| 39 | 42 | mediaClassifier MediaClassifier | |
| 40 | 43 | alreadyRunningAppRelaunchMu sync.RWMutex | |
| @@ -236,6 +239,8 @@ func (e *Engine) closeActiveWebViews() { | |||
| 236 | 239 | // each handler is registered by message type with the router, which dispatches | |
| 237 | 240 | // incoming /api/message POSTs to the correct handler based on Message.Type. | |
| 238 | 241 | func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error { | |
| 242 | + | e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator | |
| 243 | + | e.onClipboardCopied = deps.OnClipboardCopied | |
| 239 | 244 | if e.messageRouter == nil || e.registerHandlers == nil { | |
| 240 | 245 | return nil | |
| 241 | 246 | } | |
| @@ -250,7 +255,12 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||
| 250 | 255 | } | |
| 251 | 256 | ||
| 252 | 257 | // ConfigureDownloads sets up download handling (Phase 1 stub). | |
| 253 | - | func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.DownloadEventHandler, _ port.DownloadPreparer) error { | |
| 258 | + | func (e *Engine) ConfigureDownloads( | |
| 259 | + | _ context.Context, | |
| 260 | + | _ string, | |
| 261 | + | _ port.DownloadEventHandler, | |
| 262 | + | _ port.DownloadPreparer, | |
| 263 | + | ) error { | |
| 254 | 264 | return ErrDownloadsUnsupported | |
| 255 | 265 | } | |
| 256 | 266 | ||
| @@ -276,6 +286,58 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) | |||
| 276 | 286 | return nil | |
| 277 | 287 | } | |
| 278 | 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) { | |
| 294 | + | if e == nil || text == "" { | |
| 295 | + | return | |
| 296 | + | } | |
| 297 | + | if e.clipboardTextOrchestrator == nil { | |
| 298 | + | logging.FromContext(e.currentContext()).Debug(). | |
| 299 | + | Str("action", action). | |
| 300 | + | Msg("cef: clipboard orchestration skipped — orchestrator nil") | |
| 301 | + | return | |
| 302 | + | } | |
| 303 | + | textLen := utf8.RuneCountInString(text) | |
| 304 | + | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{ | |
| 305 | + | Text: text, | |
| 306 | + | Action: action, | |
| 307 | + | SourceEngine: port.ClipboardSourceCEF, | |
| 308 | + | ViewID: viewID, | |
| 309 | + | }); err != nil { | |
| 310 | + | logging.FromContext(e.currentContext()).Debug(). | |
| 311 | + | Err(err). | |
| 312 | + | Str("action", action). | |
| 313 | + | Int("text_len", textLen). | |
| 314 | + | Msg("cef: clipboard explicit copy handling failed") | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (e *Engine) handleClipboardSelectionUpdate(viewID port.WebViewID, text string) { | |
| 319 | + | if e == nil || e.clipboardTextOrchestrator == nil { | |
| 320 | + | return | |
| 321 | + | } | |
| 322 | + | if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), port.SelectionClipboardInput{ | |
| 323 | + | Text: text, | |
| 324 | + | SourceEngine: port.ClipboardSourceCEF, | |
| 325 | + | ViewID: viewID, | |
| 326 | + | }); err != nil { | |
| 327 | + | logging.FromContext(e.currentContext()).Debug(). | |
| 328 | + | Err(err). | |
| 329 | + | Int("text_len", utf8.RuneCountInString(text)). | |
| 330 | + | Msg("cef: clipboard selection handling failed") | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | func (e *Engine) notifyClipboardCopied(text string) { | |
| 335 | + | if e == nil || e.onClipboardCopied == nil || text == "" { | |
| 336 | + | return | |
| 337 | + | } | |
| 338 | + | e.onClipboardCopied(utf8.RuneCountInString(text)) | |
| 339 | + | } | |
| 340 | + | ||
| 279 | 341 | // SetHandlerContext sets the base context for message handler dispatch. | |
| 280 | 342 | func (e *Engine) SetHandlerContext(ctx context.Context) { | |
| 281 | 343 | e.ctx = ctx | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "sync" | 5 | "sync" | ||
| 6 | "sync/atomic" | 6 | "sync/atomic" | ||
| 7 | "time" | 7 | "time" | ||
| 8 | + | "unicode/utf8" | |||
| 8 | 9 | ||||
| 9 | purecef "github.com/bnema/purego-cef/cef" | 10 | purecef "github.com/bnema/purego-cef/cef" | ||
| 10 | 11 | ||||
| @@ -35,6 +36,8 @@ type Engine struct { | |||||
| 35 | ctxMenuBuilder port.ContextMenuBuilder | 36 | ctxMenuBuilder port.ContextMenuBuilder | ||
| 36 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | 37 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | ||
| 37 | clipboard port.Clipboard | 38 | clipboard port.Clipboard | ||
| 39 | + | clipboardTextOrchestrator port.ClipboardTextOrchestrator | |||
| 40 | + | onClipboardCopied func(textLen int) | |||
| 38 | resolver port.ImageDataResolver | 41 | resolver port.ImageDataResolver | ||
| 39 | mediaClassifier MediaClassifier | 42 | mediaClassifier MediaClassifier | ||
| 40 | alreadyRunningAppRelaunchMu sync.RWMutex | 43 | alreadyRunningAppRelaunchMu sync.RWMutex | ||
| @@ -236,6 +239,8 @@ func (e *Engine) closeActiveWebViews() { | |||||
| 236 | // each handler is registered by message type with the router, which dispatches | 239 | // each handler is registered by message type with the router, which dispatches | ||
| 237 | // incoming /api/message POSTs to the correct handler based on Message.Type. | 240 | // incoming /api/message POSTs to the correct handler based on Message.Type. | ||
| 238 | func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error { | 241 | func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error { | ||
| 242 | + | e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator | |||
| 243 | + | e.onClipboardCopied = deps.OnClipboardCopied | |||
| 239 | if e.messageRouter == nil || e.registerHandlers == nil { | 244 | if e.messageRouter == nil || e.registerHandlers == nil { | ||
| 240 | return nil | 245 | return nil | ||
| 241 | } | 246 | } | ||
| @@ -250,7 +255,12 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||||
| 250 | } | 255 | } | ||
| 251 | 256 | ||||
| 252 | // ConfigureDownloads sets up download handling (Phase 1 stub). | 257 | // ConfigureDownloads sets up download handling (Phase 1 stub). | ||
| 253 | - | func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.DownloadEventHandler, _ port.DownloadPreparer) error { | 258 | + | func (e *Engine) ConfigureDownloads( |
| 259 | + | _ context.Context, | |||
| 260 | + | _ string, | |||
| 261 | + | _ port.DownloadEventHandler, | |||
| 262 | + | _ port.DownloadPreparer, | |||
| 263 | + | ) error { | |||
| 254 | return ErrDownloadsUnsupported | 264 | return ErrDownloadsUnsupported | ||
| 255 | } | 265 | } | ||
| 256 | 266 | ||||
| @@ -276,6 +286,58 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) | |||||
| 276 | return nil | 286 | return nil | ||
| 277 | } | 287 | } | ||
| 278 | 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) { | |||
| 294 | + | if e == nil || text == "" { | |||
| 295 | + | return | |||
| 296 | + | } | |||
| 297 | + | if e.clipboardTextOrchestrator == nil { | |||
| 298 | + | logging.FromContext(e.currentContext()).Debug(). | |||
| 299 | + | Str("action", action). | |||
| 300 | + | Msg("cef: clipboard orchestration skipped — orchestrator nil") | |||
| 301 | + | return | |||
| 302 | + | } | |||
| 303 | + | textLen := utf8.RuneCountInString(text) | |||
| 304 | + | if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{ | |||
| 305 | + | Text: text, | |||
| 306 | + | Action: action, | |||
| 307 | + | SourceEngine: port.ClipboardSourceCEF, | |||
| 308 | + | ViewID: viewID, | |||
| 309 | + | }); err != nil { | |||
| 310 | + | logging.FromContext(e.currentContext()).Debug(). | |||
| 311 | + | Err(err). | |||
| 312 | + | Str("action", action). | |||
| 313 | + | Int("text_len", textLen). | |||
| 314 | + | Msg("cef: clipboard explicit copy handling failed") | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func (e *Engine) handleClipboardSelectionUpdate(viewID port.WebViewID, text string) { | |||
| 319 | + | if e == nil || e.clipboardTextOrchestrator == nil { | |||
| 320 | + | return | |||
| 321 | + | } | |||
| 322 | + | if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), port.SelectionClipboardInput{ | |||
| 323 | + | Text: text, | |||
| 324 | + | SourceEngine: port.ClipboardSourceCEF, | |||
| 325 | + | ViewID: viewID, | |||
| 326 | + | }); err != nil { | |||
| 327 | + | logging.FromContext(e.currentContext()).Debug(). | |||
| 328 | + | Err(err). | |||
| 329 | + | Int("text_len", utf8.RuneCountInString(text)). | |||
| 330 | + | Msg("cef: clipboard selection handling failed") | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | func (e *Engine) notifyClipboardCopied(text string) { | |||
| 335 | + | if e == nil || e.onClipboardCopied == nil || text == "" { | |||
| 336 | + | return | |||
| 337 | + | } | |||
| 338 | + | e.onClipboardCopied(utf8.RuneCountInString(text)) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 279 | // SetHandlerContext sets the base context for message handler dispatch. | 341 | // SetHandlerContext sets the base context for message handler dispatch. | ||
| 280 | func (e *Engine) SetHandlerContext(ctx context.Context) { | 342 | func (e *Engine) SetHandlerContext(ctx context.Context) { | ||
| 281 | e.ctx = ctx | 343 | e.ctx = ctx | ||
M internal/infrastructure/cef/engine_init.go +4 -17
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/gdk" | |
| 14 | - | "github.com/bnema/puregotk/v4/glib" | |
| 15 | 14 | "github.com/rs/zerolog" | |
| 16 | 15 | ||
| 17 | 16 | "github.com/bnema/dumber/assets" | |
| @@ -253,24 +252,12 @@ func wireEngine( | |||
| 253 | 252 | } | |
| 254 | 253 | schemeHandler.setAssets(assets.WebUIAssets) | |
| 255 | 254 | ||
| 256 | - | // Bridge clipboard writes from CEF JS → GDK system clipboard. | |
| 257 | - | // The callback is invoked on the CEF IO thread, so we schedule the | |
| 258 | - | // GDK write on the GTK main loop via glib.IdleAddOnce. | |
| 255 | + | // Route scheme-handler clipboard/focus callbacks through the same engine-side | |
| 256 | + | // bridge handlers used by the native renderer bridge. | |
| 259 | 257 | schemeHandler.onClipboardSet = func(text string) { | |
| 260 | - | fn := glib.SourceOnceFunc(func(_ uintptr) { | |
| 261 | - | if display := gdk.DisplayGetDefault(); display != nil { | |
| 262 | - | if cb := display.GetClipboard(); cb != nil { | |
| 263 | - | cb.SetText(text) | |
| 264 | - | logger.Debug().Msg("cef: clipboard set via GDK") | |
| 265 | - | } else { | |
| 266 | - | logger.Debug().Msg("cef: clipboard set failed — no GDK clipboard") | |
| 267 | - | } | |
| 268 | - | } else { | |
| 269 | - | logger.Debug().Msg("cef: clipboard set failed — no GDK display") | |
| 270 | - | } | |
| 271 | - | }) | |
| 272 | - | glib.IdleAddOnce(&fn, 0) | |
| 258 | + | eng.handleClipboardBridgeText(0, text) | |
| 273 | 259 | } | |
| 260 | + | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |
| 274 | 261 | ||
| 275 | 262 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | |
| 276 | 263 | ||
| @@ -11,7 +11,6 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | purecef "github.com/bnema/purego-cef/cef" | 12 | purecef "github.com/bnema/purego-cef/cef" | ||
| 13 | "github.com/bnema/puregotk/v4/gdk" | 13 | "github.com/bnema/puregotk/v4/gdk" | ||
| 14 | - | "github.com/bnema/puregotk/v4/glib" | |||
| 15 | "github.com/rs/zerolog" | 14 | "github.com/rs/zerolog" | ||
| 16 | 15 | ||||
| 17 | "github.com/bnema/dumber/assets" | 16 | "github.com/bnema/dumber/assets" | ||
| @@ -253,24 +252,12 @@ func wireEngine( | |||||
| 253 | } | 252 | } | ||
| 254 | schemeHandler.setAssets(assets.WebUIAssets) | 253 | schemeHandler.setAssets(assets.WebUIAssets) | ||
| 255 | 254 | ||||
| 256 | - | // Bridge clipboard writes from CEF JS → GDK system clipboard. | 255 | + | // Route scheme-handler clipboard/focus callbacks through the same engine-side |
| 257 | - | // The callback is invoked on the CEF IO thread, so we schedule the | 256 | + | // bridge handlers used by the native renderer bridge. |
| 258 | - | // GDK write on the GTK main loop via glib.IdleAddOnce. | |||
| 259 | schemeHandler.onClipboardSet = func(text string) { | 257 | schemeHandler.onClipboardSet = func(text string) { | ||
| 260 | - | fn := glib.SourceOnceFunc(func(_ uintptr) { | 258 | + | eng.handleClipboardBridgeText(0, text) |
| 261 | - | if display := gdk.DisplayGetDefault(); display != nil { | |||
| 262 | - | if cb := display.GetClipboard(); cb != nil { | |||
| 263 | - | cb.SetText(text) | |||
| 264 | - | logger.Debug().Msg("cef: clipboard set via GDK") | |||
| 265 | - | } else { | |||
| 266 | - | logger.Debug().Msg("cef: clipboard set failed — no GDK clipboard") | |||
| 267 | - | } | |||
| 268 | - | } else { | |||
| 269 | - | logger.Debug().Msg("cef: clipboard set failed — no GDK display") | |||
| 270 | - | } | |||
| 271 | - | }) | |||
| 272 | - | glib.IdleAddOnce(&fn, 0) | |||
| 273 | } | 259 | } | ||
| 260 | + | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |||
| 274 | 261 | ||||
| 275 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | 262 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | ||
| 276 | 263 | ||||
M internal/infrastructure/cef/engine_runtime_test.go +61 -0
| @@ -51,3 +51,64 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) { | |||
| 51 | 51 | err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{}) | |
| 52 | 52 | require.ErrorIs(t, err, want) | |
| 53 | 53 | } | |
| 54 | + | ||
| 55 | + | func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) { | |
| 56 | + | orchestrator := &stubClipboardTextOrchestrator{} | |
| 57 | + | onCopied := func(int) {} | |
| 58 | + | eng := &Engine{ | |
| 59 | + | messageRouter: NewMessageRouter(context.Background()), | |
| 60 | + | registerHandlers: func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error { | |
| 61 | + | return nil | |
| 62 | + | }, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | require.NoError(t, eng.RegisterHandlers(context.Background(), port.HandlerDependencies{ | |
| 66 | + | ClipboardTextOrchestrator: orchestrator, | |
| 67 | + | OnClipboardCopied: onCopied, | |
| 68 | + | })) | |
| 69 | + | require.Same(t, orchestrator, eng.clipboardTextOrchestrator) | |
| 70 | + | require.NotNil(t, eng.onClipboardCopied) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestEngineClipboardSelectionUpdate_ForwardsViewID(t *testing.T) { | |
| 74 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |
| 75 | + | eng := &Engine{clipboardTextOrchestrator: orchestrator} | |
| 76 | + | ||
| 77 | + | eng.handleClipboardSelectionUpdate(99, "selected") | |
| 78 | + | ||
| 79 | + | require.Equal(t, port.SelectionClipboardInput{Text: "selected", SourceEngine: port.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |
| 83 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |
| 84 | + | eng := &Engine{clipboardTextOrchestrator: orchestrator} | |
| 85 | + | ||
| 86 | + | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | |
| 87 | + | ||
| 88 | + | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | type stubClipboardTextOrchestrator struct{} | |
| 92 | + | ||
| 93 | + | type recordingClipboardTextOrchestrator struct { | |
| 94 | + | selection port.SelectionClipboardInput | |
| 95 | + | explicit port.ExplicitClipboardInput | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, port.SelectionClipboardInput) error { | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, port.ExplicitClipboardInput) error { | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |
| 107 | + | r.selection = input | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error { | |
| 112 | + | r.explicit = input | |
| 113 | + | return nil | |
| 114 | + | } | |
| @@ -51,3 +51,64 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) { | |||||
| 51 | err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{}) | 51 | err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{}) | ||
| 52 | require.ErrorIs(t, err, want) | 52 | require.ErrorIs(t, err, want) | ||
| 53 | } | 53 | } | ||
| 54 | + | ||||
| 55 | + | func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) { | |||
| 56 | + | orchestrator := &stubClipboardTextOrchestrator{} | |||
| 57 | + | onCopied := func(int) {} | |||
| 58 | + | eng := &Engine{ | |||
| 59 | + | messageRouter: NewMessageRouter(context.Background()), | |||
| 60 | + | registerHandlers: func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error { | |||
| 61 | + | return nil | |||
| 62 | + | }, | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | require.NoError(t, eng.RegisterHandlers(context.Background(), port.HandlerDependencies{ | |||
| 66 | + | ClipboardTextOrchestrator: orchestrator, | |||
| 67 | + | OnClipboardCopied: onCopied, | |||
| 68 | + | })) | |||
| 69 | + | require.Same(t, orchestrator, eng.clipboardTextOrchestrator) | |||
| 70 | + | require.NotNil(t, eng.onClipboardCopied) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestEngineClipboardSelectionUpdate_ForwardsViewID(t *testing.T) { | |||
| 74 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |||
| 75 | + | eng := &Engine{clipboardTextOrchestrator: orchestrator} | |||
| 76 | + | ||||
| 77 | + | eng.handleClipboardSelectionUpdate(99, "selected") | |||
| 78 | + | ||||
| 79 | + | require.Equal(t, port.SelectionClipboardInput{Text: "selected", SourceEngine: port.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |||
| 83 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |||
| 84 | + | eng := &Engine{clipboardTextOrchestrator: orchestrator} | |||
| 85 | + | ||||
| 86 | + | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | |||
| 87 | + | ||||
| 88 | + | require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | type stubClipboardTextOrchestrator struct{} | |||
| 92 | + | ||||
| 93 | + | type recordingClipboardTextOrchestrator struct { | |||
| 94 | + | selection port.SelectionClipboardInput | |||
| 95 | + | explicit port.ExplicitClipboardInput | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (stubClipboardTextOrchestrator) HandleSelectionUpdate(context.Context, port.SelectionClipboardInput) error { | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (stubClipboardTextOrchestrator) HandleExplicitCopy(context.Context, port.ExplicitClipboardInput) error { | |||
| 103 | + | return nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |||
| 107 | + | r.selection = input | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error { | |||
| 112 | + | r.explicit = input | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
M internal/infrastructure/cef/factory.go +6 -0
| @@ -103,6 +103,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 103 | 103 | wv.findCtrl = newFindController() | |
| 104 | 104 | ||
| 105 | 105 | input := newInputBridge(ctx, f.scale) | |
| 106 | + | input.selectionText = wv.selectedTextSnapshot | |
| 107 | + | input.explicitCopyText = func(action, text string) { | |
| 108 | + | if f.engine != nil { | |
| 109 | + | f.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |
| 110 | + | } | |
| 111 | + | } | |
| 106 | 112 | input.attachTo(pipeline.glArea) | |
| 107 | 113 | wv.input = input | |
| 108 | 114 | ||
| @@ -103,6 +103,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 103 | wv.findCtrl = newFindController() | 103 | wv.findCtrl = newFindController() | ||
| 104 | 104 | ||||
| 105 | input := newInputBridge(ctx, f.scale) | 105 | input := newInputBridge(ctx, f.scale) | ||
| 106 | + | input.selectionText = wv.selectedTextSnapshot | |||
| 107 | + | input.explicitCopyText = func(action, text string) { | |||
| 108 | + | if f.engine != nil { | |||
| 109 | + | f.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 106 | input.attachTo(pipeline.glArea) | 112 | input.attachTo(pipeline.glArea) | ||
| 107 | wv.input = input | 113 | wv.input = input | ||
| 108 | 114 | ||||
M internal/infrastructure/cef/input_bridge.go +85 -4
| @@ -3,6 +3,7 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | + | "sync/atomic" | |
| 6 | 7 | ||
| 7 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 9 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -56,6 +57,14 @@ type inputBridge struct { | |||
| 56 | 57 | // ImeCommitText. Set once during attachTo, read-only afterwards. | |
| 57 | 58 | clipboard *gdk.Clipboard | |
| 58 | 59 | ||
| 60 | + | // selectionText returns the browser's latest known text selection so Ctrl+C | |
| 61 | + | // / Ctrl+X can route the selection through the shared explicit-copy path. | |
| 62 | + | selectionText func() string | |
| 63 | + | ||
| 64 | + | // explicitCopyText routes explicit copy/cut text through the shared | |
| 65 | + | // clipboard orchestration path. | |
| 66 | + | explicitCopyText func(action, text string) | |
| 67 | + | ||
| 59 | 68 | // IMContext for dead key / compose sequence support. Held as a field | |
| 60 | 69 | // to prevent garbage collection while the controller is alive. | |
| 61 | 70 | imContext *gtk.IMContextSimple | |
| @@ -67,12 +76,32 @@ type inputBridge struct { | |||
| 67 | 76 | // show the text caret. | |
| 68 | 77 | glArea *gtk.GLArea | |
| 69 | 78 | ||
| 79 | + | // gtkFocused mirrors the latest GTK focus-controller state so non-GTK | |
| 80 | + | // threads (CEF callbacks, scheme handlers) can safely know whether this | |
| 81 | + | // widget is still the active focused view. | |
| 82 | + | gtkFocused atomic.Bool | |
| 83 | + | ||
| 70 | 84 | // onMiddleClick is called when button 2 (middle) is pressed on a link. | |
| 71 | 85 | // The callback receives the hovered URI. Set by the factory. | |
| 72 | 86 | onMiddleClick func(uri string) | |
| 73 | 87 | } | |
| 74 | 88 | ||
| 75 | 89 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | |
| 90 | + | type browserFocusSyncHost interface { | |
| 91 | + | WasHidden(hidden int32) | |
| 92 | + | SetFocus(focus int32) | |
| 93 | + | Invalidate(elementType purecef.PaintElementType) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func syncWindowlessBrowserFocus(host browserFocusSyncHost) { | |
| 97 | + | if host == nil { | |
| 98 | + | return | |
| 99 | + | } | |
| 100 | + | host.WasHidden(0) | |
| 101 | + | host.SetFocus(1) | |
| 102 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 103 | + | } | |
| 104 | + | ||
| 76 | 105 | func newInputBridge(ctx context.Context, scale int32) *inputBridge { | |
| 77 | 106 | return &inputBridge{ctx: ctx, scale: scale} | |
| 78 | 107 | } | |
| @@ -163,6 +192,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 163 | 192 | // Focus — forward to CEF and IMContext. | |
| 164 | 193 | focus := gtk.NewEventControllerFocus() | |
| 165 | 194 | focusEnterCb := func(_ gtk.EventControllerFocus) { | |
| 195 | + | ib.gtkFocused.Store(true) | |
| 166 | 196 | if ib.imContext != nil { | |
| 167 | 197 | ib.imContext.FocusIn() | |
| 168 | 198 | } | |
| @@ -170,6 +200,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 170 | 200 | } | |
| 171 | 201 | focus.ConnectEnter(&focusEnterCb) | |
| 172 | 202 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | |
| 203 | + | ib.gtkFocused.Store(false) | |
| 173 | 204 | if ib.imContext != nil { | |
| 174 | 205 | ib.imContext.Reset() | |
| 175 | 206 | ib.imContext.FocusOut() | |
| @@ -200,6 +231,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 200 | 231 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 201 | 232 | mods := uint(state) | |
| 202 | 233 | ||
| 234 | + | ib.maybeMirrorClipboardShortcut(keyval, mods) | |
| 235 | + | ||
| 203 | 236 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | |
| 204 | 237 | // Wayland/X11 clipboard, so we read from GDK and inject via | |
| 205 | 238 | // ImeCommitText instead of letting CEF handle the paste. | |
| @@ -285,6 +318,13 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount | |||
| 285 | 318 | return | |
| 286 | 319 | } | |
| 287 | 320 | ||
| 321 | + | // Reassert windowless browser focus before forwarding left-clicks. GTK may | |
| 322 | + | // still consider the GLArea focused while Chromium forgot that state after a | |
| 323 | + | // navigation or DOM transition, which prevents the caret from painting. | |
| 324 | + | if button == 1 { | |
| 325 | + | syncWindowlessBrowserFocus(host) | |
| 326 | + | } | |
| 327 | + | ||
| 288 | 328 | evt := buildMouseEvent(x, y, mods, ib.scale) | |
| 289 | 329 | btn := translateMouseButton(button) | |
| 290 | 330 | host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount)) | |
| @@ -321,10 +361,7 @@ func (ib *inputBridge) onFocusIn() { | |||
| 321 | 361 | ib.mu.Lock() | |
| 322 | 362 | host := ib.host | |
| 323 | 363 | ib.mu.Unlock() | |
| 324 | - | if host == nil { | |
| 325 | - | return | |
| 326 | - | } | |
| 327 | - | host.SetFocus(1) | |
| 364 | + | syncWindowlessBrowserFocus(host) | |
| 328 | 365 | } | |
| 329 | 366 | ||
| 330 | 367 | func (ib *inputBridge) onFocusOut() { | |
| @@ -337,6 +374,46 @@ func (ib *inputBridge) onFocusOut() { | |||
| 337 | 374 | host.SetFocus(0) | |
| 338 | 375 | } | |
| 339 | 376 | ||
| 377 | + | func (ib *inputBridge) hasGTKFocus() bool { | |
| 378 | + | if ib == nil { | |
| 379 | + | return false | |
| 380 | + | } | |
| 381 | + | return ib.gtkFocused.Load() | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) { | |
| 385 | + | action, ok := clipboardShortcutAction(keyval, mods) | |
| 386 | + | if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil { | |
| 387 | + | return | |
| 388 | + | } | |
| 389 | + | text := ib.selectionText() | |
| 390 | + | if text == "" { | |
| 391 | + | return | |
| 392 | + | } | |
| 393 | + | logging.FromContext(ib.ctx).Debug(). | |
| 394 | + | Str("action", action). | |
| 395 | + | Int("text_len", len(text)). | |
| 396 | + | Msg("cef: clipboard shortcut routed through explicit copy handler") | |
| 397 | + | ib.explicitCopyText(action, text) | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | func clipboardShortcutAction(keyval, mods uint) (string, bool) { | |
| 401 | + | if mods&uint(gdk.ControlMaskValue) == 0 { | |
| 402 | + | return "", false | |
| 403 | + | } | |
| 404 | + | if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 { | |
| 405 | + | return "", false | |
| 406 | + | } | |
| 407 | + | switch keyval { | |
| 408 | + | case gdkKeyLowercaseC, gdkKeyUppercaseC: | |
| 409 | + | return "copy", true | |
| 410 | + | case gdkKeyLowercaseX, gdkKeyUppercaseX: | |
| 411 | + | return "cut", true | |
| 412 | + | default: | |
| 413 | + | return "", false | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 340 | 417 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |
| 341 | 418 | ib.mu.Lock() | |
| 342 | 419 | host := ib.host | |
| @@ -558,8 +635,12 @@ const ( | |||
| 558 | 635 | gdkKeyPageDown = 0xff56 | |
| 559 | 636 | ||
| 560 | 637 | // Clipboard shortcut keyvals. | |
| 638 | + | gdkKeyLowercaseC = 0x063 | |
| 639 | + | gdkKeyUppercaseC = 0x043 | |
| 561 | 640 | gdkKeyLowercaseV = 0x076 | |
| 562 | 641 | gdkKeyUppercaseV = 0x056 | |
| 642 | + | gdkKeyLowercaseX = 0x078 | |
| 643 | + | gdkKeyUppercaseX = 0x058 | |
| 563 | 644 | ) | |
| 564 | 645 | ||
| 565 | 646 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | |
| @@ -3,6 +3,7 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | + | "sync/atomic" | |||
| 6 | 7 | ||||
| 7 | purecef "github.com/bnema/purego-cef/cef" | 8 | purecef "github.com/bnema/purego-cef/cef" | ||
| 8 | "github.com/bnema/puregotk/v4/gdk" | 9 | "github.com/bnema/puregotk/v4/gdk" | ||
| @@ -56,6 +57,14 @@ type inputBridge struct { | |||||
| 56 | // ImeCommitText. Set once during attachTo, read-only afterwards. | 57 | // ImeCommitText. Set once during attachTo, read-only afterwards. | ||
| 57 | clipboard *gdk.Clipboard | 58 | clipboard *gdk.Clipboard | ||
| 58 | 59 | ||||
| 60 | + | // selectionText returns the browser's latest known text selection so Ctrl+C | |||
| 61 | + | // / Ctrl+X can route the selection through the shared explicit-copy path. | |||
| 62 | + | selectionText func() string | |||
| 63 | + | ||||
| 64 | + | // explicitCopyText routes explicit copy/cut text through the shared | |||
| 65 | + | // clipboard orchestration path. | |||
| 66 | + | explicitCopyText func(action, text string) | |||
| 67 | + | ||||
| 59 | // IMContext for dead key / compose sequence support. Held as a field | 68 | // IMContext for dead key / compose sequence support. Held as a field | ||
| 60 | // to prevent garbage collection while the controller is alive. | 69 | // to prevent garbage collection while the controller is alive. | ||
| 61 | imContext *gtk.IMContextSimple | 70 | imContext *gtk.IMContextSimple | ||
| @@ -67,12 +76,32 @@ type inputBridge struct { | |||||
| 67 | // show the text caret. | 76 | // show the text caret. | ||
| 68 | glArea *gtk.GLArea | 77 | glArea *gtk.GLArea | ||
| 69 | 78 | ||||
| 79 | + | // gtkFocused mirrors the latest GTK focus-controller state so non-GTK | |||
| 80 | + | // threads (CEF callbacks, scheme handlers) can safely know whether this | |||
| 81 | + | // widget is still the active focused view. | |||
| 82 | + | gtkFocused atomic.Bool | |||
| 83 | + | ||||
| 70 | // onMiddleClick is called when button 2 (middle) is pressed on a link. | 84 | // onMiddleClick is called when button 2 (middle) is pressed on a link. | ||
| 71 | // The callback receives the hovered URI. Set by the factory. | 85 | // The callback receives the hovered URI. Set by the factory. | ||
| 72 | onMiddleClick func(uri string) | 86 | onMiddleClick func(uri string) | ||
| 73 | } | 87 | } | ||
| 74 | 88 | ||||
| 75 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | 89 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | ||
| 90 | + | type browserFocusSyncHost interface { | |||
| 91 | + | WasHidden(hidden int32) | |||
| 92 | + | SetFocus(focus int32) | |||
| 93 | + | Invalidate(elementType purecef.PaintElementType) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func syncWindowlessBrowserFocus(host browserFocusSyncHost) { | |||
| 97 | + | if host == nil { | |||
| 98 | + | return | |||
| 99 | + | } | |||
| 100 | + | host.WasHidden(0) | |||
| 101 | + | host.SetFocus(1) | |||
| 102 | + | host.Invalidate(purecef.PaintElementTypePetView) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 76 | func newInputBridge(ctx context.Context, scale int32) *inputBridge { | 105 | func newInputBridge(ctx context.Context, scale int32) *inputBridge { | ||
| 77 | return &inputBridge{ctx: ctx, scale: scale} | 106 | return &inputBridge{ctx: ctx, scale: scale} | ||
| 78 | } | 107 | } | ||
| @@ -163,6 +192,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||||
| 163 | // Focus — forward to CEF and IMContext. | 192 | // Focus — forward to CEF and IMContext. | ||
| 164 | focus := gtk.NewEventControllerFocus() | 193 | focus := gtk.NewEventControllerFocus() | ||
| 165 | focusEnterCb := func(_ gtk.EventControllerFocus) { | 194 | focusEnterCb := func(_ gtk.EventControllerFocus) { | ||
| 195 | + | ib.gtkFocused.Store(true) | |||
| 166 | if ib.imContext != nil { | 196 | if ib.imContext != nil { | ||
| 167 | ib.imContext.FocusIn() | 197 | ib.imContext.FocusIn() | ||
| 168 | } | 198 | } | ||
| @@ -170,6 +200,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||||
| 170 | } | 200 | } | ||
| 171 | focus.ConnectEnter(&focusEnterCb) | 201 | focus.ConnectEnter(&focusEnterCb) | ||
| 172 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | 202 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | ||
| 203 | + | ib.gtkFocused.Store(false) | |||
| 173 | if ib.imContext != nil { | 204 | if ib.imContext != nil { | ||
| 174 | ib.imContext.Reset() | 205 | ib.imContext.Reset() | ||
| 175 | ib.imContext.FocusOut() | 206 | ib.imContext.FocusOut() | ||
| @@ -200,6 +231,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||||
| 200 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | 231 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 201 | mods := uint(state) | 232 | mods := uint(state) | ||
| 202 | 233 | ||||
| 234 | + | ib.maybeMirrorClipboardShortcut(keyval, mods) | |||
| 235 | + | ||||
| 203 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | 236 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | ||
| 204 | // Wayland/X11 clipboard, so we read from GDK and inject via | 237 | // Wayland/X11 clipboard, so we read from GDK and inject via | ||
| 205 | // ImeCommitText instead of letting CEF handle the paste. | 238 | // ImeCommitText instead of letting CEF handle the paste. | ||
| @@ -285,6 +318,13 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount | |||||
| 285 | return | 318 | return | ||
| 286 | } | 319 | } | ||
| 287 | 320 | ||||
| 321 | + | // Reassert windowless browser focus before forwarding left-clicks. GTK may | |||
| 322 | + | // still consider the GLArea focused while Chromium forgot that state after a | |||
| 323 | + | // navigation or DOM transition, which prevents the caret from painting. | |||
| 324 | + | if button == 1 { | |||
| 325 | + | syncWindowlessBrowserFocus(host) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 288 | evt := buildMouseEvent(x, y, mods, ib.scale) | 328 | evt := buildMouseEvent(x, y, mods, ib.scale) | ||
| 289 | btn := translateMouseButton(button) | 329 | btn := translateMouseButton(button) | ||
| 290 | host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount)) | 330 | host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount)) | ||
| @@ -321,10 +361,7 @@ func (ib *inputBridge) onFocusIn() { | |||||
| 321 | ib.mu.Lock() | 361 | ib.mu.Lock() | ||
| 322 | host := ib.host | 362 | host := ib.host | ||
| 323 | ib.mu.Unlock() | 363 | ib.mu.Unlock() | ||
| 324 | - | if host == nil { | 364 | + | syncWindowlessBrowserFocus(host) |
| 325 | - | return | |||
| 326 | - | } | |||
| 327 | - | host.SetFocus(1) | |||
| 328 | } | 365 | } | ||
| 329 | 366 | ||||
| 330 | func (ib *inputBridge) onFocusOut() { | 367 | func (ib *inputBridge) onFocusOut() { | ||
| @@ -337,6 +374,46 @@ func (ib *inputBridge) onFocusOut() { | |||||
| 337 | host.SetFocus(0) | 374 | host.SetFocus(0) | ||
| 338 | } | 375 | } | ||
| 339 | 376 | ||||
| 377 | + | func (ib *inputBridge) hasGTKFocus() bool { | |||
| 378 | + | if ib == nil { | |||
| 379 | + | return false | |||
| 380 | + | } | |||
| 381 | + | return ib.gtkFocused.Load() | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) { | |||
| 385 | + | action, ok := clipboardShortcutAction(keyval, mods) | |||
| 386 | + | if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil { | |||
| 387 | + | return | |||
| 388 | + | } | |||
| 389 | + | text := ib.selectionText() | |||
| 390 | + | if text == "" { | |||
| 391 | + | return | |||
| 392 | + | } | |||
| 393 | + | logging.FromContext(ib.ctx).Debug(). | |||
| 394 | + | Str("action", action). | |||
| 395 | + | Int("text_len", len(text)). | |||
| 396 | + | Msg("cef: clipboard shortcut routed through explicit copy handler") | |||
| 397 | + | ib.explicitCopyText(action, text) | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | func clipboardShortcutAction(keyval, mods uint) (string, bool) { | |||
| 401 | + | if mods&uint(gdk.ControlMaskValue) == 0 { | |||
| 402 | + | return "", false | |||
| 403 | + | } | |||
| 404 | + | if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 { | |||
| 405 | + | return "", false | |||
| 406 | + | } | |||
| 407 | + | switch keyval { | |||
| 408 | + | case gdkKeyLowercaseC, gdkKeyUppercaseC: | |||
| 409 | + | return "copy", true | |||
| 410 | + | case gdkKeyLowercaseX, gdkKeyUppercaseX: | |||
| 411 | + | return "cut", true | |||
| 412 | + | default: | |||
| 413 | + | return "", false | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | ||||
| 340 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | 417 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | ||
| 341 | ib.mu.Lock() | 418 | ib.mu.Lock() | ||
| 342 | host := ib.host | 419 | host := ib.host | ||
| @@ -558,8 +635,12 @@ const ( | |||||
| 558 | gdkKeyPageDown = 0xff56 | 635 | gdkKeyPageDown = 0xff56 | ||
| 559 | 636 | ||||
| 560 | // Clipboard shortcut keyvals. | 637 | // Clipboard shortcut keyvals. | ||
| 638 | + | gdkKeyLowercaseC = 0x063 | |||
| 639 | + | gdkKeyUppercaseC = 0x043 | |||
| 561 | gdkKeyLowercaseV = 0x076 | 640 | gdkKeyLowercaseV = 0x076 | ||
| 562 | gdkKeyUppercaseV = 0x056 | 641 | gdkKeyUppercaseV = 0x056 | ||
| 642 | + | gdkKeyLowercaseX = 0x078 | |||
| 643 | + | gdkKeyUppercaseX = 0x058 | |||
| 563 | ) | 644 | ) | ||
| 564 | 645 | ||||
| 565 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | 646 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | ||
A internal/infrastructure/cef/input_bridge_test.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type focusSyncHostRecorder struct { | |
| 13 | + | calls []string | |
| 14 | + | hiddenValues []int32 | |
| 15 | + | focusValues []int32 | |
| 16 | + | invalidations []purecef.PaintElementType | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (h *focusSyncHostRecorder) WasHidden(hidden int32) { | |
| 20 | + | h.calls = append(h.calls, "WasHidden") | |
| 21 | + | h.hiddenValues = append(h.hiddenValues, hidden) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (h *focusSyncHostRecorder) SetFocus(focus int32) { | |
| 25 | + | h.calls = append(h.calls, "SetFocus") | |
| 26 | + | h.focusValues = append(h.focusValues, focus) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (h *focusSyncHostRecorder) Invalidate(elementType purecef.PaintElementType) { | |
| 30 | + | h.calls = append(h.calls, "Invalidate") | |
| 31 | + | h.invalidations = append(h.invalidations, elementType) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *testing.T) { | |
| 35 | + | host := &focusSyncHostRecorder{} | |
| 36 | + | ||
| 37 | + | syncWindowlessBrowserFocus(host) | |
| 38 | + | ||
| 39 | + | require.Equal(t, []string{"WasHidden", "SetFocus", "Invalidate"}, host.calls) | |
| 40 | + | require.Equal(t, []int32{0}, host.hiddenValues) | |
| 41 | + | require.Equal(t, []int32{1}, host.focusValues) | |
| 42 | + | require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) { | |
| 46 | + | action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |
| 47 | + | require.True(t, ok) | |
| 48 | + | require.Equal(t, "copy", action) | |
| 49 | + | ||
| 50 | + | action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue)) | |
| 51 | + | require.True(t, ok) | |
| 52 | + | require.Equal(t, "cut", action) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestClipboardShortcutAction_IgnoresCtrlShiftC(t *testing.T) { | |
| 56 | + | action, ok := clipboardShortcutAction(gdkKeyUppercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue)) | |
| 57 | + | require.False(t, ok) | |
| 58 | + | require.Empty(t, action) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestMaybeMirrorClipboardShortcut_DelegatesExplicitCopyAction(t *testing.T) { | |
| 62 | + | var gotAction string | |
| 63 | + | var gotText string | |
| 64 | + | ib := &inputBridge{ | |
| 65 | + | ctx: context.Background(), | |
| 66 | + | selectionText: func() string { return "selected text" }, | |
| 67 | + | explicitCopyText: func(action, text string) { | |
| 68 | + | gotAction = action | |
| 69 | + | gotText = text | |
| 70 | + | }, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |
| 74 | + | ||
| 75 | + | require.Equal(t, "copy", gotAction) | |
| 76 | + | require.Equal(t, "selected text", gotText) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestMaybeMirrorClipboardShortcut_SkipsWhenSelectionEmpty(t *testing.T) { | |
| 80 | + | called := false | |
| 81 | + | ib := &inputBridge{ | |
| 82 | + | ctx: context.Background(), | |
| 83 | + | selectionText: func() string { return "" }, | |
| 84 | + | explicitCopyText: func(string, string) { | |
| 85 | + | called = true | |
| 86 | + | }, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |
| 90 | + | ||
| 91 | + | require.False(t, called) | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type focusSyncHostRecorder struct { | |||
| 13 | + | calls []string | |||
| 14 | + | hiddenValues []int32 | |||
| 15 | + | focusValues []int32 | |||
| 16 | + | invalidations []purecef.PaintElementType | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (h *focusSyncHostRecorder) WasHidden(hidden int32) { | |||
| 20 | + | h.calls = append(h.calls, "WasHidden") | |||
| 21 | + | h.hiddenValues = append(h.hiddenValues, hidden) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (h *focusSyncHostRecorder) SetFocus(focus int32) { | |||
| 25 | + | h.calls = append(h.calls, "SetFocus") | |||
| 26 | + | h.focusValues = append(h.focusValues, focus) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (h *focusSyncHostRecorder) Invalidate(elementType purecef.PaintElementType) { | |||
| 30 | + | h.calls = append(h.calls, "Invalidate") | |||
| 31 | + | h.invalidations = append(h.invalidations, elementType) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *testing.T) { | |||
| 35 | + | host := &focusSyncHostRecorder{} | |||
| 36 | + | ||||
| 37 | + | syncWindowlessBrowserFocus(host) | |||
| 38 | + | ||||
| 39 | + | require.Equal(t, []string{"WasHidden", "SetFocus", "Invalidate"}, host.calls) | |||
| 40 | + | require.Equal(t, []int32{0}, host.hiddenValues) | |||
| 41 | + | require.Equal(t, []int32{1}, host.focusValues) | |||
| 42 | + | require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) { | |||
| 46 | + | action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |||
| 47 | + | require.True(t, ok) | |||
| 48 | + | require.Equal(t, "copy", action) | |||
| 49 | + | ||||
| 50 | + | action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue)) | |||
| 51 | + | require.True(t, ok) | |||
| 52 | + | require.Equal(t, "cut", action) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestClipboardShortcutAction_IgnoresCtrlShiftC(t *testing.T) { | |||
| 56 | + | action, ok := clipboardShortcutAction(gdkKeyUppercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue)) | |||
| 57 | + | require.False(t, ok) | |||
| 58 | + | require.Empty(t, action) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestMaybeMirrorClipboardShortcut_DelegatesExplicitCopyAction(t *testing.T) { | |||
| 62 | + | var gotAction string | |||
| 63 | + | var gotText string | |||
| 64 | + | ib := &inputBridge{ | |||
| 65 | + | ctx: context.Background(), | |||
| 66 | + | selectionText: func() string { return "selected text" }, | |||
| 67 | + | explicitCopyText: func(action, text string) { | |||
| 68 | + | gotAction = action | |||
| 69 | + | gotText = text | |||
| 70 | + | }, | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |||
| 74 | + | ||||
| 75 | + | require.Equal(t, "copy", gotAction) | |||
| 76 | + | require.Equal(t, "selected text", gotText) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestMaybeMirrorClipboardShortcut_SkipsWhenSelectionEmpty(t *testing.T) { | |||
| 80 | + | called := false | |||
| 81 | + | ib := &inputBridge{ | |||
| 82 | + | ctx: context.Background(), | |||
| 83 | + | selectionText: func() string { return "" }, | |||
| 84 | + | explicitCopyText: func(string, string) { | |||
| 85 | + | called = true | |||
| 86 | + | }, | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |||
| 90 | + | ||||
| 91 | + | require.False(t, called) | |||
| 92 | + | } | |||
A internal/infrastructure/cef/renderer_bridge.go +358 -0
| @@ -0,0 +1,358 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | "unsafe" | |
| 9 | + | ||
| 10 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 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" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const rendererBridgeExtensionJS = ` | |
| 24 | + | (function() { | |
| 25 | + | native function Dispatch(action, payload); | |
| 26 | + | ||
| 27 | + | function send(action, payload) { | |
| 28 | + | return Dispatch(action, payload == null ? '' : String(payload)); | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | function isEditable(node) { | |
| 32 | + | if (!node || node.nodeType !== 1) return false; | |
| 33 | + | if (node.isContentEditable) return true; | |
| 34 | + | var tag = node.tagName; | |
| 35 | + | if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false; | |
| 36 | + | if (node.disabled || node.readOnly) return false; | |
| 37 | + | return true; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | function getActiveElementSelection() { | |
| 41 | + | var el = document.activeElement; | |
| 42 | + | if (!el) return ''; | |
| 43 | + | var tag = el.tagName; | |
| 44 | + | if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') { | |
| 45 | + | var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0; | |
| 46 | + | var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0; | |
| 47 | + | if (end > start) return el.value.slice(start, end); | |
| 48 | + | } | |
| 49 | + | return ''; | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | function getSelectedText() { | |
| 53 | + | var activeSelection = getActiveElementSelection(); | |
| 54 | + | if (activeSelection) return activeSelection; | |
| 55 | + | var sel = window.getSelection(); | |
| 56 | + | return sel ? sel.toString() : ''; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | function notifyExplicitTextCopy(action, text) { | |
| 60 | + | var normalizedText = text == null ? '' : String(text); | |
| 61 | + | var normalizedAction = action == null || action === '' ? 'copy' : String(action); | |
| 62 | + | return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction })); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | if (typeof document !== 'undefined' && document.addEventListener) { | |
| 66 | + | document.addEventListener('focusin', function(event) { | |
| 67 | + | if (isEditable(event.target)) { | |
| 68 | + | send('focus_sync', ''); | |
| 69 | + | } | |
| 70 | + | }, true); | |
| 71 | + | ||
| 72 | + | function mirrorClipboardEvent(action, e) { | |
| 73 | + | if (!e.isTrusted) return; | |
| 74 | + | setTimeout(function() { | |
| 75 | + | var text = ''; | |
| 76 | + | if (e.clipboardData && typeof e.clipboardData.getData === 'function') { | |
| 77 | + | try { text = e.clipboardData.getData('text/plain'); } catch(_) {} | |
| 78 | + | } | |
| 79 | + | if (!text) text = getSelectedText(); | |
| 80 | + | if (text) notifyExplicitTextCopy(action, text); | |
| 81 | + | }, 0); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | document.addEventListener('copy', function(e) { | |
| 85 | + | mirrorClipboardEvent('copy', e); | |
| 86 | + | }, true); | |
| 87 | + | document.addEventListener('cut', function(e) { | |
| 88 | + | mirrorClipboardEvent('cut', e); | |
| 89 | + | }, true); | |
| 90 | + | } | |
| 91 | + | try { | |
| 92 | + | var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null; | |
| 93 | + | var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) || | |
| 94 | + | (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null); | |
| 95 | + | ||
| 96 | + | function mirrorClipboardItems(items) { | |
| 97 | + | try { | |
| 98 | + | if (!items || typeof items.length !== 'number') return; | |
| 99 | + | Array.prototype.forEach.call(items, function(item) { | |
| 100 | + | if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return; | |
| 101 | + | Promise.resolve(item.getType('text/plain')) | |
| 102 | + | .then(function(blob) { | |
| 103 | + | if (!blob || typeof blob.text !== 'function') return ''; | |
| 104 | + | return blob.text(); | |
| 105 | + | }) | |
| 106 | + | .then(function(text) { | |
| 107 | + | if (text) notifyExplicitTextCopy('copy', text); | |
| 108 | + | }) | |
| 109 | + | .catch(function() {}); | |
| 110 | + | }); | |
| 111 | + | } catch (_) {} | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) { | |
| 115 | + | clipboardProto.__dumberWriteTextPatched = true; | |
| 116 | + | var originalProtoWriteText = clipboardProto.writeText; | |
| 117 | + | var wrappedProtoWriteText = function(text) { | |
| 118 | + | var normalized = text == null ? '' : String(text); | |
| 119 | + | var result = originalProtoWriteText.call(this, normalized); | |
| 120 | + | if (result && typeof result.then === 'function') { | |
| 121 | + | return result.then(function(value) { | |
| 122 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |
| 123 | + | return value; | |
| 124 | + | }); | |
| 125 | + | } | |
| 126 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |
| 127 | + | return result; | |
| 128 | + | }; | |
| 129 | + | try { | |
| 130 | + | Object.defineProperty(clipboardProto, 'writeText', { | |
| 131 | + | configurable: true, | |
| 132 | + | writable: true, | |
| 133 | + | value: wrappedProtoWriteText | |
| 134 | + | }); | |
| 135 | + | } catch (_) { | |
| 136 | + | clipboardProto.writeText = wrappedProtoWriteText; | |
| 137 | + | } | |
| 138 | + | } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) { | |
| 139 | + | window.__dumberClipboardWriteTextPatched = true; | |
| 140 | + | var originalWriteText = clipboardObj.writeText.bind(clipboardObj); | |
| 141 | + | var wrappedWriteText = function(text) { | |
| 142 | + | var normalized = text == null ? '' : String(text); | |
| 143 | + | var result = originalWriteText(normalized); | |
| 144 | + | if (result && typeof result.then === 'function') { | |
| 145 | + | return result.then(function(value) { | |
| 146 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |
| 147 | + | return value; | |
| 148 | + | }); | |
| 149 | + | } | |
| 150 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |
| 151 | + | return result; | |
| 152 | + | }; | |
| 153 | + | try { | |
| 154 | + | Object.defineProperty(clipboardObj, 'writeText', { | |
| 155 | + | configurable: true, | |
| 156 | + | writable: true, | |
| 157 | + | value: wrappedWriteText | |
| 158 | + | }); | |
| 159 | + | } catch (_) { | |
| 160 | + | clipboardObj.writeText = wrappedWriteText; | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) { | |
| 165 | + | clipboardProto.__dumberWritePatched = true; | |
| 166 | + | var originalProtoWrite = clipboardProto.write; | |
| 167 | + | var wrappedProtoWrite = function(items) { | |
| 168 | + | var result = originalProtoWrite.apply(this, arguments); | |
| 169 | + | if (result && typeof result.then === 'function') { | |
| 170 | + | return result.then(function(value) { | |
| 171 | + | mirrorClipboardItems(items); | |
| 172 | + | return value; | |
| 173 | + | }); | |
| 174 | + | } | |
| 175 | + | mirrorClipboardItems(items); | |
| 176 | + | return result; | |
| 177 | + | }; | |
| 178 | + | try { | |
| 179 | + | Object.defineProperty(clipboardProto, 'write', { | |
| 180 | + | configurable: true, | |
| 181 | + | writable: true, | |
| 182 | + | value: wrappedProtoWrite | |
| 183 | + | }); | |
| 184 | + | } catch (_) { | |
| 185 | + | clipboardProto.write = wrappedProtoWrite; | |
| 186 | + | } | |
| 187 | + | } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) { | |
| 188 | + | window.__dumberClipboardWritePatched = true; | |
| 189 | + | var originalWrite = clipboardObj.write.bind(clipboardObj); | |
| 190 | + | var wrappedWrite = function(items) { | |
| 191 | + | var result = originalWrite(items); | |
| 192 | + | if (result && typeof result.then === 'function') { | |
| 193 | + | return result.then(function(value) { | |
| 194 | + | mirrorClipboardItems(items); | |
| 195 | + | return value; | |
| 196 | + | }); | |
| 197 | + | } | |
| 198 | + | mirrorClipboardItems(items); | |
| 199 | + | return result; | |
| 200 | + | }; | |
| 201 | + | try { | |
| 202 | + | Object.defineProperty(clipboardObj, 'write', { | |
| 203 | + | configurable: true, | |
| 204 | + | writable: true, | |
| 205 | + | value: wrappedWrite | |
| 206 | + | }); | |
| 207 | + | } catch (_) { | |
| 208 | + | clipboardObj.write = wrappedWrite; | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } catch (_) {} | |
| 212 | + | ||
| 213 | + | send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : ''); | |
| 214 | + | })(); | |
| 215 | + | ` | |
| 216 | + | ||
| 217 | + | type rendererBridgeProcessHandler struct { | |
| 218 | + | registerOnce sync.Once | |
| 219 | + | bridgeHandler purecef.V8Handler | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func newRendererBridgeProcessHandler() purecef.RenderProcessHandler { | |
| 223 | + | return &rendererBridgeProcessHandler{bridgeHandler: &rendererBridgeV8Handler{}} | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (h *rendererBridgeProcessHandler) ensureExtensionRegistered() { | |
| 227 | + | if h == nil { | |
| 228 | + | return | |
| 229 | + | } | |
| 230 | + | h.registerOnce.Do(func() { | |
| 231 | + | if h.bridgeHandler == nil { | |
| 232 | + | h.bridgeHandler = &rendererBridgeV8Handler{} | |
| 233 | + | } | |
| 234 | + | if result := purecef.RegisterExtension(rendererBridgeExtensionName, rendererBridgeExtensionJS, h.bridgeHandler); result != 1 { | |
| 235 | + | logging.FromContext(context.Background()).Error().Int32("result", result).Msg("cef: failed to register renderer bridge extension") | |
| 236 | + | } | |
| 237 | + | }) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func (h *rendererBridgeProcessHandler) OnWebKitInitialized() { | |
| 241 | + | h.ensureExtensionRegistered() | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func (*rendererBridgeProcessHandler) OnBrowserCreated(_ purecef.Browser, _ purecef.DictionaryValue) {} | |
| 245 | + | func (*rendererBridgeProcessHandler) OnBrowserDestroyed(_ purecef.Browser) {} | |
| 246 | + | func (*rendererBridgeProcessHandler) GetLoadHandler() purecef.LoadHandler { return nil } | |
| 247 | + | func (*rendererBridgeProcessHandler) OnContextCreated(_ purecef.Browser, _ purecef.Frame, _ purecef.V8Context) { | |
| 248 | + | } | |
| 249 | + | func (*rendererBridgeProcessHandler) OnContextReleased(_ purecef.Browser, _ purecef.Frame, _ purecef.V8Context) { | |
| 250 | + | } | |
| 251 | + | func (*rendererBridgeProcessHandler) OnUncaughtException( | |
| 252 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.V8Context, _ purecef.V8Exception, _ purecef.V8StackTrace, | |
| 253 | + | ) { | |
| 254 | + | } | |
| 255 | + | func (*rendererBridgeProcessHandler) OnFocusedNodeChanged(_ purecef.Browser, _ purecef.Frame, _ purecef.Domnode) { | |
| 256 | + | } | |
| 257 | + | func (*rendererBridgeProcessHandler) OnProcessMessageReceived( | |
| 258 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage, | |
| 259 | + | ) int32 { | |
| 260 | + | return 0 | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | type rendererBridgeV8Handler struct{} | |
| 264 | + | ||
| 265 | + | func (*rendererBridgeV8Handler) Execute( | |
| 266 | + | name string, _ purecef.V8Value, arguments []purecef.V8Value, _ unsafe.Pointer, _ uintptr, | |
| 267 | + | ) int32 { | |
| 268 | + | if name != "Dispatch" || len(arguments) < 2 { | |
| 269 | + | return 0 | |
| 270 | + | } | |
| 271 | + | if arguments[0] == nil || arguments[1] == nil || !arguments[0].IsString() || !arguments[1].IsString() { | |
| 272 | + | return 0 | |
| 273 | + | } | |
| 274 | + | action := arguments[0].GetStringValue() | |
| 275 | + | payload := arguments[1].GetStringValue() | |
| 276 | + | if action == "" { | |
| 277 | + | return 0 | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | ctx := purecef.V8ContextGetCurrentContext() | |
| 281 | + | if ctx == nil || !ctx.IsValid() { | |
| 282 | + | return 0 | |
| 283 | + | } | |
| 284 | + | frame := ctx.GetFrame() | |
| 285 | + | if frame == nil { | |
| 286 | + | return 0 | |
| 287 | + | } | |
| 288 | + | message := purecef.ProcessMessageCreate(rendererBridgeMessageName) | |
| 289 | + | if message == nil { | |
| 290 | + | return 0 | |
| 291 | + | } | |
| 292 | + | args := message.GetArgumentList() | |
| 293 | + | if args == nil { | |
| 294 | + | return 0 | |
| 295 | + | } | |
| 296 | + | args.SetString(0, action) | |
| 297 | + | args.SetString(1, payload) | |
| 298 | + | frame.SendProcessMessage(purecef.ProcessIDPidBrowser, message) | |
| 299 | + | return 1 | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func decodeRendererBridgeProcessMessage(message purecef.ProcessMessage) (string, string, bool) { | |
| 303 | + | if message == nil || message.GetName() != rendererBridgeMessageName { | |
| 304 | + | return "", "", false | |
| 305 | + | } | |
| 306 | + | args := message.GetArgumentList() | |
| 307 | + | if args == nil || args.GetSize() < 2 { | |
| 308 | + | return "", "", false | |
| 309 | + | } | |
| 310 | + | action := args.GetString(0) | |
| 311 | + | payload := args.GetString(1) | |
| 312 | + | if action == "" { | |
| 313 | + | return "", "", false | |
| 314 | + | } | |
| 315 | + | return action, payload, true | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | type rendererBridgeExplicitTextCopyPayload struct { | |
| 319 | + | Text string `json:"text"` | |
| 320 | + | Action string `json:"action"` | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) { | |
| 324 | + | var req rendererBridgeExplicitTextCopyPayload | |
| 325 | + | if len(payload) == 0 { | |
| 326 | + | return req, fmt.Errorf("empty payload") | |
| 327 | + | } | |
| 328 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 329 | + | return req, err | |
| 330 | + | } | |
| 331 | + | if req.Action == "" { | |
| 332 | + | req.Action = "copy" | |
| 333 | + | } | |
| 334 | + | if req.Text == "" { | |
| 335 | + | return req, fmt.Errorf("missing text") | |
| 336 | + | } | |
| 337 | + | return req, nil | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) { | |
| 341 | + | logger := logging.FromContext(e.currentContext()) | |
| 342 | + | task := purecef.NewTask(cefTaskFunc(func() { | |
| 343 | + | if browser == nil { | |
| 344 | + | logger.Debug().Msg("cef: editable focus sync skipped — browser nil") | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | host := browser.GetHost() | |
| 348 | + | if host == nil { | |
| 349 | + | logger.Debug().Msg("cef: editable focus sync skipped — host nil") | |
| 350 | + | return | |
| 351 | + | } | |
| 352 | + | syncWindowlessBrowserFocus(host) | |
| 353 | + | logger.Debug().Msg("cef: editable focus synchronized") | |
| 354 | + | })) | |
| 355 | + | if result := purecef.PostTask(purecef.ThreadIDTidUi, task); result != 1 { | |
| 356 | + | logger.Warn().Int32("result", result).Msg("cef: failed to post editable focus sync to UI thread") | |
| 357 | + | } | |
| 358 | + | } | |
| @@ -0,0 +1,358 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "sync" | |||
| 8 | + | "unsafe" | |||
| 9 | + | ||||
| 10 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 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" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | const rendererBridgeExtensionJS = ` | |||
| 24 | + | (function() { | |||
| 25 | + | native function Dispatch(action, payload); | |||
| 26 | + | ||||
| 27 | + | function send(action, payload) { | |||
| 28 | + | return Dispatch(action, payload == null ? '' : String(payload)); | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | function isEditable(node) { | |||
| 32 | + | if (!node || node.nodeType !== 1) return false; | |||
| 33 | + | if (node.isContentEditable) return true; | |||
| 34 | + | var tag = node.tagName; | |||
| 35 | + | if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false; | |||
| 36 | + | if (node.disabled || node.readOnly) return false; | |||
| 37 | + | return true; | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | function getActiveElementSelection() { | |||
| 41 | + | var el = document.activeElement; | |||
| 42 | + | if (!el) return ''; | |||
| 43 | + | var tag = el.tagName; | |||
| 44 | + | if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') { | |||
| 45 | + | var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0; | |||
| 46 | + | var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0; | |||
| 47 | + | if (end > start) return el.value.slice(start, end); | |||
| 48 | + | } | |||
| 49 | + | return ''; | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | function getSelectedText() { | |||
| 53 | + | var activeSelection = getActiveElementSelection(); | |||
| 54 | + | if (activeSelection) return activeSelection; | |||
| 55 | + | var sel = window.getSelection(); | |||
| 56 | + | return sel ? sel.toString() : ''; | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | function notifyExplicitTextCopy(action, text) { | |||
| 60 | + | var normalizedText = text == null ? '' : String(text); | |||
| 61 | + | var normalizedAction = action == null || action === '' ? 'copy' : String(action); | |||
| 62 | + | return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction })); | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | if (typeof document !== 'undefined' && document.addEventListener) { | |||
| 66 | + | document.addEventListener('focusin', function(event) { | |||
| 67 | + | if (isEditable(event.target)) { | |||
| 68 | + | send('focus_sync', ''); | |||
| 69 | + | } | |||
| 70 | + | }, true); | |||
| 71 | + | ||||
| 72 | + | function mirrorClipboardEvent(action, e) { | |||
| 73 | + | if (!e.isTrusted) return; | |||
| 74 | + | setTimeout(function() { | |||
| 75 | + | var text = ''; | |||
| 76 | + | if (e.clipboardData && typeof e.clipboardData.getData === 'function') { | |||
| 77 | + | try { text = e.clipboardData.getData('text/plain'); } catch(_) {} | |||
| 78 | + | } | |||
| 79 | + | if (!text) text = getSelectedText(); | |||
| 80 | + | if (text) notifyExplicitTextCopy(action, text); | |||
| 81 | + | }, 0); | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | document.addEventListener('copy', function(e) { | |||
| 85 | + | mirrorClipboardEvent('copy', e); | |||
| 86 | + | }, true); | |||
| 87 | + | document.addEventListener('cut', function(e) { | |||
| 88 | + | mirrorClipboardEvent('cut', e); | |||
| 89 | + | }, true); | |||
| 90 | + | } | |||
| 91 | + | try { | |||
| 92 | + | var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null; | |||
| 93 | + | var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) || | |||
| 94 | + | (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null); | |||
| 95 | + | ||||
| 96 | + | function mirrorClipboardItems(items) { | |||
| 97 | + | try { | |||
| 98 | + | if (!items || typeof items.length !== 'number') return; | |||
| 99 | + | Array.prototype.forEach.call(items, function(item) { | |||
| 100 | + | if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return; | |||
| 101 | + | Promise.resolve(item.getType('text/plain')) | |||
| 102 | + | .then(function(blob) { | |||
| 103 | + | if (!blob || typeof blob.text !== 'function') return ''; | |||
| 104 | + | return blob.text(); | |||
| 105 | + | }) | |||
| 106 | + | .then(function(text) { | |||
| 107 | + | if (text) notifyExplicitTextCopy('copy', text); | |||
| 108 | + | }) | |||
| 109 | + | .catch(function() {}); | |||
| 110 | + | }); | |||
| 111 | + | } catch (_) {} | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) { | |||
| 115 | + | clipboardProto.__dumberWriteTextPatched = true; | |||
| 116 | + | var originalProtoWriteText = clipboardProto.writeText; | |||
| 117 | + | var wrappedProtoWriteText = function(text) { | |||
| 118 | + | var normalized = text == null ? '' : String(text); | |||
| 119 | + | var result = originalProtoWriteText.call(this, normalized); | |||
| 120 | + | if (result && typeof result.then === 'function') { | |||
| 121 | + | return result.then(function(value) { | |||
| 122 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |||
| 123 | + | return value; | |||
| 124 | + | }); | |||
| 125 | + | } | |||
| 126 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |||
| 127 | + | return result; | |||
| 128 | + | }; | |||
| 129 | + | try { | |||
| 130 | + | Object.defineProperty(clipboardProto, 'writeText', { | |||
| 131 | + | configurable: true, | |||
| 132 | + | writable: true, | |||
| 133 | + | value: wrappedProtoWriteText | |||
| 134 | + | }); | |||
| 135 | + | } catch (_) { | |||
| 136 | + | clipboardProto.writeText = wrappedProtoWriteText; | |||
| 137 | + | } | |||
| 138 | + | } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) { | |||
| 139 | + | window.__dumberClipboardWriteTextPatched = true; | |||
| 140 | + | var originalWriteText = clipboardObj.writeText.bind(clipboardObj); | |||
| 141 | + | var wrappedWriteText = function(text) { | |||
| 142 | + | var normalized = text == null ? '' : String(text); | |||
| 143 | + | var result = originalWriteText(normalized); | |||
| 144 | + | if (result && typeof result.then === 'function') { | |||
| 145 | + | return result.then(function(value) { | |||
| 146 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |||
| 147 | + | return value; | |||
| 148 | + | }); | |||
| 149 | + | } | |||
| 150 | + | if (normalized) notifyExplicitTextCopy('copy', normalized); | |||
| 151 | + | return result; | |||
| 152 | + | }; | |||
| 153 | + | try { | |||
| 154 | + | Object.defineProperty(clipboardObj, 'writeText', { | |||
| 155 | + | configurable: true, | |||
| 156 | + | writable: true, | |||
| 157 | + | value: wrappedWriteText | |||
| 158 | + | }); | |||
| 159 | + | } catch (_) { | |||
| 160 | + | clipboardObj.writeText = wrappedWriteText; | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) { | |||
| 165 | + | clipboardProto.__dumberWritePatched = true; | |||
| 166 | + | var originalProtoWrite = clipboardProto.write; | |||
| 167 | + | var wrappedProtoWrite = function(items) { | |||
| 168 | + | var result = originalProtoWrite.apply(this, arguments); | |||
| 169 | + | if (result && typeof result.then === 'function') { | |||
| 170 | + | return result.then(function(value) { | |||
| 171 | + | mirrorClipboardItems(items); | |||
| 172 | + | return value; | |||
| 173 | + | }); | |||
| 174 | + | } | |||
| 175 | + | mirrorClipboardItems(items); | |||
| 176 | + | return result; | |||
| 177 | + | }; | |||
| 178 | + | try { | |||
| 179 | + | Object.defineProperty(clipboardProto, 'write', { | |||
| 180 | + | configurable: true, | |||
| 181 | + | writable: true, | |||
| 182 | + | value: wrappedProtoWrite | |||
| 183 | + | }); | |||
| 184 | + | } catch (_) { | |||
| 185 | + | clipboardProto.write = wrappedProtoWrite; | |||
| 186 | + | } | |||
| 187 | + | } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) { | |||
| 188 | + | window.__dumberClipboardWritePatched = true; | |||
| 189 | + | var originalWrite = clipboardObj.write.bind(clipboardObj); | |||
| 190 | + | var wrappedWrite = function(items) { | |||
| 191 | + | var result = originalWrite(items); | |||
| 192 | + | if (result && typeof result.then === 'function') { | |||
| 193 | + | return result.then(function(value) { | |||
| 194 | + | mirrorClipboardItems(items); | |||
| 195 | + | return value; | |||
| 196 | + | }); | |||
| 197 | + | } | |||
| 198 | + | mirrorClipboardItems(items); | |||
| 199 | + | return result; | |||
| 200 | + | }; | |||
| 201 | + | try { | |||
| 202 | + | Object.defineProperty(clipboardObj, 'write', { | |||
| 203 | + | configurable: true, | |||
| 204 | + | writable: true, | |||
| 205 | + | value: wrappedWrite | |||
| 206 | + | }); | |||
| 207 | + | } catch (_) { | |||
| 208 | + | clipboardObj.write = wrappedWrite; | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | } catch (_) {} | |||
| 212 | + | ||||
| 213 | + | send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : ''); | |||
| 214 | + | })(); | |||
| 215 | + | ` | |||
| 216 | + | ||||
| 217 | + | type rendererBridgeProcessHandler struct { | |||
| 218 | + | registerOnce sync.Once | |||
| 219 | + | bridgeHandler purecef.V8Handler | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func newRendererBridgeProcessHandler() purecef.RenderProcessHandler { | |||
| 223 | + | return &rendererBridgeProcessHandler{bridgeHandler: &rendererBridgeV8Handler{}} | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func (h *rendererBridgeProcessHandler) ensureExtensionRegistered() { | |||
| 227 | + | if h == nil { | |||
| 228 | + | return | |||
| 229 | + | } | |||
| 230 | + | h.registerOnce.Do(func() { | |||
| 231 | + | if h.bridgeHandler == nil { | |||
| 232 | + | h.bridgeHandler = &rendererBridgeV8Handler{} | |||
| 233 | + | } | |||
| 234 | + | if result := purecef.RegisterExtension(rendererBridgeExtensionName, rendererBridgeExtensionJS, h.bridgeHandler); result != 1 { | |||
| 235 | + | logging.FromContext(context.Background()).Error().Int32("result", result).Msg("cef: failed to register renderer bridge extension") | |||
| 236 | + | } | |||
| 237 | + | }) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func (h *rendererBridgeProcessHandler) OnWebKitInitialized() { | |||
| 241 | + | h.ensureExtensionRegistered() | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func (*rendererBridgeProcessHandler) OnBrowserCreated(_ purecef.Browser, _ purecef.DictionaryValue) {} | |||
| 245 | + | func (*rendererBridgeProcessHandler) OnBrowserDestroyed(_ purecef.Browser) {} | |||
| 246 | + | func (*rendererBridgeProcessHandler) GetLoadHandler() purecef.LoadHandler { return nil } | |||
| 247 | + | func (*rendererBridgeProcessHandler) OnContextCreated(_ purecef.Browser, _ purecef.Frame, _ purecef.V8Context) { | |||
| 248 | + | } | |||
| 249 | + | func (*rendererBridgeProcessHandler) OnContextReleased(_ purecef.Browser, _ purecef.Frame, _ purecef.V8Context) { | |||
| 250 | + | } | |||
| 251 | + | func (*rendererBridgeProcessHandler) OnUncaughtException( | |||
| 252 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.V8Context, _ purecef.V8Exception, _ purecef.V8StackTrace, | |||
| 253 | + | ) { | |||
| 254 | + | } | |||
| 255 | + | func (*rendererBridgeProcessHandler) OnFocusedNodeChanged(_ purecef.Browser, _ purecef.Frame, _ purecef.Domnode) { | |||
| 256 | + | } | |||
| 257 | + | func (*rendererBridgeProcessHandler) OnProcessMessageReceived( | |||
| 258 | + | _ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage, | |||
| 259 | + | ) int32 { | |||
| 260 | + | return 0 | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | type rendererBridgeV8Handler struct{} | |||
| 264 | + | ||||
| 265 | + | func (*rendererBridgeV8Handler) Execute( | |||
| 266 | + | name string, _ purecef.V8Value, arguments []purecef.V8Value, _ unsafe.Pointer, _ uintptr, | |||
| 267 | + | ) int32 { | |||
| 268 | + | if name != "Dispatch" || len(arguments) < 2 { | |||
| 269 | + | return 0 | |||
| 270 | + | } | |||
| 271 | + | if arguments[0] == nil || arguments[1] == nil || !arguments[0].IsString() || !arguments[1].IsString() { | |||
| 272 | + | return 0 | |||
| 273 | + | } | |||
| 274 | + | action := arguments[0].GetStringValue() | |||
| 275 | + | payload := arguments[1].GetStringValue() | |||
| 276 | + | if action == "" { | |||
| 277 | + | return 0 | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | ctx := purecef.V8ContextGetCurrentContext() | |||
| 281 | + | if ctx == nil || !ctx.IsValid() { | |||
| 282 | + | return 0 | |||
| 283 | + | } | |||
| 284 | + | frame := ctx.GetFrame() | |||
| 285 | + | if frame == nil { | |||
| 286 | + | return 0 | |||
| 287 | + | } | |||
| 288 | + | message := purecef.ProcessMessageCreate(rendererBridgeMessageName) | |||
| 289 | + | if message == nil { | |||
| 290 | + | return 0 | |||
| 291 | + | } | |||
| 292 | + | args := message.GetArgumentList() | |||
| 293 | + | if args == nil { | |||
| 294 | + | return 0 | |||
| 295 | + | } | |||
| 296 | + | args.SetString(0, action) | |||
| 297 | + | args.SetString(1, payload) | |||
| 298 | + | frame.SendProcessMessage(purecef.ProcessIDPidBrowser, message) | |||
| 299 | + | return 1 | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func decodeRendererBridgeProcessMessage(message purecef.ProcessMessage) (string, string, bool) { | |||
| 303 | + | if message == nil || message.GetName() != rendererBridgeMessageName { | |||
| 304 | + | return "", "", false | |||
| 305 | + | } | |||
| 306 | + | args := message.GetArgumentList() | |||
| 307 | + | if args == nil || args.GetSize() < 2 { | |||
| 308 | + | return "", "", false | |||
| 309 | + | } | |||
| 310 | + | action := args.GetString(0) | |||
| 311 | + | payload := args.GetString(1) | |||
| 312 | + | if action == "" { | |||
| 313 | + | return "", "", false | |||
| 314 | + | } | |||
| 315 | + | return action, payload, true | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | type rendererBridgeExplicitTextCopyPayload struct { | |||
| 319 | + | Text string `json:"text"` | |||
| 320 | + | Action string `json:"action"` | |||
| 321 | + | } | |||
| 322 | + | ||||
| 323 | + | func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) { | |||
| 324 | + | var req rendererBridgeExplicitTextCopyPayload | |||
| 325 | + | if len(payload) == 0 { | |||
| 326 | + | return req, fmt.Errorf("empty payload") | |||
| 327 | + | } | |||
| 328 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 329 | + | return req, err | |||
| 330 | + | } | |||
| 331 | + | if req.Action == "" { | |||
| 332 | + | req.Action = "copy" | |||
| 333 | + | } | |||
| 334 | + | if req.Text == "" { | |||
| 335 | + | return req, fmt.Errorf("missing text") | |||
| 336 | + | } | |||
| 337 | + | return req, nil | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) { | |||
| 341 | + | logger := logging.FromContext(e.currentContext()) | |||
| 342 | + | task := purecef.NewTask(cefTaskFunc(func() { | |||
| 343 | + | if browser == nil { | |||
| 344 | + | logger.Debug().Msg("cef: editable focus sync skipped — browser nil") | |||
| 345 | + | return | |||
| 346 | + | } | |||
| 347 | + | host := browser.GetHost() | |||
| 348 | + | if host == nil { | |||
| 349 | + | logger.Debug().Msg("cef: editable focus sync skipped — host nil") | |||
| 350 | + | return | |||
| 351 | + | } | |||
| 352 | + | syncWindowlessBrowserFocus(host) | |||
| 353 | + | logger.Debug().Msg("cef: editable focus synchronized") | |||
| 354 | + | })) | |||
| 355 | + | if result := purecef.PostTask(purecef.ThreadIDTidUi, task); result != 1 { | |||
| 356 | + | logger.Warn().Int32("result", result).Msg("cef: failed to post editable focus sync to UI thread") | |||
| 357 | + | } | |||
| 358 | + | } | |||
A internal/infrastructure/cef/renderer_bridge_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRendererBridgeExtensionJS_DoesNotExposeWritableGlobalDispatch(t *testing.T) { | |
| 11 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberNativeBridge") | |
| 12 | + | require.NotContains(t, rendererBridgeExtensionJS, "DumberBridgeDispatch =") | |
| 13 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.DumberBridgeDispatch") | |
| 14 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberBridgeAction") | |
| 15 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberBridgePayload") | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing.T) { | |
| 19 | + | require.Contains(t, rendererBridgeExtensionJS, "native function Dispatch(action, payload);") | |
| 20 | + | require.Contains(t, rendererBridgeExtensionJS, "return Dispatch(action, payload == null ? '' : String(payload));") | |
| 21 | + | require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) { | |
| 25 | + | require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;") | |
| 26 | + | require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {") | |
| 27 | + | require.NotContains(t, rendererBridgeExtensionJS, "document.execCommand = function") | |
| 28 | + | require.Contains(t, rendererBridgeExtensionJS, "return result;") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestRendererBridgeSourceDoesNotNeedGoLinkname(t *testing.T) { | |
| 32 | + | src, err := os.ReadFile("renderer_bridge.go") | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.NotContains(t, string(src), "go:linkname") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestCEFContentInjectorSourceDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) { | |
| 38 | + | src, err := os.ReadFile("content_injector.go") | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | require.NotContains(t, string(src), "autoCopySelectionBridgeJS") | |
| 41 | + | require.NotContains(t, string(src), "clipboardCopyBridgeJS") | |
| 42 | + | require.NotContains(t, string(src), "editableFocusBridgeJS") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | |
| 46 | + | req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(`{"text":"copied text","action":"cut"}`)) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | require.Equal(t, "copied text", req.Text) | |
| 49 | + | require.Equal(t, "cut", req.Action) | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestRendererBridgeExtensionJS_DoesNotExposeWritableGlobalDispatch(t *testing.T) { | |||
| 11 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberNativeBridge") | |||
| 12 | + | require.NotContains(t, rendererBridgeExtensionJS, "DumberBridgeDispatch =") | |||
| 13 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.DumberBridgeDispatch") | |||
| 14 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberBridgeAction") | |||
| 15 | + | require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberBridgePayload") | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing.T) { | |||
| 19 | + | require.Contains(t, rendererBridgeExtensionJS, "native function Dispatch(action, payload);") | |||
| 20 | + | require.Contains(t, rendererBridgeExtensionJS, "return Dispatch(action, payload == null ? '' : String(payload));") | |||
| 21 | + | require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) { | |||
| 25 | + | require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;") | |||
| 26 | + | require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {") | |||
| 27 | + | require.NotContains(t, rendererBridgeExtensionJS, "document.execCommand = function") | |||
| 28 | + | require.Contains(t, rendererBridgeExtensionJS, "return result;") | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestRendererBridgeSourceDoesNotNeedGoLinkname(t *testing.T) { | |||
| 32 | + | src, err := os.ReadFile("renderer_bridge.go") | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.NotContains(t, string(src), "go:linkname") | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestCEFContentInjectorSourceDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) { | |||
| 38 | + | src, err := os.ReadFile("content_injector.go") | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | require.NotContains(t, string(src), "autoCopySelectionBridgeJS") | |||
| 41 | + | require.NotContains(t, string(src), "clipboardCopyBridgeJS") | |||
| 42 | + | require.NotContains(t, string(src), "editableFocusBridgeJS") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) { | |||
| 46 | + | req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(`{"text":"copied text","action":"cut"}`)) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | require.Equal(t, "copied text", req.Text) | |||
| 49 | + | require.Equal(t, "cut", req.Action) | |||
| 50 | + | } | |||
M internal/infrastructure/cef/scheme_handler.go +55 -16
| @@ -57,8 +57,12 @@ type dumbSchemeHandler struct { | |||
| 57 | 57 | mu sync.RWMutex | |
| 58 | 58 | ||
| 59 | 59 | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | |
| 60 | - | // Set by the engine to write to the GDK system clipboard. | |
| 60 | + | // Set by the engine to mirror copied text to the real system clipboard. | |
| 61 | 61 | onClipboardSet func(text string) | |
| 62 | + | ||
| 63 | + | // 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. | |
| 65 | + | onEditableFocus func(browser purecef.Browser) | |
| 62 | 66 | } | |
| 63 | 67 | ||
| 64 | 68 | // newDumbSchemeHandler creates a handler for internal CEF pages. | |
| @@ -99,7 +103,7 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||
| 99 | 103 | ||
| 100 | 104 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | |
| 101 | 105 | // for requests to either dumb:// or the internal https origin. | |
| 102 | - | func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | |
| 106 | + | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | |
| 103 | 107 | reqURL := request.GetURL() | |
| 104 | 108 | method := request.GetMethod() | |
| 105 | 109 | ||
| @@ -132,7 +136,7 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||
| 132 | 136 | path := u.Path | |
| 133 | 137 | ||
| 134 | 138 | if strings.HasPrefix(path, "/api/") { | |
| 135 | - | return h.handleAPI(method, path, request) | |
| 139 | + | return h.handleAPI(browser, method, path, request) | |
| 136 | 140 | } | |
| 137 | 141 | ||
| 138 | 142 | // Serve static assets. | |
| @@ -140,7 +144,7 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||
| 140 | 144 | } | |
| 141 | 145 | ||
| 142 | 146 | // handleAPI routes API requests to the message router or built-in handlers. | |
| 143 | - | func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Request) purecef.ResourceHandler { | |
| 147 | + | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { | |
| 144 | 148 | switch { | |
| 145 | 149 | case path == "/api/message" && strings.EqualFold(method, "POST"): | |
| 146 | 150 | body := readBodyFromHeader(request) | |
| @@ -165,6 +169,9 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||
| 165 | 169 | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | |
| 166 | 170 | return h.handleClipboardSet(request) | |
| 167 | 171 | ||
| 172 | + | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | |
| 173 | + | return h.handleFocusSync(browser) | |
| 174 | + | ||
| 168 | 175 | default: | |
| 169 | 176 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | |
| 170 | 177 | } | |
| @@ -242,31 +249,48 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece | |||
| 242 | 249 | // it to the system clipboard via the engine callback. | |
| 243 | 250 | const maxClipboardBytes = 10 << 20 // 10 MB | |
| 244 | 251 | ||
| 245 | - | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler { | |
| 246 | - | h.logger.Debug().Msg("cef: /api/clipboard-set request received") | |
| 247 | - | ||
| 248 | - | body := readBodyFromHeader(request) | |
| 252 | + | func decodeClipboardSetBody(body []byte) (string, error) { | |
| 249 | 253 | if body == nil { | |
| 250 | - | h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)") | |
| 251 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 254 | + | return "", fmt.Errorf("empty body") | |
| 252 | 255 | } | |
| 253 | 256 | if len(body) > maxClipboardBytes { | |
| 254 | - | h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large") | |
| 255 | - | return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"}) | |
| 257 | + | return "", fmt.Errorf("payload too large") | |
| 256 | 258 | } | |
| 257 | 259 | ||
| 258 | 260 | var payload struct { | |
| 259 | 261 | Text string `json:"text"` | |
| 260 | 262 | } | |
| 261 | 263 | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | |
| 262 | - | h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload") | |
| 263 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) | |
| 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 | + | } | |
| 264 | 286 | } | |
| 265 | 287 | ||
| 266 | - | h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard") | |
| 288 | + | h.logger.Debug(). | |
| 289 | + | Int("text_len", len(text)). | |
| 290 | + | Msg("cef: clipboard-set request reached backend") | |
| 267 | 291 | ||
| 268 | 292 | if h.onClipboardSet != nil { | |
| 269 | - | h.onClipboardSet(payload.Text) | |
| 293 | + | h.onClipboardSet(text) | |
| 270 | 294 | } else { | |
| 271 | 295 | h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired") | |
| 272 | 296 | } | |
| @@ -274,6 +298,21 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||
| 274 | 298 | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 275 | 299 | } | |
| 276 | 300 | ||
| 301 | + | func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler { | |
| 302 | + | if browser == nil { | |
| 303 | + | h.logger.Debug().Msg("cef: focus-sync — browser unavailable") | |
| 304 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | if h.onEditableFocus != nil { | |
| 308 | + | h.onEditableFocus(browser) | |
| 309 | + | } else { | |
| 310 | + | h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired") | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |
| 314 | + | } | |
| 315 | + | ||
| 277 | 316 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | |
| 278 | 317 | return purecef.NewResourceHandler(&staticResourceHandler{ | |
| 279 | 318 | contentType: "text/plain; charset=utf-8", | |
| @@ -57,8 +57,12 @@ type dumbSchemeHandler struct { | |||||
| 57 | mu sync.RWMutex | 57 | mu sync.RWMutex | ||
| 58 | 58 | ||||
| 59 | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | 59 | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | ||
| 60 | - | // Set by the engine to write to the GDK system clipboard. | 60 | + | // Set by the engine to mirror copied text to the real system clipboard. |
| 61 | onClipboardSet func(text string) | 61 | onClipboardSet func(text string) | ||
| 62 | + | ||||
| 63 | + | // 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. | |||
| 65 | + | onEditableFocus func(browser purecef.Browser) | |||
| 62 | } | 66 | } | ||
| 63 | 67 | ||||
| 64 | // newDumbSchemeHandler creates a handler for internal CEF pages. | 68 | // newDumbSchemeHandler creates a handler for internal CEF pages. | ||
| @@ -99,7 +103,7 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||||
| 99 | 103 | ||||
| 100 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | 104 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | ||
| 101 | // for requests to either dumb:// or the internal https origin. | 105 | // for requests to either dumb:// or the internal https origin. | ||
| 102 | - | func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | 106 | + | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { |
| 103 | reqURL := request.GetURL() | 107 | reqURL := request.GetURL() | ||
| 104 | method := request.GetMethod() | 108 | method := request.GetMethod() | ||
| 105 | 109 | ||||
| @@ -132,7 +136,7 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||||
| 132 | path := u.Path | 136 | path := u.Path | ||
| 133 | 137 | ||||
| 134 | if strings.HasPrefix(path, "/api/") { | 138 | if strings.HasPrefix(path, "/api/") { | ||
| 135 | - | return h.handleAPI(method, path, request) | 139 | + | return h.handleAPI(browser, method, path, request) |
| 136 | } | 140 | } | ||
| 137 | 141 | ||||
| 138 | // Serve static assets. | 142 | // Serve static assets. | ||
| @@ -140,7 +144,7 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||||
| 140 | } | 144 | } | ||
| 141 | 145 | ||||
| 142 | // handleAPI routes API requests to the message router or built-in handlers. | 146 | // handleAPI routes API requests to the message router or built-in handlers. | ||
| 143 | - | func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Request) purecef.ResourceHandler { | 147 | + | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { |
| 144 | switch { | 148 | switch { | ||
| 145 | case path == "/api/message" && strings.EqualFold(method, "POST"): | 149 | case path == "/api/message" && strings.EqualFold(method, "POST"): | ||
| 146 | body := readBodyFromHeader(request) | 150 | body := readBodyFromHeader(request) | ||
| @@ -165,6 +169,9 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||||
| 165 | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | 169 | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | ||
| 166 | return h.handleClipboardSet(request) | 170 | return h.handleClipboardSet(request) | ||
| 167 | 171 | ||||
| 172 | + | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | |||
| 173 | + | return h.handleFocusSync(browser) | |||
| 174 | + | ||||
| 168 | default: | 175 | default: | ||
| 169 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | 176 | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | ||
| 170 | } | 177 | } | ||
| @@ -242,31 +249,48 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece | |||||
| 242 | // it to the system clipboard via the engine callback. | 249 | // it to the system clipboard via the engine callback. | ||
| 243 | const maxClipboardBytes = 10 << 20 // 10 MB | 250 | const maxClipboardBytes = 10 << 20 // 10 MB | ||
| 244 | 251 | ||||
| 245 | - | func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler { | 252 | + | func decodeClipboardSetBody(body []byte) (string, error) { |
| 246 | - | h.logger.Debug().Msg("cef: /api/clipboard-set request received") | |||
| 247 | - | ||||
| 248 | - | body := readBodyFromHeader(request) | |||
| 249 | if body == nil { | 253 | if body == nil { | ||
| 250 | - | h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)") | 254 | + | return "", fmt.Errorf("empty body") |
| 251 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |||
| 252 | } | 255 | } | ||
| 253 | if len(body) > maxClipboardBytes { | 256 | if len(body) > maxClipboardBytes { | ||
| 254 | - | h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large") | 257 | + | return "", fmt.Errorf("payload too large") |
| 255 | - | return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"}) | |||
| 256 | } | 258 | } | ||
| 257 | 259 | ||||
| 258 | var payload struct { | 260 | var payload struct { | ||
| 259 | Text string `json:"text"` | 261 | Text string `json:"text"` | ||
| 260 | } | 262 | } | ||
| 261 | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | 263 | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | ||
| 262 | - | h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload") | 264 | + | return "", fmt.Errorf("invalid payload") |
| 263 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "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 | + | } | |||
| 264 | } | 286 | } | ||
| 265 | 287 | ||||
| 266 | - | h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard") | 288 | + | h.logger.Debug(). |
| 289 | + | Int("text_len", len(text)). | |||
| 290 | + | Msg("cef: clipboard-set request reached backend") | |||
| 267 | 291 | ||||
| 268 | if h.onClipboardSet != nil { | 292 | if h.onClipboardSet != nil { | ||
| 269 | - | h.onClipboardSet(payload.Text) | 293 | + | h.onClipboardSet(text) |
| 270 | } else { | 294 | } else { | ||
| 271 | h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired") | 295 | h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired") | ||
| 272 | } | 296 | } | ||
| @@ -274,6 +298,21 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||||
| 274 | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | 298 | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | ||
| 275 | } | 299 | } | ||
| 276 | 300 | ||||
| 301 | + | func (h *dumbSchemeHandler) handleFocusSync(browser purecef.Browser) purecef.ResourceHandler { | |||
| 302 | + | if browser == nil { | |||
| 303 | + | h.logger.Debug().Msg("cef: focus-sync — browser unavailable") | |||
| 304 | + | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | if h.onEditableFocus != nil { | |||
| 308 | + | h.onEditableFocus(browser) | |||
| 309 | + | } else { | |||
| 310 | + | h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired") | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |||
| 314 | + | } | |||
| 315 | + | ||||
| 277 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | 316 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | ||
| 278 | return purecef.NewResourceHandler(&staticResourceHandler{ | 317 | return purecef.NewResourceHandler(&staticResourceHandler{ | ||
| 279 | contentType: "text/plain; charset=utf-8", | 318 | contentType: "text/plain; charset=utf-8", | ||
M internal/infrastructure/cef/scheme_handler_test.go +34 -0
| @@ -2,9 +2,12 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/base64" | |
| 5 | 6 | "errors" | |
| 7 | + | "strings" | |
| 6 | 8 | "testing" | |
| 7 | 9 | ||
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | 11 | "github.com/stretchr/testify/require" | |
| 9 | 12 | ) | |
| 10 | 13 | ||
| @@ -43,3 +46,34 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||
| 43 | 46 | require.Nil(t, h) | |
| 44 | 47 | require.Contains(t, err.Error(), "default config payload builder") | |
| 45 | 48 | } | |
| 49 | + | ||
| 50 | + | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |
| 51 | + | request := cefmocks.NewMockRequest(t) | |
| 52 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |
| 53 | + | request.EXPECT().GetHeaderByName("X-Dumber-Body").Return(encoded).Once() | |
| 54 | + | ||
| 55 | + | body := readBodyFromHeader(request) | |
| 56 | + | ||
| 57 | + | require.JSONEq(t, `{"text":"copied from js"}`, string(body)) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestDecodeClipboardSetBody_ReturnsText(t *testing.T) { | |
| 61 | + | text, err := decodeClipboardSetBody([]byte(`{"text":"copied from js"}`)) | |
| 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") | |
| 79 | + | } | |
| @@ -2,9 +2,12 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/base64" | |||
| 5 | "errors" | 6 | "errors" | ||
| 7 | + | "strings" | |||
| 6 | "testing" | 8 | "testing" | ||
| 7 | 9 | ||||
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 8 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 12 | ) | ||
| 10 | 13 | ||||
| @@ -43,3 +46,34 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||||
| 43 | require.Nil(t, h) | 46 | require.Nil(t, h) | ||
| 44 | require.Contains(t, err.Error(), "default config payload builder") | 47 | require.Contains(t, err.Error(), "default config payload builder") | ||
| 45 | } | 48 | } | ||
| 49 | + | ||||
| 50 | + | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |||
| 51 | + | request := cefmocks.NewMockRequest(t) | |||
| 52 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |||
| 53 | + | request.EXPECT().GetHeaderByName("X-Dumber-Body").Return(encoded).Once() | |||
| 54 | + | ||||
| 55 | + | body := readBodyFromHeader(request) | |||
| 56 | + | ||||
| 57 | + | require.JSONEq(t, `{"text":"copied from js"}`, string(body)) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestDecodeClipboardSetBody_ReturnsText(t *testing.T) { | |||
| 61 | + | text, err := decodeClipboardSetBody([]byte(`{"text":"copied from js"}`)) | |||
| 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") | |||
| 79 | + | } | |||
M internal/infrastructure/cef/webview.go +22 -6
| @@ -65,12 +65,13 @@ type WebView struct { | |||
| 65 | 65 | callbacks *port.WebViewCallbacks | |
| 66 | 66 | ||
| 67 | 67 | // State cache (mutex-protected). | |
| 68 | - | uri string | |
| 69 | - | title string | |
| 70 | - | progress float64 | |
| 71 | - | canGoBack bool | |
| 72 | - | canGoFwd bool | |
| 73 | - | isLoading bool | |
| 68 | + | uri string | |
| 69 | + | title string | |
| 70 | + | progress float64 | |
| 71 | + | canGoBack bool | |
| 72 | + | canGoFwd bool | |
| 73 | + | isLoading bool | |
| 74 | + | selectedText string | |
| 74 | 75 | ||
| 75 | 76 | // Last known hover URI for middle-click → new tab. | |
| 76 | 77 | lastHoverURI string | |
| @@ -565,6 +566,21 @@ func (wv *WebView) setAudioPlaying(playing bool) { | |||
| 565 | 566 | } | |
| 566 | 567 | } | |
| 567 | 568 | ||
| 569 | + | func (wv *WebView) setSelectedText(text string) (string, bool) { | |
| 570 | + | wv.mu.Lock() | |
| 571 | + | previous := wv.selectedText | |
| 572 | + | changed := previous != text | |
| 573 | + | wv.selectedText = text | |
| 574 | + | wv.mu.Unlock() | |
| 575 | + | return previous, changed | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | func (wv *WebView) selectedTextSnapshot() string { | |
| 579 | + | wv.mu.RLock() | |
| 580 | + | defer wv.mu.RUnlock() | |
| 581 | + | return wv.selectedText | |
| 582 | + | } | |
| 583 | + | ||
| 568 | 584 | func (wv *WebView) updateHoverURI(uri string) { | |
| 569 | 585 | wv.mu.Lock() | |
| 570 | 586 | wv.lastHoverURI = uri | |
| @@ -65,12 +65,13 @@ type WebView struct { | |||||
| 65 | callbacks *port.WebViewCallbacks | 65 | callbacks *port.WebViewCallbacks | ||
| 66 | 66 | ||||
| 67 | // State cache (mutex-protected). | 67 | // State cache (mutex-protected). | ||
| 68 | - | uri string | 68 | + | uri string |
| 69 | - | title string | 69 | + | title string |
| 70 | - | progress float64 | 70 | + | progress float64 |
| 71 | - | canGoBack bool | 71 | + | canGoBack bool |
| 72 | - | canGoFwd bool | 72 | + | canGoFwd bool |
| 73 | - | isLoading bool | 73 | + | isLoading bool |
| 74 | + | selectedText string | |||
| 74 | 75 | ||||
| 75 | // Last known hover URI for middle-click → new tab. | 76 | // Last known hover URI for middle-click → new tab. | ||
| 76 | lastHoverURI string | 77 | lastHoverURI string | ||
| @@ -565,6 +566,21 @@ func (wv *WebView) setAudioPlaying(playing bool) { | |||||
| 565 | } | 566 | } | ||
| 566 | } | 567 | } | ||
| 567 | 568 | ||||
| 569 | + | func (wv *WebView) setSelectedText(text string) (string, bool) { | |||
| 570 | + | wv.mu.Lock() | |||
| 571 | + | previous := wv.selectedText | |||
| 572 | + | changed := previous != text | |||
| 573 | + | wv.selectedText = text | |||
| 574 | + | wv.mu.Unlock() | |||
| 575 | + | return previous, changed | |||
| 576 | + | } | |||
| 577 | + | ||||
| 578 | + | func (wv *WebView) selectedTextSnapshot() string { | |||
| 579 | + | wv.mu.RLock() | |||
| 580 | + | defer wv.mu.RUnlock() | |||
| 581 | + | return wv.selectedText | |||
| 582 | + | } | |||
| 583 | + | ||||
| 568 | func (wv *WebView) updateHoverURI(uri string) { | 584 | func (wv *WebView) updateHoverURI(uri string) { | ||
| 569 | wv.mu.Lock() | 585 | wv.mu.Lock() | ||
| 570 | wv.lastHoverURI = uri | 586 | wv.lastHoverURI = uri | ||
M internal/infrastructure/cef/webview_handlers.go +124 -17
| @@ -15,6 +15,8 @@ import ( | |||
| 15 | 15 | const ( | |
| 16 | 16 | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |
| 17 | 17 | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | |
| 18 | + | consoleMarkerClipboardBridge = "[CLIPBOARD-BRIDGE]" | |
| 19 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| 20 | 22 | // handlerSet implements all CEF handler interfaces and dispatches events to the | |
| @@ -67,8 +69,44 @@ func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return | |||
| 67 | 69 | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | |
| 68 | 70 | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |
| 69 | 71 | ||
| 70 | - | func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 { | |
| 71 | - | return 0 | |
| 72 | + | func (h *handlerSet) OnProcessMessageReceived( | |
| 73 | + | browser purecef.Browser, | |
| 74 | + | _ purecef.Frame, | |
| 75 | + | _ purecef.ProcessID, | |
| 76 | + | message purecef.ProcessMessage, | |
| 77 | + | ) int32 { | |
| 78 | + | if h == nil || h.wv == nil || h.wv.engine == nil { | |
| 79 | + | return 0 | |
| 80 | + | } | |
| 81 | + | action, payload, ok := decodeRendererBridgeProcessMessage(message) | |
| 82 | + | if !ok { | |
| 83 | + | return 0 | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | log := logging.FromContext(h.wv.ctx) | |
| 87 | + | log.Debug(). | |
| 88 | + | Str("action", action). | |
| 89 | + | Int("payload_len", len(payload)). | |
| 90 | + | Msg("cef: renderer bridge message received") | |
| 91 | + | ||
| 92 | + | switch action { | |
| 93 | + | case rendererBridgeActionExplicitTextCopy: | |
| 94 | + | req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(payload)) | |
| 95 | + | if err != nil { | |
| 96 | + | log.Debug().Str("action", action).Msg("cef: invalid explicit copy payload") | |
| 97 | + | return 1 | |
| 98 | + | } | |
| 99 | + | h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text) | |
| 100 | + | case rendererBridgeActionFocusSync: | |
| 101 | + | h.wv.engine.handleEditableFocusBridge(browser) | |
| 102 | + | case rendererBridgeActionReady: | |
| 103 | + | log.Debug(). | |
| 104 | + | Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)). | |
| 105 | + | Msg("cef: renderer bridge ready") | |
| 106 | + | default: | |
| 107 | + | log.Debug().Str("action", action).Msg("cef: unknown renderer bridge action") | |
| 108 | + | } | |
| 109 | + | return 1 | |
| 72 | 110 | } | |
| 73 | 111 | ||
| 74 | 112 | // =========================================================================== | |
| @@ -295,7 +333,12 @@ func (h *handlerSet) shouldAcceptMainViewPaint( | |||
| 295 | 333 | return sizeMatchesCurrentView | |
| 296 | 334 | } | |
| 297 | 335 | ||
| 298 | - | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | |
| 336 | + | func (h *handlerSet) OnAcceleratedPaint( | |
| 337 | + | _ purecef.Browser, | |
| 338 | + | _ purecef.PaintElementType, | |
| 339 | + | _ []purecef.Rect, | |
| 340 | + | _ *purecef.AcceleratedPaintInfo, | |
| 341 | + | ) { | |
| 299 | 342 | count := h.wv.pipeline.recordAcceleratedPaint() | |
| 300 | 343 | if count <= 5 || count%100 == 0 { | |
| 301 | 344 | logging.FromContext(h.wv.ctx).Info(). | |
| @@ -309,7 +352,12 @@ func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalA | |||
| 309 | 352 | ||
| 310 | 353 | func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {} | |
| 311 | 354 | ||
| 312 | - | func (h *handlerSet) StartDragging(_ purecef.Browser, _ purecef.DragData, _ purecef.DragOperationsMask, _, _ int32) int32 { | |
| 355 | + | func (h *handlerSet) StartDragging( | |
| 356 | + | _ purecef.Browser, | |
| 357 | + | _ purecef.DragData, | |
| 358 | + | _ purecef.DragOperationsMask, | |
| 359 | + | _, _ int32, | |
| 360 | + | ) int32 { | |
| 313 | 361 | return 0 | |
| 314 | 362 | } | |
| 315 | 363 | ||
| @@ -320,7 +368,29 @@ func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {} | |||
| 320 | 368 | func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) { | |
| 321 | 369 | } | |
| 322 | 370 | ||
| 323 | - | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, _ string, _ *purecef.Range) {} | |
| 371 | + | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) { | |
| 372 | + | if h == nil || h.wv == nil { | |
| 373 | + | return | |
| 374 | + | } | |
| 375 | + | previous, changed := h.wv.setSelectedText(selectedText) | |
| 376 | + | if !changed || h.wv.ctx == nil { | |
| 377 | + | return | |
| 378 | + | } | |
| 379 | + | if selectedText == "" { | |
| 380 | + | if previous != "" { | |
| 381 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 382 | + | Int("prev_text_len", len(previous)). | |
| 383 | + | Msg("cef: text selection cleared") | |
| 384 | + | } | |
| 385 | + | } else { | |
| 386 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 387 | + | Int("text_len", len(selectedText)). | |
| 388 | + | Msg("cef: text selection changed") | |
| 389 | + | } | |
| 390 | + | if h.wv.engine != nil { | |
| 391 | + | h.wv.engine.handleClipboardSelectionUpdate(h.wv.id, selectedText) | |
| 392 | + | } | |
| 393 | + | } | |
| 324 | 394 | ||
| 325 | 395 | func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {} | |
| 326 | 396 | ||
| @@ -382,9 +452,17 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||
| 382 | 452 | } | |
| 383 | 453 | } | |
| 384 | 454 | ||
| 385 | - | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | |
| 455 | + | func (h *handlerSet) OnConsoleMessage( | |
| 456 | + | _ purecef.Browser, | |
| 457 | + | level purecef.LogSeverity, | |
| 458 | + | message, source string, | |
| 459 | + | line int32, | |
| 460 | + | ) int32 { | |
| 386 | 461 | if h.wv != nil && h.wv.ctx != nil && | |
| 387 | - | (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) { | |
| 462 | + | (strings.Contains(message, consoleMarkerVideoDiag) || | |
| 463 | + | strings.Contains(message, consoleMarkerRedditVideoPatch) || | |
| 464 | + | strings.Contains(message, consoleMarkerClipboardBridge) || | |
| 465 | + | strings.Contains(message, consoleMarkerAutoCopy)) { | |
| 388 | 466 | log := logging.FromContext(h.wv.ctx).With(). | |
| 389 | 467 | Str("component", "cef-console"). | |
| 390 | 468 | Str("source", source). | |
| @@ -409,7 +487,12 @@ func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64 | |||
| 409 | 487 | h.wv.updateProgress(progress) | |
| 410 | 488 | } | |
| 411 | 489 | ||
| 412 | - | func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 { | |
| 490 | + | func (h *handlerSet) OnCursorChange( | |
| 491 | + | _ purecef.Browser, | |
| 492 | + | _ uintptr, | |
| 493 | + | cursorType purecef.CursorType, | |
| 494 | + | _ *purecef.CursorInfo, | |
| 495 | + | ) int32 { | |
| 413 | 496 | name := cefCursorToGDKName(cursorType) | |
| 414 | 497 | h.wv.runOnGTK(func() { | |
| 415 | 498 | h.wv.pipeline.glArea.SetCursorFromName(&name) | |
| @@ -432,6 +515,13 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac | |||
| 432 | 515 | loading := isloading != 0 | |
| 433 | 516 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | |
| 434 | 517 | ||
| 518 | + | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | |
| 519 | + | h.wv.mu.RLock() | |
| 520 | + | host := h.wv.host | |
| 521 | + | h.wv.mu.RUnlock() | |
| 522 | + | syncWindowlessBrowserFocus(host) | |
| 523 | + | } | |
| 524 | + | ||
| 435 | 525 | h.wv.mu.RLock() | |
| 436 | 526 | cb := h.wv.callbacks | |
| 437 | 527 | h.wv.mu.RUnlock() | |
| @@ -579,11 +669,10 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 579 | 669 | // UI elements like the text caret until explicitly told the browser is shown. | |
| 580 | 670 | host.WasHidden(0) | |
| 581 | 671 | ||
| 582 | - | // If the GLArea already has GTK focus (cold start race), tell CEF now. | |
| 583 | - | // The focus-enter event fired before the browser existed, so SetFocus | |
| 584 | - | // was never called and the caret won't blink until the user clicks. | |
| 585 | - | if h.wv.input != nil && h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() { | |
| 586 | - | host.SetFocus(1) | |
| 672 | + | // If GTK focus entered before the browser existed, reassert browser focus | |
| 673 | + | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. | |
| 674 | + | if h.wv.input != nil && h.wv.input.hasGTKFocus() { | |
| 675 | + | syncWindowlessBrowserFocus(host) | |
| 587 | 676 | } | |
| 588 | 677 | ||
| 589 | 678 | // Replay any navigation that was requested before the browser existed. | |
| @@ -636,7 +725,13 @@ func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purece | |||
| 636 | 725 | return false | |
| 637 | 726 | } | |
| 638 | 727 | ||
| 639 | - | func (h *handlerSet) OnOpenUrlfromTab(_ purecef.Browser, _ purecef.Frame, _ string, _ purecef.WindowOpenDisposition, _ int32) int32 { | |
| 728 | + | func (h *handlerSet) OnOpenUrlfromTab( | |
| 729 | + | _ purecef.Browser, | |
| 730 | + | _ purecef.Frame, | |
| 731 | + | _ string, | |
| 732 | + | _ purecef.WindowOpenDisposition, | |
| 733 | + | _ int32, | |
| 734 | + | ) int32 { | |
| 640 | 735 | return 0 | |
| 641 | 736 | } | |
| 642 | 737 | ||
| @@ -644,7 +739,8 @@ func (h *handlerSet) GetResourceRequestHandler( | |||
| 644 | 739 | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |
| 645 | 740 | _, _ int32, _ string, disableDefaultHandling *int32, | |
| 646 | 741 | ) purecef.ResourceRequestHandler { | |
| 647 | - | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && transcoder.IsEagerTranscodeURL(request.GetURL()) { | |
| 742 | + | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && | |
| 743 | + | transcoder.IsEagerTranscodeURL(request.GetURL()) { | |
| 648 | 744 | *disableDefaultHandling = 1 | |
| 649 | 745 | if h.wv != nil && h.wv.ctx != nil { | |
| 650 | 746 | logging.FromContext(h.wv.ctx).Info(). | |
| @@ -665,7 +761,13 @@ func (h *handlerSet) GetAuthCredentials( | |||
| 665 | 761 | return 0 | |
| 666 | 762 | } | |
| 667 | 763 | ||
| 668 | - | func (h *handlerSet) OnCertificateError(_ purecef.Browser, _ purecef.Errorcode, _ string, _ purecef.Sslinfo, _ purecef.Callback) int32 { | |
| 764 | + | func (h *handlerSet) OnCertificateError( | |
| 765 | + | _ purecef.Browser, | |
| 766 | + | _ purecef.Errorcode, | |
| 767 | + | _ string, | |
| 768 | + | _ purecef.Sslinfo, | |
| 769 | + | _ purecef.Callback, | |
| 770 | + | ) int32 { | |
| 669 | 771 | return 0 | |
| 670 | 772 | } | |
| 671 | 773 | ||
| @@ -894,7 +996,12 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) { | |||
| 894 | 996 | // =========================================================================== | |
| 895 | 997 | ||
| 896 | 998 | // OnFindResult dispatches CEF find results to the WebView's FindController. | |
| 897 | - | func (h *handlerSet) OnFindResult(_ purecef.Browser, identifier, count int32, _ *purecef.Rect, activematchordinal, finalupdate int32) { | |
| 999 | + | func (h *handlerSet) OnFindResult( | |
| 1000 | + | _ purecef.Browser, | |
| 1001 | + | identifier, count int32, | |
| 1002 | + | _ *purecef.Rect, | |
| 1003 | + | activematchordinal, finalupdate int32, | |
| 1004 | + | ) { | |
| 898 | 1005 | if h.wv.findCtrl != nil { | |
| 899 | 1006 | h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate) | |
| 900 | 1007 | } | |
| @@ -15,6 +15,8 @@ import ( | |||||
| 15 | const ( | 15 | const ( | ||
| 16 | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | 16 | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | ||
| 17 | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | 17 | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | ||
| 18 | + | consoleMarkerClipboardBridge = "[CLIPBOARD-BRIDGE]" | |||
| 19 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" | |||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| 20 | // handlerSet implements all CEF handler interfaces and dispatches events to the | 22 | // handlerSet implements all CEF handler interfaces and dispatches events to the | ||
| @@ -67,8 +69,44 @@ func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return | |||||
| 67 | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | 69 | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | ||
| 68 | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | 70 | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | ||
| 69 | 71 | ||||
| 70 | - | func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 { | 72 | + | func (h *handlerSet) OnProcessMessageReceived( |
| 71 | - | return 0 | 73 | + | browser purecef.Browser, |
| 74 | + | _ purecef.Frame, | |||
| 75 | + | _ purecef.ProcessID, | |||
| 76 | + | message purecef.ProcessMessage, | |||
| 77 | + | ) int32 { | |||
| 78 | + | if h == nil || h.wv == nil || h.wv.engine == nil { | |||
| 79 | + | return 0 | |||
| 80 | + | } | |||
| 81 | + | action, payload, ok := decodeRendererBridgeProcessMessage(message) | |||
| 82 | + | if !ok { | |||
| 83 | + | return 0 | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | log := logging.FromContext(h.wv.ctx) | |||
| 87 | + | log.Debug(). | |||
| 88 | + | Str("action", action). | |||
| 89 | + | Int("payload_len", len(payload)). | |||
| 90 | + | Msg("cef: renderer bridge message received") | |||
| 91 | + | ||||
| 92 | + | switch action { | |||
| 93 | + | case rendererBridgeActionExplicitTextCopy: | |||
| 94 | + | req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(payload)) | |||
| 95 | + | if err != nil { | |||
| 96 | + | log.Debug().Str("action", action).Msg("cef: invalid explicit copy payload") | |||
| 97 | + | return 1 | |||
| 98 | + | } | |||
| 99 | + | h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text) | |||
| 100 | + | case rendererBridgeActionFocusSync: | |||
| 101 | + | h.wv.engine.handleEditableFocusBridge(browser) | |||
| 102 | + | case rendererBridgeActionReady: | |||
| 103 | + | log.Debug(). | |||
| 104 | + | Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)). | |||
| 105 | + | Msg("cef: renderer bridge ready") | |||
| 106 | + | default: | |||
| 107 | + | log.Debug().Str("action", action).Msg("cef: unknown renderer bridge action") | |||
| 108 | + | } | |||
| 109 | + | return 1 | |||
| 72 | } | 110 | } | ||
| 73 | 111 | ||||
| 74 | // =========================================================================== | 112 | // =========================================================================== | ||
| @@ -295,7 +333,12 @@ func (h *handlerSet) shouldAcceptMainViewPaint( | |||||
| 295 | return sizeMatchesCurrentView | 333 | return sizeMatchesCurrentView | ||
| 296 | } | 334 | } | ||
| 297 | 335 | ||||
| 298 | - | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | 336 | + | func (h *handlerSet) OnAcceleratedPaint( |
| 337 | + | _ purecef.Browser, | |||
| 338 | + | _ purecef.PaintElementType, | |||
| 339 | + | _ []purecef.Rect, | |||
| 340 | + | _ *purecef.AcceleratedPaintInfo, | |||
| 341 | + | ) { | |||
| 299 | count := h.wv.pipeline.recordAcceleratedPaint() | 342 | count := h.wv.pipeline.recordAcceleratedPaint() | ||
| 300 | if count <= 5 || count%100 == 0 { | 343 | if count <= 5 || count%100 == 0 { | ||
| 301 | logging.FromContext(h.wv.ctx).Info(). | 344 | logging.FromContext(h.wv.ctx).Info(). | ||
| @@ -309,7 +352,12 @@ func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalA | |||||
| 309 | 352 | ||||
| 310 | func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {} | 353 | func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {} | ||
| 311 | 354 | ||||
| 312 | - | func (h *handlerSet) StartDragging(_ purecef.Browser, _ purecef.DragData, _ purecef.DragOperationsMask, _, _ int32) int32 { | 355 | + | func (h *handlerSet) StartDragging( |
| 356 | + | _ purecef.Browser, | |||
| 357 | + | _ purecef.DragData, | |||
| 358 | + | _ purecef.DragOperationsMask, | |||
| 359 | + | _, _ int32, | |||
| 360 | + | ) int32 { | |||
| 313 | return 0 | 361 | return 0 | ||
| 314 | } | 362 | } | ||
| 315 | 363 | ||||
| @@ -320,7 +368,29 @@ func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {} | |||||
| 320 | func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) { | 368 | func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) { | ||
| 321 | } | 369 | } | ||
| 322 | 370 | ||||
| 323 | - | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, _ string, _ *purecef.Range) {} | 371 | + | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) { |
| 372 | + | if h == nil || h.wv == nil { | |||
| 373 | + | return | |||
| 374 | + | } | |||
| 375 | + | previous, changed := h.wv.setSelectedText(selectedText) | |||
| 376 | + | if !changed || h.wv.ctx == nil { | |||
| 377 | + | return | |||
| 378 | + | } | |||
| 379 | + | if selectedText == "" { | |||
| 380 | + | if previous != "" { | |||
| 381 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 382 | + | Int("prev_text_len", len(previous)). | |||
| 383 | + | Msg("cef: text selection cleared") | |||
| 384 | + | } | |||
| 385 | + | } else { | |||
| 386 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 387 | + | Int("text_len", len(selectedText)). | |||
| 388 | + | Msg("cef: text selection changed") | |||
| 389 | + | } | |||
| 390 | + | if h.wv.engine != nil { | |||
| 391 | + | h.wv.engine.handleClipboardSelectionUpdate(h.wv.id, selectedText) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 324 | 394 | ||||
| 325 | func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {} | 395 | func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {} | ||
| 326 | 396 | ||||
| @@ -382,9 +452,17 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||||
| 382 | } | 452 | } | ||
| 383 | } | 453 | } | ||
| 384 | 454 | ||||
| 385 | - | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | 455 | + | func (h *handlerSet) OnConsoleMessage( |
| 456 | + | _ purecef.Browser, | |||
| 457 | + | level purecef.LogSeverity, | |||
| 458 | + | message, source string, | |||
| 459 | + | line int32, | |||
| 460 | + | ) int32 { | |||
| 386 | if h.wv != nil && h.wv.ctx != nil && | 461 | if h.wv != nil && h.wv.ctx != nil && | ||
| 387 | - | (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) { | 462 | + | (strings.Contains(message, consoleMarkerVideoDiag) || |
| 463 | + | strings.Contains(message, consoleMarkerRedditVideoPatch) || | |||
| 464 | + | strings.Contains(message, consoleMarkerClipboardBridge) || | |||
| 465 | + | strings.Contains(message, consoleMarkerAutoCopy)) { | |||
| 388 | log := logging.FromContext(h.wv.ctx).With(). | 466 | log := logging.FromContext(h.wv.ctx).With(). | ||
| 389 | Str("component", "cef-console"). | 467 | Str("component", "cef-console"). | ||
| 390 | Str("source", source). | 468 | Str("source", source). | ||
| @@ -409,7 +487,12 @@ func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64 | |||||
| 409 | h.wv.updateProgress(progress) | 487 | h.wv.updateProgress(progress) | ||
| 410 | } | 488 | } | ||
| 411 | 489 | ||||
| 412 | - | func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 { | 490 | + | func (h *handlerSet) OnCursorChange( |
| 491 | + | _ purecef.Browser, | |||
| 492 | + | _ uintptr, | |||
| 493 | + | cursorType purecef.CursorType, | |||
| 494 | + | _ *purecef.CursorInfo, | |||
| 495 | + | ) int32 { | |||
| 413 | name := cefCursorToGDKName(cursorType) | 496 | name := cefCursorToGDKName(cursorType) | ||
| 414 | h.wv.runOnGTK(func() { | 497 | h.wv.runOnGTK(func() { | ||
| 415 | h.wv.pipeline.glArea.SetCursorFromName(&name) | 498 | h.wv.pipeline.glArea.SetCursorFromName(&name) | ||
| @@ -432,6 +515,13 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac | |||||
| 432 | loading := isloading != 0 | 515 | loading := isloading != 0 | ||
| 433 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | 516 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | ||
| 434 | 517 | ||||
| 518 | + | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | |||
| 519 | + | h.wv.mu.RLock() | |||
| 520 | + | host := h.wv.host | |||
| 521 | + | h.wv.mu.RUnlock() | |||
| 522 | + | syncWindowlessBrowserFocus(host) | |||
| 523 | + | } | |||
| 524 | + | ||||
| 435 | h.wv.mu.RLock() | 525 | h.wv.mu.RLock() | ||
| 436 | cb := h.wv.callbacks | 526 | cb := h.wv.callbacks | ||
| 437 | h.wv.mu.RUnlock() | 527 | h.wv.mu.RUnlock() | ||
| @@ -579,11 +669,10 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||||
| 579 | // UI elements like the text caret until explicitly told the browser is shown. | 669 | // UI elements like the text caret until explicitly told the browser is shown. | ||
| 580 | host.WasHidden(0) | 670 | host.WasHidden(0) | ||
| 581 | 671 | ||||
| 582 | - | // If the GLArea already has GTK focus (cold start race), tell CEF now. | 672 | + | // If GTK focus entered before the browser existed, reassert browser focus |
| 583 | - | // The focus-enter event fired before the browser existed, so SetFocus | 673 | + | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. |
| 584 | - | // was never called and the caret won't blink until the user clicks. | 674 | + | if h.wv.input != nil && h.wv.input.hasGTKFocus() { |
| 585 | - | if h.wv.input != nil && h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() { | 675 | + | syncWindowlessBrowserFocus(host) |
| 586 | - | host.SetFocus(1) | |||
| 587 | } | 676 | } | ||
| 588 | 677 | ||||
| 589 | // Replay any navigation that was requested before the browser existed. | 678 | // Replay any navigation that was requested before the browser existed. | ||
| @@ -636,7 +725,13 @@ func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purece | |||||
| 636 | return false | 725 | return false | ||
| 637 | } | 726 | } | ||
| 638 | 727 | ||||
| 639 | - | func (h *handlerSet) OnOpenUrlfromTab(_ purecef.Browser, _ purecef.Frame, _ string, _ purecef.WindowOpenDisposition, _ int32) int32 { | 728 | + | func (h *handlerSet) OnOpenUrlfromTab( |
| 729 | + | _ purecef.Browser, | |||
| 730 | + | _ purecef.Frame, | |||
| 731 | + | _ string, | |||
| 732 | + | _ purecef.WindowOpenDisposition, | |||
| 733 | + | _ int32, | |||
| 734 | + | ) int32 { | |||
| 640 | return 0 | 735 | return 0 | ||
| 641 | } | 736 | } | ||
| 642 | 737 | ||||
| @@ -644,7 +739,8 @@ func (h *handlerSet) GetResourceRequestHandler( | |||||
| 644 | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | 739 | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | ||
| 645 | _, _ int32, _ string, disableDefaultHandling *int32, | 740 | _, _ int32, _ string, disableDefaultHandling *int32, | ||
| 646 | ) purecef.ResourceRequestHandler { | 741 | ) purecef.ResourceRequestHandler { | ||
| 647 | - | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && transcoder.IsEagerTranscodeURL(request.GetURL()) { | 742 | + | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && |
| 743 | + | transcoder.IsEagerTranscodeURL(request.GetURL()) { | |||
| 648 | *disableDefaultHandling = 1 | 744 | *disableDefaultHandling = 1 | ||
| 649 | if h.wv != nil && h.wv.ctx != nil { | 745 | if h.wv != nil && h.wv.ctx != nil { | ||
| 650 | logging.FromContext(h.wv.ctx).Info(). | 746 | logging.FromContext(h.wv.ctx).Info(). | ||
| @@ -665,7 +761,13 @@ func (h *handlerSet) GetAuthCredentials( | |||||
| 665 | return 0 | 761 | return 0 | ||
| 666 | } | 762 | } | ||
| 667 | 763 | ||||
| 668 | - | func (h *handlerSet) OnCertificateError(_ purecef.Browser, _ purecef.Errorcode, _ string, _ purecef.Sslinfo, _ purecef.Callback) int32 { | 764 | + | func (h *handlerSet) OnCertificateError( |
| 765 | + | _ purecef.Browser, | |||
| 766 | + | _ purecef.Errorcode, | |||
| 767 | + | _ string, | |||
| 768 | + | _ purecef.Sslinfo, | |||
| 769 | + | _ purecef.Callback, | |||
| 770 | + | ) int32 { | |||
| 669 | return 0 | 771 | return 0 | ||
| 670 | } | 772 | } | ||
| 671 | 773 | ||||
| @@ -894,7 +996,12 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) { | |||||
| 894 | // =========================================================================== | 996 | // =========================================================================== | ||
| 895 | 997 | ||||
| 896 | // OnFindResult dispatches CEF find results to the WebView's FindController. | 998 | // OnFindResult dispatches CEF find results to the WebView's FindController. | ||
| 897 | - | func (h *handlerSet) OnFindResult(_ purecef.Browser, identifier, count int32, _ *purecef.Rect, activematchordinal, finalupdate int32) { | 999 | + | func (h *handlerSet) OnFindResult( |
| 1000 | + | _ purecef.Browser, | |||
| 1001 | + | identifier, count int32, | |||
| 1002 | + | _ *purecef.Rect, | |||
| 1003 | + | activematchordinal, finalupdate int32, | |||
| 1004 | + | ) { | |||
| 898 | if h.wv.findCtrl != nil { | 1005 | if h.wv.findCtrl != nil { | ||
| 899 | h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate) | 1006 | h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate) | ||
| 900 | } | 1007 | } | ||
M internal/infrastructure/cef/webview_handlers_test.go +33 -0
| @@ -11,6 +11,21 @@ import ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | type recordingClipboardOrchestrator struct { | |
| 15 | + | selection port.SelectionClipboardInput | |
| 16 | + | explicit port.ExplicitClipboardInput | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |
| 20 | + | r.selection = input | |
| 21 | + | return nil | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error { | |
| 25 | + | r.explicit = input | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | ||
| 14 | 29 | func newTestPipeline(w, h, s int32) *renderPipeline { | |
| 15 | 30 | rp := &renderPipeline{scale: s} | |
| 16 | 31 | rp.widthAtomic.Store(w) | |
| @@ -89,6 +104,24 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) { | |||
| 89 | 104 | assert.Empty(t, gotProgress) | |
| 90 | 105 | } | |
| 91 | 106 | ||
| 107 | + | func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) { | |
| 108 | + | orchestrator := &recordingClipboardOrchestrator{} | |
| 109 | + | wv := &WebView{ | |
| 110 | + | ctx: context.Background(), | |
| 111 | + | id: 42, | |
| 112 | + | engine: &Engine{ | |
| 113 | + | clipboardTextOrchestrator: orchestrator, | |
| 114 | + | }, | |
| 115 | + | } | |
| 116 | + | h := &handlerSet{wv: wv} | |
| 117 | + | ||
| 118 | + | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 119 | + | ||
| 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) | |
| 123 | + | } | |
| 124 | + | ||
| 92 | 125 | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | |
| 93 | 126 | rect := &purecef.Rect{} | |
| 94 | 127 | h := &handlerSet{ | |
| @@ -11,6 +11,21 @@ import ( | |||||
| 11 | "github.com/bnema/dumber/internal/application/port" | 11 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | type recordingClipboardOrchestrator struct { | |||
| 15 | + | selection port.SelectionClipboardInput | |||
| 16 | + | explicit port.ExplicitClipboardInput | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |||
| 20 | + | r.selection = input | |||
| 21 | + | return nil | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error { | |||
| 25 | + | r.explicit = input | |||
| 26 | + | return nil | |||
| 27 | + | } | |||
| 28 | + | ||||
| 14 | func newTestPipeline(w, h, s int32) *renderPipeline { | 29 | func newTestPipeline(w, h, s int32) *renderPipeline { | ||
| 15 | rp := &renderPipeline{scale: s} | 30 | rp := &renderPipeline{scale: s} | ||
| 16 | rp.widthAtomic.Store(w) | 31 | rp.widthAtomic.Store(w) | ||
| @@ -89,6 +104,24 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) { | |||||
| 89 | assert.Empty(t, gotProgress) | 104 | assert.Empty(t, gotProgress) | ||
| 90 | } | 105 | } | ||
| 91 | 106 | ||||
| 107 | + | func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) { | |||
| 108 | + | orchestrator := &recordingClipboardOrchestrator{} | |||
| 109 | + | wv := &WebView{ | |||
| 110 | + | ctx: context.Background(), | |||
| 111 | + | id: 42, | |||
| 112 | + | engine: &Engine{ | |||
| 113 | + | clipboardTextOrchestrator: orchestrator, | |||
| 114 | + | }, | |||
| 115 | + | } | |||
| 116 | + | h := &handlerSet{wv: wv} | |||
| 117 | + | ||||
| 118 | + | h.OnTextSelectionChanged(nil, "selected text", nil) | |||
| 119 | + | ||||
| 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) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 92 | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | 125 | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | ||
| 93 | rect := &purecef.Rect{} | 126 | rect := &purecef.Rect{} | ||
| 94 | h := &handlerSet{ | 127 | h := &handlerSet{ | ||
M internal/infrastructure/handlers/clipboard.go +44 -28
| @@ -10,17 +10,13 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // ClipboardHandler handles clipboard-related messages from webviews. | |
| 12 | 12 | type ClipboardHandler struct { | |
| 13 | - | clipboard port.Clipboard | |
| 14 | - | autoCopyConfig port.AutoCopyConfig | |
| 15 | - | onCopied func(textLen int) // Called after successful auto-copy (for toast notification) | |
| 13 | + | orchestrator port.ClipboardTextOrchestrator | |
| 16 | 14 | } | |
| 17 | 15 | ||
| 18 | 16 | // NewClipboardHandler creates a new ClipboardHandler. | |
| 19 | - | func NewClipboardHandler(clipboard port.Clipboard, autoCopyConfig port.AutoCopyConfig, onCopied func(textLen int)) *ClipboardHandler { | |
| 17 | + | func NewClipboardHandler(orchestrator port.ClipboardTextOrchestrator) *ClipboardHandler { | |
| 20 | 18 | return &ClipboardHandler{ | |
| 21 | - | clipboard: clipboard, | |
| 22 | - | autoCopyConfig: autoCopyConfig, | |
| 23 | - | onCopied: onCopied, | |
| 19 | + | orchestrator: orchestrator, | |
| 24 | 20 | } | |
| 25 | 21 | } | |
| 26 | 22 | ||
| @@ -29,38 +25,57 @@ type autoCopyRequest struct { | |||
| 29 | 25 | Text string `json:"text"` | |
| 30 | 26 | } | |
| 31 | 27 | ||
| 28 | + | type explicitCopyRequest struct { | |
| 29 | + | Text string `json:"text"` | |
| 30 | + | Action string `json:"action"` | |
| 31 | + | } | |
| 32 | + | ||
| 32 | 33 | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | |
| 33 | 34 | // It copies the selected text to the clipboard if the feature is enabled. | |
| 34 | 35 | func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | |
| 35 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | |
| 36 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { | |
| 36 | 37 | log := logging.FromContext(ctx) | |
| 37 | 38 | ||
| 38 | - | // Check if feature is enabled | |
| 39 | - | if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() { | |
| 40 | - | return nil, nil | |
| 41 | - | } | |
| 42 | - | ||
| 43 | 39 | var req autoCopyRequest | |
| 44 | 40 | if err := json.Unmarshal(payload, &req); err != nil { | |
| 45 | 41 | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | |
| 46 | 42 | return nil, nil // Silently ignore malformed requests | |
| 47 | 43 | } | |
| 48 | - | ||
| 49 | - | // Skip empty or very short selections (< 2 chars) | |
| 50 | - | if len(req.Text) < 2 { | |
| 44 | + | if h.orchestrator == nil { | |
| 51 | 45 | return nil, nil | |
| 52 | 46 | } | |
| 53 | - | ||
| 54 | - | if err := h.clipboard.WriteText(ctx, req.Text); err != nil { | |
| 55 | - | log.Debug().Err(err).Msg("failed to write selection to clipboard") | |
| 56 | - | return nil, nil // Don't propagate error to JS | |
| 47 | + | if err := h.orchestrator.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{ | |
| 48 | + | Text: req.Text, | |
| 49 | + | SourceEngine: port.ClipboardSourceWebKit, | |
| 50 | + | ViewID: webviewID, | |
| 51 | + | }); err != nil { | |
| 52 | + | log.Debug().Err(err).Msg("clipboard selection handling failed") | |
| 57 | 53 | } | |
| 58 | 54 | ||
| 59 | - | log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard") | |
| 55 | + | return nil, nil | |
| 56 | + | }) | |
| 57 | + | } | |
| 60 | 58 | ||
| 61 | - | // Notify UI for toast feedback (if callback is set) | |
| 62 | - | if h.onCopied != nil { | |
| 63 | - | h.onCopied(len(req.Text)) | |
| 59 | + | // HandleExplicitCopy handles the explicit_text_copy message from JS. | |
| 60 | + | func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler { | |
| 61 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { | |
| 62 | + | log := logging.FromContext(ctx) | |
| 63 | + | ||
| 64 | + | var req explicitCopyRequest | |
| 65 | + | if err := json.Unmarshal(payload, &req); err != nil { | |
| 66 | + | log.Debug().Err(err).Msg("failed to unmarshal explicit copy payload") | |
| 67 | + | return nil, nil | |
| 68 | + | } | |
| 69 | + | if h.orchestrator == nil { | |
| 70 | + | return nil, nil | |
| 71 | + | } | |
| 72 | + | if err := h.orchestrator.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{ | |
| 73 | + | Text: req.Text, | |
| 74 | + | Action: req.Action, | |
| 75 | + | SourceEngine: port.ClipboardSourceWebKit, | |
| 76 | + | ViewID: webviewID, | |
| 77 | + | }); err != nil { | |
| 78 | + | log.Debug().Err(err).Msg("clipboard explicit handling failed") | |
| 64 | 79 | } | |
| 65 | 80 | ||
| 66 | 81 | return nil, nil | |
| @@ -71,16 +86,17 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | |||
| 71 | 86 | func RegisterClipboardHandlers( | |
| 72 | 87 | ctx context.Context, | |
| 73 | 88 | router port.WebUIHandlerRouter, | |
| 74 | - | clipboard port.Clipboard, | |
| 75 | - | autoCopyConfig port.AutoCopyConfig, | |
| 76 | - | onCopied func(textLen int), | |
| 89 | + | orchestrator port.ClipboardTextOrchestrator, | |
| 77 | 90 | ) error { | |
| 78 | - | handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied) | |
| 91 | + | handler := NewClipboardHandler(orchestrator) | |
| 79 | 92 | ||
| 80 | 93 | // Register auto_copy_selection handler | |
| 81 | 94 | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | |
| 82 | 95 | return err | |
| 83 | 96 | } | |
| 97 | + | if err := router.RegisterHandler("explicit_text_copy", handler.HandleExplicitCopy()); err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 84 | 100 | ||
| 85 | 101 | log := logging.FromContext(ctx) | |
| 86 | 102 | log.Info().Msg("registered clipboard handlers") | |
| @@ -10,17 +10,13 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | // ClipboardHandler handles clipboard-related messages from webviews. | 11 | // ClipboardHandler handles clipboard-related messages from webviews. | ||
| 12 | type ClipboardHandler struct { | 12 | type ClipboardHandler struct { | ||
| 13 | - | clipboard port.Clipboard | 13 | + | orchestrator port.ClipboardTextOrchestrator |
| 14 | - | autoCopyConfig port.AutoCopyConfig | |||
| 15 | - | onCopied func(textLen int) // Called after successful auto-copy (for toast notification) | |||
| 16 | } | 14 | } | ||
| 17 | 15 | ||||
| 18 | // NewClipboardHandler creates a new ClipboardHandler. | 16 | // NewClipboardHandler creates a new ClipboardHandler. | ||
| 19 | - | func NewClipboardHandler(clipboard port.Clipboard, autoCopyConfig port.AutoCopyConfig, onCopied func(textLen int)) *ClipboardHandler { | 17 | + | func NewClipboardHandler(orchestrator port.ClipboardTextOrchestrator) *ClipboardHandler { |
| 20 | return &ClipboardHandler{ | 18 | return &ClipboardHandler{ | ||
| 21 | - | clipboard: clipboard, | 19 | + | orchestrator: orchestrator, |
| 22 | - | autoCopyConfig: autoCopyConfig, | |||
| 23 | - | onCopied: onCopied, | |||
| 24 | } | 20 | } | ||
| 25 | } | 21 | } | ||
| 26 | 22 | ||||
| @@ -29,38 +25,57 @@ type autoCopyRequest struct { | |||||
| 29 | Text string `json:"text"` | 25 | Text string `json:"text"` | ||
| 30 | } | 26 | } | ||
| 31 | 27 | ||||
| 28 | + | type explicitCopyRequest struct { | |||
| 29 | + | Text string `json:"text"` | |||
| 30 | + | Action string `json:"action"` | |||
| 31 | + | } | |||
| 32 | + | ||||
| 32 | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | 33 | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | ||
| 33 | // It copies the selected text to the clipboard if the feature is enabled. | 34 | // It copies the selected text to the clipboard if the feature is enabled. | ||
| 34 | func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | 35 | func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | ||
| 35 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 36 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { |
| 36 | log := logging.FromContext(ctx) | 37 | log := logging.FromContext(ctx) | ||
| 37 | 38 | ||||
| 38 | - | // Check if feature is enabled | |||
| 39 | - | if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() { | |||
| 40 | - | return nil, nil | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | var req autoCopyRequest | 39 | var req autoCopyRequest | ||
| 44 | if err := json.Unmarshal(payload, &req); err != nil { | 40 | if err := json.Unmarshal(payload, &req); err != nil { | ||
| 45 | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | 41 | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | ||
| 46 | return nil, nil // Silently ignore malformed requests | 42 | return nil, nil // Silently ignore malformed requests | ||
| 47 | } | 43 | } | ||
| 48 | - | 44 | + | if h.orchestrator == nil { | |
| 49 | - | // Skip empty or very short selections (< 2 chars) | |||
| 50 | - | if len(req.Text) < 2 { | |||
| 51 | return nil, nil | 45 | return nil, nil | ||
| 52 | } | 46 | } | ||
| 53 | - | 47 | + | if err := h.orchestrator.HandleSelectionUpdate(ctx, port.SelectionClipboardInput{ | |
| 54 | - | if err := h.clipboard.WriteText(ctx, req.Text); err != nil { | 48 | + | Text: req.Text, |
| 55 | - | log.Debug().Err(err).Msg("failed to write selection to clipboard") | 49 | + | SourceEngine: port.ClipboardSourceWebKit, |
| 56 | - | return nil, nil // Don't propagate error to JS | 50 | + | ViewID: webviewID, |
| 51 | + | }); err != nil { | |||
| 52 | + | log.Debug().Err(err).Msg("clipboard selection handling failed") | |||
| 57 | } | 53 | } | ||
| 58 | 54 | ||||
| 59 | - | log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard") | 55 | + | return nil, nil |
| 56 | + | }) | |||
| 57 | + | } | |||
| 60 | 58 | ||||
| 61 | - | // Notify UI for toast feedback (if callback is set) | 59 | + | // HandleExplicitCopy handles the explicit_text_copy message from JS. |
| 62 | - | if h.onCopied != nil { | 60 | + | func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler { |
| 63 | - | h.onCopied(len(req.Text)) | 61 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { |
| 62 | + | log := logging.FromContext(ctx) | |||
| 63 | + | ||||
| 64 | + | var req explicitCopyRequest | |||
| 65 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 66 | + | log.Debug().Err(err).Msg("failed to unmarshal explicit copy payload") | |||
| 67 | + | return nil, nil | |||
| 68 | + | } | |||
| 69 | + | if h.orchestrator == nil { | |||
| 70 | + | return nil, nil | |||
| 71 | + | } | |||
| 72 | + | if err := h.orchestrator.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{ | |||
| 73 | + | Text: req.Text, | |||
| 74 | + | Action: req.Action, | |||
| 75 | + | SourceEngine: port.ClipboardSourceWebKit, | |||
| 76 | + | ViewID: webviewID, | |||
| 77 | + | }); err != nil { | |||
| 78 | + | log.Debug().Err(err).Msg("clipboard explicit handling failed") | |||
| 64 | } | 79 | } | ||
| 65 | 80 | ||||
| 66 | return nil, nil | 81 | return nil, nil | ||
| @@ -71,16 +86,17 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | |||||
| 71 | func RegisterClipboardHandlers( | 86 | func RegisterClipboardHandlers( | ||
| 72 | ctx context.Context, | 87 | ctx context.Context, | ||
| 73 | router port.WebUIHandlerRouter, | 88 | router port.WebUIHandlerRouter, | ||
| 74 | - | clipboard port.Clipboard, | 89 | + | orchestrator port.ClipboardTextOrchestrator, |
| 75 | - | autoCopyConfig port.AutoCopyConfig, | |||
| 76 | - | onCopied func(textLen int), | |||
| 77 | ) error { | 90 | ) error { | ||
| 78 | - | handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied) | 91 | + | handler := NewClipboardHandler(orchestrator) |
| 79 | 92 | ||||
| 80 | // Register auto_copy_selection handler | 93 | // Register auto_copy_selection handler | ||
| 81 | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | 94 | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | ||
| 82 | return err | 95 | return err | ||
| 83 | } | 96 | } | ||
| 97 | + | if err := router.RegisterHandler("explicit_text_copy", handler.HandleExplicitCopy()); err != nil { | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 84 | 100 | ||||
| 85 | log := logging.FromContext(ctx) | 101 | log := logging.FromContext(ctx) | ||
| 86 | log.Info().Msg("registered clipboard handlers") | 102 | log.Info().Msg("registered clipboard handlers") | ||
A internal/infrastructure/handlers/clipboard_test.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package handlers | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type recordingClipboardOrchestrator struct { | |
| 13 | + | mu sync.Mutex | |
| 14 | + | selection port.SelectionClipboardInput | |
| 15 | + | explicit port.ExplicitClipboardInput | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |
| 19 | + | r.mu.Lock() | |
| 20 | + | defer r.mu.Unlock() | |
| 21 | + | r.selection = input | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error { | |
| 26 | + | r.mu.Lock() | |
| 27 | + | defer r.mu.Unlock() | |
| 28 | + | r.explicit = input | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *testing.T) { | |
| 33 | + | ctx := context.Background() | |
| 34 | + | orchestrator := &recordingClipboardOrchestrator{} | |
| 35 | + | handler := NewClipboardHandler(orchestrator) | |
| 36 | + | ||
| 37 | + | _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`)) | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatalf("Handle() error = %v, want nil", err) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | orchestrator.mu.Lock() | |
| 43 | + | defer orchestrator.mu.Unlock() | |
| 44 | + | if orchestrator.selection.Text != "selected text" { | |
| 45 | + | t.Fatalf("selection text = %q, want %q", orchestrator.selection.Text, "selected text") | |
| 46 | + | } | |
| 47 | + | if orchestrator.selection.SourceEngine != port.ClipboardSourceWebKit { | |
| 48 | + | t.Fatalf("selection source = %q, want %q", orchestrator.selection.SourceEngine, port.ClipboardSourceWebKit) | |
| 49 | + | } | |
| 50 | + | if orchestrator.selection.ViewID != 42 { | |
| 51 | + | t.Fatalf("selection view id = %d, want %d", orchestrator.selection.ViewID, 42) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) { | |
| 56 | + | ctx := context.Background() | |
| 57 | + | orchestrator := &recordingClipboardOrchestrator{} | |
| 58 | + | handler := NewClipboardHandler(orchestrator) | |
| 59 | + | ||
| 60 | + | _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`)) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatalf("Handle() error = %v, want nil", err) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | orchestrator.mu.Lock() | |
| 66 | + | defer orchestrator.mu.Unlock() | |
| 67 | + | if orchestrator.explicit.Text != "copied text" { | |
| 68 | + | t.Fatalf("explicit text = %q, want %q", orchestrator.explicit.Text, "copied text") | |
| 69 | + | } | |
| 70 | + | if orchestrator.explicit.Action != "copy" { | |
| 71 | + | t.Fatalf("explicit action = %q, want %q", orchestrator.explicit.Action, "copy") | |
| 72 | + | } | |
| 73 | + | if orchestrator.explicit.SourceEngine != port.ClipboardSourceWebKit { | |
| 74 | + | t.Fatalf("explicit source = %q, want %q", orchestrator.explicit.SourceEngine, port.ClipboardSourceWebKit) | |
| 75 | + | } | |
| 76 | + | if orchestrator.explicit.ViewID != 42 { | |
| 77 | + | t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42) | |
| 78 | + | } | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type recordingClipboardOrchestrator struct { | |||
| 13 | + | mu sync.Mutex | |||
| 14 | + | selection port.SelectionClipboardInput | |||
| 15 | + | explicit port.ExplicitClipboardInput | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error { | |||
| 19 | + | r.mu.Lock() | |||
| 20 | + | defer r.mu.Unlock() | |||
| 21 | + | r.selection = input | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error { | |||
| 26 | + | r.mu.Lock() | |||
| 27 | + | defer r.mu.Unlock() | |||
| 28 | + | r.explicit = input | |||
| 29 | + | return nil | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *testing.T) { | |||
| 33 | + | ctx := context.Background() | |||
| 34 | + | orchestrator := &recordingClipboardOrchestrator{} | |||
| 35 | + | handler := NewClipboardHandler(orchestrator) | |||
| 36 | + | ||||
| 37 | + | _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`)) | |||
| 38 | + | if err != nil { | |||
| 39 | + | t.Fatalf("Handle() error = %v, want nil", err) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | orchestrator.mu.Lock() | |||
| 43 | + | defer orchestrator.mu.Unlock() | |||
| 44 | + | if orchestrator.selection.Text != "selected text" { | |||
| 45 | + | t.Fatalf("selection text = %q, want %q", orchestrator.selection.Text, "selected text") | |||
| 46 | + | } | |||
| 47 | + | if orchestrator.selection.SourceEngine != port.ClipboardSourceWebKit { | |||
| 48 | + | t.Fatalf("selection source = %q, want %q", orchestrator.selection.SourceEngine, port.ClipboardSourceWebKit) | |||
| 49 | + | } | |||
| 50 | + | if orchestrator.selection.ViewID != 42 { | |||
| 51 | + | t.Fatalf("selection view id = %d, want %d", orchestrator.selection.ViewID, 42) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) { | |||
| 56 | + | ctx := context.Background() | |||
| 57 | + | orchestrator := &recordingClipboardOrchestrator{} | |||
| 58 | + | handler := NewClipboardHandler(orchestrator) | |||
| 59 | + | ||||
| 60 | + | _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`)) | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Fatalf("Handle() error = %v, want nil", err) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | orchestrator.mu.Lock() | |||
| 66 | + | defer orchestrator.mu.Unlock() | |||
| 67 | + | if orchestrator.explicit.Text != "copied text" { | |||
| 68 | + | t.Fatalf("explicit text = %q, want %q", orchestrator.explicit.Text, "copied text") | |||
| 69 | + | } | |||
| 70 | + | if orchestrator.explicit.Action != "copy" { | |||
| 71 | + | t.Fatalf("explicit action = %q, want %q", orchestrator.explicit.Action, "copy") | |||
| 72 | + | } | |||
| 73 | + | if orchestrator.explicit.SourceEngine != port.ClipboardSourceWebKit { | |||
| 74 | + | t.Fatalf("explicit source = %q, want %q", orchestrator.explicit.SourceEngine, port.ClipboardSourceWebKit) | |||
| 75 | + | } | |||
| 76 | + | if orchestrator.explicit.ViewID != 42 { | |||
| 77 | + | t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42) | |||
| 78 | + | } | |||
| 79 | + | } | |||
M internal/infrastructure/handlers/register.go +2 -2
| @@ -52,8 +52,8 @@ func RegisterAll(ctx context.Context, router port.WebUIHandlerRouter, deps port. | |||
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | // Clipboard handlers (for auto-copy on selection feature) | |
| 55 | - | if deps.Clipboard != nil && deps.AutoCopyConfig != nil { | |
| 56 | - | if err := RegisterClipboardHandlers(ctx, router, deps.Clipboard, deps.AutoCopyConfig, deps.OnClipboardCopied); err != nil { | |
| 55 | + | if deps.ClipboardTextOrchestrator != nil { | |
| 56 | + | if err := RegisterClipboardHandlers(ctx, router, deps.ClipboardTextOrchestrator); err != nil { | |
| 57 | 57 | return err | |
| 58 | 58 | } | |
| 59 | 59 | } | |
| @@ -52,8 +52,8 @@ func RegisterAll(ctx context.Context, router port.WebUIHandlerRouter, deps port. | |||||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | // Clipboard handlers (for auto-copy on selection feature) | 54 | // Clipboard handlers (for auto-copy on selection feature) | ||
| 55 | - | if deps.Clipboard != nil && deps.AutoCopyConfig != nil { | 55 | + | if deps.ClipboardTextOrchestrator != nil { |
| 56 | - | if err := RegisterClipboardHandlers(ctx, router, deps.Clipboard, deps.AutoCopyConfig, deps.OnClipboardCopied); err != nil { | 56 | + | if err := RegisterClipboardHandlers(ctx, router, deps.ClipboardTextOrchestrator); err != nil { |
| 57 | return err | 57 | return err | ||
| 58 | } | 58 | } | ||
| 59 | } | 59 | } | ||
M internal/infrastructure/webkit/context_menu.go +53 -1
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "net/url" | |
| 14 | 14 | "strings" | |
| 15 | 15 | "time" | |
| 16 | + | "unicode/utf8" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/dumber/internal/application/port" | |
| 18 | 19 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -365,6 +366,7 @@ type contextMenuPipeline struct { | |||
| 365 | 366 | builder port.ContextMenuBuilder | |
| 366 | 367 | executorFactory port.ContextMenuActionExecutorFactory | |
| 367 | 368 | clipboard port.Clipboard | |
| 369 | + | onCopied func(textLen int) | |
| 368 | 370 | resolver port.ImageDataResolver | |
| 369 | 371 | saver port.ResolvedImageSaver | |
| 370 | 372 | renderer *Renderer | |
| @@ -374,14 +376,64 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe | |||
| 374 | 376 | if p == nil || p.executorFactory == nil { | |
| 375 | 377 | return nil | |
| 376 | 378 | } | |
| 379 | + | clipboard := p.clipboard | |
| 380 | + | if clipboard != nil && p.onCopied != nil { | |
| 381 | + | clipboard = &clipboardCopyNotifier{clipboard: clipboard, onCopied: p.onCopied} | |
| 382 | + | } | |
| 377 | 383 | return p.executorFactory.NewContextMenuActionExecutor( | |
| 378 | - | p.clipboard, | |
| 384 | + | clipboard, | |
| 379 | 385 | p.resolver, | |
| 380 | 386 | p.saver, | |
| 381 | 387 | &webkitMenuDelegator{wv: wv}, | |
| 382 | 388 | ) | |
| 383 | 389 | } | |
| 384 | 390 | ||
| 391 | + | type clipboardCopyNotifier struct { | |
| 392 | + | clipboard port.Clipboard | |
| 393 | + | onCopied func(textLen int) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func (c *clipboardCopyNotifier) WriteText(ctx context.Context, text string) error { | |
| 397 | + | if c == nil || c.clipboard == nil { | |
| 398 | + | return fmt.Errorf("clipboard not available") | |
| 399 | + | } | |
| 400 | + | if err := c.clipboard.WriteText(ctx, text); err != nil { | |
| 401 | + | return err | |
| 402 | + | } | |
| 403 | + | if c.onCopied != nil { | |
| 404 | + | c.onCopied(utf8.RuneCountInString(text)) | |
| 405 | + | } | |
| 406 | + | return nil | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func (c *clipboardCopyNotifier) WriteImage(ctx context.Context, image entity.ImageData) error { | |
| 410 | + | if c == nil || c.clipboard == nil { | |
| 411 | + | return fmt.Errorf("clipboard not available") | |
| 412 | + | } | |
| 413 | + | return c.clipboard.WriteImage(ctx, image) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func (c *clipboardCopyNotifier) ReadText(ctx context.Context) (string, error) { | |
| 417 | + | if c == nil || c.clipboard == nil { | |
| 418 | + | return "", fmt.Errorf("clipboard not available") | |
| 419 | + | } | |
| 420 | + | return c.clipboard.ReadText(ctx) | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func (c *clipboardCopyNotifier) Clear(ctx context.Context) error { | |
| 424 | + | if c == nil || c.clipboard == nil { | |
| 425 | + | return fmt.Errorf("clipboard not available") | |
| 426 | + | } | |
| 427 | + | return c.clipboard.Clear(ctx) | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func (c *clipboardCopyNotifier) HasText(ctx context.Context) (bool, error) { | |
| 431 | + | if c == nil || c.clipboard == nil { | |
| 432 | + | return false, fmt.Errorf("clipboard not available") | |
| 433 | + | } | |
| 434 | + | return c.clipboard.HasText(ctx) | |
| 435 | + | } | |
| 436 | + | ||
| 385 | 437 | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | |
| 386 | 438 | // shared context menu pipeline. When the user right-clicks, WebKit's native | |
| 387 | 439 | // menu is suppressed and replaced by the shared GTK popover menu. | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "net/url" | 13 | "net/url" | ||
| 14 | "strings" | 14 | "strings" | ||
| 15 | "time" | 15 | "time" | ||
| 16 | + | "unicode/utf8" | |||
| 16 | 17 | ||||
| 17 | "github.com/bnema/dumber/internal/application/port" | 18 | "github.com/bnema/dumber/internal/application/port" | ||
| 18 | "github.com/bnema/dumber/internal/domain/entity" | 19 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -365,6 +366,7 @@ type contextMenuPipeline struct { | |||||
| 365 | builder port.ContextMenuBuilder | 366 | builder port.ContextMenuBuilder | ||
| 366 | executorFactory port.ContextMenuActionExecutorFactory | 367 | executorFactory port.ContextMenuActionExecutorFactory | ||
| 367 | clipboard port.Clipboard | 368 | clipboard port.Clipboard | ||
| 369 | + | onCopied func(textLen int) | |||
| 368 | resolver port.ImageDataResolver | 370 | resolver port.ImageDataResolver | ||
| 369 | saver port.ResolvedImageSaver | 371 | saver port.ResolvedImageSaver | ||
| 370 | renderer *Renderer | 372 | renderer *Renderer | ||
| @@ -374,14 +376,64 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe | |||||
| 374 | if p == nil || p.executorFactory == nil { | 376 | if p == nil || p.executorFactory == nil { | ||
| 375 | return nil | 377 | return nil | ||
| 376 | } | 378 | } | ||
| 379 | + | clipboard := p.clipboard | |||
| 380 | + | if clipboard != nil && p.onCopied != nil { | |||
| 381 | + | clipboard = &clipboardCopyNotifier{clipboard: clipboard, onCopied: p.onCopied} | |||
| 382 | + | } | |||
| 377 | return p.executorFactory.NewContextMenuActionExecutor( | 383 | return p.executorFactory.NewContextMenuActionExecutor( | ||
| 378 | - | p.clipboard, | 384 | + | clipboard, |
| 379 | p.resolver, | 385 | p.resolver, | ||
| 380 | p.saver, | 386 | p.saver, | ||
| 381 | &webkitMenuDelegator{wv: wv}, | 387 | &webkitMenuDelegator{wv: wv}, | ||
| 382 | ) | 388 | ) | ||
| 383 | } | 389 | } | ||
| 384 | 390 | ||||
| 391 | + | type clipboardCopyNotifier struct { | |||
| 392 | + | clipboard port.Clipboard | |||
| 393 | + | onCopied func(textLen int) | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func (c *clipboardCopyNotifier) WriteText(ctx context.Context, text string) error { | |||
| 397 | + | if c == nil || c.clipboard == nil { | |||
| 398 | + | return fmt.Errorf("clipboard not available") | |||
| 399 | + | } | |||
| 400 | + | if err := c.clipboard.WriteText(ctx, text); err != nil { | |||
| 401 | + | return err | |||
| 402 | + | } | |||
| 403 | + | if c.onCopied != nil { | |||
| 404 | + | c.onCopied(utf8.RuneCountInString(text)) | |||
| 405 | + | } | |||
| 406 | + | return nil | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func (c *clipboardCopyNotifier) WriteImage(ctx context.Context, image entity.ImageData) error { | |||
| 410 | + | if c == nil || c.clipboard == nil { | |||
| 411 | + | return fmt.Errorf("clipboard not available") | |||
| 412 | + | } | |||
| 413 | + | return c.clipboard.WriteImage(ctx, image) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | func (c *clipboardCopyNotifier) ReadText(ctx context.Context) (string, error) { | |||
| 417 | + | if c == nil || c.clipboard == nil { | |||
| 418 | + | return "", fmt.Errorf("clipboard not available") | |||
| 419 | + | } | |||
| 420 | + | return c.clipboard.ReadText(ctx) | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func (c *clipboardCopyNotifier) Clear(ctx context.Context) error { | |||
| 424 | + | if c == nil || c.clipboard == nil { | |||
| 425 | + | return fmt.Errorf("clipboard not available") | |||
| 426 | + | } | |||
| 427 | + | return c.clipboard.Clear(ctx) | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func (c *clipboardCopyNotifier) HasText(ctx context.Context) (bool, error) { | |||
| 431 | + | if c == nil || c.clipboard == nil { | |||
| 432 | + | return false, fmt.Errorf("clipboard not available") | |||
| 433 | + | } | |||
| 434 | + | return c.clipboard.HasText(ctx) | |||
| 435 | + | } | |||
| 436 | + | ||||
| 385 | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | 437 | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | ||
| 386 | // shared context menu pipeline. When the user right-clicks, WebKit's native | 438 | // shared context menu pipeline. When the user right-clicks, WebKit's native | ||
| 387 | // menu is suppressed and replaced by the shared GTK popover menu. | 439 | // menu is suppressed and replaced by the shared GTK popover menu. | ||
M internal/infrastructure/webkit/context_menu_test.go +69 -0
| @@ -9,8 +9,11 @@ import ( | |||
| 9 | 9 | "net/http" | |
| 10 | 10 | "net/http/httptest" | |
| 11 | 11 | "testing" | |
| 12 | + | "unicode/utf8" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/dumber/internal/application/port" | |
| 15 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | 17 | "github.com/stretchr/testify/assert" | |
| 15 | 18 | "github.com/stretchr/testify/require" | |
| 16 | 19 | ) | |
| @@ -313,6 +316,42 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) | |||
| 313 | 316 | assert.Contains(t, err.Error(), "action not handled") | |
| 314 | 317 | } | |
| 315 | 318 | ||
| 319 | + | func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | |
| 320 | + | var copiedLen int | |
| 321 | + | baseClipboard := &recordingClipboard{} | |
| 322 | + | factory := &capturingExecutorFactory{} | |
| 323 | + | pipeline := &contextMenuPipeline{ | |
| 324 | + | clipboard: baseClipboard, | |
| 325 | + | onCopied: func(textLen int) { copiedLen = textLen }, | |
| 326 | + | executorFactory: factory, | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | executor := pipeline.newExecutor(&WebView{}) | |
| 330 | + | require.NotNil(t, executor) | |
| 331 | + | ||
| 332 | + | t.Run("copy link", func(t *testing.T) { | |
| 333 | + | copiedLen = 0 | |
| 334 | + | require.NotNil(t, factory.clipboard) | |
| 335 | + | ||
| 336 | + | err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"}) | |
| 337 | + | require.NoError(t, err) | |
| 338 | + | require.Equal(t, "https://example.com/new", baseClipboard.text) | |
| 339 | + | require.Equal(t, 1, baseClipboard.writeTextCalls) | |
| 340 | + | require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen) | |
| 341 | + | }) | |
| 342 | + | ||
| 343 | + | t.Run("copy selection", func(t *testing.T) { | |
| 344 | + | copiedLen = 0 | |
| 345 | + | require.NotNil(t, factory.clipboard) | |
| 346 | + | ||
| 347 | + | err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"}) | |
| 348 | + | require.NoError(t, err) | |
| 349 | + | require.Equal(t, "selected text", baseClipboard.text) | |
| 350 | + | require.Equal(t, 2, baseClipboard.writeTextCalls) | |
| 351 | + | require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen) | |
| 352 | + | }) | |
| 353 | + | } | |
| 354 | + | ||
| 316 | 355 | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | |
| 317 | 356 | // that works with our stub type instead of requiring real WebKit objects. | |
| 318 | 357 | func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext { | |
| @@ -335,6 +374,36 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo | |||
| 335 | 374 | return ctx | |
| 336 | 375 | } | |
| 337 | 376 | ||
| 377 | + | type recordingClipboard struct { | |
| 378 | + | writeTextCalls int | |
| 379 | + | text string | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func (r *recordingClipboard) WriteText(_ context.Context, text string) error { | |
| 383 | + | r.writeTextCalls++ | |
| 384 | + | r.text = text | |
| 385 | + | return nil | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func (*recordingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |
| 389 | + | func (*recordingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |
| 390 | + | func (*recordingClipboard) Clear(context.Context) error { return nil } | |
| 391 | + | func (*recordingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |
| 392 | + | ||
| 393 | + | type capturingExecutorFactory struct { | |
| 394 | + | clipboard port.Clipboard | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | func (f *capturingExecutorFactory) NewContextMenuActionExecutor( | |
| 398 | + | clipboard port.Clipboard, | |
| 399 | + | resolver port.ImageDataResolver, | |
| 400 | + | saver port.ResolvedImageSaver, | |
| 401 | + | delegator port.MenuActionDelegator, | |
| 402 | + | ) port.ContextMenuActionExecutor { | |
| 403 | + | f.clipboard = clipboard | |
| 404 | + | return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |
| 405 | + | } | |
| 406 | + | ||
| 338 | 407 | type roundTripperFunc func(*http.Request) (*http.Response, error) | |
| 339 | 408 | ||
| 340 | 409 | func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |
| @@ -9,8 +9,11 @@ import ( | |||||
| 9 | "net/http" | 9 | "net/http" | ||
| 10 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| 11 | "testing" | 11 | "testing" | ||
| 12 | + | "unicode/utf8" | |||
| 12 | 13 | ||||
| 13 | "github.com/bnema/dumber/internal/application/port" | 14 | "github.com/bnema/dumber/internal/application/port" | ||
| 15 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 14 | "github.com/stretchr/testify/assert" | 17 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 16 | ) | 19 | ) | ||
| @@ -313,6 +316,42 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) | |||||
| 313 | assert.Contains(t, err.Error(), "action not handled") | 316 | assert.Contains(t, err.Error(), "action not handled") | ||
| 314 | } | 317 | } | ||
| 315 | 318 | ||||
| 319 | + | func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | |||
| 320 | + | var copiedLen int | |||
| 321 | + | baseClipboard := &recordingClipboard{} | |||
| 322 | + | factory := &capturingExecutorFactory{} | |||
| 323 | + | pipeline := &contextMenuPipeline{ | |||
| 324 | + | clipboard: baseClipboard, | |||
| 325 | + | onCopied: func(textLen int) { copiedLen = textLen }, | |||
| 326 | + | executorFactory: factory, | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | executor := pipeline.newExecutor(&WebView{}) | |||
| 330 | + | require.NotNil(t, executor) | |||
| 331 | + | ||||
| 332 | + | t.Run("copy link", func(t *testing.T) { | |||
| 333 | + | copiedLen = 0 | |||
| 334 | + | require.NotNil(t, factory.clipboard) | |||
| 335 | + | ||||
| 336 | + | err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"}) | |||
| 337 | + | require.NoError(t, err) | |||
| 338 | + | require.Equal(t, "https://example.com/new", baseClipboard.text) | |||
| 339 | + | require.Equal(t, 1, baseClipboard.writeTextCalls) | |||
| 340 | + | require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen) | |||
| 341 | + | }) | |||
| 342 | + | ||||
| 343 | + | t.Run("copy selection", func(t *testing.T) { | |||
| 344 | + | copiedLen = 0 | |||
| 345 | + | require.NotNil(t, factory.clipboard) | |||
| 346 | + | ||||
| 347 | + | err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"}) | |||
| 348 | + | require.NoError(t, err) | |||
| 349 | + | require.Equal(t, "selected text", baseClipboard.text) | |||
| 350 | + | require.Equal(t, 2, baseClipboard.writeTextCalls) | |||
| 351 | + | require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen) | |||
| 352 | + | }) | |||
| 353 | + | } | |||
| 354 | + | ||||
| 316 | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | 355 | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | ||
| 317 | // that works with our stub type instead of requiring real WebKit objects. | 356 | // that works with our stub type instead of requiring real WebKit objects. | ||
| 318 | func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext { | 357 | func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext { | ||
| @@ -335,6 +374,36 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo | |||||
| 335 | return ctx | 374 | return ctx | ||
| 336 | } | 375 | } | ||
| 337 | 376 | ||||
| 377 | + | type recordingClipboard struct { | |||
| 378 | + | writeTextCalls int | |||
| 379 | + | text string | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func (r *recordingClipboard) WriteText(_ context.Context, text string) error { | |||
| 383 | + | r.writeTextCalls++ | |||
| 384 | + | r.text = text | |||
| 385 | + | return nil | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func (*recordingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |||
| 389 | + | func (*recordingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |||
| 390 | + | func (*recordingClipboard) Clear(context.Context) error { return nil } | |||
| 391 | + | func (*recordingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |||
| 392 | + | ||||
| 393 | + | type capturingExecutorFactory struct { | |||
| 394 | + | clipboard port.Clipboard | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | func (f *capturingExecutorFactory) NewContextMenuActionExecutor( | |||
| 398 | + | clipboard port.Clipboard, | |||
| 399 | + | resolver port.ImageDataResolver, | |||
| 400 | + | saver port.ResolvedImageSaver, | |||
| 401 | + | delegator port.MenuActionDelegator, | |||
| 402 | + | ) port.ContextMenuActionExecutor { | |||
| 403 | + | f.clipboard = clipboard | |||
| 404 | + | return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 405 | + | } | |||
| 406 | + | ||||
| 338 | type roundTripperFunc func(*http.Request) (*http.Response, error) | 407 | type roundTripperFunc func(*http.Request) (*http.Response, error) | ||
| 339 | 408 | ||||
| 340 | func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { | 409 | func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { | ||
M internal/infrastructure/webkit/engine.go +3 -0
| @@ -26,6 +26,7 @@ type Engine struct { | |||
| 26 | 26 | logger zerolog.Logger | |
| 27 | 27 | ctxMenuBuilder port.ContextMenuBuilder | |
| 28 | 28 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 29 | + | onClipboardCopied func(textLen int) | |
| 29 | 30 | downloadPath string | |
| 30 | 31 | downloadPreparer port.DownloadPreparer | |
| 31 | 32 | clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring | |
| @@ -99,6 +100,7 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen | |||
| 99 | 100 | } | |
| 100 | 101 | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | |
| 101 | 102 | e.clipboard = deps.Clipboard | |
| 103 | + | e.onClipboardCopied = deps.OnClipboardCopied | |
| 102 | 104 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | |
| 103 | 105 | } | |
| 104 | 106 | ||
| @@ -179,6 +181,7 @@ func (e *Engine) installContextMenuPipeline() { | |||
| 179 | 181 | builder: e.ctxMenuBuilder, | |
| 180 | 182 | executorFactory: e.ctxMenuExecutorFactory, | |
| 181 | 183 | clipboard: e.clipboard, | |
| 184 | + | onCopied: e.onClipboardCopied, | |
| 182 | 185 | resolver: resolver, | |
| 183 | 186 | saver: saver, | |
| 184 | 187 | renderer: renderer, | |
| @@ -26,6 +26,7 @@ type Engine struct { | |||||
| 26 | logger zerolog.Logger | 26 | logger zerolog.Logger | ||
| 27 | ctxMenuBuilder port.ContextMenuBuilder | 27 | ctxMenuBuilder port.ContextMenuBuilder | ||
| 28 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | 28 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | ||
| 29 | + | onClipboardCopied func(textLen int) | |||
| 29 | downloadPath string | 30 | downloadPath string | ||
| 30 | downloadPreparer port.DownloadPreparer | 31 | downloadPreparer port.DownloadPreparer | ||
| 31 | clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring | 32 | clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring | ||
| @@ -99,6 +100,7 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen | |||||
| 99 | } | 100 | } | ||
| 100 | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | 101 | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | ||
| 101 | e.clipboard = deps.Clipboard | 102 | e.clipboard = deps.Clipboard | ||
| 103 | + | e.onClipboardCopied = deps.OnClipboardCopied | |||
| 102 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | 104 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | ||
| 103 | } | 105 | } | ||
| 104 | 106 | ||||
| @@ -179,6 +181,7 @@ func (e *Engine) installContextMenuPipeline() { | |||||
| 179 | builder: e.ctxMenuBuilder, | 181 | builder: e.ctxMenuBuilder, | ||
| 180 | executorFactory: e.ctxMenuExecutorFactory, | 182 | executorFactory: e.ctxMenuExecutorFactory, | ||
| 181 | clipboard: e.clipboard, | 183 | clipboard: e.clipboard, | ||
| 184 | + | onCopied: e.onClipboardCopied, | |||
| 182 | resolver: resolver, | 185 | resolver: resolver, | ||
| 183 | saver: saver, | 186 | saver: saver, | ||
| 184 | renderer: renderer, | 187 | renderer: renderer, | ||
M internal/infrastructure/webkit/injector.go +89 -6
| @@ -59,10 +59,81 @@ const autoCopySelectionScript = `(function() { | |||
| 59 | 59 | }); | |
| 60 | 60 | })();` | |
| 61 | 61 | ||
| 62 | + | // explicitCopyScript captures explicit copy/cut operations and forwards them to Go. | |
| 63 | + | const explicitCopyScript = `(function() { | |
| 64 | + | var pendingCommand = ''; | |
| 65 | + | ||
| 66 | + | function selectedText() { | |
| 67 | + | var activeEl = document.activeElement; | |
| 68 | + | if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) { | |
| 69 | + | if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') { | |
| 70 | + | var start = activeEl.selectionStart; | |
| 71 | + | var end = activeEl.selectionEnd; | |
| 72 | + | if (end > start) { | |
| 73 | + | return activeEl.value.slice(start, end); | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | var selection = window.getSelection ? window.getSelection() : null; | |
| 78 | + | return selection ? selection.toString() : ''; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | function postExplicitCopy(action, text) { | |
| 82 | + | if (!text) { | |
| 83 | + | return; | |
| 84 | + | } | |
| 85 | + | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) { | |
| 86 | + | window.webkit.messageHandlers.dumber.postMessage({ | |
| 87 | + | type: 'explicit_text_copy', | |
| 88 | + | payload: { text: text, action: action } | |
| 89 | + | }); | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | function consumePendingAction(fallback) { | |
| 94 | + | var action = pendingCommand || fallback; | |
| 95 | + | pendingCommand = ''; | |
| 96 | + | return action; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | document.addEventListener('copy', function() { | |
| 100 | + | postExplicitCopy(consumePendingAction('copy'), selectedText()); | |
| 101 | + | }, true); | |
| 102 | + | ||
| 103 | + | document.addEventListener('cut', function() { | |
| 104 | + | postExplicitCopy(consumePendingAction('cut'), selectedText()); | |
| 105 | + | }, true); | |
| 106 | + | ||
| 107 | + | if (document.execCommand) { | |
| 108 | + | var originalExecCommand = document.execCommand.bind(document); | |
| 109 | + | document.execCommand = function(commandId, showUI, value) { | |
| 110 | + | var command = String(commandId || '').toLowerCase(); | |
| 111 | + | if (command === 'copy' || command === 'cut') { | |
| 112 | + | pendingCommand = command; | |
| 113 | + | } | |
| 114 | + | return originalExecCommand(commandId, showUI, value); | |
| 115 | + | }; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { | |
| 119 | + | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | |
| 120 | + | navigator.clipboard.writeText = function(text) { | |
| 121 | + | return Promise.resolve(originalWriteText(text)).then(function(result) { | |
| 122 | + | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | |
| 123 | + | return result; | |
| 124 | + | }); | |
| 125 | + | }; | |
| 126 | + | } | |
| 127 | + | })();` | |
| 128 | + | ||
| 62 | 129 | // accentDetectionScript is built at init from entity.AccentMap so the JS | |
| 63 | 130 | // filter stays in sync with the Go-side accent table. | |
| 64 | 131 | var accentDetectionScript string | |
| 65 | 132 | ||
| 133 | + | func buildExplicitCopyScript() string { | |
| 134 | + | return explicitCopyScript | |
| 135 | + | } | |
| 136 | + | ||
| 66 | 137 | func init() { | |
| 67 | 138 | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | |
| 68 | 139 | keys := make([]string, 0, len(entity.AccentMap)) | |
| @@ -229,7 +300,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 229 | 300 | "webrtc-compat-shim", | |
| 230 | 301 | ) | |
| 231 | 302 | ||
| 232 | - | // 2. Inject WebView ID for debugging (internal pages only) | |
| 303 | + | // 2. Inject explicit copy capture for all pages. | |
| 304 | + | addScript( | |
| 305 | + | webkit.NewUserScript( | |
| 306 | + | buildExplicitCopyScript(), | |
| 307 | + | webkit.UserContentInjectAllFramesValue, | |
| 308 | + | webkit.UserScriptInjectAtDocumentStartValue, | |
| 309 | + | nil, | |
| 310 | + | nil, | |
| 311 | + | ), | |
| 312 | + | "explicit-copy-capture", | |
| 313 | + | ) | |
| 314 | + | ||
| 315 | + | // 3. Inject WebView ID for debugging (internal pages only) | |
| 233 | 316 | if webviewID != 0 { | |
| 234 | 317 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | |
| 235 | 318 | addScript( | |
| @@ -247,7 +330,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 247 | 330 | log.Warn().Msg("webview ID is 0, skipping ID injection") | |
| 248 | 331 | } | |
| 249 | 332 | ||
| 250 | - | // 3. Inject dark mode handler for internal pages only | |
| 333 | + | // 4. Inject dark mode handler for internal pages only | |
| 251 | 334 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | |
| 252 | 335 | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") | |
| 253 | 336 | addScript( | |
| @@ -261,7 +344,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 261 | 344 | "internal-dark-mode-handler", | |
| 262 | 345 | ) | |
| 263 | 346 | ||
| 264 | - | // 4. Inject theme CSS for internal pages (dumb://* only) | |
| 347 | + | // 5. Inject theme CSS for internal pages (dumb://* only) | |
| 265 | 348 | if ci.themeCSSVars != "" { | |
| 266 | 349 | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) | |
| 267 | 350 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | |
| @@ -278,7 +361,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 278 | 361 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | |
| 279 | 362 | } | |
| 280 | 363 | ||
| 281 | - | // 5. Inject find highlight CSS for all pages | |
| 364 | + | // 6. Inject find highlight CSS for all pages | |
| 282 | 365 | if ci.findCSS != "" { | |
| 283 | 366 | stylesheet := webkit.NewUserStyleSheet( | |
| 284 | 367 | ci.findCSS, | |
| @@ -295,7 +378,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 295 | 378 | } | |
| 296 | 379 | } | |
| 297 | 380 | ||
| 298 | - | // 6. Inject auto-copy selection script for all pages (if enabled) | |
| 381 | + | // 7. Inject auto-copy selection script for all pages (if enabled) | |
| 299 | 382 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | |
| 300 | 383 | if autoCopyEnabled { | |
| 301 | 384 | addScript( | |
| @@ -311,7 +394,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 311 | 394 | log.Debug().Msg("auto-copy selection script injected") | |
| 312 | 395 | } | |
| 313 | 396 | ||
| 314 | - | // 7. Inject accent key detection for all pages and all frames (unconditional). | |
| 397 | + | // 8. Inject accent key detection for all pages and all frames (unconditional). | |
| 315 | 398 | // JS only reports keydown/keyup events; Go handles timing and picker display. | |
| 316 | 399 | addScript( | |
| 317 | 400 | webkit.NewUserScript( | |
| @@ -59,10 +59,81 @@ const autoCopySelectionScript = `(function() { | |||||
| 59 | }); | 59 | }); | ||
| 60 | })();` | 60 | })();` | ||
| 61 | 61 | ||||
| 62 | + | // explicitCopyScript captures explicit copy/cut operations and forwards them to Go. | |||
| 63 | + | const explicitCopyScript = `(function() { | |||
| 64 | + | var pendingCommand = ''; | |||
| 65 | + | ||||
| 66 | + | function selectedText() { | |||
| 67 | + | var activeEl = document.activeElement; | |||
| 68 | + | if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA')) { | |||
| 69 | + | if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') { | |||
| 70 | + | var start = activeEl.selectionStart; | |||
| 71 | + | var end = activeEl.selectionEnd; | |||
| 72 | + | if (end > start) { | |||
| 73 | + | return activeEl.value.slice(start, end); | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | var selection = window.getSelection ? window.getSelection() : null; | |||
| 78 | + | return selection ? selection.toString() : ''; | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | function postExplicitCopy(action, text) { | |||
| 82 | + | if (!text) { | |||
| 83 | + | return; | |||
| 84 | + | } | |||
| 85 | + | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) { | |||
| 86 | + | window.webkit.messageHandlers.dumber.postMessage({ | |||
| 87 | + | type: 'explicit_text_copy', | |||
| 88 | + | payload: { text: text, action: action } | |||
| 89 | + | }); | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | function consumePendingAction(fallback) { | |||
| 94 | + | var action = pendingCommand || fallback; | |||
| 95 | + | pendingCommand = ''; | |||
| 96 | + | return action; | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | document.addEventListener('copy', function() { | |||
| 100 | + | postExplicitCopy(consumePendingAction('copy'), selectedText()); | |||
| 101 | + | }, true); | |||
| 102 | + | ||||
| 103 | + | document.addEventListener('cut', function() { | |||
| 104 | + | postExplicitCopy(consumePendingAction('cut'), selectedText()); | |||
| 105 | + | }, true); | |||
| 106 | + | ||||
| 107 | + | if (document.execCommand) { | |||
| 108 | + | var originalExecCommand = document.execCommand.bind(document); | |||
| 109 | + | document.execCommand = function(commandId, showUI, value) { | |||
| 110 | + | var command = String(commandId || '').toLowerCase(); | |||
| 111 | + | if (command === 'copy' || command === 'cut') { | |||
| 112 | + | pendingCommand = command; | |||
| 113 | + | } | |||
| 114 | + | return originalExecCommand(commandId, showUI, value); | |||
| 115 | + | }; | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { | |||
| 119 | + | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | |||
| 120 | + | navigator.clipboard.writeText = function(text) { | |||
| 121 | + | return Promise.resolve(originalWriteText(text)).then(function(result) { | |||
| 122 | + | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | |||
| 123 | + | return result; | |||
| 124 | + | }); | |||
| 125 | + | }; | |||
| 126 | + | } | |||
| 127 | + | })();` | |||
| 128 | + | ||||
| 62 | // accentDetectionScript is built at init from entity.AccentMap so the JS | 129 | // accentDetectionScript is built at init from entity.AccentMap so the JS | ||
| 63 | // filter stays in sync with the Go-side accent table. | 130 | // filter stays in sync with the Go-side accent table. | ||
| 64 | var accentDetectionScript string | 131 | var accentDetectionScript string | ||
| 65 | 132 | ||||
| 133 | + | func buildExplicitCopyScript() string { | |||
| 134 | + | return explicitCopyScript | |||
| 135 | + | } | |||
| 136 | + | ||||
| 66 | func init() { | 137 | func init() { | ||
| 67 | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | 138 | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | ||
| 68 | keys := make([]string, 0, len(entity.AccentMap)) | 139 | keys := make([]string, 0, len(entity.AccentMap)) | ||
| @@ -229,7 +300,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 229 | "webrtc-compat-shim", | 300 | "webrtc-compat-shim", | ||
| 230 | ) | 301 | ) | ||
| 231 | 302 | ||||
| 232 | - | // 2. Inject WebView ID for debugging (internal pages only) | 303 | + | // 2. Inject explicit copy capture for all pages. |
| 304 | + | addScript( | |||
| 305 | + | webkit.NewUserScript( | |||
| 306 | + | buildExplicitCopyScript(), | |||
| 307 | + | webkit.UserContentInjectAllFramesValue, | |||
| 308 | + | webkit.UserScriptInjectAtDocumentStartValue, | |||
| 309 | + | nil, | |||
| 310 | + | nil, | |||
| 311 | + | ), | |||
| 312 | + | "explicit-copy-capture", | |||
| 313 | + | ) | |||
| 314 | + | ||||
| 315 | + | // 3. Inject WebView ID for debugging (internal pages only) | |||
| 233 | if webviewID != 0 { | 316 | if webviewID != 0 { | ||
| 234 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | 317 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | ||
| 235 | addScript( | 318 | addScript( | ||
| @@ -247,7 +330,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 247 | log.Warn().Msg("webview ID is 0, skipping ID injection") | 330 | log.Warn().Msg("webview ID is 0, skipping ID injection") | ||
| 248 | } | 331 | } | ||
| 249 | 332 | ||||
| 250 | - | // 3. Inject dark mode handler for internal pages only | 333 | + | // 4. Inject dark mode handler for internal pages only |
| 251 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | 334 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | ||
| 252 | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") | 335 | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") | ||
| 253 | addScript( | 336 | addScript( | ||
| @@ -261,7 +344,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 261 | "internal-dark-mode-handler", | 344 | "internal-dark-mode-handler", | ||
| 262 | ) | 345 | ) | ||
| 263 | 346 | ||||
| 264 | - | // 4. Inject theme CSS for internal pages (dumb://* only) | 347 | + | // 5. Inject theme CSS for internal pages (dumb://* only) |
| 265 | if ci.themeCSSVars != "" { | 348 | if ci.themeCSSVars != "" { | ||
| 266 | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) | 349 | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) | ||
| 267 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | 350 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | ||
| @@ -278,7 +361,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 278 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | 361 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | ||
| 279 | } | 362 | } | ||
| 280 | 363 | ||||
| 281 | - | // 5. Inject find highlight CSS for all pages | 364 | + | // 6. Inject find highlight CSS for all pages |
| 282 | if ci.findCSS != "" { | 365 | if ci.findCSS != "" { | ||
| 283 | stylesheet := webkit.NewUserStyleSheet( | 366 | stylesheet := webkit.NewUserStyleSheet( | ||
| 284 | ci.findCSS, | 367 | ci.findCSS, | ||
| @@ -295,7 +378,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 295 | } | 378 | } | ||
| 296 | } | 379 | } | ||
| 297 | 380 | ||||
| 298 | - | // 6. Inject auto-copy selection script for all pages (if enabled) | 381 | + | // 7. Inject auto-copy selection script for all pages (if enabled) |
| 299 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | 382 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | ||
| 300 | if autoCopyEnabled { | 383 | if autoCopyEnabled { | ||
| 301 | addScript( | 384 | addScript( | ||
| @@ -311,7 +394,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 311 | log.Debug().Msg("auto-copy selection script injected") | 394 | log.Debug().Msg("auto-copy selection script injected") | ||
| 312 | } | 395 | } | ||
| 313 | 396 | ||||
| 314 | - | // 7. Inject accent key detection for all pages and all frames (unconditional). | 397 | + | // 8. Inject accent key detection for all pages and all frames (unconditional). |
| 315 | // JS only reports keydown/keyup events; Go handles timing and picker display. | 398 | // JS only reports keydown/keyup events; Go handles timing and picker display. | ||
| 316 | addScript( | 399 | addScript( | ||
| 317 | webkit.NewUserScript( | 400 | webkit.NewUserScript( | ||
M internal/infrastructure/webkit/injector_accent_test.go +20 -0
| @@ -30,3 +30,23 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) { | |||
| 30 | 30 | "accent detection script must register a focusin listener", | |
| 31 | 31 | ) | |
| 32 | 32 | } | |
| 33 | + | ||
| 34 | + | func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | |
| 35 | + | script := buildExplicitCopyScript() | |
| 36 | + | ||
| 37 | + | assert.Contains(t, script, "explicit_text_copy") | |
| 38 | + | assert.Contains(t, script, "document.addEventListener('copy'") | |
| 39 | + | assert.Contains(t, script, "document.addEventListener('cut'") | |
| 40 | + | assert.Contains(t, script, "document.execCommand") | |
| 41 | + | assert.Contains(t, script, "navigator.clipboard.writeText") | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) { | |
| 45 | + | script := buildExplicitCopyScript() | |
| 46 | + | ||
| 47 | + | assert.Contains(t, script, "document.activeElement") | |
| 48 | + | assert.Contains(t, script, "selectionStart") | |
| 49 | + | assert.Contains(t, script, "selectionEnd") | |
| 50 | + | assert.Contains(t, script, "INPUT") | |
| 51 | + | assert.Contains(t, script, "TEXTAREA") | |
| 52 | + | } | |
| @@ -30,3 +30,23 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) { | |||||
| 30 | "accent detection script must register a focusin listener", | 30 | "accent detection script must register a focusin listener", | ||
| 31 | ) | 31 | ) | ||
| 32 | } | 32 | } | ||
| 33 | + | ||||
| 34 | + | func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | |||
| 35 | + | script := buildExplicitCopyScript() | |||
| 36 | + | ||||
| 37 | + | assert.Contains(t, script, "explicit_text_copy") | |||
| 38 | + | assert.Contains(t, script, "document.addEventListener('copy'") | |||
| 39 | + | assert.Contains(t, script, "document.addEventListener('cut'") | |||
| 40 | + | assert.Contains(t, script, "document.execCommand") | |||
| 41 | + | assert.Contains(t, script, "navigator.clipboard.writeText") | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) { | |||
| 45 | + | script := buildExplicitCopyScript() | |||
| 46 | + | ||||
| 47 | + | assert.Contains(t, script, "document.activeElement") | |||
| 48 | + | assert.Contains(t, script, "selectionStart") | |||
| 49 | + | assert.Contains(t, script, "selectionEnd") | |||
| 50 | + | assert.Contains(t, script, "INPUT") | |||
| 51 | + | assert.Contains(t, script, "TEXTAREA") | |||
| 52 | + | } | |||
M internal/ui/app.go +27 -10
| @@ -37,7 +37,8 @@ import ( | |||
| 37 | 37 | ||
| 38 | 38 | const ( | |
| 39 | 39 | // AppID is the application identifier for GTK. | |
| 40 | - | AppID = "com.github.bnema.dumber" | |
| 40 | + | AppID = "com.github.bnema.dumber" | |
| 41 | + | appTitle = "Dumber" | |
| 41 | 42 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | |
| 42 | 43 | crashReportToastDurationMs = 5000 | |
| 43 | 44 | floatingPaneFallbackWidth = 1200 | |
| @@ -181,17 +182,33 @@ func New(deps *Dependencies) (*App, error) { | |||
| 181 | 182 | engine: deps.Engine, | |
| 182 | 183 | cancel: cancel, | |
| 183 | 184 | } | |
| 184 | - | // Register message handlers through the engine. | |
| 185 | - | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |
| 186 | - | HistoryUC: deps.HistoryUC, | |
| 187 | - | FavoritesUC: deps.FavoritesUC, | |
| 188 | - | Clipboard: deps.Clipboard, | |
| 189 | - | AutoCopyConfig: &autoCopyConfigFn{fn: func() bool { | |
| 185 | + | var autoCopyConfig port.AutoCopyConfig | |
| 186 | + | var clipboardOrchestrator port.ClipboardTextOrchestrator | |
| 187 | + | if deps.Clipboard != nil { | |
| 188 | + | autoCopyConfig = &autoCopyConfigFn{fn: func() bool { | |
| 190 | 189 | if deps.Config == nil { | |
| 191 | 190 | return false | |
| 192 | 191 | } | |
| 193 | 192 | return deps.Config.Clipboard.AutoCopyOnSelection | |
| 194 | - | }}, | |
| 193 | + | }} | |
| 194 | + | clipboardOrchestrator = usecase.NewClipboardTextOrchestrator(deps.Clipboard, autoCopyConfig, func(textLen int) { | |
| 195 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 196 | + | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | |
| 197 | + | component.WithDuration(component.ToastBriefDurationMs), | |
| 198 | + | component.WithPosition(component.ToastPositionBottomRight), | |
| 199 | + | ) | |
| 200 | + | return false | |
| 201 | + | }) | |
| 202 | + | glib.IdleAdd(&cb, 0) | |
| 203 | + | }) | |
| 204 | + | } | |
| 205 | + | // Register message handlers through the engine. | |
| 206 | + | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |
| 207 | + | HistoryUC: deps.HistoryUC, | |
| 208 | + | FavoritesUC: deps.FavoritesUC, | |
| 209 | + | Clipboard: deps.Clipboard, | |
| 210 | + | AutoCopyConfig: autoCopyConfig, | |
| 211 | + | ClipboardTextOrchestrator: clipboardOrchestrator, | |
| 195 | 212 | OnClipboardCopied: func(textLen int) { | |
| 196 | 213 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 197 | 214 | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | |
| @@ -2339,9 +2356,9 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |||
| 2339 | 2356 | return | |
| 2340 | 2357 | } | |
| 2341 | 2358 | ||
| 2342 | - | title := "Dumber" | |
| 2359 | + | title := appTitle | |
| 2343 | 2360 | if pageTitle != "" { | |
| 2344 | - | title = pageTitle + " - Dumber" | |
| 2361 | + | title = pageTitle + " - " + appTitle | |
| 2345 | 2362 | } | |
| 2346 | 2363 | target.mainWindow.SetTitle(title) | |
| 2347 | 2364 | } | |
| @@ -37,7 +37,8 @@ import ( | |||||
| 37 | 37 | ||||
| 38 | const ( | 38 | const ( | ||
| 39 | // AppID is the application identifier for GTK. | 39 | // AppID is the application identifier for GTK. | ||
| 40 | - | AppID = "com.github.bnema.dumber" | 40 | + | AppID = "com.github.bnema.dumber" |
| 41 | + | appTitle = "Dumber" | |||
| 41 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | 42 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | ||
| 42 | crashReportToastDurationMs = 5000 | 43 | crashReportToastDurationMs = 5000 | ||
| 43 | floatingPaneFallbackWidth = 1200 | 44 | floatingPaneFallbackWidth = 1200 | ||
| @@ -181,17 +182,33 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 181 | engine: deps.Engine, | 182 | engine: deps.Engine, | ||
| 182 | cancel: cancel, | 183 | cancel: cancel, | ||
| 183 | } | 184 | } | ||
| 184 | - | // Register message handlers through the engine. | 185 | + | var autoCopyConfig port.AutoCopyConfig |
| 185 | - | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | 186 | + | var clipboardOrchestrator port.ClipboardTextOrchestrator |
| 186 | - | HistoryUC: deps.HistoryUC, | 187 | + | if deps.Clipboard != nil { |
| 187 | - | FavoritesUC: deps.FavoritesUC, | 188 | + | autoCopyConfig = &autoCopyConfigFn{fn: func() bool { |
| 188 | - | Clipboard: deps.Clipboard, | |||
| 189 | - | AutoCopyConfig: &autoCopyConfigFn{fn: func() bool { | |||
| 190 | if deps.Config == nil { | 189 | if deps.Config == nil { | ||
| 191 | return false | 190 | return false | ||
| 192 | } | 191 | } | ||
| 193 | return deps.Config.Clipboard.AutoCopyOnSelection | 192 | return deps.Config.Clipboard.AutoCopyOnSelection | ||
| 194 | - | }}, | 193 | + | }} |
| 194 | + | clipboardOrchestrator = usecase.NewClipboardTextOrchestrator(deps.Clipboard, autoCopyConfig, func(textLen int) { | |||
| 195 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 196 | + | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | |||
| 197 | + | component.WithDuration(component.ToastBriefDurationMs), | |||
| 198 | + | component.WithPosition(component.ToastPositionBottomRight), | |||
| 199 | + | ) | |||
| 200 | + | return false | |||
| 201 | + | }) | |||
| 202 | + | glib.IdleAdd(&cb, 0) | |||
| 203 | + | }) | |||
| 204 | + | } | |||
| 205 | + | // Register message handlers through the engine. | |||
| 206 | + | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |||
| 207 | + | HistoryUC: deps.HistoryUC, | |||
| 208 | + | FavoritesUC: deps.FavoritesUC, | |||
| 209 | + | Clipboard: deps.Clipboard, | |||
| 210 | + | AutoCopyConfig: autoCopyConfig, | |||
| 211 | + | ClipboardTextOrchestrator: clipboardOrchestrator, | |||
| 195 | OnClipboardCopied: func(textLen int) { | 212 | OnClipboardCopied: func(textLen int) { | ||
| 196 | cb := glib.SourceFunc(func(_ uintptr) bool { | 213 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 197 | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | 214 | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | ||
| @@ -2339,9 +2356,9 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |||||
| 2339 | return | 2356 | return | ||
| 2340 | } | 2357 | } | ||
| 2341 | 2358 | ||||
| 2342 | - | title := "Dumber" | 2359 | + | title := appTitle |
| 2343 | if pageTitle != "" { | 2360 | if pageTitle != "" { | ||
| 2344 | - | title = pageTitle + " - Dumber" | 2361 | + | title = pageTitle + " - " + appTitle |
| 2345 | } | 2362 | } | ||
| 2346 | target.mainWindow.SetTitle(title) | 2363 | target.mainWindow.SetTitle(title) | ||
| 2347 | } | 2364 | } | ||
A internal/ui/app_clipboard_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type fakeClipboardEngine struct { | |
| 14 | + | registerDeps port.HandlerDependencies | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | var _ port.Engine = (*fakeClipboardEngine)(nil) | |
| 18 | + | ||
| 19 | + | func (f *fakeClipboardEngine) Factory() port.WebViewFactory { return nil } | |
| 20 | + | func (f *fakeClipboardEngine) Pool() port.WebViewPool { return nil } | |
| 21 | + | func (f *fakeClipboardEngine) ContentInjector() port.ContentInjector { return nil } | |
| 22 | + | func (f *fakeClipboardEngine) SettingsApplier() port.SettingsApplier { return nil } | |
| 23 | + | func (f *fakeClipboardEngine) FilterApplier() port.FilterApplier { return nil } | |
| 24 | + | func (f *fakeClipboardEngine) FaviconDatabase() port.FaviconDatabase { return nil } | |
| 25 | + | func (f *fakeClipboardEngine) InternalSchemePath() string { return "" } | |
| 26 | + | func (f *fakeClipboardEngine) Close() error { return nil } | |
| 27 | + | func (f *fakeClipboardEngine) RegisterHandlers(_ context.Context, deps port.HandlerDependencies) error { | |
| 28 | + | f.registerDeps = deps | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | func (f *fakeClipboardEngine) RegisterAccentHandlers(context.Context, port.AccentKeyHandler) error { | |
| 32 | + | return nil | |
| 33 | + | } | |
| 34 | + | func (f *fakeClipboardEngine) ConfigureDownloads(context.Context, string, port.DownloadEventHandler, port.DownloadPreparer) error { | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | func (f *fakeClipboardEngine) OnToolkitReady(context.Context) error { return nil } | |
| 38 | + | func (f *fakeClipboardEngine) UpdateAppearance(context.Context, float64, float64, float64, float64) error { | |
| 39 | + | return nil | |
| 40 | + | } | |
| 41 | + | func (f *fakeClipboardEngine) UpdateSettings(context.Context, port.EngineSettingsUpdate) error { | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | func (f *fakeClipboardEngine) SetHandlerContext(context.Context) {} | |
| 45 | + | ||
| 46 | + | func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) { | |
| 47 | + | ctx := context.Background() | |
| 48 | + | engine := &fakeClipboardEngine{} | |
| 49 | + | ||
| 50 | + | deps := &Dependencies{ | |
| 51 | + | Ctx: ctx, | |
| 52 | + | Config: &config.Config{}, | |
| 53 | + | Engine: engine, | |
| 54 | + | HandlerDeps: port.HandlerDeps{ | |
| 55 | + | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | |
| 56 | + | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |
| 57 | + | }, | |
| 58 | + | Clipboard: nil, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | app, err := New(deps) | |
| 62 | + | require.NoError(t, err) | |
| 63 | + | require.NotNil(t, app) | |
| 64 | + | require.Nil(t, engine.registerDeps.ClipboardTextOrchestrator) | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type fakeClipboardEngine struct { | |||
| 14 | + | registerDeps port.HandlerDependencies | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | var _ port.Engine = (*fakeClipboardEngine)(nil) | |||
| 18 | + | ||||
| 19 | + | func (f *fakeClipboardEngine) Factory() port.WebViewFactory { return nil } | |||
| 20 | + | func (f *fakeClipboardEngine) Pool() port.WebViewPool { return nil } | |||
| 21 | + | func (f *fakeClipboardEngine) ContentInjector() port.ContentInjector { return nil } | |||
| 22 | + | func (f *fakeClipboardEngine) SettingsApplier() port.SettingsApplier { return nil } | |||
| 23 | + | func (f *fakeClipboardEngine) FilterApplier() port.FilterApplier { return nil } | |||
| 24 | + | func (f *fakeClipboardEngine) FaviconDatabase() port.FaviconDatabase { return nil } | |||
| 25 | + | func (f *fakeClipboardEngine) InternalSchemePath() string { return "" } | |||
| 26 | + | func (f *fakeClipboardEngine) Close() error { return nil } | |||
| 27 | + | func (f *fakeClipboardEngine) RegisterHandlers(_ context.Context, deps port.HandlerDependencies) error { | |||
| 28 | + | f.registerDeps = deps | |||
| 29 | + | return nil | |||
| 30 | + | } | |||
| 31 | + | func (f *fakeClipboardEngine) RegisterAccentHandlers(context.Context, port.AccentKeyHandler) error { | |||
| 32 | + | return nil | |||
| 33 | + | } | |||
| 34 | + | func (f *fakeClipboardEngine) ConfigureDownloads(context.Context, string, port.DownloadEventHandler, port.DownloadPreparer) error { | |||
| 35 | + | return nil | |||
| 36 | + | } | |||
| 37 | + | func (f *fakeClipboardEngine) OnToolkitReady(context.Context) error { return nil } | |||
| 38 | + | func (f *fakeClipboardEngine) UpdateAppearance(context.Context, float64, float64, float64, float64) error { | |||
| 39 | + | return nil | |||
| 40 | + | } | |||
| 41 | + | func (f *fakeClipboardEngine) UpdateSettings(context.Context, port.EngineSettingsUpdate) error { | |||
| 42 | + | return nil | |||
| 43 | + | } | |||
| 44 | + | func (f *fakeClipboardEngine) SetHandlerContext(context.Context) {} | |||
| 45 | + | ||||
| 46 | + | func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) { | |||
| 47 | + | ctx := context.Background() | |||
| 48 | + | engine := &fakeClipboardEngine{} | |||
| 49 | + | ||||
| 50 | + | deps := &Dependencies{ | |||
| 51 | + | Ctx: ctx, | |||
| 52 | + | Config: &config.Config{}, | |||
| 53 | + | Engine: engine, | |||
| 54 | + | HandlerDeps: port.HandlerDeps{ | |||
| 55 | + | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | |||
| 56 | + | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |||
| 57 | + | }, | |||
| 58 | + | Clipboard: nil, | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | app, err := New(deps) | |||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | require.NotNil(t, app) | |||
| 64 | + | require.Nil(t, engine.registerDeps.ClipboardTextOrchestrator) | |||
| 65 | + | } | |||