detached 1202f7c

content: extract popup manager scaffolding

commit
1202f7c40d7995af80a78a02a6a57d69ae845d67
author
brice · · 2026-04-22 14:22 UTC
parent
93cde2a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +1245 -1222
M internal/application/port/webview.go +11 -34
@@ -313,49 +313,26 @@ type Printer interface {
313313 PrintPage()
314314 }
315315
316-// PopupCapable is implemented by WebViews that support popup lifecycle callbacks.
317-// SetOnClose composes the provided function with any existing close handler so
318-// that multiple callers can each register a close callback without overwriting
319-// one another.
320-type PopupCapable interface {
316+// PopupLifecycleCapable is implemented by WebViews that support the full popup
317+// pane lifecycle. SetOnClose composes the provided function with any existing
318+// close handler so multiple callers can register close hooks without
319+// overwriting one another. PrimePopupNavigation lets engines preserve the popup
320+// target URI before the browser is fully created.
321+type PopupLifecycleCapable interface {
321322 SetOnReadyToShow(fn func())
322323 SetOnClose(fn func())
323324 Show()
324-}
325-
326-// PopupNavigationCapable is implemented by WebViews that need the popup target
327-// URI primed before the browser is fully created. Engines with native popup
328-// creation can ignore this capability; engines that create popup shells and
329-// attach or create browsers later can use it to preserve a unified popup
330-// lifecycle without coordinator-specific fallback logic.
331-type PopupNavigationCapable interface {
332325 PrimePopupNavigation(uri string)
333326 }
334327
335-// PopupOpenerBridgeCapable is implemented by WebViews that can emulate
336-// opener semantics when a popup must be created directly instead of through a
337-// native related-popup lifecycle. Engines can ignore this capability when they
338-// provide real popup opener relationships.
339-type PopupOpenerBridgeCapable interface {
328+// PopupOpenerCapable is implemented by popup WebViews that can emulate opener
329+// semantics when a popup must be created directly instead of through a native
330+// related-popup lifecycle, and that can report or observe synthetic opener
331+// traffic needed by OAuth flows.
332+type PopupOpenerCapable interface {
340333 EnablePopupOpenerBridge(parent WebView, noJavaScriptAccess bool)
341-}
342-
343-// PopupOpenerMessageCapable is implemented by popup WebViews that can observe
344-// synthetic `window.opener.postMessage(...)` traffic in fallback popup flows.
345-type PopupOpenerMessageCapable interface {
346334 AddOpenerMessageCallback(fn func())
347-}
348-
349-// PopupOpenerNavigationCapable is implemented by popup WebViews that can observe
350-// synthetic `window.opener.location = ...` navigations in fallback popup flows.
351-type PopupOpenerNavigationCapable interface {
352335 AddOpenerNavigationCallback(fn func(uri string))
353-}
354-
355-// PopupOpenerBridgeStateCapable is implemented by popup WebViews that can
356-// report whether a synthetic opener bridge is actively installed for the
357-// current popup instance.
358-type PopupOpenerBridgeStateCapable interface {
359336 HasActivePopupOpenerBridge() bool
360337 }
361338
@@ -313,49 +313,26 @@ type Printer interface {
313 PrintPage()313 PrintPage()
314 }314 }
315 315
316-// PopupCapable is implemented by WebViews that support popup lifecycle callbacks.316+// PopupLifecycleCapable is implemented by WebViews that support the full popup
317-// SetOnClose composes the provided function with any existing close handler so317+// pane lifecycle. SetOnClose composes the provided function with any existing
318-// that multiple callers can each register a close callback without overwriting318+// close handler so multiple callers can register close hooks without
319-// one another.319+// overwriting one another. PrimePopupNavigation lets engines preserve the popup
320-type PopupCapable interface {320+// target URI before the browser is fully created.
321+type PopupLifecycleCapable interface {
321 SetOnReadyToShow(fn func())322 SetOnReadyToShow(fn func())
322 SetOnClose(fn func())323 SetOnClose(fn func())
323 Show()324 Show()
324-}
325-
326-// PopupNavigationCapable is implemented by WebViews that need the popup target
327-// URI primed before the browser is fully created. Engines with native popup
328-// creation can ignore this capability; engines that create popup shells and
329-// attach or create browsers later can use it to preserve a unified popup
330-// lifecycle without coordinator-specific fallback logic.
331-type PopupNavigationCapable interface {
332 PrimePopupNavigation(uri string)325 PrimePopupNavigation(uri string)
333 }326 }
334 327
335-// PopupOpenerBridgeCapable is implemented by WebViews that can emulate328+// PopupOpenerCapable is implemented by popup WebViews that can emulate opener
336-// opener semantics when a popup must be created directly instead of through a329+// semantics when a popup must be created directly instead of through a native
337-// native related-popup lifecycle. Engines can ignore this capability when they330+// related-popup lifecycle, and that can report or observe synthetic opener
338-// provide real popup opener relationships.331+// traffic needed by OAuth flows.
339-type PopupOpenerBridgeCapable interface {332+type PopupOpenerCapable interface {
340 EnablePopupOpenerBridge(parent WebView, noJavaScriptAccess bool)333 EnablePopupOpenerBridge(parent WebView, noJavaScriptAccess bool)
341-}
342-
343-// PopupOpenerMessageCapable is implemented by popup WebViews that can observe
344-// synthetic `window.opener.postMessage(...)` traffic in fallback popup flows.
345-type PopupOpenerMessageCapable interface {
346 AddOpenerMessageCallback(fn func())334 AddOpenerMessageCallback(fn func())
347-}
348-
349-// PopupOpenerNavigationCapable is implemented by popup WebViews that can observe
350-// synthetic `window.opener.location = ...` navigations in fallback popup flows.
351-type PopupOpenerNavigationCapable interface {
352 AddOpenerNavigationCallback(fn func(uri string))335 AddOpenerNavigationCallback(fn func(uri string))
353-}
354-
355-// PopupOpenerBridgeStateCapable is implemented by popup WebViews that can
356-// report whether a synthetic opener bridge is actively installed for the
357-// current popup instance.
358-type PopupOpenerBridgeStateCapable interface {
359 HasActivePopupOpenerBridge() bool336 HasActivePopupOpenerBridge() bool
360 }337 }
361 338
A internal/infrastructure/cef/bridge_script_templates.go +787 -0
@@ -0,0 +1,787 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/webutil"
8+)
9+
10+// trustedPageFetchBridgeBaseJS is the invariant fetch-bridge bundle injected on
11+// external pages. It is assembled once from shared templates and then only the
12+// bridge nonce placeholder is replaced per navigation.
13+var trustedPageFetchBridgeBaseJS = buildTrustedPageFetchBridgeBaseJS()
14+
15+// rendererBridgeExtensionJS is assembled once from the shared popup proxy
16+// builder so the renderer-extension and fetch-based popup bridges stay aligned.
17+var rendererBridgeExtensionJS = buildRendererBridgeExtensionJS()
18+
19+type popupProxyBridgeDispatch struct {
20+ patchFlag string
21+ helpers string
22+}
23+
24+const popupProxyBridgeBodyTemplateJS = `
25+ if (typeof window === 'undefined') return;
26+ if (window.__DUMBER_POPUP_PATCH_FLAG__) return;
27+
28+ __DUMBER_POPUP_DISPATCH_HELPERS__
29+
30+ function resolvePopupURL(rawURL) {
31+ if (rawURL == null || rawURL === '') return '';
32+ try {
33+ return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
34+ } catch (_) {
35+ return String(rawURL);
36+ }
37+ }
38+
39+ function hasUserGesture() {
40+ try {
41+ return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
42+ } catch (_) {
43+ return false;
44+ }
45+ }
46+
47+ function popupHasNoOpener(features) {
48+ if (features == null || features === '') return false;
49+ var normalized = String(features).toLowerCase();
50+ return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
51+ }
52+
53+ function shouldDelegateWindowOpen(target) {
54+ return target === '_self' || target === '_top' || target === '_parent';
55+ }
56+
57+ function createSyntheticPopupProxy(proxyID, initialURL, features) {
58+ var href = initialURL || 'about:blank';
59+ var closed = false;
60+ var noJavaScriptAccess = popupHasNoOpener(features);
61+
62+ function navigate(nextURL) {
63+ href = resolvePopupURL(nextURL);
64+ dispatchPopupNavigate(proxyID, href);
65+ return href;
66+ }
67+
68+ var locationProxy = {
69+ assign: function(nextURL) { return navigate(nextURL); },
70+ replace: function(nextURL) { return navigate(nextURL); },
71+ toString: function() { return href; }
72+ };
73+ try {
74+ Object.defineProperty(locationProxy, 'href', {
75+ configurable: true,
76+ enumerable: true,
77+ get: function() { return href; },
78+ set: function(nextURL) { navigate(nextURL); }
79+ });
80+ } catch (_) {
81+ locationProxy.href = href;
82+ }
83+
84+ var proxy = {
85+ blur: function() { return undefined; },
86+ close: function() {
87+ if (closed) return;
88+ closed = true;
89+ dispatchPopupClose(proxyID);
90+ },
91+ focus: function() { return undefined; },
92+ postMessage: function() { return undefined; }
93+ };
94+ proxy.opener = noJavaScriptAccess ? null : window;
95+ proxy.self = proxy;
96+ proxy.window = proxy;
97+
98+ try {
99+ Object.defineProperty(proxy, 'closed', {
100+ configurable: true,
101+ enumerable: true,
102+ get: function() { return closed; },
103+ set: function(next) { closed = !!next; }
104+ });
105+ } catch (_) {
106+ proxy.closed = false;
107+ }
108+
109+ try {
110+ Object.defineProperty(proxy, 'location', {
111+ configurable: true,
112+ enumerable: true,
113+ get: function() { return locationProxy; },
114+ set: function(nextURL) { navigate(nextURL); }
115+ });
116+ } catch (_) {
117+ proxy.location = locationProxy;
118+ }
119+
120+ try {
121+ Object.defineProperty(proxy, Symbol.toStringTag, {
122+ configurable: true,
123+ value: 'Window'
124+ });
125+ } catch (_) {}
126+
127+ return proxy;
128+ }
129+
130+ try {
131+ var originalWindowOpen = typeof window.open === 'function' ? window.open : null;
132+ if (!originalWindowOpen) return;
133+
134+ window.__DUMBER_POPUP_PATCH_FLAG__ = true;
135+ window.open = function(url, target, features) {
136+ var normalizedTarget = target == null ? '' : String(target);
137+ if (shouldDelegateWindowOpen(normalizedTarget)) {
138+ return originalWindowOpen.apply(this, arguments);
139+ }
140+
141+ var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
142+ var resolvedURL = resolvePopupURL(url);
143+ var noJavaScriptAccess = popupHasNoOpener(features);
144+ var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
145+ dispatchPopupOpen({
146+ proxy_id: proxyID,
147+ url: resolvedURL,
148+ frame_name: normalizedTarget,
149+ user_gesture: hasUserGesture(),
150+ no_javascript_access: noJavaScriptAccess
151+ });
152+ return popupProxy;
153+ };
154+ } catch (_) {
155+ window.__DUMBER_POPUP_PATCH_FLAG__ = false;
156+ }
157+`
158+
159+const popupFetchBridgeHelpersJSTemplate = `
160+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
161+
162+ function encodeBody(body) {
163+ try {
164+ return btoa(unescape(encodeURIComponent(body)));
165+ } catch (_) {
166+ return '';
167+ }
168+ }
169+
170+ function postBridge(url, payload) {
171+ var encoded = encodeBody(JSON.stringify(payload));
172+ if (!encoded) return;
173+ fetch(url, {
174+ method: 'POST',
175+ headers: {
176+ 'X-Dumber-Body': encoded,
177+ 'X-Dumber-Bridge-Nonce': bridgeNonce
178+ }
179+ }).catch(function() {});
180+ }
181+
182+ function dispatchPopupOpen(payload) {
183+ postBridge('dumb:///api/popup-open', payload);
184+ }
185+
186+ function dispatchPopupNavigate(proxyID, url) {
187+ postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: url });
188+ }
189+
190+ function dispatchPopupClose(proxyID) {
191+ postBridge('dumb:///api/popup-close', { proxy_id: proxyID });
192+ }
193+`
194+
195+const rendererPopupDispatchHelpersJS = `
196+ function dispatchPopupOpen(payload) {
197+ send('popup_open', JSON.stringify(payload));
198+ }
199+
200+ function dispatchPopupNavigate(proxyID, url) {
201+ send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: url }));
202+ }
203+
204+ function dispatchPopupClose(proxyID) {
205+ send('popup_close', JSON.stringify({ proxy_id: proxyID }));
206+ }
207+`
208+
209+func buildPopupProxyBridgeBodyJS(dispatch popupProxyBridgeDispatch) string {
210+ replacer := strings.NewReplacer(
211+ "__DUMBER_POPUP_PATCH_FLAG__", dispatch.patchFlag,
212+ "__DUMBER_POPUP_DISPATCH_HELPERS__", dispatch.helpers,
213+ )
214+ return replacer.Replace(popupProxyBridgeBodyTemplateJS)
215+}
216+
217+func wrapSelfExecutingScript(body string) string {
218+ return fmt.Sprintf("(function() {\n%s\n})();", body)
219+}
220+
221+func buildTrustedPageFetchBridgeBaseJS() string {
222+ popupBridge := wrapSelfExecutingScript(buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{
223+ patchFlag: "__dumberPopupBridgePatched",
224+ helpers: popupFetchBridgeHelpersJSTemplate,
225+ }))
226+ return clipboardSelectionFetchBridgeJSTemplate + "\n" + popupBridge
227+}
228+
229+func buildTrustedPageFetchBridgeJS(bridgeNonce string) string {
230+ return strings.ReplaceAll(trustedPageFetchBridgeBaseJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
231+}
232+
233+func buildRendererBridgeExtensionJS() string {
234+ popupBridgeBody := buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{
235+ patchFlag: "__dumberPopupOpenPatched",
236+ helpers: rendererPopupDispatchHelpersJS,
237+ })
238+ return strings.ReplaceAll(rendererBridgeExtensionTemplateJS, "__DUMBER_POPUP_PROXY_BRIDGE_BODY__", popupBridgeBody)
239+}
240+
241+func buildPopupOpenerBridgeJS(bridgeNonce, parentURI string) string {
242+ replacer := strings.NewReplacer(
243+ "__DUMBER_BRIDGE_NONCE__", webutil.EscapeForJSString(bridgeNonce),
244+ "__DUMBER_PARENT_URI__", webutil.EscapeForJSString(parentURI),
245+ )
246+ return replacer.Replace(popupOpenerBridgeTemplateJS)
247+}
248+
249+// clipboardSelectionFetchBridgeJSTemplate keeps the lightweight fetch bridge
250+// enabled while the native renderer bridge remains disabled due to an OSR
251+// startup regression. It mirrors explicit clipboard text from DOM selections,
252+// safe input/textarea selections, and async Clipboard API writes, and also
253+// reasserts browser focus when an editable element gains DOM focus.
254+const clipboardSelectionFetchBridgeJSTemplate = `(function(){
255+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
256+
257+ function encodeBody(body) {
258+ return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
259+ }
260+
261+ function inputType(node) {
262+ return node && node.type ? String(node.type).toLowerCase() : 'text';
263+ }
264+
265+ function isTextEditableInputType(type) {
266+ switch ((type || 'text').toLowerCase()) {
267+ case 'button':
268+ case 'submit':
269+ case 'reset':
270+ case 'checkbox':
271+ case 'radio':
272+ case 'range':
273+ case 'color':
274+ case 'file':
275+ case 'image':
276+ case 'hidden':
277+ return false;
278+ default:
279+ return true;
280+ }
281+ }
282+
283+ function isSelectionReadableInputType(type) {
284+ return isTextEditableInputType(type) && type !== 'password';
285+ }
286+
287+ function getActiveElementSelection() {
288+ var el = document.activeElement;
289+ if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
290+ var tag = el.tagName;
291+ if (el.disabled || el.readOnly) return '';
292+ if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
293+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
294+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
295+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
296+ if (end > start) return el.value.slice(start, end);
297+ return '';
298+ }
299+
300+ function getSelectedText() {
301+ var activeSelection = getActiveElementSelection();
302+ if (activeSelection) return activeSelection;
303+ var sel = window.getSelection();
304+ return sel ? sel.toString() : '';
305+ }
306+
307+ function isEditable(node) {
308+ if (!node || node.nodeType !== 1) return false;
309+ if (node.isContentEditable) return true;
310+ var tag = node.tagName;
311+ if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
312+ if (tag !== 'INPUT') return false;
313+ if (node.disabled || node.readOnly) return false;
314+ return isTextEditableInputType(inputType(node));
315+ }
316+
317+ function sendToClipboard(text) {
318+ if (!text) return;
319+ var body = JSON.stringify({text: text});
320+ var encoded = encodeBody(body);
321+ if (!encoded) return;
322+ fetch('dumb:///api/clipboard-set', {
323+ method: 'POST',
324+ headers: {
325+ 'X-Dumber-Body': encoded,
326+ 'X-Dumber-Bridge-Nonce': bridgeNonce
327+ }
328+ }).catch(function(){});
329+ }
330+
331+ function sendFocusSync() {
332+ fetch('dumb:///api/focus-sync', {
333+ method: 'POST',
334+ headers: {
335+ 'X-Dumber-Bridge-Action': 'focus-sync',
336+ 'X-Dumber-Bridge-Nonce': bridgeNonce
337+ }
338+ }).catch(function(){});
339+ }
340+
341+ function mirrorClipboardItems(items) {
342+ try {
343+ if (!items || typeof items.length !== 'number') return;
344+ Array.prototype.forEach.call(items, function(item) {
345+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
346+ Promise.resolve(item.getType('text/plain'))
347+ .then(function(blob) {
348+ if (!blob || typeof blob.text !== 'function') return '';
349+ return blob.text();
350+ })
351+ .then(function(text) {
352+ if (text) sendToClipboard(text);
353+ })
354+ .catch(function(){});
355+ });
356+ } catch (_) {}
357+ }
358+
359+ document.addEventListener('copy', function(event) {
360+ if (event && event.isTrusted === false) return;
361+ var text = getSelectedText();
362+ if (text) sendToClipboard(text);
363+ });
364+ document.addEventListener('cut', function(event) {
365+ if (event && event.isTrusted === false) return;
366+ var text = getSelectedText();
367+ if (text) sendToClipboard(text);
368+ });
369+ document.addEventListener('focusin', function(event) {
370+ if (event && event.isTrusted === false) return;
371+ if (isEditable(event && event.target)) sendFocusSync();
372+ }, true);
373+
374+ try {
375+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
376+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
377+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
378+
379+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
380+ clipboardProto.__dumberWriteTextPatched = true;
381+ var originalProtoWriteText = clipboardProto.writeText;
382+ var wrappedProtoWriteText = function(text) {
383+ var normalized = text == null ? '' : String(text);
384+ var result = originalProtoWriteText.call(this, normalized);
385+ if (result && typeof result.then === 'function') {
386+ return result.then(function(value) {
387+ if (normalized) sendToClipboard(normalized);
388+ return value;
389+ });
390+ }
391+ if (normalized) sendToClipboard(normalized);
392+ return result;
393+ };
394+ try {
395+ Object.defineProperty(clipboardProto, 'writeText', {
396+ configurable: true,
397+ writable: true,
398+ value: wrappedProtoWriteText
399+ });
400+ } catch (_) {
401+ clipboardProto.writeText = wrappedProtoWriteText;
402+ }
403+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
404+ window.__dumberClipboardWriteTextPatched = true;
405+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
406+ var wrappedWriteText = function(text) {
407+ var normalized = text == null ? '' : String(text);
408+ var result = originalWriteText(normalized);
409+ if (result && typeof result.then === 'function') {
410+ return result.then(function(value) {
411+ if (normalized) sendToClipboard(normalized);
412+ return value;
413+ });
414+ }
415+ if (normalized) sendToClipboard(normalized);
416+ return result;
417+ };
418+ try {
419+ Object.defineProperty(clipboardObj, 'writeText', {
420+ configurable: true,
421+ writable: true,
422+ value: wrappedWriteText
423+ });
424+ } catch (_) {
425+ clipboardObj.writeText = wrappedWriteText;
426+ }
427+ }
428+
429+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
430+ clipboardProto.__dumberWritePatched = true;
431+ var originalProtoWrite = clipboardProto.write;
432+ var wrappedProtoWrite = function(items) {
433+ var result = originalProtoWrite.apply(this, arguments);
434+ if (result && typeof result.then === 'function') {
435+ return result.then(function(value) {
436+ mirrorClipboardItems(items);
437+ return value;
438+ });
439+ }
440+ mirrorClipboardItems(items);
441+ return result;
442+ };
443+ try {
444+ Object.defineProperty(clipboardProto, 'write', {
445+ configurable: true,
446+ writable: true,
447+ value: wrappedProtoWrite
448+ });
449+ } catch (_) {
450+ clipboardProto.write = wrappedProtoWrite;
451+ }
452+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
453+ window.__dumberClipboardWritePatched = true;
454+ var originalWrite = clipboardObj.write.bind(clipboardObj);
455+ var wrappedWrite = function(items) {
456+ var result = originalWrite(items);
457+ if (result && typeof result.then === 'function') {
458+ return result.then(function(value) {
459+ mirrorClipboardItems(items);
460+ return value;
461+ });
462+ }
463+ mirrorClipboardItems(items);
464+ return result;
465+ };
466+ try {
467+ Object.defineProperty(clipboardObj, 'write', {
468+ configurable: true,
469+ writable: true,
470+ value: wrappedWrite
471+ });
472+ } catch (_) {
473+ clipboardObj.write = wrappedWrite;
474+ }
475+ }
476+ } catch (_) {}
477+
478+ if (isEditable(document.activeElement)) sendFocusSync();
479+})();`
480+
481+const rendererBridgeExtensionTemplateJS = `
482+(function() {
483+ native function Dispatch(action, payload);
484+
485+ function send(action, payload) {
486+ return Dispatch(action, payload == null ? '' : String(payload));
487+ }
488+
489+ function isEditable(node) {
490+ if (!node || node.nodeType !== 1) return false;
491+ if (node.isContentEditable) return true;
492+ var tag = node.tagName;
493+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;
494+ if (node.disabled || node.readOnly) return false;
495+ return true;
496+ }
497+
498+ function getActiveElementSelection() {
499+ var el = document.activeElement;
500+ if (!el) return '';
501+ var tag = el.tagName;
502+ if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {
503+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
504+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
505+ if (end > start) return el.value.slice(start, end);
506+ }
507+ return '';
508+ }
509+
510+ function getSelectedText() {
511+ var activeSelection = getActiveElementSelection();
512+ if (activeSelection) return activeSelection;
513+ var sel = window.getSelection();
514+ return sel ? sel.toString() : '';
515+ }
516+
517+ function notifyExplicitTextCopy(action, text) {
518+ var normalizedText = text == null ? '' : String(text);
519+ var normalizedAction = action == null || action === '' ? 'copy' : String(action);
520+ return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
521+ }
522+
523+__DUMBER_POPUP_PROXY_BRIDGE_BODY__
524+
525+ if (typeof document !== 'undefined' && document.addEventListener) {
526+ document.addEventListener('focusin', function(event) {
527+ if (isEditable(event.target)) {
528+ send('focus_sync', '');
529+ }
530+ }, true);
531+
532+ function mirrorClipboardEvent(action, e) {
533+ if (!e.isTrusted) return;
534+ var capturedText = '';
535+ if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
536+ try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {}
537+ }
538+ setTimeout(function() {
539+ var text = capturedText;
540+ if (!text && !e.defaultPrevented) text = getSelectedText();
541+ if (text) notifyExplicitTextCopy(action, text);
542+ }, 0);
543+ }
544+
545+ document.addEventListener('copy', function(e) {
546+ mirrorClipboardEvent('copy', e);
547+ }, true);
548+ document.addEventListener('cut', function(e) {
549+ mirrorClipboardEvent('cut', e);
550+ }, true);
551+ }
552+ try {
553+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
554+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
555+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
556+
557+ function mirrorClipboardItems(items) {
558+ try {
559+ if (!items || typeof items.length !== 'number') return;
560+ Array.prototype.forEach.call(items, function(item) {
561+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
562+ Promise.resolve(item.getType('text/plain'))
563+ .then(function(blob) {
564+ if (!blob || typeof blob.text !== 'function') return '';
565+ return blob.text();
566+ })
567+ .then(function(text) {
568+ if (text) notifyExplicitTextCopy('copy', text);
569+ })
570+ .catch(function() {});
571+ });
572+ } catch (_) {}
573+ }
574+
575+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
576+ clipboardProto.__dumberWriteTextPatched = true;
577+ var originalProtoWriteText = clipboardProto.writeText;
578+ var wrappedProtoWriteText = function(text) {
579+ var normalized = text == null ? '' : String(text);
580+ var result = originalProtoWriteText.call(this, normalized);
581+ if (result && typeof result.then === 'function') {
582+ return result.then(function(value) {
583+ if (normalized) notifyExplicitTextCopy('copy', normalized);
584+ return value;
585+ });
586+ }
587+ if (normalized) notifyExplicitTextCopy('copy', normalized);
588+ return result;
589+ };
590+ try {
591+ Object.defineProperty(clipboardProto, 'writeText', {
592+ configurable: true,
593+ writable: true,
594+ value: wrappedProtoWriteText
595+ });
596+ } catch (_) {
597+ clipboardProto.writeText = wrappedProtoWriteText;
598+ }
599+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
600+ window.__dumberClipboardWriteTextPatched = true;
601+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
602+ var wrappedWriteText = function(text) {
603+ var normalized = text == null ? '' : String(text);
604+ var result = originalWriteText(normalized);
605+ if (result && typeof result.then === 'function') {
606+ return result.then(function(value) {
607+ if (normalized) notifyExplicitTextCopy('copy', normalized);
608+ return value;
609+ });
610+ }
611+ if (normalized) notifyExplicitTextCopy('copy', normalized);
612+ return result;
613+ };
614+ try {
615+ Object.defineProperty(clipboardObj, 'writeText', {
616+ configurable: true,
617+ writable: true,
618+ value: wrappedWriteText
619+ });
620+ } catch (_) {
621+ clipboardObj.writeText = wrappedWriteText;
622+ }
623+ }
624+
625+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
626+ clipboardProto.__dumberWritePatched = true;
627+ var originalProtoWrite = clipboardProto.write;
628+ var wrappedProtoWrite = function(items) {
629+ var result = originalProtoWrite.apply(this, arguments);
630+ if (result && typeof result.then === 'function') {
631+ return result.then(function(value) {
632+ mirrorClipboardItems(items);
633+ return value;
634+ });
635+ }
636+ mirrorClipboardItems(items);
637+ return result;
638+ };
639+ try {
640+ Object.defineProperty(clipboardProto, 'write', {
641+ configurable: true,
642+ writable: true,
643+ value: wrappedProtoWrite
644+ });
645+ } catch (_) {
646+ clipboardProto.write = wrappedProtoWrite;
647+ }
648+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
649+ window.__dumberClipboardWritePatched = true;
650+ var originalWrite = clipboardObj.write.bind(clipboardObj);
651+ var wrappedWrite = function(items) {
652+ var result = originalWrite(items);
653+ if (result && typeof result.then === 'function') {
654+ return result.then(function(value) {
655+ mirrorClipboardItems(items);
656+ return value;
657+ });
658+ }
659+ mirrorClipboardItems(items);
660+ return result;
661+ };
662+ try {
663+ Object.defineProperty(clipboardObj, 'write', {
664+ configurable: true,
665+ writable: true,
666+ value: wrappedWrite
667+ });
668+ } catch (_) {
669+ clipboardObj.write = wrappedWrite;
670+ }
671+ }
672+ } catch (_) {}
673+
674+ send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : '');
675+})();
676+`
677+
678+const popupOpenerBridgeTemplateJS = `(function() {
679+ if (typeof window === 'undefined') return;
680+ if (window.__dumberPopupOpenerBridgeInstalled) return;
681+ window.__dumberPopupOpenerBridgeInstalled = true;
682+ if (window.opener != null) return;
683+
684+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
685+ var openerHref = '__DUMBER_PARENT_URI__';
686+
687+ function postBridge(path, payload) {
688+ try {
689+ fetch('dumb:///api/' + path, {
690+ method: 'POST',
691+ headers: {
692+ 'Content-Type': 'application/json',
693+ 'X-Dumber-Body': btoa(unescape(encodeURIComponent(JSON.stringify(payload)))),
694+ 'X-Dumber-Bridge-Nonce': bridgeNonce
695+ }
696+ }).catch(function() {});
697+ } catch (_) {}
698+ }
699+
700+ function normalizeURLValue(rawURL) {
701+ if (rawURL == null) return '';
702+ try {
703+ return String(rawURL);
704+ } catch (_) {
705+ return '';
706+ }
707+ }
708+
709+ function navigateOpener(nextURL) {
710+ var rawURL = normalizeURLValue(nextURL);
711+ if (rawURL !== '') openerHref = rawURL;
712+ postBridge('popup-opener-navigate', { url: rawURL });
713+ return openerHref;
714+ }
715+
716+ function serializeMessage(value) {
717+ try {
718+ return { kind: 'json', value: JSON.stringify(value) };
719+ } catch (_) {
720+ return { kind: 'string', value: String(value) };
721+ }
722+ }
723+
724+ var locationProxy = {
725+ assign: function(nextURL) { return navigateOpener(nextURL); },
726+ replace: function(nextURL) { return navigateOpener(nextURL); },
727+ toString: function() { return openerHref; }
728+ };
729+ try {
730+ Object.defineProperty(locationProxy, 'href', {
731+ configurable: true,
732+ enumerable: true,
733+ get: function() { return openerHref; },
734+ set: function(nextURL) { navigateOpener(nextURL); }
735+ });
736+ } catch (_) {
737+ locationProxy.href = openerHref;
738+ }
739+
740+ var openerProxy = {
741+ blur: function() { return undefined; },
742+ close: function() { return undefined; },
743+ focus: function() { return undefined; },
744+ postMessage: function(message, targetOrigin) {
745+ var serialized = serializeMessage(message);
746+ postBridge('popup-opener-post-message', {
747+ data: serialized.value,
748+ data_kind: serialized.kind,
749+ target_origin: targetOrigin == null ? '*' : String(targetOrigin),
750+ source_origin: (typeof location !== 'undefined' && location && location.origin) ? location.origin : '',
751+ source_href: (typeof location !== 'undefined' && location && location.href) ? location.href : ''
752+ });
753+ return undefined;
754+ }
755+ };
756+ openerProxy.self = openerProxy;
757+ openerProxy.window = openerProxy;
758+ try {
759+ Object.defineProperty(openerProxy, 'closed', {
760+ configurable: true,
761+ enumerable: true,
762+ get: function() { return false; }
763+ });
764+ } catch (_) {
765+ openerProxy.closed = false;
766+ }
767+ try {
768+ Object.defineProperty(openerProxy, 'location', {
769+ configurable: true,
770+ enumerable: true,
771+ get: function() { return locationProxy; },
772+ set: function(nextURL) { navigateOpener(nextURL); }
773+ });
774+ } catch (_) {
775+ openerProxy.location = locationProxy;
776+ }
777+
778+ try {
779+ Object.defineProperty(window, 'opener', {
780+ configurable: true,
781+ enumerable: true,
782+ get: function() { return openerProxy; }
783+ });
784+ } catch (_) {
785+ try { window.opener = openerProxy; } catch (_) {}
786+ }
787+})();`
@@ -0,0 +1,787 @@
1+package cef
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/webutil"
8+)
9+
10+// trustedPageFetchBridgeBaseJS is the invariant fetch-bridge bundle injected on
11+// external pages. It is assembled once from shared templates and then only the
12+// bridge nonce placeholder is replaced per navigation.
13+var trustedPageFetchBridgeBaseJS = buildTrustedPageFetchBridgeBaseJS()
14+
15+// rendererBridgeExtensionJS is assembled once from the shared popup proxy
16+// builder so the renderer-extension and fetch-based popup bridges stay aligned.
17+var rendererBridgeExtensionJS = buildRendererBridgeExtensionJS()
18+
19+type popupProxyBridgeDispatch struct {
20+ patchFlag string
21+ helpers string
22+}
23+
24+const popupProxyBridgeBodyTemplateJS = `
25+ if (typeof window === 'undefined') return;
26+ if (window.__DUMBER_POPUP_PATCH_FLAG__) return;
27+
28+ __DUMBER_POPUP_DISPATCH_HELPERS__
29+
30+ function resolvePopupURL(rawURL) {
31+ if (rawURL == null || rawURL === '') return '';
32+ try {
33+ return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
34+ } catch (_) {
35+ return String(rawURL);
36+ }
37+ }
38+
39+ function hasUserGesture() {
40+ try {
41+ return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
42+ } catch (_) {
43+ return false;
44+ }
45+ }
46+
47+ function popupHasNoOpener(features) {
48+ if (features == null || features === '') return false;
49+ var normalized = String(features).toLowerCase();
50+ return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
51+ }
52+
53+ function shouldDelegateWindowOpen(target) {
54+ return target === '_self' || target === '_top' || target === '_parent';
55+ }
56+
57+ function createSyntheticPopupProxy(proxyID, initialURL, features) {
58+ var href = initialURL || 'about:blank';
59+ var closed = false;
60+ var noJavaScriptAccess = popupHasNoOpener(features);
61+
62+ function navigate(nextURL) {
63+ href = resolvePopupURL(nextURL);
64+ dispatchPopupNavigate(proxyID, href);
65+ return href;
66+ }
67+
68+ var locationProxy = {
69+ assign: function(nextURL) { return navigate(nextURL); },
70+ replace: function(nextURL) { return navigate(nextURL); },
71+ toString: function() { return href; }
72+ };
73+ try {
74+ Object.defineProperty(locationProxy, 'href', {
75+ configurable: true,
76+ enumerable: true,
77+ get: function() { return href; },
78+ set: function(nextURL) { navigate(nextURL); }
79+ });
80+ } catch (_) {
81+ locationProxy.href = href;
82+ }
83+
84+ var proxy = {
85+ blur: function() { return undefined; },
86+ close: function() {
87+ if (closed) return;
88+ closed = true;
89+ dispatchPopupClose(proxyID);
90+ },
91+ focus: function() { return undefined; },
92+ postMessage: function() { return undefined; }
93+ };
94+ proxy.opener = noJavaScriptAccess ? null : window;
95+ proxy.self = proxy;
96+ proxy.window = proxy;
97+
98+ try {
99+ Object.defineProperty(proxy, 'closed', {
100+ configurable: true,
101+ enumerable: true,
102+ get: function() { return closed; },
103+ set: function(next) { closed = !!next; }
104+ });
105+ } catch (_) {
106+ proxy.closed = false;
107+ }
108+
109+ try {
110+ Object.defineProperty(proxy, 'location', {
111+ configurable: true,
112+ enumerable: true,
113+ get: function() { return locationProxy; },
114+ set: function(nextURL) { navigate(nextURL); }
115+ });
116+ } catch (_) {
117+ proxy.location = locationProxy;
118+ }
119+
120+ try {
121+ Object.defineProperty(proxy, Symbol.toStringTag, {
122+ configurable: true,
123+ value: 'Window'
124+ });
125+ } catch (_) {}
126+
127+ return proxy;
128+ }
129+
130+ try {
131+ var originalWindowOpen = typeof window.open === 'function' ? window.open : null;
132+ if (!originalWindowOpen) return;
133+
134+ window.__DUMBER_POPUP_PATCH_FLAG__ = true;
135+ window.open = function(url, target, features) {
136+ var normalizedTarget = target == null ? '' : String(target);
137+ if (shouldDelegateWindowOpen(normalizedTarget)) {
138+ return originalWindowOpen.apply(this, arguments);
139+ }
140+
141+ var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
142+ var resolvedURL = resolvePopupURL(url);
143+ var noJavaScriptAccess = popupHasNoOpener(features);
144+ var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
145+ dispatchPopupOpen({
146+ proxy_id: proxyID,
147+ url: resolvedURL,
148+ frame_name: normalizedTarget,
149+ user_gesture: hasUserGesture(),
150+ no_javascript_access: noJavaScriptAccess
151+ });
152+ return popupProxy;
153+ };
154+ } catch (_) {
155+ window.__DUMBER_POPUP_PATCH_FLAG__ = false;
156+ }
157+`
158+
159+const popupFetchBridgeHelpersJSTemplate = `
160+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
161+
162+ function encodeBody(body) {
163+ try {
164+ return btoa(unescape(encodeURIComponent(body)));
165+ } catch (_) {
166+ return '';
167+ }
168+ }
169+
170+ function postBridge(url, payload) {
171+ var encoded = encodeBody(JSON.stringify(payload));
172+ if (!encoded) return;
173+ fetch(url, {
174+ method: 'POST',
175+ headers: {
176+ 'X-Dumber-Body': encoded,
177+ 'X-Dumber-Bridge-Nonce': bridgeNonce
178+ }
179+ }).catch(function() {});
180+ }
181+
182+ function dispatchPopupOpen(payload) {
183+ postBridge('dumb:///api/popup-open', payload);
184+ }
185+
186+ function dispatchPopupNavigate(proxyID, url) {
187+ postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: url });
188+ }
189+
190+ function dispatchPopupClose(proxyID) {
191+ postBridge('dumb:///api/popup-close', { proxy_id: proxyID });
192+ }
193+`
194+
195+const rendererPopupDispatchHelpersJS = `
196+ function dispatchPopupOpen(payload) {
197+ send('popup_open', JSON.stringify(payload));
198+ }
199+
200+ function dispatchPopupNavigate(proxyID, url) {
201+ send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: url }));
202+ }
203+
204+ function dispatchPopupClose(proxyID) {
205+ send('popup_close', JSON.stringify({ proxy_id: proxyID }));
206+ }
207+`
208+
209+func buildPopupProxyBridgeBodyJS(dispatch popupProxyBridgeDispatch) string {
210+ replacer := strings.NewReplacer(
211+ "__DUMBER_POPUP_PATCH_FLAG__", dispatch.patchFlag,
212+ "__DUMBER_POPUP_DISPATCH_HELPERS__", dispatch.helpers,
213+ )
214+ return replacer.Replace(popupProxyBridgeBodyTemplateJS)
215+}
216+
217+func wrapSelfExecutingScript(body string) string {
218+ return fmt.Sprintf("(function() {\n%s\n})();", body)
219+}
220+
221+func buildTrustedPageFetchBridgeBaseJS() string {
222+ popupBridge := wrapSelfExecutingScript(buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{
223+ patchFlag: "__dumberPopupBridgePatched",
224+ helpers: popupFetchBridgeHelpersJSTemplate,
225+ }))
226+ return clipboardSelectionFetchBridgeJSTemplate + "\n" + popupBridge
227+}
228+
229+func buildTrustedPageFetchBridgeJS(bridgeNonce string) string {
230+ return strings.ReplaceAll(trustedPageFetchBridgeBaseJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
231+}
232+
233+func buildRendererBridgeExtensionJS() string {
234+ popupBridgeBody := buildPopupProxyBridgeBodyJS(popupProxyBridgeDispatch{
235+ patchFlag: "__dumberPopupOpenPatched",
236+ helpers: rendererPopupDispatchHelpersJS,
237+ })
238+ return strings.ReplaceAll(rendererBridgeExtensionTemplateJS, "__DUMBER_POPUP_PROXY_BRIDGE_BODY__", popupBridgeBody)
239+}
240+
241+func buildPopupOpenerBridgeJS(bridgeNonce, parentURI string) string {
242+ replacer := strings.NewReplacer(
243+ "__DUMBER_BRIDGE_NONCE__", webutil.EscapeForJSString(bridgeNonce),
244+ "__DUMBER_PARENT_URI__", webutil.EscapeForJSString(parentURI),
245+ )
246+ return replacer.Replace(popupOpenerBridgeTemplateJS)
247+}
248+
249+// clipboardSelectionFetchBridgeJSTemplate keeps the lightweight fetch bridge
250+// enabled while the native renderer bridge remains disabled due to an OSR
251+// startup regression. It mirrors explicit clipboard text from DOM selections,
252+// safe input/textarea selections, and async Clipboard API writes, and also
253+// reasserts browser focus when an editable element gains DOM focus.
254+const clipboardSelectionFetchBridgeJSTemplate = `(function(){
255+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
256+
257+ function encodeBody(body) {
258+ return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
259+ }
260+
261+ function inputType(node) {
262+ return node && node.type ? String(node.type).toLowerCase() : 'text';
263+ }
264+
265+ function isTextEditableInputType(type) {
266+ switch ((type || 'text').toLowerCase()) {
267+ case 'button':
268+ case 'submit':
269+ case 'reset':
270+ case 'checkbox':
271+ case 'radio':
272+ case 'range':
273+ case 'color':
274+ case 'file':
275+ case 'image':
276+ case 'hidden':
277+ return false;
278+ default:
279+ return true;
280+ }
281+ }
282+
283+ function isSelectionReadableInputType(type) {
284+ return isTextEditableInputType(type) && type !== 'password';
285+ }
286+
287+ function getActiveElementSelection() {
288+ var el = document.activeElement;
289+ if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
290+ var tag = el.tagName;
291+ if (el.disabled || el.readOnly) return '';
292+ if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
293+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
294+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
295+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
296+ if (end > start) return el.value.slice(start, end);
297+ return '';
298+ }
299+
300+ function getSelectedText() {
301+ var activeSelection = getActiveElementSelection();
302+ if (activeSelection) return activeSelection;
303+ var sel = window.getSelection();
304+ return sel ? sel.toString() : '';
305+ }
306+
307+ function isEditable(node) {
308+ if (!node || node.nodeType !== 1) return false;
309+ if (node.isContentEditable) return true;
310+ var tag = node.tagName;
311+ if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
312+ if (tag !== 'INPUT') return false;
313+ if (node.disabled || node.readOnly) return false;
314+ return isTextEditableInputType(inputType(node));
315+ }
316+
317+ function sendToClipboard(text) {
318+ if (!text) return;
319+ var body = JSON.stringify({text: text});
320+ var encoded = encodeBody(body);
321+ if (!encoded) return;
322+ fetch('dumb:///api/clipboard-set', {
323+ method: 'POST',
324+ headers: {
325+ 'X-Dumber-Body': encoded,
326+ 'X-Dumber-Bridge-Nonce': bridgeNonce
327+ }
328+ }).catch(function(){});
329+ }
330+
331+ function sendFocusSync() {
332+ fetch('dumb:///api/focus-sync', {
333+ method: 'POST',
334+ headers: {
335+ 'X-Dumber-Bridge-Action': 'focus-sync',
336+ 'X-Dumber-Bridge-Nonce': bridgeNonce
337+ }
338+ }).catch(function(){});
339+ }
340+
341+ function mirrorClipboardItems(items) {
342+ try {
343+ if (!items || typeof items.length !== 'number') return;
344+ Array.prototype.forEach.call(items, function(item) {
345+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
346+ Promise.resolve(item.getType('text/plain'))
347+ .then(function(blob) {
348+ if (!blob || typeof blob.text !== 'function') return '';
349+ return blob.text();
350+ })
351+ .then(function(text) {
352+ if (text) sendToClipboard(text);
353+ })
354+ .catch(function(){});
355+ });
356+ } catch (_) {}
357+ }
358+
359+ document.addEventListener('copy', function(event) {
360+ if (event && event.isTrusted === false) return;
361+ var text = getSelectedText();
362+ if (text) sendToClipboard(text);
363+ });
364+ document.addEventListener('cut', function(event) {
365+ if (event && event.isTrusted === false) return;
366+ var text = getSelectedText();
367+ if (text) sendToClipboard(text);
368+ });
369+ document.addEventListener('focusin', function(event) {
370+ if (event && event.isTrusted === false) return;
371+ if (isEditable(event && event.target)) sendFocusSync();
372+ }, true);
373+
374+ try {
375+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
376+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
377+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
378+
379+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
380+ clipboardProto.__dumberWriteTextPatched = true;
381+ var originalProtoWriteText = clipboardProto.writeText;
382+ var wrappedProtoWriteText = function(text) {
383+ var normalized = text == null ? '' : String(text);
384+ var result = originalProtoWriteText.call(this, normalized);
385+ if (result && typeof result.then === 'function') {
386+ return result.then(function(value) {
387+ if (normalized) sendToClipboard(normalized);
388+ return value;
389+ });
390+ }
391+ if (normalized) sendToClipboard(normalized);
392+ return result;
393+ };
394+ try {
395+ Object.defineProperty(clipboardProto, 'writeText', {
396+ configurable: true,
397+ writable: true,
398+ value: wrappedProtoWriteText
399+ });
400+ } catch (_) {
401+ clipboardProto.writeText = wrappedProtoWriteText;
402+ }
403+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
404+ window.__dumberClipboardWriteTextPatched = true;
405+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
406+ var wrappedWriteText = function(text) {
407+ var normalized = text == null ? '' : String(text);
408+ var result = originalWriteText(normalized);
409+ if (result && typeof result.then === 'function') {
410+ return result.then(function(value) {
411+ if (normalized) sendToClipboard(normalized);
412+ return value;
413+ });
414+ }
415+ if (normalized) sendToClipboard(normalized);
416+ return result;
417+ };
418+ try {
419+ Object.defineProperty(clipboardObj, 'writeText', {
420+ configurable: true,
421+ writable: true,
422+ value: wrappedWriteText
423+ });
424+ } catch (_) {
425+ clipboardObj.writeText = wrappedWriteText;
426+ }
427+ }
428+
429+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
430+ clipboardProto.__dumberWritePatched = true;
431+ var originalProtoWrite = clipboardProto.write;
432+ var wrappedProtoWrite = function(items) {
433+ var result = originalProtoWrite.apply(this, arguments);
434+ if (result && typeof result.then === 'function') {
435+ return result.then(function(value) {
436+ mirrorClipboardItems(items);
437+ return value;
438+ });
439+ }
440+ mirrorClipboardItems(items);
441+ return result;
442+ };
443+ try {
444+ Object.defineProperty(clipboardProto, 'write', {
445+ configurable: true,
446+ writable: true,
447+ value: wrappedProtoWrite
448+ });
449+ } catch (_) {
450+ clipboardProto.write = wrappedProtoWrite;
451+ }
452+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
453+ window.__dumberClipboardWritePatched = true;
454+ var originalWrite = clipboardObj.write.bind(clipboardObj);
455+ var wrappedWrite = function(items) {
456+ var result = originalWrite(items);
457+ if (result && typeof result.then === 'function') {
458+ return result.then(function(value) {
459+ mirrorClipboardItems(items);
460+ return value;
461+ });
462+ }
463+ mirrorClipboardItems(items);
464+ return result;
465+ };
466+ try {
467+ Object.defineProperty(clipboardObj, 'write', {
468+ configurable: true,
469+ writable: true,
470+ value: wrappedWrite
471+ });
472+ } catch (_) {
473+ clipboardObj.write = wrappedWrite;
474+ }
475+ }
476+ } catch (_) {}
477+
478+ if (isEditable(document.activeElement)) sendFocusSync();
479+})();`
480+
481+const rendererBridgeExtensionTemplateJS = `
482+(function() {
483+ native function Dispatch(action, payload);
484+
485+ function send(action, payload) {
486+ return Dispatch(action, payload == null ? '' : String(payload));
487+ }
488+
489+ function isEditable(node) {
490+ if (!node || node.nodeType !== 1) return false;
491+ if (node.isContentEditable) return true;
492+ var tag = node.tagName;
493+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;
494+ if (node.disabled || node.readOnly) return false;
495+ return true;
496+ }
497+
498+ function getActiveElementSelection() {
499+ var el = document.activeElement;
500+ if (!el) return '';
501+ var tag = el.tagName;
502+ if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {
503+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
504+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
505+ if (end > start) return el.value.slice(start, end);
506+ }
507+ return '';
508+ }
509+
510+ function getSelectedText() {
511+ var activeSelection = getActiveElementSelection();
512+ if (activeSelection) return activeSelection;
513+ var sel = window.getSelection();
514+ return sel ? sel.toString() : '';
515+ }
516+
517+ function notifyExplicitTextCopy(action, text) {
518+ var normalizedText = text == null ? '' : String(text);
519+ var normalizedAction = action == null || action === '' ? 'copy' : String(action);
520+ return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
521+ }
522+
523+__DUMBER_POPUP_PROXY_BRIDGE_BODY__
524+
525+ if (typeof document !== 'undefined' && document.addEventListener) {
526+ document.addEventListener('focusin', function(event) {
527+ if (isEditable(event.target)) {
528+ send('focus_sync', '');
529+ }
530+ }, true);
531+
532+ function mirrorClipboardEvent(action, e) {
533+ if (!e.isTrusted) return;
534+ var capturedText = '';
535+ if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
536+ try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {}
537+ }
538+ setTimeout(function() {
539+ var text = capturedText;
540+ if (!text && !e.defaultPrevented) text = getSelectedText();
541+ if (text) notifyExplicitTextCopy(action, text);
542+ }, 0);
543+ }
544+
545+ document.addEventListener('copy', function(e) {
546+ mirrorClipboardEvent('copy', e);
547+ }, true);
548+ document.addEventListener('cut', function(e) {
549+ mirrorClipboardEvent('cut', e);
550+ }, true);
551+ }
552+ try {
553+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
554+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
555+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
556+
557+ function mirrorClipboardItems(items) {
558+ try {
559+ if (!items || typeof items.length !== 'number') return;
560+ Array.prototype.forEach.call(items, function(item) {
561+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
562+ Promise.resolve(item.getType('text/plain'))
563+ .then(function(blob) {
564+ if (!blob || typeof blob.text !== 'function') return '';
565+ return blob.text();
566+ })
567+ .then(function(text) {
568+ if (text) notifyExplicitTextCopy('copy', text);
569+ })
570+ .catch(function() {});
571+ });
572+ } catch (_) {}
573+ }
574+
575+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
576+ clipboardProto.__dumberWriteTextPatched = true;
577+ var originalProtoWriteText = clipboardProto.writeText;
578+ var wrappedProtoWriteText = function(text) {
579+ var normalized = text == null ? '' : String(text);
580+ var result = originalProtoWriteText.call(this, normalized);
581+ if (result && typeof result.then === 'function') {
582+ return result.then(function(value) {
583+ if (normalized) notifyExplicitTextCopy('copy', normalized);
584+ return value;
585+ });
586+ }
587+ if (normalized) notifyExplicitTextCopy('copy', normalized);
588+ return result;
589+ };
590+ try {
591+ Object.defineProperty(clipboardProto, 'writeText', {
592+ configurable: true,
593+ writable: true,
594+ value: wrappedProtoWriteText
595+ });
596+ } catch (_) {
597+ clipboardProto.writeText = wrappedProtoWriteText;
598+ }
599+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
600+ window.__dumberClipboardWriteTextPatched = true;
601+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
602+ var wrappedWriteText = function(text) {
603+ var normalized = text == null ? '' : String(text);
604+ var result = originalWriteText(normalized);
605+ if (result && typeof result.then === 'function') {
606+ return result.then(function(value) {
607+ if (normalized) notifyExplicitTextCopy('copy', normalized);
608+ return value;
609+ });
610+ }
611+ if (normalized) notifyExplicitTextCopy('copy', normalized);
612+ return result;
613+ };
614+ try {
615+ Object.defineProperty(clipboardObj, 'writeText', {
616+ configurable: true,
617+ writable: true,
618+ value: wrappedWriteText
619+ });
620+ } catch (_) {
621+ clipboardObj.writeText = wrappedWriteText;
622+ }
623+ }
624+
625+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
626+ clipboardProto.__dumberWritePatched = true;
627+ var originalProtoWrite = clipboardProto.write;
628+ var wrappedProtoWrite = function(items) {
629+ var result = originalProtoWrite.apply(this, arguments);
630+ if (result && typeof result.then === 'function') {
631+ return result.then(function(value) {
632+ mirrorClipboardItems(items);
633+ return value;
634+ });
635+ }
636+ mirrorClipboardItems(items);
637+ return result;
638+ };
639+ try {
640+ Object.defineProperty(clipboardProto, 'write', {
641+ configurable: true,
642+ writable: true,
643+ value: wrappedProtoWrite
644+ });
645+ } catch (_) {
646+ clipboardProto.write = wrappedProtoWrite;
647+ }
648+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
649+ window.__dumberClipboardWritePatched = true;
650+ var originalWrite = clipboardObj.write.bind(clipboardObj);
651+ var wrappedWrite = function(items) {
652+ var result = originalWrite(items);
653+ if (result && typeof result.then === 'function') {
654+ return result.then(function(value) {
655+ mirrorClipboardItems(items);
656+ return value;
657+ });
658+ }
659+ mirrorClipboardItems(items);
660+ return result;
661+ };
662+ try {
663+ Object.defineProperty(clipboardObj, 'write', {
664+ configurable: true,
665+ writable: true,
666+ value: wrappedWrite
667+ });
668+ } catch (_) {
669+ clipboardObj.write = wrappedWrite;
670+ }
671+ }
672+ } catch (_) {}
673+
674+ send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : '');
675+})();
676+`
677+
678+const popupOpenerBridgeTemplateJS = `(function() {
679+ if (typeof window === 'undefined') return;
680+ if (window.__dumberPopupOpenerBridgeInstalled) return;
681+ window.__dumberPopupOpenerBridgeInstalled = true;
682+ if (window.opener != null) return;
683+
684+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
685+ var openerHref = '__DUMBER_PARENT_URI__';
686+
687+ function postBridge(path, payload) {
688+ try {
689+ fetch('dumb:///api/' + path, {
690+ method: 'POST',
691+ headers: {
692+ 'Content-Type': 'application/json',
693+ 'X-Dumber-Body': btoa(unescape(encodeURIComponent(JSON.stringify(payload)))),
694+ 'X-Dumber-Bridge-Nonce': bridgeNonce
695+ }
696+ }).catch(function() {});
697+ } catch (_) {}
698+ }
699+
700+ function normalizeURLValue(rawURL) {
701+ if (rawURL == null) return '';
702+ try {
703+ return String(rawURL);
704+ } catch (_) {
705+ return '';
706+ }
707+ }
708+
709+ function navigateOpener(nextURL) {
710+ var rawURL = normalizeURLValue(nextURL);
711+ if (rawURL !== '') openerHref = rawURL;
712+ postBridge('popup-opener-navigate', { url: rawURL });
713+ return openerHref;
714+ }
715+
716+ function serializeMessage(value) {
717+ try {
718+ return { kind: 'json', value: JSON.stringify(value) };
719+ } catch (_) {
720+ return { kind: 'string', value: String(value) };
721+ }
722+ }
723+
724+ var locationProxy = {
725+ assign: function(nextURL) { return navigateOpener(nextURL); },
726+ replace: function(nextURL) { return navigateOpener(nextURL); },
727+ toString: function() { return openerHref; }
728+ };
729+ try {
730+ Object.defineProperty(locationProxy, 'href', {
731+ configurable: true,
732+ enumerable: true,
733+ get: function() { return openerHref; },
734+ set: function(nextURL) { navigateOpener(nextURL); }
735+ });
736+ } catch (_) {
737+ locationProxy.href = openerHref;
738+ }
739+
740+ var openerProxy = {
741+ blur: function() { return undefined; },
742+ close: function() { return undefined; },
743+ focus: function() { return undefined; },
744+ postMessage: function(message, targetOrigin) {
745+ var serialized = serializeMessage(message);
746+ postBridge('popup-opener-post-message', {
747+ data: serialized.value,
748+ data_kind: serialized.kind,
749+ target_origin: targetOrigin == null ? '*' : String(targetOrigin),
750+ source_origin: (typeof location !== 'undefined' && location && location.origin) ? location.origin : '',
751+ source_href: (typeof location !== 'undefined' && location && location.href) ? location.href : ''
752+ });
753+ return undefined;
754+ }
755+ };
756+ openerProxy.self = openerProxy;
757+ openerProxy.window = openerProxy;
758+ try {
759+ Object.defineProperty(openerProxy, 'closed', {
760+ configurable: true,
761+ enumerable: true,
762+ get: function() { return false; }
763+ });
764+ } catch (_) {
765+ openerProxy.closed = false;
766+ }
767+ try {
768+ Object.defineProperty(openerProxy, 'location', {
769+ configurable: true,
770+ enumerable: true,
771+ get: function() { return locationProxy; },
772+ set: function(nextURL) { navigateOpener(nextURL); }
773+ });
774+ } catch (_) {
775+ openerProxy.location = locationProxy;
776+ }
777+
778+ try {
779+ Object.defineProperty(window, 'opener', {
780+ configurable: true,
781+ enumerable: true,
782+ get: function() { return openerProxy; }
783+ });
784+ } catch (_) {
785+ try { window.opener = openerProxy; } catch (_) {}
786+ }
787+})();`
M internal/infrastructure/cef/content_injector.go +1 -394
@@ -385,398 +385,6 @@ const redditDirectVideoJS = `(function(){
385385 }, 1000);
386386 })();`
387387
388-// clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled
389-// while the native renderer bridge remains disabled due to an OSR startup
390-// regression. It mirrors explicit clipboard text from DOM selections,
391-// safe input/textarea selections, and async Clipboard API writes, and also
392-// reasserts browser focus when an editable element gains DOM focus.
393-const clipboardSelectionFetchBridgeJS = `(function(){
394- var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
395-
396- function encodeBody(body) {
397- return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
398- }
399-
400- function inputType(node) {
401- return node && node.type ? String(node.type).toLowerCase() : 'text';
402- }
403-
404- function isTextEditableInputType(type) {
405- switch ((type || 'text').toLowerCase()) {
406- case 'button':
407- case 'submit':
408- case 'reset':
409- case 'checkbox':
410- case 'radio':
411- case 'range':
412- case 'color':
413- case 'file':
414- case 'image':
415- case 'hidden':
416- return false;
417- default:
418- return true;
419- }
420- }
421-
422- function isSelectionReadableInputType(type) {
423- return isTextEditableInputType(type) && type !== 'password';
424- }
425-
426- function getActiveElementSelection() {
427- var el = document.activeElement;
428- if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
429- var tag = el.tagName;
430- if (el.disabled || el.readOnly) return '';
431- if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
432- if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
433- var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
434- var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
435- if (end > start) return el.value.slice(start, end);
436- return '';
437- }
438-
439- function getSelectedText() {
440- var activeSelection = getActiveElementSelection();
441- if (activeSelection) return activeSelection;
442- var sel = window.getSelection();
443- return sel ? sel.toString() : '';
444- }
445-
446- function isEditable(node) {
447- if (!node || node.nodeType !== 1) return false;
448- if (node.isContentEditable) return true;
449- var tag = node.tagName;
450- if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
451- if (tag !== 'INPUT') return false;
452- if (node.disabled || node.readOnly) return false;
453- return isTextEditableInputType(inputType(node));
454- }
455-
456- function sendToClipboard(text) {
457- if (!text) return;
458- var body = JSON.stringify({text: text});
459- var encoded = encodeBody(body);
460- if (!encoded) return;
461- fetch('dumb:///api/clipboard-set', {
462- method: 'POST',
463- headers: {
464- 'X-Dumber-Body': encoded,
465- 'X-Dumber-Bridge-Nonce': bridgeNonce
466- }
467- }).catch(function(){});
468- }
469-
470- function sendFocusSync() {
471- fetch('dumb:///api/focus-sync', {
472- method: 'POST',
473- headers: {
474- 'X-Dumber-Bridge-Action': 'focus-sync',
475- 'X-Dumber-Bridge-Nonce': bridgeNonce
476- }
477- }).catch(function(){});
478- }
479-
480- function mirrorClipboardItems(items) {
481- try {
482- if (!items || typeof items.length !== 'number') return;
483- Array.prototype.forEach.call(items, function(item) {
484- if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
485- Promise.resolve(item.getType('text/plain'))
486- .then(function(blob) {
487- if (!blob || typeof blob.text !== 'function') return '';
488- return blob.text();
489- })
490- .then(function(text) {
491- if (text) sendToClipboard(text);
492- })
493- .catch(function(){});
494- });
495- } catch (_) {}
496- }
497-
498- document.addEventListener('copy', function(event) {
499- if (event && event.isTrusted === false) return;
500- var text = getSelectedText();
501- if (text) sendToClipboard(text);
502- });
503- document.addEventListener('cut', function(event) {
504- if (event && event.isTrusted === false) return;
505- var text = getSelectedText();
506- if (text) sendToClipboard(text);
507- });
508- document.addEventListener('focusin', function(event) {
509- if (event && event.isTrusted === false) return;
510- if (isEditable(event && event.target)) sendFocusSync();
511- }, true);
512-
513- try {
514- var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
515- var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
516- (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
517-
518- if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
519- clipboardProto.__dumberWriteTextPatched = true;
520- var originalProtoWriteText = clipboardProto.writeText;
521- var wrappedProtoWriteText = function(text) {
522- var normalized = text == null ? '' : String(text);
523- var result = originalProtoWriteText.call(this, normalized);
524- if (result && typeof result.then === 'function') {
525- return result.then(function(value) {
526- if (normalized) sendToClipboard(normalized);
527- return value;
528- });
529- }
530- if (normalized) sendToClipboard(normalized);
531- return result;
532- };
533- try {
534- Object.defineProperty(clipboardProto, 'writeText', {
535- configurable: true,
536- writable: true,
537- value: wrappedProtoWriteText
538- });
539- } catch (_) {
540- clipboardProto.writeText = wrappedProtoWriteText;
541- }
542- } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
543- window.__dumberClipboardWriteTextPatched = true;
544- var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
545- var wrappedWriteText = function(text) {
546- var normalized = text == null ? '' : String(text);
547- var result = originalWriteText(normalized);
548- if (result && typeof result.then === 'function') {
549- return result.then(function(value) {
550- if (normalized) sendToClipboard(normalized);
551- return value;
552- });
553- }
554- if (normalized) sendToClipboard(normalized);
555- return result;
556- };
557- try {
558- Object.defineProperty(clipboardObj, 'writeText', {
559- configurable: true,
560- writable: true,
561- value: wrappedWriteText
562- });
563- } catch (_) {
564- clipboardObj.writeText = wrappedWriteText;
565- }
566- }
567-
568- if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
569- clipboardProto.__dumberWritePatched = true;
570- var originalProtoWrite = clipboardProto.write;
571- var wrappedProtoWrite = function(items) {
572- var result = originalProtoWrite.apply(this, arguments);
573- if (result && typeof result.then === 'function') {
574- return result.then(function(value) {
575- mirrorClipboardItems(items);
576- return value;
577- });
578- }
579- mirrorClipboardItems(items);
580- return result;
581- };
582- try {
583- Object.defineProperty(clipboardProto, 'write', {
584- configurable: true,
585- writable: true,
586- value: wrappedProtoWrite
587- });
588- } catch (_) {
589- clipboardProto.write = wrappedProtoWrite;
590- }
591- } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
592- window.__dumberClipboardWritePatched = true;
593- var originalWrite = clipboardObj.write.bind(clipboardObj);
594- var wrappedWrite = function(items) {
595- var result = originalWrite(items);
596- if (result && typeof result.then === 'function') {
597- return result.then(function(value) {
598- mirrorClipboardItems(items);
599- return value;
600- });
601- }
602- mirrorClipboardItems(items);
603- return result;
604- };
605- try {
606- Object.defineProperty(clipboardObj, 'write', {
607- configurable: true,
608- writable: true,
609- value: wrappedWrite
610- });
611- } catch (_) {
612- clipboardObj.write = wrappedWrite;
613- }
614- }
615- } catch (_) {}
616-
617- if (isEditable(document.activeElement)) sendFocusSync();
618-})();`
619-
620-const popupFetchBridgeJS = `(function() {
621- if (window.__dumberPopupBridgePatched) return;
622- window.__dumberPopupBridgePatched = true;
623-
624- var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
625-
626- function encodeBody(body) {
627- try {
628- return btoa(unescape(encodeURIComponent(body)));
629- } catch (_) {
630- return '';
631- }
632- }
633-
634- function postBridge(url, payload) {
635- var encoded = encodeBody(JSON.stringify(payload));
636- if (!encoded) return;
637- fetch(url, {
638- method: 'POST',
639- headers: {
640- 'X-Dumber-Body': encoded,
641- 'X-Dumber-Bridge-Nonce': bridgeNonce
642- }
643- }).catch(function() {});
644- }
645-
646- function resolvePopupURL(rawURL) {
647- if (rawURL == null || rawURL === '') return '';
648- try {
649- return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
650- } catch (_) {
651- return String(rawURL);
652- }
653- }
654-
655- function hasUserGesture() {
656- try {
657- return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
658- } catch (_) {
659- return false;
660- }
661- }
662-
663- function popupHasNoOpener(features) {
664- if (features == null || features === '') return false;
665- var normalized = String(features).toLowerCase();
666- return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
667- }
668-
669- function shouldDelegateWindowOpen(target) {
670- return target === '_self' || target === '_top' || target === '_parent';
671- }
672-
673- function createSyntheticPopupProxy(proxyID, initialURL, features) {
674- var href = initialURL || 'about:blank';
675- var closed = false;
676- var noJavaScriptAccess = popupHasNoOpener(features);
677-
678- function navigate(nextURL) {
679- href = resolvePopupURL(nextURL);
680- postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: href });
681- return href;
682- }
683-
684- var locationProxy = {
685- assign: function(nextURL) { return navigate(nextURL); },
686- replace: function(nextURL) { return navigate(nextURL); },
687- toString: function() { return href; }
688- };
689- try {
690- Object.defineProperty(locationProxy, 'href', {
691- configurable: true,
692- enumerable: true,
693- get: function() { return href; },
694- set: function(nextURL) { navigate(nextURL); }
695- });
696- } catch (_) {
697- locationProxy.href = href;
698- }
699-
700- var proxy = {
701- blur: function() { return undefined; },
702- close: function() {
703- if (closed) return;
704- closed = true;
705- postBridge('dumb:///api/popup-close', { proxy_id: proxyID });
706- },
707- focus: function() { return undefined; },
708- postMessage: function() { return undefined; }
709- };
710- proxy.opener = noJavaScriptAccess ? null : window;
711- proxy.self = proxy;
712- proxy.window = proxy;
713-
714- try {
715- Object.defineProperty(proxy, 'closed', {
716- configurable: true,
717- enumerable: true,
718- get: function() { return closed; },
719- set: function(next) { closed = !!next; }
720- });
721- } catch (_) {
722- proxy.closed = false;
723- }
724-
725- try {
726- Object.defineProperty(proxy, 'location', {
727- configurable: true,
728- enumerable: true,
729- get: function() { return locationProxy; },
730- set: function(nextURL) { navigate(nextURL); }
731- });
732- } catch (_) {
733- proxy.location = locationProxy;
734- }
735-
736- try {
737- Object.defineProperty(proxy, Symbol.toStringTag, {
738- configurable: true,
739- value: 'Window'
740- });
741- } catch (_) {}
742-
743- return proxy;
744- }
745-
746- try {
747- var originalWindowOpen = typeof window.open === 'function' ? window.open.bind(window) : null;
748- window.open = function(url, target, features) {
749- var normalizedTarget = target == null ? '' : String(target);
750- if (originalWindowOpen && shouldDelegateWindowOpen(normalizedTarget)) {
751- return originalWindowOpen.apply(window, arguments);
752- }
753-
754- var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
755- var resolvedURL = resolvePopupURL(url);
756- var noJavaScriptAccess = popupHasNoOpener(features);
757- var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
758- postBridge('dumb:///api/popup-open', {
759- proxy_id: proxyID,
760- url: resolvedURL,
761- frame_name: normalizedTarget,
762- user_gesture: hasUserGesture(),
763- no_javascript_access: noJavaScriptAccess
764- });
765- return popupProxy;
766- };
767- } catch (_) {
768- window.__dumberPopupBridgePatched = false;
769- }
770-})();`
771-
772-func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {
773- return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
774-}
775-
776-func buildPopupFetchBridgeJS(bridgeNonce string) string {
777- return strings.ReplaceAll(popupFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
778-}
779-
780388 // contentInjector implements port.ContentInjector for the CEF engine.
781389 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
782390 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -907,8 +515,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
907515 }
908516 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
909517 } else {
910- wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))
911- wv.RunJavaScript(context.Background(), buildPopupFetchBridgeJS(bridgeNonce))
518+ wv.RunJavaScript(context.Background(), buildTrustedPageFetchBridgeJS(bridgeNonce))
912519 }
913520
914521 // Video playback diagnostic — logs video element state changes.
@@ -385,398 +385,6 @@ const redditDirectVideoJS = `(function(){
385 }, 1000);385 }, 1000);
386 })();`386 })();`
387 387
388-// clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled
389-// while the native renderer bridge remains disabled due to an OSR startup
390-// regression. It mirrors explicit clipboard text from DOM selections,
391-// safe input/textarea selections, and async Clipboard API writes, and also
392-// reasserts browser focus when an editable element gains DOM focus.
393-const clipboardSelectionFetchBridgeJS = `(function(){
394- var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
395-
396- function encodeBody(body) {
397- return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
398- }
399-
400- function inputType(node) {
401- return node && node.type ? String(node.type).toLowerCase() : 'text';
402- }
403-
404- function isTextEditableInputType(type) {
405- switch ((type || 'text').toLowerCase()) {
406- case 'button':
407- case 'submit':
408- case 'reset':
409- case 'checkbox':
410- case 'radio':
411- case 'range':
412- case 'color':
413- case 'file':
414- case 'image':
415- case 'hidden':
416- return false;
417- default:
418- return true;
419- }
420- }
421-
422- function isSelectionReadableInputType(type) {
423- return isTextEditableInputType(type) && type !== 'password';
424- }
425-
426- function getActiveElementSelection() {
427- var el = document.activeElement;
428- if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
429- var tag = el.tagName;
430- if (el.disabled || el.readOnly) return '';
431- if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
432- if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
433- var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
434- var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
435- if (end > start) return el.value.slice(start, end);
436- return '';
437- }
438-
439- function getSelectedText() {
440- var activeSelection = getActiveElementSelection();
441- if (activeSelection) return activeSelection;
442- var sel = window.getSelection();
443- return sel ? sel.toString() : '';
444- }
445-
446- function isEditable(node) {
447- if (!node || node.nodeType !== 1) return false;
448- if (node.isContentEditable) return true;
449- var tag = node.tagName;
450- if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
451- if (tag !== 'INPUT') return false;
452- if (node.disabled || node.readOnly) return false;
453- return isTextEditableInputType(inputType(node));
454- }
455-
456- function sendToClipboard(text) {
457- if (!text) return;
458- var body = JSON.stringify({text: text});
459- var encoded = encodeBody(body);
460- if (!encoded) return;
461- fetch('dumb:///api/clipboard-set', {
462- method: 'POST',
463- headers: {
464- 'X-Dumber-Body': encoded,
465- 'X-Dumber-Bridge-Nonce': bridgeNonce
466- }
467- }).catch(function(){});
468- }
469-
470- function sendFocusSync() {
471- fetch('dumb:///api/focus-sync', {
472- method: 'POST',
473- headers: {
474- 'X-Dumber-Bridge-Action': 'focus-sync',
475- 'X-Dumber-Bridge-Nonce': bridgeNonce
476- }
477- }).catch(function(){});
478- }
479-
480- function mirrorClipboardItems(items) {
481- try {
482- if (!items || typeof items.length !== 'number') return;
483- Array.prototype.forEach.call(items, function(item) {
484- if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
485- Promise.resolve(item.getType('text/plain'))
486- .then(function(blob) {
487- if (!blob || typeof blob.text !== 'function') return '';
488- return blob.text();
489- })
490- .then(function(text) {
491- if (text) sendToClipboard(text);
492- })
493- .catch(function(){});
494- });
495- } catch (_) {}
496- }
497-
498- document.addEventListener('copy', function(event) {
499- if (event && event.isTrusted === false) return;
500- var text = getSelectedText();
501- if (text) sendToClipboard(text);
502- });
503- document.addEventListener('cut', function(event) {
504- if (event && event.isTrusted === false) return;
505- var text = getSelectedText();
506- if (text) sendToClipboard(text);
507- });
508- document.addEventListener('focusin', function(event) {
509- if (event && event.isTrusted === false) return;
510- if (isEditable(event && event.target)) sendFocusSync();
511- }, true);
512-
513- try {
514- var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
515- var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
516- (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
517-
518- if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
519- clipboardProto.__dumberWriteTextPatched = true;
520- var originalProtoWriteText = clipboardProto.writeText;
521- var wrappedProtoWriteText = function(text) {
522- var normalized = text == null ? '' : String(text);
523- var result = originalProtoWriteText.call(this, normalized);
524- if (result && typeof result.then === 'function') {
525- return result.then(function(value) {
526- if (normalized) sendToClipboard(normalized);
527- return value;
528- });
529- }
530- if (normalized) sendToClipboard(normalized);
531- return result;
532- };
533- try {
534- Object.defineProperty(clipboardProto, 'writeText', {
535- configurable: true,
536- writable: true,
537- value: wrappedProtoWriteText
538- });
539- } catch (_) {
540- clipboardProto.writeText = wrappedProtoWriteText;
541- }
542- } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
543- window.__dumberClipboardWriteTextPatched = true;
544- var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
545- var wrappedWriteText = function(text) {
546- var normalized = text == null ? '' : String(text);
547- var result = originalWriteText(normalized);
548- if (result && typeof result.then === 'function') {
549- return result.then(function(value) {
550- if (normalized) sendToClipboard(normalized);
551- return value;
552- });
553- }
554- if (normalized) sendToClipboard(normalized);
555- return result;
556- };
557- try {
558- Object.defineProperty(clipboardObj, 'writeText', {
559- configurable: true,
560- writable: true,
561- value: wrappedWriteText
562- });
563- } catch (_) {
564- clipboardObj.writeText = wrappedWriteText;
565- }
566- }
567-
568- if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
569- clipboardProto.__dumberWritePatched = true;
570- var originalProtoWrite = clipboardProto.write;
571- var wrappedProtoWrite = function(items) {
572- var result = originalProtoWrite.apply(this, arguments);
573- if (result && typeof result.then === 'function') {
574- return result.then(function(value) {
575- mirrorClipboardItems(items);
576- return value;
577- });
578- }
579- mirrorClipboardItems(items);
580- return result;
581- };
582- try {
583- Object.defineProperty(clipboardProto, 'write', {
584- configurable: true,
585- writable: true,
586- value: wrappedProtoWrite
587- });
588- } catch (_) {
589- clipboardProto.write = wrappedProtoWrite;
590- }
591- } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
592- window.__dumberClipboardWritePatched = true;
593- var originalWrite = clipboardObj.write.bind(clipboardObj);
594- var wrappedWrite = function(items) {
595- var result = originalWrite(items);
596- if (result && typeof result.then === 'function') {
597- return result.then(function(value) {
598- mirrorClipboardItems(items);
599- return value;
600- });
601- }
602- mirrorClipboardItems(items);
603- return result;
604- };
605- try {
606- Object.defineProperty(clipboardObj, 'write', {
607- configurable: true,
608- writable: true,
609- value: wrappedWrite
610- });
611- } catch (_) {
612- clipboardObj.write = wrappedWrite;
613- }
614- }
615- } catch (_) {}
616-
617- if (isEditable(document.activeElement)) sendFocusSync();
618-})();`
619-
620-const popupFetchBridgeJS = `(function() {
621- if (window.__dumberPopupBridgePatched) return;
622- window.__dumberPopupBridgePatched = true;
623-
624- var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
625-
626- function encodeBody(body) {
627- try {
628- return btoa(unescape(encodeURIComponent(body)));
629- } catch (_) {
630- return '';
631- }
632- }
633-
634- function postBridge(url, payload) {
635- var encoded = encodeBody(JSON.stringify(payload));
636- if (!encoded) return;
637- fetch(url, {
638- method: 'POST',
639- headers: {
640- 'X-Dumber-Body': encoded,
641- 'X-Dumber-Bridge-Nonce': bridgeNonce
642- }
643- }).catch(function() {});
644- }
645-
646- function resolvePopupURL(rawURL) {
647- if (rawURL == null || rawURL === '') return '';
648- try {
649- return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
650- } catch (_) {
651- return String(rawURL);
652- }
653- }
654-
655- function hasUserGesture() {
656- try {
657- return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
658- } catch (_) {
659- return false;
660- }
661- }
662-
663- function popupHasNoOpener(features) {
664- if (features == null || features === '') return false;
665- var normalized = String(features).toLowerCase();
666- return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
667- }
668-
669- function shouldDelegateWindowOpen(target) {
670- return target === '_self' || target === '_top' || target === '_parent';
671- }
672-
673- function createSyntheticPopupProxy(proxyID, initialURL, features) {
674- var href = initialURL || 'about:blank';
675- var closed = false;
676- var noJavaScriptAccess = popupHasNoOpener(features);
677-
678- function navigate(nextURL) {
679- href = resolvePopupURL(nextURL);
680- postBridge('dumb:///api/popup-navigate', { proxy_id: proxyID, url: href });
681- return href;
682- }
683-
684- var locationProxy = {
685- assign: function(nextURL) { return navigate(nextURL); },
686- replace: function(nextURL) { return navigate(nextURL); },
687- toString: function() { return href; }
688- };
689- try {
690- Object.defineProperty(locationProxy, 'href', {
691- configurable: true,
692- enumerable: true,
693- get: function() { return href; },
694- set: function(nextURL) { navigate(nextURL); }
695- });
696- } catch (_) {
697- locationProxy.href = href;
698- }
699-
700- var proxy = {
701- blur: function() { return undefined; },
702- close: function() {
703- if (closed) return;
704- closed = true;
705- postBridge('dumb:///api/popup-close', { proxy_id: proxyID });
706- },
707- focus: function() { return undefined; },
708- postMessage: function() { return undefined; }
709- };
710- proxy.opener = noJavaScriptAccess ? null : window;
711- proxy.self = proxy;
712- proxy.window = proxy;
713-
714- try {
715- Object.defineProperty(proxy, 'closed', {
716- configurable: true,
717- enumerable: true,
718- get: function() { return closed; },
719- set: function(next) { closed = !!next; }
720- });
721- } catch (_) {
722- proxy.closed = false;
723- }
724-
725- try {
726- Object.defineProperty(proxy, 'location', {
727- configurable: true,
728- enumerable: true,
729- get: function() { return locationProxy; },
730- set: function(nextURL) { navigate(nextURL); }
731- });
732- } catch (_) {
733- proxy.location = locationProxy;
734- }
735-
736- try {
737- Object.defineProperty(proxy, Symbol.toStringTag, {
738- configurable: true,
739- value: 'Window'
740- });
741- } catch (_) {}
742-
743- return proxy;
744- }
745-
746- try {
747- var originalWindowOpen = typeof window.open === 'function' ? window.open.bind(window) : null;
748- window.open = function(url, target, features) {
749- var normalizedTarget = target == null ? '' : String(target);
750- if (originalWindowOpen && shouldDelegateWindowOpen(normalizedTarget)) {
751- return originalWindowOpen.apply(window, arguments);
752- }
753-
754- var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
755- var resolvedURL = resolvePopupURL(url);
756- var noJavaScriptAccess = popupHasNoOpener(features);
757- var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
758- postBridge('dumb:///api/popup-open', {
759- proxy_id: proxyID,
760- url: resolvedURL,
761- frame_name: normalizedTarget,
762- user_gesture: hasUserGesture(),
763- no_javascript_access: noJavaScriptAccess
764- });
765- return popupProxy;
766- };
767- } catch (_) {
768- window.__dumberPopupBridgePatched = false;
769- }
770-})();`
771-
772-func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {
773- return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
774-}
775-
776-func buildPopupFetchBridgeJS(bridgeNonce string) string {
777- return strings.ReplaceAll(popupFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
778-}
779-
780 // contentInjector implements port.ContentInjector for the CEF engine.388 // contentInjector implements port.ContentInjector for the CEF engine.
781 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.389 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
782 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd390 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -907,8 +515,7 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
907 }515 }
908 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")516 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
909 } else {517 } else {
910- wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(bridgeNonce))518+ wv.RunJavaScript(context.Background(), buildTrustedPageFetchBridgeJS(bridgeNonce))
911- wv.RunJavaScript(context.Background(), buildPopupFetchBridgeJS(bridgeNonce))
912 }519 }
913 520
914 // Video playback diagnostic — logs video element state changes.521 // Video playback diagnostic — logs video element state changes.
M internal/infrastructure/cef/content_injector_test.go +8 -12
@@ -7,16 +7,12 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10-func testClipboardSelectionFetchBridgeJS() string {
11- return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")
12-}
13-
14-func testPopupFetchBridgeJS() string {
15- return buildPopupFetchBridgeJS("test-bridge-nonce")
10+func testTrustedPageFetchBridgeJS() string {
11+ return buildTrustedPageFetchBridgeJS("test-bridge-nonce")
1612 }
1713
1814 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
19- script := testClipboardSelectionFetchBridgeJS()
15+ script := testTrustedPageFetchBridgeJS()
2016
2117 require.Contains(t, script, "document.activeElement")
2218 require.Contains(t, script, "selectionStart")
@@ -26,7 +22,7 @@ func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *t
2622 }
2723
2824 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {
29- script := testClipboardSelectionFetchBridgeJS()
25+ script := testTrustedPageFetchBridgeJS()
3026
3127 require.Contains(t, script, "dumb:///api/focus-sync")
3228 require.Contains(t, script, "X-Dumber-Bridge-Action")
@@ -41,7 +37,7 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin
4137 }
4238
4339 func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {
44- script := testClipboardSelectionFetchBridgeJS()
40+ script := testTrustedPageFetchBridgeJS()
4541
4642 require.Contains(t, script, "dumb:///api/clipboard-set")
4743 require.Contains(t, script, "X-Dumber-Body")
@@ -52,7 +48,7 @@ func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testin
5248 }
5349
5450 func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {
55- script := testClipboardSelectionFetchBridgeJS()
51+ script := testTrustedPageFetchBridgeJS()
5652
5753 require.Contains(t, script, "navigator && navigator.clipboard")
5854 require.Contains(t, script, "clipboardProto.writeText")
@@ -67,8 +63,8 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
6763 require.Contains(t, script, "test-bridge-nonce")
6864 }
6965
70-func TestPopupFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
71- script := testPopupFetchBridgeJS()
66+func TestTrustedPageFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
67+ script := testTrustedPageFetchBridgeJS()
7268
7369 require.Contains(t, script, "window.__dumberPopupBridgePatched")
7470 require.Contains(t, script, "window.open = function(url, target, features)")
@@ -7,16 +7,12 @@ import (
7 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
8 )8 )
9 9
10-func testClipboardSelectionFetchBridgeJS() string {10+func testTrustedPageFetchBridgeJS() string {
11- return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")11+ return buildTrustedPageFetchBridgeJS("test-bridge-nonce")
12-}
13-
14-func testPopupFetchBridgeJS() string {
15- return buildPopupFetchBridgeJS("test-bridge-nonce")
16 }12 }
17 13
18 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {14 func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
19- script := testClipboardSelectionFetchBridgeJS()15+ script := testTrustedPageFetchBridgeJS()
20 16
21 require.Contains(t, script, "document.activeElement")17 require.Contains(t, script, "document.activeElement")
22 require.Contains(t, script, "selectionStart")18 require.Contains(t, script, "selectionStart")
@@ -26,7 +22,7 @@ func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *t
26 }22 }
27 23
28 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {24 func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {
29- script := testClipboardSelectionFetchBridgeJS()25+ script := testTrustedPageFetchBridgeJS()
30 26
31 require.Contains(t, script, "dumb:///api/focus-sync")27 require.Contains(t, script, "dumb:///api/focus-sync")
32 require.Contains(t, script, "X-Dumber-Bridge-Action")28 require.Contains(t, script, "X-Dumber-Bridge-Action")
@@ -41,7 +37,7 @@ func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testin
41 }37 }
42 38
43 func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {39 func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {
44- script := testClipboardSelectionFetchBridgeJS()40+ script := testTrustedPageFetchBridgeJS()
45 41
46 require.Contains(t, script, "dumb:///api/clipboard-set")42 require.Contains(t, script, "dumb:///api/clipboard-set")
47 require.Contains(t, script, "X-Dumber-Body")43 require.Contains(t, script, "X-Dumber-Body")
@@ -52,7 +48,7 @@ func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testin
52 }48 }
53 49
54 func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {50 func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {
55- script := testClipboardSelectionFetchBridgeJS()51+ script := testTrustedPageFetchBridgeJS()
56 52
57 require.Contains(t, script, "navigator && navigator.clipboard")53 require.Contains(t, script, "navigator && navigator.clipboard")
58 require.Contains(t, script, "clipboardProto.writeText")54 require.Contains(t, script, "clipboardProto.writeText")
@@ -67,8 +63,8 @@ func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T)
67 require.Contains(t, script, "test-bridge-nonce")63 require.Contains(t, script, "test-bridge-nonce")
68 }64 }
69 65
70-func TestPopupFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {66+func TestTrustedPageFetchBridgeJS_ShimsWindowOpenToBridgePopupRequests(t *testing.T) {
71- script := testPopupFetchBridgeJS()67+ script := testTrustedPageFetchBridgeJS()
72 68
73 require.Contains(t, script, "window.__dumberPopupBridgePatched")69 require.Contains(t, script, "window.__dumberPopupBridgePatched")
74 require.Contains(t, script, "window.open = function(url, target, features)")70 require.Contains(t, script, "window.open = function(url, target, features)")
M internal/infrastructure/cef/popup_opener_bridge.go +5 -114
@@ -32,7 +32,7 @@ func (wv *WebView) setPopupNoJavaScriptAccess(noJavaScriptAccess bool) {
3232 wv.mu.Unlock()
3333 }
3434
35-// AddOpenerMessageCallback implements port.PopupOpenerMessageCapable.
35+// AddOpenerMessageCallback implements port.PopupOpenerCapable.
3636 func (wv *WebView) AddOpenerMessageCallback(fn func()) {
3737 if wv == nil || fn == nil {
3838 return
@@ -42,7 +42,7 @@ func (wv *WebView) AddOpenerMessageCallback(fn func()) {
4242 wv.mu.Unlock()
4343 }
4444
45-// AddOpenerNavigationCallback implements port.PopupOpenerNavigationCapable.
45+// AddOpenerNavigationCallback implements port.PopupOpenerCapable.
4646 func (wv *WebView) AddOpenerNavigationCallback(fn func(uri string)) {
4747 if wv == nil || fn == nil {
4848 return
@@ -90,7 +90,7 @@ func (wv *WebView) runOpenerNavigationCallbacks(uri string) {
9090 })
9191 }
9292
93-// HasActivePopupOpenerBridge implements port.PopupOpenerBridgeStateCapable.
93+// HasActivePopupOpenerBridge implements port.PopupOpenerCapable.
9494 func (wv *WebView) HasActivePopupOpenerBridge() bool {
9595 if wv == nil {
9696 return false
@@ -100,7 +100,7 @@ func (wv *WebView) HasActivePopupOpenerBridge() bool {
100100 return !wv.popupNoJavaScriptAccess && wv.popupOpenerBridgeParent != nil
101101 }
102102
103-// EnablePopupOpenerBridge implements port.PopupOpenerBridgeCapable.
103+// EnablePopupOpenerBridge implements port.PopupOpenerCapable.
104104 func (wv *WebView) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
105105 if wv == nil {
106106 return
@@ -137,116 +137,7 @@ func (wv *WebView) popupOpenerBridgeScript(bridgeNonce string) string {
137137 return ""
138138 }
139139
140- return fmt.Sprintf(`(function() {
141- if (typeof window === 'undefined') return;
142- if (window.__dumberPopupOpenerBridgeInstalled) return;
143- window.__dumberPopupOpenerBridgeInstalled = true;
144- if (window.opener != null) return;
145-
146- var bridgeNonce = '%s';
147- var openerHref = '%s';
148-
149- function postBridge(path, payload) {
150- try {
151- fetch('dumb:///api/' + path, {
152- method: 'POST',
153- headers: {
154- 'Content-Type': 'application/json',
155- 'X-Dumber-Body': btoa(unescape(encodeURIComponent(JSON.stringify(payload)))),
156- 'X-Dumber-Bridge-Nonce': bridgeNonce
157- }
158- }).catch(function() {});
159- } catch (_) {}
160- }
161-
162- function normalizeURLValue(rawURL) {
163- if (rawURL == null) return '';
164- try {
165- return String(rawURL);
166- } catch (_) {
167- return '';
168- }
169- }
170-
171- function navigateOpener(nextURL) {
172- var rawURL = normalizeURLValue(nextURL);
173- if (rawURL !== '') openerHref = rawURL;
174- postBridge('popup-opener-navigate', { url: rawURL });
175- return openerHref;
176- }
177-
178- function serializeMessage(value) {
179- try {
180- return { kind: 'json', value: JSON.stringify(value) };
181- } catch (_) {
182- return { kind: 'string', value: String(value) };
183- }
184- }
185-
186- var locationProxy = {
187- assign: function(nextURL) { return navigateOpener(nextURL); },
188- replace: function(nextURL) { return navigateOpener(nextURL); },
189- toString: function() { return openerHref; }
190- };
191- try {
192- Object.defineProperty(locationProxy, 'href', {
193- configurable: true,
194- enumerable: true,
195- get: function() { return openerHref; },
196- set: function(nextURL) { navigateOpener(nextURL); }
197- });
198- } catch (_) {
199- locationProxy.href = openerHref;
200- }
201-
202- var openerProxy = {
203- blur: function() { return undefined; },
204- close: function() { return undefined; },
205- focus: function() { return undefined; },
206- postMessage: function(message, targetOrigin) {
207- var serialized = serializeMessage(message);
208- postBridge('popup-opener-post-message', {
209- data: serialized.value,
210- data_kind: serialized.kind,
211- target_origin: targetOrigin == null ? '*' : String(targetOrigin),
212- source_origin: (typeof location !== 'undefined' && location && location.origin) ? location.origin : '',
213- source_href: (typeof location !== 'undefined' && location && location.href) ? location.href : ''
214- });
215- return undefined;
216- }
217- };
218- openerProxy.self = openerProxy;
219- openerProxy.window = openerProxy;
220- try {
221- Object.defineProperty(openerProxy, 'closed', {
222- configurable: true,
223- enumerable: true,
224- get: function() { return false; }
225- });
226- } catch (_) {
227- openerProxy.closed = false;
228- }
229- try {
230- Object.defineProperty(openerProxy, 'location', {
231- configurable: true,
232- enumerable: true,
233- get: function() { return locationProxy; },
234- set: function(nextURL) { navigateOpener(nextURL); }
235- });
236- } catch (_) {
237- openerProxy.location = locationProxy;
238- }
239-
240- try {
241- Object.defineProperty(window, 'opener', {
242- configurable: true,
243- enumerable: true,
244- get: function() { return openerProxy; }
245- });
246- } catch (_) {
247- try { window.opener = openerProxy; } catch (_) {}
248- }
249-})();`, webutil.EscapeForJSString(bridgeNonce), webutil.EscapeForJSString(parentURI))
140+ return buildPopupOpenerBridgeJS(bridgeNonce, parentURI)
250141 }
251142
252143 func (wv *WebView) handlePopupOpenerNavigate(targetURL string) {
@@ -32,7 +32,7 @@ func (wv *WebView) setPopupNoJavaScriptAccess(noJavaScriptAccess bool) {
32 wv.mu.Unlock()32 wv.mu.Unlock()
33 }33 }
34 34
35-// AddOpenerMessageCallback implements port.PopupOpenerMessageCapable.35+// AddOpenerMessageCallback implements port.PopupOpenerCapable.
36 func (wv *WebView) AddOpenerMessageCallback(fn func()) {36 func (wv *WebView) AddOpenerMessageCallback(fn func()) {
37 if wv == nil || fn == nil {37 if wv == nil || fn == nil {
38 return38 return
@@ -42,7 +42,7 @@ func (wv *WebView) AddOpenerMessageCallback(fn func()) {
42 wv.mu.Unlock()42 wv.mu.Unlock()
43 }43 }
44 44
45-// AddOpenerNavigationCallback implements port.PopupOpenerNavigationCapable.45+// AddOpenerNavigationCallback implements port.PopupOpenerCapable.
46 func (wv *WebView) AddOpenerNavigationCallback(fn func(uri string)) {46 func (wv *WebView) AddOpenerNavigationCallback(fn func(uri string)) {
47 if wv == nil || fn == nil {47 if wv == nil || fn == nil {
48 return48 return
@@ -90,7 +90,7 @@ func (wv *WebView) runOpenerNavigationCallbacks(uri string) {
90 })90 })
91 }91 }
92 92
93-// HasActivePopupOpenerBridge implements port.PopupOpenerBridgeStateCapable.93+// HasActivePopupOpenerBridge implements port.PopupOpenerCapable.
94 func (wv *WebView) HasActivePopupOpenerBridge() bool {94 func (wv *WebView) HasActivePopupOpenerBridge() bool {
95 if wv == nil {95 if wv == nil {
96 return false96 return false
@@ -100,7 +100,7 @@ func (wv *WebView) HasActivePopupOpenerBridge() bool {
100 return !wv.popupNoJavaScriptAccess && wv.popupOpenerBridgeParent != nil100 return !wv.popupNoJavaScriptAccess && wv.popupOpenerBridgeParent != nil
101 }101 }
102 102
103-// EnablePopupOpenerBridge implements port.PopupOpenerBridgeCapable.103+// EnablePopupOpenerBridge implements port.PopupOpenerCapable.
104 func (wv *WebView) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {104 func (wv *WebView) EnablePopupOpenerBridge(parent port.WebView, noJavaScriptAccess bool) {
105 if wv == nil {105 if wv == nil {
106 return106 return
@@ -137,116 +137,7 @@ func (wv *WebView) popupOpenerBridgeScript(bridgeNonce string) string {
137 return ""137 return ""
138 }138 }
139 139
140- return fmt.Sprintf(`(function() {140+ return buildPopupOpenerBridgeJS(bridgeNonce, parentURI)
141- if (typeof window === 'undefined') return;
142- if (window.__dumberPopupOpenerBridgeInstalled) return;
143- window.__dumberPopupOpenerBridgeInstalled = true;
144- if (window.opener != null) return;
145-
146- var bridgeNonce = '%s';
147- var openerHref = '%s';
148-
149- function postBridge(path, payload) {
150- try {
151- fetch('dumb:///api/' + path, {
152- method: 'POST',
153- headers: {
154- 'Content-Type': 'application/json',
155- 'X-Dumber-Body': btoa(unescape(encodeURIComponent(JSON.stringify(payload)))),
156- 'X-Dumber-Bridge-Nonce': bridgeNonce
157- }
158- }).catch(function() {});
159- } catch (_) {}
160- }
161-
162- function normalizeURLValue(rawURL) {
163- if (rawURL == null) return '';
164- try {
165- return String(rawURL);
166- } catch (_) {
167- return '';
168- }
169- }
170-
171- function navigateOpener(nextURL) {
172- var rawURL = normalizeURLValue(nextURL);
173- if (rawURL !== '') openerHref = rawURL;
174- postBridge('popup-opener-navigate', { url: rawURL });
175- return openerHref;
176- }
177-
178- function serializeMessage(value) {
179- try {
180- return { kind: 'json', value: JSON.stringify(value) };
181- } catch (_) {
182- return { kind: 'string', value: String(value) };
183- }
184- }
185-
186- var locationProxy = {
187- assign: function(nextURL) { return navigateOpener(nextURL); },
188- replace: function(nextURL) { return navigateOpener(nextURL); },
189- toString: function() { return openerHref; }
190- };
191- try {
192- Object.defineProperty(locationProxy, 'href', {
193- configurable: true,
194- enumerable: true,
195- get: function() { return openerHref; },
196- set: function(nextURL) { navigateOpener(nextURL); }
197- });
198- } catch (_) {
199- locationProxy.href = openerHref;
200- }
201-
202- var openerProxy = {
203- blur: function() { return undefined; },
204- close: function() { return undefined; },
205- focus: function() { return undefined; },
206- postMessage: function(message, targetOrigin) {
207- var serialized = serializeMessage(message);
208- postBridge('popup-opener-post-message', {
209- data: serialized.value,
210- data_kind: serialized.kind,
211- target_origin: targetOrigin == null ? '*' : String(targetOrigin),
212- source_origin: (typeof location !== 'undefined' && location && location.origin) ? location.origin : '',
213- source_href: (typeof location !== 'undefined' && location && location.href) ? location.href : ''
214- });
215- return undefined;
216- }
217- };
218- openerProxy.self = openerProxy;
219- openerProxy.window = openerProxy;
220- try {
221- Object.defineProperty(openerProxy, 'closed', {
222- configurable: true,
223- enumerable: true,
224- get: function() { return false; }
225- });
226- } catch (_) {
227- openerProxy.closed = false;
228- }
229- try {
230- Object.defineProperty(openerProxy, 'location', {
231- configurable: true,
232- enumerable: true,
233- get: function() { return locationProxy; },
234- set: function(nextURL) { navigateOpener(nextURL); }
235- });
236- } catch (_) {
237- openerProxy.location = locationProxy;
238- }
239-
240- try {
241- Object.defineProperty(window, 'opener', {
242- configurable: true,
243- enumerable: true,
244- get: function() { return openerProxy; }
245- });
246- } catch (_) {
247- try { window.opener = openerProxy; } catch (_) {}
248- }
249-})();`, webutil.EscapeForJSString(bridgeNonce), webutil.EscapeForJSString(parentURI))
250 }141 }
251 142
252 func (wv *WebView) handlePopupOpenerNavigate(targetURL string) {143 func (wv *WebView) handlePopupOpenerNavigate(targetURL string) {
M internal/infrastructure/cef/renderer_bridge.go +0 -319
@@ -26,325 +26,6 @@ const (
2626
2727 var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate
2828
29-const rendererBridgeExtensionJS = `
30-(function() {
31- native function Dispatch(action, payload);
32-
33- function send(action, payload) {
34- return Dispatch(action, payload == null ? '' : String(payload));
35- }
36-
37- function isEditable(node) {
38- if (!node || node.nodeType !== 1) return false;
39- if (node.isContentEditable) return true;
40- var tag = node.tagName;
41- if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;
42- if (node.disabled || node.readOnly) return false;
43- return true;
44- }
45-
46- function getActiveElementSelection() {
47- var el = document.activeElement;
48- if (!el) return '';
49- var tag = el.tagName;
50- if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {
51- var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
52- var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
53- if (end > start) return el.value.slice(start, end);
54- }
55- return '';
56- }
57-
58- function getSelectedText() {
59- var activeSelection = getActiveElementSelection();
60- if (activeSelection) return activeSelection;
61- var sel = window.getSelection();
62- return sel ? sel.toString() : '';
63- }
64-
65- function notifyExplicitTextCopy(action, text) {
66- var normalizedText = text == null ? '' : String(text);
67- var normalizedAction = action == null || action === '' ? 'copy' : String(action);
68- return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
69- }
70-
71- function resolvePopupURL(rawURL) {
72- if (rawURL == null || rawURL === '') return '';
73- try {
74- return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
75- } catch (_) {
76- return String(rawURL);
77- }
78- }
79-
80- function hasUserGesture() {
81- try {
82- return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
83- } catch (_) {
84- return false;
85- }
86- }
87-
88- function popupHasNoOpener(features) {
89- if (features == null || features === '') return false;
90- var normalized = String(features).toLowerCase();
91- return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
92- }
93-
94- function shouldDelegateWindowOpen(target) {
95- return target === '_self' || target === '_top' || target === '_parent';
96- }
97-
98- function createSyntheticPopupProxy(proxyID, initialURL, features) {
99- var href = initialURL || 'about:blank';
100- var closed = false;
101- var noJavaScriptAccess = popupHasNoOpener(features);
102-
103- function navigate(nextURL) {
104- href = resolvePopupURL(nextURL);
105- send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }));
106- return href;
107- }
108-
109- var locationProxy = {
110- assign: function(nextURL) { return navigate(nextURL); },
111- replace: function(nextURL) { return navigate(nextURL); },
112- toString: function() { return href; }
113- };
114- try {
115- Object.defineProperty(locationProxy, 'href', {
116- configurable: true,
117- enumerable: true,
118- get: function() { return href; },
119- set: function(nextURL) { navigate(nextURL); }
120- });
121- } catch (_) {
122- locationProxy.href = href;
123- }
124-
125- var proxy = {
126- blur: function() { return undefined; },
127- close: function() {
128- if (closed) return;
129- closed = true;
130- send('popup_close', JSON.stringify({ proxy_id: proxyID }));
131- },
132- focus: function() { return undefined; },
133- postMessage: function() { return undefined; }
134- };
135- proxy.opener = popupHasNoOpener(features) ? null : window;
136- proxy.self = proxy;
137- proxy.window = proxy;
138-
139- try {
140- Object.defineProperty(proxy, 'closed', {
141- configurable: true,
142- enumerable: true,
143- get: function() { return closed; },
144- set: function(next) { closed = !!next; }
145- });
146- } catch (_) {
147- proxy.closed = false;
148- }
149-
150- try {
151- Object.defineProperty(proxy, 'location', {
152- configurable: true,
153- enumerable: true,
154- get: function() { return locationProxy; },
155- set: function(nextURL) { navigate(nextURL); }
156- });
157- } catch (_) {
158- proxy.location = locationProxy;
159- }
160-
161- try {
162- Object.defineProperty(proxy, Symbol.toStringTag, {
163- configurable: true,
164- value: 'Window'
165- });
166- } catch (_) {}
167-
168- return proxy;
169- }
170-
171- if (typeof window !== 'undefined' && typeof window.open === 'function' && !window.__dumberPopupOpenPatched) {
172- window.__dumberPopupOpenPatched = true;
173- var originalWindowOpen = window.open;
174- window.open = function(url, target, features) {
175- var normalizedTarget = target == null ? '' : String(target);
176- if (shouldDelegateWindowOpen(normalizedTarget)) {
177- return originalWindowOpen.apply(this, arguments);
178- }
179-
180- var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
181- var resolvedURL = resolvePopupURL(url);
182- var noJavaScriptAccess = popupHasNoOpener(features);
183- var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
184- send('popup_open', JSON.stringify({
185- proxy_id: proxyID,
186- url: resolvedURL,
187- frame_name: normalizedTarget,
188- user_gesture: hasUserGesture(),
189- no_javascript_access: noJavaScriptAccess
190- }));
191- return popupProxy;
192- };
193- }
194-
195- if (typeof document !== 'undefined' && document.addEventListener) {
196- document.addEventListener('focusin', function(event) {
197- if (isEditable(event.target)) {
198- send('focus_sync', '');
199- }
200- }, true);
201-
202- function mirrorClipboardEvent(action, e) {
203- if (!e.isTrusted) return;
204- var capturedText = '';
205- if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
206- try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {}
207- }
208- setTimeout(function() {
209- var text = capturedText;
210- if (!text && !e.defaultPrevented) text = getSelectedText();
211- if (text) notifyExplicitTextCopy(action, text);
212- }, 0);
213- }
214-
215- document.addEventListener('copy', function(e) {
216- mirrorClipboardEvent('copy', e);
217- }, true);
218- document.addEventListener('cut', function(e) {
219- mirrorClipboardEvent('cut', e);
220- }, true);
221- }
222- try {
223- var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
224- var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
225- (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
226-
227- function mirrorClipboardItems(items) {
228- try {
229- if (!items || typeof items.length !== 'number') return;
230- Array.prototype.forEach.call(items, function(item) {
231- if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
232- Promise.resolve(item.getType('text/plain'))
233- .then(function(blob) {
234- if (!blob || typeof blob.text !== 'function') return '';
235- return blob.text();
236- })
237- .then(function(text) {
238- if (text) notifyExplicitTextCopy('copy', text);
239- })
240- .catch(function() {});
241- });
242- } catch (_) {}
243- }
244-
245- if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
246- clipboardProto.__dumberWriteTextPatched = true;
247- var originalProtoWriteText = clipboardProto.writeText;
248- var wrappedProtoWriteText = function(text) {
249- var normalized = text == null ? '' : String(text);
250- var result = originalProtoWriteText.call(this, normalized);
251- if (result && typeof result.then === 'function') {
252- return result.then(function(value) {
253- if (normalized) notifyExplicitTextCopy('copy', normalized);
254- return value;
255- });
256- }
257- if (normalized) notifyExplicitTextCopy('copy', normalized);
258- return result;
259- };
260- try {
261- Object.defineProperty(clipboardProto, 'writeText', {
262- configurable: true,
263- writable: true,
264- value: wrappedProtoWriteText
265- });
266- } catch (_) {
267- clipboardProto.writeText = wrappedProtoWriteText;
268- }
269- } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
270- window.__dumberClipboardWriteTextPatched = true;
271- var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
272- var wrappedWriteText = function(text) {
273- var normalized = text == null ? '' : String(text);
274- var result = originalWriteText(normalized);
275- if (result && typeof result.then === 'function') {
276- return result.then(function(value) {
277- if (normalized) notifyExplicitTextCopy('copy', normalized);
278- return value;
279- });
280- }
281- if (normalized) notifyExplicitTextCopy('copy', normalized);
282- return result;
283- };
284- try {
285- Object.defineProperty(clipboardObj, 'writeText', {
286- configurable: true,
287- writable: true,
288- value: wrappedWriteText
289- });
290- } catch (_) {
291- clipboardObj.writeText = wrappedWriteText;
292- }
293- }
294-
295- if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
296- clipboardProto.__dumberWritePatched = true;
297- var originalProtoWrite = clipboardProto.write;
298- var wrappedProtoWrite = function(items) {
299- var result = originalProtoWrite.apply(this, arguments);
300- if (result && typeof result.then === 'function') {
301- return result.then(function(value) {
302- mirrorClipboardItems(items);
303- return value;
304- });
305- }
306- mirrorClipboardItems(items);
307- return result;
308- };
309- try {
310- Object.defineProperty(clipboardProto, 'write', {
311- configurable: true,
312- writable: true,
313- value: wrappedProtoWrite
314- });
315- } catch (_) {
316- clipboardProto.write = wrappedProtoWrite;
317- }
318- } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
319- window.__dumberClipboardWritePatched = true;
320- var originalWrite = clipboardObj.write.bind(clipboardObj);
321- var wrappedWrite = function(items) {
322- var result = originalWrite(items);
323- if (result && typeof result.then === 'function') {
324- return result.then(function(value) {
325- mirrorClipboardItems(items);
326- return value;
327- });
328- }
329- mirrorClipboardItems(items);
330- return result;
331- };
332- try {
333- Object.defineProperty(clipboardObj, 'write', {
334- configurable: true,
335- writable: true,
336- value: wrappedWrite
337- });
338- } catch (_) {
339- clipboardObj.write = wrappedWrite;
340- }
341- }
342- } catch (_) {}
343-
344- send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : '');
345-})();
346-`
347-
34829 type rendererBridgeProcessHandler struct {
34930 registerOnce sync.Once
35031 bridgeHandler purecef.V8Handler
@@ -26,325 +26,6 @@ const (
26 26
27 var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate27 var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate
28 28
29-const rendererBridgeExtensionJS = `
30-(function() {
31- native function Dispatch(action, payload);
32-
33- function send(action, payload) {
34- return Dispatch(action, payload == null ? '' : String(payload));
35- }
36-
37- function isEditable(node) {
38- if (!node || node.nodeType !== 1) return false;
39- if (node.isContentEditable) return true;
40- var tag = node.tagName;
41- if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;
42- if (node.disabled || node.readOnly) return false;
43- return true;
44- }
45-
46- function getActiveElementSelection() {
47- var el = document.activeElement;
48- if (!el) return '';
49- var tag = el.tagName;
50- if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {
51- var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
52- var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
53- if (end > start) return el.value.slice(start, end);
54- }
55- return '';
56- }
57-
58- function getSelectedText() {
59- var activeSelection = getActiveElementSelection();
60- if (activeSelection) return activeSelection;
61- var sel = window.getSelection();
62- return sel ? sel.toString() : '';
63- }
64-
65- function notifyExplicitTextCopy(action, text) {
66- var normalizedText = text == null ? '' : String(text);
67- var normalizedAction = action == null || action === '' ? 'copy' : String(action);
68- return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
69- }
70-
71- function resolvePopupURL(rawURL) {
72- if (rawURL == null || rawURL === '') return '';
73- try {
74- return new URL(String(rawURL), (document && document.baseURI) || location.href).href;
75- } catch (_) {
76- return String(rawURL);
77- }
78- }
79-
80- function hasUserGesture() {
81- try {
82- return !!(navigator && navigator.userActivation && navigator.userActivation.isActive);
83- } catch (_) {
84- return false;
85- }
86- }
87-
88- function popupHasNoOpener(features) {
89- if (features == null || features === '') return false;
90- var normalized = String(features).toLowerCase();
91- return normalized.indexOf('noopener') !== -1 || normalized.indexOf('noreferrer') !== -1;
92- }
93-
94- function shouldDelegateWindowOpen(target) {
95- return target === '_self' || target === '_top' || target === '_parent';
96- }
97-
98- function createSyntheticPopupProxy(proxyID, initialURL, features) {
99- var href = initialURL || 'about:blank';
100- var closed = false;
101- var noJavaScriptAccess = popupHasNoOpener(features);
102-
103- function navigate(nextURL) {
104- href = resolvePopupURL(nextURL);
105- send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }));
106- return href;
107- }
108-
109- var locationProxy = {
110- assign: function(nextURL) { return navigate(nextURL); },
111- replace: function(nextURL) { return navigate(nextURL); },
112- toString: function() { return href; }
113- };
114- try {
115- Object.defineProperty(locationProxy, 'href', {
116- configurable: true,
117- enumerable: true,
118- get: function() { return href; },
119- set: function(nextURL) { navigate(nextURL); }
120- });
121- } catch (_) {
122- locationProxy.href = href;
123- }
124-
125- var proxy = {
126- blur: function() { return undefined; },
127- close: function() {
128- if (closed) return;
129- closed = true;
130- send('popup_close', JSON.stringify({ proxy_id: proxyID }));
131- },
132- focus: function() { return undefined; },
133- postMessage: function() { return undefined; }
134- };
135- proxy.opener = popupHasNoOpener(features) ? null : window;
136- proxy.self = proxy;
137- proxy.window = proxy;
138-
139- try {
140- Object.defineProperty(proxy, 'closed', {
141- configurable: true,
142- enumerable: true,
143- get: function() { return closed; },
144- set: function(next) { closed = !!next; }
145- });
146- } catch (_) {
147- proxy.closed = false;
148- }
149-
150- try {
151- Object.defineProperty(proxy, 'location', {
152- configurable: true,
153- enumerable: true,
154- get: function() { return locationProxy; },
155- set: function(nextURL) { navigate(nextURL); }
156- });
157- } catch (_) {
158- proxy.location = locationProxy;
159- }
160-
161- try {
162- Object.defineProperty(proxy, Symbol.toStringTag, {
163- configurable: true,
164- value: 'Window'
165- });
166- } catch (_) {}
167-
168- return proxy;
169- }
170-
171- if (typeof window !== 'undefined' && typeof window.open === 'function' && !window.__dumberPopupOpenPatched) {
172- window.__dumberPopupOpenPatched = true;
173- var originalWindowOpen = window.open;
174- window.open = function(url, target, features) {
175- var normalizedTarget = target == null ? '' : String(target);
176- if (shouldDelegateWindowOpen(normalizedTarget)) {
177- return originalWindowOpen.apply(this, arguments);
178- }
179-
180- var proxyID = 'popup-' + Date.now() + '-' + Math.random().toString(36).slice(2);
181- var resolvedURL = resolvePopupURL(url);
182- var noJavaScriptAccess = popupHasNoOpener(features);
183- var popupProxy = createSyntheticPopupProxy(proxyID, resolvedURL, features);
184- send('popup_open', JSON.stringify({
185- proxy_id: proxyID,
186- url: resolvedURL,
187- frame_name: normalizedTarget,
188- user_gesture: hasUserGesture(),
189- no_javascript_access: noJavaScriptAccess
190- }));
191- return popupProxy;
192- };
193- }
194-
195- if (typeof document !== 'undefined' && document.addEventListener) {
196- document.addEventListener('focusin', function(event) {
197- if (isEditable(event.target)) {
198- send('focus_sync', '');
199- }
200- }, true);
201-
202- function mirrorClipboardEvent(action, e) {
203- if (!e.isTrusted) return;
204- var capturedText = '';
205- if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
206- try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {}
207- }
208- setTimeout(function() {
209- var text = capturedText;
210- if (!text && !e.defaultPrevented) text = getSelectedText();
211- if (text) notifyExplicitTextCopy(action, text);
212- }, 0);
213- }
214-
215- document.addEventListener('copy', function(e) {
216- mirrorClipboardEvent('copy', e);
217- }, true);
218- document.addEventListener('cut', function(e) {
219- mirrorClipboardEvent('cut', e);
220- }, true);
221- }
222- try {
223- var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
224- var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
225- (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
226-
227- function mirrorClipboardItems(items) {
228- try {
229- if (!items || typeof items.length !== 'number') return;
230- Array.prototype.forEach.call(items, function(item) {
231- if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
232- Promise.resolve(item.getType('text/plain'))
233- .then(function(blob) {
234- if (!blob || typeof blob.text !== 'function') return '';
235- return blob.text();
236- })
237- .then(function(text) {
238- if (text) notifyExplicitTextCopy('copy', text);
239- })
240- .catch(function() {});
241- });
242- } catch (_) {}
243- }
244-
245- if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
246- clipboardProto.__dumberWriteTextPatched = true;
247- var originalProtoWriteText = clipboardProto.writeText;
248- var wrappedProtoWriteText = function(text) {
249- var normalized = text == null ? '' : String(text);
250- var result = originalProtoWriteText.call(this, normalized);
251- if (result && typeof result.then === 'function') {
252- return result.then(function(value) {
253- if (normalized) notifyExplicitTextCopy('copy', normalized);
254- return value;
255- });
256- }
257- if (normalized) notifyExplicitTextCopy('copy', normalized);
258- return result;
259- };
260- try {
261- Object.defineProperty(clipboardProto, 'writeText', {
262- configurable: true,
263- writable: true,
264- value: wrappedProtoWriteText
265- });
266- } catch (_) {
267- clipboardProto.writeText = wrappedProtoWriteText;
268- }
269- } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
270- window.__dumberClipboardWriteTextPatched = true;
271- var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
272- var wrappedWriteText = function(text) {
273- var normalized = text == null ? '' : String(text);
274- var result = originalWriteText(normalized);
275- if (result && typeof result.then === 'function') {
276- return result.then(function(value) {
277- if (normalized) notifyExplicitTextCopy('copy', normalized);
278- return value;
279- });
280- }
281- if (normalized) notifyExplicitTextCopy('copy', normalized);
282- return result;
283- };
284- try {
285- Object.defineProperty(clipboardObj, 'writeText', {
286- configurable: true,
287- writable: true,
288- value: wrappedWriteText
289- });
290- } catch (_) {
291- clipboardObj.writeText = wrappedWriteText;
292- }
293- }
294-
295- if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
296- clipboardProto.__dumberWritePatched = true;
297- var originalProtoWrite = clipboardProto.write;
298- var wrappedProtoWrite = function(items) {
299- var result = originalProtoWrite.apply(this, arguments);
300- if (result && typeof result.then === 'function') {
301- return result.then(function(value) {
302- mirrorClipboardItems(items);
303- return value;
304- });
305- }
306- mirrorClipboardItems(items);
307- return result;
308- };
309- try {
310- Object.defineProperty(clipboardProto, 'write', {
311- configurable: true,
312- writable: true,
313- value: wrappedProtoWrite
314- });
315- } catch (_) {
316- clipboardProto.write = wrappedProtoWrite;
317- }
318- } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
319- window.__dumberClipboardWritePatched = true;
320- var originalWrite = clipboardObj.write.bind(clipboardObj);
321- var wrappedWrite = function(items) {
322- var result = originalWrite(items);
323- if (result && typeof result.then === 'function') {
324- return result.then(function(value) {
325- mirrorClipboardItems(items);
326- return value;
327- });
328- }
329- mirrorClipboardItems(items);
330- return result;
331- };
332- try {
333- Object.defineProperty(clipboardObj, 'write', {
334- configurable: true,
335- writable: true,
336- value: wrappedWrite
337- });
338- } catch (_) {
339- clipboardObj.write = wrappedWrite;
340- }
341- }
342- } catch (_) {}
343-
344- send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : '');
345-})();
346-`
347-
348 type rendererBridgeProcessHandler struct {29 type rendererBridgeProcessHandler struct {
349 registerOnce sync.Once30 registerOnce sync.Once
350 bridgeHandler purecef.V8Handler31 bridgeHandler purecef.V8Handler
M internal/infrastructure/cef/renderer_bridge_test.go +4 -3
@@ -99,9 +99,10 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
9999
100100 func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) {
101101 require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102- require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify({")
103- require.Contains(t, rendererBridgeExtensionJS, "send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }))")
104- require.Contains(t, rendererBridgeExtensionJS, "send('popup_close', JSON.stringify({ proxy_id: proxyID }))")
102+ require.Contains(t, rendererBridgeExtensionJS, "function dispatchPopupOpen(payload)")
103+ require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify(payload));")
104+ require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupNavigate(proxyID, href)")
105+ require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupClose(proxyID)")
105106 require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access")
106107 require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
107108 require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
@@ -99,9 +99,10 @@ func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing
99 99
100 func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) {100 func TestRendererBridgeExtensionJS_ShimsWindowOpenForSyntheticPopups(t *testing.T) {
101 require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")101 require.Contains(t, rendererBridgeExtensionJS, "window.__dumberPopupOpenPatched")
102- require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify({")102+ require.Contains(t, rendererBridgeExtensionJS, "function dispatchPopupOpen(payload)")
103- require.Contains(t, rendererBridgeExtensionJS, "send('popup_navigate', JSON.stringify({ proxy_id: proxyID, url: href }))")103+ require.Contains(t, rendererBridgeExtensionJS, "send('popup_open', JSON.stringify(payload));")
104- require.Contains(t, rendererBridgeExtensionJS, "send('popup_close', JSON.stringify({ proxy_id: proxyID }))")104+ require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupNavigate(proxyID, href)")
105+ require.Contains(t, rendererBridgeExtensionJS, "dispatchPopupClose(proxyID)")
105 require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access")106 require.Contains(t, rendererBridgeExtensionJS, "no_javascript_access")
106 require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")107 require.Contains(t, rendererBridgeExtensionJS, "Object.defineProperty(proxy, 'closed'")
107 require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")108 require.Contains(t, rendererBridgeExtensionJS, "return popupProxy;")
M internal/infrastructure/cef/webview.go +10 -14
@@ -23,16 +23,12 @@ import (
2323
2424 // Compile-time interface checks.
2525 var (
26- _ port.WebView = (*WebView)(nil)
27- _ port.NativeWidgetProvider = (*WebView)(nil)
28- _ port.DevToolsOpener = (*WebView)(nil)
29- _ port.PopupCapable = (*WebView)(nil)
30- _ port.PopupNavigationCapable = (*WebView)(nil)
31- _ port.PopupOpenerBridgeCapable = (*WebView)(nil)
32- _ port.PopupOpenerMessageCapable = (*WebView)(nil)
33- _ port.PopupOpenerNavigationCapable = (*WebView)(nil)
34- _ port.PopupOpenerBridgeStateCapable = (*WebView)(nil)
35- _ port.OAuthCallbackCapable = (*WebView)(nil)
26+ _ port.WebView = (*WebView)(nil)
27+ _ port.NativeWidgetProvider = (*WebView)(nil)
28+ _ port.DevToolsOpener = (*WebView)(nil)
29+ _ port.PopupLifecycleCapable = (*WebView)(nil)
30+ _ port.PopupOpenerCapable = (*WebView)(nil)
31+ _ port.OAuthCallbackCapable = (*WebView)(nil)
3632 )
3733
3834 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -507,7 +503,7 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
507503 wv.callbacks = cb
508504 }
509505
510-// SetOnReadyToShow implements port.PopupCapable.
506+// SetOnReadyToShow implements port.PopupLifecycleCapable.
511507 func (wv *WebView) SetOnReadyToShow(fn func()) {
512508 if wv == nil {
513509 return
@@ -521,12 +517,12 @@ func (wv *WebView) SetOnReadyToShow(fn func()) {
521517 }
522518 }
523519
524-// SetOnClose implements port.PopupCapable.
520+// SetOnClose implements port.PopupLifecycleCapable.
525521 func (wv *WebView) SetOnClose(fn func()) {
526522 wv.AddCloseCallback(fn)
527523 }
528524
529-// Show implements port.PopupCapable.
525+// Show implements port.PopupLifecycleCapable.
530526 func (wv *WebView) Show() {
531527 if wv == nil || wv.destroyed.Load() {
532528 return
@@ -539,7 +535,7 @@ func (wv *WebView) Show() {
539535 }
540536 }
541537
542-// PrimePopupNavigation implements port.PopupNavigationCapable.
538+// PrimePopupNavigation implements port.PopupLifecycleCapable.
543539 func (wv *WebView) PrimePopupNavigation(uri string) {
544540 if wv == nil || wv.destroyed.Load() {
545541 return
@@ -23,16 +23,12 @@ import (
23 23
24 // Compile-time interface checks.24 // Compile-time interface checks.
25 var (25 var (
26- _ port.WebView = (*WebView)(nil)26+ _ port.WebView = (*WebView)(nil)
27- _ port.NativeWidgetProvider = (*WebView)(nil)27+ _ port.NativeWidgetProvider = (*WebView)(nil)
28- _ port.DevToolsOpener = (*WebView)(nil)28+ _ port.DevToolsOpener = (*WebView)(nil)
29- _ port.PopupCapable = (*WebView)(nil)29+ _ port.PopupLifecycleCapable = (*WebView)(nil)
30- _ port.PopupNavigationCapable = (*WebView)(nil)30+ _ port.PopupOpenerCapable = (*WebView)(nil)
31- _ port.PopupOpenerBridgeCapable = (*WebView)(nil)31+ _ port.OAuthCallbackCapable = (*WebView)(nil)
32- _ port.PopupOpenerMessageCapable = (*WebView)(nil)
33- _ port.PopupOpenerNavigationCapable = (*WebView)(nil)
34- _ port.PopupOpenerBridgeStateCapable = (*WebView)(nil)
35- _ port.OAuthCallbackCapable = (*WebView)(nil)
36 )32 )
37 33
38 // errDestroyed is returned when an operation is attempted on a destroyed WebView.34 // errDestroyed is returned when an operation is attempted on a destroyed WebView.
@@ -507,7 +503,7 @@ func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
507 wv.callbacks = cb503 wv.callbacks = cb
508 }504 }
509 505
510-// SetOnReadyToShow implements port.PopupCapable.506+// SetOnReadyToShow implements port.PopupLifecycleCapable.
511 func (wv *WebView) SetOnReadyToShow(fn func()) {507 func (wv *WebView) SetOnReadyToShow(fn func()) {
512 if wv == nil {508 if wv == nil {
513 return509 return
@@ -521,12 +517,12 @@ func (wv *WebView) SetOnReadyToShow(fn func()) {
521 }517 }
522 }518 }
523 519
524-// SetOnClose implements port.PopupCapable.520+// SetOnClose implements port.PopupLifecycleCapable.
525 func (wv *WebView) SetOnClose(fn func()) {521 func (wv *WebView) SetOnClose(fn func()) {
526 wv.AddCloseCallback(fn)522 wv.AddCloseCallback(fn)
527 }523 }
528 524
529-// Show implements port.PopupCapable.525+// Show implements port.PopupLifecycleCapable.
530 func (wv *WebView) Show() {526 func (wv *WebView) Show() {
531 if wv == nil || wv.destroyed.Load() {527 if wv == nil || wv.destroyed.Load() {
532 return528 return
@@ -539,7 +535,7 @@ func (wv *WebView) Show() {
539 }535 }
540 }536 }
541 537
542-// PrimePopupNavigation implements port.PopupNavigationCapable.538+// PrimePopupNavigation implements port.PopupLifecycleCapable.
543 func (wv *WebView) PrimePopupNavigation(uri string) {539 func (wv *WebView) PrimePopupNavigation(uri string) {
544 if wv == nil || wv.destroyed.Load() {540 if wv == nil || wv.destroyed.Load() {
545 return541 return
M internal/infrastructure/webkit/webview.go +7 -2
@@ -30,6 +30,7 @@ import (
3030 var _ port.WebView = (*WebView)(nil)
3131 var _ port.DevToolsOpener = (*WebView)(nil)
3232 var _ port.Printer = (*WebView)(nil)
33+var _ port.PopupLifecycleCapable = (*WebView)(nil)
3334 var _ port.OAuthCallbackCapable = (*WebView)(nil)
3435
3536 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
@@ -1584,15 +1585,19 @@ func (wv *WebView) Show() {
15841585 wv.inner.SetVisible(true)
15851586 }
15861587
1588+// PrimePopupNavigation is a no-op for WebKit, which already has a native popup
1589+// browser at the time the coordinator receives popup lifecycle callbacks.
1590+func (*WebView) PrimePopupNavigation(string) {}
1591+
15871592 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1588-// It implements port.PopupCapable.
1593+// It implements port.PopupLifecycleCapable.
15891594 func (wv *WebView) SetOnReadyToShow(fn func()) {
15901595 wv.OnReadyToShow = fn
15911596 }
15921597
15931598 // SetOnClose composes fn with any existing OnClose handler so multiple callers
15941599 // can each register a close callback without overwriting one another.
1595-// It implements port.PopupCapable.
1600+// It implements port.PopupLifecycleCapable.
15961601 func (wv *WebView) SetOnClose(fn func()) {
15971602 existing := wv.OnClose
15981603 if existing == nil {
@@ -30,6 +30,7 @@ import (
30 var _ port.WebView = (*WebView)(nil)30 var _ port.WebView = (*WebView)(nil)
31 var _ port.DevToolsOpener = (*WebView)(nil)31 var _ port.DevToolsOpener = (*WebView)(nil)
32 var _ port.Printer = (*WebView)(nil)32 var _ port.Printer = (*WebView)(nil)
33+var _ port.PopupLifecycleCapable = (*WebView)(nil)
33 var _ port.OAuthCallbackCapable = (*WebView)(nil)34 var _ port.OAuthCallbackCapable = (*WebView)(nil)
34 35
35 // WebViewID is an alias to port.WebViewID for clean architecture compliance.36 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
@@ -1584,15 +1585,19 @@ func (wv *WebView) Show() {
1584 wv.inner.SetVisible(true)1585 wv.inner.SetVisible(true)
1585 }1586 }
1586 1587
1588+// PrimePopupNavigation is a no-op for WebKit, which already has a native popup
1589+// browser at the time the coordinator receives popup lifecycle callbacks.
1590+func (*WebView) PrimePopupNavigation(string) {}
1591+
1587 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.1592 // SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1588-// It implements port.PopupCapable.1593+// It implements port.PopupLifecycleCapable.
1589 func (wv *WebView) SetOnReadyToShow(fn func()) {1594 func (wv *WebView) SetOnReadyToShow(fn func()) {
1590 wv.OnReadyToShow = fn1595 wv.OnReadyToShow = fn
1591 }1596 }
1592 1597
1593 // SetOnClose composes fn with any existing OnClose handler so multiple callers1598 // SetOnClose composes fn with any existing OnClose handler so multiple callers
1594 // can each register a close callback without overwriting one another.1599 // can each register a close callback without overwriting one another.
1595-// It implements port.PopupCapable.1600+// It implements port.PopupLifecycleCapable.
1596 func (wv *WebView) SetOnClose(fn func()) {1601 func (wv *WebView) SetOnClose(fn func()) {
1597 existing := wv.OnClose1602 existing := wv.OnClose
1598 if existing == nil {1603 if existing == nil {
M internal/ui/coordinator/content/coordinator.go +15 -33
@@ -3,7 +3,6 @@ package content
33 import (
44 "context"
55 "sync"
6- "time"
76
87 "github.com/bnema/dumber/internal/application/port"
98 "github.com/bnema/dumber/internal/application/usecase"
@@ -82,25 +81,9 @@ type Coordinator struct {
8281 // Gesture action handler for mouse button navigation
8382 gestureActionHandler input.ActionHandler
8483
85- // Popup handling
86- factory port.WebViewFactory
87- popupConfig *entity.PopupBehaviorConfig
88- pendingPopups map[port.WebViewID]*PendingPopup
89- namedPopups map[namedPopupKey]*namedPopupState
90- popupOAuth map[port.WebViewID]*popupOAuthState
91- popupRefresh map[entity.PaneID]*time.Timer
92- relatedPopupUnsupported bool
93- relatedPopupSupportDetected bool
94- popupMu sync.RWMutex
95-
96- // Callback to insert popup into workspace (avoids circular dependency)
97- onInsertPopup func(ctx context.Context, input InsertPopupInput) error
98-
99- // Callback to close a pane when popup closes
100- onClosePane func(ctx context.Context, paneID entity.PaneID) error
101-
102- // ID generator for popup panes
103- generateID func() string
84+ // Popup handling stays in a dedicated UI-layer manager so popup-specific
85+ // pane state does not bloat the main coordinator.
86+ popups *popupManager
10487
10588 // Idle inhibitor for fullscreen video playback
10689 idleInhibitor port.IdleInhibitor
@@ -125,15 +108,6 @@ type pendingThemeUpdate struct {
125108 cssText string
126109 }
127110
128-type popupOAuthState struct {
129- ParentPaneID entity.PaneID
130- ParentURIAtOpen string
131- CallbackURI string
132- Success bool
133- Error bool
134- Seen bool
135-}
136-
137111 // PermissionActivityState represents the visible state for media permission activity.
138112 type PermissionActivityState string
139113
@@ -173,11 +147,19 @@ func NewCoordinator(
173147 pendingScriptRefresh: make(map[entity.PaneID]bool),
174148 pendingThemePanes: make(map[entity.PaneID]bool),
175149 getActiveWS: getActiveWS,
176- pendingPopups: make(map[port.WebViewID]*PendingPopup),
177- namedPopups: make(map[namedPopupKey]*namedPopupState),
178- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
179- popupRefresh: make(map[entity.PaneID]*time.Timer),
150+ popups: newPopupManager(),
151+ }
152+}
153+
154+func (c *Coordinator) ensurePopupManager() *popupManager {
155+ if c == nil {
156+ return nil
157+ }
158+ if c.popups == nil {
159+ c.popups = newPopupManager()
180160 }
161+ c.popups.ensureInitialized()
162+ return c.popups
181163 }
182164
183165 // SetOnTitleUpdated sets the callback for title changes (for history persistence).
@@ -3,7 +3,6 @@ package content
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6- "time"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
@@ -82,25 +81,9 @@ type Coordinator struct {
82 // Gesture action handler for mouse button navigation81 // Gesture action handler for mouse button navigation
83 gestureActionHandler input.ActionHandler82 gestureActionHandler input.ActionHandler
84 83
85- // Popup handling84+ // Popup handling stays in a dedicated UI-layer manager so popup-specific
86- factory port.WebViewFactory85+ // pane state does not bloat the main coordinator.
87- popupConfig *entity.PopupBehaviorConfig86+ popups *popupManager
88- pendingPopups map[port.WebViewID]*PendingPopup
89- namedPopups map[namedPopupKey]*namedPopupState
90- popupOAuth map[port.WebViewID]*popupOAuthState
91- popupRefresh map[entity.PaneID]*time.Timer
92- relatedPopupUnsupported bool
93- relatedPopupSupportDetected bool
94- popupMu sync.RWMutex
95-
96- // Callback to insert popup into workspace (avoids circular dependency)
97- onInsertPopup func(ctx context.Context, input InsertPopupInput) error
98-
99- // Callback to close a pane when popup closes
100- onClosePane func(ctx context.Context, paneID entity.PaneID) error
101-
102- // ID generator for popup panes
103- generateID func() string
104 87
105 // Idle inhibitor for fullscreen video playback88 // Idle inhibitor for fullscreen video playback
106 idleInhibitor port.IdleInhibitor89 idleInhibitor port.IdleInhibitor
@@ -125,15 +108,6 @@ type pendingThemeUpdate struct {
125 cssText string108 cssText string
126 }109 }
127 110
128-type popupOAuthState struct {
129- ParentPaneID entity.PaneID
130- ParentURIAtOpen string
131- CallbackURI string
132- Success bool
133- Error bool
134- Seen bool
135-}
136-
137 // PermissionActivityState represents the visible state for media permission activity.111 // PermissionActivityState represents the visible state for media permission activity.
138 type PermissionActivityState string112 type PermissionActivityState string
139 113
@@ -173,11 +147,19 @@ func NewCoordinator(
173 pendingScriptRefresh: make(map[entity.PaneID]bool),147 pendingScriptRefresh: make(map[entity.PaneID]bool),
174 pendingThemePanes: make(map[entity.PaneID]bool),148 pendingThemePanes: make(map[entity.PaneID]bool),
175 getActiveWS: getActiveWS,149 getActiveWS: getActiveWS,
176- pendingPopups: make(map[port.WebViewID]*PendingPopup),150+ popups: newPopupManager(),
177- namedPopups: make(map[namedPopupKey]*namedPopupState),151+ }
178- popupOAuth: make(map[port.WebViewID]*popupOAuthState),152+}
179- popupRefresh: make(map[entity.PaneID]*time.Timer),153+
154+func (c *Coordinator) ensurePopupManager() *popupManager {
155+ if c == nil {
156+ return nil
157+ }
158+ if c.popups == nil {
159+ c.popups = newPopupManager()
180 }160 }
161+ c.popups.ensureInitialized()
162+ return c.popups
181 }163 }
182 164
183 // SetOnTitleUpdated sets the callback for title changes (for history persistence).165 // SetOnTitleUpdated sets the callback for title changes (for history persistence).
M internal/ui/coordinator/content/oauth.go +29 -31
@@ -231,8 +231,8 @@ func popupUsesSyntheticOpenerSignals(wv port.WebView) bool {
231231 if wv == nil {
232232 return false
233233 }
234- state, ok := wv.(port.PopupOpenerBridgeStateCapable)
235- return ok && state.HasActivePopupOpenerBridge()
234+ opener, ok := wv.(port.PopupOpenerCapable)
235+ return ok && opener.HasActivePopupOpenerBridge()
236236 }
237237
238238 func (c *Coordinator) setupOAuthAutoClose(
@@ -330,14 +330,12 @@ func (c *Coordinator) setupOAuthAutoClose(
330330 }
331331 requestOAuthClose("navigation")
332332 })
333- if openerNavigations, ok := wv.(port.PopupOpenerNavigationCapable); ok {
334- openerNavigations.AddOpenerNavigationCallback(func(uri string) {
333+ if opener, ok := wv.(port.PopupOpenerCapable); ok {
334+ opener.AddOpenerNavigationCallback(func(uri string) {
335335 c.capturePopupOAuthState(popupID, uri)
336336 requestOAuthClose("opener-navigation")
337337 })
338- }
339- if openerMessages, ok := wv.(port.PopupOpenerMessageCapable); ok {
340- openerMessages.AddOpenerMessageCallback(func() {
338+ opener.AddOpenerMessageCallback(func() {
341339 c.capturePopupOAuthMessage(popupID)
342340 requestOAuthClose("opener-message")
343341 })
@@ -350,22 +348,22 @@ func (c *Coordinator) setupOAuthAutoClose(
350348 }
351349
352350 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
353- c.popupMu.Lock()
354- defer c.popupMu.Unlock()
355- if c.popupOAuth == nil {
356- c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
351+ c.popups.mu.Lock()
352+ defer c.popups.mu.Unlock()
353+ if c.popups.popupOAuth == nil {
354+ c.popups.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
357355 }
358- c.popupOAuth[popupID] = &popupOAuthState{
356+ c.popups.popupOAuth[popupID] = &popupOAuthState{
359357 ParentPaneID: parentPaneID,
360358 ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
361359 }
362360 }
363361
364362 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
365- c.popupMu.Lock()
366- defer c.popupMu.Unlock()
363+ c.popups.mu.Lock()
364+ defer c.popups.mu.Unlock()
367365
368- state, ok := c.popupOAuth[popupID]
366+ state, ok := c.popups.popupOAuth[popupID]
369367 if !ok {
370368 return
371369 }
@@ -377,10 +375,10 @@ func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string)
377375 }
378376
379377 func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
380- c.popupMu.Lock()
381- defer c.popupMu.Unlock()
378+ c.popups.mu.Lock()
379+ defer c.popups.mu.Unlock()
382380
383- state, ok := c.popupOAuth[popupID]
381+ state, ok := c.popups.popupOAuth[popupID]
384382 if !ok {
385383 return
386384 }
@@ -397,12 +395,12 @@ func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
397395 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
398396 log := logging.FromContext(ctx)
399397
400- c.popupMu.Lock()
401- state, ok := c.popupOAuth[popupID]
398+ c.popups.mu.Lock()
399+ state, ok := c.popups.popupOAuth[popupID]
402400 if ok {
403- delete(c.popupOAuth, popupID)
401+ delete(c.popups.popupOAuth, popupID)
404402 }
405- c.popupMu.Unlock()
403+ c.popups.mu.Unlock()
406404
407405 if !ok || state == nil || !state.Seen {
408406 return
@@ -433,24 +431,24 @@ func (c *Coordinator) scheduleParentPaneOAuthResume(
433431 parentURIAtOpen string,
434432 callbackURI string,
435433 ) {
436- c.popupMu.Lock()
437- if c.popupRefresh == nil {
438- c.popupRefresh = make(map[entity.PaneID]*time.Timer)
434+ c.popups.mu.Lock()
435+ if c.popups.popupRefresh == nil {
436+ c.popups.popupRefresh = make(map[entity.PaneID]*time.Timer)
439437 }
440- if existing := c.popupRefresh[parentPaneID]; existing != nil {
438+ if existing := c.popups.popupRefresh[parentPaneID]; existing != nil {
441439 existing.Stop()
442440 }
443- c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
444- c.popupMu.Lock()
445- delete(c.popupRefresh, parentPaneID)
446- c.popupMu.Unlock()
441+ c.popups.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
442+ c.popups.mu.Lock()
443+ delete(c.popups.popupRefresh, parentPaneID)
444+ c.popups.mu.Unlock()
447445 cb := glib.SourceFunc(func(_ uintptr) bool {
448446 c.resumeParentPaneAfterOAuth(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI)
449447 return false
450448 })
451449 glib.IdleAdd(&cb, 0)
452450 })
453- c.popupMu.Unlock()
451+ c.popups.mu.Unlock()
454452 }
455453
456454 func (c *Coordinator) resumeParentPaneAfterOAuth(
@@ -231,8 +231,8 @@ func popupUsesSyntheticOpenerSignals(wv port.WebView) bool {
231 if wv == nil {231 if wv == nil {
232 return false232 return false
233 }233 }
234- state, ok := wv.(port.PopupOpenerBridgeStateCapable)234+ opener, ok := wv.(port.PopupOpenerCapable)
235- return ok && state.HasActivePopupOpenerBridge()235+ return ok && opener.HasActivePopupOpenerBridge()
236 }236 }
237 237
238 func (c *Coordinator) setupOAuthAutoClose(238 func (c *Coordinator) setupOAuthAutoClose(
@@ -330,14 +330,12 @@ func (c *Coordinator) setupOAuthAutoClose(
330 }330 }
331 requestOAuthClose("navigation")331 requestOAuthClose("navigation")
332 })332 })
333- if openerNavigations, ok := wv.(port.PopupOpenerNavigationCapable); ok {333+ if opener, ok := wv.(port.PopupOpenerCapable); ok {
334- openerNavigations.AddOpenerNavigationCallback(func(uri string) {334+ opener.AddOpenerNavigationCallback(func(uri string) {
335 c.capturePopupOAuthState(popupID, uri)335 c.capturePopupOAuthState(popupID, uri)
336 requestOAuthClose("opener-navigation")336 requestOAuthClose("opener-navigation")
337 })337 })
338- }338+ opener.AddOpenerMessageCallback(func() {
339- if openerMessages, ok := wv.(port.PopupOpenerMessageCapable); ok {
340- openerMessages.AddOpenerMessageCallback(func() {
341 c.capturePopupOAuthMessage(popupID)339 c.capturePopupOAuthMessage(popupID)
342 requestOAuthClose("opener-message")340 requestOAuthClose("opener-message")
343 })341 })
@@ -350,22 +348,22 @@ func (c *Coordinator) setupOAuthAutoClose(
350 }348 }
351 349
352 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {350 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
353- c.popupMu.Lock()351+ c.popups.mu.Lock()
354- defer c.popupMu.Unlock()352+ defer c.popups.mu.Unlock()
355- if c.popupOAuth == nil {353+ if c.popups.popupOAuth == nil {
356- c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)354+ c.popups.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
357 }355 }
358- c.popupOAuth[popupID] = &popupOAuthState{356+ c.popups.popupOAuth[popupID] = &popupOAuthState{
359 ParentPaneID: parentPaneID,357 ParentPaneID: parentPaneID,
360 ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),358 ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
361 }359 }
362 }360 }
363 361
364 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {362 func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
365- c.popupMu.Lock()363+ c.popups.mu.Lock()
366- defer c.popupMu.Unlock()364+ defer c.popups.mu.Unlock()
367 365
368- state, ok := c.popupOAuth[popupID]366+ state, ok := c.popups.popupOAuth[popupID]
369 if !ok {367 if !ok {
370 return368 return
371 }369 }
@@ -377,10 +375,10 @@ func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string)
377 }375 }
378 376
379 func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {377 func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
380- c.popupMu.Lock()378+ c.popups.mu.Lock()
381- defer c.popupMu.Unlock()379+ defer c.popups.mu.Unlock()
382 380
383- state, ok := c.popupOAuth[popupID]381+ state, ok := c.popups.popupOAuth[popupID]
384 if !ok {382 if !ok {
385 return383 return
386 }384 }
@@ -397,12 +395,12 @@ func (c *Coordinator) capturePopupOAuthMessage(popupID port.WebViewID) {
397 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {395 func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
398 log := logging.FromContext(ctx)396 log := logging.FromContext(ctx)
399 397
400- c.popupMu.Lock()398+ c.popups.mu.Lock()
401- state, ok := c.popupOAuth[popupID]399+ state, ok := c.popups.popupOAuth[popupID]
402 if ok {400 if ok {
403- delete(c.popupOAuth, popupID)401+ delete(c.popups.popupOAuth, popupID)
404 }402 }
405- c.popupMu.Unlock()403+ c.popups.mu.Unlock()
406 404
407 if !ok || state == nil || !state.Seen {405 if !ok || state == nil || !state.Seen {
408 return406 return
@@ -433,24 +431,24 @@ func (c *Coordinator) scheduleParentPaneOAuthResume(
433 parentURIAtOpen string,431 parentURIAtOpen string,
434 callbackURI string,432 callbackURI string,
435 ) {433 ) {
436- c.popupMu.Lock()434+ c.popups.mu.Lock()
437- if c.popupRefresh == nil {435+ if c.popups.popupRefresh == nil {
438- c.popupRefresh = make(map[entity.PaneID]*time.Timer)436+ c.popups.popupRefresh = make(map[entity.PaneID]*time.Timer)
439 }437 }
440- if existing := c.popupRefresh[parentPaneID]; existing != nil {438+ if existing := c.popups.popupRefresh[parentPaneID]; existing != nil {
441 existing.Stop()439 existing.Stop()
442 }440 }
443- c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {441+ c.popups.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
444- c.popupMu.Lock()442+ c.popups.mu.Lock()
445- delete(c.popupRefresh, parentPaneID)443+ delete(c.popups.popupRefresh, parentPaneID)
446- c.popupMu.Unlock()444+ c.popups.mu.Unlock()
447 cb := glib.SourceFunc(func(_ uintptr) bool {445 cb := glib.SourceFunc(func(_ uintptr) bool {
448 c.resumeParentPaneAfterOAuth(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI)446 c.resumeParentPaneAfterOAuth(ctx, parentPaneID, popupID, parentURIAtOpen, callbackURI)
449 return false447 return false
450 })448 })
451 glib.IdleAdd(&cb, 0)449 glib.IdleAdd(&cb, 0)
452 })450 })
453- c.popupMu.Unlock()451+ c.popups.mu.Unlock()
454 }452 }
455 453
456 func (c *Coordinator) resumeParentPaneAfterOAuth(454 func (c *Coordinator) resumeParentPaneAfterOAuth(
M internal/ui/coordinator/content/oauth_test.go +18 -16
@@ -18,6 +18,10 @@ type popupOpenerBridgeStateStub struct {
1818 active bool
1919 }
2020
21+func (s *popupOpenerBridgeStateStub) EnablePopupOpenerBridge(port.WebView, bool) {}
22+func (s *popupOpenerBridgeStateStub) AddOpenerMessageCallback(func()) {}
23+func (s *popupOpenerBridgeStateStub) AddOpenerNavigationCallback(func(string)) {
24+}
2125 func (s *popupOpenerBridgeStateStub) HasActivePopupOpenerBridge() bool { return s.active }
2226
2327 func TestPopupUsesSyntheticOpenerSignals_DetectsActiveBridge(t *testing.T) {
@@ -69,16 +73,15 @@ func TestComposeOnLoadChanged_Order(t *testing.T) {
6973 func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {
7074 popupID := port.WebViewID(100)
7175 c := &Coordinator{
72- popupOAuth: map[port.WebViewID]*popupOAuthState{
73- popupID: {
74- ParentPaneID: entity.PaneID("parent-pane"),
75- },
76- },
76+ popups: newPopupManager(),
77+ }
78+ c.popups.popupOAuth[popupID] = &popupOAuthState{
79+ ParentPaneID: entity.PaneID("parent-pane"),
7780 }
7881
7982 c.capturePopupOAuthMessage(popupID)
8083
81- state := c.popupOAuth[popupID]
84+ state := c.popups.popupOAuth[popupID]
8285 assert.True(t, state.Seen)
8386 assert.True(t, state.Success)
8487 assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)
@@ -89,27 +92,26 @@ func TestHandlePopupOAuthClose_SuccessSchedulesParentResume(t *testing.T) {
8992 popupID := port.WebViewID(101)
9093
9194 c := &Coordinator{
92- webViews: make(map[entity.PaneID]port.WebView),
93- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
94- popupRefresh: make(map[entity.PaneID]*time.Timer),
95+ webViews: make(map[entity.PaneID]port.WebView),
96+ popups: newPopupManager(),
9597 }
9698
9799 c.trackOAuthPopup(popupID, parentPaneID, "https://www.notion.so/login")
98100 c.capturePopupOAuthState(popupID, "https://www.notion.so/googlepopupcallback?code=123")
99101 c.handlePopupOAuthClose(context.Background(), popupID)
100102
101- c.popupMu.RLock()
102- _, exists := c.popupOAuth[popupID]
103- refreshTimer := c.popupRefresh[parentPaneID]
104- c.popupMu.RUnlock()
103+ c.popups.mu.RLock()
104+ _, exists := c.popups.popupOAuth[popupID]
105+ refreshTimer := c.popups.popupRefresh[parentPaneID]
106+ c.popups.mu.RUnlock()
105107
106108 assert.False(t, exists, "oauth state should be removed after close handling")
107109 assert.NotNil(t, refreshTimer, "oauth callback should schedule parent resume")
108110
109111 waitFor(t, time.Second, func() bool {
110- c.popupMu.RLock()
111- defer c.popupMu.RUnlock()
112- return c.popupRefresh[parentPaneID] == nil
112+ c.popups.mu.RLock()
113+ defer c.popups.mu.RUnlock()
114+ return c.popups.popupRefresh[parentPaneID] == nil
113115 })
114116 }
115117
@@ -18,6 +18,10 @@ type popupOpenerBridgeStateStub struct {
18 active bool18 active bool
19 }19 }
20 20
21+func (s *popupOpenerBridgeStateStub) EnablePopupOpenerBridge(port.WebView, bool) {}
22+func (s *popupOpenerBridgeStateStub) AddOpenerMessageCallback(func()) {}
23+func (s *popupOpenerBridgeStateStub) AddOpenerNavigationCallback(func(string)) {
24+}
21 func (s *popupOpenerBridgeStateStub) HasActivePopupOpenerBridge() bool { return s.active }25 func (s *popupOpenerBridgeStateStub) HasActivePopupOpenerBridge() bool { return s.active }
22 26
23 func TestPopupUsesSyntheticOpenerSignals_DetectsActiveBridge(t *testing.T) {27 func TestPopupUsesSyntheticOpenerSignals_DetectsActiveBridge(t *testing.T) {
@@ -69,16 +73,15 @@ func TestComposeOnLoadChanged_Order(t *testing.T) {
69 func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {73 func TestCapturePopupOAuthMessage_MarksPopupSeenAndSuccessful(t *testing.T) {
70 popupID := port.WebViewID(100)74 popupID := port.WebViewID(100)
71 c := &Coordinator{75 c := &Coordinator{
72- popupOAuth: map[port.WebViewID]*popupOAuthState{76+ popups: newPopupManager(),
73- popupID: {77+ }
74- ParentPaneID: entity.PaneID("parent-pane"),78+ c.popups.popupOAuth[popupID] = &popupOAuthState{
75- },79+ ParentPaneID: entity.PaneID("parent-pane"),
76- },
77 }80 }
78 81
79 c.capturePopupOAuthMessage(popupID)82 c.capturePopupOAuthMessage(popupID)
80 83
81- state := c.popupOAuth[popupID]84+ state := c.popups.popupOAuth[popupID]
82 assert.True(t, state.Seen)85 assert.True(t, state.Seen)
83 assert.True(t, state.Success)86 assert.True(t, state.Success)
84 assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)87 assert.Equal(t, "postmessage://oauth-complete", state.CallbackURI)
@@ -89,27 +92,26 @@ func TestHandlePopupOAuthClose_SuccessSchedulesParentResume(t *testing.T) {
89 popupID := port.WebViewID(101)92 popupID := port.WebViewID(101)
90 93
91 c := &Coordinator{94 c := &Coordinator{
92- webViews: make(map[entity.PaneID]port.WebView),95+ webViews: make(map[entity.PaneID]port.WebView),
93- popupOAuth: make(map[port.WebViewID]*popupOAuthState),96+ popups: newPopupManager(),
94- popupRefresh: make(map[entity.PaneID]*time.Timer),
95 }97 }
96 98
97 c.trackOAuthPopup(popupID, parentPaneID, "https://www.notion.so/login")99 c.trackOAuthPopup(popupID, parentPaneID, "https://www.notion.so/login")
98 c.capturePopupOAuthState(popupID, "https://www.notion.so/googlepopupcallback?code=123")100 c.capturePopupOAuthState(popupID, "https://www.notion.so/googlepopupcallback?code=123")
99 c.handlePopupOAuthClose(context.Background(), popupID)101 c.handlePopupOAuthClose(context.Background(), popupID)
100 102
101- c.popupMu.RLock()103+ c.popups.mu.RLock()
102- _, exists := c.popupOAuth[popupID]104+ _, exists := c.popups.popupOAuth[popupID]
103- refreshTimer := c.popupRefresh[parentPaneID]105+ refreshTimer := c.popups.popupRefresh[parentPaneID]
104- c.popupMu.RUnlock()106+ c.popups.mu.RUnlock()
105 107
106 assert.False(t, exists, "oauth state should be removed after close handling")108 assert.False(t, exists, "oauth state should be removed after close handling")
107 assert.NotNil(t, refreshTimer, "oauth callback should schedule parent resume")109 assert.NotNil(t, refreshTimer, "oauth callback should schedule parent resume")
108 110
109 waitFor(t, time.Second, func() bool {111 waitFor(t, time.Second, func() bool {
110- c.popupMu.RLock()112+ c.popups.mu.RLock()
111- defer c.popupMu.RUnlock()113+ defer c.popups.mu.RUnlock()
112- return c.popupRefresh[parentPaneID] == nil114+ return c.popups.popupRefresh[parentPaneID] == nil
113 })115 })
114 }116 }
115 117
M internal/ui/coordinator/content/popup.go +147 -178
@@ -101,19 +101,19 @@ func (c *Coordinator) lookupReusableNamedPopup(parentPaneID entity.PaneID, frame
101101
102102 key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
103103
104- c.popupMu.RLock()
105- state, ok := c.namedPopups[key]
106- c.popupMu.RUnlock()
104+ c.popups.mu.RLock()
105+ state, ok := c.popups.namedPopups[key]
106+ c.popups.mu.RUnlock()
107107 if !ok || state == nil || state.WebView == nil {
108108 return nil, false
109109 }
110110
111111 if state.WebView.IsDestroyed() {
112- c.popupMu.Lock()
113- if current, ok := c.namedPopups[key]; ok && current == state {
114- delete(c.namedPopups, key)
112+ c.popups.mu.Lock()
113+ if current, ok := c.popups.namedPopups[key]; ok && current == state {
114+ delete(c.popups.namedPopups, key)
115115 }
116- c.popupMu.Unlock()
116+ c.popups.mu.Unlock()
117117 return nil, false
118118 }
119119
@@ -131,30 +131,30 @@ func (c *Coordinator) storeReusableNamedPopup(
131131
132132 key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
133133
134- c.popupMu.Lock()
135- if c.namedPopups == nil {
136- c.namedPopups = make(map[namedPopupKey]*namedPopupState)
134+ c.popups.mu.Lock()
135+ if c.popups.namedPopups == nil {
136+ c.popups.namedPopups = make(map[namedPopupKey]*namedPopupState)
137137 }
138- c.namedPopups[key] = &namedPopupState{WebView: wv}
139- c.popupMu.Unlock()
138+ c.popups.namedPopups[key] = &namedPopupState{WebView: wv}
139+ c.popups.mu.Unlock()
140140 }
141141
142142 func (c *Coordinator) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
143- c.popupMu.Lock()
144- if pending, ok := c.pendingPopups[popupID]; ok && pending != nil {
143+ c.popups.mu.Lock()
144+ if pending, ok := c.popups.pendingPopups[popupID]; ok && pending != nil {
145145 pending.TargetURI = targetURI
146146 }
147- c.popupMu.Unlock()
147+ c.popups.mu.Unlock()
148148 }
149149
150150 func (c *Coordinator) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
151- c.popupMu.Lock()
152- for key, state := range c.namedPopups {
151+ c.popups.mu.Lock()
152+ for key, state := range c.popups.namedPopups {
153153 if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
154- delete(c.namedPopups, key)
154+ delete(c.popups.namedPopups, key)
155155 }
156156 }
157- c.popupMu.Unlock()
157+ c.popups.mu.Unlock()
158158 }
159159
160160 // InsertPopupInput contains the data needed to insert a popup into the workspace.
@@ -215,24 +215,17 @@ func (c *Coordinator) SetPopupConfig(
215215 popupConfig *entity.PopupBehaviorConfig,
216216 generateID func() string,
217217 ) {
218- c.factory = factory
219- c.popupConfig = popupConfig
220- c.generateID = generateID
221-
222- c.popupMu.Lock()
223- c.relatedPopupUnsupported = false
224- c.relatedPopupSupportDetected = false
225- c.popupMu.Unlock()
218+ c.ensurePopupManager().setConfig(factory, popupConfig, generateID)
226219 }
227220
228221 // SetOnInsertPopup sets the callback to insert popups into the workspace.
229222 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
230- c.onInsertPopup = fn
223+ c.ensurePopupManager().setOnInsertPopup(fn)
231224 }
232225
233226 // SetOnClosePane sets the callback to close a pane when its popup closes.
234227 func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
235- c.onClosePane = fn
228+ c.ensurePopupManager().setOnClosePane(fn)
236229 }
237230
238231 // buildPopupCreateHandler returns the OnCreate callback for a WebView.
@@ -258,40 +251,30 @@ func (c *Coordinator) createPopupPane(
258251 parentPaneID entity.PaneID,
259252 targetURI string,
260253 ) (entity.PaneID, *entity.Pane) {
261- var paneID entity.PaneID
262- if c.generateID != nil {
263- paneID = entity.PaneID(c.generateID())
264- } else {
265- paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
266- }
267-
268- popupPane := entity.NewPane(paneID)
269- popupPane.WindowType = entity.WindowPopup
270- popupPane.IsRelated = true
271- popupPane.ParentPaneID = &parentPaneID
272- popupPane.URI = targetURI
273-
274- return paneID, popupPane
254+ return c.ensurePopupManager().createPopupPane(popupID, parentPaneID, targetURI)
275255 }
276256
277257 func (c *Coordinator) relatedPopupSupportDisabled() bool {
278- c.popupMu.RLock()
279- defer c.popupMu.RUnlock()
280- return c.relatedPopupSupportDetected && c.relatedPopupUnsupported
258+ popups := c.ensurePopupManager()
259+ popups.mu.RLock()
260+ defer popups.mu.RUnlock()
261+ return popups.relatedPopupSupportDetected && popups.relatedPopupUnsupported
281262 }
282263
283264 func (c *Coordinator) markRelatedPopupUnsupported() {
284- c.popupMu.Lock()
285- defer c.popupMu.Unlock()
286- c.relatedPopupSupportDetected = true
287- c.relatedPopupUnsupported = true
265+ popups := c.ensurePopupManager()
266+ popups.mu.Lock()
267+ defer popups.mu.Unlock()
268+ popups.relatedPopupSupportDetected = true
269+ popups.relatedPopupUnsupported = true
288270 }
289271
290272 func (c *Coordinator) markRelatedPopupSupported() {
291- c.popupMu.Lock()
292- defer c.popupMu.Unlock()
293- c.relatedPopupSupportDetected = true
294- c.relatedPopupUnsupported = false
273+ popups := c.ensurePopupManager()
274+ popups.mu.Lock()
275+ defer popups.mu.Unlock()
276+ popups.relatedPopupSupportDetected = true
277+ popups.relatedPopupUnsupported = false
295278 }
296279
297280 // createPopupWebView prefers a related WebView so popups can share the
@@ -304,7 +287,7 @@ func (c *Coordinator) createPopupWebView(
304287 targetURI string,
305288 noJavaScriptAccess bool,
306289 ) (port.WebView, string, bool, error) {
307- if c.factory == nil {
290+ if c.popups.factory == nil {
308291 return nil, targetURI, false, fmt.Errorf("no webview factory configured")
309292 }
310293
@@ -312,7 +295,7 @@ func (c *Coordinator) createPopupWebView(
312295 relatedErr := error(nil)
313296
314297 if !noJavaScriptAccess && !c.relatedPopupSupportDisabled() {
315- popupWV, err := c.factory.CreateRelated(ctx, parentID)
298+ popupWV, err := c.popups.factory.CreateRelated(ctx, parentID)
316299 if err == nil && popupWV != nil {
317300 c.markRelatedPopupSupported()
318301 return popupWV, targetURI, false, nil
@@ -351,7 +334,7 @@ func (c *Coordinator) createPopupWebView(
351334 Msg("related popup webviews known unsupported, creating regular webview")
352335 }
353336
354- popupWV, fallbackErr := c.factory.Create(ctx)
337+ popupWV, fallbackErr := c.popups.factory.Create(ctx)
355338 if fallbackErr != nil {
356339 if relatedErr != nil {
357340 return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
@@ -388,20 +371,20 @@ func (c *Coordinator) handlePopupCreate(
388371 Msg("popup create request")
389372
390373 // Check if popups are enabled
391- if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
374+ if c.popups.popupConfig != nil && !c.popups.popupConfig.OpenInNewPane {
392375 log.Debug().Msg("popups disabled by config, blocking")
393376 return nil
394377 }
395378
396379 // Check if factory is available
397- if c.factory == nil {
380+ if c.popups.factory == nil {
398381 log.Warn().Msg("no webview factory, cannot create popup")
399382 return nil
400383 }
401384
402385 parentID := parentWV.ID()
403386 parentURIAtOpen := ""
404- if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
387+ if c.popups.popupConfig != nil && c.popups.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
405388 if parentWV != nil {
406389 parentURIAtOpen = parentWV.URI()
407390 }
@@ -430,7 +413,7 @@ func (c *Coordinator) handlePopupCreate(
430413 req.TargetURI = effectiveTargetURI
431414 }
432415 if usedRegularFallback {
433- if openerBridge, ok := popupWV.(port.PopupOpenerBridgeCapable); ok {
416+ if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {
434417 openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
435418 }
436419 }
@@ -440,29 +423,28 @@ func (c *Coordinator) handlePopupCreate(
440423 popupID := popupWV.ID()
441424
442425 // Determine behavior from config
443- behavior := GetBehavior(popupType, c.popupConfig)
426+ behavior := GetBehavior(popupType, c.popups.popupConfig)
444427 placement := "right"
445- if c.popupConfig != nil {
446- placement = c.popupConfig.Placement
428+ if c.popups.popupConfig != nil {
429+ placement = c.popups.popupConfig.Placement
447430 }
448431
449432 // Create popup pane entity
450433 paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
451434
452- return c.finishPopupCreate(
453- ctx,
454- parentPaneID,
455- parentID,
456- parentURIAtOpen,
457- popupID,
458- popupWV,
459- popupPane,
460- paneID,
461- popupType,
462- behavior,
463- placement,
464- req,
465- )
435+ return c.finishPopupCreate(ctx, popupCreateContext{
436+ ParentPaneID: parentPaneID,
437+ ParentWebViewID: parentID,
438+ ParentURIAtOpen: parentURIAtOpen,
439+ PopupID: popupID,
440+ PopupWebView: popupWV,
441+ PopupPane: popupPane,
442+ PopupPaneID: paneID,
443+ PopupType: popupType,
444+ Behavior: behavior,
445+ Placement: placement,
446+ Request: req,
447+ })
466448 }
467449
468450 func (c *Coordinator) reuseNamedPopup(
@@ -491,142 +473,129 @@ func (c *Coordinator) reuseNamedPopup(
491473 return nil, false
492474 }
493475
494-func (c *Coordinator) finishPopupCreate(
495- ctx context.Context,
496- parentPaneID entity.PaneID,
497- parentID port.WebViewID,
498- parentURIAtOpen string,
499- popupID port.WebViewID,
500- popupWV port.WebView,
501- popupPane *entity.Pane,
502- paneID entity.PaneID,
503- popupType PopupType,
504- behavior entity.PopupBehavior,
505- placement string,
506- req port.PopupRequest,
507-) port.WebView {
476+func (c *Coordinator) finishPopupCreate(ctx context.Context, create popupCreateContext) port.WebView {
508477 log := logging.FromContext(ctx)
509- hasConfig := c.popupConfig != nil
510- oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
511- isOAuth := IsOAuthURL(req.TargetURI)
478+ hasConfig := c.popups.popupConfig != nil
479+ oauthEnabled := hasConfig && c.popups.popupConfig.OAuthAutoClose
480+ isOAuth := IsOAuthURL(create.Request.TargetURI)
512481
513482 log.Debug().
514483 Bool("has_config", hasConfig).
515484 Bool("oauth_enabled", oauthEnabled).
516485 Bool("is_oauth", isOAuth).
517- Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
486+ Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
518487 Msg("popup OAuth check")
519488
520489 // Initialize callbacks before publishing the WebView so any reader that sees
521490 // the popup during workspace insertion gets a fully configured instance.
522- c.setupWebViewCallbacks(ctx, paneID, popupWV)
491+ c.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
523492
524493 // Register the popup WebView before workspace insertion so split/stack UI
525494 // updates can reuse the real popup instead of acquiring a placeholder pane
526495 // WebView that races the actual popup flow.
527- c.RegisterPopupWebView(paneID, popupWV)
496+ c.RegisterPopupWebView(create.PopupPaneID, create.PopupWebView)
528497 inserted := false
529498 defer func() {
530499 if inserted {
531500 return
532501 }
533- if current := c.getWebViewLocked(paneID); current == popupWV {
534- c.deleteWebViewLocked(paneID)
502+ if current := c.getWebViewLocked(create.PopupPaneID); current == create.PopupWebView {
503+ c.deleteWebViewLocked(create.PopupPaneID)
535504 }
536505 }()
537506
538507 // Insert into workspace IMMEDIATELY (WebView stays hidden)
539- // This is required for WebKit to establish window.opener relationship
540- if c.onInsertPopup != nil {
508+ // This is required for WebKit to establish window.opener relationship.
509+ if c.popups.onInsertPopup != nil {
541510 popupInput := InsertPopupInput{
542- ParentPaneID: parentPaneID,
543- PopupPane: popupPane,
544- WebView: popupWV,
545- Behavior: behavior,
546- Placement: placement,
547- PopupType: popupType,
548- TargetURI: req.TargetURI,
511+ ParentPaneID: create.ParentPaneID,
512+ PopupPane: create.PopupPane,
513+ WebView: create.PopupWebView,
514+ Behavior: create.Behavior,
515+ Placement: create.Placement,
516+ PopupType: create.PopupType,
517+ TargetURI: create.Request.TargetURI,
549518 }
550519
551- if err := c.onInsertPopup(ctx, popupInput); err != nil {
520+ if err := c.popups.onInsertPopup(ctx, popupInput); err != nil {
552521 log.Error().Err(err).Msg("failed to insert popup into workspace")
553- popupWV.Destroy()
522+ create.PopupWebView.Destroy()
554523 return nil
555524 }
556525 }
557526 inserted = true
558527
559- if primable, ok := popupWV.(port.PopupNavigationCapable); ok {
560- primable.PrimePopupNavigation(req.TargetURI)
528+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
529+ lifecycle.PrimePopupNavigation(create.Request.TargetURI)
561530 }
562531
563- if !req.NoJavaScriptAccess {
564- c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)
532+ if !create.Request.NoJavaScriptAccess {
533+ c.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupWebView)
565534 }
566535
567536 // Engines without native popup lifecycle hooks can still support
568537 // programmatic popup closure (proxy.close(), OAuth auto-close) by exposing
569538 // the optional OAuthCallbackCapable interface.
570- if _, hasNativePopupLifecycle := popupWV.(port.PopupCapable); !hasNativePopupLifecycle {
571- if closeCapable, ok := popupWV.(port.OAuthCallbackCapable); ok {
539+ if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
540+ if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
572541 closeCapable.AddCloseCallback(func() {
573- c.handlePopupClose(ctx, popupID)
542+ c.handlePopupClose(ctx, create.PopupID)
574543 })
575544 }
576545 }
577546
578- // Setup OAuth auto-close if configured
547+ // Setup OAuth auto-close if configured.
579548 if hasConfig && oauthEnabled && isOAuth {
580- popupPane.AutoClose = true
581- c.trackOAuthPopup(popupID, parentPaneID, parentURIAtOpen)
582- c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
583- log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
549+ create.PopupPane.AutoClose = true
550+ c.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
551+ c.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
552+ log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
584553 }
585554
586- // Store pending popup for ready-to-show handling (just visibility now)
555+ // Store pending popup for ready-to-show handling (just visibility now).
587556 pending := &PendingPopup{
588- PaneID: paneID,
589- WebView: popupWV,
590- ParentPaneID: parentPaneID,
591- ParentWebViewID: parentID,
592- TargetURI: req.TargetURI,
593- FrameName: req.FrameName,
594- IsUserGesture: req.IsUserGesture,
595- PopupType: popupType,
557+ PaneID: create.PopupPaneID,
558+ WebView: create.PopupWebView,
559+ ParentPaneID: create.ParentPaneID,
560+ ParentWebViewID: create.ParentWebViewID,
561+ TargetURI: create.Request.TargetURI,
562+ FrameName: create.Request.FrameName,
563+ IsUserGesture: create.Request.IsUserGesture,
564+ PopupType: create.PopupType,
596565 CreatedAt: time.Now(),
597566 }
598567
599- c.popupMu.Lock()
600- c.pendingPopups[popupID] = pending
601- c.popupMu.Unlock()
568+ c.popups.mu.Lock()
569+ c.popups.pendingPopups[create.PopupID] = pending
570+ c.popups.mu.Unlock()
602571
603- // Wire ready-to-show and close signals via the PopupCapable interface.
604- if pc, ok := popupWV.(port.PopupCapable); ok {
605- pc.SetOnReadyToShow(func() {
606- c.handlePopupReadyToShow(ctx, popupID)
572+ // Wire ready-to-show and close signals via the popup lifecycle interface.
573+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
574+ lifecycle.SetOnReadyToShow(func() {
575+ c.handlePopupReadyToShow(ctx, create.PopupID)
607576 })
608- pc.SetOnClose(func() {
609- c.handlePopupClose(ctx, popupID)
577+ lifecycle.SetOnClose(func() {
578+ c.handlePopupClose(ctx, create.PopupID)
610579 })
611580 log.Info().
612- Uint64("popup_id", uint64(popupID)).
613- Str("pane_id", string(paneID)).
614- Str("popup_type", popupType.String()).
615- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
581+ Uint64("popup_id", uint64(create.PopupID)).
582+ Str("pane_id", string(create.PopupPaneID)).
583+ Str("popup_type", create.PopupType.String()).
584+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
616585 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
617586 } else {
618587 // Engine does not expose popup lifecycle callbacks. Treat the inserted
619588 // WebView as ready immediately and continue with the same coordinator flow.
620589 log.Info().
621- Uint64("popup_id", uint64(popupID)).
622- Str("pane_id", string(paneID)).
623- Str("popup_type", popupType.String()).
624- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
625- Msg("popup inserted, immediately ready (no PopupCapable)")
626- c.handlePopupReadyToShow(ctx, popupID)
590+ Uint64("popup_id", uint64(create.PopupID)).
591+ Str("pane_id", string(create.PopupPaneID)).
592+ Str("popup_type", create.PopupType.String()).
593+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
594+ Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
595+ c.handlePopupReadyToShow(ctx, create.PopupID)
627596 }
628597
629- return popupWV
598+ return create.PopupWebView
630599 }
631600
632601 // handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
@@ -636,12 +605,12 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
636605 log := logging.FromContext(ctx)
637606
638607 // Get pending popup
639- c.popupMu.Lock()
640- pending, ok := c.pendingPopups[popupID]
608+ c.popups.mu.Lock()
609+ pending, ok := c.popups.pendingPopups[popupID]
641610 if ok {
642- delete(c.pendingPopups, popupID)
611+ delete(c.popups.pendingPopups, popupID)
643612 }
644- c.popupMu.Unlock()
613+ c.popups.mu.Unlock()
645614
646615 if !ok || pending == nil {
647616 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
@@ -655,10 +624,10 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
655624
656625 // Make the WebView visible now that it's ready.
657626 if pending.WebView != nil {
658- if pc, ok := pending.WebView.(port.PopupCapable); ok {
659- pc.Show()
627+ if lifecycle, ok := pending.WebView.(port.PopupLifecycleCapable); ok {
628+ lifecycle.Show()
660629 }
661- _, preloadsNavigation := pending.WebView.(port.PopupNavigationCapable)
630+ _, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
662631 // Engines that do not preload popup navigation can request the target URI
663632 // here once the popup becomes visible.
664633 if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
@@ -682,17 +651,17 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
682651 log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
683652
684653 // Check if still pending (never shown)
685- c.popupMu.Lock()
686- pending, wasPending := c.pendingPopups[popupID]
654+ c.popups.mu.Lock()
655+ pending, wasPending := c.popups.pendingPopups[popupID]
687656 if wasPending {
688- delete(c.pendingPopups, popupID)
657+ delete(c.popups.pendingPopups, popupID)
689658 }
690- c.popupMu.Unlock()
659+ c.popups.mu.Unlock()
691660
692661 if wasPending && pending != nil {
693662 c.handlePopupOAuthClose(ctx, popupID)
694- if c.onClosePane != nil {
695- if err := c.onClosePane(ctx, pending.PaneID); err != nil {
663+ if c.popups.onClosePane != nil {
664+ if err := c.popups.onClosePane(ctx, pending.PaneID); err != nil {
696665 log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
697666 }
698667 }
@@ -727,8 +696,8 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
727696 c.handlePopupOAuthClose(ctx, popupID)
728697
729698 // Close the pane in workspace (this removes the UI element)
730- if c.onClosePane != nil {
731- if err := c.onClosePane(ctx, paneID); err != nil {
699+ if c.popups.onClosePane != nil {
700+ if err := c.popups.onClosePane(ctx, paneID); err != nil {
732701 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
733702 }
734703 }
@@ -755,19 +724,19 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
755724 Msg("middle-click/ctrl+click on link")
756725
757726 // Check if popups are enabled
758- if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
727+ if c.popups.popupConfig != nil && !c.popups.popupConfig.OpenInNewPane {
759728 log.Debug().Msg("popups disabled by config, ignoring middle-click")
760729 return false
761730 }
762731
763732 // Check if factory is available
764- if c.factory == nil {
733+ if c.popups.factory == nil {
765734 log.Warn().Msg("no webview factory, cannot handle middle-click")
766735 return false
767736 }
768737
769738 // Create a new WebView (regular, not related - just opening a link)
770- newWV, err := c.factory.Create(ctx)
739+ newWV, err := c.popups.factory.Create(ctx)
771740 if err != nil {
772741 log.Error().Err(err).Msg("failed to create webview for middle-click")
773742 return false
@@ -775,8 +744,8 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
775744
776745 // Generate pane ID
777746 var paneID entity.PaneID
778- if c.generateID != nil {
779- paneID = entity.PaneID(c.generateID())
747+ if c.popups.generatePaneID != nil {
748+ paneID = entity.PaneID(c.popups.generatePaneID())
780749 } else {
781750 paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
782751 }
@@ -794,16 +763,16 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
794763
795764 // Get behavior from config (same as _blank links)
796765 behavior := entity.PopupBehaviorStacked // default
797- if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
798- behavior = entity.PopupBehavior(c.popupConfig.BlankTargetBehavior)
766+ if c.popups.popupConfig != nil && c.popups.popupConfig.BlankTargetBehavior != "" {
767+ behavior = entity.PopupBehavior(c.popups.popupConfig.BlankTargetBehavior)
799768 }
800769 placement := "right"
801- if c.popupConfig != nil {
802- placement = c.popupConfig.Placement
770+ if c.popups.popupConfig != nil {
771+ placement = c.popups.popupConfig.Placement
803772 }
804773
805774 // Request insertion into workspace
806- if c.onInsertPopup != nil {
775+ if c.popups.onInsertPopup != nil {
807776 popupInput := InsertPopupInput{
808777 ParentPaneID: parentPaneID,
809778 PopupPane: newPane,
@@ -814,7 +783,7 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
814783 TargetURI: uri,
815784 }
816785
817- if err := c.onInsertPopup(ctx, popupInput); err != nil {
786+ if err := c.popups.onInsertPopup(ctx, popupInput); err != nil {
818787 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
819788 // Clean up on failure — use the locking helper to avoid a data race.
820789 c.deleteWebViewLocked(paneID)
@@ -101,19 +101,19 @@ func (c *Coordinator) lookupReusableNamedPopup(parentPaneID entity.PaneID, frame
101 101
102 key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}102 key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
103 103
104- c.popupMu.RLock()104+ c.popups.mu.RLock()
105- state, ok := c.namedPopups[key]105+ state, ok := c.popups.namedPopups[key]
106- c.popupMu.RUnlock()106+ c.popups.mu.RUnlock()
107 if !ok || state == nil || state.WebView == nil {107 if !ok || state == nil || state.WebView == nil {
108 return nil, false108 return nil, false
109 }109 }
110 110
111 if state.WebView.IsDestroyed() {111 if state.WebView.IsDestroyed() {
112- c.popupMu.Lock()112+ c.popups.mu.Lock()
113- if current, ok := c.namedPopups[key]; ok && current == state {113+ if current, ok := c.popups.namedPopups[key]; ok && current == state {
114- delete(c.namedPopups, key)114+ delete(c.popups.namedPopups, key)
115 }115 }
116- c.popupMu.Unlock()116+ c.popups.mu.Unlock()
117 return nil, false117 return nil, false
118 }118 }
119 119
@@ -131,30 +131,30 @@ func (c *Coordinator) storeReusableNamedPopup(
131 131
132 key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}132 key := namedPopupKey{ParentPaneID: parentPaneID, FrameName: frameName}
133 133
134- c.popupMu.Lock()134+ c.popups.mu.Lock()
135- if c.namedPopups == nil {135+ if c.popups.namedPopups == nil {
136- c.namedPopups = make(map[namedPopupKey]*namedPopupState)136+ c.popups.namedPopups = make(map[namedPopupKey]*namedPopupState)
137 }137 }
138- c.namedPopups[key] = &namedPopupState{WebView: wv}138+ c.popups.namedPopups[key] = &namedPopupState{WebView: wv}
139- c.popupMu.Unlock()139+ c.popups.mu.Unlock()
140 }140 }
141 141
142 func (c *Coordinator) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {142 func (c *Coordinator) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
143- c.popupMu.Lock()143+ c.popups.mu.Lock()
144- if pending, ok := c.pendingPopups[popupID]; ok && pending != nil {144+ if pending, ok := c.popups.pendingPopups[popupID]; ok && pending != nil {
145 pending.TargetURI = targetURI145 pending.TargetURI = targetURI
146 }146 }
147- c.popupMu.Unlock()147+ c.popups.mu.Unlock()
148 }148 }
149 149
150 func (c *Coordinator) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {150 func (c *Coordinator) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
151- c.popupMu.Lock()151+ c.popups.mu.Lock()
152- for key, state := range c.namedPopups {152+ for key, state := range c.popups.namedPopups {
153 if state != nil && state.WebView != nil && state.WebView.ID() == popupID {153 if state != nil && state.WebView != nil && state.WebView.ID() == popupID {
154- delete(c.namedPopups, key)154+ delete(c.popups.namedPopups, key)
155 }155 }
156 }156 }
157- c.popupMu.Unlock()157+ c.popups.mu.Unlock()
158 }158 }
159 159
160 // InsertPopupInput contains the data needed to insert a popup into the workspace.160 // InsertPopupInput contains the data needed to insert a popup into the workspace.
@@ -215,24 +215,17 @@ func (c *Coordinator) SetPopupConfig(
215 popupConfig *entity.PopupBehaviorConfig,215 popupConfig *entity.PopupBehaviorConfig,
216 generateID func() string,216 generateID func() string,
217 ) {217 ) {
218- c.factory = factory218+ c.ensurePopupManager().setConfig(factory, popupConfig, generateID)
219- c.popupConfig = popupConfig
220- c.generateID = generateID
221-
222- c.popupMu.Lock()
223- c.relatedPopupUnsupported = false
224- c.relatedPopupSupportDetected = false
225- c.popupMu.Unlock()
226 }219 }
227 220
228 // SetOnInsertPopup sets the callback to insert popups into the workspace.221 // SetOnInsertPopup sets the callback to insert popups into the workspace.
229 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {222 func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
230- c.onInsertPopup = fn223+ c.ensurePopupManager().setOnInsertPopup(fn)
231 }224 }
232 225
233 // SetOnClosePane sets the callback to close a pane when its popup closes.226 // SetOnClosePane sets the callback to close a pane when its popup closes.
234 func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {227 func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
235- c.onClosePane = fn228+ c.ensurePopupManager().setOnClosePane(fn)
236 }229 }
237 230
238 // buildPopupCreateHandler returns the OnCreate callback for a WebView.231 // buildPopupCreateHandler returns the OnCreate callback for a WebView.
@@ -258,40 +251,30 @@ func (c *Coordinator) createPopupPane(
258 parentPaneID entity.PaneID,251 parentPaneID entity.PaneID,
259 targetURI string,252 targetURI string,
260 ) (entity.PaneID, *entity.Pane) {253 ) (entity.PaneID, *entity.Pane) {
261- var paneID entity.PaneID254+ return c.ensurePopupManager().createPopupPane(popupID, parentPaneID, targetURI)
262- if c.generateID != nil {
263- paneID = entity.PaneID(c.generateID())
264- } else {
265- paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
266- }
267-
268- popupPane := entity.NewPane(paneID)
269- popupPane.WindowType = entity.WindowPopup
270- popupPane.IsRelated = true
271- popupPane.ParentPaneID = &parentPaneID
272- popupPane.URI = targetURI
273-
274- return paneID, popupPane
275 }255 }
276 256
277 func (c *Coordinator) relatedPopupSupportDisabled() bool {257 func (c *Coordinator) relatedPopupSupportDisabled() bool {
278- c.popupMu.RLock()258+ popups := c.ensurePopupManager()
279- defer c.popupMu.RUnlock()259+ popups.mu.RLock()
280- return c.relatedPopupSupportDetected && c.relatedPopupUnsupported260+ defer popups.mu.RUnlock()
261+ return popups.relatedPopupSupportDetected && popups.relatedPopupUnsupported
281 }262 }
282 263
283 func (c *Coordinator) markRelatedPopupUnsupported() {264 func (c *Coordinator) markRelatedPopupUnsupported() {
284- c.popupMu.Lock()265+ popups := c.ensurePopupManager()
285- defer c.popupMu.Unlock()266+ popups.mu.Lock()
286- c.relatedPopupSupportDetected = true267+ defer popups.mu.Unlock()
287- c.relatedPopupUnsupported = true268+ popups.relatedPopupSupportDetected = true
269+ popups.relatedPopupUnsupported = true
288 }270 }
289 271
290 func (c *Coordinator) markRelatedPopupSupported() {272 func (c *Coordinator) markRelatedPopupSupported() {
291- c.popupMu.Lock()273+ popups := c.ensurePopupManager()
292- defer c.popupMu.Unlock()274+ popups.mu.Lock()
293- c.relatedPopupSupportDetected = true275+ defer popups.mu.Unlock()
294- c.relatedPopupUnsupported = false276+ popups.relatedPopupSupportDetected = true
277+ popups.relatedPopupUnsupported = false
295 }278 }
296 279
297 // createPopupWebView prefers a related WebView so popups can share the280 // createPopupWebView prefers a related WebView so popups can share the
@@ -304,7 +287,7 @@ func (c *Coordinator) createPopupWebView(
304 targetURI string,287 targetURI string,
305 noJavaScriptAccess bool,288 noJavaScriptAccess bool,
306 ) (port.WebView, string, bool, error) {289 ) (port.WebView, string, bool, error) {
307- if c.factory == nil {290+ if c.popups.factory == nil {
308 return nil, targetURI, false, fmt.Errorf("no webview factory configured")291 return nil, targetURI, false, fmt.Errorf("no webview factory configured")
309 }292 }
310 293
@@ -312,7 +295,7 @@ func (c *Coordinator) createPopupWebView(
312 relatedErr := error(nil)295 relatedErr := error(nil)
313 296
314 if !noJavaScriptAccess && !c.relatedPopupSupportDisabled() {297 if !noJavaScriptAccess && !c.relatedPopupSupportDisabled() {
315- popupWV, err := c.factory.CreateRelated(ctx, parentID)298+ popupWV, err := c.popups.factory.CreateRelated(ctx, parentID)
316 if err == nil && popupWV != nil {299 if err == nil && popupWV != nil {
317 c.markRelatedPopupSupported()300 c.markRelatedPopupSupported()
318 return popupWV, targetURI, false, nil301 return popupWV, targetURI, false, nil
@@ -351,7 +334,7 @@ func (c *Coordinator) createPopupWebView(
351 Msg("related popup webviews known unsupported, creating regular webview")334 Msg("related popup webviews known unsupported, creating regular webview")
352 }335 }
353 336
354- popupWV, fallbackErr := c.factory.Create(ctx)337+ popupWV, fallbackErr := c.popups.factory.Create(ctx)
355 if fallbackErr != nil {338 if fallbackErr != nil {
356 if relatedErr != nil {339 if relatedErr != nil {
357 return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)340 return nil, targetURI, false, fmt.Errorf("create popup webview: related failed: %w; fallback failed: %w", relatedErr, fallbackErr)
@@ -388,20 +371,20 @@ func (c *Coordinator) handlePopupCreate(
388 Msg("popup create request")371 Msg("popup create request")
389 372
390 // Check if popups are enabled373 // Check if popups are enabled
391- if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {374+ if c.popups.popupConfig != nil && !c.popups.popupConfig.OpenInNewPane {
392 log.Debug().Msg("popups disabled by config, blocking")375 log.Debug().Msg("popups disabled by config, blocking")
393 return nil376 return nil
394 }377 }
395 378
396 // Check if factory is available379 // Check if factory is available
397- if c.factory == nil {380+ if c.popups.factory == nil {
398 log.Warn().Msg("no webview factory, cannot create popup")381 log.Warn().Msg("no webview factory, cannot create popup")
399 return nil382 return nil
400 }383 }
401 384
402 parentID := parentWV.ID()385 parentID := parentWV.ID()
403 parentURIAtOpen := ""386 parentURIAtOpen := ""
404- if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {387+ if c.popups.popupConfig != nil && c.popups.popupConfig.OAuthAutoClose && IsOAuthURL(req.TargetURI) {
405 if parentWV != nil {388 if parentWV != nil {
406 parentURIAtOpen = parentWV.URI()389 parentURIAtOpen = parentWV.URI()
407 }390 }
@@ -430,7 +413,7 @@ func (c *Coordinator) handlePopupCreate(
430 req.TargetURI = effectiveTargetURI413 req.TargetURI = effectiveTargetURI
431 }414 }
432 if usedRegularFallback {415 if usedRegularFallback {
433- if openerBridge, ok := popupWV.(port.PopupOpenerBridgeCapable); ok {416+ if openerBridge, ok := popupWV.(port.PopupOpenerCapable); ok {
434 openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)417 openerBridge.EnablePopupOpenerBridge(parentWV, req.NoJavaScriptAccess)
435 }418 }
436 }419 }
@@ -440,29 +423,28 @@ func (c *Coordinator) handlePopupCreate(
440 popupID := popupWV.ID()423 popupID := popupWV.ID()
441 424
442 // Determine behavior from config425 // Determine behavior from config
443- behavior := GetBehavior(popupType, c.popupConfig)426+ behavior := GetBehavior(popupType, c.popups.popupConfig)
444 placement := "right"427 placement := "right"
445- if c.popupConfig != nil {428+ if c.popups.popupConfig != nil {
446- placement = c.popupConfig.Placement429+ placement = c.popups.popupConfig.Placement
447 }430 }
448 431
449 // Create popup pane entity432 // Create popup pane entity
450 paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)433 paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
451 434
452- return c.finishPopupCreate(435+ return c.finishPopupCreate(ctx, popupCreateContext{
453- ctx,436+ ParentPaneID: parentPaneID,
454- parentPaneID,437+ ParentWebViewID: parentID,
455- parentID,438+ ParentURIAtOpen: parentURIAtOpen,
456- parentURIAtOpen,439+ PopupID: popupID,
457- popupID,440+ PopupWebView: popupWV,
458- popupWV,441+ PopupPane: popupPane,
459- popupPane,442+ PopupPaneID: paneID,
460- paneID,443+ PopupType: popupType,
461- popupType,444+ Behavior: behavior,
462- behavior,445+ Placement: placement,
463- placement,446+ Request: req,
464- req,447+ })
465- )
466 }448 }
467 449
468 func (c *Coordinator) reuseNamedPopup(450 func (c *Coordinator) reuseNamedPopup(
@@ -491,142 +473,129 @@ func (c *Coordinator) reuseNamedPopup(
491 return nil, false473 return nil, false
492 }474 }
493 475
494-func (c *Coordinator) finishPopupCreate(476+func (c *Coordinator) finishPopupCreate(ctx context.Context, create popupCreateContext) port.WebView {
495- ctx context.Context,
496- parentPaneID entity.PaneID,
497- parentID port.WebViewID,
498- parentURIAtOpen string,
499- popupID port.WebViewID,
500- popupWV port.WebView,
501- popupPane *entity.Pane,
502- paneID entity.PaneID,
503- popupType PopupType,
504- behavior entity.PopupBehavior,
505- placement string,
506- req port.PopupRequest,
507-) port.WebView {
508 log := logging.FromContext(ctx)477 log := logging.FromContext(ctx)
509- hasConfig := c.popupConfig != nil478+ hasConfig := c.popups.popupConfig != nil
510- oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose479+ oauthEnabled := hasConfig && c.popups.popupConfig.OAuthAutoClose
511- isOAuth := IsOAuthURL(req.TargetURI)480+ isOAuth := IsOAuthURL(create.Request.TargetURI)
512 481
513 log.Debug().482 log.Debug().
514 Bool("has_config", hasConfig).483 Bool("has_config", hasConfig).
515 Bool("oauth_enabled", oauthEnabled).484 Bool("oauth_enabled", oauthEnabled).
516 Bool("is_oauth", isOAuth).485 Bool("is_oauth", isOAuth).
517- Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).486+ Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
518 Msg("popup OAuth check")487 Msg("popup OAuth check")
519 488
520 // Initialize callbacks before publishing the WebView so any reader that sees489 // Initialize callbacks before publishing the WebView so any reader that sees
521 // the popup during workspace insertion gets a fully configured instance.490 // the popup during workspace insertion gets a fully configured instance.
522- c.setupWebViewCallbacks(ctx, paneID, popupWV)491+ c.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
523 492
524 // Register the popup WebView before workspace insertion so split/stack UI493 // Register the popup WebView before workspace insertion so split/stack UI
525 // updates can reuse the real popup instead of acquiring a placeholder pane494 // updates can reuse the real popup instead of acquiring a placeholder pane
526 // WebView that races the actual popup flow.495 // WebView that races the actual popup flow.
527- c.RegisterPopupWebView(paneID, popupWV)496+ c.RegisterPopupWebView(create.PopupPaneID, create.PopupWebView)
528 inserted := false497 inserted := false
529 defer func() {498 defer func() {
530 if inserted {499 if inserted {
531 return500 return
532 }501 }
533- if current := c.getWebViewLocked(paneID); current == popupWV {502+ if current := c.getWebViewLocked(create.PopupPaneID); current == create.PopupWebView {
534- c.deleteWebViewLocked(paneID)503+ c.deleteWebViewLocked(create.PopupPaneID)
535 }504 }
536 }()505 }()
537 506
538 // Insert into workspace IMMEDIATELY (WebView stays hidden)507 // Insert into workspace IMMEDIATELY (WebView stays hidden)
539- // This is required for WebKit to establish window.opener relationship508+ // This is required for WebKit to establish window.opener relationship.
540- if c.onInsertPopup != nil {509+ if c.popups.onInsertPopup != nil {
541 popupInput := InsertPopupInput{510 popupInput := InsertPopupInput{
542- ParentPaneID: parentPaneID,511+ ParentPaneID: create.ParentPaneID,
543- PopupPane: popupPane,512+ PopupPane: create.PopupPane,
544- WebView: popupWV,513+ WebView: create.PopupWebView,
545- Behavior: behavior,514+ Behavior: create.Behavior,
546- Placement: placement,515+ Placement: create.Placement,
547- PopupType: popupType,516+ PopupType: create.PopupType,
548- TargetURI: req.TargetURI,517+ TargetURI: create.Request.TargetURI,
549 }518 }
550 519
551- if err := c.onInsertPopup(ctx, popupInput); err != nil {520+ if err := c.popups.onInsertPopup(ctx, popupInput); err != nil {
552 log.Error().Err(err).Msg("failed to insert popup into workspace")521 log.Error().Err(err).Msg("failed to insert popup into workspace")
553- popupWV.Destroy()522+ create.PopupWebView.Destroy()
554 return nil523 return nil
555 }524 }
556 }525 }
557 inserted = true526 inserted = true
558 527
559- if primable, ok := popupWV.(port.PopupNavigationCapable); ok {528+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
560- primable.PrimePopupNavigation(req.TargetURI)529+ lifecycle.PrimePopupNavigation(create.Request.TargetURI)
561 }530 }
562 531
563- if !req.NoJavaScriptAccess {532+ if !create.Request.NoJavaScriptAccess {
564- c.storeReusableNamedPopup(parentPaneID, req.FrameName, popupWV)533+ c.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupWebView)
565 }534 }
566 535
567 // Engines without native popup lifecycle hooks can still support536 // Engines without native popup lifecycle hooks can still support
568 // programmatic popup closure (proxy.close(), OAuth auto-close) by exposing537 // programmatic popup closure (proxy.close(), OAuth auto-close) by exposing
569 // the optional OAuthCallbackCapable interface.538 // the optional OAuthCallbackCapable interface.
570- if _, hasNativePopupLifecycle := popupWV.(port.PopupCapable); !hasNativePopupLifecycle {539+ if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
571- if closeCapable, ok := popupWV.(port.OAuthCallbackCapable); ok {540+ if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
572 closeCapable.AddCloseCallback(func() {541 closeCapable.AddCloseCallback(func() {
573- c.handlePopupClose(ctx, popupID)542+ c.handlePopupClose(ctx, create.PopupID)
574 })543 })
575 }544 }
576 }545 }
577 546
578- // Setup OAuth auto-close if configured547+ // Setup OAuth auto-close if configured.
579 if hasConfig && oauthEnabled && isOAuth {548 if hasConfig && oauthEnabled && isOAuth {
580- popupPane.AutoClose = true549+ create.PopupPane.AutoClose = true
581- c.trackOAuthPopup(popupID, parentPaneID, parentURIAtOpen)550+ c.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
582- c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)551+ c.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
583- log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")552+ log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
584 }553 }
585 554
586- // Store pending popup for ready-to-show handling (just visibility now)555+ // Store pending popup for ready-to-show handling (just visibility now).
587 pending := &PendingPopup{556 pending := &PendingPopup{
588- PaneID: paneID,557+ PaneID: create.PopupPaneID,
589- WebView: popupWV,558+ WebView: create.PopupWebView,
590- ParentPaneID: parentPaneID,559+ ParentPaneID: create.ParentPaneID,
591- ParentWebViewID: parentID,560+ ParentWebViewID: create.ParentWebViewID,
592- TargetURI: req.TargetURI,561+ TargetURI: create.Request.TargetURI,
593- FrameName: req.FrameName,562+ FrameName: create.Request.FrameName,
594- IsUserGesture: req.IsUserGesture,563+ IsUserGesture: create.Request.IsUserGesture,
595- PopupType: popupType,564+ PopupType: create.PopupType,
596 CreatedAt: time.Now(),565 CreatedAt: time.Now(),
597 }566 }
598 567
599- c.popupMu.Lock()568+ c.popups.mu.Lock()
600- c.pendingPopups[popupID] = pending569+ c.popups.pendingPopups[create.PopupID] = pending
601- c.popupMu.Unlock()570+ c.popups.mu.Unlock()
602 571
603- // Wire ready-to-show and close signals via the PopupCapable interface.572+ // Wire ready-to-show and close signals via the popup lifecycle interface.
604- if pc, ok := popupWV.(port.PopupCapable); ok {573+ if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
605- pc.SetOnReadyToShow(func() {574+ lifecycle.SetOnReadyToShow(func() {
606- c.handlePopupReadyToShow(ctx, popupID)575+ c.handlePopupReadyToShow(ctx, create.PopupID)
607 })576 })
608- pc.SetOnClose(func() {577+ lifecycle.SetOnClose(func() {
609- c.handlePopupClose(ctx, popupID)578+ c.handlePopupClose(ctx, create.PopupID)
610 })579 })
611 log.Info().580 log.Info().
612- Uint64("popup_id", uint64(popupID)).581+ Uint64("popup_id", uint64(create.PopupID)).
613- Str("pane_id", string(paneID)).582+ Str("pane_id", string(create.PopupPaneID)).
614- Str("popup_type", popupType.String()).583+ Str("popup_type", create.PopupType.String()).
615- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).584+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
616 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")585 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
617 } else {586 } else {
618 // Engine does not expose popup lifecycle callbacks. Treat the inserted587 // Engine does not expose popup lifecycle callbacks. Treat the inserted
619 // WebView as ready immediately and continue with the same coordinator flow.588 // WebView as ready immediately and continue with the same coordinator flow.
620 log.Info().589 log.Info().
621- Uint64("popup_id", uint64(popupID)).590+ Uint64("popup_id", uint64(create.PopupID)).
622- Str("pane_id", string(paneID)).591+ Str("pane_id", string(create.PopupPaneID)).
623- Str("popup_type", popupType.String()).592+ Str("popup_type", create.PopupType.String()).
624- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).593+ Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
625- Msg("popup inserted, immediately ready (no PopupCapable)")594+ Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
626- c.handlePopupReadyToShow(ctx, popupID)595+ c.handlePopupReadyToShow(ctx, create.PopupID)
627 }596 }
628 597
629- return popupWV598+ return create.PopupWebView
630 }599 }
631 600
632 // handlePopupReadyToShow handles the WebKit "ready-to-show" signal.601 // handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
@@ -636,12 +605,12 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
636 log := logging.FromContext(ctx)605 log := logging.FromContext(ctx)
637 606
638 // Get pending popup607 // Get pending popup
639- c.popupMu.Lock()608+ c.popups.mu.Lock()
640- pending, ok := c.pendingPopups[popupID]609+ pending, ok := c.popups.pendingPopups[popupID]
641 if ok {610 if ok {
642- delete(c.pendingPopups, popupID)611+ delete(c.popups.pendingPopups, popupID)
643 }612 }
644- c.popupMu.Unlock()613+ c.popups.mu.Unlock()
645 614
646 if !ok || pending == nil {615 if !ok || pending == nil {
647 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")616 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
@@ -655,10 +624,10 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
655 624
656 // Make the WebView visible now that it's ready.625 // Make the WebView visible now that it's ready.
657 if pending.WebView != nil {626 if pending.WebView != nil {
658- if pc, ok := pending.WebView.(port.PopupCapable); ok {627+ if lifecycle, ok := pending.WebView.(port.PopupLifecycleCapable); ok {
659- pc.Show()628+ lifecycle.Show()
660 }629 }
661- _, preloadsNavigation := pending.WebView.(port.PopupNavigationCapable)630+ _, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
662 // Engines that do not preload popup navigation can request the target URI631 // Engines that do not preload popup navigation can request the target URI
663 // here once the popup becomes visible.632 // here once the popup becomes visible.
664 if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {633 if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
@@ -682,17 +651,17 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
682 log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")651 log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
683 652
684 // Check if still pending (never shown)653 // Check if still pending (never shown)
685- c.popupMu.Lock()654+ c.popups.mu.Lock()
686- pending, wasPending := c.pendingPopups[popupID]655+ pending, wasPending := c.popups.pendingPopups[popupID]
687 if wasPending {656 if wasPending {
688- delete(c.pendingPopups, popupID)657+ delete(c.popups.pendingPopups, popupID)
689 }658 }
690- c.popupMu.Unlock()659+ c.popups.mu.Unlock()
691 660
692 if wasPending && pending != nil {661 if wasPending && pending != nil {
693 c.handlePopupOAuthClose(ctx, popupID)662 c.handlePopupOAuthClose(ctx, popupID)
694- if c.onClosePane != nil {663+ if c.popups.onClosePane != nil {
695- if err := c.onClosePane(ctx, pending.PaneID); err != nil {664+ if err := c.popups.onClosePane(ctx, pending.PaneID); err != nil {
696 log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")665 log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
697 }666 }
698 }667 }
@@ -727,8 +696,8 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
727 c.handlePopupOAuthClose(ctx, popupID)696 c.handlePopupOAuthClose(ctx, popupID)
728 697
729 // Close the pane in workspace (this removes the UI element)698 // Close the pane in workspace (this removes the UI element)
730- if c.onClosePane != nil {699+ if c.popups.onClosePane != nil {
731- if err := c.onClosePane(ctx, paneID); err != nil {700+ if err := c.popups.onClosePane(ctx, paneID); err != nil {
732 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")701 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
733 }702 }
734 }703 }
@@ -755,19 +724,19 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
755 Msg("middle-click/ctrl+click on link")724 Msg("middle-click/ctrl+click on link")
756 725
757 // Check if popups are enabled726 // Check if popups are enabled
758- if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {727+ if c.popups.popupConfig != nil && !c.popups.popupConfig.OpenInNewPane {
759 log.Debug().Msg("popups disabled by config, ignoring middle-click")728 log.Debug().Msg("popups disabled by config, ignoring middle-click")
760 return false729 return false
761 }730 }
762 731
763 // Check if factory is available732 // Check if factory is available
764- if c.factory == nil {733+ if c.popups.factory == nil {
765 log.Warn().Msg("no webview factory, cannot handle middle-click")734 log.Warn().Msg("no webview factory, cannot handle middle-click")
766 return false735 return false
767 }736 }
768 737
769 // Create a new WebView (regular, not related - just opening a link)738 // Create a new WebView (regular, not related - just opening a link)
770- newWV, err := c.factory.Create(ctx)739+ newWV, err := c.popups.factory.Create(ctx)
771 if err != nil {740 if err != nil {
772 log.Error().Err(err).Msg("failed to create webview for middle-click")741 log.Error().Err(err).Msg("failed to create webview for middle-click")
773 return false742 return false
@@ -775,8 +744,8 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
775 744
776 // Generate pane ID745 // Generate pane ID
777 var paneID entity.PaneID746 var paneID entity.PaneID
778- if c.generateID != nil {747+ if c.popups.generatePaneID != nil {
779- paneID = entity.PaneID(c.generateID())748+ paneID = entity.PaneID(c.popups.generatePaneID())
780 } else {749 } else {
781 paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))750 paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
782 }751 }
@@ -794,16 +763,16 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
794 763
795 // Get behavior from config (same as _blank links)764 // Get behavior from config (same as _blank links)
796 behavior := entity.PopupBehaviorStacked // default765 behavior := entity.PopupBehaviorStacked // default
797- if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {766+ if c.popups.popupConfig != nil && c.popups.popupConfig.BlankTargetBehavior != "" {
798- behavior = entity.PopupBehavior(c.popupConfig.BlankTargetBehavior)767+ behavior = entity.PopupBehavior(c.popups.popupConfig.BlankTargetBehavior)
799 }768 }
800 placement := "right"769 placement := "right"
801- if c.popupConfig != nil {770+ if c.popups.popupConfig != nil {
802- placement = c.popupConfig.Placement771+ placement = c.popups.popupConfig.Placement
803 }772 }
804 773
805 // Request insertion into workspace774 // Request insertion into workspace
806- if c.onInsertPopup != nil {775+ if c.popups.onInsertPopup != nil {
807 popupInput := InsertPopupInput{776 popupInput := InsertPopupInput{
808 ParentPaneID: parentPaneID,777 ParentPaneID: parentPaneID,
809 PopupPane: newPane,778 PopupPane: newPane,
@@ -814,7 +783,7 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
814 TargetURI: uri,783 TargetURI: uri,
815 }784 }
816 785
817- if err := c.onInsertPopup(ctx, popupInput); err != nil {786+ if err := c.popups.onInsertPopup(ctx, popupInput); err != nil {
818 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")787 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
819 // Clean up on failure — use the locking helper to avoid a data race.788 // Clean up on failure — use the locking helper to avoid a data race.
820 c.deleteWebViewLocked(paneID)789 c.deleteWebViewLocked(paneID)
A internal/ui/coordinator/content/popup_manager.go +130 -0
@@ -0,0 +1,130 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+// popupManager owns popup-specific coordinator state. It stays in the UI
14+// adapter layer so popup pane bookkeeping and workspace orchestration do not
15+// leak into the application/usecase or domain layers.
16+type popupManager struct {
17+ factory port.WebViewFactory
18+ popupConfig *entity.PopupBehaviorConfig
19+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
20+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
21+ generatePaneID func() string
22+ pendingPopups map[port.WebViewID]*PendingPopup
23+ namedPopups map[namedPopupKey]*namedPopupState
24+ popupOAuth map[port.WebViewID]*popupOAuthState
25+ popupRefresh map[entity.PaneID]*time.Timer
26+ relatedPopupUnsupported bool
27+ relatedPopupSupportDetected bool
28+ mu sync.RWMutex
29+}
30+
31+type popupOAuthState struct {
32+ ParentPaneID entity.PaneID
33+ ParentURIAtOpen string
34+ CallbackURI string
35+ Success bool
36+ Error bool
37+ Seen bool
38+}
39+
40+type popupCreateContext struct {
41+ ParentPaneID entity.PaneID
42+ ParentWebViewID port.WebViewID
43+ ParentURIAtOpen string
44+ PopupID port.WebViewID
45+ PopupWebView port.WebView
46+ PopupPane *entity.Pane
47+ PopupPaneID entity.PaneID
48+ PopupType PopupType
49+ Behavior entity.PopupBehavior
50+ Placement string
51+ Request port.PopupRequest
52+}
53+
54+func newPopupManager() *popupManager {
55+ pm := &popupManager{}
56+ pm.ensureInitialized()
57+ return pm
58+}
59+
60+func (pm *popupManager) ensureInitialized() {
61+ if pm == nil {
62+ return
63+ }
64+ if pm.pendingPopups == nil {
65+ pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
66+ }
67+ if pm.namedPopups == nil {
68+ pm.namedPopups = make(map[namedPopupKey]*namedPopupState)
69+ }
70+ if pm.popupOAuth == nil {
71+ pm.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
72+ }
73+ if pm.popupRefresh == nil {
74+ pm.popupRefresh = make(map[entity.PaneID]*time.Timer)
75+ }
76+}
77+
78+func (pm *popupManager) setConfig(
79+ factory port.WebViewFactory,
80+ popupConfig *entity.PopupBehaviorConfig,
81+ generateID func() string,
82+) {
83+ if pm == nil {
84+ return
85+ }
86+ pm.ensureInitialized()
87+ pm.factory = factory
88+ pm.popupConfig = popupConfig
89+ pm.generatePaneID = generateID
90+
91+ pm.mu.Lock()
92+ pm.relatedPopupUnsupported = false
93+ pm.relatedPopupSupportDetected = false
94+ pm.mu.Unlock()
95+}
96+
97+func (pm *popupManager) setOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
98+ if pm == nil {
99+ return
100+ }
101+ pm.onInsertPopup = fn
102+}
103+
104+func (pm *popupManager) setOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
105+ if pm == nil {
106+ return
107+ }
108+ pm.onClosePane = fn
109+}
110+
111+func (pm *popupManager) createPopupPane(
112+ popupID port.WebViewID,
113+ parentPaneID entity.PaneID,
114+ targetURI string,
115+) (entity.PaneID, *entity.Pane) {
116+ var paneID entity.PaneID
117+ if pm != nil && pm.generatePaneID != nil {
118+ paneID = entity.PaneID(pm.generatePaneID())
119+ } else {
120+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
121+ }
122+
123+ popupPane := entity.NewPane(paneID)
124+ popupPane.WindowType = entity.WindowPopup
125+ popupPane.IsRelated = true
126+ popupPane.ParentPaneID = &parentPaneID
127+ popupPane.URI = targetURI
128+
129+ return paneID, popupPane
130+}
@@ -0,0 +1,130 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+// popupManager owns popup-specific coordinator state. It stays in the UI
14+// adapter layer so popup pane bookkeeping and workspace orchestration do not
15+// leak into the application/usecase or domain layers.
16+type popupManager struct {
17+ factory port.WebViewFactory
18+ popupConfig *entity.PopupBehaviorConfig
19+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
20+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
21+ generatePaneID func() string
22+ pendingPopups map[port.WebViewID]*PendingPopup
23+ namedPopups map[namedPopupKey]*namedPopupState
24+ popupOAuth map[port.WebViewID]*popupOAuthState
25+ popupRefresh map[entity.PaneID]*time.Timer
26+ relatedPopupUnsupported bool
27+ relatedPopupSupportDetected bool
28+ mu sync.RWMutex
29+}
30+
31+type popupOAuthState struct {
32+ ParentPaneID entity.PaneID
33+ ParentURIAtOpen string
34+ CallbackURI string
35+ Success bool
36+ Error bool
37+ Seen bool
38+}
39+
40+type popupCreateContext struct {
41+ ParentPaneID entity.PaneID
42+ ParentWebViewID port.WebViewID
43+ ParentURIAtOpen string
44+ PopupID port.WebViewID
45+ PopupWebView port.WebView
46+ PopupPane *entity.Pane
47+ PopupPaneID entity.PaneID
48+ PopupType PopupType
49+ Behavior entity.PopupBehavior
50+ Placement string
51+ Request port.PopupRequest
52+}
53+
54+func newPopupManager() *popupManager {
55+ pm := &popupManager{}
56+ pm.ensureInitialized()
57+ return pm
58+}
59+
60+func (pm *popupManager) ensureInitialized() {
61+ if pm == nil {
62+ return
63+ }
64+ if pm.pendingPopups == nil {
65+ pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
66+ }
67+ if pm.namedPopups == nil {
68+ pm.namedPopups = make(map[namedPopupKey]*namedPopupState)
69+ }
70+ if pm.popupOAuth == nil {
71+ pm.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
72+ }
73+ if pm.popupRefresh == nil {
74+ pm.popupRefresh = make(map[entity.PaneID]*time.Timer)
75+ }
76+}
77+
78+func (pm *popupManager) setConfig(
79+ factory port.WebViewFactory,
80+ popupConfig *entity.PopupBehaviorConfig,
81+ generateID func() string,
82+) {
83+ if pm == nil {
84+ return
85+ }
86+ pm.ensureInitialized()
87+ pm.factory = factory
88+ pm.popupConfig = popupConfig
89+ pm.generatePaneID = generateID
90+
91+ pm.mu.Lock()
92+ pm.relatedPopupUnsupported = false
93+ pm.relatedPopupSupportDetected = false
94+ pm.mu.Unlock()
95+}
96+
97+func (pm *popupManager) setOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
98+ if pm == nil {
99+ return
100+ }
101+ pm.onInsertPopup = fn
102+}
103+
104+func (pm *popupManager) setOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
105+ if pm == nil {
106+ return
107+ }
108+ pm.onClosePane = fn
109+}
110+
111+func (pm *popupManager) createPopupPane(
112+ popupID port.WebViewID,
113+ parentPaneID entity.PaneID,
114+ targetURI string,
115+) (entity.PaneID, *entity.Pane) {
116+ var paneID entity.PaneID
117+ if pm != nil && pm.generatePaneID != nil {
118+ paneID = entity.PaneID(pm.generatePaneID())
119+ } else {
120+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
121+ }
122+
123+ popupPane := entity.NewPane(paneID)
124+ popupPane.WindowType = entity.WindowPopup
125+ popupPane.IsRelated = true
126+ popupPane.ParentPaneID = &parentPaneID
127+ popupPane.URI = targetURI
128+
129+ return paneID, popupPane
130+}
M internal/ui/coordinator/content/popup_test.go +73 -72
@@ -142,9 +142,8 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
142142 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
143143
144144 c := &Coordinator{
145- webViews: make(map[entity.PaneID]port.WebView),
146- pendingPopups: make(map[port.WebViewID]*PendingPopup),
147- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
145+ webViews: make(map[entity.PaneID]port.WebView),
146+ popups: newPopupManager(),
148147 }
149148 c.SetPopupConfig(factory, nil, func() string { return "popup-pane" })
150149
@@ -166,9 +165,8 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
166165 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
167166
168167 c := &Coordinator{
169- webViews: make(map[entity.PaneID]port.WebView),
170- pendingPopups: make(map[port.WebViewID]*PendingPopup),
171- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
168+ webViews: make(map[entity.PaneID]port.WebView),
169+ popups: newPopupManager(),
172170 }
173171 c.SetPopupConfig(factory, nil, nil)
174172 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -250,9 +248,8 @@ func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t
250248 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
251249
252250 c := &Coordinator{
253- webViews: make(map[entity.PaneID]port.WebView),
254- pendingPopups: make(map[port.WebViewID]*PendingPopup),
255- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
251+ webViews: make(map[entity.PaneID]port.WebView),
252+ popups: newPopupManager(),
256253 }
257254 c.SetPopupConfig(factory, nil, nil)
258255 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -298,9 +295,8 @@ func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *tes
298295 factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()
299296
300297 c := &Coordinator{
301- webViews: make(map[entity.PaneID]port.WebView),
302- pendingPopups: make(map[port.WebViewID]*PendingPopup),
303- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
298+ webViews: make(map[entity.PaneID]port.WebView),
299+ popups: newPopupManager(),
304300 }
305301 c.SetPopupConfig(factory, nil, nil)
306302 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -342,9 +338,8 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupport
342338
343339 insertCalls := 0
344340 c := &Coordinator{
345- webViews: make(map[entity.PaneID]port.WebView),
346- pendingPopups: make(map[port.WebViewID]*PendingPopup),
347- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
341+ webViews: make(map[entity.PaneID]port.WebView),
342+ popups: newPopupManager(),
348343 }
349344 c.SetPopupConfig(factory, nil, nil)
350345 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -385,9 +380,8 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFailsUnexpe
385380
386381 insertCalls := 0
387382 c := &Coordinator{
388- webViews: make(map[entity.PaneID]port.WebView),
389- pendingPopups: make(map[port.WebViewID]*PendingPopup),
390- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
383+ webViews: make(map[entity.PaneID]port.WebView),
384+ popups: newPopupManager(),
391385 }
392386 c.SetPopupConfig(factory, nil, nil)
393387 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -426,9 +420,8 @@ func TestHandlePopupCreate_PreservesOriginalTargetWhenRelatedCreateIsUnsupported
426420
427421 insertCalls := 0
428422 c := &Coordinator{
429- webViews: make(map[entity.PaneID]port.WebView),
430- pendingPopups: make(map[port.WebViewID]*PendingPopup),
431- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
423+ webViews: make(map[entity.PaneID]port.WebView),
424+ popups: newPopupManager(),
432425 }
433426 c.SetPopupConfig(factory, nil, nil)
434427 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -477,9 +470,8 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
477470
478471 insertCalls := 0
479472 c := &Coordinator{
480- webViews: make(map[entity.PaneID]port.WebView),
481- pendingPopups: make(map[port.WebViewID]*PendingPopup),
482- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
473+ webViews: make(map[entity.PaneID]port.WebView),
474+ popups: newPopupManager(),
483475 }
484476 c.SetPopupConfig(factory, nil, nil)
485477 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
@@ -513,8 +505,8 @@ func TestTrackOAuthPopup_StoresState(t *testing.T) {
513505 t.Parallel()
514506
515507 c := &Coordinator{
516- webViews: make(map[entity.PaneID]port.WebView),
517- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
508+ webViews: make(map[entity.PaneID]port.WebView),
509+ popups: newPopupManager(),
518510 }
519511
520512 popupID := port.WebViewID(1)
@@ -522,9 +514,9 @@ func TestTrackOAuthPopup_StoresState(t *testing.T) {
522514
523515 c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")
524516
525- c.popupMu.RLock()
526- state, ok := c.popupOAuth[popupID]
527- c.popupMu.RUnlock()
517+ c.popups.mu.RLock()
518+ state, ok := c.popups.popupOAuth[popupID]
519+ c.popups.mu.RUnlock()
528520
529521 require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
530522 require.NotNil(t, state)
@@ -537,17 +529,17 @@ func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
537529 t.Parallel()
538530
539531 c := &Coordinator{
540- webViews: make(map[entity.PaneID]port.WebView),
541- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
532+ webViews: make(map[entity.PaneID]port.WebView),
533+ popups: newPopupManager(),
542534 }
543535
544536 popupID := port.WebViewID(2)
545537 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
546538 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
547539
548- c.popupMu.RLock()
549- state := c.popupOAuth[popupID]
550- c.popupMu.RUnlock()
540+ c.popups.mu.RLock()
541+ state := c.popups.popupOAuth[popupID]
542+ c.popups.mu.RUnlock()
551543
552544 require.NotNil(t, state)
553545 assert.True(t, state.Seen)
@@ -560,17 +552,17 @@ func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
560552 t.Parallel()
561553
562554 c := &Coordinator{
563- webViews: make(map[entity.PaneID]port.WebView),
564- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
555+ webViews: make(map[entity.PaneID]port.WebView),
556+ popups: newPopupManager(),
565557 }
566558
567559 popupID := port.WebViewID(3)
568560 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
569561 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
570562
571- c.popupMu.RLock()
572- state := c.popupOAuth[popupID]
573- c.popupMu.RUnlock()
563+ c.popups.mu.RLock()
564+ state := c.popups.popupOAuth[popupID]
565+ c.popups.mu.RUnlock()
574566
575567 require.NotNil(t, state)
576568 assert.True(t, state.Seen)
@@ -582,16 +574,16 @@ func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
582574 t.Parallel()
583575
584576 c := &Coordinator{
585- webViews: make(map[entity.PaneID]port.WebView),
586- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
577+ webViews: make(map[entity.PaneID]port.WebView),
578+ popups: newPopupManager(),
587579 }
588580
589581 // Should not panic when no tracking entry exists.
590582 c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
591583
592- c.popupMu.RLock()
593- _, ok := c.popupOAuth[port.WebViewID(999)]
594- c.popupMu.RUnlock()
584+ c.popups.mu.RLock()
585+ _, ok := c.popups.popupOAuth[port.WebViewID(999)]
586+ c.popups.mu.RUnlock()
595587
596588 assert.False(t, ok)
597589 }
@@ -611,10 +603,10 @@ func TestSetPopupConfig_SetsFields(t *testing.T) {
611603
612604 c.SetPopupConfig(factory, cfg, genID)
613605
614- assert.Equal(t, factory, c.factory)
615- assert.Equal(t, cfg, c.popupConfig)
616- assert.NotNil(t, c.generateID)
617- assert.Equal(t, "test-id", c.generateID())
606+ assert.Equal(t, factory, c.popups.factory)
607+ assert.Equal(t, cfg, c.popups.popupConfig)
608+ assert.NotNil(t, c.popups.generatePaneID)
609+ assert.Equal(t, "test-id", c.popups.generatePaneID())
618610 }
619611
620612 func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
@@ -623,9 +615,9 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
623615 c := &Coordinator{}
624616 c.SetPopupConfig(nil, nil, nil)
625617
626- assert.Nil(t, c.factory)
627- assert.Nil(t, c.popupConfig)
628- assert.Nil(t, c.generateID)
618+ assert.Nil(t, c.popups.factory)
619+ assert.Nil(t, c.popups.popupConfig)
620+ assert.Nil(t, c.popups.generatePaneID)
629621 }
630622
631623 type popupNavigationWebViewStub struct {
@@ -637,6 +629,10 @@ func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
637629 s.primed = append(s.primed, uri)
638630 }
639631
632+func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
633+func (*popupNavigationWebViewStub) SetOnClose(func()) {}
634+func (*popupNavigationWebViewStub) Show() {}
635+
640636 type popupOpenerBridgeWebViewStub struct {
641637 *mocks.MockWebView
642638 parent port.WebView
@@ -648,6 +644,11 @@ func (s *popupOpenerBridgeWebViewStub) EnablePopupOpenerBridge(parent port.WebVi
648644 s.noJavaScriptAccess = noJavaScriptAccess
649645 }
650646
647+func (*popupOpenerBridgeWebViewStub) AddOpenerMessageCallback(func()) {}
648+func (*popupOpenerBridgeWebViewStub) AddOpenerNavigationCallback(func(string)) {
649+}
650+func (*popupOpenerBridgeWebViewStub) HasActivePopupOpenerBridge() bool { return false }
651+
651652 // stubFactory satisfies port.WebViewFactory without importing the mocks package.
652653 type stubFactory struct{}
653654
@@ -667,7 +668,7 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
667668 t.Parallel()
668669
669670 c := &Coordinator{
670- pendingPopups: make(map[port.WebViewID]*PendingPopup),
671+ popups: newPopupManager(),
671672 }
672673
673674 const workers = 20
@@ -679,12 +680,12 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
679680 go func(id int) {
680681 defer wg.Done()
681682 popupID := port.WebViewID(id)
682- c.popupMu.Lock()
683- c.pendingPopups[popupID] = &PendingPopup{
683+ c.popups.mu.Lock()
684+ c.popups.pendingPopups[popupID] = &PendingPopup{
684685 FrameName: "_blank",
685686 PopupType: PopupTypeTab,
686687 }
687- c.popupMu.Unlock()
688+ c.popups.mu.Unlock()
688689 }(i)
689690 }
690691
@@ -693,17 +694,17 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
693694 go func(id int) {
694695 defer wg.Done()
695696 popupID := port.WebViewID(id)
696- c.popupMu.RLock()
697- _ = c.pendingPopups[popupID]
698- c.popupMu.RUnlock()
697+ c.popups.mu.RLock()
698+ _ = c.popups.pendingPopups[popupID]
699+ c.popups.mu.RUnlock()
699700 }(i)
700701 }
701702
702703 wg.Wait()
703704
704- c.popupMu.RLock()
705- count := len(c.pendingPopups)
706- c.popupMu.RUnlock()
705+ c.popups.mu.RLock()
706+ count := len(c.popups.pendingPopups)
707+ c.popups.mu.RUnlock()
707708
708709 assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
709710 }
@@ -712,12 +713,12 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
712713 t.Parallel()
713714
714715 c := &Coordinator{
715- pendingPopups: make(map[port.WebViewID]*PendingPopup),
716+ popups: newPopupManager(),
716717 }
717718
718719 // Pre-populate
719720 for i := 0; i < 10; i++ {
720- c.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
721+ c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
721722 }
722723
723724 var wg sync.WaitGroup
@@ -727,9 +728,9 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
727728 for i := 0; i < 10; i++ {
728729 go func(id int) {
729730 defer wg.Done()
730- c.popupMu.Lock()
731- delete(c.pendingPopups, port.WebViewID(id))
732- c.popupMu.Unlock()
731+ c.popups.mu.Lock()
732+ delete(c.popups.pendingPopups, port.WebViewID(id))
733+ c.popups.mu.Unlock()
733734 }(i)
734735 }
735736
@@ -737,15 +738,15 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
737738 for i := 0; i < 10; i++ {
738739 go func(id int) {
739740 defer wg.Done()
740- c.popupMu.RLock()
741- _ = c.pendingPopups[port.WebViewID(id)]
742- c.popupMu.RUnlock()
741+ c.popups.mu.RLock()
742+ _ = c.popups.pendingPopups[port.WebViewID(id)]
743+ c.popups.mu.RUnlock()
743744 }(i)
744745 }
745746
746747 wg.Wait()
747748
748- c.popupMu.RLock()
749- defer c.popupMu.RUnlock()
750- assert.Empty(t, c.pendingPopups, "all preloaded popups should have been deleted")
749+ c.popups.mu.RLock()
750+ defer c.popups.mu.RUnlock()
751+ assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
751752 }
@@ -142,9 +142,8 @@ func newPopupCreateCoordinatorForTest(t *testing.T, popupID port.WebViewID) (con
142 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()142 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
143 143
144 c := &Coordinator{144 c := &Coordinator{
145- webViews: make(map[entity.PaneID]port.WebView),145+ webViews: make(map[entity.PaneID]port.WebView),
146- pendingPopups: make(map[port.WebViewID]*PendingPopup),146+ popups: newPopupManager(),
147- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
148 }147 }
149 c.SetPopupConfig(factory, nil, func() string { return "popup-pane" })148 c.SetPopupConfig(factory, nil, func() string { return "popup-pane" })
150 149
@@ -166,9 +165,8 @@ func TestHandlePopupCreate_PrimesPopupNavigationCapability(t *testing.T) {
166 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()165 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
167 166
168 c := &Coordinator{167 c := &Coordinator{
169- webViews: make(map[entity.PaneID]port.WebView),168+ webViews: make(map[entity.PaneID]port.WebView),
170- pendingPopups: make(map[port.WebViewID]*PendingPopup),169+ popups: newPopupManager(),
171- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
172 }170 }
173 c.SetPopupConfig(factory, nil, nil)171 c.SetPopupConfig(factory, nil, nil)
174 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {172 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -250,9 +248,8 @@ func TestHandlePopupCreate_UsesRegularWebViewWhenPopupDisablesJavaScriptAccess(t
250 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()248 factory.EXPECT().Create(mock.Anything).Return(popupWV, nil).Once()
251 249
252 c := &Coordinator{250 c := &Coordinator{
253- webViews: make(map[entity.PaneID]port.WebView),251+ webViews: make(map[entity.PaneID]port.WebView),
254- pendingPopups: make(map[port.WebViewID]*PendingPopup),252+ popups: newPopupManager(),
255- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
256 }253 }
257 c.SetPopupConfig(factory, nil, nil)254 c.SetPopupConfig(factory, nil, nil)
258 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {255 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -298,9 +295,8 @@ func TestHandlePopupCreate_SkipsRelatedCreateAfterUnsupportedFactoryError(t *tes
298 factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()295 factory.EXPECT().Create(mock.Anything).Return(secondPopupWV, nil).Once()
299 296
300 c := &Coordinator{297 c := &Coordinator{
301- webViews: make(map[entity.PaneID]port.WebView),298+ webViews: make(map[entity.PaneID]port.WebView),
302- pendingPopups: make(map[port.WebViewID]*PendingPopup),299+ popups: newPopupManager(),
303- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
304 }300 }
305 c.SetPopupConfig(factory, nil, nil)301 c.SetPopupConfig(factory, nil, nil)
306 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {302 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -342,9 +338,8 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateIsUnsupport
342 338
343 insertCalls := 0339 insertCalls := 0
344 c := &Coordinator{340 c := &Coordinator{
345- webViews: make(map[entity.PaneID]port.WebView),341+ webViews: make(map[entity.PaneID]port.WebView),
346- pendingPopups: make(map[port.WebViewID]*PendingPopup),342+ popups: newPopupManager(),
347- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
348 }343 }
349 c.SetPopupConfig(factory, nil, nil)344 c.SetPopupConfig(factory, nil, nil)
350 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {345 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -385,9 +380,8 @@ func TestHandlePopupCreate_FallsBackToRegularWebViewWhenRelatedCreateFailsUnexpe
385 380
386 insertCalls := 0381 insertCalls := 0
387 c := &Coordinator{382 c := &Coordinator{
388- webViews: make(map[entity.PaneID]port.WebView),383+ webViews: make(map[entity.PaneID]port.WebView),
389- pendingPopups: make(map[port.WebViewID]*PendingPopup),384+ popups: newPopupManager(),
390- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
391 }385 }
392 c.SetPopupConfig(factory, nil, nil)386 c.SetPopupConfig(factory, nil, nil)
393 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {387 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -426,9 +420,8 @@ func TestHandlePopupCreate_PreservesOriginalTargetWhenRelatedCreateIsUnsupported
426 420
427 insertCalls := 0421 insertCalls := 0
428 c := &Coordinator{422 c := &Coordinator{
429- webViews: make(map[entity.PaneID]port.WebView),423+ webViews: make(map[entity.PaneID]port.WebView),
430- pendingPopups: make(map[port.WebViewID]*PendingPopup),424+ popups: newPopupManager(),
431- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
432 }425 }
433 c.SetPopupConfig(factory, nil, nil)426 c.SetPopupConfig(factory, nil, nil)
434 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {427 c.SetOnInsertPopup(func(_ context.Context, input InsertPopupInput) error {
@@ -477,9 +470,8 @@ func TestHandlePopupCreate_ReusesNamedPopup(t *testing.T) {
477 470
478 insertCalls := 0471 insertCalls := 0
479 c := &Coordinator{472 c := &Coordinator{
480- webViews: make(map[entity.PaneID]port.WebView),473+ webViews: make(map[entity.PaneID]port.WebView),
481- pendingPopups: make(map[port.WebViewID]*PendingPopup),474+ popups: newPopupManager(),
482- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
483 }475 }
484 c.SetPopupConfig(factory, nil, nil)476 c.SetPopupConfig(factory, nil, nil)
485 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {477 c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error {
@@ -513,8 +505,8 @@ func TestTrackOAuthPopup_StoresState(t *testing.T) {
513 t.Parallel()505 t.Parallel()
514 506
515 c := &Coordinator{507 c := &Coordinator{
516- webViews: make(map[entity.PaneID]port.WebView),508+ webViews: make(map[entity.PaneID]port.WebView),
517- popupOAuth: make(map[port.WebViewID]*popupOAuthState),509+ popups: newPopupManager(),
518 }510 }
519 511
520 popupID := port.WebViewID(1)512 popupID := port.WebViewID(1)
@@ -522,9 +514,9 @@ func TestTrackOAuthPopup_StoresState(t *testing.T) {
522 514
523 c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")515 c.trackOAuthPopup(popupID, parentPaneID, "https://parent.example.com/login")
524 516
525- c.popupMu.RLock()517+ c.popups.mu.RLock()
526- state, ok := c.popupOAuth[popupID]518+ state, ok := c.popups.popupOAuth[popupID]
527- c.popupMu.RUnlock()519+ c.popups.mu.RUnlock()
528 520
529 require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")521 require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
530 require.NotNil(t, state)522 require.NotNil(t, state)
@@ -537,17 +529,17 @@ func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
537 t.Parallel()529 t.Parallel()
538 530
539 c := &Coordinator{531 c := &Coordinator{
540- webViews: make(map[entity.PaneID]port.WebView),532+ webViews: make(map[entity.PaneID]port.WebView),
541- popupOAuth: make(map[port.WebViewID]*popupOAuthState),533+ popups: newPopupManager(),
542 }534 }
543 535
544 popupID := port.WebViewID(2)536 popupID := port.WebViewID(2)
545 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")537 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
546 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")538 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
547 539
548- c.popupMu.RLock()540+ c.popups.mu.RLock()
549- state := c.popupOAuth[popupID]541+ state := c.popups.popupOAuth[popupID]
550- c.popupMu.RUnlock()542+ c.popups.mu.RUnlock()
551 543
552 require.NotNil(t, state)544 require.NotNil(t, state)
553 assert.True(t, state.Seen)545 assert.True(t, state.Seen)
@@ -560,17 +552,17 @@ func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
560 t.Parallel()552 t.Parallel()
561 553
562 c := &Coordinator{554 c := &Coordinator{
563- webViews: make(map[entity.PaneID]port.WebView),555+ webViews: make(map[entity.PaneID]port.WebView),
564- popupOAuth: make(map[port.WebViewID]*popupOAuthState),556+ popups: newPopupManager(),
565 }557 }
566 558
567 popupID := port.WebViewID(3)559 popupID := port.WebViewID(3)
568 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")560 c.trackOAuthPopup(popupID, entity.PaneID("parent"), "https://parent.example.com/login")
569 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")561 c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
570 562
571- c.popupMu.RLock()563+ c.popups.mu.RLock()
572- state := c.popupOAuth[popupID]564+ state := c.popups.popupOAuth[popupID]
573- c.popupMu.RUnlock()565+ c.popups.mu.RUnlock()
574 566
575 require.NotNil(t, state)567 require.NotNil(t, state)
576 assert.True(t, state.Seen)568 assert.True(t, state.Seen)
@@ -582,16 +574,16 @@ func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
582 t.Parallel()574 t.Parallel()
583 575
584 c := &Coordinator{576 c := &Coordinator{
585- webViews: make(map[entity.PaneID]port.WebView),577+ webViews: make(map[entity.PaneID]port.WebView),
586- popupOAuth: make(map[port.WebViewID]*popupOAuthState),578+ popups: newPopupManager(),
587 }579 }
588 580
589 // Should not panic when no tracking entry exists.581 // Should not panic when no tracking entry exists.
590 c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")582 c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
591 583
592- c.popupMu.RLock()584+ c.popups.mu.RLock()
593- _, ok := c.popupOAuth[port.WebViewID(999)]585+ _, ok := c.popups.popupOAuth[port.WebViewID(999)]
594- c.popupMu.RUnlock()586+ c.popups.mu.RUnlock()
595 587
596 assert.False(t, ok)588 assert.False(t, ok)
597 }589 }
@@ -611,10 +603,10 @@ func TestSetPopupConfig_SetsFields(t *testing.T) {
611 603
612 c.SetPopupConfig(factory, cfg, genID)604 c.SetPopupConfig(factory, cfg, genID)
613 605
614- assert.Equal(t, factory, c.factory)606+ assert.Equal(t, factory, c.popups.factory)
615- assert.Equal(t, cfg, c.popupConfig)607+ assert.Equal(t, cfg, c.popups.popupConfig)
616- assert.NotNil(t, c.generateID)608+ assert.NotNil(t, c.popups.generatePaneID)
617- assert.Equal(t, "test-id", c.generateID())609+ assert.Equal(t, "test-id", c.popups.generatePaneID())
618 }610 }
619 611
620 func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {612 func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
@@ -623,9 +615,9 @@ func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
623 c := &Coordinator{}615 c := &Coordinator{}
624 c.SetPopupConfig(nil, nil, nil)616 c.SetPopupConfig(nil, nil, nil)
625 617
626- assert.Nil(t, c.factory)618+ assert.Nil(t, c.popups.factory)
627- assert.Nil(t, c.popupConfig)619+ assert.Nil(t, c.popups.popupConfig)
628- assert.Nil(t, c.generateID)620+ assert.Nil(t, c.popups.generatePaneID)
629 }621 }
630 622
631 type popupNavigationWebViewStub struct {623 type popupNavigationWebViewStub struct {
@@ -637,6 +629,10 @@ func (s *popupNavigationWebViewStub) PrimePopupNavigation(uri string) {
637 s.primed = append(s.primed, uri)629 s.primed = append(s.primed, uri)
638 }630 }
639 631
632+func (*popupNavigationWebViewStub) SetOnReadyToShow(func()) {}
633+func (*popupNavigationWebViewStub) SetOnClose(func()) {}
634+func (*popupNavigationWebViewStub) Show() {}
635+
640 type popupOpenerBridgeWebViewStub struct {636 type popupOpenerBridgeWebViewStub struct {
641 *mocks.MockWebView637 *mocks.MockWebView
642 parent port.WebView638 parent port.WebView
@@ -648,6 +644,11 @@ func (s *popupOpenerBridgeWebViewStub) EnablePopupOpenerBridge(parent port.WebVi
648 s.noJavaScriptAccess = noJavaScriptAccess644 s.noJavaScriptAccess = noJavaScriptAccess
649 }645 }
650 646
647+func (*popupOpenerBridgeWebViewStub) AddOpenerMessageCallback(func()) {}
648+func (*popupOpenerBridgeWebViewStub) AddOpenerNavigationCallback(func(string)) {
649+}
650+func (*popupOpenerBridgeWebViewStub) HasActivePopupOpenerBridge() bool { return false }
651+
651 // stubFactory satisfies port.WebViewFactory without importing the mocks package.652 // stubFactory satisfies port.WebViewFactory without importing the mocks package.
652 type stubFactory struct{}653 type stubFactory struct{}
653 654
@@ -667,7 +668,7 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
667 t.Parallel()668 t.Parallel()
668 669
669 c := &Coordinator{670 c := &Coordinator{
670- pendingPopups: make(map[port.WebViewID]*PendingPopup),671+ popups: newPopupManager(),
671 }672 }
672 673
673 const workers = 20674 const workers = 20
@@ -679,12 +680,12 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
679 go func(id int) {680 go func(id int) {
680 defer wg.Done()681 defer wg.Done()
681 popupID := port.WebViewID(id)682 popupID := port.WebViewID(id)
682- c.popupMu.Lock()683+ c.popups.mu.Lock()
683- c.pendingPopups[popupID] = &PendingPopup{684+ c.popups.pendingPopups[popupID] = &PendingPopup{
684 FrameName: "_blank",685 FrameName: "_blank",
685 PopupType: PopupTypeTab,686 PopupType: PopupTypeTab,
686 }687 }
687- c.popupMu.Unlock()688+ c.popups.mu.Unlock()
688 }(i)689 }(i)
689 }690 }
690 691
@@ -693,17 +694,17 @@ func TestPendingPopups_ConcurrentAccess(t *testing.T) {
693 go func(id int) {694 go func(id int) {
694 defer wg.Done()695 defer wg.Done()
695 popupID := port.WebViewID(id)696 popupID := port.WebViewID(id)
696- c.popupMu.RLock()697+ c.popups.mu.RLock()
697- _ = c.pendingPopups[popupID]698+ _ = c.popups.pendingPopups[popupID]
698- c.popupMu.RUnlock()699+ c.popups.mu.RUnlock()
699 }(i)700 }(i)
700 }701 }
701 702
702 wg.Wait()703 wg.Wait()
703 704
704- c.popupMu.RLock()705+ c.popups.mu.RLock()
705- count := len(c.pendingPopups)706+ count := len(c.popups.pendingPopups)
706- c.popupMu.RUnlock()707+ c.popups.mu.RUnlock()
707 708
708 assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")709 assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
709 }710 }
@@ -712,12 +713,12 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
712 t.Parallel()713 t.Parallel()
713 714
714 c := &Coordinator{715 c := &Coordinator{
715- pendingPopups: make(map[port.WebViewID]*PendingPopup),716+ popups: newPopupManager(),
716 }717 }
717 718
718 // Pre-populate719 // Pre-populate
719 for i := 0; i < 10; i++ {720 for i := 0; i < 10; i++ {
720- c.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}721+ c.popups.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
721 }722 }
722 723
723 var wg sync.WaitGroup724 var wg sync.WaitGroup
@@ -727,9 +728,9 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
727 for i := 0; i < 10; i++ {728 for i := 0; i < 10; i++ {
728 go func(id int) {729 go func(id int) {
729 defer wg.Done()730 defer wg.Done()
730- c.popupMu.Lock()731+ c.popups.mu.Lock()
731- delete(c.pendingPopups, port.WebViewID(id))732+ delete(c.popups.pendingPopups, port.WebViewID(id))
732- c.popupMu.Unlock()733+ c.popups.mu.Unlock()
733 }(i)734 }(i)
734 }735 }
735 736
@@ -737,15 +738,15 @@ func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
737 for i := 0; i < 10; i++ {738 for i := 0; i < 10; i++ {
738 go func(id int) {739 go func(id int) {
739 defer wg.Done()740 defer wg.Done()
740- c.popupMu.RLock()741+ c.popups.mu.RLock()
741- _ = c.pendingPopups[port.WebViewID(id)]742+ _ = c.popups.pendingPopups[port.WebViewID(id)]
742- c.popupMu.RUnlock()743+ c.popups.mu.RUnlock()
743 }(i)744 }(i)
744 }745 }
745 746
746 wg.Wait()747 wg.Wait()
747 748
748- c.popupMu.RLock()749+ c.popups.mu.RLock()
749- defer c.popupMu.RUnlock()750+ defer c.popups.mu.RUnlock()
750- assert.Empty(t, c.pendingPopups, "all preloaded popups should have been deleted")751+ assert.Empty(t, c.popups.pendingPopups, "all preloaded popups should have been deleted")
751 }752 }

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close