detached a06c390

fix(popup): prevent OAuth hijacking, fix rendering corruption, and clean up localStorage

- Add navigation policy handler to all popup types to block script-initiated navigations in parent panes when popups are active (prevents OAuth window.opener hijacking)
- Apply hide→show→resize→draw sequence to parent pane after popup closes to fix GTK rendering corruption
- Clean up all popup-related localStorage entries (popup_*_parent_info, popup_*_parent_action, etc.) when popup closes
- Add requestID tracking to paneNode for proper localStorage cleanup
- Add RegisterNavigationPolicyHandler to both CGO and stub WebView implementations
- Remove obsolete modules/window-open.ts module (window.open now handled in main-world.ts)
commit
a06c3904455f1e99dcffde6d994038d48b664ffb
author
Brice · · 2025-10-04 08:57 UTC
parent
d7d1748
signature
unsigned
12 files · +539 -59
M gui/src/injected/gui.ts +1 -4
@@ -13,7 +13,6 @@ import {
1313 type WorkspaceConfigPayload,
1414 type WorkspaceRuntime,
1515 } from "./modules/workspace";
16-import { initializeWindowOpenInterceptor } from "./modules/window-open";
1716 import { keyboardService, type KeyboardService } from "$lib/keyboard";
1817 import type { Suggestion } from "../components/omnibox/types";
1918 // Note: color-scheme module is loaded separately at document-start by WebKit
@@ -156,9 +155,7 @@ if (!window.__dumber_gui_ready) {
156155 whenDOMReady(async () => {
157156 try {
158157 // Page-world bridge is injected by WebKit; avoid duplicate injection here
159-
160- // Initialize window.open interceptor first (must be early)
161- initializeWindowOpenInterceptor();
158+ // window.open interceptor is now in main-world.ts (page-world context)
162159
163160 // Initialize toast system first
164161 await initializeToast();
@@ -13,7 +13,6 @@ import {
13 type WorkspaceConfigPayload,13 type WorkspaceConfigPayload,
14 type WorkspaceRuntime,14 type WorkspaceRuntime,
15 } from "./modules/workspace";15 } from "./modules/workspace";
16-import { initializeWindowOpenInterceptor } from "./modules/window-open";
17 import { keyboardService, type KeyboardService } from "$lib/keyboard";16 import { keyboardService, type KeyboardService } from "$lib/keyboard";
18 import type { Suggestion } from "../components/omnibox/types";17 import type { Suggestion } from "../components/omnibox/types";
19 // Note: color-scheme module is loaded separately at document-start by WebKit18 // Note: color-scheme module is loaded separately at document-start by WebKit
@@ -156,9 +155,7 @@ if (!window.__dumber_gui_ready) {
156 whenDOMReady(async () => {155 whenDOMReady(async () => {
157 try {156 try {
158 // Page-world bridge is injected by WebKit; avoid duplicate injection here157 // Page-world bridge is injected by WebKit; avoid duplicate injection here
159-158+ // window.open interceptor is now in main-world.ts (page-world context)
160- // Initialize window.open interceptor first (must be early)
161- initializeWindowOpenInterceptor();
162 159
163 // Initialize toast system first160 // Initialize toast system first
164 await initializeToast();161 await initializeToast();
D gui/src/injected/modules/window-open.ts +0 -29
@@ -1,29 +0,0 @@
1-/**
2- * Window Open Interceptor Module
3- *
4- * OBSOLETE: window.open() is now handled directly via page-world bridge bypass.
5- * This module is kept for compatibility but no longer performs active interception.
6- */
7-
8-let isInitialized = false;
9-
10-/**
11- * Initialize the window.open interceptor (no-op)
12- * Window.open is now handled directly via JavaScript bypass in page-world
13- */
14-export function initializeWindowOpenInterceptor(): void {
15- if (isInitialized) {
16- return;
17- }
18-
19- // No-op - window.open handled directly via page-world bridge
20- isInitialized = true;
21- console.log("[window-open] ✅ Interceptor initialized (bypass mode)");
22-}
23-
24-/**
25- * Get initialization status
26- */
27-export function isWindowOpenInterceptorInitialized(): boolean {
28- return isInitialized;
29-}
@@ -1,29 +0,0 @@
1-/**
2- * Window Open Interceptor Module
3- *
4- * OBSOLETE: window.open() is now handled directly via page-world bridge bypass.
5- * This module is kept for compatibility but no longer performs active interception.
6- */
7-
8-let isInitialized = false;
9-
10-/**
11- * Initialize the window.open interceptor (no-op)
12- * Window.open is now handled directly via JavaScript bypass in page-world
13- */
14-export function initializeWindowOpenInterceptor(): void {
15- if (isInitialized) {
16- return;
17- }
18-
19- // No-op - window.open handled directly via page-world bridge
20- isInitialized = true;
21- console.log("[window-open] ✅ Interceptor initialized (bypass mode)");
22-}
23-
24-/**
25- * Get initialization status
26- */
27-export function isWindowOpenInterceptorInitialized(): boolean {
28- return isInitialized;
29-}
M internal/app/browser/workspace_manager.go +89 -0
@@ -397,11 +397,100 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
397397
398398 log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
399399
400+ // Remove this popup from parent's activePopupChildren before closing
401+ if targetNode.parentPane != nil {
402+ parentNode := targetNode.parentPane
403+ for i, childID := range parentNode.activePopupChildren {
404+ if childID == msg.WebViewID {
405+ parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
406+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", msg.WebViewID, len(parentNode.activePopupChildren))
407+ break
408+ }
409+ }
410+
411+ // Clean up ALL localStorage entries for this popup in parent WebView
412+ // This includes popup_<requestId>_parent_info, popup_<requestId>_parent_action, etc.
413+ if parentNode.pane != nil && parentNode.pane.WebView() != nil && targetNode.requestID != "" {
414+ parentWebViewID := parentNode.pane.WebView().ID()
415+ requestID := targetNode.requestID
416+
417+ cleanupScript := fmt.Sprintf(`
418+ try {
419+ // Clean up popup_mapping_<parentId> (from Go code)
420+ localStorage.removeItem('popup_mapping_%s');
421+
422+ // Clean up popup_<requestId>_* entries (from JavaScript)
423+ const requestId = '%s';
424+ const keysToRemove = [
425+ 'popup_' + requestId + '_parent_info',
426+ 'popup_' + requestId + '_parent_action',
427+ 'popup_' + requestId + '_message_to_popup',
428+ 'popup_' + requestId + '_message_to_parent',
429+ 'oauth_callback_' + '%s'
430+ ];
431+
432+ keysToRemove.forEach(key => {
433+ try {
434+ localStorage.removeItem(key);
435+ } catch(e) {}
436+ });
437+
438+ console.log('[workspace] Cleaned up popup localStorage for requestId:', requestId);
439+ } catch(e) {
440+ console.warn('[workspace] Failed to clean popup localStorage:', e);
441+ }
442+ `, parentWebViewID, requestID, msg.WebViewID)
443+
444+ if err := parentNode.pane.WebView().InjectScript(cleanupScript); err != nil {
445+ log.Printf("[workspace] Failed to inject localStorage cleanup script: %v", err)
446+ } else {
447+ log.Printf("[workspace] Cleaned up localStorage for popup requestId=%s webviewId=%s", requestID, msg.WebViewID)
448+ }
449+ }
450+ }
451+
400452 // Close the popup pane
401453 if err := wm.ClosePane(targetNode); err != nil {
402454 log.Printf("[workspace] Failed to close popup pane: %v", err)
403455 } else {
404456 log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
457+
458+ // Fix GTK rendering for parent pane after popup closes
459+ // The parent pane's rendering can become corrupted after popup closes
460+ if targetNode.parentPane != nil {
461+ parentNode := targetNode.parentPane
462+ if parentNode.container != 0 && webkit.WidgetIsValid(parentNode.container) {
463+ log.Printf("[workspace] Fixing parent pane rendering after popup close")
464+
465+ // CRITICAL: Hide parent container to disconnect WebKitGTK rendering pipeline
466+ webkit.WidgetHide(parentNode.container)
467+
468+ // Schedule showing and forcing GTK to reconnect rendering
469+ wm.scheduleIdleGuarded(func() bool {
470+ if parentNode == nil || !parentNode.widgetValid || parentNode.container == 0 {
471+ return false
472+ }
473+ // Show widget to reconnect WebKit rendering pipeline
474+ webkit.WidgetShow(parentNode.container)
475+ // Force GTK to recalculate size and recreate rendering surface
476+ webkit.WidgetQueueResize(parentNode.container)
477+ webkit.WidgetQueueDraw(parentNode.container)
478+
479+ // Also queue resize+draw on the WebView widget itself
480+ if parentNode.pane != nil && parentNode.pane.WebView() != nil {
481+ webViewWidget := parentNode.pane.WebView().Widget()
482+ if webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
483+ webkit.WidgetQueueResize(webViewWidget)
484+ webkit.WidgetQueueDraw(webViewWidget)
485+ log.Printf("[workspace] Queued resize+draw for parent WebView widget")
486+ }
487+ }
488+
489+ log.Printf("[workspace] Shown and queued resize+draw for parent pane after popup close")
490+ return false
491+ }, parentNode)
492+ }
493+ }
405494 }
406495 default:
407496 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
@@ -397,11 +397,100 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
397 397
398 log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)398 log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
399 399
400+ // Remove this popup from parent's activePopupChildren before closing
401+ if targetNode.parentPane != nil {
402+ parentNode := targetNode.parentPane
403+ for i, childID := range parentNode.activePopupChildren {
404+ if childID == msg.WebViewID {
405+ parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
406+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", msg.WebViewID, len(parentNode.activePopupChildren))
407+ break
408+ }
409+ }
410+
411+ // Clean up ALL localStorage entries for this popup in parent WebView
412+ // This includes popup_<requestId>_parent_info, popup_<requestId>_parent_action, etc.
413+ if parentNode.pane != nil && parentNode.pane.WebView() != nil && targetNode.requestID != "" {
414+ parentWebViewID := parentNode.pane.WebView().ID()
415+ requestID := targetNode.requestID
416+
417+ cleanupScript := fmt.Sprintf(`
418+ try {
419+ // Clean up popup_mapping_<parentId> (from Go code)
420+ localStorage.removeItem('popup_mapping_%s');
421+
422+ // Clean up popup_<requestId>_* entries (from JavaScript)
423+ const requestId = '%s';
424+ const keysToRemove = [
425+ 'popup_' + requestId + '_parent_info',
426+ 'popup_' + requestId + '_parent_action',
427+ 'popup_' + requestId + '_message_to_popup',
428+ 'popup_' + requestId + '_message_to_parent',
429+ 'oauth_callback_' + '%s'
430+ ];
431+
432+ keysToRemove.forEach(key => {
433+ try {
434+ localStorage.removeItem(key);
435+ } catch(e) {}
436+ });
437+
438+ console.log('[workspace] Cleaned up popup localStorage for requestId:', requestId);
439+ } catch(e) {
440+ console.warn('[workspace] Failed to clean popup localStorage:', e);
441+ }
442+ `, parentWebViewID, requestID, msg.WebViewID)
443+
444+ if err := parentNode.pane.WebView().InjectScript(cleanupScript); err != nil {
445+ log.Printf("[workspace] Failed to inject localStorage cleanup script: %v", err)
446+ } else {
447+ log.Printf("[workspace] Cleaned up localStorage for popup requestId=%s webviewId=%s", requestID, msg.WebViewID)
448+ }
449+ }
450+ }
451+
400 // Close the popup pane452 // Close the popup pane
401 if err := wm.ClosePane(targetNode); err != nil {453 if err := wm.ClosePane(targetNode); err != nil {
402 log.Printf("[workspace] Failed to close popup pane: %v", err)454 log.Printf("[workspace] Failed to close popup pane: %v", err)
403 } else {455 } else {
404 log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)456 log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
457+
458+ // Fix GTK rendering for parent pane after popup closes
459+ // The parent pane's rendering can become corrupted after popup closes
460+ if targetNode.parentPane != nil {
461+ parentNode := targetNode.parentPane
462+ if parentNode.container != 0 && webkit.WidgetIsValid(parentNode.container) {
463+ log.Printf("[workspace] Fixing parent pane rendering after popup close")
464+
465+ // CRITICAL: Hide parent container to disconnect WebKitGTK rendering pipeline
466+ webkit.WidgetHide(parentNode.container)
467+
468+ // Schedule showing and forcing GTK to reconnect rendering
469+ wm.scheduleIdleGuarded(func() bool {
470+ if parentNode == nil || !parentNode.widgetValid || parentNode.container == 0 {
471+ return false
472+ }
473+ // Show widget to reconnect WebKit rendering pipeline
474+ webkit.WidgetShow(parentNode.container)
475+ // Force GTK to recalculate size and recreate rendering surface
476+ webkit.WidgetQueueResize(parentNode.container)
477+ webkit.WidgetQueueDraw(parentNode.container)
478+
479+ // Also queue resize+draw on the WebView widget itself
480+ if parentNode.pane != nil && parentNode.pane.WebView() != nil {
481+ webViewWidget := parentNode.pane.WebView().Widget()
482+ if webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
483+ webkit.WidgetQueueResize(webViewWidget)
484+ webkit.WidgetQueueDraw(webViewWidget)
485+ log.Printf("[workspace] Queued resize+draw for parent WebView widget")
486+ }
487+ }
488+
489+ log.Printf("[workspace] Shown and queued resize+draw for parent pane after popup close")
490+ return false
491+ }, parentNode)
492+ }
493+ }
405 }494 }
406 default:495 default:
407 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)496 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
M internal/app/browser/workspace_pane_ops.go +116 -3
@@ -341,8 +341,10 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
341341 }
342342 webkit.WidgetSetHExpand(paned, true)
343343 webkit.WidgetSetVExpand(paned, true)
344- webkit.PanedSetResizeStart(paned, true)
345- webkit.PanedSetResizeEnd(paned, true)
344+ // For popup panes: preserve existing pane size, let popup take remaining space
345+ webkit.PanedSetResizeStart(paned, false) // existing pane keeps its size
346+ webkit.PanedSetResizeEnd(paned, true) // popup can resize
347+ log.Printf("[workspace] configured paned for popup: start=fixed, end=flexible")
346348
347349 newLeaf := &paneNode{
348350 pane: newPane,
@@ -359,17 +361,44 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
359361
360362 parent := split.parent
361363
364+ // CRITICAL: Capture parent paned's divider position BEFORE reparenting to preserve layout
365+ var parentDividerPos int
366+ if parent != nil && parent.container != 0 && webkit.WidgetIsValid(parent.container) {
367+ parentDividerPos = webkit.PanedGetPosition(parent.container)
368+ log.Printf("[workspace] captured parent divider position: %d", parentDividerPos)
369+ }
370+
371+ // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
372+ existingContainer := target.container
373+ if existingContainer != 0 && webkit.WidgetIsValid(existingContainer) {
374+ // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
375+ if webkit.WidgetHasCSSClass(existingContainer, activePaneClass) {
376+ webkit.WidgetRemoveCSSClass(existingContainer, activePaneClass)
377+ }
378+
379+ // CRITICAL: Hide widget before reparenting to disconnect WebKitGTK rendering pipeline
380+ // This forces WebKit to detach its compositor, preventing rendering corruption
381+ webkit.WidgetHide(existingContainer)
382+ log.Printf("[workspace] Hidden existing container before reparenting: %#x", existingContainer)
383+ }
384+
362385 // Prevent GTK from auto-removing controllers while the widget is temporarily unparented.
363386 wm.safelyDetachControllersBeforeReparent(target)
364387
365388 // Detach existing container from its current GTK parent before inserting into new paned.
366- existingContainer := target.container
367389 if parent == nil {
368390 // Target is the root - remove it from the window
369391 log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
370392 if wm.window != nil {
371393 wm.window.SetChild(0)
372394 }
395+ // For root widgets, manually unparent if they have a GTK parent (like splitNode does)
396+ if webkit.WidgetIsValid(existingContainer) {
397+ if webkit.WidgetGetParent(existingContainer) != 0 {
398+ webkit.WidgetUnparent(existingContainer)
399+ log.Printf("[workspace] manually unparented root widget: %#x", existingContainer)
400+ }
401+ }
373402 } else if parent.container != 0 {
374403 // Target has a parent paned - unparent it from there
375404 log.Printf("[workspace] unparenting existing container from parent paned")
@@ -422,11 +451,56 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
422451 webkit.WidgetQueueAllocate(parent.container)
423452 webkit.WidgetQueueAllocate(paned)
424453 log.Printf("[workspace] paned inserted into parent successfully")
454+
455+ // CRITICAL: Restore parent paned's divider position to preserve existing layout
456+ if parentDividerPos > 0 {
457+ webkit.PanedSetPosition(parent.container, parentDividerPos)
458+ log.Printf("[workspace] restored parent divider position: %d", parentDividerPos)
459+ }
425460 }
426461 }
427462
428463 webkit.WidgetShow(paned)
429464
465+ // CRITICAL: Set initial 50/50 split position after showing paned
466+ // GTK needs the widget to be realized before we can set position based on allocation
467+ // Schedule this to run after GTK has allocated space to the paned
468+ wm.scheduleIdleGuarded(func() bool {
469+ if !webkit.WidgetIsValid(paned) {
470+ return false
471+ }
472+ // Get paned allocation to calculate 50% position
473+ alloc := webkit.WidgetGetAllocation(paned)
474+ var splitPos int
475+ if orientation == webkit.OrientationHorizontal {
476+ splitPos = alloc.Width / 2
477+ } else {
478+ splitPos = alloc.Height / 2
479+ }
480+ if splitPos > 0 {
481+ webkit.PanedSetPosition(paned, splitPos)
482+ log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height)
483+ }
484+ return false
485+ }, split)
486+
487+ // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
488+ // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
489+ if target != nil && target.container != 0 {
490+ wm.scheduleIdleGuarded(func() bool {
491+ if target == nil || !target.widgetValid || target.container == 0 {
492+ return false
493+ }
494+ // Show widget to reconnect WebKit rendering pipeline
495+ webkit.WidgetShow(target.container)
496+ // Force GTK to recalculate size and recreate rendering surface
497+ webkit.WidgetQueueResize(target.container)
498+ webkit.WidgetQueueDraw(target.container)
499+ log.Printf("[workspace] Shown and queued resize+draw for target container after reparenting")
500+ return false
501+ }, target)
502+ }
503+
430504 wm.viewToNode[newPane.webView] = newLeaf
431505 wm.ensureHover(newLeaf)
432506 wm.ensureHover(target)
@@ -684,6 +758,45 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
684758
685759 webkit.WidgetShow(paned)
686760
761+ // CRITICAL: Set initial 50/50 split position after showing paned
762+ // GTK needs the widget to be realized before we can set position based on allocation
763+ // Schedule this to run after GTK has allocated space to the paned
764+ wm.scheduleIdleGuarded(func() bool {
765+ if !webkit.WidgetIsValid(paned) {
766+ return false
767+ }
768+ // Get paned allocation to calculate 50% position
769+ alloc := webkit.WidgetGetAllocation(paned)
770+ var splitPos int
771+ if orientation == webkit.OrientationHorizontal {
772+ splitPos = alloc.Width / 2
773+ } else {
774+ splitPos = alloc.Height / 2
775+ }
776+ if splitPos > 0 {
777+ webkit.PanedSetPosition(paned, splitPos)
778+ log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height)
779+ }
780+ return false
781+ }, split)
782+
783+ // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
784+ // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
785+ if target != nil && target.container != 0 {
786+ wm.scheduleIdleGuarded(func() bool {
787+ if target == nil || !target.widgetValid || target.container == 0 {
788+ return false
789+ }
790+ // Show widget to reconnect WebKit rendering pipeline
791+ webkit.WidgetShow(target.container)
792+ // Force GTK to recalculate size and recreate rendering surface
793+ webkit.WidgetQueueResize(target.container)
794+ webkit.WidgetQueueDraw(target.container)
795+ log.Printf("[workspace] Shown and queued resize+draw for target container after reparenting")
796+ return false
797+ }, target)
798+ }
799+
687800 wm.viewToNode[newPane.webView] = newLeaf
688801 wm.ensureHover(newLeaf)
689802
@@ -341,8 +341,10 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
341 }341 }
342 webkit.WidgetSetHExpand(paned, true)342 webkit.WidgetSetHExpand(paned, true)
343 webkit.WidgetSetVExpand(paned, true)343 webkit.WidgetSetVExpand(paned, true)
344- webkit.PanedSetResizeStart(paned, true)344+ // For popup panes: preserve existing pane size, let popup take remaining space
345- webkit.PanedSetResizeEnd(paned, true)345+ webkit.PanedSetResizeStart(paned, false) // existing pane keeps its size
346+ webkit.PanedSetResizeEnd(paned, true) // popup can resize
347+ log.Printf("[workspace] configured paned for popup: start=fixed, end=flexible")
346 348
347 newLeaf := &paneNode{349 newLeaf := &paneNode{
348 pane: newPane,350 pane: newPane,
@@ -359,17 +361,44 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
359 361
360 parent := split.parent362 parent := split.parent
361 363
364+ // CRITICAL: Capture parent paned's divider position BEFORE reparenting to preserve layout
365+ var parentDividerPos int
366+ if parent != nil && parent.container != 0 && webkit.WidgetIsValid(parent.container) {
367+ parentDividerPos = webkit.PanedGetPosition(parent.container)
368+ log.Printf("[workspace] captured parent divider position: %d", parentDividerPos)
369+ }
370+
371+ // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
372+ existingContainer := target.container
373+ if existingContainer != 0 && webkit.WidgetIsValid(existingContainer) {
374+ // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
375+ if webkit.WidgetHasCSSClass(existingContainer, activePaneClass) {
376+ webkit.WidgetRemoveCSSClass(existingContainer, activePaneClass)
377+ }
378+
379+ // CRITICAL: Hide widget before reparenting to disconnect WebKitGTK rendering pipeline
380+ // This forces WebKit to detach its compositor, preventing rendering corruption
381+ webkit.WidgetHide(existingContainer)
382+ log.Printf("[workspace] Hidden existing container before reparenting: %#x", existingContainer)
383+ }
384+
362 // Prevent GTK from auto-removing controllers while the widget is temporarily unparented.385 // Prevent GTK from auto-removing controllers while the widget is temporarily unparented.
363 wm.safelyDetachControllersBeforeReparent(target)386 wm.safelyDetachControllersBeforeReparent(target)
364 387
365 // Detach existing container from its current GTK parent before inserting into new paned.388 // Detach existing container from its current GTK parent before inserting into new paned.
366- existingContainer := target.container
367 if parent == nil {389 if parent == nil {
368 // Target is the root - remove it from the window390 // Target is the root - remove it from the window
369 log.Printf("[workspace] removing existing container=%#x from window", existingContainer)391 log.Printf("[workspace] removing existing container=%#x from window", existingContainer)
370 if wm.window != nil {392 if wm.window != nil {
371 wm.window.SetChild(0)393 wm.window.SetChild(0)
372 }394 }
395+ // For root widgets, manually unparent if they have a GTK parent (like splitNode does)
396+ if webkit.WidgetIsValid(existingContainer) {
397+ if webkit.WidgetGetParent(existingContainer) != 0 {
398+ webkit.WidgetUnparent(existingContainer)
399+ log.Printf("[workspace] manually unparented root widget: %#x", existingContainer)
400+ }
401+ }
373 } else if parent.container != 0 {402 } else if parent.container != 0 {
374 // Target has a parent paned - unparent it from there403 // Target has a parent paned - unparent it from there
375 log.Printf("[workspace] unparenting existing container from parent paned")404 log.Printf("[workspace] unparenting existing container from parent paned")
@@ -422,11 +451,56 @@ func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPa
422 webkit.WidgetQueueAllocate(parent.container)451 webkit.WidgetQueueAllocate(parent.container)
423 webkit.WidgetQueueAllocate(paned)452 webkit.WidgetQueueAllocate(paned)
424 log.Printf("[workspace] paned inserted into parent successfully")453 log.Printf("[workspace] paned inserted into parent successfully")
454+
455+ // CRITICAL: Restore parent paned's divider position to preserve existing layout
456+ if parentDividerPos > 0 {
457+ webkit.PanedSetPosition(parent.container, parentDividerPos)
458+ log.Printf("[workspace] restored parent divider position: %d", parentDividerPos)
459+ }
425 }460 }
426 }461 }
427 462
428 webkit.WidgetShow(paned)463 webkit.WidgetShow(paned)
429 464
465+ // CRITICAL: Set initial 50/50 split position after showing paned
466+ // GTK needs the widget to be realized before we can set position based on allocation
467+ // Schedule this to run after GTK has allocated space to the paned
468+ wm.scheduleIdleGuarded(func() bool {
469+ if !webkit.WidgetIsValid(paned) {
470+ return false
471+ }
472+ // Get paned allocation to calculate 50% position
473+ alloc := webkit.WidgetGetAllocation(paned)
474+ var splitPos int
475+ if orientation == webkit.OrientationHorizontal {
476+ splitPos = alloc.Width / 2
477+ } else {
478+ splitPos = alloc.Height / 2
479+ }
480+ if splitPos > 0 {
481+ webkit.PanedSetPosition(paned, splitPos)
482+ log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height)
483+ }
484+ return false
485+ }, split)
486+
487+ // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
488+ // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
489+ if target != nil && target.container != 0 {
490+ wm.scheduleIdleGuarded(func() bool {
491+ if target == nil || !target.widgetValid || target.container == 0 {
492+ return false
493+ }
494+ // Show widget to reconnect WebKit rendering pipeline
495+ webkit.WidgetShow(target.container)
496+ // Force GTK to recalculate size and recreate rendering surface
497+ webkit.WidgetQueueResize(target.container)
498+ webkit.WidgetQueueDraw(target.container)
499+ log.Printf("[workspace] Shown and queued resize+draw for target container after reparenting")
500+ return false
501+ }, target)
502+ }
503+
430 wm.viewToNode[newPane.webView] = newLeaf504 wm.viewToNode[newPane.webView] = newLeaf
431 wm.ensureHover(newLeaf)505 wm.ensureHover(newLeaf)
432 wm.ensureHover(target)506 wm.ensureHover(target)
@@ -684,6 +758,45 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
684 758
685 webkit.WidgetShow(paned)759 webkit.WidgetShow(paned)
686 760
761+ // CRITICAL: Set initial 50/50 split position after showing paned
762+ // GTK needs the widget to be realized before we can set position based on allocation
763+ // Schedule this to run after GTK has allocated space to the paned
764+ wm.scheduleIdleGuarded(func() bool {
765+ if !webkit.WidgetIsValid(paned) {
766+ return false
767+ }
768+ // Get paned allocation to calculate 50% position
769+ alloc := webkit.WidgetGetAllocation(paned)
770+ var splitPos int
771+ if orientation == webkit.OrientationHorizontal {
772+ splitPos = alloc.Width / 2
773+ } else {
774+ splitPos = alloc.Height / 2
775+ }
776+ if splitPos > 0 {
777+ webkit.PanedSetPosition(paned, splitPos)
778+ log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height)
779+ }
780+ return false
781+ }, split)
782+
783+ // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
784+ // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
785+ if target != nil && target.container != 0 {
786+ wm.scheduleIdleGuarded(func() bool {
787+ if target == nil || !target.widgetValid || target.container == 0 {
788+ return false
789+ }
790+ // Show widget to reconnect WebKit rendering pipeline
791+ webkit.WidgetShow(target.container)
792+ // Force GTK to recalculate size and recreate rendering surface
793+ webkit.WidgetQueueResize(target.container)
794+ webkit.WidgetQueueDraw(target.container)
795+ log.Printf("[workspace] Shown and queued resize+draw for target container after reparenting")
796+ return false
797+ }, target)
798+ }
799+
687 wm.viewToNode[newPane.webView] = newLeaf800 wm.viewToNode[newPane.webView] = newLeaf
688 wm.ensureHover(newLeaf)801 wm.ensureHover(newLeaf)
689 802
M internal/app/browser/workspace_popup.go +203 -2
@@ -134,21 +134,55 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
134134 return nil
135135 }
136136
137+ // Get the new node and popup WebView ID for tracking
138+ newNode := wm.viewToNode[newView]
139+ popupWebViewID := newView.ID()
140+
137141 // Apply different behavior based on target type
138142 if isBlankTarget {
139143 log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
140- // For _blank targets, just ensure GUI - no popup-specific behavior
144+ // For _blank targets, set up parent relationship but no auto-close
145+ if newNode != nil {
146+ newNode.parentPane = node
147+ }
148+
149+ // Register close handler to clean up activePopupChildren tracking
150+ newView.RegisterCloseHandler(func() {
151+ log.Printf("[workspace] _blank popup requesting close via window.close()")
152+ // Remove this popup from parent's activePopupChildren
153+ if node != nil && popupWebViewID != "" {
154+ for i, childID := range node.activePopupChildren {
155+ if childID == popupWebViewID {
156+ node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
157+ log.Printf("[workspace] Removed _blank popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
158+ break
159+ }
160+ }
161+ }
162+ })
141163 } else {
142164 log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
143165 // Mark as popup for auto-close handling (OAuth flows, etc.)
144- newNode := wm.viewToNode[newView]
145166 if newNode != nil {
146167 newNode.isPopup = true
168+ newNode.parentPane = node
147169 log.Printf("[workspace] Marked pane as popup for auto-close handling")
148170
149171 // Register close handler for popup auto-close on window.close()
150172 newView.RegisterCloseHandler(func() {
151173 log.Printf("[workspace] Popup requesting close via window.close()")
174+
175+ // Remove this popup from parent's activePopupChildren
176+ if node != nil && popupWebViewID != "" {
177+ for i, childID := range node.activePopupChildren {
178+ if childID == popupWebViewID {
179+ node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
180+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
181+ break
182+ }
183+ }
184+ }
185+
152186 // Look up the node at close time
153187 if node := wm.viewToNode[newView]; node != nil && node.isPopup {
154188 log.Printf("[workspace] Closing popup pane")
@@ -173,6 +207,37 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
173207 }
174208 }
175209
210+ // Track this popup in the parent's activePopupChildren (for both _blank and popup types)
211+ // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
212+ if node != nil && popupWebViewID != "" {
213+ if node.activePopupChildren == nil {
214+ node.activePopupChildren = make([]string, 0)
215+ }
216+ node.activePopupChildren = append(node.activePopupChildren, popupWebViewID)
217+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(node.activePopupChildren))
218+
219+ // Register navigation policy handler for parent to block script-initiated navigations
220+ // when it has active popup children (prevents OAuth from hijacking parent pane)
221+ if node.pane != nil && node.pane.WebView() != nil {
222+ node.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
223+ // Always allow user-initiated navigations
224+ if isUserGesture {
225+ return true
226+ }
227+
228+ // Block script-initiated navigations if this pane has active popup children
229+ if len(node.activePopupChildren) > 0 {
230+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(node.activePopupChildren), url)
231+ return false
232+ }
233+
234+ // Allow if no active popups
235+ return true
236+ })
237+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
238+ }
239+ }
240+
176241 // Ensure GUI components are available in the new pane
177242 wm.ensureGUIInPane(newPane)
178243
@@ -391,13 +456,56 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string
391456 node.isPopup = true
392457 node.autoClose = wm.shouldAutoClose(url)
393458
459+ // Track this popup in the parent's activePopupChildren
460+ if sourceNode != nil {
461+ if sourceNode.activePopupChildren == nil {
462+ sourceNode.activePopupChildren = make([]string, 0)
463+ }
464+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
465+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
466+
467+ // Register navigation policy handler for parent to block script-initiated navigations
468+ // when it has active popup children (prevents OAuth from hijacking parent pane)
469+ if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
470+ sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
471+ // Always allow user-initiated navigations
472+ if isUserGesture {
473+ return true
474+ }
475+
476+ // Block script-initiated navigations if this pane has active popup children
477+ if len(sourceNode.activePopupChildren) > 0 {
478+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
479+ return false
480+ }
481+
482+ // Allow if no active popups
483+ return true
484+ })
485+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
486+ }
487+ }
488+
394489 // Store requestID for deduplication cleanup
395490 if intent != nil {
396491 requestID = intent.RequestID
492+ node.requestID = requestID
397493 }
398494
399495 // Apply window features from JavaScript intent
400496 wm.applyWindowFeatures(newView, intent, true)
497+
498+ // Apply minimum size constraints to prevent compression
499+ var width, height int
500+ if intent != nil {
501+ if intent.Width != nil {
502+ width = *intent.Width
503+ }
504+ if intent.Height != nil {
505+ height = *intent.Height
506+ }
507+ }
508+ wm.applyPopupSizeConstraints(newView, width, height)
401509 }
402510
403511 // Register close handler for popup auto-close
@@ -409,6 +517,17 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string
409517 wm.paneDeduplicator.ClearRequestID(requestID)
410518 }
411519
520+ // Remove this popup from parent's activePopupChildren
521+ if sourceNode != nil && popupWebViewID != "" {
522+ for i, childID := range sourceNode.activePopupChildren {
523+ if childID == popupWebViewID {
524+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
525+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
526+ break
527+ }
528+ }
529+ }
530+
412531 if n := wm.viewToNode[newView]; n != nil && n.isPopup {
413532 time.AfterFunc(200*time.Millisecond, func() {
414533 wm.scheduleIdleGuarded(func() bool {
@@ -486,6 +605,8 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
486605 return
487606 }
488607 node := wm.viewToNode[related]
608+ popupWebViewID := related.ID()
609+
489610 if node != nil {
490611 node.windowType = webkit.WindowTypePopup
491612 node.windowFeatures = feat
@@ -494,10 +615,62 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
494615 node.isPopup = true
495616 // Heuristic + config for auto-close intent
496617 node.autoClose = wm.shouldAutoClose(url)
618+
619+ // Apply minimum size constraints to prevent compression
620+ var width, height int
621+ if feat != nil {
622+ width = feat.Width
623+ height = feat.Height
624+ }
625+ wm.applyPopupSizeConstraints(related, width, height)
497626 }
627+
628+ // Track this popup in the parent's activePopupChildren
629+ // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
630+ if sourceNode != nil && popupWebViewID != "" {
631+ if sourceNode.activePopupChildren == nil {
632+ sourceNode.activePopupChildren = make([]string, 0)
633+ }
634+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
635+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
636+
637+ // Register navigation policy handler for parent to block script-initiated navigations
638+ // when it has active popup children (prevents OAuth from hijacking parent pane)
639+ if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
640+ sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
641+ // Always allow user-initiated navigations
642+ if isUserGesture {
643+ return true
644+ }
645+
646+ // Block script-initiated navigations if this pane has active popup children
647+ if len(sourceNode.activePopupChildren) > 0 {
648+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
649+ return false
650+ }
651+
652+ // Allow if no active popups
653+ return true
654+ })
655+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
656+ }
657+ }
658+
498659 // Pipe into existing auto-close flow only for popups (confirmed by detection)
499660 related.RegisterCloseHandler(func() {
500661 log.Printf("[workspace] Popup requesting close via window.close()")
662+
663+ // Remove this popup from parent's activePopupChildren
664+ if sourceNode != nil && popupWebViewID != "" {
665+ for i, childID := range sourceNode.activePopupChildren {
666+ if childID == popupWebViewID {
667+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
668+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
669+ break
670+ }
671+ }
672+ }
673+
501674 if n := wm.viewToNode[related]; n != nil && n.isPopup {
502675 time.AfterFunc(200*time.Millisecond, func() {
503676 wm.scheduleIdleGuarded(func() bool {
@@ -522,6 +695,34 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
522695 }
523696 }
524697
698+// applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression
699+func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) {
700+ minWidth := 500 // Default minimum width
701+ minHeight := 600 // Default minimum height
702+
703+ // Use provided dimensions if valid
704+ if width > 0 {
705+ minWidth = width
706+ }
707+ if height > 0 {
708+ minHeight = height
709+ }
710+
711+ // Apply to container
712+ if container := view.RootWidget(); container != 0 && webkit.WidgetIsValid(container) {
713+ webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
714+ webkit.WidgetQueueResize(container)
715+ log.Printf("[workspace] Applied minimum size %dx%d to popup container=%#x", minWidth, minHeight, container)
716+ }
717+
718+ // Apply to WebView widget
719+ if webViewWidget := view.Widget(); webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
720+ webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
721+ webkit.WidgetQueueResize(webViewWidget)
722+ log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%#x", minWidth, minHeight, webViewWidget)
723+ }
724+}
725+
525726 // shouldAutoClose checks simple OAuth-like URL patterns and config flag
526727 func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
527728 log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
@@ -134,21 +134,55 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
134 return nil134 return nil
135 }135 }
136 136
137+ // Get the new node and popup WebView ID for tracking
138+ newNode := wm.viewToNode[newView]
139+ popupWebViewID := newView.ID()
140+
137 // Apply different behavior based on target type141 // Apply different behavior based on target type
138 if isBlankTarget {142 if isBlankTarget {
139 log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")143 log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
140- // For _blank targets, just ensure GUI - no popup-specific behavior144+ // For _blank targets, set up parent relationship but no auto-close
145+ if newNode != nil {
146+ newNode.parentPane = node
147+ }
148+
149+ // Register close handler to clean up activePopupChildren tracking
150+ newView.RegisterCloseHandler(func() {
151+ log.Printf("[workspace] _blank popup requesting close via window.close()")
152+ // Remove this popup from parent's activePopupChildren
153+ if node != nil && popupWebViewID != "" {
154+ for i, childID := range node.activePopupChildren {
155+ if childID == popupWebViewID {
156+ node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
157+ log.Printf("[workspace] Removed _blank popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
158+ break
159+ }
160+ }
161+ }
162+ })
141 } else {163 } else {
142 log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")164 log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
143 // Mark as popup for auto-close handling (OAuth flows, etc.)165 // Mark as popup for auto-close handling (OAuth flows, etc.)
144- newNode := wm.viewToNode[newView]
145 if newNode != nil {166 if newNode != nil {
146 newNode.isPopup = true167 newNode.isPopup = true
168+ newNode.parentPane = node
147 log.Printf("[workspace] Marked pane as popup for auto-close handling")169 log.Printf("[workspace] Marked pane as popup for auto-close handling")
148 170
149 // Register close handler for popup auto-close on window.close()171 // Register close handler for popup auto-close on window.close()
150 newView.RegisterCloseHandler(func() {172 newView.RegisterCloseHandler(func() {
151 log.Printf("[workspace] Popup requesting close via window.close()")173 log.Printf("[workspace] Popup requesting close via window.close()")
174+
175+ // Remove this popup from parent's activePopupChildren
176+ if node != nil && popupWebViewID != "" {
177+ for i, childID := range node.activePopupChildren {
178+ if childID == popupWebViewID {
179+ node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
180+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
181+ break
182+ }
183+ }
184+ }
185+
152 // Look up the node at close time186 // Look up the node at close time
153 if node := wm.viewToNode[newView]; node != nil && node.isPopup {187 if node := wm.viewToNode[newView]; node != nil && node.isPopup {
154 log.Printf("[workspace] Closing popup pane")188 log.Printf("[workspace] Closing popup pane")
@@ -173,6 +207,37 @@ func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *web
173 }207 }
174 }208 }
175 209
210+ // Track this popup in the parent's activePopupChildren (for both _blank and popup types)
211+ // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
212+ if node != nil && popupWebViewID != "" {
213+ if node.activePopupChildren == nil {
214+ node.activePopupChildren = make([]string, 0)
215+ }
216+ node.activePopupChildren = append(node.activePopupChildren, popupWebViewID)
217+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(node.activePopupChildren))
218+
219+ // Register navigation policy handler for parent to block script-initiated navigations
220+ // when it has active popup children (prevents OAuth from hijacking parent pane)
221+ if node.pane != nil && node.pane.WebView() != nil {
222+ node.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
223+ // Always allow user-initiated navigations
224+ if isUserGesture {
225+ return true
226+ }
227+
228+ // Block script-initiated navigations if this pane has active popup children
229+ if len(node.activePopupChildren) > 0 {
230+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(node.activePopupChildren), url)
231+ return false
232+ }
233+
234+ // Allow if no active popups
235+ return true
236+ })
237+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
238+ }
239+ }
240+
176 // Ensure GUI components are available in the new pane241 // Ensure GUI components are available in the new pane
177 wm.ensureGUIInPane(newPane)242 wm.ensureGUIInPane(newPane)
178 243
@@ -391,13 +456,56 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string
391 node.isPopup = true456 node.isPopup = true
392 node.autoClose = wm.shouldAutoClose(url)457 node.autoClose = wm.shouldAutoClose(url)
393 458
459+ // Track this popup in the parent's activePopupChildren
460+ if sourceNode != nil {
461+ if sourceNode.activePopupChildren == nil {
462+ sourceNode.activePopupChildren = make([]string, 0)
463+ }
464+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
465+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
466+
467+ // Register navigation policy handler for parent to block script-initiated navigations
468+ // when it has active popup children (prevents OAuth from hijacking parent pane)
469+ if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
470+ sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
471+ // Always allow user-initiated navigations
472+ if isUserGesture {
473+ return true
474+ }
475+
476+ // Block script-initiated navigations if this pane has active popup children
477+ if len(sourceNode.activePopupChildren) > 0 {
478+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
479+ return false
480+ }
481+
482+ // Allow if no active popups
483+ return true
484+ })
485+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
486+ }
487+ }
488+
394 // Store requestID for deduplication cleanup489 // Store requestID for deduplication cleanup
395 if intent != nil {490 if intent != nil {
396 requestID = intent.RequestID491 requestID = intent.RequestID
492+ node.requestID = requestID
397 }493 }
398 494
399 // Apply window features from JavaScript intent495 // Apply window features from JavaScript intent
400 wm.applyWindowFeatures(newView, intent, true)496 wm.applyWindowFeatures(newView, intent, true)
497+
498+ // Apply minimum size constraints to prevent compression
499+ var width, height int
500+ if intent != nil {
501+ if intent.Width != nil {
502+ width = *intent.Width
503+ }
504+ if intent.Height != nil {
505+ height = *intent.Height
506+ }
507+ }
508+ wm.applyPopupSizeConstraints(newView, width, height)
401 }509 }
402 510
403 // Register close handler for popup auto-close511 // Register close handler for popup auto-close
@@ -409,6 +517,17 @@ func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string
409 wm.paneDeduplicator.ClearRequestID(requestID)517 wm.paneDeduplicator.ClearRequestID(requestID)
410 }518 }
411 519
520+ // Remove this popup from parent's activePopupChildren
521+ if sourceNode != nil && popupWebViewID != "" {
522+ for i, childID := range sourceNode.activePopupChildren {
523+ if childID == popupWebViewID {
524+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
525+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
526+ break
527+ }
528+ }
529+ }
530+
412 if n := wm.viewToNode[newView]; n != nil && n.isPopup {531 if n := wm.viewToNode[newView]; n != nil && n.isPopup {
413 time.AfterFunc(200*time.Millisecond, func() {532 time.AfterFunc(200*time.Millisecond, func() {
414 wm.scheduleIdleGuarded(func() bool {533 wm.scheduleIdleGuarded(func() bool {
@@ -486,6 +605,8 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
486 return605 return
487 }606 }
488 node := wm.viewToNode[related]607 node := wm.viewToNode[related]
608+ popupWebViewID := related.ID()
609+
489 if node != nil {610 if node != nil {
490 node.windowType = webkit.WindowTypePopup611 node.windowType = webkit.WindowTypePopup
491 node.windowFeatures = feat612 node.windowFeatures = feat
@@ -494,10 +615,62 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
494 node.isPopup = true615 node.isPopup = true
495 // Heuristic + config for auto-close intent616 // Heuristic + config for auto-close intent
496 node.autoClose = wm.shouldAutoClose(url)617 node.autoClose = wm.shouldAutoClose(url)
618+
619+ // Apply minimum size constraints to prevent compression
620+ var width, height int
621+ if feat != nil {
622+ width = feat.Width
623+ height = feat.Height
624+ }
625+ wm.applyPopupSizeConstraints(related, width, height)
497 }626 }
627+
628+ // Track this popup in the parent's activePopupChildren
629+ // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
630+ if sourceNode != nil && popupWebViewID != "" {
631+ if sourceNode.activePopupChildren == nil {
632+ sourceNode.activePopupChildren = make([]string, 0)
633+ }
634+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
635+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
636+
637+ // Register navigation policy handler for parent to block script-initiated navigations
638+ // when it has active popup children (prevents OAuth from hijacking parent pane)
639+ if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
640+ sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
641+ // Always allow user-initiated navigations
642+ if isUserGesture {
643+ return true
644+ }
645+
646+ // Block script-initiated navigations if this pane has active popup children
647+ if len(sourceNode.activePopupChildren) > 0 {
648+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
649+ return false
650+ }
651+
652+ // Allow if no active popups
653+ return true
654+ })
655+ log.Printf("[workspace] Registered navigation policy handler for parent pane")
656+ }
657+ }
658+
498 // Pipe into existing auto-close flow only for popups (confirmed by detection)659 // Pipe into existing auto-close flow only for popups (confirmed by detection)
499 related.RegisterCloseHandler(func() {660 related.RegisterCloseHandler(func() {
500 log.Printf("[workspace] Popup requesting close via window.close()")661 log.Printf("[workspace] Popup requesting close via window.close()")
662+
663+ // Remove this popup from parent's activePopupChildren
664+ if sourceNode != nil && popupWebViewID != "" {
665+ for i, childID := range sourceNode.activePopupChildren {
666+ if childID == popupWebViewID {
667+ sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
668+ log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
669+ break
670+ }
671+ }
672+ }
673+
501 if n := wm.viewToNode[related]; n != nil && n.isPopup {674 if n := wm.viewToNode[related]; n != nil && n.isPopup {
502 time.AfterFunc(200*time.Millisecond, func() {675 time.AfterFunc(200*time.Millisecond, func() {
503 wm.scheduleIdleGuarded(func() bool {676 wm.scheduleIdleGuarded(func() bool {
@@ -522,6 +695,34 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
522 }695 }
523 }696 }
524 697
698+// applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression
699+func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) {
700+ minWidth := 500 // Default minimum width
701+ minHeight := 600 // Default minimum height
702+
703+ // Use provided dimensions if valid
704+ if width > 0 {
705+ minWidth = width
706+ }
707+ if height > 0 {
708+ minHeight = height
709+ }
710+
711+ // Apply to container
712+ if container := view.RootWidget(); container != 0 && webkit.WidgetIsValid(container) {
713+ webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
714+ webkit.WidgetQueueResize(container)
715+ log.Printf("[workspace] Applied minimum size %dx%d to popup container=%#x", minWidth, minHeight, container)
716+ }
717+
718+ // Apply to WebView widget
719+ if webViewWidget := view.Widget(); webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
720+ webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
721+ webkit.WidgetQueueResize(webViewWidget)
722+ log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%#x", minWidth, minHeight, webViewWidget)
723+ }
724+}
725+
525 // shouldAutoClose checks simple OAuth-like URL patterns and config flag726 // shouldAutoClose checks simple OAuth-like URL patterns and config flag
526 func (wm *WorkspaceManager) shouldAutoClose(url string) bool {727 func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
527 log.Printf("[workspace] shouldAutoClose called for URL: %s", url)728 log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
M internal/app/browser/workspace_types.go +2 -0
@@ -60,7 +60,9 @@ type paneNode struct {
6060 windowFeatures *webkit.WindowFeatures // Features if popup
6161 isRelated bool // Shares context
6262 parentPane *paneNode // Parent for related views
63+ activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
6364 autoClose bool // Auto-close on OAuth success
65+ requestID string // Request ID for popup (used for localStorage cleanup)
6466 hoverToken uintptr
6567 focusControllerToken uintptr
6668 pendingHoverReattach bool
@@ -60,7 +60,9 @@ type paneNode struct {
60 windowFeatures *webkit.WindowFeatures // Features if popup60 windowFeatures *webkit.WindowFeatures // Features if popup
61 isRelated bool // Shares context61 isRelated bool // Shares context
62 parentPane *paneNode // Parent for related views62 parentPane *paneNode // Parent for related views
63+ activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
63 autoClose bool // Auto-close on OAuth success64 autoClose bool // Auto-close on OAuth success
65+ requestID string // Request ID for popup (used for localStorage cleanup)
64 hoverToken uintptr66 hoverToken uintptr
65 focusControllerToken uintptr67 focusControllerToken uintptr
66 pendingHoverReattach bool68 pendingHoverReattach bool
M internal/app/messaging/handler.go +1 -1
@@ -355,7 +355,7 @@ func (h *Handler) handleQuery(msg Message) {
355355 } else {
356356 // Prefer unified page-world API; fallback to legacy global function
357357 script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
358- log.Printf("[DEBUG] Injecting omnibox suggestions: %s", script)
358+ log.Print("[DEBUG] Injecting omnibox suggestions")
359359 if injErr := h.webView.InjectScript(script); injErr != nil {
360360 log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)
361361 } else {
@@ -355,7 +355,7 @@ func (h *Handler) handleQuery(msg Message) {
355 } else {355 } else {
356 // Prefer unified page-world API; fallback to legacy global function356 // Prefer unified page-world API; fallback to legacy global function
357 script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"357 script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
358- log.Printf("[DEBUG] Injecting omnibox suggestions: %s", script)358+ log.Print("[DEBUG] Injecting omnibox suggestions")
359 if injErr := h.webView.InjectScript(script); injErr != nil {359 if injErr := h.webView.InjectScript(script); injErr != nil {
360 log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)360 log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr)
361 } else {361 } else {
M internal/services/browser.go +0 -2
@@ -662,7 +662,5 @@ func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI stri
662662 faviconNullString := sql.NullString{String: faviconURI, Valid: true}
663663 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
664664 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
665- } else {
666- log.Printf("[browser] Successfully stored favicon URI for %s: %s", pageURL, faviconURI)
667665 }
668666 }
@@ -662,7 +662,5 @@ func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI stri
662 faviconNullString := sql.NullString{String: faviconURI, Valid: true}662 faviconNullString := sql.NullString{String: faviconURI, Valid: true}
663 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {663 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
664 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)664 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
665- } else {
666- log.Printf("[browser] Successfully stored favicon URI for %s: %s", pageURL, faviconURI)
667 }665 }
668 }666 }
M pkg/webkit/webview.go +12 -7
@@ -32,13 +32,14 @@ type WebView struct {
3232 uriHandler func(uri string)
3333 faviconHandler func(data []byte)
3434 faviconURIHandler func(pageURI, faviconURI string)
35- zoomHandler func(level float64)
36- popupHandler func(string) *WebView
37- closeHandler func()
38- useDomZoom bool
39- domZoomSeed float64
40- container uintptr
41- id uintptr // WebView unique identifier
35+ zoomHandler func(level float64)
36+ popupHandler func(string) *WebView
37+ closeHandler func()
38+ navigationPolicyHandler func(url string, isUserGesture bool) bool
39+ useDomZoom bool
40+ domZoomSeed float64
41+ container uintptr
42+ id uintptr // WebView unique identifier
4243
4344 // Window type fields (no-op in stub)
4445 windowType WindowType
@@ -156,6 +157,10 @@ func (w *WebView) RegisterPopupHandler(cb func(string) *WebView) { w.popupHandle
156157
157158 func (w *WebView) RegisterCloseHandler(cb func()) { w.closeHandler = cb }
158159
160+func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
161+ w.navigationPolicyHandler = cb
162+}
163+
159164 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks
160165 if w != nil && w.msgHandler != nil {
161166 w.msgHandler(payload)
@@ -32,13 +32,14 @@ type WebView struct {
32 uriHandler func(uri string)32 uriHandler func(uri string)
33 faviconHandler func(data []byte)33 faviconHandler func(data []byte)
34 faviconURIHandler func(pageURI, faviconURI string)34 faviconURIHandler func(pageURI, faviconURI string)
35- zoomHandler func(level float64)35+ zoomHandler func(level float64)
36- popupHandler func(string) *WebView36+ popupHandler func(string) *WebView
37- closeHandler func()37+ closeHandler func()
38- useDomZoom bool38+ navigationPolicyHandler func(url string, isUserGesture bool) bool
39- domZoomSeed float6439+ useDomZoom bool
40- container uintptr40+ domZoomSeed float64
41- id uintptr // WebView unique identifier41+ container uintptr
42+ id uintptr // WebView unique identifier
42 43
43 // Window type fields (no-op in stub)44 // Window type fields (no-op in stub)
44 windowType WindowType45 windowType WindowType
@@ -156,6 +157,10 @@ func (w *WebView) RegisterPopupHandler(cb func(string) *WebView) { w.popupHandle
156 157
157 func (w *WebView) RegisterCloseHandler(cb func()) { w.closeHandler = cb }158 func (w *WebView) RegisterCloseHandler(cb func()) { w.closeHandler = cb }
158 159
160+func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
161+ w.navigationPolicyHandler = cb
162+}
163+
159 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks164 func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks
160 if w != nil && w.msgHandler != nil {165 if w != nil && w.msgHandler != nil {
161 w.msgHandler(payload)166 w.msgHandler(payload)
M pkg/webkit/webview_cgo.go +76 -11
@@ -80,7 +80,10 @@ static void on_webview_close(WebKitWebView* web_view, gpointer user_data) {
8080 }
8181
8282
83-// Handle decide-policy signal for new window requests - more robust than create signal
83+// Forward declare navigation policy handler
84+extern gboolean goHandleNavigationPolicy(unsigned long view_id, char* uri, gboolean is_user_gesture);
85+
86+// Handle decide-policy signal for new window requests and navigations
8487 static gboolean on_decide_policy(WebKitWebView *web_view,
8588 WebKitPolicyDecision *decision,
8689 WebKitPolicyDecisionType type,
@@ -90,6 +93,32 @@ static gboolean on_decide_policy(WebKitWebView *web_view,
9093 const char* type_name = (type >= 0 && type < 3) ? type_names[type] : "UNKNOWN";
9194 printf("[webkit-policy] Policy decision: type=%s (%d)\n", type_name, type);
9295
96+ unsigned long view_id = (unsigned long)user_data;
97+
98+ if (type == WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION) {
99+ WebKitNavigationPolicyDecision *nav_decision =
100+ WEBKIT_NAVIGATION_POLICY_DECISION(decision);
101+ WebKitNavigationAction *action =
102+ webkit_navigation_policy_decision_get_navigation_action(nav_decision);
103+ WebKitURIRequest *request =
104+ webkit_navigation_action_get_request(action);
105+ const char* uri = webkit_uri_request_get_uri(request);
106+ gboolean is_user_gesture = webkit_navigation_action_is_user_gesture(action);
107+
108+ // Ask Go if this navigation should be allowed
109+ gboolean allowed = goHandleNavigationPolicy(view_id, (char*)uri, is_user_gesture);
110+
111+ if (!allowed) {
112+ printf("[webkit-policy] Navigation BLOCKED by Go handler: uri=%s is_user_gesture=%d\n", uri, is_user_gesture);
113+ webkit_policy_decision_ignore(decision);
114+ return TRUE;
115+ }
116+
117+ // Allow navigation
118+ webkit_policy_decision_use(decision);
119+ return TRUE;
120+ }
121+
93122 if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) {
94123 WebKitNavigationPolicyDecision *nav_decision =
95124 WEBKIT_NAVIGATION_POLICY_DECISION(decision);
@@ -111,7 +140,6 @@ static gboolean on_decide_policy(WebKitWebView *web_view,
111140 guint mouse_button = webkit_navigation_action_get_mouse_button(action);
112141
113142 // Call Go handler for popup creation
114- unsigned long view_id = (unsigned long)user_data;
115143 gboolean handled = goHandleNewWindowPolicy(view_id, (char*)uri, nav_type, (char*)frame_name,
116144 is_user_gesture, modifiers, mouse_button);
117145
@@ -248,9 +276,9 @@ static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserCon
248276
249277 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {
250278 if (!web_view) return;
251- // Create signal is now handled in window_handler_cgo.go
252- // Use ConnectCreateSignal from Go to connect the signal
253- printf("[webkit-debug] Policy handler connected (create signal handled separately)\n");
279+ // Connect decide-policy signal for navigation control
280+ g_signal_connect_data(G_OBJECT(web_view), "decide-policy", G_CALLBACK(on_decide_policy), (gpointer)id, NULL, 0);
281+ printf("[webkit-debug] Policy handler connected for WebView id: %lu\n", id);
254282 }
255283
256284 // Connect close signal to a WebView for popup auto-close functionality
@@ -987,12 +1015,13 @@ type WebView struct {
9871015 wasReparented bool // Track if this WebView has been reparented
9881016
9891017 // Window type detection and relationships
990- windowType WindowType
991- windowFeatures *WindowFeatures
992- windowTypeCallback func(WindowType, *WindowFeatures)
993- parentWebView *WebView
994- isRelated bool
995- isActive bool // Track if this WebView is currently active/focused
1018+ windowType WindowType
1019+ windowFeatures *WindowFeatures
1020+ windowTypeCallback func(WindowType, *WindowFeatures)
1021+ parentWebView *WebView
1022+ isRelated bool
1023+ isActive bool // Track if this WebView is currently active/focused
1024+ navigationPolicyHandler func(url string, isUserGesture bool) bool // Handler to decide if navigation should be allowed
9961025 }
9971026
9981027 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -1769,6 +1798,11 @@ func (w *WebView) RegisterCloseHandler(cb func()) {
17691798 }
17701799 }
17711800
1801+// RegisterNavigationPolicyHandler sets a handler to control navigation decisions
1802+func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
1803+ w.navigationPolicyHandler = cb
1804+}
1805+
17721806 // EnablePrintInterception connects the print signal to intercept Ctrl+P
17731807 func (w *WebView) EnablePrintInterception() {
17741808 if w.native != nil && w.native.wv != nil {
@@ -2370,6 +2404,37 @@ func goHandleConsoleMessage(message *C.char) {
23702404 logging.CaptureWebKitLog(messageText)
23712405 }
23722406
2407+//export goHandleNavigationPolicy
2408+func goHandleNavigationPolicy(viewID C.ulong, uri *C.char, isUserGesture C.gboolean) C.gboolean {
2409+ id := uintptr(viewID)
2410+ urlStr := C.GoString(uri)
2411+ userGesture := isUserGesture != 0
2412+
2413+ log.Printf("[navigation-policy] WebView %d navigating to: %s (user_gesture=%t)", id, urlStr, userGesture)
2414+
2415+ // Find the WebView
2416+ regMu.RLock()
2417+ view, exists := viewByID[id]
2418+ regMu.RUnlock()
2419+
2420+ if !exists || view == nil {
2421+ log.Printf("[navigation-policy] WebView %d not found, allowing navigation", id)
2422+ return C.TRUE // Allow by default if view not found
2423+ }
2424+
2425+ // Check if this webview has a navigation policy handler
2426+ if view.navigationPolicyHandler != nil {
2427+ allowed := view.navigationPolicyHandler(urlStr, userGesture)
2428+ if !allowed {
2429+ log.Printf("[navigation-policy] WebView %d: Navigation BLOCKED by handler", id)
2430+ return C.FALSE
2431+ }
2432+ }
2433+
2434+ // Allow navigation
2435+ return C.TRUE
2436+}
2437+
23732438 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability
23742439 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {
23752440 if w == nil || w.native == nil || w.native.win == nil {
@@ -80,7 +80,10 @@ static void on_webview_close(WebKitWebView* web_view, gpointer user_data) {
80 }80 }
81 81
82 82
83-// Handle decide-policy signal for new window requests - more robust than create signal83+// Forward declare navigation policy handler
84+extern gboolean goHandleNavigationPolicy(unsigned long view_id, char* uri, gboolean is_user_gesture);
85+
86+// Handle decide-policy signal for new window requests and navigations
84 static gboolean on_decide_policy(WebKitWebView *web_view,87 static gboolean on_decide_policy(WebKitWebView *web_view,
85 WebKitPolicyDecision *decision,88 WebKitPolicyDecision *decision,
86 WebKitPolicyDecisionType type,89 WebKitPolicyDecisionType type,
@@ -90,6 +93,32 @@ static gboolean on_decide_policy(WebKitWebView *web_view,
90 const char* type_name = (type >= 0 && type < 3) ? type_names[type] : "UNKNOWN";93 const char* type_name = (type >= 0 && type < 3) ? type_names[type] : "UNKNOWN";
91 printf("[webkit-policy] Policy decision: type=%s (%d)\n", type_name, type);94 printf("[webkit-policy] Policy decision: type=%s (%d)\n", type_name, type);
92 95
96+ unsigned long view_id = (unsigned long)user_data;
97+
98+ if (type == WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION) {
99+ WebKitNavigationPolicyDecision *nav_decision =
100+ WEBKIT_NAVIGATION_POLICY_DECISION(decision);
101+ WebKitNavigationAction *action =
102+ webkit_navigation_policy_decision_get_navigation_action(nav_decision);
103+ WebKitURIRequest *request =
104+ webkit_navigation_action_get_request(action);
105+ const char* uri = webkit_uri_request_get_uri(request);
106+ gboolean is_user_gesture = webkit_navigation_action_is_user_gesture(action);
107+
108+ // Ask Go if this navigation should be allowed
109+ gboolean allowed = goHandleNavigationPolicy(view_id, (char*)uri, is_user_gesture);
110+
111+ if (!allowed) {
112+ printf("[webkit-policy] Navigation BLOCKED by Go handler: uri=%s is_user_gesture=%d\n", uri, is_user_gesture);
113+ webkit_policy_decision_ignore(decision);
114+ return TRUE;
115+ }
116+
117+ // Allow navigation
118+ webkit_policy_decision_use(decision);
119+ return TRUE;
120+ }
121+
93 if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) {122 if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) {
94 WebKitNavigationPolicyDecision *nav_decision =123 WebKitNavigationPolicyDecision *nav_decision =
95 WEBKIT_NAVIGATION_POLICY_DECISION(decision);124 WEBKIT_NAVIGATION_POLICY_DECISION(decision);
@@ -111,7 +140,6 @@ static gboolean on_decide_policy(WebKitWebView *web_view,
111 guint mouse_button = webkit_navigation_action_get_mouse_button(action);140 guint mouse_button = webkit_navigation_action_get_mouse_button(action);
112 141
113 // Call Go handler for popup creation142 // Call Go handler for popup creation
114- unsigned long view_id = (unsigned long)user_data;
115 gboolean handled = goHandleNewWindowPolicy(view_id, (char*)uri, nav_type, (char*)frame_name,143 gboolean handled = goHandleNewWindowPolicy(view_id, (char*)uri, nav_type, (char*)frame_name,
116 is_user_gesture, modifiers, mouse_button);144 is_user_gesture, modifiers, mouse_button);
117 145
@@ -248,9 +276,9 @@ static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserCon
248 276
249 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {277 static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {
250 if (!web_view) return;278 if (!web_view) return;
251- // Create signal is now handled in window_handler_cgo.go279+ // Connect decide-policy signal for navigation control
252- // Use ConnectCreateSignal from Go to connect the signal280+ g_signal_connect_data(G_OBJECT(web_view), "decide-policy", G_CALLBACK(on_decide_policy), (gpointer)id, NULL, 0);
253- printf("[webkit-debug] Policy handler connected (create signal handled separately)\n");281+ printf("[webkit-debug] Policy handler connected for WebView id: %lu\n", id);
254 }282 }
255 283
256 // Connect close signal to a WebView for popup auto-close functionality284 // Connect close signal to a WebView for popup auto-close functionality
@@ -987,12 +1015,13 @@ type WebView struct {
987 wasReparented bool // Track if this WebView has been reparented1015 wasReparented bool // Track if this WebView has been reparented
988 1016
989 // Window type detection and relationships1017 // Window type detection and relationships
990- windowType WindowType1018+ windowType WindowType
991- windowFeatures *WindowFeatures1019+ windowFeatures *WindowFeatures
992- windowTypeCallback func(WindowType, *WindowFeatures)1020+ windowTypeCallback func(WindowType, *WindowFeatures)
993- parentWebView *WebView1021+ parentWebView *WebView
994- isRelated bool1022+ isRelated bool
995- isActive bool // Track if this WebView is currently active/focused1023+ isActive bool // Track if this WebView is currently active/focused
1024+ navigationPolicyHandler func(url string, isUserGesture bool) bool // Handler to decide if navigation should be allowed
996 }1025 }
997 1026
998 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.1027 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
@@ -1769,6 +1798,11 @@ func (w *WebView) RegisterCloseHandler(cb func()) {
1769 }1798 }
1770 }1799 }
1771 1800
1801+// RegisterNavigationPolicyHandler sets a handler to control navigation decisions
1802+func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
1803+ w.navigationPolicyHandler = cb
1804+}
1805+
1772 // EnablePrintInterception connects the print signal to intercept Ctrl+P1806 // EnablePrintInterception connects the print signal to intercept Ctrl+P
1773 func (w *WebView) EnablePrintInterception() {1807 func (w *WebView) EnablePrintInterception() {
1774 if w.native != nil && w.native.wv != nil {1808 if w.native != nil && w.native.wv != nil {
@@ -2370,6 +2404,37 @@ func goHandleConsoleMessage(message *C.char) {
2370 logging.CaptureWebKitLog(messageText)2404 logging.CaptureWebKitLog(messageText)
2371 }2405 }
2372 2406
2407+//export goHandleNavigationPolicy
2408+func goHandleNavigationPolicy(viewID C.ulong, uri *C.char, isUserGesture C.gboolean) C.gboolean {
2409+ id := uintptr(viewID)
2410+ urlStr := C.GoString(uri)
2411+ userGesture := isUserGesture != 0
2412+
2413+ log.Printf("[navigation-policy] WebView %d navigating to: %s (user_gesture=%t)", id, urlStr, userGesture)
2414+
2415+ // Find the WebView
2416+ regMu.RLock()
2417+ view, exists := viewByID[id]
2418+ regMu.RUnlock()
2419+
2420+ if !exists || view == nil {
2421+ log.Printf("[navigation-policy] WebView %d not found, allowing navigation", id)
2422+ return C.TRUE // Allow by default if view not found
2423+ }
2424+
2425+ // Check if this webview has a navigation policy handler
2426+ if view.navigationPolicyHandler != nil {
2427+ allowed := view.navigationPolicyHandler(urlStr, userGesture)
2428+ if !allowed {
2429+ log.Printf("[navigation-policy] WebView %d: Navigation BLOCKED by handler", id)
2430+ return C.FALSE
2431+ }
2432+ }
2433+
2434+ // Allow navigation
2435+ return C.TRUE
2436+}
2437+
2373 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability2438 // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability
2374 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {2439 func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {
2375 if w == nil || w.native == nil || w.native.win == nil {2440 if w == nil || w.native == nil || w.native.win == nil {
M pkg/webkit/workspace_widgets_cgo.go +32 -0
@@ -586,6 +586,14 @@ func PanedSetPosition(paned uintptr, pos int) {
586586 C.gtk_paned_set_position((*C.GtkPaned)(unsafe.Pointer(paned)), C.int(pos))
587587 }
588588
589+// PanedGetPosition gets the current position of the divider in a GtkPaned
590+func PanedGetPosition(paned uintptr) int {
591+ if paned == 0 {
592+ return 0
593+ }
594+ return int(C.gtk_paned_get_position((*C.GtkPaned)(unsafe.Pointer(paned))))
595+}
596+
589597 // WidgetUnparent detaches a widget from its current parent.
590598 func WidgetUnparent(widget uintptr) {
591599 if ok := WidgetUnparentChecked(widget); !ok {
@@ -661,6 +669,16 @@ func WidgetSetVExpand(widget uintptr, expand bool) {
661669 C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))
662670 }
663671
672+// WidgetSetSizeRequest sets the minimum size request for a widget.
673+// Use -1 for width or height to unset that dimension.
674+func WidgetSetSizeRequest(widget uintptr, width, height int) {
675+ if widget == 0 || !widgetIsValid(widget) {
676+ return
677+ }
678+ log.Printf("[workspace] WidgetSetSizeRequest widget=%#x width=%d height=%d", widget, width, height)
679+ C.gtk_widget_set_size_request((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(width), C.int(height))
680+}
681+
664682 // WidgetResetSizeRequest clears explicit size constraints so GTK can recalculate allocation.
665683 func WidgetResetSizeRequest(widget uintptr) {
666684 if widget == 0 || !widgetIsValid(widget) {
@@ -737,6 +755,20 @@ func WidgetQueueAllocate(widget uintptr) {
737755 C.gtk_widget_queue_allocate((*C.GtkWidget)(unsafe.Pointer(widget)))
738756 }
739757
758+// WidgetQueueResize forces GTK to recalculate the widget's layout and size allocation.
759+// This is useful when a widget's size requirements have changed (e.g., after adding children).
760+func WidgetQueueResize(widget uintptr) {
761+ if widget == 0 {
762+ return
763+ }
764+ if !widgetIsValid(widget) {
765+ log.Printf("[workspace] WidgetQueueResize skipped invalid widget=%#x", widget)
766+ return
767+ }
768+ log.Printf("[workspace] WidgetQueueResize widget=%#x", widget)
769+ C.gtk_widget_queue_resize((*C.GtkWidget)(unsafe.Pointer(widget)))
770+}
771+
740772 // WidgetRealizeInContainer ensures a widget is properly realized within a container
741773 func WidgetRealizeInContainer(widget uintptr) {
742774 if widget == 0 {
@@ -586,6 +586,14 @@ func PanedSetPosition(paned uintptr, pos int) {
586 C.gtk_paned_set_position((*C.GtkPaned)(unsafe.Pointer(paned)), C.int(pos))586 C.gtk_paned_set_position((*C.GtkPaned)(unsafe.Pointer(paned)), C.int(pos))
587 }587 }
588 588
589+// PanedGetPosition gets the current position of the divider in a GtkPaned
590+func PanedGetPosition(paned uintptr) int {
591+ if paned == 0 {
592+ return 0
593+ }
594+ return int(C.gtk_paned_get_position((*C.GtkPaned)(unsafe.Pointer(paned))))
595+}
596+
589 // WidgetUnparent detaches a widget from its current parent.597 // WidgetUnparent detaches a widget from its current parent.
590 func WidgetUnparent(widget uintptr) {598 func WidgetUnparent(widget uintptr) {
591 if ok := WidgetUnparentChecked(widget); !ok {599 if ok := WidgetUnparentChecked(widget); !ok {
@@ -661,6 +669,16 @@ func WidgetSetVExpand(widget uintptr, expand bool) {
661 C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))669 C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))
662 }670 }
663 671
672+// WidgetSetSizeRequest sets the minimum size request for a widget.
673+// Use -1 for width or height to unset that dimension.
674+func WidgetSetSizeRequest(widget uintptr, width, height int) {
675+ if widget == 0 || !widgetIsValid(widget) {
676+ return
677+ }
678+ log.Printf("[workspace] WidgetSetSizeRequest widget=%#x width=%d height=%d", widget, width, height)
679+ C.gtk_widget_set_size_request((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(width), C.int(height))
680+}
681+
664 // WidgetResetSizeRequest clears explicit size constraints so GTK can recalculate allocation.682 // WidgetResetSizeRequest clears explicit size constraints so GTK can recalculate allocation.
665 func WidgetResetSizeRequest(widget uintptr) {683 func WidgetResetSizeRequest(widget uintptr) {
666 if widget == 0 || !widgetIsValid(widget) {684 if widget == 0 || !widgetIsValid(widget) {
@@ -737,6 +755,20 @@ func WidgetQueueAllocate(widget uintptr) {
737 C.gtk_widget_queue_allocate((*C.GtkWidget)(unsafe.Pointer(widget)))755 C.gtk_widget_queue_allocate((*C.GtkWidget)(unsafe.Pointer(widget)))
738 }756 }
739 757
758+// WidgetQueueResize forces GTK to recalculate the widget's layout and size allocation.
759+// This is useful when a widget's size requirements have changed (e.g., after adding children).
760+func WidgetQueueResize(widget uintptr) {
761+ if widget == 0 {
762+ return
763+ }
764+ if !widgetIsValid(widget) {
765+ log.Printf("[workspace] WidgetQueueResize skipped invalid widget=%#x", widget)
766+ return
767+ }
768+ log.Printf("[workspace] WidgetQueueResize widget=%#x", widget)
769+ C.gtk_widget_queue_resize((*C.GtkWidget)(unsafe.Pointer(widget)))
770+}
771+
740 // WidgetRealizeInContainer ensures a widget is properly realized within a container772 // WidgetRealizeInContainer ensures a widget is properly realized within a container
741 func WidgetRealizeInContainer(widget uintptr) {773 func WidgetRealizeInContainer(widget uintptr) {
742 if widget == 0 {774 if widget == 0 {
M pkg/webkit/workspace_widgets_stub.go +7 -0
@@ -199,6 +199,9 @@ func PanedSetResizeEnd(paned uintptr, resize bool) {}
199199 // PanedSetPosition is a no-op in stub builds.
200200 func PanedSetPosition(paned uintptr, pos int) {}
201201
202+// PanedGetPosition gets the current position of the divider in a GtkPaned (stub)
203+func PanedGetPosition(paned uintptr) int { return 0 }
204+
202205 func WidgetUnparent(widget uintptr) {
203206 widgetMu.Lock()
204207 defer widgetMu.Unlock()
@@ -289,8 +292,12 @@ func WidgetQueueAllocate(widget uintptr) {}
289292
290293 func WidgetRealizeInContainer(widget uintptr) {}
291294
295+func WidgetSetSizeRequest(widget uintptr, width, height int) {}
296+
292297 func WidgetResetSizeRequest(widget uintptr) {}
293298
299+func WidgetQueueResize(widget uintptr) {}
300+
294301 func WidgetHookDestroy(widget uintptr) {}
295302
296303 func WidgetIsValid(widget uintptr) bool {
@@ -199,6 +199,9 @@ func PanedSetResizeEnd(paned uintptr, resize bool) {}
199 // PanedSetPosition is a no-op in stub builds.199 // PanedSetPosition is a no-op in stub builds.
200 func PanedSetPosition(paned uintptr, pos int) {}200 func PanedSetPosition(paned uintptr, pos int) {}
201 201
202+// PanedGetPosition gets the current position of the divider in a GtkPaned (stub)
203+func PanedGetPosition(paned uintptr) int { return 0 }
204+
202 func WidgetUnparent(widget uintptr) {205 func WidgetUnparent(widget uintptr) {
203 widgetMu.Lock()206 widgetMu.Lock()
204 defer widgetMu.Unlock()207 defer widgetMu.Unlock()
@@ -289,8 +292,12 @@ func WidgetQueueAllocate(widget uintptr) {}
289 292
290 func WidgetRealizeInContainer(widget uintptr) {}293 func WidgetRealizeInContainer(widget uintptr) {}
291 294
295+func WidgetSetSizeRequest(widget uintptr, width, height int) {}
296+
292 func WidgetResetSizeRequest(widget uintptr) {}297 func WidgetResetSizeRequest(widget uintptr) {}
293 298
299+func WidgetQueueResize(widget uintptr) {}
300+
294 func WidgetHookDestroy(widget uintptr) {}301 func WidgetHookDestroy(widget uintptr) {}
295 302
296 func WidgetIsValid(widget uintptr) bool {303 func WidgetIsValid(widget uintptr) bool {

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