fix(cef): harden the vim page bridge
End the page interaction when the renderer terminates, clear it on Destroy and ignore results for destroyed views. Dispatch visual and yank clipboard writes to GTK instead of the CEF IO thread, and accept a visual copy only for a y/Enter key Go forwarded so a stolen token cannot rewrite the clipboard. Ship the runtime only on start; key and cancel call the installed runtime or report the end when it is gone. Answer oversized payloads with 413, hide decode details behind a generic message and send only hex accent colors to the page.
- commit
- 1d8d6c086d6ede5a21c17e61627c21a6d2518575
- author
- brice · · 2026-10-04 16:04 UTC
- committer
- brice · · 2026-10-10 08:27 UTC
- parent
- a86e62f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +334 -39
M internal/infrastructure/cef/scheme_handler.go +5 -1
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "encoding/base64" | |
| 8 | 8 | "encoding/hex" | |
| 9 | 9 | "encoding/json" | |
| 10 | + | "errors" | |
| 10 | 11 | "fmt" | |
| 11 | 12 | "html" | |
| 12 | 13 | "io/fs" | |
| @@ -492,8 +493,11 @@ func (h *dumbSchemeHandler) handleVimPage(request purecef.Request, browser purec | |||
| 492 | 493 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | |
| 493 | 494 | } | |
| 494 | 495 | payload, err := decodeVimPageBridgePayload(body) | |
| 496 | + | if errors.Is(err, errVimPagePayloadTooLarge) { | |
| 497 | + | return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"}) | |
| 498 | + | } | |
| 495 | 499 | if err != nil { | |
| 496 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()}) | |
| 500 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) | |
| 497 | 501 | } | |
| 498 | 502 | if h.onVimPage == nil { | |
| 499 | 503 | h.logger.Warn().Msg("cef: vim-page — callback not wired") | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "encoding/base64" | 7 | "encoding/base64" | ||
| 8 | "encoding/hex" | 8 | "encoding/hex" | ||
| 9 | "encoding/json" | 9 | "encoding/json" | ||
| 10 | + | "errors" | |||
| 10 | "fmt" | 11 | "fmt" | ||
| 11 | "html" | 12 | "html" | ||
| 12 | "io/fs" | 13 | "io/fs" | ||
| @@ -492,8 +493,11 @@ func (h *dumbSchemeHandler) handleVimPage(request purecef.Request, browser purec | |||||
| 492 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | 493 | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | ||
| 493 | } | 494 | } | ||
| 494 | payload, err := decodeVimPageBridgePayload(body) | 495 | payload, err := decodeVimPageBridgePayload(body) | ||
| 496 | + | if errors.Is(err, errVimPagePayloadTooLarge) { | |||
| 497 | + | return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"}) | |||
| 498 | + | } | |||
| 495 | if err != nil { | 499 | if err != nil { | ||
| 496 | - | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()}) | 500 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) |
| 497 | } | 501 | } | ||
| 498 | if h.onVimPage == nil { | 502 | if h.onVimPage == nil { | ||
| 499 | h.logger.Warn().Msg("cef: vim-page — callback not wired") | 503 | h.logger.Warn().Msg("cef: vim-page — callback not wired") | ||
M internal/infrastructure/cef/vim_page.go +103 -23
| @@ -4,10 +4,12 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/subtle" | |
| 6 | 6 | _ "embed" | |
| 7 | + | "encoding/base64" | |
| 7 | 8 | "encoding/json" | |
| 8 | 9 | "errors" | |
| 9 | 10 | "fmt" | |
| 10 | 11 | "net/url" | |
| 12 | + | "regexp" | |
| 11 | 13 | "strings" | |
| 12 | 14 | ||
| 13 | 15 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -22,7 +24,13 @@ var _ port.VimPageInteractor = (*WebView)(nil) | |||
| 22 | 24 | //go:embed vim_page_runtime.js | |
| 23 | 25 | var vimPageRuntimeTemplateJS string | |
| 24 | 26 | ||
| 25 | - | var errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable") | |
| 27 | + | var ( | |
| 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,8}$`) | |
| 33 | + | ) | |
| 26 | 34 | ||
| 27 | 35 | // vimPageBridgePayload is one result reported by the page runtime. Token is | |
| 28 | 36 | // the per-interaction secret issued by StartVimPageInteraction. | |
| @@ -47,7 +55,7 @@ const ( | |||
| 47 | 55 | func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) { | |
| 48 | 56 | var payload vimPageBridgePayload | |
| 49 | 57 | if len(body) > maxClipboardBytes { | |
| 50 | - | return payload, errors.New("payload too large") | |
| 58 | + | return payload, errVimPagePayloadTooLarge | |
| 51 | 59 | } | |
| 52 | 60 | if err := json.Unmarshal(body, &payload); err != nil { | |
| 53 | 61 | return payload, err | |
| @@ -140,11 +148,15 @@ func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) { | |||
| 140 | 148 | return "", fmt.Errorf("unsupported vim text object: %d", request.Object) | |
| 141 | 149 | } | |
| 142 | 150 | } | |
| 151 | + | color := request.HighlightColor | |
| 152 | + | if !vimPageAccentColor.MatchString(color) { | |
| 153 | + | color = "" | |
| 154 | + | } | |
| 143 | 155 | encoded, err := json.Marshal(struct { | |
| 144 | 156 | Kind string `json:"kind"` | |
| 145 | 157 | Object string `json:"object,omitempty"` | |
| 146 | 158 | Color string `json:"color,omitempty"` | |
| 147 | - | }{Kind: kind, Object: object, Color: request.HighlightColor}) | |
| 159 | + | }{Kind: kind, Object: object, Color: color}) | |
| 148 | 160 | if err != nil { | |
| 149 | 161 | return "", err | |
| 150 | 162 | } | |
| @@ -157,13 +169,28 @@ func jsString(s string) string { | |||
| 157 | 169 | return string(encoded) | |
| 158 | 170 | } | |
| 159 | 171 | ||
| 160 | - | // vimPageScript installs the runtime when absent and then invokes one method | |
| 161 | - | // with the interaction token. The token is a per-interaction secret, not the | |
| 162 | - | // shared bridge nonce, so a page that intercepts the call only learns a token | |
| 163 | - | // valid for the interaction the user just started. | |
| 164 | - | func vimPageScript(method, token string, args ...string) string { | |
| 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. | |
| 176 | + | func 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. | |
| 183 | + | func vimPageCallScript(method, token string, args ...string) string { | |
| 165 | 184 | call := append([]string{jsString(token)}, args...) | |
| 166 | - | return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ");" | |
| 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. | |
| 190 | + | func 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(() => {});` | |
| 167 | 194 | } | |
| 168 | 195 | ||
| 169 | 196 | // armVimPageInteraction issues a fresh token for a new interaction and | |
| @@ -176,6 +203,7 @@ func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (strin | |||
| 176 | 203 | wv.mu.Lock() | |
| 177 | 204 | wv.vimPageToken = token | |
| 178 | 205 | wv.vimPageKind = kind | |
| 206 | + | wv.vimPageCopyAllowance = 0 | |
| 179 | 207 | wv.vimPageGeneration++ | |
| 180 | 208 | wv.mu.Unlock() | |
| 181 | 209 | return token, nil | |
| @@ -195,6 +223,7 @@ func (wv *WebView) disarmVimPageInteraction() (bool, uint64) { | |||
| 195 | 223 | captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys() | |
| 196 | 224 | wv.vimPageToken = "" | |
| 197 | 225 | wv.vimPageKind = 0 | |
| 226 | + | wv.vimPageCopyAllowance = 0 | |
| 198 | 227 | return captured, wv.vimPageGeneration | |
| 199 | 228 | } | |
| 200 | 229 | ||
| @@ -220,8 +249,9 @@ func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) { | |||
| 220 | 249 | ||
| 221 | 250 | // vimPageResultEndsInteraction reports whether a result spends the token. | |
| 222 | 251 | // The caret and visual flow is long-lived: its mode changes and copies leave | |
| 223 | - | // the interaction running so copies can be chained; every other result is | |
| 224 | - | // terminal, so a token grants exactly one of them. | |
| 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. | |
| 225 | 255 | func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool { | |
| 226 | 256 | if kind != dto.VimPageVisual { | |
| 227 | 257 | return true | |
| @@ -279,13 +309,32 @@ func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPag | |||
| 279 | 309 | if err != nil { | |
| 280 | 310 | return err | |
| 281 | 311 | } | |
| 282 | - | if err := wv.scheduleJavaScript(vimPageScript("start", token, encoded)); err != nil { | |
| 312 | + | if err := wv.scheduleJavaScript(vimPageStartScript(token, encoded)); err != nil { | |
| 283 | 313 | wv.disarmVimPageInteraction() | |
| 284 | 314 | return err | |
| 285 | 315 | } | |
| 286 | 316 | return nil | |
| 287 | 317 | } | |
| 288 | 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. | |
| 321 | + | func 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. | |
| 327 | + | func (wv *WebView) grantVimPageCopyAllowance(token, key string) { | |
| 328 | + | if !vimPageKeyRequestsCopy(key) { | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | wv.mu.Lock() | |
| 332 | + | if wv.vimPageToken == token && wv.vimPageKind == dto.VimPageVisual { | |
| 333 | + | wv.vimPageCopyAllowance++ | |
| 334 | + | } | |
| 335 | + | wv.mu.Unlock() | |
| 336 | + | } | |
| 337 | + | ||
| 289 | 338 | // SendVimPageKey forwards one canonical key to the active page interaction. | |
| 290 | 339 | func (wv *WebView) SendVimPageKey(_ context.Context, key string) error { | |
| 291 | 340 | if wv == nil || wv.destroyed.Load() { | |
| @@ -295,7 +344,8 @@ func (wv *WebView) SendVimPageKey(_ context.Context, key string) error { | |||
| 295 | 344 | if key == "" || token == "" { | |
| 296 | 345 | return nil | |
| 297 | 346 | } | |
| 298 | - | return wv.scheduleJavaScript(vimPageScript("key", token, jsString(key))) | |
| 347 | + | wv.grantVimPageCopyAllowance(token, key) | |
| 348 | + | return wv.scheduleJavaScript(vimPageCallScript("key", token, jsString(key))) | |
| 299 | 349 | } | |
| 300 | 350 | ||
| 301 | 351 | // CancelVimPageInteraction removes any hint overlay or visual selection. | |
| @@ -308,7 +358,7 @@ func (wv *WebView) CancelVimPageInteraction(_ context.Context) error { | |||
| 308 | 358 | if token == "" { | |
| 309 | 359 | return nil | |
| 310 | 360 | } | |
| 311 | - | return wv.scheduleJavaScript(vimPageScript("cancel", token)) | |
| 361 | + | return wv.scheduleJavaScript(vimPageCallScript("cancel", token)) | |
| 312 | 362 | } | |
| 313 | 363 | ||
| 314 | 364 | // endVimPageInteractionOnNavigation drops an interaction whose document is | |
| @@ -326,26 +376,54 @@ func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBri | |||
| 326 | 376 | }) | |
| 327 | 377 | } | |
| 328 | 378 | ||
| 379 | + | // spendVimPageResultLocked validates the token of one result and applies its | |
| 380 | + | // effect on the armed state: a terminal result disarms the interaction, and a | |
| 381 | + | // visual copy spends one allowance. A visual copy is non-terminal, so the | |
| 382 | + | // token alone would let a page that read it write the clipboard repeatedly; | |
| 383 | + | // each accepted copy needs a y/Enter key the user pressed. wv.mu must be held. | |
| 384 | + | func (wv *WebView) spendVimPageResultLocked(payload vimPageBridgePayload) (valid, ends, copyDenied bool) { | |
| 385 | + | armed := wv.vimPageToken | |
| 386 | + | valid = armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1 | |
| 387 | + | if !valid { | |
| 388 | + | return false, false, false | |
| 389 | + | } | |
| 390 | + | kind := wv.vimPageKind | |
| 391 | + | if payload.Type == vimPageMessageCopy && kind == dto.VimPageVisual { | |
| 392 | + | if wv.vimPageCopyAllowance <= 0 { | |
| 393 | + | return true, false, true | |
| 394 | + | } | |
| 395 | + | wv.vimPageCopyAllowance-- | |
| 396 | + | } | |
| 397 | + | ends = vimPageResultEndsInteraction(kind, payload.Type) | |
| 398 | + | if ends { | |
| 399 | + | wv.vimPageToken = "" | |
| 400 | + | wv.vimPageKind = 0 | |
| 401 | + | wv.vimPageCopyAllowance = 0 | |
| 402 | + | } | |
| 403 | + | return true, ends, false | |
| 404 | + | } | |
| 405 | + | ||
| 329 | 406 | // handleVimPageResult accepts a result only for the interaction the user | |
| 330 | 407 | // armed; results from stale or forged interactions are dropped. | |
| 331 | 408 | func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | |
| 409 | + | if wv == nil || wv.destroyed.Load() { | |
| 410 | + | return | |
| 411 | + | } | |
| 332 | 412 | wv.mu.Lock() | |
| 333 | 413 | cb := wv.callbacks | |
| 334 | 414 | sourceURI := wv.uri | |
| 335 | - | armed := wv.vimPageToken | |
| 336 | 415 | kind := wv.vimPageKind | |
| 337 | 416 | generation := wv.vimPageGeneration | |
| 338 | - | valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1 | |
| 339 | - | ends := vimPageResultEndsInteraction(kind, payload.Type) | |
| 340 | - | if valid && ends { | |
| 341 | - | wv.vimPageToken = "" | |
| 342 | - | wv.vimPageKind = 0 | |
| 343 | - | } | |
| 417 | + | valid, ends, copyDenied := wv.spendVimPageResultLocked(payload) | |
| 344 | 418 | wv.mu.Unlock() | |
| 345 | 419 | if !valid { | |
| 346 | 420 | logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token") | |
| 347 | 421 | return | |
| 348 | 422 | } | |
| 423 | + | if copyDenied { | |
| 424 | + | logging.FromContext(wv.ctx).Debug().Msg("cef: vim page copy rejected — no user copy key pending") | |
| 425 | + | return | |
| 426 | + | } | |
| 349 | 427 | ||
| 350 | 428 | if ends { | |
| 351 | 429 | logging.FromContext(wv.ctx).Debug(). | |
| @@ -365,8 +443,10 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | |||
| 365 | 443 | case vimPageMessageMode: | |
| 366 | 444 | wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode)) | |
| 367 | 445 | case vimPageMessageCopy: | |
| 368 | - | if wv.engine != nil { | |
| 369 | - | wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text) | |
| 446 | + | // Results arrive on the CEF IO thread; the clipboard write belongs on GTK. | |
| 447 | + | if engine := wv.engine; engine != nil { | |
| 448 | + | id, text := wv.id, payload.Text | |
| 449 | + | wv.runOnGTK(func() { engine.handleExplicitClipboardBridgeText(id, "copy", text) }) | |
| 370 | 450 | } | |
| 371 | 451 | case vimPageMessageOpenNew: | |
| 372 | 452 | if !isNavigableVimHintURL(payload.URL, sourceURI) { | |
| @@ -4,10 +4,12 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/subtle" | 5 | "crypto/subtle" | ||
| 6 | _ "embed" | 6 | _ "embed" | ||
| 7 | + | "encoding/base64" | |||
| 7 | "encoding/json" | 8 | "encoding/json" | ||
| 8 | "errors" | 9 | "errors" | ||
| 9 | "fmt" | 10 | "fmt" | ||
| 10 | "net/url" | 11 | "net/url" | ||
| 12 | + | "regexp" | |||
| 11 | "strings" | 13 | "strings" | ||
| 12 | 14 | ||||
| 13 | purecef "github.com/bnema/purego-cef/cef" | 15 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -22,7 +24,13 @@ var _ port.VimPageInteractor = (*WebView)(nil) | |||||
| 22 | //go:embed vim_page_runtime.js | 24 | //go:embed vim_page_runtime.js | ||
| 23 | var vimPageRuntimeTemplateJS string | 25 | var vimPageRuntimeTemplateJS string | ||
| 24 | 26 | ||||
| 25 | - | var errVimPageTokenUnavailable = errors.New("vim page: interaction token unavailable") | 27 | + | var ( |
| 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,8}$`) | |||
| 33 | + | ) | |||
| 26 | 34 | ||||
| 27 | // vimPageBridgePayload is one result reported by the page runtime. Token is | 35 | // vimPageBridgePayload is one result reported by the page runtime. Token is | ||
| 28 | // the per-interaction secret issued by StartVimPageInteraction. | 36 | // the per-interaction secret issued by StartVimPageInteraction. | ||
| @@ -47,7 +55,7 @@ const ( | |||||
| 47 | func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) { | 55 | func decodeVimPageBridgePayload(body []byte) (vimPageBridgePayload, error) { | ||
| 48 | var payload vimPageBridgePayload | 56 | var payload vimPageBridgePayload | ||
| 49 | if len(body) > maxClipboardBytes { | 57 | if len(body) > maxClipboardBytes { | ||
| 50 | - | return payload, errors.New("payload too large") | 58 | + | return payload, errVimPagePayloadTooLarge |
| 51 | } | 59 | } | ||
| 52 | if err := json.Unmarshal(body, &payload); err != nil { | 60 | if err := json.Unmarshal(body, &payload); err != nil { | ||
| 53 | return payload, err | 61 | return payload, err | ||
| @@ -140,11 +148,15 @@ func vimPageRequestJSON(request dto.VimPageInteractionRequest) (string, error) { | |||||
| 140 | return "", fmt.Errorf("unsupported vim text object: %d", request.Object) | 148 | return "", fmt.Errorf("unsupported vim text object: %d", request.Object) | ||
| 141 | } | 149 | } | ||
| 142 | } | 150 | } | ||
| 151 | + | color := request.HighlightColor | |||
| 152 | + | if !vimPageAccentColor.MatchString(color) { | |||
| 153 | + | color = "" | |||
| 154 | + | } | |||
| 143 | encoded, err := json.Marshal(struct { | 155 | encoded, err := json.Marshal(struct { | ||
| 144 | Kind string `json:"kind"` | 156 | Kind string `json:"kind"` | ||
| 145 | Object string `json:"object,omitempty"` | 157 | Object string `json:"object,omitempty"` | ||
| 146 | Color string `json:"color,omitempty"` | 158 | Color string `json:"color,omitempty"` | ||
| 147 | - | }{Kind: kind, Object: object, Color: request.HighlightColor}) | 159 | + | }{Kind: kind, Object: object, Color: color}) |
| 148 | if err != nil { | 160 | if err != nil { | ||
| 149 | return "", err | 161 | return "", err | ||
| 150 | } | 162 | } | ||
| @@ -157,13 +169,28 @@ func jsString(s string) string { | |||||
| 157 | return string(encoded) | 169 | return string(encoded) | ||
| 158 | } | 170 | } | ||
| 159 | 171 | ||||
| 160 | - | // vimPageScript installs the runtime when absent and then invokes one method | 172 | + | // vimPageStartScript installs the runtime when absent and starts an |
| 161 | - | // with the interaction token. The token is a per-interaction secret, not the | 173 | + | // interaction. The token is a per-interaction secret, not the shared bridge |
| 162 | - | // shared bridge nonce, so a page that intercepts the call only learns a token | 174 | + | // nonce, so a page that intercepts the call only learns a token valid for the |
| 163 | - | // valid for the interaction the user just started. | 175 | + | // interaction the user just started. |
| 164 | - | func vimPageScript(method, token string, args ...string) string { | 176 | + | func 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. | |||
| 183 | + | func vimPageCallScript(method, token string, args ...string) string { | |||
| 165 | call := append([]string{jsString(token)}, args...) | 184 | call := append([]string{jsString(token)}, args...) | ||
| 166 | - | return vimPageRuntimeTemplateJS + "\nwindow.__dumberVimPage." + method + "(" + strings.Join(call, ", ") + ");" | 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. | |||
| 190 | + | func 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(() => {});` | |||
| 167 | } | 194 | } | ||
| 168 | 195 | ||||
| 169 | // armVimPageInteraction issues a fresh token for a new interaction and | 196 | // armVimPageInteraction issues a fresh token for a new interaction and | ||
| @@ -176,6 +203,7 @@ func (wv *WebView) armVimPageInteraction(kind dto.VimPageInteractionKind) (strin | |||||
| 176 | wv.mu.Lock() | 203 | wv.mu.Lock() | ||
| 177 | wv.vimPageToken = token | 204 | wv.vimPageToken = token | ||
| 178 | wv.vimPageKind = kind | 205 | wv.vimPageKind = kind | ||
| 206 | + | wv.vimPageCopyAllowance = 0 | |||
| 179 | wv.vimPageGeneration++ | 207 | wv.vimPageGeneration++ | ||
| 180 | wv.mu.Unlock() | 208 | wv.mu.Unlock() | ||
| 181 | return token, nil | 209 | return token, nil | ||
| @@ -195,6 +223,7 @@ func (wv *WebView) disarmVimPageInteraction() (bool, uint64) { | |||||
| 195 | captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys() | 223 | captured := wv.vimPageToken != "" && wv.vimPageKind.CapturesKeys() | ||
| 196 | wv.vimPageToken = "" | 224 | wv.vimPageToken = "" | ||
| 197 | wv.vimPageKind = 0 | 225 | wv.vimPageKind = 0 | ||
| 226 | + | wv.vimPageCopyAllowance = 0 | |||
| 198 | return captured, wv.vimPageGeneration | 227 | return captured, wv.vimPageGeneration | ||
| 199 | } | 228 | } | ||
| 200 | 229 | ||||
| @@ -220,8 +249,9 @@ func (wv *WebView) notifyVimPageInteractionEnded(generation uint64) { | |||||
| 220 | 249 | ||||
| 221 | // vimPageResultEndsInteraction reports whether a result spends the token. | 250 | // vimPageResultEndsInteraction reports whether a result spends the token. | ||
| 222 | // The caret and visual flow is long-lived: its mode changes and copies leave | 251 | // The caret and visual flow is long-lived: its mode changes and copies leave | ||
| 223 | - | // the interaction running so copies can be chained; every other result is | 252 | + | // the interaction running so copies can be chained. Copies stay bounded by |
| 224 | - | // terminal, so a token grants exactly one of them. | 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. | |||
| 225 | func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool { | 255 | func vimPageResultEndsInteraction(kind dto.VimPageInteractionKind, messageType string) bool { | ||
| 226 | if kind != dto.VimPageVisual { | 256 | if kind != dto.VimPageVisual { | ||
| 227 | return true | 257 | return true | ||
| @@ -279,13 +309,32 @@ func (wv *WebView) StartVimPageInteraction(_ context.Context, request dto.VimPag | |||||
| 279 | if err != nil { | 309 | if err != nil { | ||
| 280 | return err | 310 | return err | ||
| 281 | } | 311 | } | ||
| 282 | - | if err := wv.scheduleJavaScript(vimPageScript("start", token, encoded)); err != nil { | 312 | + | if err := wv.scheduleJavaScript(vimPageStartScript(token, encoded)); err != nil { |
| 283 | wv.disarmVimPageInteraction() | 313 | wv.disarmVimPageInteraction() | ||
| 284 | return err | 314 | return err | ||
| 285 | } | 315 | } | ||
| 286 | return nil | 316 | return nil | ||
| 287 | } | 317 | } | ||
| 288 | 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. | |||
| 321 | + | func 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. | |||
| 327 | + | func (wv *WebView) grantVimPageCopyAllowance(token, key string) { | |||
| 328 | + | if !vimPageKeyRequestsCopy(key) { | |||
| 329 | + | return | |||
| 330 | + | } | |||
| 331 | + | wv.mu.Lock() | |||
| 332 | + | if wv.vimPageToken == token && wv.vimPageKind == dto.VimPageVisual { | |||
| 333 | + | wv.vimPageCopyAllowance++ | |||
| 334 | + | } | |||
| 335 | + | wv.mu.Unlock() | |||
| 336 | + | } | |||
| 337 | + | ||||
| 289 | // SendVimPageKey forwards one canonical key to the active page interaction. | 338 | // SendVimPageKey forwards one canonical key to the active page interaction. | ||
| 290 | func (wv *WebView) SendVimPageKey(_ context.Context, key string) error { | 339 | func (wv *WebView) SendVimPageKey(_ context.Context, key string) error { | ||
| 291 | if wv == nil || wv.destroyed.Load() { | 340 | if wv == nil || wv.destroyed.Load() { | ||
| @@ -295,7 +344,8 @@ func (wv *WebView) SendVimPageKey(_ context.Context, key string) error { | |||||
| 295 | if key == "" || token == "" { | 344 | if key == "" || token == "" { | ||
| 296 | return nil | 345 | return nil | ||
| 297 | } | 346 | } | ||
| 298 | - | return wv.scheduleJavaScript(vimPageScript("key", token, jsString(key))) | 347 | + | wv.grantVimPageCopyAllowance(token, key) |
| 348 | + | return wv.scheduleJavaScript(vimPageCallScript("key", token, jsString(key))) | |||
| 299 | } | 349 | } | ||
| 300 | 350 | ||||
| 301 | // CancelVimPageInteraction removes any hint overlay or visual selection. | 351 | // CancelVimPageInteraction removes any hint overlay or visual selection. | ||
| @@ -308,7 +358,7 @@ func (wv *WebView) CancelVimPageInteraction(_ context.Context) error { | |||||
| 308 | if token == "" { | 358 | if token == "" { | ||
| 309 | return nil | 359 | return nil | ||
| 310 | } | 360 | } | ||
| 311 | - | return wv.scheduleJavaScript(vimPageScript("cancel", token)) | 361 | + | return wv.scheduleJavaScript(vimPageCallScript("cancel", token)) |
| 312 | } | 362 | } | ||
| 313 | 363 | ||||
| 314 | // endVimPageInteractionOnNavigation drops an interaction whose document is | 364 | // endVimPageInteractionOnNavigation drops an interaction whose document is | ||
| @@ -326,26 +376,54 @@ func (e *Engine) handleVimPageBridge(browser purecef.Browser, payload vimPageBri | |||||
| 326 | }) | 376 | }) | ||
| 327 | } | 377 | } | ||
| 328 | 378 | ||||
| 379 | + | // spendVimPageResultLocked validates the token of one result and applies its | |||
| 380 | + | // effect on the armed state: a terminal result disarms the interaction, and a | |||
| 381 | + | // visual copy spends one allowance. A visual copy is non-terminal, so the | |||
| 382 | + | // token alone would let a page that read it write the clipboard repeatedly; | |||
| 383 | + | // each accepted copy needs a y/Enter key the user pressed. wv.mu must be held. | |||
| 384 | + | func (wv *WebView) spendVimPageResultLocked(payload vimPageBridgePayload) (valid, ends, copyDenied bool) { | |||
| 385 | + | armed := wv.vimPageToken | |||
| 386 | + | valid = armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1 | |||
| 387 | + | if !valid { | |||
| 388 | + | return false, false, false | |||
| 389 | + | } | |||
| 390 | + | kind := wv.vimPageKind | |||
| 391 | + | if payload.Type == vimPageMessageCopy && kind == dto.VimPageVisual { | |||
| 392 | + | if wv.vimPageCopyAllowance <= 0 { | |||
| 393 | + | return true, false, true | |||
| 394 | + | } | |||
| 395 | + | wv.vimPageCopyAllowance-- | |||
| 396 | + | } | |||
| 397 | + | ends = vimPageResultEndsInteraction(kind, payload.Type) | |||
| 398 | + | if ends { | |||
| 399 | + | wv.vimPageToken = "" | |||
| 400 | + | wv.vimPageKind = 0 | |||
| 401 | + | wv.vimPageCopyAllowance = 0 | |||
| 402 | + | } | |||
| 403 | + | return true, ends, false | |||
| 404 | + | } | |||
| 405 | + | ||||
| 329 | // handleVimPageResult accepts a result only for the interaction the user | 406 | // handleVimPageResult accepts a result only for the interaction the user | ||
| 330 | // armed; results from stale or forged interactions are dropped. | 407 | // armed; results from stale or forged interactions are dropped. | ||
| 331 | func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | 408 | func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | ||
| 409 | + | if wv == nil || wv.destroyed.Load() { | |||
| 410 | + | return | |||
| 411 | + | } | |||
| 332 | wv.mu.Lock() | 412 | wv.mu.Lock() | ||
| 333 | cb := wv.callbacks | 413 | cb := wv.callbacks | ||
| 334 | sourceURI := wv.uri | 414 | sourceURI := wv.uri | ||
| 335 | - | armed := wv.vimPageToken | |||
| 336 | kind := wv.vimPageKind | 415 | kind := wv.vimPageKind | ||
| 337 | generation := wv.vimPageGeneration | 416 | generation := wv.vimPageGeneration | ||
| 338 | - | valid := armed != "" && subtle.ConstantTimeCompare([]byte(armed), []byte(payload.Token)) == 1 | 417 | + | valid, ends, copyDenied := wv.spendVimPageResultLocked(payload) |
| 339 | - | ends := vimPageResultEndsInteraction(kind, payload.Type) | |||
| 340 | - | if valid && ends { | |||
| 341 | - | wv.vimPageToken = "" | |||
| 342 | - | wv.vimPageKind = 0 | |||
| 343 | - | } | |||
| 344 | wv.mu.Unlock() | 418 | wv.mu.Unlock() | ||
| 345 | if !valid { | 419 | if !valid { | ||
| 346 | logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token") | 420 | logging.FromContext(wv.ctx).Debug().Str("type", payload.Type).Msg("cef: vim page result rejected — stale or unknown token") | ||
| 347 | return | 421 | return | ||
| 348 | } | 422 | } | ||
| 423 | + | if copyDenied { | |||
| 424 | + | logging.FromContext(wv.ctx).Debug().Msg("cef: vim page copy rejected — no user copy key pending") | |||
| 425 | + | return | |||
| 426 | + | } | |||
| 349 | 427 | ||||
| 350 | if ends { | 428 | if ends { | ||
| 351 | logging.FromContext(wv.ctx).Debug(). | 429 | logging.FromContext(wv.ctx).Debug(). | ||
| @@ -365,8 +443,10 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) { | |||||
| 365 | case vimPageMessageMode: | 443 | case vimPageMessageMode: | ||
| 366 | wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode)) | 444 | wv.notifyVimPageModeChanged(generation, dto.VimPageMode(payload.Mode)) | ||
| 367 | case vimPageMessageCopy: | 445 | case vimPageMessageCopy: | ||
| 368 | - | if wv.engine != nil { | 446 | + | // Results arrive on the CEF IO thread; the clipboard write belongs on GTK. |
| 369 | - | wv.engine.handleExplicitClipboardBridgeText(wv.id, "copy", payload.Text) | 447 | + | if engine := wv.engine; engine != nil { |
| 448 | + | id, text := wv.id, payload.Text | |||
| 449 | + | wv.runOnGTK(func() { engine.handleExplicitClipboardBridgeText(id, "copy", text) }) | |||
| 370 | } | 450 | } | ||
| 371 | case vimPageMessageOpenNew: | 451 | case vimPageMessageOpenNew: | ||
| 372 | if !isNavigableVimHintURL(payload.URL, sourceURI) { | 452 | if !isNavigableVimHintURL(payload.URL, sourceURI) { | ||
M internal/infrastructure/cef/vim_page_test.go +210 -13
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/base64" | |
| 6 | 6 | "net/http" | |
| 7 | + | "regexp" | |
| 8 | + | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | ||
| 9 | 11 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -16,21 +18,20 @@ import ( | |||
| 16 | 18 | "github.com/bnema/dumber/internal/application/port" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | - | func expectVimPageScript(t *testing.T, fragments ...string) *WebView { | |
| 21 | + | // expectVimPageScript expects one ExecuteJavaScript call and captures the | |
| 22 | + | // script that was actually sent to the page. | |
| 23 | + | func expectVimPageScript(t *testing.T) (*WebView, *string) { | |
| 20 | 24 | t.Helper() | |
| 21 | 25 | browser := cefmocks.NewMockBrowser(t) | |
| 22 | 26 | frame := cefmocks.NewMockFrame(t) | |
| 27 | + | var script string | |
| 23 | 28 | browser.EXPECT().GetMainFrame().Return(frame).Once() | |
| 24 | - | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, fragments...)), "", int32(0)).Once() | |
| 25 | - | return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"} | |
| 29 | + | frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(code, _ string, _ int32) { script = code }).Once() | |
| 30 | + | return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"}, &script | |
| 26 | 31 | } | |
| 27 | 32 | ||
| 28 | 33 | func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) { | |
| 29 | - | wv := expectVimPageScript(t, | |
| 30 | - | "if (window.__dumberVimPage) return;", | |
| 31 | - | `dumb:///api/vim-page`, | |
| 32 | - | `{"kind":"yank","object":"code","color":"#123456"});`, | |
| 33 | - | ) | |
| 34 | + | wv, script := expectVimPageScript(t) | |
| 34 | 35 | ||
| 35 | 36 | err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{ | |
| 36 | 37 | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456", | |
| @@ -39,16 +40,38 @@ func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testi | |||
| 39 | 40 | token := wv.currentVimPageToken() | |
| 40 | 41 | require.NotEmpty(t, token) | |
| 41 | 42 | assert.NotEqual(t, "shared-bridge-nonce", token) | |
| 42 | - | assert.NotContains(t, vimPageScript("start", token, "{}"), "shared-bridge-nonce") | |
| 43 | - | assert.Contains(t, vimPageScript("start", token, "{}"), `window.__dumberVimPage.start("`+token+`", {});`) | |
| 43 | + | assert.Contains(t, *script, "if (window.__dumberVimPage) return;", "start ships the runtime") | |
| 44 | + | assert.Contains(t, *script, `dumb:///api/vim-page`) | |
| 45 | + | assert.NotContains(t, *script, "shared-bridge-nonce") | |
| 46 | + | assert.Contains(t, *script, `window.__dumberVimPage.start("`+token+`", {"kind":"yank","object":"code","color":"#123456"});`) | |
| 44 | 47 | } | |
| 45 | 48 | ||
| 46 | - | func TestWebViewSendVimPageKeyEncodesKeyAsJSString(t *testing.T) { | |
| 47 | - | wv := expectVimPageScript(t, `window.__dumberVimPage.key("tok", "\"\u0007");`) | |
| 49 | + | // Keys and cancels run on the already installed runtime: shipping the whole | |
| 50 | + | // runtime for every key press would cost tens of kilobytes per keystroke. | |
| 51 | + | func TestWebViewSendVimPageKeyDoesNotShipRuntime(t *testing.T) { | |
| 52 | + | wv, script := expectVimPageScript(t) | |
| 48 | 53 | wv.vimPageToken = "tok" | |
| 49 | 54 | ||
| 50 | 55 | require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a")) | |
| 51 | 56 | require.NoError(t, wv.SendVimPageKey(context.Background(), "")) | |
| 57 | + | ||
| 58 | + | assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.key("tok", "\"\u0007") : `), *script) | |
| 59 | + | assert.NotContains(t, *script, "if (window.__dumberVimPage) return;") | |
| 60 | + | assert.Less(t, len(*script), 1024) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // A document replaced between start and key leaves no runtime; the script must | |
| 64 | + | // then report the end of that token so the UI releases its key capture. | |
| 65 | + | func TestVimPageCallScriptReportsEndWhenRuntimeMissing(t *testing.T) { | |
| 66 | + | script := vimPageCallScript("cancel", "tok") | |
| 67 | + | ||
| 68 | + | assert.Contains(t, script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `) | |
| 69 | + | assert.Contains(t, script, `dumb:///api/vim-page`) | |
| 70 | + | match := regexp.MustCompile(`"X-Dumber-Body": "([^"]+)"`).FindStringSubmatch(script) | |
| 71 | + | require.Len(t, match, 2) | |
| 72 | + | body, err := base64.StdEncoding.DecodeString(match[1]) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | assert.JSONEq(t, `{"token":"tok","type":"end"}`, string(body)) | |
| 52 | 75 | } | |
| 53 | 76 | ||
| 54 | 77 | func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) { | |
| @@ -58,10 +81,11 @@ func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) { | |||
| 58 | 81 | } | |
| 59 | 82 | ||
| 60 | 83 | func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) { | |
| 61 | - | wv := expectVimPageScript(t, `window.__dumberVimPage.cancel("tok");`) | |
| 84 | + | wv, script := expectVimPageScript(t) | |
| 62 | 85 | wv.vimPageToken = "tok" | |
| 63 | 86 | ||
| 64 | 87 | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | |
| 88 | + | assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `), *script) | |
| 65 | 89 | assert.Empty(t, wv.currentVimPageToken()) | |
| 66 | 90 | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | |
| 67 | 91 | } | |
| @@ -77,6 +101,26 @@ func TestVimPageRequestJSONRejectsUnknownRequests(t *testing.T) { | |||
| 77 | 101 | assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got) | |
| 78 | 102 | } | |
| 79 | 103 | ||
| 104 | + | // The accent reaches the page as a CSS color; only hex colors are sent. | |
| 105 | + | func TestVimPageRequestJSONValidatesAccentColor(t *testing.T) { | |
| 106 | + | for color, want := range map[string]string{ | |
| 107 | + | "#fbbf24": `{"kind":"visual","color":"#fbbf24"}`, | |
| 108 | + | "#FFF": `{"kind":"visual","color":"#FFF"}`, | |
| 109 | + | "#aabbccdd": `{"kind":"visual","color":"#aabbccdd"}`, | |
| 110 | + | "": `{"kind":"visual"}`, | |
| 111 | + | "red": `{"kind":"visual"}`, | |
| 112 | + | "#12": `{"kind":"visual"}`, | |
| 113 | + | "#123456789": `{"kind":"visual"}`, | |
| 114 | + | "#abc;} body{display:none": `{"kind":"visual"}`, | |
| 115 | + | "url(javascript:alert(1))": `{"kind":"visual"}`, | |
| 116 | + | "#abc\"; alert(1); //": `{"kind":"visual"}`, | |
| 117 | + | } { | |
| 118 | + | got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: color}) | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | assert.JSONEq(t, want, got, "color %q", color) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 80 | 124 | func TestDecodeVimPageBridgePayload(t *testing.T) { | |
| 81 | 125 | tests := []struct { | |
| 82 | 126 | name string | |
| @@ -134,6 +178,7 @@ func TestWebViewHandleVimPageResultVisualFlowStaysArmedAcrossModeAndCopy(t *test | |||
| 134 | 178 | wv := newVimResultWebView(&opened, &ended) | |
| 135 | 179 | wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) } | |
| 136 | 180 | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual | |
| 181 | + | wv.vimPageCopyAllowance = 1 | |
| 137 | 182 | ||
| 138 | 183 | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"}) | |
| 139 | 184 | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"}) | |
| @@ -301,3 +346,155 @@ func TestWebViewVimPageEndedCallbackSkippedAfterRearm(t *testing.T) { | |||
| 301 | 346 | ||
| 302 | 347 | assert.Zero(t, ended) | |
| 303 | 348 | } | |
| 349 | + | ||
| 350 | + | // handleVimPageResult runs on the CEF IO thread. The clipboard write must be | |
| 351 | + | // handed to GTK instead of running there. | |
| 352 | + | func newVimClipboardWebView(t *testing.T, kind dto.VimPageInteractionKind) (*WebView, *recordingClipboardTextOrchestrator, *[]func()) { | |
| 353 | + | t.Helper() | |
| 354 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |
| 355 | + | queued := &[]func(){} | |
| 356 | + | wv := &WebView{ | |
| 357 | + | ctx: context.Background(), id: 7, uri: "https://site.test/", | |
| 358 | + | engine: &Engine{clipboardTextOrchestrator: orchestrator}, | |
| 359 | + | gtkDispatch: func(fn func()) { *queued = append(*queued, fn) }, | |
| 360 | + | callbacks: &port.WebViewCallbacks{}, | |
| 361 | + | } | |
| 362 | + | wv.vimPageToken, wv.vimPageKind = "tok", kind | |
| 363 | + | return wv, orchestrator, queued | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func TestWebViewHandleVimPageResultCopyRunsOnGTK(t *testing.T) { | |
| 367 | + | wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject) | |
| 368 | + | ||
| 369 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "yanked"}) | |
| 370 | + | ||
| 371 | + | assert.Empty(t, orchestrator.explicit.Text, "the clipboard must not be written on the IO thread") | |
| 372 | + | require.Len(t, *queued, 1) | |
| 373 | + | (*queued)[0]() | |
| 374 | + | assert.Equal(t, dto.ExplicitClipboardInput{ | |
| 375 | + | Text: "yanked", Action: "copy", SourceEngine: dto.ClipboardSourceCEF, ViewID: 7, | |
| 376 | + | }, orchestrator.explicit) | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // A hostile page that read the token must not be able to write the clipboard | |
| 380 | + | // again and again: visual copies are non-terminal, so each needs a y/Enter the | |
| 381 | + | // user pressed and Go forwarded. | |
| 382 | + | func TestWebViewHandleVimPageResultVisualCopyNeedsForwardedCopyKey(t *testing.T) { | |
| 383 | + | wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageVisual) | |
| 384 | + | copyResult := vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "stolen"} | |
| 385 | + | ||
| 386 | + | wv.handleVimPageResult(copyResult) | |
| 387 | + | assert.Empty(t, *queued, "no key was forwarded: the copy is refused") | |
| 388 | + | assert.Equal(t, "tok", wv.currentVimPageToken(), "a refused copy leaves the interaction running") | |
| 389 | + | ||
| 390 | + | wv.grantVimPageCopyAllowance("tok", "j") | |
| 391 | + | wv.handleVimPageResult(copyResult) | |
| 392 | + | assert.Empty(t, *queued, "motion keys grant no copy") | |
| 393 | + | ||
| 394 | + | wv.grantVimPageCopyAllowance("tok", "y") | |
| 395 | + | wv.handleVimPageResult(copyResult) | |
| 396 | + | wv.handleVimPageResult(copyResult) | |
| 397 | + | require.Len(t, *queued, 1, "one forwarded key allows exactly one copy") | |
| 398 | + | (*queued)[0]() | |
| 399 | + | assert.Equal(t, "stolen", orchestrator.explicit.Text) | |
| 400 | + | ||
| 401 | + | wv.grantVimPageCopyAllowance("tok", "<Return>") | |
| 402 | + | wv.grantVimPageCopyAllowance("other", "y") | |
| 403 | + | wv.handleVimPageResult(copyResult) | |
| 404 | + | assert.Len(t, *queued, 2) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | func TestWebViewSendVimPageKeyGrantsCopyAllowanceOnlyForVisual(t *testing.T) { | |
| 408 | + | for kind, want := range map[dto.VimPageInteractionKind]int{dto.VimPageVisual: 2, dto.VimPageHintYankURL: 0} { | |
| 409 | + | browser := cefmocks.NewMockBrowser(t) | |
| 410 | + | frame := cefmocks.NewMockFrame(t) | |
| 411 | + | browser.EXPECT().GetMainFrame().Return(frame).Times(3) | |
| 412 | + | frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Times(3) | |
| 413 | + | wv := &WebView{browser: browser, vimPageToken: "tok", vimPageKind: kind} | |
| 414 | + | ||
| 415 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "y")) | |
| 416 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "j")) | |
| 417 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "<Return>")) | |
| 418 | + | ||
| 419 | + | assert.Equal(t, want, wv.vimPageCopyAllowance, "kind %d", kind) | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | ||
| 423 | + | func TestWebViewArmVimPageInteractionResetsCopyAllowance(t *testing.T) { | |
| 424 | + | wv := &WebView{vimPageToken: "old", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 3} | |
| 425 | + | ||
| 426 | + | _, err := wv.armVimPageInteraction(dto.VimPageVisual) | |
| 427 | + | require.NoError(t, err) | |
| 428 | + | assert.Zero(t, wv.vimPageCopyAllowance) | |
| 429 | + | wv.vimPageCopyAllowance = 2 | |
| 430 | + | wv.disarmVimPageInteraction() | |
| 431 | + | assert.Zero(t, wv.vimPageCopyAllowance) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | func TestWebViewHandleVimPageResultIgnoredAfterDestroy(t *testing.T) { | |
| 435 | + | wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject) | |
| 436 | + | ended := 0 | |
| 437 | + | wv.callbacks.OnVimPageInteractionEnded = func() { ended++ } | |
| 438 | + | wv.destroyed.Store(true) | |
| 439 | + | ||
| 440 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "late"}) | |
| 441 | + | ||
| 442 | + | assert.Empty(t, *queued) | |
| 443 | + | assert.Empty(t, orchestrator.explicit.Text) | |
| 444 | + | assert.Zero(t, ended) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | func TestWebViewDestroyClearsVimPageInteraction(t *testing.T) { | |
| 448 | + | wv := &WebView{ctx: context.Background(), vimPageToken: "tok", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 1} | |
| 449 | + | ||
| 450 | + | wv.Destroy() | |
| 451 | + | ||
| 452 | + | assert.Empty(t, wv.currentVimPageToken()) | |
| 453 | + | assert.Zero(t, wv.vimPageKind) | |
| 454 | + | assert.Zero(t, wv.vimPageCopyAllowance) | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | // A crashed renderer takes the page runtime with it, so it can never report | |
| 458 | + | // the end; the render-process-terminated handler must release the capture. | |
| 459 | + | func TestHandlerSetOnRenderProcessTerminatedEndsVimPageInteraction(t *testing.T) { | |
| 460 | + | var opened string | |
| 461 | + | ended := 0 | |
| 462 | + | wv := newVimResultWebView(&opened, &ended) | |
| 463 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual | |
| 464 | + | h := &handlerSet{wv: wv} | |
| 465 | + | ||
| 466 | + | h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "") | |
| 467 | + | ||
| 468 | + | assert.Equal(t, 1, ended) | |
| 469 | + | assert.Empty(t, wv.currentVimPageToken()) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | func TestSchemeHandler_APIVimPageRejectsOversizedPayloadWith413(t *testing.T) { | |
| 473 | + | oldNewResourceHandler := cefNewResourceHandler | |
| 474 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |
| 475 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 476 | + | ||
| 477 | + | h := newTestDumbSchemeHandler(t) | |
| 478 | + | h.onVimPage = func(purecef.Browser, vimPageBridgePayload) { t.Fatal("oversized payload must not reach the handler") } | |
| 479 | + | request := cefmocks.NewMockRequest(t) | |
| 480 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"type":"copy","token":"t","text":"` + strings.Repeat("a", maxClipboardBytes) + `"}`)) | |
| 481 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |
| 482 | + | response := cefmocks.NewMockResponse(t) | |
| 483 | + | response.EXPECT().SetStatus(int32(http.StatusRequestEntityTooLarge)).Once() | |
| 484 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusRequestEntityTooLarge)).Once() | |
| 485 | + | response.EXPECT().SetMimeType("application/json").Once() | |
| 486 | + | expectAPIResponseHeaders(response) | |
| 487 | + | ||
| 488 | + | handler := h.handleAPI(cefmocks.NewMockBrowser(t), http.MethodPost, "/api/vim-page", request) | |
| 489 | + | require.NotNil(t, handler) | |
| 490 | + | var length int64 | |
| 491 | + | handler.GetResponseHeaders(response, &length, 0) | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | func TestDecodeVimPageBridgePayloadOversizedIsSentinel(t *testing.T) { | |
| 495 | + | _, err := decodeVimPageBridgePayload(make([]byte, maxClipboardBytes+1)) | |
| 496 | + | require.ErrorIs(t, err, errVimPagePayloadTooLarge) | |
| 497 | + | _, err = decodeVimPageBridgePayload([]byte(`{`)) | |
| 498 | + | require.Error(t, err) | |
| 499 | + | require.NotErrorIs(t, err, errVimPagePayloadTooLarge) | |
| 500 | + | } | |
| @@ -4,6 +4,8 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/base64" | 5 | "encoding/base64" | ||
| 6 | "net/http" | 6 | "net/http" | ||
| 7 | + | "regexp" | |||
| 8 | + | "strings" | |||
| 7 | "testing" | 9 | "testing" | ||
| 8 | 10 | ||||
| 9 | purecef "github.com/bnema/purego-cef/cef" | 11 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -16,21 +18,20 @@ import ( | |||||
| 16 | "github.com/bnema/dumber/internal/application/port" | 18 | "github.com/bnema/dumber/internal/application/port" | ||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| 19 | - | func expectVimPageScript(t *testing.T, fragments ...string) *WebView { | 21 | + | // expectVimPageScript expects one ExecuteJavaScript call and captures the |
| 22 | + | // script that was actually sent to the page. | |||
| 23 | + | func expectVimPageScript(t *testing.T) (*WebView, *string) { | |||
| 20 | t.Helper() | 24 | t.Helper() | ||
| 21 | browser := cefmocks.NewMockBrowser(t) | 25 | browser := cefmocks.NewMockBrowser(t) | ||
| 22 | frame := cefmocks.NewMockFrame(t) | 26 | frame := cefmocks.NewMockFrame(t) | ||
| 27 | + | var script string | |||
| 23 | browser.EXPECT().GetMainFrame().Return(frame).Once() | 28 | browser.EXPECT().GetMainFrame().Return(frame).Once() | ||
| 24 | - | frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t, fragments...)), "", int32(0)).Once() | 29 | + | frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(code, _ string, _ int32) { script = code }).Once() |
| 25 | - | return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"} | 30 | + | return &WebView{browser: browser, bridgeNonce: "shared-bridge-nonce"}, &script |
| 26 | } | 31 | } | ||
| 27 | 32 | ||||
| 28 | func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) { | 33 | func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testing.T) { | ||
| 29 | - | wv := expectVimPageScript(t, | 34 | + | wv, script := expectVimPageScript(t) |
| 30 | - | "if (window.__dumberVimPage) return;", | |||
| 31 | - | `dumb:///api/vim-page`, | |||
| 32 | - | `{"kind":"yank","object":"code","color":"#123456"});`, | |||
| 33 | - | ) | |||
| 34 | 35 | ||||
| 35 | err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{ | 36 | err := wv.StartVimPageInteraction(context.Background(), dto.VimPageInteractionRequest{ | ||
| 36 | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456", | 37 | Kind: dto.VimPageYankObject, Object: dto.VimPageTextObjectCode, HighlightColor: "#123456", | ||
| @@ -39,16 +40,38 @@ func TestWebViewStartVimPageInteractionArmsFreshTokenWithoutBridgeNonce(t *testi | |||||
| 39 | token := wv.currentVimPageToken() | 40 | token := wv.currentVimPageToken() | ||
| 40 | require.NotEmpty(t, token) | 41 | require.NotEmpty(t, token) | ||
| 41 | assert.NotEqual(t, "shared-bridge-nonce", token) | 42 | assert.NotEqual(t, "shared-bridge-nonce", token) | ||
| 42 | - | assert.NotContains(t, vimPageScript("start", token, "{}"), "shared-bridge-nonce") | 43 | + | assert.Contains(t, *script, "if (window.__dumberVimPage) return;", "start ships the runtime") |
| 43 | - | assert.Contains(t, vimPageScript("start", token, "{}"), `window.__dumberVimPage.start("`+token+`", {});`) | 44 | + | assert.Contains(t, *script, `dumb:///api/vim-page`) |
| 45 | + | assert.NotContains(t, *script, "shared-bridge-nonce") | |||
| 46 | + | assert.Contains(t, *script, `window.__dumberVimPage.start("`+token+`", {"kind":"yank","object":"code","color":"#123456"});`) | |||
| 44 | } | 47 | } | ||
| 45 | 48 | ||||
| 46 | - | func TestWebViewSendVimPageKeyEncodesKeyAsJSString(t *testing.T) { | 49 | + | // Keys and cancels run on the already installed runtime: shipping the whole |
| 47 | - | wv := expectVimPageScript(t, `window.__dumberVimPage.key("tok", "\"\u0007");`) | 50 | + | // runtime for every key press would cost tens of kilobytes per keystroke. |
| 51 | + | func TestWebViewSendVimPageKeyDoesNotShipRuntime(t *testing.T) { | |||
| 52 | + | wv, script := expectVimPageScript(t) | |||
| 48 | wv.vimPageToken = "tok" | 53 | wv.vimPageToken = "tok" | ||
| 49 | 54 | ||||
| 50 | require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a")) | 55 | require.NoError(t, wv.SendVimPageKey(context.Background(), "\"\a")) | ||
| 51 | require.NoError(t, wv.SendVimPageKey(context.Background(), "")) | 56 | require.NoError(t, wv.SendVimPageKey(context.Background(), "")) | ||
| 57 | + | ||||
| 58 | + | assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.key("tok", "\"\u0007") : `), *script) | |||
| 59 | + | assert.NotContains(t, *script, "if (window.__dumberVimPage) return;") | |||
| 60 | + | assert.Less(t, len(*script), 1024) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // A document replaced between start and key leaves no runtime; the script must | |||
| 64 | + | // then report the end of that token so the UI releases its key capture. | |||
| 65 | + | func TestVimPageCallScriptReportsEndWhenRuntimeMissing(t *testing.T) { | |||
| 66 | + | script := vimPageCallScript("cancel", "tok") | |||
| 67 | + | ||||
| 68 | + | assert.Contains(t, script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `) | |||
| 69 | + | assert.Contains(t, script, `dumb:///api/vim-page`) | |||
| 70 | + | match := regexp.MustCompile(`"X-Dumber-Body": "([^"]+)"`).FindStringSubmatch(script) | |||
| 71 | + | require.Len(t, match, 2) | |||
| 72 | + | body, err := base64.StdEncoding.DecodeString(match[1]) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | assert.JSONEq(t, `{"token":"tok","type":"end"}`, string(body)) | |||
| 52 | } | 75 | } | ||
| 53 | 76 | ||||
| 54 | func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) { | 77 | func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) { | ||
| @@ -58,10 +81,11 @@ func TestWebViewSendVimPageKeyWithoutInteractionIsNoop(t *testing.T) { | |||||
| 58 | } | 81 | } | ||
| 59 | 82 | ||||
| 60 | func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) { | 83 | func TestWebViewCancelVimPageInteractionDisarmsToken(t *testing.T) { | ||
| 61 | - | wv := expectVimPageScript(t, `window.__dumberVimPage.cancel("tok");`) | 84 | + | wv, script := expectVimPageScript(t) |
| 62 | wv.vimPageToken = "tok" | 85 | wv.vimPageToken = "tok" | ||
| 63 | 86 | ||||
| 64 | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | 87 | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | ||
| 88 | + | assert.True(t, strings.HasPrefix(*script, `window.__dumberVimPage ? window.__dumberVimPage.cancel("tok") : `), *script) | |||
| 65 | assert.Empty(t, wv.currentVimPageToken()) | 89 | assert.Empty(t, wv.currentVimPageToken()) | ||
| 66 | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | 90 | require.NoError(t, wv.CancelVimPageInteraction(context.Background())) | ||
| 67 | } | 91 | } | ||
| @@ -77,6 +101,26 @@ func TestVimPageRequestJSONRejectsUnknownRequests(t *testing.T) { | |||||
| 77 | assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got) | 101 | assert.JSONEq(t, `{"kind":"hint-follow-new"}`, got) | ||
| 78 | } | 102 | } | ||
| 79 | 103 | ||||
| 104 | + | // The accent reaches the page as a CSS color; only hex colors are sent. | |||
| 105 | + | func TestVimPageRequestJSONValidatesAccentColor(t *testing.T) { | |||
| 106 | + | for color, want := range map[string]string{ | |||
| 107 | + | "#fbbf24": `{"kind":"visual","color":"#fbbf24"}`, | |||
| 108 | + | "#FFF": `{"kind":"visual","color":"#FFF"}`, | |||
| 109 | + | "#aabbccdd": `{"kind":"visual","color":"#aabbccdd"}`, | |||
| 110 | + | "": `{"kind":"visual"}`, | |||
| 111 | + | "red": `{"kind":"visual"}`, | |||
| 112 | + | "#12": `{"kind":"visual"}`, | |||
| 113 | + | "#123456789": `{"kind":"visual"}`, | |||
| 114 | + | "#abc;} body{display:none": `{"kind":"visual"}`, | |||
| 115 | + | "url(javascript:alert(1))": `{"kind":"visual"}`, | |||
| 116 | + | "#abc\"; alert(1); //": `{"kind":"visual"}`, | |||
| 117 | + | } { | |||
| 118 | + | got, err := vimPageRequestJSON(dto.VimPageInteractionRequest{Kind: dto.VimPageVisual, HighlightColor: color}) | |||
| 119 | + | require.NoError(t, err) | |||
| 120 | + | assert.JSONEq(t, want, got, "color %q", color) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 80 | func TestDecodeVimPageBridgePayload(t *testing.T) { | 124 | func TestDecodeVimPageBridgePayload(t *testing.T) { | ||
| 81 | tests := []struct { | 125 | tests := []struct { | ||
| 82 | name string | 126 | name string | ||
| @@ -134,6 +178,7 @@ func TestWebViewHandleVimPageResultVisualFlowStaysArmedAcrossModeAndCopy(t *test | |||||
| 134 | wv := newVimResultWebView(&opened, &ended) | 178 | wv := newVimResultWebView(&opened, &ended) | ||
| 135 | wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) } | 179 | wv.callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) { modes = append(modes, mode) } | ||
| 136 | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual | 180 | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual | ||
| 181 | + | wv.vimPageCopyAllowance = 1 | |||
| 137 | 182 | ||||
| 138 | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"}) | 183 | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "caret"}) | ||
| 139 | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"}) | 184 | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageMode, Token: "tok", Mode: "visual-line"}) | ||
| @@ -301,3 +346,155 @@ func TestWebViewVimPageEndedCallbackSkippedAfterRearm(t *testing.T) { | |||||
| 301 | 346 | ||||
| 302 | assert.Zero(t, ended) | 347 | assert.Zero(t, ended) | ||
| 303 | } | 348 | } | ||
| 349 | + | ||||
| 350 | + | // handleVimPageResult runs on the CEF IO thread. The clipboard write must be | |||
| 351 | + | // handed to GTK instead of running there. | |||
| 352 | + | func newVimClipboardWebView(t *testing.T, kind dto.VimPageInteractionKind) (*WebView, *recordingClipboardTextOrchestrator, *[]func()) { | |||
| 353 | + | t.Helper() | |||
| 354 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |||
| 355 | + | queued := &[]func(){} | |||
| 356 | + | wv := &WebView{ | |||
| 357 | + | ctx: context.Background(), id: 7, uri: "https://site.test/", | |||
| 358 | + | engine: &Engine{clipboardTextOrchestrator: orchestrator}, | |||
| 359 | + | gtkDispatch: func(fn func()) { *queued = append(*queued, fn) }, | |||
| 360 | + | callbacks: &port.WebViewCallbacks{}, | |||
| 361 | + | } | |||
| 362 | + | wv.vimPageToken, wv.vimPageKind = "tok", kind | |||
| 363 | + | return wv, orchestrator, queued | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func TestWebViewHandleVimPageResultCopyRunsOnGTK(t *testing.T) { | |||
| 367 | + | wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject) | |||
| 368 | + | ||||
| 369 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "yanked"}) | |||
| 370 | + | ||||
| 371 | + | assert.Empty(t, orchestrator.explicit.Text, "the clipboard must not be written on the IO thread") | |||
| 372 | + | require.Len(t, *queued, 1) | |||
| 373 | + | (*queued)[0]() | |||
| 374 | + | assert.Equal(t, dto.ExplicitClipboardInput{ | |||
| 375 | + | Text: "yanked", Action: "copy", SourceEngine: dto.ClipboardSourceCEF, ViewID: 7, | |||
| 376 | + | }, orchestrator.explicit) | |||
| 377 | + | } | |||
| 378 | + | ||||
| 379 | + | // A hostile page that read the token must not be able to write the clipboard | |||
| 380 | + | // again and again: visual copies are non-terminal, so each needs a y/Enter the | |||
| 381 | + | // user pressed and Go forwarded. | |||
| 382 | + | func TestWebViewHandleVimPageResultVisualCopyNeedsForwardedCopyKey(t *testing.T) { | |||
| 383 | + | wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageVisual) | |||
| 384 | + | copyResult := vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "stolen"} | |||
| 385 | + | ||||
| 386 | + | wv.handleVimPageResult(copyResult) | |||
| 387 | + | assert.Empty(t, *queued, "no key was forwarded: the copy is refused") | |||
| 388 | + | assert.Equal(t, "tok", wv.currentVimPageToken(), "a refused copy leaves the interaction running") | |||
| 389 | + | ||||
| 390 | + | wv.grantVimPageCopyAllowance("tok", "j") | |||
| 391 | + | wv.handleVimPageResult(copyResult) | |||
| 392 | + | assert.Empty(t, *queued, "motion keys grant no copy") | |||
| 393 | + | ||||
| 394 | + | wv.grantVimPageCopyAllowance("tok", "y") | |||
| 395 | + | wv.handleVimPageResult(copyResult) | |||
| 396 | + | wv.handleVimPageResult(copyResult) | |||
| 397 | + | require.Len(t, *queued, 1, "one forwarded key allows exactly one copy") | |||
| 398 | + | (*queued)[0]() | |||
| 399 | + | assert.Equal(t, "stolen", orchestrator.explicit.Text) | |||
| 400 | + | ||||
| 401 | + | wv.grantVimPageCopyAllowance("tok", "<Return>") | |||
| 402 | + | wv.grantVimPageCopyAllowance("other", "y") | |||
| 403 | + | wv.handleVimPageResult(copyResult) | |||
| 404 | + | assert.Len(t, *queued, 2) | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | func TestWebViewSendVimPageKeyGrantsCopyAllowanceOnlyForVisual(t *testing.T) { | |||
| 408 | + | for kind, want := range map[dto.VimPageInteractionKind]int{dto.VimPageVisual: 2, dto.VimPageHintYankURL: 0} { | |||
| 409 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 410 | + | frame := cefmocks.NewMockFrame(t) | |||
| 411 | + | browser.EXPECT().GetMainFrame().Return(frame).Times(3) | |||
| 412 | + | frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Times(3) | |||
| 413 | + | wv := &WebView{browser: browser, vimPageToken: "tok", vimPageKind: kind} | |||
| 414 | + | ||||
| 415 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "y")) | |||
| 416 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "j")) | |||
| 417 | + | require.NoError(t, wv.SendVimPageKey(context.Background(), "<Return>")) | |||
| 418 | + | ||||
| 419 | + | assert.Equal(t, want, wv.vimPageCopyAllowance, "kind %d", kind) | |||
| 420 | + | } | |||
| 421 | + | } | |||
| 422 | + | ||||
| 423 | + | func TestWebViewArmVimPageInteractionResetsCopyAllowance(t *testing.T) { | |||
| 424 | + | wv := &WebView{vimPageToken: "old", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 3} | |||
| 425 | + | ||||
| 426 | + | _, err := wv.armVimPageInteraction(dto.VimPageVisual) | |||
| 427 | + | require.NoError(t, err) | |||
| 428 | + | assert.Zero(t, wv.vimPageCopyAllowance) | |||
| 429 | + | wv.vimPageCopyAllowance = 2 | |||
| 430 | + | wv.disarmVimPageInteraction() | |||
| 431 | + | assert.Zero(t, wv.vimPageCopyAllowance) | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func TestWebViewHandleVimPageResultIgnoredAfterDestroy(t *testing.T) { | |||
| 435 | + | wv, orchestrator, queued := newVimClipboardWebView(t, dto.VimPageYankObject) | |||
| 436 | + | ended := 0 | |||
| 437 | + | wv.callbacks.OnVimPageInteractionEnded = func() { ended++ } | |||
| 438 | + | wv.destroyed.Store(true) | |||
| 439 | + | ||||
| 440 | + | wv.handleVimPageResult(vimPageBridgePayload{Type: vimPageMessageCopy, Token: "tok", Text: "late"}) | |||
| 441 | + | ||||
| 442 | + | assert.Empty(t, *queued) | |||
| 443 | + | assert.Empty(t, orchestrator.explicit.Text) | |||
| 444 | + | assert.Zero(t, ended) | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | func TestWebViewDestroyClearsVimPageInteraction(t *testing.T) { | |||
| 448 | + | wv := &WebView{ctx: context.Background(), vimPageToken: "tok", vimPageKind: dto.VimPageVisual, vimPageCopyAllowance: 1} | |||
| 449 | + | ||||
| 450 | + | wv.Destroy() | |||
| 451 | + | ||||
| 452 | + | assert.Empty(t, wv.currentVimPageToken()) | |||
| 453 | + | assert.Zero(t, wv.vimPageKind) | |||
| 454 | + | assert.Zero(t, wv.vimPageCopyAllowance) | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | // A crashed renderer takes the page runtime with it, so it can never report | |||
| 458 | + | // the end; the render-process-terminated handler must release the capture. | |||
| 459 | + | func TestHandlerSetOnRenderProcessTerminatedEndsVimPageInteraction(t *testing.T) { | |||
| 460 | + | var opened string | |||
| 461 | + | ended := 0 | |||
| 462 | + | wv := newVimResultWebView(&opened, &ended) | |||
| 463 | + | wv.vimPageToken, wv.vimPageKind = "tok", dto.VimPageVisual | |||
| 464 | + | h := &handlerSet{wv: wv} | |||
| 465 | + | ||||
| 466 | + | h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "") | |||
| 467 | + | ||||
| 468 | + | assert.Equal(t, 1, ended) | |||
| 469 | + | assert.Empty(t, wv.currentVimPageToken()) | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | func TestSchemeHandler_APIVimPageRejectsOversizedPayloadWith413(t *testing.T) { | |||
| 473 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 474 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 475 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 476 | + | ||||
| 477 | + | h := newTestDumbSchemeHandler(t) | |||
| 478 | + | h.onVimPage = func(purecef.Browser, vimPageBridgePayload) { t.Fatal("oversized payload must not reach the handler") } | |||
| 479 | + | request := cefmocks.NewMockRequest(t) | |||
| 480 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"type":"copy","token":"t","text":"` + strings.Repeat("a", maxClipboardBytes) + `"}`)) | |||
| 481 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |||
| 482 | + | response := cefmocks.NewMockResponse(t) | |||
| 483 | + | response.EXPECT().SetStatus(int32(http.StatusRequestEntityTooLarge)).Once() | |||
| 484 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusRequestEntityTooLarge)).Once() | |||
| 485 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 486 | + | expectAPIResponseHeaders(response) | |||
| 487 | + | ||||
| 488 | + | handler := h.handleAPI(cefmocks.NewMockBrowser(t), http.MethodPost, "/api/vim-page", request) | |||
| 489 | + | require.NotNil(t, handler) | |||
| 490 | + | var length int64 | |||
| 491 | + | handler.GetResponseHeaders(response, &length, 0) | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | func TestDecodeVimPageBridgePayloadOversizedIsSentinel(t *testing.T) { | |||
| 495 | + | _, err := decodeVimPageBridgePayload(make([]byte, maxClipboardBytes+1)) | |||
| 496 | + | require.ErrorIs(t, err, errVimPagePayloadTooLarge) | |||
| 497 | + | _, err = decodeVimPageBridgePayload([]byte(`{`)) | |||
| 498 | + | require.Error(t, err) | |||
| 499 | + | require.NotErrorIs(t, err, errVimPagePayloadTooLarge) | |||
| 500 | + | } | |||
M internal/infrastructure/cef/webview.go +14 -2
| @@ -203,8 +203,10 @@ type WebView struct { | |||
| 203 | 203 | // GTK sync dispatch hooks are injectable for tests. Production uses the GTK | |
| 204 | 204 | // default main context through runOnGTK and isOnGTKThread. | |
| 205 | 205 | gtkSyncDispatch func(func()) | |
| 206 | - | gtkSyncIsOwner func() bool | |
| 207 | - | gtkSyncTimeout time.Duration | |
| 206 | + | // gtkDispatch replaces the async GTK idle hop in tests. | |
| 207 | + | gtkDispatch func(func()) | |
| 208 | + | gtkSyncIsOwner func() bool | |
| 209 | + | gtkSyncTimeout time.Duration | |
| 208 | 210 | ||
| 209 | 211 | // scrollCancelSeam overrides the adapter for scroll cancellation in | |
| 210 | 212 | // tests. Production leaves it nil so the live view bridge is used. | |
| @@ -277,6 +279,9 @@ type WebView struct { | |||
| 277 | 279 | // carrying any other token are rejected. Empty when none is armed. | |
| 278 | 280 | vimPageToken string | |
| 279 | 281 | vimPageKind dto.VimPageInteractionKind | |
| 282 | + | // vimPageCopyAllowance counts y/Enter keys Go forwarded in a visual | |
| 283 | + | // interaction that no accepted copy result has consumed yet. | |
| 284 | + | vimPageCopyAllowance int | |
| 280 | 285 | // vimPageGeneration increments on every arm so deferred end callbacks | |
| 281 | 286 | // from an older interaction cannot release a newer one's key capture. | |
| 282 | 287 | vimPageGeneration uint64 | |
| @@ -1360,6 +1365,9 @@ func (wv *WebView) Destroy() { | |||
| 1360 | 1365 | wv.nativePopupFallbackStarted = false | |
| 1361 | 1366 | wv.popupOpenerBridgeParent = nil | |
| 1362 | 1367 | wv.popupOpenerBridgeParentURI = "" | |
| 1368 | + | wv.vimPageToken = "" | |
| 1369 | + | wv.vimPageKind = 0 | |
| 1370 | + | wv.vimPageCopyAllowance = 0 | |
| 1363 | 1371 | wv.mu.Unlock() | |
| 1364 | 1372 | wv.stopNativePopupFallbackTimer() | |
| 1365 | 1373 | wv.stopRenderStallWatchdog() | |
| @@ -2625,6 +2633,10 @@ func (wv *WebView) runOnGTK(fn func()) { | |||
| 2625 | 2633 | if fn == nil { | |
| 2626 | 2634 | return | |
| 2627 | 2635 | } | |
| 2636 | + | if wv.gtkDispatch != nil { | |
| 2637 | + | wv.gtkDispatch(fn) | |
| 2638 | + | return | |
| 2639 | + | } | |
| 2628 | 2640 | // When engine is nil (test/bootstrap), call directly. | |
| 2629 | 2641 | if wv.engine == nil { | |
| 2630 | 2642 | fn() | |
| @@ -203,8 +203,10 @@ type WebView struct { | |||||
| 203 | // GTK sync dispatch hooks are injectable for tests. Production uses the GTK | 203 | // GTK sync dispatch hooks are injectable for tests. Production uses the GTK | ||
| 204 | // default main context through runOnGTK and isOnGTKThread. | 204 | // default main context through runOnGTK and isOnGTKThread. | ||
| 205 | gtkSyncDispatch func(func()) | 205 | gtkSyncDispatch func(func()) | ||
| 206 | - | gtkSyncIsOwner func() bool | 206 | + | // gtkDispatch replaces the async GTK idle hop in tests. |
| 207 | - | gtkSyncTimeout time.Duration | 207 | + | gtkDispatch func(func()) |
| 208 | + | gtkSyncIsOwner func() bool | |||
| 209 | + | gtkSyncTimeout time.Duration | |||
| 208 | 210 | ||||
| 209 | // scrollCancelSeam overrides the adapter for scroll cancellation in | 211 | // scrollCancelSeam overrides the adapter for scroll cancellation in | ||
| 210 | // tests. Production leaves it nil so the live view bridge is used. | 212 | // tests. Production leaves it nil so the live view bridge is used. | ||
| @@ -277,6 +279,9 @@ type WebView struct { | |||||
| 277 | // carrying any other token are rejected. Empty when none is armed. | 279 | // carrying any other token are rejected. Empty when none is armed. | ||
| 278 | vimPageToken string | 280 | vimPageToken string | ||
| 279 | vimPageKind dto.VimPageInteractionKind | 281 | vimPageKind dto.VimPageInteractionKind | ||
| 282 | + | // vimPageCopyAllowance counts y/Enter keys Go forwarded in a visual | |||
| 283 | + | // interaction that no accepted copy result has consumed yet. | |||
| 284 | + | vimPageCopyAllowance int | |||
| 280 | // vimPageGeneration increments on every arm so deferred end callbacks | 285 | // vimPageGeneration increments on every arm so deferred end callbacks | ||
| 281 | // from an older interaction cannot release a newer one's key capture. | 286 | // from an older interaction cannot release a newer one's key capture. | ||
| 282 | vimPageGeneration uint64 | 287 | vimPageGeneration uint64 | ||
| @@ -1360,6 +1365,9 @@ func (wv *WebView) Destroy() { | |||||
| 1360 | wv.nativePopupFallbackStarted = false | 1365 | wv.nativePopupFallbackStarted = false | ||
| 1361 | wv.popupOpenerBridgeParent = nil | 1366 | wv.popupOpenerBridgeParent = nil | ||
| 1362 | wv.popupOpenerBridgeParentURI = "" | 1367 | wv.popupOpenerBridgeParentURI = "" | ||
| 1368 | + | wv.vimPageToken = "" | |||
| 1369 | + | wv.vimPageKind = 0 | |||
| 1370 | + | wv.vimPageCopyAllowance = 0 | |||
| 1363 | wv.mu.Unlock() | 1371 | wv.mu.Unlock() | ||
| 1364 | wv.stopNativePopupFallbackTimer() | 1372 | wv.stopNativePopupFallbackTimer() | ||
| 1365 | wv.stopRenderStallWatchdog() | 1373 | wv.stopRenderStallWatchdog() | ||
| @@ -2625,6 +2633,10 @@ func (wv *WebView) runOnGTK(fn func()) { | |||||
| 2625 | if fn == nil { | 2633 | if fn == nil { | ||
| 2626 | return | 2634 | return | ||
| 2627 | } | 2635 | } | ||
| 2636 | + | if wv.gtkDispatch != nil { | |||
| 2637 | + | wv.gtkDispatch(fn) | |||
| 2638 | + | return | |||
| 2639 | + | } | |||
| 2628 | // When engine is nil (test/bootstrap), call directly. | 2640 | // When engine is nil (test/bootstrap), call directly. | ||
| 2629 | if wv.engine == nil { | 2641 | if wv.engine == nil { | ||
| 2630 | fn() | 2642 | fn() | ||
M internal/infrastructure/cef/webview_handlers.go +2 -0
| @@ -1308,6 +1308,8 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef | |||
| 1308 | 1308 | // (and the dead page's callback) hanging. | |
| 1309 | 1309 | h.wv.cancelJSDialogs() | |
| 1310 | 1310 | ||
| 1311 | + | // The page runtime died with the renderer and can no longer report its end. | |
| 1312 | + | h.wv.endVimPageInteractionOnNavigation() | |
| 1311 | 1313 | if h.wv.crashCount.Add(1) > maxConsecutiveCrashes { | |
| 1312 | 1314 | return // suppress to break the loop | |
| 1313 | 1315 | } | |
| @@ -1308,6 +1308,8 @@ func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef | |||||
| 1308 | // (and the dead page's callback) hanging. | 1308 | // (and the dead page's callback) hanging. | ||
| 1309 | h.wv.cancelJSDialogs() | 1309 | h.wv.cancelJSDialogs() | ||
| 1310 | 1310 | ||||
| 1311 | + | // The page runtime died with the renderer and can no longer report its end. | |||
| 1312 | + | h.wv.endVimPageInteractionOnNavigation() | |||
| 1311 | if h.wv.crashCount.Add(1) > maxConsecutiveCrashes { | 1313 | if h.wv.crashCount.Add(1) > maxConsecutiveCrashes { | ||
| 1312 | return // suppress to break the loop | 1314 | return // suppress to break the loop | ||
| 1313 | } | 1315 | } | ||