detached ee4aed5

refactor(clipboard): unify engine clipboard handling

commit
ee4aed57df635aa19ffd1753fbd8428fe1374222
author
brice · · 2026-04-17 14:45 UTC
parent
2dc133d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
36 files · +2219 -178
M cmd/cef-helper/main.go +4 -4
@@ -13,6 +13,7 @@ import (
1313 "os"
1414 "runtime"
1515
16+ infracef "github.com/bnema/dumber/internal/infrastructure/cef"
1617 cef "github.com/bnema/purego-cef/cef"
1718 )
1819
@@ -22,10 +23,9 @@ func main() {
2223
2324 // cef_execute_process will handle the subprocess lifecycle and call
2425 // 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())
2929
3030 // If we get here, something went wrong.
3131 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 call24 // 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't26+ // We pass the lightweight subprocess app so renderer-process handlers
26- // happen in normal operation since CEF only launches this binary27+ // (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 (
6969 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
7070 modernc.org/sqlite v1.47.0 // indirect
7171 )
72+
73+replace github.com/bnema/purego-cef => ../purego-cef
@@ -69,3 +69,5 @@ require (
69 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect69 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
70 modernc.org/sqlite v1.47.0 // indirect70 modernc.org/sqlite v1.47.0 // indirect
71 )71 )
72+
73+replace github.com/bnema/purego-cef => ../purego-cef
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 (
1919
2020 // MenuContext captures the state needed to build and execute a context menu.
2121 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
3132 }
3233
3334 // 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 string22+ PageURI string
23- LinkURI string23+ LinkURI string
24- ImageURI string24+ ImageURI string
25- HasSelection bool25+ SelectionText string
26- IsEditable bool26+ HasSelection bool
27- CanGoBack bool27+ IsEditable bool
28- CanGoForward bool28+ CanGoBack bool
29- X int29+ CanGoForward bool
30- Y int30+ 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 {
193193
194194 // HandlerDependencies holds use cases needed by WebUI message handlers.
195195 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)
201202 HandlerDeps
202203 }
203204
@@ -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 HomepageHistory196+ HistoryUC HomepageHistory
197- FavoritesUC HomepageFavorites197+ FavoritesUC HomepageFavorites
198- Clipboard Clipboard198+ Clipboard Clipboard
199- AutoCopyConfig AutoCopyConfig199+ AutoCopyConfig AutoCopyConfig
200- OnClipboardCopied func(textLen int)200+ ClipboardTextOrchestrator ClipboardTextOrchestrator
201+ OnClipboardCopied func(textLen int)
201 HandlerDeps202 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(
105105 return fmt.Errorf("save image: %w", err)
106106 }
107107 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
112117 }
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
117118 }
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
118128 }
119129
120130 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 nil107 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) {
217217 require.Contains(t, err.Error(), "boom")
218218 }
219219
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+
220241 func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {
221242 clipboard := &fakeClipboard{}
222243 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) {
4545 type dumberApp struct {
4646 engine *Engine
4747 bph purecef.BrowserProcessHandler
48+ rph purecef.RenderProcessHandler
4849 }
4950
5051 // newDumberApp creates an App whose BrowserProcessHandler handles context
@@ -53,13 +54,15 @@ type dumberApp struct {
5354 func newDumberApp(engine *Engine) purecef.App {
5455 app := &dumberApp{engine: engine}
5556 app.bph = &dumberBPH{engine: engine}
57+ app.rph = newRendererBridgeProcessHandler()
5658 return app
5759 }
5860
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.
6164 func NewSubprocessApp() purecef.App {
62- return purecef.NewApp(&subprocessApp{})
65+ return &subprocessApp{rph: newRendererBridgeProcessHandler()}
6366 }
6467
6568 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
@@ -89,9 +92,9 @@ func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
8992 }
9093 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
9194 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 }
9396
94-type subprocessApp struct{}
97+type subprocessApp struct{ rph purecef.RenderProcessHandler }
9598
9699 func (a *subprocessApp) OnBeforeCommandLineProcessing(_ string, commandLine purecef.CommandLine) {
97100 configureCommandLine(commandLine)
@@ -101,7 +104,7 @@ func (a *subprocessApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistra
101104 }
102105 func (a *subprocessApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
103106 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 }
105108
106109 // dumberBPH implements purecef.BrowserProcessHandler for context initialization,
107110 // 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 *Engine46 engine *Engine
47 bph purecef.BrowserProcessHandler47 bph purecef.BrowserProcessHandler
48+ rph purecef.RenderProcessHandler
48 }49 }
49 50
50 // newDumberApp creates an App whose BrowserProcessHandler handles context51 // 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 app58 return app
57 }59 }
58 60
59-// NewSubprocessApp returns a lightweight App for helper processes so CEF sees61+// 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(){
385385 }, 1000);
386386 })();`
387387
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-
415388 // contentInjector implements port.ContentInjector for the CEF engine.
416389 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
417390 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -532,8 +505,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
532505 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
533506 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
534507
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.
537511
538512 // Video playback diagnostic — logs video element state changes.
539513 // 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 OnLoadEnd390 // 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(
8282 x,
8383 y,
8484 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)
8694 },
8795 func() {
8896 callback.Cancel()
@@ -162,6 +170,8 @@ func dispatchContextMenuSelection(
162170 ctx context.Context,
163171 executor port.ContextMenuActionExecutor,
164172 callback purecef.RunContextMenuCallback,
173+ explicitCopyCallback func(text string),
174+ copiedCallback func(text string),
165175 commandIDByAction map[port.MenuAction]int32,
166176 item port.MenuItem,
167177 menuContext port.MenuContext,
@@ -171,14 +181,25 @@ func dispatchContextMenuSelection(
171181 return
172182 }
173183 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+ }
174190 if shouldExecuteDirectCEFAction(item.Action) && executor != nil {
175191 if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil {
176192 log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed")
177193 callback.Cancel()
178194 return
179195 }
196+ if copiedCallback != nil {
197+ if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" {
198+ copiedCallback(copiedText)
199+ }
200+ }
180201 log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly")
181- callback.Cont(0, 0)
202+ callback.Cancel()
182203 return
183204 }
184205 if cmdID, ok := commandIDByAction[item.Action]; ok {
@@ -190,6 +211,20 @@ func dispatchContextMenuSelection(
190211 callback.Cancel()
191212 }
192213
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+
193228 func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
194229 switch action {
195230 case port.MenuActionBack,
@@ -296,7 +331,8 @@ func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuCo
296331 if params.HasImageContents() {
297332 menuContext.ImageURI = params.GetSourceURL()
298333 }
299- menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != ""
334+ menuContext.SelectionText = params.GetSelectionText()
335+ menuContext.HasSelection = strings.TrimSpace(menuContext.SelectionText) != ""
300336 menuContext.IsEditable = params.IsEditable()
301337 menuContext.X = int(params.GetXcoord())
302338 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 return181 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 return194 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 return203 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) {
6464 require.Equal(t, "https://example.com/page", ctx.PageURI)
6565 require.Equal(t, "https://example.com/link", ctx.LinkURI)
6666 require.Equal(t, "https://example.com/image.png", ctx.ImageURI)
67+ require.Equal(t, "selected text", ctx.SelectionText)
6768 require.True(t, ctx.HasSelection)
6869 require.True(t, ctx.IsEditable)
6970 require.True(t, ctx.CanGoBack)
@@ -104,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() {
104105 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
105106 callback := &stubRunContextMenuCallback{}
106107
107- dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{
108+ dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{
108109 port.MenuActionReload: 102,
109110 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
110111
@@ -115,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
115116 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
116117 callback := &stubRunContextMenuCallback{}
117118
118- dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{
119+ dispatchContextMenuSelection(context.Background(), nil, callback, nil, nil, map[port.MenuAction]int32{
119120 port.MenuActionInspectElement: 204,
120121 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
121122
@@ -133,6 +134,8 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin
133134 context.Background(),
134135 executor,
135136 callback,
137+ nil,
138+ nil,
136139 map[port.MenuAction]int32{port.MenuActionReload: 102},
137140 port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},
138141 menuContext,
@@ -141,9 +144,9 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin
141144 require.Equal(t, 1, executor.executeCalls)
142145 require.Equal(t, port.MenuActionReload, executor.action)
143146 require.Equal(t, menuContext, executor.menuContext)
144- require.Equal(t, 1, callback.contCalls)
147+ require.Zero(t, callback.contCalls)
145148 require.Zero(t, callback.commandID)
146- require.Zero(t, callback.cancelCalls)
149+ require.Equal(t, 1, callback.cancelCalls)
147150 }
148151
149152 func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) {
@@ -155,6 +158,8 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
155158 context.Background(),
156159 executor,
157160 callback,
161+ nil,
162+ nil,
158163 map[port.MenuAction]int32{},
159164 port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
160165 menuContext,
@@ -163,9 +168,39 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
163168 require.Equal(t, 1, executor.executeCalls)
164169 require.Equal(t, port.MenuActionCopyImage, executor.action)
165170 require.Equal(t, menuContext, executor.menuContext)
166- require.Equal(t, 1, callback.contCalls)
171+ require.Zero(t, callback.contCalls)
167172 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)
169204 }
170205
171206 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 (
55 "sync"
66 "sync/atomic"
77 "time"
8+ "unicode/utf8"
89
910 purecef "github.com/bnema/purego-cef/cef"
1011
@@ -35,6 +36,8 @@ type Engine struct {
3536 ctxMenuBuilder port.ContextMenuBuilder
3637 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
3738 clipboard port.Clipboard
39+ clipboardTextOrchestrator port.ClipboardTextOrchestrator
40+ onClipboardCopied func(textLen int)
3841 resolver port.ImageDataResolver
3942 mediaClassifier MediaClassifier
4043 alreadyRunningAppRelaunchMu sync.RWMutex
@@ -236,6 +239,8 @@ func (e *Engine) closeActiveWebViews() {
236239 // each handler is registered by message type with the router, which dispatches
237240 // incoming /api/message POSTs to the correct handler based on Message.Type.
238241 func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {
242+ e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator
243+ e.onClipboardCopied = deps.OnClipboardCopied
239244 if e.messageRouter == nil || e.registerHandlers == nil {
240245 return nil
241246 }
@@ -250,7 +255,12 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
250255 }
251256
252257 // 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 {
254264 return ErrDownloadsUnsupported
255265 }
256266
@@ -276,6 +286,58 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
276286 return nil
277287 }
278288
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+
279341 // SetHandlerContext sets the base context for message handler dispatch.
280342 func (e *Engine) SetHandlerContext(ctx context.Context) {
281343 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.ContextMenuBuilder36 ctxMenuBuilder port.ContextMenuBuilder
36 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory37 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
37 clipboard port.Clipboard38 clipboard port.Clipboard
39+ clipboardTextOrchestrator port.ClipboardTextOrchestrator
40+ onClipboardCopied func(textLen int)
38 resolver port.ImageDataResolver41 resolver port.ImageDataResolver
39 mediaClassifier MediaClassifier42 mediaClassifier MediaClassifier
40 alreadyRunningAppRelaunchMu sync.RWMutex43 alreadyRunningAppRelaunchMu sync.RWMutex
@@ -236,6 +239,8 @@ func (e *Engine) closeActiveWebViews() {
236 // each handler is registered by message type with the router, which dispatches239 // 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 nil245 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 ErrDownloadsUnsupported264 return ErrDownloadsUnsupported
255 }265 }
256 266
@@ -276,6 +286,58 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
276 return nil286 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 = ctx343 e.ctx = ctx
M internal/infrastructure/cef/engine_init.go +4 -17
@@ -11,7 +11,6 @@ import (
1111
1212 purecef "github.com/bnema/purego-cef/cef"
1313 "github.com/bnema/puregotk/v4/gdk"
14- "github.com/bnema/puregotk/v4/glib"
1514 "github.com/rs/zerolog"
1615
1716 "github.com/bnema/dumber/assets"
@@ -253,24 +252,12 @@ func wireEngine(
253252 }
254253 schemeHandler.setAssets(assets.WebUIAssets)
255254
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.
259257 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)
273259 }
260+ schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
274261
275262 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
276263
@@ -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 the256+ // 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) {
5151 err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{})
5252 require.ErrorIs(t, err, want)
5353 }
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) {
103103 wv.findCtrl = newFindController()
104104
105105 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+ }
106112 input.attachTo(pipeline.glArea)
107113 wv.input = input
108114
@@ -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 = input113 wv.input = input
108 114
M internal/infrastructure/cef/input_bridge.go +85 -4
@@ -3,6 +3,7 @@ package cef
33 import (
44 "context"
55 "sync"
6+ "sync/atomic"
67
78 purecef "github.com/bnema/purego-cef/cef"
89 "github.com/bnema/puregotk/v4/gdk"
@@ -56,6 +57,14 @@ type inputBridge struct {
5657 // ImeCommitText. Set once during attachTo, read-only afterwards.
5758 clipboard *gdk.Clipboard
5859
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+
5968 // IMContext for dead key / compose sequence support. Held as a field
6069 // to prevent garbage collection while the controller is alive.
6170 imContext *gtk.IMContextSimple
@@ -67,12 +76,32 @@ type inputBridge struct {
6776 // show the text caret.
6877 glArea *gtk.GLArea
6978
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+
7084 // onMiddleClick is called when button 2 (middle) is pressed on a link.
7185 // The callback receives the hovered URI. Set by the factory.
7286 onMiddleClick func(uri string)
7387 }
7488
7589 // 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+
76105 func newInputBridge(ctx context.Context, scale int32) *inputBridge {
77106 return &inputBridge{ctx: ctx, scale: scale}
78107 }
@@ -163,6 +192,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
163192 // Focus — forward to CEF and IMContext.
164193 focus := gtk.NewEventControllerFocus()
165194 focusEnterCb := func(_ gtk.EventControllerFocus) {
195+ ib.gtkFocused.Store(true)
166196 if ib.imContext != nil {
167197 ib.imContext.FocusIn()
168198 }
@@ -170,6 +200,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
170200 }
171201 focus.ConnectEnter(&focusEnterCb)
172202 focusLeaveCb := func(_ gtk.EventControllerFocus) {
203+ ib.gtkFocused.Store(false)
173204 if ib.imContext != nil {
174205 ib.imContext.Reset()
175206 ib.imContext.FocusOut()
@@ -200,6 +231,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
200231 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
201232 mods := uint(state)
202233
234+ ib.maybeMirrorClipboardShortcut(keyval, mods)
235+
203236 // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
204237 // Wayland/X11 clipboard, so we read from GDK and inject via
205238 // ImeCommitText instead of letting CEF handle the paste.
@@ -285,6 +318,13 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount
285318 return
286319 }
287320
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+
288328 evt := buildMouseEvent(x, y, mods, ib.scale)
289329 btn := translateMouseButton(button)
290330 host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))
@@ -321,10 +361,7 @@ func (ib *inputBridge) onFocusIn() {
321361 ib.mu.Lock()
322362 host := ib.host
323363 ib.mu.Unlock()
324- if host == nil {
325- return
326- }
327- host.SetFocus(1)
364+ syncWindowlessBrowserFocus(host)
328365 }
329366
330367 func (ib *inputBridge) onFocusOut() {
@@ -337,6 +374,46 @@ func (ib *inputBridge) onFocusOut() {
337374 host.SetFocus(0)
338375 }
339376
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+
340417 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
341418 ib.mu.Lock()
342419 host := ib.host
@@ -558,8 +635,12 @@ const (
558635 gdkKeyPageDown = 0xff56
559636
560637 // Clipboard shortcut keyvals.
638+ gdkKeyLowercaseC = 0x063
639+ gdkKeyUppercaseC = 0x043
561640 gdkKeyLowercaseV = 0x076
562641 gdkKeyUppercaseV = 0x056
642+ gdkKeyLowercaseX = 0x078
643+ gdkKeyUppercaseX = 0x058
563644 )
564645
565646 // 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.Clipboard58 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 field68 // 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.IMContextSimple70 imContext *gtk.IMContextSimple
@@ -67,12 +76,32 @@ type inputBridge struct {
67 // show the text caret.76 // show the text caret.
68 glArea *gtk.GLArea77 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 the236 // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
204 // Wayland/X11 clipboard, so we read from GDK and inject via237 // 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 return318 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.host362 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.host419 host := ib.host
@@ -558,8 +635,12 @@ const (
558 gdkKeyPageDown = 0xff56635 gdkKeyPageDown = 0xff56
559 636
560 // Clipboard shortcut keyvals.637 // Clipboard shortcut keyvals.
638+ gdkKeyLowercaseC = 0x063
639+ gdkKeyUppercaseC = 0x043
561 gdkKeyLowercaseV = 0x076640 gdkKeyLowercaseV = 0x076
562 gdkKeyUppercaseV = 0x056641 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 {
5757 mu sync.RWMutex
5858
5959 // 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.
6161 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)
6266 }
6367
6468 // newDumbSchemeHandler creates a handler for internal CEF pages.
@@ -99,7 +103,7 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
99103
100104 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
101105 // 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 {
103107 reqURL := request.GetURL()
104108 method := request.GetMethod()
105109
@@ -132,7 +136,7 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string,
132136 path := u.Path
133137
134138 if strings.HasPrefix(path, "/api/") {
135- return h.handleAPI(method, path, request)
139+ return h.handleAPI(browser, method, path, request)
136140 }
137141
138142 // Serve static assets.
@@ -140,7 +144,7 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string,
140144 }
141145
142146 // 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 {
144148 switch {
145149 case path == "/api/message" && strings.EqualFold(method, "POST"):
146150 body := readBodyFromHeader(request)
@@ -165,6 +169,9 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque
165169 case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
166170 return h.handleClipboardSet(request)
167171
172+ case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
173+ return h.handleFocusSync(browser)
174+
168175 default:
169176 return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
170177 }
@@ -242,31 +249,48 @@ func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purece
242249 // it to the system clipboard via the engine callback.
243250 const maxClipboardBytes = 10 << 20 // 10 MB
244251
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) {
249253 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")
252255 }
253256 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")
256258 }
257259
258260 var payload struct {
259261 Text string `json:"text"`
260262 }
261263 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+ }
264286 }
265287
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")
267291
268292 if h.onClipboardSet != nil {
269- h.onClipboardSet(payload.Text)
293+ h.onClipboardSet(text)
270294 } else {
271295 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
272296 }
@@ -274,6 +298,21 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
274298 return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
275299 }
276300
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+
277316 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
278317 return purecef.NewResourceHandler(&staticResourceHandler{
279318 contentType: "text/plain; charset=utf-8",
@@ -57,8 +57,12 @@ type dumbSchemeHandler struct {
57 mu sync.RWMutex57 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 thread104 // 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.Path136 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 MB250 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
22
33 import (
44 "context"
5+ "encoding/base64"
56 "errors"
7+ "strings"
68 "testing"
79
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
811 "github.com/stretchr/testify/require"
912 )
1013
@@ -43,3 +46,34 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
4346 require.Nil(t, h)
4447 require.Contains(t, err.Error(), "default config payload builder")
4548 }
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 {
6565 callbacks *port.WebViewCallbacks
6666
6767 // 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
7475
7576 // Last known hover URI for middle-click → new tab.
7677 lastHoverURI string
@@ -565,6 +566,21 @@ func (wv *WebView) setAudioPlaying(playing bool) {
565566 }
566567 }
567568
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+
568584 func (wv *WebView) updateHoverURI(uri string) {
569585 wv.mu.Lock()
570586 wv.lastHoverURI = uri
@@ -65,12 +65,13 @@ type WebView struct {
65 callbacks *port.WebViewCallbacks65 callbacks *port.WebViewCallbacks
66 66
67 // State cache (mutex-protected).67 // State cache (mutex-protected).
68- uri string68+ uri string
69- title string69+ title string
70- progress float6470+ progress float64
71- canGoBack bool71+ canGoBack bool
72- canGoFwd bool72+ canGoFwd bool
73- isLoading bool73+ 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 string77 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 = uri586 wv.lastHoverURI = uri
M internal/infrastructure/cef/webview_handlers.go +124 -17
@@ -15,6 +15,8 @@ import (
1515 const (
1616 consoleMarkerVideoDiag = "[VIDEO-DIAG]"
1717 consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"
18+ consoleMarkerClipboardBridge = "[CLIPBOARD-BRIDGE]"
19+ consoleMarkerAutoCopy = "[AUTO-COPY]"
1820 )
1921
2022 // handlerSet implements all CEF handler interfaces and dispatches events to the
@@ -67,8 +69,44 @@ func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return
6769 func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
6870 func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
6971
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
72110 }
73111
74112 // ===========================================================================
@@ -295,7 +333,12 @@ func (h *handlerSet) shouldAcceptMainViewPaint(
295333 return sizeMatchesCurrentView
296334 }
297335
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+) {
299342 count := h.wv.pipeline.recordAcceleratedPaint()
300343 if count <= 5 || count%100 == 0 {
301344 logging.FromContext(h.wv.ctx).Info().
@@ -309,7 +352,12 @@ func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalA
309352
310353 func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {}
311354
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 {
313361 return 0
314362 }
315363
@@ -320,7 +368,29 @@ func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {}
320368 func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) {
321369 }
322370
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+}
324394
325395 func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {}
326396
@@ -382,9 +452,17 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
382452 }
383453 }
384454
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 {
386461 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)) {
388466 log := logging.FromContext(h.wv.ctx).With().
389467 Str("component", "cef-console").
390468 Str("source", source).
@@ -409,7 +487,12 @@ func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64
409487 h.wv.updateProgress(progress)
410488 }
411489
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 {
413496 name := cefCursorToGDKName(cursorType)
414497 h.wv.runOnGTK(func() {
415498 h.wv.pipeline.glArea.SetCursorFromName(&name)
@@ -432,6 +515,13 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
432515 loading := isloading != 0
433516 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
434517
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+
435525 h.wv.mu.RLock()
436526 cb := h.wv.callbacks
437527 h.wv.mu.RUnlock()
@@ -579,11 +669,10 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
579669 // UI elements like the text caret until explicitly told the browser is shown.
580670 host.WasHidden(0)
581671
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)
587676 }
588677
589678 // Replay any navigation that was requested before the browser existed.
@@ -636,7 +725,13 @@ func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purece
636725 return false
637726 }
638727
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 {
640735 return 0
641736 }
642737
@@ -644,7 +739,8 @@ func (h *handlerSet) GetResourceRequestHandler(
644739 _ purecef.Browser, _ purecef.Frame, request purecef.Request,
645740 _, _ int32, _ string, disableDefaultHandling *int32,
646741 ) 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()) {
648744 *disableDefaultHandling = 1
649745 if h.wv != nil && h.wv.ctx != nil {
650746 logging.FromContext(h.wv.ctx).Info().
@@ -665,7 +761,13 @@ func (h *handlerSet) GetAuthCredentials(
665761 return 0
666762 }
667763
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 {
669771 return 0
670772 }
671773
@@ -894,7 +996,12 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) {
894996 // ===========================================================================
895997
896998 // 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+) {
8981005 if h.wv.findCtrl != nil {
8991006 h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
9001007 }
@@ -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 the22 // 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 073+ 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 sizeMatchesCurrentView333 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 0361 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 != 0515 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.callbacks526 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 SetFocus673+ // 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 false725 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 0735 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 = 1744 *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 0761 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 0771 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 (
1111 "github.com/bnema/dumber/internal/application/port"
1212 )
1313
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+
1429 func newTestPipeline(w, h, s int32) *renderPipeline {
1530 rp := &renderPipeline{scale: s}
1631 rp.widthAtomic.Store(w)
@@ -89,6 +104,24 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
89104 assert.Empty(t, gotProgress)
90105 }
91106
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+
92125 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
93126 rect := &purecef.Rect{}
94127 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 (
1010
1111 // ClipboardHandler handles clipboard-related messages from webviews.
1212 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
1614 }
1715
1816 // 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 {
2018 return &ClipboardHandler{
21- clipboard: clipboard,
22- autoCopyConfig: autoCopyConfig,
23- onCopied: onCopied,
19+ orchestrator: orchestrator,
2420 }
2521 }
2622
@@ -29,38 +25,57 @@ type autoCopyRequest struct {
2925 Text string `json:"text"`
3026 }
3127
28+type explicitCopyRequest struct {
29+ Text string `json:"text"`
30+ Action string `json:"action"`
31+}
32+
3233 // HandleAutoCopySelection handles the auto_copy_selection message from JS.
3334 // It copies the selected text to the clipboard if the feature is enabled.
3435 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) {
3637 log := logging.FromContext(ctx)
3738
38- // Check if feature is enabled
39- if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() {
40- return nil, nil
41- }
42-
4339 var req autoCopyRequest
4440 if err := json.Unmarshal(payload, &req); err != nil {
4541 log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload")
4642 return nil, nil // Silently ignore malformed requests
4743 }
48-
49- // Skip empty or very short selections (< 2 chars)
50- if len(req.Text) < 2 {
44+ if h.orchestrator == nil {
5145 return nil, nil
5246 }
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")
5753 }
5854
59- log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard")
55+ return nil, nil
56+ })
57+}
6058
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")
6479 }
6580
6681 return nil, nil
@@ -71,16 +86,17 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler {
7186 func RegisterClipboardHandlers(
7287 ctx context.Context,
7388 router port.WebUIHandlerRouter,
74- clipboard port.Clipboard,
75- autoCopyConfig port.AutoCopyConfig,
76- onCopied func(textLen int),
89+ orchestrator port.ClipboardTextOrchestrator,
7790 ) error {
78- handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied)
91+ handler := NewClipboardHandler(orchestrator)
7992
8093 // Register auto_copy_selection handler
8194 if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {
8295 return err
8396 }
97+ if err := router.RegisterHandler("explicit_text_copy", handler.HandleExplicitCopy()); err != nil {
98+ return err
99+ }
84100
85101 log := logging.FromContext(ctx)
86102 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.Clipboard13+ 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 autoCopyRequest39 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 requests42 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, nil45 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 JS50+ 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, nil81 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 handler93 // 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 err95 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.
5252 }
5353
5454 // 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 {
5757 return err
5858 }
5959 }
@@ -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 err57 return err
58 }58 }
59 }59 }
M internal/infrastructure/webkit/context_menu.go +53 -1
@@ -13,6 +13,7 @@ import (
1313 "net/url"
1414 "strings"
1515 "time"
16+ "unicode/utf8"
1617
1718 "github.com/bnema/dumber/internal/application/port"
1819 "github.com/bnema/dumber/internal/domain/entity"
@@ -365,6 +366,7 @@ type contextMenuPipeline struct {
365366 builder port.ContextMenuBuilder
366367 executorFactory port.ContextMenuActionExecutorFactory
367368 clipboard port.Clipboard
369+ onCopied func(textLen int)
368370 resolver port.ImageDataResolver
369371 saver port.ResolvedImageSaver
370372 renderer *Renderer
@@ -374,14 +376,64 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe
374376 if p == nil || p.executorFactory == nil {
375377 return nil
376378 }
379+ clipboard := p.clipboard
380+ if clipboard != nil && p.onCopied != nil {
381+ clipboard = &clipboardCopyNotifier{clipboard: clipboard, onCopied: p.onCopied}
382+ }
377383 return p.executorFactory.NewContextMenuActionExecutor(
378- p.clipboard,
384+ clipboard,
379385 p.resolver,
380386 p.saver,
381387 &webkitMenuDelegator{wv: wv},
382388 )
383389 }
384390
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+
385437 // connectContextMenuSignal wires the WebKit "context-menu" signal to the
386438 // shared context menu pipeline. When the user right-clicks, WebKit's native
387439 // 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.ContextMenuBuilder366 builder port.ContextMenuBuilder
366 executorFactory port.ContextMenuActionExecutorFactory367 executorFactory port.ContextMenuActionExecutorFactory
367 clipboard port.Clipboard368 clipboard port.Clipboard
369+ onCopied func(textLen int)
368 resolver port.ImageDataResolver370 resolver port.ImageDataResolver
369 saver port.ResolvedImageSaver371 saver port.ResolvedImageSaver
370 renderer *Renderer372 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 nil377 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 the437 // connectContextMenuSignal wires the WebKit "context-menu" signal to the
386 // shared context menu pipeline. When the user right-clicks, WebKit's native438 // 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 (
99 "net/http"
1010 "net/http/httptest"
1111 "testing"
12+ "unicode/utf8"
1213
1314 "github.com/bnema/dumber/internal/application/port"
15+ "github.com/bnema/dumber/internal/application/usecase"
16+ "github.com/bnema/dumber/internal/domain/entity"
1417 "github.com/stretchr/testify/assert"
1518 "github.com/stretchr/testify/require"
1619 )
@@ -313,6 +316,42 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T)
313316 assert.Contains(t, err.Error(), "action not handled")
314317 }
315318
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+
316355 // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
317356 // that works with our stub type instead of requiring real WebKit objects.
318357 func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
@@ -335,6 +374,36 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo
335374 return ctx
336375 }
337376
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+
338407 type roundTripperFunc func(*http.Request) (*http.Response, error)
339408
340409 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 buildMenuContextFromHitTest355 // 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 ctx374 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 {
2626 logger zerolog.Logger
2727 ctxMenuBuilder port.ContextMenuBuilder
2828 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
29+ onClipboardCopied func(textLen int)
2930 downloadPath string
3031 downloadPreparer port.DownloadPreparer
3132 clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring
@@ -99,6 +100,7 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
99100 }
100101 // Capture clipboard for context menu pipeline wiring in ConfigureDownloads.
101102 e.clipboard = deps.Clipboard
103+ e.onClipboardCopied = deps.OnClipboardCopied
102104 return handlers.RegisterAll(ctx, e.messageRouter, deps)
103105 }
104106
@@ -179,6 +181,7 @@ func (e *Engine) installContextMenuPipeline() {
179181 builder: e.ctxMenuBuilder,
180182 executorFactory: e.ctxMenuExecutorFactory,
181183 clipboard: e.clipboard,
184+ onCopied: e.onClipboardCopied,
182185 resolver: resolver,
183186 saver: saver,
184187 renderer: renderer,
@@ -26,6 +26,7 @@ type Engine struct {
26 logger zerolog.Logger26 logger zerolog.Logger
27 ctxMenuBuilder port.ContextMenuBuilder27 ctxMenuBuilder port.ContextMenuBuilder
28 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory28 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
29+ onClipboardCopied func(textLen int)
29 downloadPath string30 downloadPath string
30 downloadPreparer port.DownloadPreparer31 downloadPreparer port.DownloadPreparer
31 clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring32 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.Clipboard102 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() {
5959 });
6060 })();`
6161
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+
62129 // accentDetectionScript is built at init from entity.AccentMap so the JS
63130 // filter stays in sync with the Go-side accent table.
64131 var accentDetectionScript string
65132
133+func buildExplicitCopyScript() string {
134+ return explicitCopyScript
135+}
136+
66137 func init() {
67138 // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
68139 keys := make([]string, 0, len(entity.AccentMap))
@@ -229,7 +300,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
229300 "webrtc-compat-shim",
230301 )
231302
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)
233316 if webviewID != 0 {
234317 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
235318 addScript(
@@ -247,7 +330,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
247330 log.Warn().Msg("webview ID is 0, skipping ID injection")
248331 }
249332
250- // 3. Inject dark mode handler for internal pages only
333+ // 4. Inject dark mode handler for internal pages only
251334 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
252335 internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark")
253336 addScript(
@@ -261,7 +344,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
261344 "internal-dark-mode-handler",
262345 )
263346
264- // 4. Inject theme CSS for internal pages (dumb://* only)
347+ // 5. Inject theme CSS for internal pages (dumb://* only)
265348 if ci.themeCSSVars != "" {
266349 escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars)
267350 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
@@ -278,7 +361,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
278361 log.Debug().Msg("theme CSS vars injection configured for internal pages")
279362 }
280363
281- // 5. Inject find highlight CSS for all pages
364+ // 6. Inject find highlight CSS for all pages
282365 if ci.findCSS != "" {
283366 stylesheet := webkit.NewUserStyleSheet(
284367 ci.findCSS,
@@ -295,7 +378,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
295378 }
296379 }
297380
298- // 6. Inject auto-copy selection script for all pages (if enabled)
381+ // 7. Inject auto-copy selection script for all pages (if enabled)
299382 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
300383 if autoCopyEnabled {
301384 addScript(
@@ -311,7 +394,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
311394 log.Debug().Msg("auto-copy selection script injected")
312395 }
313396
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).
315398 // JS only reports keydown/keyup events; Go handles timing and picker display.
316399 addScript(
317400 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 JS129 // 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 string131 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 only333+ // 4. Inject dark mode handler for internal pages only
251 // This sets .dark/.light class on <html> and patches matchMedia for WebUI334 // 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 pages364+ // 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) {
3030 "accent detection script must register a focusin listener",
3131 )
3232 }
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 (
3737
3838 const (
3939 // AppID is the application identifier for GTK.
40- AppID = "com.github.bnema.dumber"
40+ AppID = "com.github.bnema.dumber"
41+ appTitle = "Dumber"
4142 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
4243 crashReportToastDurationMs = 5000
4344 floatingPaneFallbackWidth = 1200
@@ -181,17 +182,33 @@ func New(deps *Dependencies) (*App, error) {
181182 engine: deps.Engine,
182183 cancel: cancel,
183184 }
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 {
190189 if deps.Config == nil {
191190 return false
192191 }
193192 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,
195212 OnClipboardCopied: func(textLen int) {
196213 cb := glib.SourceFunc(func(_ uintptr) bool {
197214 app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,
@@ -2339,9 +2356,9 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
23392356 return
23402357 }
23412358
2342- title := "Dumber"
2359+ title := appTitle
23432360 if pageTitle != "" {
2344- title = pageTitle + " - Dumber"
2361+ title = pageTitle + " - " + appTitle
23452362 }
23462363 target.mainWindow.SetTitle(title)
23472364 }
@@ -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 = 500043 crashReportToastDurationMs = 5000
43 floatingPaneFallbackWidth = 120044 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 false190 return false
192 }191 }
193 return deps.Config.Clipboard.AutoCopyOnSelection192 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 return2356 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+}

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close