1package cef
2
3import (
4 "context"
5 "crypto/subtle"
6 _ "embed"
7 "encoding/base64"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "net/url"
12 "regexp"
13 "strings"
14
15 purecef "github.com/bnema/purego-cef/cef"
16
17 "github.com/bnema/dumber/internal/application/dto"
18 "github.com/bnema/dumber/internal/application/port"
19 "github.com/bnema/dumber/internal/logging"
20)
21
22var _ port.VimPageInteractor = (*WebView)(nil)
23
24//go:embed vim_page_runtime.js
25var vimPageRuntimeTemplateJS string
26
27var (
28 errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable")
29 errVimPagePayloadTooLarge = errors.New("vim page: payload too large")
30 // vimPageAccentColor admits the CSS hex colors the runtime paints with;
31 // anything else is replaced by the runtime default before reaching JS.
32 vimPageAccentColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
33)
34
35// vimPageBridgePayload is one result reported by the page runtime. Token is
36// the per-interaction secret issued by StartVimPageInteraction.
37type vimPageBridgePayload struct {
38 Type string `json:"type"`
39 Token string `json:"token"`
40 Text string `json:"text,omitempty"`
41 URL string `json:"url,omitempty"`
42 // Reason explains an early end (for example no visible targets).
43 Reason string `json:"reason,omitempty"`
44 // Mode names the sub-mode of a "mode" message.
45 Mode string `json:"mode,omitempty"`
46}
47
48const (
49 vimPageMessageCopy = "copy"
50 vimPageMessageMode = "mode"
51 vimPageMessageOpenNew = "open-new"
52 vimPageMessageEnd = "end"
53)
54
55func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) {
56 var payload vimPageBridgePayload
57 if len(body) > maxClipboardBytes {
58 return payload, errVimPagePayloadTooLarge
59 }
60 if err := json.Unmarshal(body, &payload); err != nil {
61 return payload, err
62 }
63 if payload.Token == "" {
64 return payload, errors.New("missing token")
65 }
66 switch payload.Type {
67 case vimPageMessageCopy:
68 if payload.Text == "" {
69 return payload, errors.New("missing text")
70 }
71 case vimPageMessageOpenNew:
72 if payload.URL == "" {
73 return payload, errors.New("missing url")
74 }
75 case vimPageMessageMode:
76 if !dto.VimPageMode(payload.Mode).Valid() {
77 return payload, fmt.Errorf("unknown mode %q", payload.Mode)
78 }
79 case vimPageMessageEnd:
80 default:
81 return payload, fmt.Errorf("unknown type %q", payload.Type)
82 }
83 return payload, nil
84}
85
86// isNavigableVimHintURL keeps hint-opened panes to web documents with a host
87// so a page cannot smuggle javascript: or internal URLs through the bridge.
88// file: targets are allowed only from a page that is itself a file: document,
89// matching Chromium's own http(s)-to-file navigation block.
90func isNavigableVimHintURL(raw, sourceURI string) bool {
91 parsed, err := url.Parse(raw)
92 if err != nil {
93 return false
94 }
95 switch strings.ToLower(parsed.Scheme) {
96 case "http", "https":
97 return parsed.Host != ""
98 case "file":
99 source, err := url.Parse(sourceURI)
100 return err == nil && strings.EqualFold(source.Scheme, "file")
101 default:
102 return false
103 }
104}
105
106func vimPageKindName(kind dto.VimPageInteractionKind) (string, bool) {
107 switch kind {
108 case dto.VimPageHintFollow:
109 return "hint-follow", true
110 case dto.VimPageHintFollowNew:
111 return "hint-follow-new", true
112 case dto.VimPageHintYankURL:
113 return "hint-yank-url", true
114 case dto.VimPageVisual:
115 return "visual", true
116 case dto.VimPageYankObject:
117 return "yank", true
118 default:
119 return "", false
120 }
121}
122
123func vimPageObjectName(object dto.VimPageTextObject) (string, bool) {
124 switch object {
125 case dto.VimPageTextObjectSection:
126 return "section", true
127 case dto.VimPageTextObjectParagraph:
128 return "paragraph", true
129 case dto.VimPageTextObjectCode:
130 return "code", true
131 case dto.VimPageTextObjectTable:
132 return "table", true
133 case dto.VimPageTextObjectList:
134 return "list", true
135 default:
136 return "", false
137 }
138}
139
140func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) {
141 kind, ok := vimPageKindName(request.Kind)
142 if !ok {
143 return "", fmt.Errorf("unsupported vim page interaction: %d", request.Kind)
144 }
145 object := ""
146 if request.Kind == dto.VimPageYankObject {
147 if object, ok = vimPageObjectName(request.Object); !ok {
148 return "", fmt.Errorf("unsupported vim text object: %d", request.Object)
149 }
150 }
151 color := request.HighlightColor
152 if !vimPageAccentColor.MatchString(color) {
153 color = ""
154 }
155 encoded, err := json.Marshal(struct {
156 Kind string `json:"kind"`
157 Object string `json:"object,omitempty"`
158 Color string `json:"color,omitempty"`
159 }{Kind: kind, Object: object, Color: color})
160 if err != nil {
161 return "", err
162 }
163 return string(encoded), nil
164}
165
166// jsString renders s as a JavaScript string literal (JSON is valid JS).
167func jsString(s string) string {
168 encoded, _ := json.Marshal(s)
169 return string(encoded)
170}
171
172// vimPageStartScript installs the runtime when absent and starts an
173// interaction. The token is a per-interaction secret, not the shared bridge
174// nonce, so a page that intercepts the call only learns a token valid for the
175// interaction the user just started.
176func vimPageStartScript(token, request string) string {
177 return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage.start(" + jsString(token) + ", " + request + ");"
178}
179
180// vimPageCallScript invokes key or cancel on the installed runtime without
181// shipping it again. When the runtime is gone (the document was replaced), it
182// reports the end of the token so Go releases the key capture.
183func vimPageCallScript(method, token string, args ...string) string {
184 call := append([]string{jsString(token)}, args...)
185 return "window.__dumberVimPage ? window.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ") : " + vimPageEndScript(token)
186}
187
188// vimPageEndScript posts an end message for token over the same bridge the
189// runtime uses.
190func vimPageEndScript(token string) string {
191 body, _ := json.Marshal(map[string]string{"token": token, "type": vimPageMessageEnd})
192 return `window.fetch("dumb:///api/vim-page", {method: "POST", headers: {"X-Dumber-Body": ` +
193 jsString(base64.StdEncoding.EncodeToString(body)) + `}}).catch(() => {});`
194}
195
196// armVimPageInteraction issues a fresh token for a new interaction and
197// invalidates any previous one, so late results from it are rejected.
198func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (string, error) {
199 token := newBridgeNonce()
200 if token == "" {
201 return "", errVimPageTokenUnavailable
202 }
203 wv.mu.Lock()
204 wv.vimPageToken = token
205 wv.vimPageKind = kind
206 wv.vimPageCopyAllowance = 0
207 wv.vimPageGeneration++
208 wv.mu.Unlock()
209 return token, nil
210}
211
212func (wv *WebView) currentVimPageToken() string {
213 wv.mu.RLock()
214 defer wv.mu.RUnlock()
215 return wv.vimPageToken
216}
217
218// disarmVimPageInteraction clears the active token. It reports whether a
219// key-capturing interaction was active and the generation it belonged to.
220func (wv *WebView) disarmVimPageInteraction() (bool, uint64) {
221 wv.mu.Lock()
222 defer wv.mu.Unlock()
223 captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys()
224 wv.vimPageToken = ""
225 wv.vimPageKind = 0
226 wv.vimPageCopyAllowance = 0
227 return captured, wv.vimPageGeneration
228}
229
230// notifyVimPageInteractionEnded runs the ended callback on GTK unless a newer
231// interaction was armed in the meantime (keys queued ahead of the idle).
232func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) {
233 wv.mu.RLock()
234 cb := wv.callbacks
235 wv.mu.RUnlock()
236 if cb == nil || cb.OnVimPageInteractionEnded == nil {
237 return
238 }
239 ended := cb.OnVimPageInteractionEnded
240 wv.runOnGTK(func() {
241 wv.mu.RLock()
242 current := wv.vimPageGeneration
243 wv.mu.RUnlock()
244 if current == generation {
245 ended()
246 }
247 })
248}
249
250// vimPageResultEndsInteraction reports whether a result spends the token.
251// The caret and visual flow is long-lived: its mode changes and copies leave
252// the interaction running so copies can be chained. Copies stay bounded by
253// the user's own y/Enter presses (see vimPageCopyAllowance), not by the
254// token. Every other result is terminal, so a token grants exactly one.
255func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool {
256 if kind != dto.VimPageVisual {
257 return true
258 }
259 return messageType != vimPageMessageMode && messageType != vimPageMessageCopy
260}
261
262// notifyVimPageModeChanged reports a sub-mode change on GTK unless a newer
263// interaction was armed in the meantime.
264func (wv *WebView) notifyVimPageModeChanged(generation uint64, mode dto.VimPageMode) {
265 wv.mu.RLock()
266 cb := wv.callbacks
267 wv.mu.RUnlock()
268 if cb == nil || cb.OnVimPageModeChanged == nil {
269 return
270 }
271 changed := cb.OnVimPageModeChanged
272 wv.runOnGTK(func() {
273 wv.mu.RLock()
274 current := wv.vimPageGeneration
275 wv.mu.RUnlock()
276 if current == generation {
277 changed(mode)
278 }
279 })
280}
281
282// vimPageResultAllowed ties each result type to the interaction the user
283// started, so a hijacked token only yields the kind of result requested.
284func vimPageResultAllowed(kind dto.VimPageInteractionKind, messageType string) bool {
285 switch messageType {
286 case vimPageMessageEnd:
287 return true
288 case vimPageMessageOpenNew:
289 return kind == dto.VimPageHintFollow || kind == dto.VimPageHintFollowNew
290 case vimPageMessageCopy:
291 return kind == dto.VimPageYankObject || kind == dto.VimPageHintYankURL || kind == dto.VimPageVisual
292 case vimPageMessageMode:
293 return kind == dto.VimPageVisual
294 default:
295 return false
296 }
297}
298
299// StartVimPageInteraction starts link hints, visual selection, or a text-object yank.
300func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPageInteractionRequest) error {
301 if wv == nil || wv.destroyed.Load() {
302 return errDestroyed
303 }
304 encoded, err := vimPageRequestJSON(request)
305 if err != nil {
306 return err
307 }
308 token, err := wv.armVimPageInteraction(request.Kind)
309 if err != nil {
310 return err
311 }
312 if err := wv.scheduleJavaScript(vimPageStartScript(token, encoded)); err != nil {
313 wv.disarmVimPageInteraction()
314 return err
315 }
316 return nil
317}
318
319// vimPageKeyRequestsCopy reports whether a forwarded key may make a visual
320// selection copy: y and Enter yank in the caret and visual flow.
321func vimPageKeyRequestsCopy(key string) bool {
322 return key == "y" || key == "<Return>"
323}
324
325// grantVimPageCopyAllowance records that the user pressed a copy key for the
326// armed visual interaction, so one visual copy result will be accepted. The
327// allowance is capped at one: y/Enter presses the page ignores (caret or hint
328// phase, empty selection) must not bank copies for a page holding the token.
329func (wv *WebView) grantVimPageCopyAllowance(token, key string) {
330 if !vimPageKeyRequestsCopy(key) {
331 return
332 }
333 wv.mu.Lock()
334 if wv.vimPageToken == token && wv.vimPageKind == dto.VimPageVisual {
335 wv.vimPageCopyAllowance = 1
336 }
337 wv.mu.Unlock()
338}
339
340// SendVimPageKey forwards one canonical key to the active page interaction.
341func (wv *WebView) SendVimPageKey(_ context.Context, key string) error {
342 if wv == nil || wv.destroyed.Load() {
343 return errDestroyed
344 }
345 token := wv.currentVimPageToken()
346 if key == "" || token == "" {
347 return nil
348 }
349 wv.grantVimPageCopyAllowance(token, key)
350 return wv.scheduleJavaScript(vimPageCallScript("key", token, jsString(key)))
351}
352
353// CancelVimPageInteraction removes any hint overlay or visual selection.
354func (wv *WebView) CancelVimPageInteraction(_ context.Context) error {
355 if wv == nil || wv.destroyed.Load() {
356 return errDestroyed
357 }
358 token := wv.currentVimPageToken()
359 wv.disarmVimPageInteraction()
360 if token == "" {
361 return nil
362 }
363 return wv.scheduleJavaScript(vimPageCallScript("cancel", token))
364}
365
366// endVimPageInteractionOnNavigation drops an interaction whose document is
367// being replaced; its runtime can no longer report the end itself.
368func (wv *WebView) endVimPageInteractionOnNavigation() {
369 if captured, generation := wv.disarmVimPageInteraction(); captured {
370 wv.notifyVimPageInteractionEnded(generation)
371 }
372}
373
374// handleVimPageBridge routes one runtime result from the trusted bridge.
375func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBridgePayload) {
376 e.withBridgeSourceWebView(browser, "", "vim-page", func(wv *WebView) {
377 wv.handleVimPageResult(payload)
378 })
379}
380
381// spendVimPageResultLocked validates the token of one result and applies its
382// effect on the armed state: a terminal result disarms the interaction, and a
383// visual copy spends one allowance. A visual copy is non-terminal, so the
384// token alone would let a page that read it write the clipboard repeatedly;
385// each accepted copy needs a y/Enter key the user pressed. wv.mu must be held.
386func (wv *WebView) spendVimPageResultLocked(payload vimPageBridgePayload) (valid, ends, copyDenied bool) {
387 armed := wv.vimPageToken
388 valid = armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1
389 if !valid {
390 return false, false, false
391 }
392 kind := wv.vimPageKind
393 if payload.Type == vimPageMessageCopy && kind == dto.VimPageVisual {
394 if wv.vimPageCopyAllowance <= 0 {
395 return true, false, true
396 }
397 wv.vimPageCopyAllowance--
398 }
399 ends = vimPageResultEndsInteraction(kind, payload.Type)
400 if ends {
401 wv.vimPageToken = ""
402 wv.vimPageKind = 0
403 wv.vimPageCopyAllowance = 0
404 }
405 return true, ends, false
406}
407
408// handleVimPageResult accepts a result only for the interaction the user
409// armed; results from stale or forged interactions are dropped.
410func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
411 if wv == nil || wv.destroyed.Load() {
412 return
413 }
414 wv.mu.Lock()
415 cb := wv.callbacks
416 sourceURI := wv.uri
417 kind := wv.vimPageKind
418 generation := wv.vimPageGeneration
419 valid, ends, copyDenied := wv.spendVimPageResultLocked(payload)
420 wv.mu.Unlock()
421 if !valid {
422 logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token")
423 return
424 }
425 if copyDenied {
426 logging.FromContext(wv.ctx).Debug().Msg("cef: vim page copy rejected — no user copy key pending")
427 return
428 }
429
430 if ends {
431 logging.FromContext(wv.ctx).Debug().
432 Str("type", payload.Type).
433 Str("reason", payload.Reason).
434 Msg("cef: vim page interaction finished")
435 if kind.CapturesKeys() {
436 wv.notifyVimPageInteractionEnded(generation)
437 }
438 }
439 if !vimPageResultAllowed(kind, payload.Type) {
440 logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — not allowed for interaction")
441 return
442 }
443
444 switch payload.Type {
445 case vimPageMessageMode:
446 wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode))
447 case vimPageMessageCopy:
448 // Results arrive on the CEF IO thread; the clipboard write belongs on GTK.
449 if engine := wv.engine; engine != nil {
450 id, text := wv.id, payload.Text
451 wv.runOnGTK(func() { engine.handleExplicitClipboardBridgeText(id, "copy", text) })
452 }
453 case vimPageMessageOpenNew:
454 if !isNavigableVimHintURL(payload.URL, sourceURI) {
455 logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open rejected — unsupported url")
456 return
457 }
458 if cb == nil || cb.OnLinkMiddleClick == nil {
459 logging.FromContext(wv.ctx).Debug().Msg("cef: vim hint open skipped — handler not available")
460 return
461 }
462 target := payload.URL
463 wv.runOnGTK(func() { cb.OnLinkMiddleClick(target) })
464 }
465}