detached d96730e

fix: address code review findings (port abstraction, races, error handling, lint)

- Add PopupCapable and OAuthCallbackCapable capability interfaces to port layer
- Replace *webkit.WebView type assertions in popup.go and oauth.go with port interfaces
- Fix data races: lock webViews map access in popup close/lookup paths
- Use incoming ctx instead of context.Background() in navigation callbacks
- Log errors from OpenDevTools, PrintPage, and wv.Stop instead of discarding
- Replace panic in RegisterScheme stub with warning log
- Add warn logging on failed type assertion in pool Release
- Use require-style checks in migrate_engine_test.go instead of bare assertions
- Change coordinator.go to use port.ContentInjector instead of webkit.ContentInjector
- Fix unused receivers, long lines, and misspelling for lint compliance
commit
d96730e30dd2e9f6342387b216e9053b3a548c32
author
brice · · 2026-03-15 18:23 UTC
parent
7c11dec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +190 -70
M internal/application/port/webview.go +25 -0
@@ -272,3 +272,28 @@ type DevToolsOpener interface {
272272 type Printer interface {
273273 PrintPage()
274274 }
275+
276+// PopupCapable is implemented by WebViews that support popup lifecycle callbacks.
277+// SetOnClose composes the provided function with any existing close handler so
278+// that multiple callers can each register a close callback without overwriting
279+// one another.
280+type PopupCapable interface {
281+ SetOnReadyToShow(fn func())
282+ SetOnClose(fn func())
283+ Show()
284+}
285+
286+// OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.
287+// It is an optional capability; callers should type-assert port.WebView to this interface
288+// before use and degrade gracefully when it is absent.
289+type OAuthCallbackCapable interface {
290+ // AddCloseCallback registers fn to be called when the WebView is programmatically closed.
291+ // Multiple callbacks may be registered; each will be called in registration order.
292+ AddCloseCallback(fn func())
293+ // AddNavigationCallback registers fn to be called whenever the URI changes or a
294+ // committed load is detected. The uri parameter contains the new URL.
295+ // Multiple callbacks may be registered; each will be called in registration order.
296+ AddNavigationCallback(fn func(uri string))
297+ // Close triggers a programmatic close of the WebView (equivalent to window.close()).
298+ Close()
299+}
@@ -272,3 +272,28 @@ type DevToolsOpener interface {
272 type Printer interface {272 type Printer interface {
273 PrintPage()273 PrintPage()
274 }274 }
275+
276+// PopupCapable is implemented by WebViews that support popup lifecycle callbacks.
277+// SetOnClose composes the provided function with any existing close handler so
278+// that multiple callers can each register a close callback without overwriting
279+// one another.
280+type PopupCapable interface {
281+ SetOnReadyToShow(fn func())
282+ SetOnClose(fn func())
283+ Show()
284+}
285+
286+// OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.
287+// It is an optional capability; callers should type-assert port.WebView to this interface
288+// before use and degrade gracefully when it is absent.
289+type OAuthCallbackCapable interface {
290+ // AddCloseCallback registers fn to be called when the WebView is programmatically closed.
291+ // Multiple callbacks may be registered; each will be called in registration order.
292+ AddCloseCallback(fn func())
293+ // AddNavigationCallback registers fn to be called whenever the URI changes or a
294+ // committed load is detected. The uri parameter contains the new URL.
295+ // Multiple callbacks may be registered; each will be called in registration order.
296+ AddNavigationCallback(fn func(uri string))
297+ // Close triggers a programmatic close of the WebView (equivalent to window.close()).
298+ Close()
299+}
M internal/infrastructure/config/migrate_engine_test.go +8 -2
@@ -240,8 +240,14 @@ disable_dmabuf_renderer = false
240240 assert.NotContains(t, media, "gstreamer_debug_level")
241241
242242 // Migrated fields should be in engine.webkit
243- engine := raw["engine"].(map[string]any)
244- webkit := engine["webkit"].(map[string]any)
243+ engineAny, ok := raw["engine"]
244+ require.True(t, ok, "engine section must exist")
245+ engine, ok := engineAny.(map[string]any)
246+ require.True(t, ok, "engine section must be a map")
247+ webkitAny, ok := engine["webkit"]
248+ require.True(t, ok, "engine.webkit section must exist")
249+ webkit, ok := webkitAny.(map[string]any)
250+ require.True(t, ok, "engine.webkit section must be a map")
245251 assert.Equal(t, true, webkit["force_vsync"])
246252 assert.Equal(t, "auto", webkit["gl_rendering_mode"])
247253 assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])
@@ -240,8 +240,14 @@ disable_dmabuf_renderer = false
240 assert.NotContains(t, media, "gstreamer_debug_level")240 assert.NotContains(t, media, "gstreamer_debug_level")
241 241
242 // Migrated fields should be in engine.webkit242 // Migrated fields should be in engine.webkit
243- engine := raw["engine"].(map[string]any)243+ engineAny, ok := raw["engine"]
244- webkit := engine["webkit"].(map[string]any)244+ require.True(t, ok, "engine section must exist")
245+ engine, ok := engineAny.(map[string]any)
246+ require.True(t, ok, "engine section must be a map")
247+ webkitAny, ok := engine["webkit"]
248+ require.True(t, ok, "engine.webkit section must exist")
249+ webkit, ok := webkitAny.(map[string]any)
250+ require.True(t, ok, "engine.webkit section must be a map")
245 assert.Equal(t, true, webkit["force_vsync"])251 assert.Equal(t, true, webkit["force_vsync"])
246 assert.Equal(t, "auto", webkit["gl_rendering_mode"])252 assert.Equal(t, "auto", webkit["gl_rendering_mode"])
247 assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])253 assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])
M internal/infrastructure/webkit/engine.go +5 -3
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/dumber/internal/application/port"
77 "github.com/bnema/dumber/internal/infrastructure/filtering"
8+ "github.com/rs/zerolog"
89 )
910
1011 // Engine implements port.Engine for the WebKit browser engine.
@@ -18,13 +19,14 @@ type Engine struct {
1819 filterManager *filtering.Manager
1920 schemeHandler *DumbSchemeHandler
2021 schemePath string
22+ logger zerolog.Logger
2123 }
2224
2325 // Compile-time check that Engine implements port.Engine.
2426 var _ port.Engine = (*Engine)(nil)
2527
2628 // Init is a no-op for WebKit — initialization happens in NewEngine.
27-func (e *Engine) Init(_ context.Context, _ port.EngineOptions) error {
29+func (*Engine) Init(_ context.Context, _ port.EngineOptions) error {
2830 return nil
2931 }
3032
@@ -35,7 +37,7 @@ func (e *Engine) Factory() port.WebViewFactory {
3537
3638 // Pool returns the WebViewPool wrapped as a port.WebViewPool.
3739 func (e *Engine) Pool() port.WebViewPool {
38- return &webViewPoolAdapter{pool: e.pool}
40+ return &webViewPoolAdapter{pool: e.pool, logger: e.logger}
3941 }
4042
4143 // ContentInjector returns the ContentInjector implementing port.ContentInjector.
@@ -58,7 +60,7 @@ func (e *Engine) Close() error {
5860
5961 // SchemeHandler returns a port.SchemeHandler adapter for the DumbSchemeHandler.
6062 func (e *Engine) SchemeHandler() port.SchemeHandler {
61- return &schemeHandlerAdapter{handler: e.schemeHandler}
63+ return &schemeHandlerAdapter{handler: e.schemeHandler, logger: e.logger}
6264 }
6365
6466 // MessageRouter returns a port.MessageRouter adapter for the internal MessageRouter.
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/dumber/internal/application/port"6 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/infrastructure/filtering"7 "github.com/bnema/dumber/internal/infrastructure/filtering"
8+ "github.com/rs/zerolog"
8 )9 )
9 10
10 // Engine implements port.Engine for the WebKit browser engine.11 // Engine implements port.Engine for the WebKit browser engine.
@@ -18,13 +19,14 @@ type Engine struct {
18 filterManager *filtering.Manager19 filterManager *filtering.Manager
19 schemeHandler *DumbSchemeHandler20 schemeHandler *DumbSchemeHandler
20 schemePath string21 schemePath string
22+ logger zerolog.Logger
21 }23 }
22 24
23 // Compile-time check that Engine implements port.Engine.25 // Compile-time check that Engine implements port.Engine.
24 var _ port.Engine = (*Engine)(nil)26 var _ port.Engine = (*Engine)(nil)
25 27
26 // Init is a no-op for WebKit — initialization happens in NewEngine.28 // Init is a no-op for WebKit — initialization happens in NewEngine.
27-func (e *Engine) Init(_ context.Context, _ port.EngineOptions) error {29+func (*Engine) Init(_ context.Context, _ port.EngineOptions) error {
28 return nil30 return nil
29 }31 }
30 32
@@ -35,7 +37,7 @@ func (e *Engine) Factory() port.WebViewFactory {
35 37
36 // Pool returns the WebViewPool wrapped as a port.WebViewPool.38 // Pool returns the WebViewPool wrapped as a port.WebViewPool.
37 func (e *Engine) Pool() port.WebViewPool {39 func (e *Engine) Pool() port.WebViewPool {
38- return &webViewPoolAdapter{pool: e.pool}40+ return &webViewPoolAdapter{pool: e.pool, logger: e.logger}
39 }41 }
40 42
41 // ContentInjector returns the ContentInjector implementing port.ContentInjector.43 // ContentInjector returns the ContentInjector implementing port.ContentInjector.
@@ -58,7 +60,7 @@ func (e *Engine) Close() error {
58 60
59 // SchemeHandler returns a port.SchemeHandler adapter for the DumbSchemeHandler.61 // SchemeHandler returns a port.SchemeHandler adapter for the DumbSchemeHandler.
60 func (e *Engine) SchemeHandler() port.SchemeHandler {62 func (e *Engine) SchemeHandler() port.SchemeHandler {
61- return &schemeHandlerAdapter{handler: e.schemeHandler}63+ return &schemeHandlerAdapter{handler: e.schemeHandler, logger: e.logger}
62 }64 }
63 65
64 // MessageRouter returns a port.MessageRouter adapter for the internal MessageRouter.66 // MessageRouter returns a port.MessageRouter adapter for the internal MessageRouter.
M internal/infrastructure/webkit/engine_adapters.go +18 -8
@@ -6,6 +6,7 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/infrastructure/filtering"
9+ "github.com/rs/zerolog"
910 )
1011
1112 // --- WebViewFactory adapter ---
@@ -39,7 +40,8 @@ func (a *webViewFactoryAdapter) CreateRelated(ctx context.Context, parentID port
3940 // WebViewPool methods use *WebView and require a context; this adapter adapts
4041 // the signatures to match the port interface.
4142 type webViewPoolAdapter struct {
42- pool *WebViewPool
43+ pool *WebViewPool
44+ logger zerolog.Logger
4345 }
4446
4547 func (a *webViewPoolAdapter) Acquire(ctx context.Context) (port.WebView, error) {
@@ -54,9 +56,14 @@ func (a *webViewPoolAdapter) Release(wv port.WebView) {
5456 if wv == nil {
5557 return
5658 }
57- if wwv, ok := wv.(*WebView); ok {
58- a.pool.Release(context.Background(), wwv)
59+ wwv, ok := wv.(*WebView)
60+ if !ok {
61+ a.logger.Warn().
62+ Str("concrete_type", fmt.Sprintf("%T", wv)).
63+ Msg("webViewPoolAdapter.Release: unexpected type, cannot release to pool")
64+ return
5965 }
66+ a.pool.Release(context.Background(), wwv)
6067 }
6168
6269 func (a *webViewPoolAdapter) Prewarm(count int) {
@@ -79,12 +86,15 @@ func (a *webViewPoolAdapter) Close() {
7986 // consumers are migrated.
8087 type schemeHandlerAdapter struct {
8188 handler *DumbSchemeHandler
89+ logger zerolog.Logger
8290 }
8391
84-func (a *schemeHandlerAdapter) RegisterScheme(_ string, _ func(uri string) ([]byte, string, error)) {
92+func (a *schemeHandlerAdapter) RegisterScheme(scheme string, _ func(uri string) ([]byte, string, error)) {
8593 // DumbSchemeHandler handles the "dumb" scheme exclusively via RegisterPage/RegisterWithContext.
8694 // Generic scheme registration is not yet wired through this adapter.
87- panic("not implemented — DumbSchemeHandler uses RegisterPage/RegisterWithContext pattern")
95+ a.logger.Warn().
96+ Str("scheme", scheme).
97+ Msg("schemeHandlerAdapter.RegisterScheme: not implemented — DumbSchemeHandler uses RegisterPage/RegisterWithContext pattern")
8898 }
8999
90100 // --- MessageRouter adapter ---
@@ -97,13 +107,13 @@ type messageRouterAdapter struct {
97107 router *MessageRouter
98108 }
99109
100-func (a *messageRouterAdapter) RegisterHandler(_ string, _ func(message string) (string, error)) {
110+func (*messageRouterAdapter) RegisterHandler(_ string, _ func(message string) (string, error)) {
101111 // Internal MessageRouter.RegisterHandler takes a MessageHandler interface, not a plain func.
102112 // Wire up via RegisterHandler(msgType, MessageHandlerFunc{...}) when consumers are migrated.
103113 panic("not implemented — use MessageRouter.RegisterHandler(msgType, MessageHandler) directly")
104114 }
105115
106-func (a *messageRouterAdapter) PostMessage(webviewID port.WebViewID, message string) error {
116+func (*messageRouterAdapter) PostMessage(webviewID port.WebViewID, message string) error {
107117 wv := LookupWebView(webviewID)
108118 if wv == nil {
109119 return fmt.Errorf("webview %d not found", webviewID)
@@ -151,7 +161,7 @@ type faviconDatabaseAdapter struct {
151161 wkCtx *WebKitContext
152162 }
153163
154-func (a *faviconDatabaseAdapter) GetFaviconAsync(_ string, callback func(port.Texture)) {
164+func (*faviconDatabaseAdapter) GetFaviconAsync(_ string, callback func(port.Texture)) {
155165 // FaviconDatabase async lookup is not yet wired through this adapter.
156166 // The underlying API is: wkCtx.FaviconDatabase().GetFavicon(uri, callback).
157167 // This stub satisfies the interface contract; callers will receive nil.
@@ -6,6 +6,7 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/infrastructure/filtering"8 "github.com/bnema/dumber/internal/infrastructure/filtering"
9+ "github.com/rs/zerolog"
9 )10 )
10 11
11 // --- WebViewFactory adapter ---12 // --- WebViewFactory adapter ---
@@ -39,7 +40,8 @@ func (a *webViewFactoryAdapter) CreateRelated(ctx context.Context, parentID port
39 // WebViewPool methods use *WebView and require a context; this adapter adapts40 // WebViewPool methods use *WebView and require a context; this adapter adapts
40 // the signatures to match the port interface.41 // the signatures to match the port interface.
41 type webViewPoolAdapter struct {42 type webViewPoolAdapter struct {
42- pool *WebViewPool43+ pool *WebViewPool
44+ logger zerolog.Logger
43 }45 }
44 46
45 func (a *webViewPoolAdapter) Acquire(ctx context.Context) (port.WebView, error) {47 func (a *webViewPoolAdapter) Acquire(ctx context.Context) (port.WebView, error) {
@@ -54,9 +56,14 @@ func (a *webViewPoolAdapter) Release(wv port.WebView) {
54 if wv == nil {56 if wv == nil {
55 return57 return
56 }58 }
57- if wwv, ok := wv.(*WebView); ok {59+ wwv, ok := wv.(*WebView)
58- a.pool.Release(context.Background(), wwv)60+ if !ok {
61+ a.logger.Warn().
62+ Str("concrete_type", fmt.Sprintf("%T", wv)).
63+ Msg("webViewPoolAdapter.Release: unexpected type, cannot release to pool")
64+ return
59 }65 }
66+ a.pool.Release(context.Background(), wwv)
60 }67 }
61 68
62 func (a *webViewPoolAdapter) Prewarm(count int) {69 func (a *webViewPoolAdapter) Prewarm(count int) {
@@ -79,12 +86,15 @@ func (a *webViewPoolAdapter) Close() {
79 // consumers are migrated.86 // consumers are migrated.
80 type schemeHandlerAdapter struct {87 type schemeHandlerAdapter struct {
81 handler *DumbSchemeHandler88 handler *DumbSchemeHandler
89+ logger zerolog.Logger
82 }90 }
83 91
84-func (a *schemeHandlerAdapter) RegisterScheme(_ string, _ func(uri string) ([]byte, string, error)) {92+func (a *schemeHandlerAdapter) RegisterScheme(scheme string, _ func(uri string) ([]byte, string, error)) {
85 // DumbSchemeHandler handles the "dumb" scheme exclusively via RegisterPage/RegisterWithContext.93 // DumbSchemeHandler handles the "dumb" scheme exclusively via RegisterPage/RegisterWithContext.
86 // Generic scheme registration is not yet wired through this adapter.94 // Generic scheme registration is not yet wired through this adapter.
87- panic("not implemented — DumbSchemeHandler uses RegisterPage/RegisterWithContext pattern")95+ a.logger.Warn().
96+ Str("scheme", scheme).
97+ Msg("schemeHandlerAdapter.RegisterScheme: not implemented — DumbSchemeHandler uses RegisterPage/RegisterWithContext pattern")
88 }98 }
89 99
90 // --- MessageRouter adapter ---100 // --- MessageRouter adapter ---
@@ -97,13 +107,13 @@ type messageRouterAdapter struct {
97 router *MessageRouter107 router *MessageRouter
98 }108 }
99 109
100-func (a *messageRouterAdapter) RegisterHandler(_ string, _ func(message string) (string, error)) {110+func (*messageRouterAdapter) RegisterHandler(_ string, _ func(message string) (string, error)) {
101 // Internal MessageRouter.RegisterHandler takes a MessageHandler interface, not a plain func.111 // Internal MessageRouter.RegisterHandler takes a MessageHandler interface, not a plain func.
102 // Wire up via RegisterHandler(msgType, MessageHandlerFunc{...}) when consumers are migrated.112 // Wire up via RegisterHandler(msgType, MessageHandlerFunc{...}) when consumers are migrated.
103 panic("not implemented — use MessageRouter.RegisterHandler(msgType, MessageHandler) directly")113 panic("not implemented — use MessageRouter.RegisterHandler(msgType, MessageHandler) directly")
104 }114 }
105 115
106-func (a *messageRouterAdapter) PostMessage(webviewID port.WebViewID, message string) error {116+func (*messageRouterAdapter) PostMessage(webviewID port.WebViewID, message string) error {
107 wv := LookupWebView(webviewID)117 wv := LookupWebView(webviewID)
108 if wv == nil {118 if wv == nil {
109 return fmt.Errorf("webview %d not found", webviewID)119 return fmt.Errorf("webview %d not found", webviewID)
@@ -151,7 +161,7 @@ type faviconDatabaseAdapter struct {
151 wkCtx *WebKitContext161 wkCtx *WebKitContext
152 }162 }
153 163
154-func (a *faviconDatabaseAdapter) GetFaviconAsync(_ string, callback func(port.Texture)) {164+func (*faviconDatabaseAdapter) GetFaviconAsync(_ string, callback func(port.Texture)) {
155 // FaviconDatabase async lookup is not yet wired through this adapter.165 // FaviconDatabase async lookup is not yet wired through this adapter.
156 // The underlying API is: wkCtx.FaviconDatabase().GetFavicon(uri, callback).166 // The underlying API is: wkCtx.FaviconDatabase().GetFavicon(uri, callback).
157 // This stub satisfies the interface contract; callers will receive nil.167 // This stub satisfies the interface contract; callers will receive nil.
M internal/infrastructure/webkit/engine_init.go +4 -1
@@ -113,13 +113,16 @@ func NewEngine(
113113 filterManager: filterManager,
114114 schemeHandler: schemeHandler,
115115 schemePath: "dumb://",
116+ logger: logger,
116117 }
117118
118119 return engine, nil
119120 }
120121
121122 // engineSurveyHardwareAndResolveProfile surveys hardware and resolves the performance profile.
122-func engineSurveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, logger zerolog.Logger) config.ResolvedPerformanceSettings {
123+func engineSurveyHardwareAndResolveProfile(
124+ ctx context.Context, cfg *config.Config, logger zerolog.Logger,
125+) config.ResolvedPerformanceSettings {
123126 hwSurveyor := env.NewHardwareSurveyor()
124127 hwInfo := hwSurveyor.Survey(ctx)
125128 logging.Trace().Mark("hardware_survey")
@@ -113,13 +113,16 @@ func NewEngine(
113 filterManager: filterManager,113 filterManager: filterManager,
114 schemeHandler: schemeHandler,114 schemeHandler: schemeHandler,
115 schemePath: "dumb://",115 schemePath: "dumb://",
116+ logger: logger,
116 }117 }
117 118
118 return engine, nil119 return engine, nil
119 }120 }
120 121
121 // engineSurveyHardwareAndResolveProfile surveys hardware and resolves the performance profile.122 // engineSurveyHardwareAndResolveProfile surveys hardware and resolves the performance profile.
122-func engineSurveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, logger zerolog.Logger) config.ResolvedPerformanceSettings {123+func engineSurveyHardwareAndResolveProfile(
124+ ctx context.Context, cfg *config.Config, logger zerolog.Logger,
125+) config.ResolvedPerformanceSettings {
123 hwSurveyor := env.NewHardwareSurveyor()126 hwSurveyor := env.NewHardwareSurveyor()
124 hwInfo := hwSurveyor.Survey(ctx)127 hwInfo := hwSurveyor.Survey(ctx)
125 logging.Trace().Mark("hardware_survey")128 logging.Trace().Mark("hardware_survey")
M internal/infrastructure/webkit/webview.go +79 -2
@@ -28,6 +28,7 @@ import (
2828 var _ port.WebView = (*WebView)(nil)
2929 var _ port.DevToolsOpener = (*WebView)(nil)
3030 var _ port.Printer = (*WebView)(nil)
31+var _ port.OAuthCallbackCapable = (*WebView)(nil)
3132
3233 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
3334 // Infrastructure layer uses the type defined in the application port.
@@ -1477,6 +1478,30 @@ func (wv *WebView) Show() {
14771478 wv.inner.SetVisible(true)
14781479 }
14791480
1481+// SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1482+// It implements port.PopupCapable.
1483+func (wv *WebView) SetOnReadyToShow(fn func()) {
1484+ wv.OnReadyToShow = fn
1485+}
1486+
1487+// SetOnClose composes fn with any existing OnClose handler so multiple callers
1488+// can each register a close callback without overwriting one another.
1489+// It implements port.PopupCapable.
1490+func (wv *WebView) SetOnClose(fn func()) {
1491+ existing := wv.OnClose
1492+ if existing == nil {
1493+ wv.OnClose = fn
1494+ return
1495+ }
1496+ if fn == nil {
1497+ return
1498+ }
1499+ wv.OnClose = func() {
1500+ existing()
1501+ fn()
1502+ }
1503+}
1504+
14801505 // State returns the current WebView state as a snapshot.
14811506 func (wv *WebView) State() port.WebViewState {
14821507 return port.WebViewState{
@@ -1584,12 +1609,16 @@ func (wv *WebView) Print() error {
15841609
15851610 // OpenDevTools implements port.DevToolsOpener.
15861611 func (wv *WebView) OpenDevTools() {
1587- _ = wv.ShowDevTools()
1612+ if err := wv.ShowDevTools(); err != nil {
1613+ wv.logger.Error().Err(err).Uint64("id", uint64(wv.id)).Msg("failed to open dev tools")
1614+ }
15881615 }
15891616
15901617 // PrintPage implements port.Printer.
15911618 func (wv *WebView) PrintPage() {
1592- _ = wv.Print()
1619+ if err := wv.Print(); err != nil {
1620+ wv.logger.Error().Err(err).Uint64("id", uint64(wv.id)).Msg("failed to open print dialog")
1621+ }
15931622 }
15941623
15951624 // IsDestroyed returns true if the WebView has been destroyed.
@@ -1618,6 +1647,54 @@ func (wv *WebView) Close() {
16181647 }
16191648 }
16201649
1650+// AddCloseCallback implements port.OAuthCallbackCapable.
1651+// It composes fn with any existing OnClose handler so multiple callers can register
1652+// close callbacks without overwriting one another.
1653+func (wv *WebView) AddCloseCallback(fn func()) {
1654+ if wv.OnClose == nil {
1655+ wv.OnClose = fn
1656+ return
1657+ }
1658+ existing := wv.OnClose
1659+ wv.OnClose = func() {
1660+ existing()
1661+ fn()
1662+ }
1663+}
1664+
1665+// AddNavigationCallback implements port.OAuthCallbackCapable.
1666+// fn is invoked when the URI changes (OnURIChanged) and when a page load is committed
1667+// (OnLoadChanged with LoadCommitted), covering both redirect-based and postMessage OAuth flows.
1668+func (wv *WebView) AddNavigationCallback(fn func(uri string)) {
1669+ // Compose with existing OnURIChanged.
1670+ if wv.OnURIChanged == nil {
1671+ wv.OnURIChanged = fn
1672+ } else {
1673+ existing := wv.OnURIChanged
1674+ wv.OnURIChanged = func(uri string) {
1675+ existing(uri)
1676+ fn(uri)
1677+ }
1678+ }
1679+
1680+ // Compose with existing OnLoadChanged to also fire on committed loads.
1681+ if wv.OnLoadChanged == nil {
1682+ wv.OnLoadChanged = func(event LoadEvent) {
1683+ if event == LoadCommitted {
1684+ fn(wv.URI())
1685+ }
1686+ }
1687+ } else {
1688+ existing := wv.OnLoadChanged
1689+ wv.OnLoadChanged = func(event LoadEvent) {
1690+ existing(event)
1691+ if event == LoadCommitted {
1692+ fn(wv.URI())
1693+ }
1694+ }
1695+ }
1696+}
1697+
16211698 // DisconnectSignals disconnects all GLib signal handlers from the WebView.
16221699 // This must be called before releasing the WebView to the pool or destroying it
16231700 // to prevent callbacks from firing on freed/pooled WebViews.
@@ -28,6 +28,7 @@ import (
28 var _ port.WebView = (*WebView)(nil)28 var _ port.WebView = (*WebView)(nil)
29 var _ port.DevToolsOpener = (*WebView)(nil)29 var _ port.DevToolsOpener = (*WebView)(nil)
30 var _ port.Printer = (*WebView)(nil)30 var _ port.Printer = (*WebView)(nil)
31+var _ port.OAuthCallbackCapable = (*WebView)(nil)
31 32
32 // WebViewID is an alias to port.WebViewID for clean architecture compliance.33 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
33 // Infrastructure layer uses the type defined in the application port.34 // Infrastructure layer uses the type defined in the application port.
@@ -1477,6 +1478,30 @@ func (wv *WebView) Show() {
1477 wv.inner.SetVisible(true)1478 wv.inner.SetVisible(true)
1478 }1479 }
1479 1480
1481+// SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1482+// It implements port.PopupCapable.
1483+func (wv *WebView) SetOnReadyToShow(fn func()) {
1484+ wv.OnReadyToShow = fn
1485+}
1486+
1487+// SetOnClose composes fn with any existing OnClose handler so multiple callers
1488+// can each register a close callback without overwriting one another.
1489+// It implements port.PopupCapable.
1490+func (wv *WebView) SetOnClose(fn func()) {
1491+ existing := wv.OnClose
1492+ if existing == nil {
1493+ wv.OnClose = fn
1494+ return
1495+ }
1496+ if fn == nil {
1497+ return
1498+ }
1499+ wv.OnClose = func() {
1500+ existing()
1501+ fn()
1502+ }
1503+}
1504+
1480 // State returns the current WebView state as a snapshot.1505 // State returns the current WebView state as a snapshot.
1481 func (wv *WebView) State() port.WebViewState {1506 func (wv *WebView) State() port.WebViewState {
1482 return port.WebViewState{1507 return port.WebViewState{
@@ -1584,12 +1609,16 @@ func (wv *WebView) Print() error {
1584 1609
1585 // OpenDevTools implements port.DevToolsOpener.1610 // OpenDevTools implements port.DevToolsOpener.
1586 func (wv *WebView) OpenDevTools() {1611 func (wv *WebView) OpenDevTools() {
1587- _ = wv.ShowDevTools()1612+ if err := wv.ShowDevTools(); err != nil {
1613+ wv.logger.Error().Err(err).Uint64("id", uint64(wv.id)).Msg("failed to open dev tools")
1614+ }
1588 }1615 }
1589 1616
1590 // PrintPage implements port.Printer.1617 // PrintPage implements port.Printer.
1591 func (wv *WebView) PrintPage() {1618 func (wv *WebView) PrintPage() {
1592- _ = wv.Print()1619+ if err := wv.Print(); err != nil {
1620+ wv.logger.Error().Err(err).Uint64("id", uint64(wv.id)).Msg("failed to open print dialog")
1621+ }
1593 }1622 }
1594 1623
1595 // IsDestroyed returns true if the WebView has been destroyed.1624 // IsDestroyed returns true if the WebView has been destroyed.
@@ -1618,6 +1647,54 @@ func (wv *WebView) Close() {
1618 }1647 }
1619 }1648 }
1620 1649
1650+// AddCloseCallback implements port.OAuthCallbackCapable.
1651+// It composes fn with any existing OnClose handler so multiple callers can register
1652+// close callbacks without overwriting one another.
1653+func (wv *WebView) AddCloseCallback(fn func()) {
1654+ if wv.OnClose == nil {
1655+ wv.OnClose = fn
1656+ return
1657+ }
1658+ existing := wv.OnClose
1659+ wv.OnClose = func() {
1660+ existing()
1661+ fn()
1662+ }
1663+}
1664+
1665+// AddNavigationCallback implements port.OAuthCallbackCapable.
1666+// fn is invoked when the URI changes (OnURIChanged) and when a page load is committed
1667+// (OnLoadChanged with LoadCommitted), covering both redirect-based and postMessage OAuth flows.
1668+func (wv *WebView) AddNavigationCallback(fn func(uri string)) {
1669+ // Compose with existing OnURIChanged.
1670+ if wv.OnURIChanged == nil {
1671+ wv.OnURIChanged = fn
1672+ } else {
1673+ existing := wv.OnURIChanged
1674+ wv.OnURIChanged = func(uri string) {
1675+ existing(uri)
1676+ fn(uri)
1677+ }
1678+ }
1679+
1680+ // Compose with existing OnLoadChanged to also fire on committed loads.
1681+ if wv.OnLoadChanged == nil {
1682+ wv.OnLoadChanged = func(event LoadEvent) {
1683+ if event == LoadCommitted {
1684+ fn(wv.URI())
1685+ }
1686+ }
1687+ } else {
1688+ existing := wv.OnLoadChanged
1689+ wv.OnLoadChanged = func(event LoadEvent) {
1690+ existing(event)
1691+ if event == LoadCommitted {
1692+ fn(wv.URI())
1693+ }
1694+ }
1695+ }
1696+}
1697+
1621 // DisconnectSignals disconnects all GLib signal handlers from the WebView.1698 // DisconnectSignals disconnects all GLib signal handlers from the WebView.
1622 // This must be called before releasing the WebView to the pool or destroying it1699 // This must be called before releasing the WebView to the pool or destroying it
1623 // to prevent callbacks from firing on freed/pooled WebViews.1700 // to prevent callbacks from firing on freed/pooled WebViews.
M internal/ui/coordinator/content/appearance.go +9 -3
@@ -262,14 +262,20 @@ func (c *Coordinator) shouldDeferAppearance(wv port.WebView) bool {
262262
263263 func (c *Coordinator) refreshInjectedScripts(
264264 ctx context.Context,
265- injector *webkit.ContentInjector,
265+ injector port.ContentInjector,
266266 paneID entity.PaneID,
267267 wv port.WebView,
268268 ) {
269269 if injector == nil || wv == nil || wv.IsDestroyed() {
270270 return
271271 }
272- // Type-assert to access webkit-specific UserContentManager
272+ // Type-assert to access webkit-specific UserContentManager and InjectScripts.
273+ // refreshInjectedScripts is a webkit-specific operation; other engines will
274+ // need their own implementation when wired through a port.ScriptRefresher.
275+ wkInjector, ok := injector.(*webkit.ContentInjector)
276+ if !ok {
277+ return
278+ }
273279 wkWV, ok := wv.(*webkit.WebView)
274280 if !ok {
275281 return
@@ -280,7 +286,7 @@ func (c *Coordinator) refreshInjectedScripts(
280286 }
281287 ucm.RemoveAllScripts()
282288 ucm.RemoveAllStyleSheets()
283- injector.InjectScripts(ctx, ucm, wv.ID())
289+ wkInjector.InjectScripts(ctx, ucm, wv.ID())
284290 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
285291 }
286292
@@ -262,14 +262,20 @@ func (c *Coordinator) shouldDeferAppearance(wv port.WebView) bool {
262 262
263 func (c *Coordinator) refreshInjectedScripts(263 func (c *Coordinator) refreshInjectedScripts(
264 ctx context.Context,264 ctx context.Context,
265- injector *webkit.ContentInjector,265+ injector port.ContentInjector,
266 paneID entity.PaneID,266 paneID entity.PaneID,
267 wv port.WebView,267 wv port.WebView,
268 ) {268 ) {
269 if injector == nil || wv == nil || wv.IsDestroyed() {269 if injector == nil || wv == nil || wv.IsDestroyed() {
270 return270 return
271 }271 }
272- // Type-assert to access webkit-specific UserContentManager272+ // Type-assert to access webkit-specific UserContentManager and InjectScripts.
273+ // refreshInjectedScripts is a webkit-specific operation; other engines will
274+ // need their own implementation when wired through a port.ScriptRefresher.
275+ wkInjector, ok := injector.(*webkit.ContentInjector)
276+ if !ok {
277+ return
278+ }
273 wkWV, ok := wv.(*webkit.WebView)279 wkWV, ok := wv.(*webkit.WebView)
274 if !ok {280 if !ok {
275 return281 return
@@ -280,7 +286,7 @@ func (c *Coordinator) refreshInjectedScripts(
280 }286 }
281 ucm.RemoveAllScripts()287 ucm.RemoveAllScripts()
282 ucm.RemoveAllStyleSheets()288 ucm.RemoveAllStyleSheets()
283- injector.InjectScripts(ctx, ucm, wv.ID())289+ wkInjector.InjectScripts(ctx, ucm, wv.ID())
284 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")290 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
285 }291 }
286 292
M internal/ui/coordinator/content/coordinator.go +1 -12
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/dumber/internal/application/usecase"
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/infrastructure/config"
12- "github.com/bnema/dumber/internal/infrastructure/webkit"
1312 "github.com/bnema/dumber/internal/logging"
1413 "github.com/bnema/dumber/internal/ui/adapter"
1514 "github.com/bnema/dumber/internal/ui/component"
@@ -24,7 +23,7 @@ type Coordinator struct {
2423 faviconAdapter *adapter.FaviconAdapter
2524 zoomUC *usecase.ManageZoomUseCase
2625 permissionUC *usecase.HandlePermissionUseCase
27- injector *webkit.ContentInjector
26+ injector port.ContentInjector
2827
2928 webViews map[entity.PaneID]port.WebView
3029 webViewsMu sync.RWMutex
@@ -289,13 +288,3 @@ func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
289288 delete(c.webViews, paneID)
290289 return wv
291290 }
292-
293-func (c *Coordinator) snapshotWebViews() map[entity.PaneID]port.WebView {
294- c.webViewsMu.RLock()
295- defer c.webViewsMu.RUnlock()
296- snapshot := make(map[entity.PaneID]port.WebView, len(c.webViews))
297- for paneID, wv := range c.webViews {
298- snapshot[paneID] = wv
299- }
300- return snapshot
301-}
@@ -9,7 +9,6 @@ import (
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/infrastructure/config"11 "github.com/bnema/dumber/internal/infrastructure/config"
12- "github.com/bnema/dumber/internal/infrastructure/webkit"
13 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/dumber/internal/ui/adapter"13 "github.com/bnema/dumber/internal/ui/adapter"
15 "github.com/bnema/dumber/internal/ui/component"14 "github.com/bnema/dumber/internal/ui/component"
@@ -24,7 +23,7 @@ type Coordinator struct {
24 faviconAdapter *adapter.FaviconAdapter23 faviconAdapter *adapter.FaviconAdapter
25 zoomUC *usecase.ManageZoomUseCase24 zoomUC *usecase.ManageZoomUseCase
26 permissionUC *usecase.HandlePermissionUseCase25 permissionUC *usecase.HandlePermissionUseCase
27- injector *webkit.ContentInjector26+ injector port.ContentInjector
28 27
29 webViews map[entity.PaneID]port.WebView28 webViews map[entity.PaneID]port.WebView
30 webViewsMu sync.RWMutex29 webViewsMu sync.RWMutex
@@ -289,13 +288,3 @@ func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
289 delete(c.webViews, paneID)288 delete(c.webViews, paneID)
290 return wv289 return wv
291 }290 }
292-
293-func (c *Coordinator) snapshotWebViews() map[entity.PaneID]port.WebView {
294- c.webViewsMu.RLock()
295- defer c.webViewsMu.RUnlock()
296- snapshot := make(map[entity.PaneID]port.WebView, len(c.webViews))
297- for paneID, wv := range c.webViews {
298- snapshot[paneID] = wv
299- }
300- return snapshot
301-}
M internal/ui/coordinator/content/navigation.go +4 -2
@@ -205,7 +205,7 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
205205 paneView.SetLoading(false)
206206 }
207207
208- c.revealIfPending(context.Background(), paneID, "", "load-finished")
208+ c.revealIfPending(ctx, paneID, "", "load-finished")
209209 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
210210 return
211211 }
@@ -321,7 +321,9 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
321321
322322 // Stop loading to prevent WebKit from showing an error page
323323 // The page stays on the previous URL before the JS redirect
324- _ = wv.Stop(ctx)
324+ if err := wv.Stop(ctx); err != nil {
325+ log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for external URL")
326+ }
325327
326328 // Navigate back to avoid stale URI in omnibox/history
327329 if wv.CanGoBack() {
@@ -205,7 +205,7 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
205 paneView.SetLoading(false)205 paneView.SetLoading(false)
206 }206 }
207 207
208- c.revealIfPending(context.Background(), paneID, "", "load-finished")208+ c.revealIfPending(ctx, paneID, "", "load-finished")
209 if c.shouldSkipAboutBlankAppearance(paneID, wv) {209 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
210 return210 return
211 }211 }
@@ -321,7 +321,9 @@ func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID
321 321
322 // Stop loading to prevent WebKit from showing an error page322 // Stop loading to prevent WebKit from showing an error page
323 // The page stays on the previous URL before the JS redirect323 // The page stays on the previous URL before the JS redirect
324- _ = wv.Stop(ctx)324+ if err := wv.Stop(ctx); err != nil {
325+ log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for external URL")
326+ }
325 327
326 // Navigate back to avoid stale URI in omnibox/history328 // Navigate back to avoid stale URI in omnibox/history
327 if wv.CanGoBack() {329 if wv.CanGoBack() {
M internal/ui/coordinator/content/oauth.go +19 -26
@@ -8,7 +8,6 @@ import (
88
99 "github.com/bnema/dumber/internal/application/port"
1010 "github.com/bnema/dumber/internal/domain/entity"
11- "github.com/bnema/dumber/internal/infrastructure/webkit"
1211 "github.com/bnema/dumber/internal/logging"
1312 "github.com/jwijenbergh/puregotk/v4/glib"
1413 )
@@ -60,6 +59,15 @@ var oauthRequestPatterns = []string{
6059
6160 // IsOAuthURL checks if the URL is related to an OAuth flow.
6261 // This includes authorization endpoints, login pages, and callback URLs.
62+//
63+// The matching is intentionally broad: both oauthFlowPatterns (path-based terms like
64+// "login", "authorize", "callback") and oauthRequestPatterns (query parameters like
65+// "client_id=" and "scope=") use simple substring matching without anchoring.
66+// This maximizes recall — we prefer false positives over missed detections — so that
67+// edge-case provider URLs and non-standard redirect paths are still caught. The
68+// trade-off is reduced precision: some unrelated URLs that happen to contain these
69+// substrings will be classified as OAuth URLs. Callers that need higher confidence
70+// should combine this with IsOAuthCallback, which checks for concrete response params.
6371 func IsOAuthURL(url string) bool {
6472 if url == "" {
6573 return false
@@ -212,10 +220,10 @@ func (c *Coordinator) setupOAuthAutoClose(
212220 ) {
213221 log := logging.FromContext(ctx)
214222
215- // Type-assert to access webkit-specific direct field callbacks and Close()
216- wkWV, ok := wv.(*webkit.WebView)
223+ // Use the optional OAuthCallbackCapable capability instead of a webkit-specific assertion.
224+ oauthWV, ok := wv.(port.OAuthCallbackCapable)
217225 if !ok {
218- log.Debug().Str("pane_id", string(paneID)).Msg("oauth auto-close: webview does not support webkit callbacks")
226+ log.Debug().Str("pane_id", string(paneID)).Msg("oauth auto-close: webview does not support oauth callbacks")
219227 return
220228 }
221229
@@ -239,7 +247,7 @@ func (c *Coordinator) setupOAuthAutoClose(
239247 uri := wv.URI()
240248 if shouldForceCloseOnSafetyTimeout(uri) {
241249 log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
242- wkWV.Close()
250+ oauthWV.Close()
243251 return false
244252 }
245253 log.Debug().
@@ -275,7 +283,7 @@ func (c *Coordinator) setupOAuthAutoClose(
275283 time.AfterFunc(oauthCloseDelay, func() {
276284 cb := glib.SourceFunc(func(_ uintptr) bool {
277285 if wv != nil && !wv.IsDestroyed() {
278- wkWV.Close()
286+ oauthWV.Close()
279287 }
280288 return false
281289 })
@@ -287,32 +295,17 @@ func (c *Coordinator) setupOAuthAutoClose(
287295 // Start safety timer immediately.
288296 startSafetyTimer()
289297
290- // Wrap OnURIChanged to check for OAuth callbacks.
291- wkWV.OnURIChanged = composeOnURIChanged(wkWV.OnURIChanged, func(uri string) {
298+ // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
299+ oauthWV.AddNavigationCallback(func(uri string) {
292300 if ShouldAutoClose(uri) {
293- requestOAuthClose(uri, "uri_changed")
301+ requestOAuthClose(uri, "navigation")
294302 }
295303 })
296304
297- // Monitor load events for URL-based detection.
298- // Wrap webkit.LoadEvent to port.LoadEvent at the boundary.
299- existingOnLoad := wkWV.OnLoadChanged
300- wkWV.OnLoadChanged = func(event webkit.LoadEvent) {
301- if existingOnLoad != nil {
302- existingOnLoad(event)
303- }
304- if port.LoadEvent(event) == port.LoadCommitted {
305- uri := wv.URI()
306- if ShouldAutoClose(uri) {
307- requestOAuthClose(uri, "load_committed")
308- }
309- }
310- }
311-
312305 // Cancel safety timer on any close path.
313- wkWV.OnClose = composeOnClose(func() {
306+ oauthWV.AddCloseCallback(func() {
314307 cancelSafetyTimer()
315- }, wkWV.OnClose)
308+ })
316309 }
317310
318311 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
@@ -8,7 +8,6 @@ import (
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11- "github.com/bnema/dumber/internal/infrastructure/webkit"
12 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
13 "github.com/jwijenbergh/puregotk/v4/glib"12 "github.com/jwijenbergh/puregotk/v4/glib"
14 )13 )
@@ -60,6 +59,15 @@ var oauthRequestPatterns = []string{
60 59
61 // IsOAuthURL checks if the URL is related to an OAuth flow.60 // IsOAuthURL checks if the URL is related to an OAuth flow.
62 // This includes authorization endpoints, login pages, and callback URLs.61 // This includes authorization endpoints, login pages, and callback URLs.
62+//
63+// The matching is intentionally broad: both oauthFlowPatterns (path-based terms like
64+// "login", "authorize", "callback") and oauthRequestPatterns (query parameters like
65+// "client_id=" and "scope=") use simple substring matching without anchoring.
66+// This maximizes recall — we prefer false positives over missed detections — so that
67+// edge-case provider URLs and non-standard redirect paths are still caught. The
68+// trade-off is reduced precision: some unrelated URLs that happen to contain these
69+// substrings will be classified as OAuth URLs. Callers that need higher confidence
70+// should combine this with IsOAuthCallback, which checks for concrete response params.
63 func IsOAuthURL(url string) bool {71 func IsOAuthURL(url string) bool {
64 if url == "" {72 if url == "" {
65 return false73 return false
@@ -212,10 +220,10 @@ func (c *Coordinator) setupOAuthAutoClose(
212 ) {220 ) {
213 log := logging.FromContext(ctx)221 log := logging.FromContext(ctx)
214 222
215- // Type-assert to access webkit-specific direct field callbacks and Close()223+ // Use the optional OAuthCallbackCapable capability instead of a webkit-specific assertion.
216- wkWV, ok := wv.(*webkit.WebView)224+ oauthWV, ok := wv.(port.OAuthCallbackCapable)
217 if !ok {225 if !ok {
218- log.Debug().Str("pane_id", string(paneID)).Msg("oauth auto-close: webview does not support webkit callbacks")226+ log.Debug().Str("pane_id", string(paneID)).Msg("oauth auto-close: webview does not support oauth callbacks")
219 return227 return
220 }228 }
221 229
@@ -239,7 +247,7 @@ func (c *Coordinator) setupOAuthAutoClose(
239 uri := wv.URI()247 uri := wv.URI()
240 if shouldForceCloseOnSafetyTimeout(uri) {248 if shouldForceCloseOnSafetyTimeout(uri) {
241 log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")249 log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
242- wkWV.Close()250+ oauthWV.Close()
243 return false251 return false
244 }252 }
245 log.Debug().253 log.Debug().
@@ -275,7 +283,7 @@ func (c *Coordinator) setupOAuthAutoClose(
275 time.AfterFunc(oauthCloseDelay, func() {283 time.AfterFunc(oauthCloseDelay, func() {
276 cb := glib.SourceFunc(func(_ uintptr) bool {284 cb := glib.SourceFunc(func(_ uintptr) bool {
277 if wv != nil && !wv.IsDestroyed() {285 if wv != nil && !wv.IsDestroyed() {
278- wkWV.Close()286+ oauthWV.Close()
279 }287 }
280 return false288 return false
281 })289 })
@@ -287,32 +295,17 @@ func (c *Coordinator) setupOAuthAutoClose(
287 // Start safety timer immediately.295 // Start safety timer immediately.
288 startSafetyTimer()296 startSafetyTimer()
289 297
290- // Wrap OnURIChanged to check for OAuth callbacks.298+ // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
291- wkWV.OnURIChanged = composeOnURIChanged(wkWV.OnURIChanged, func(uri string) {299+ oauthWV.AddNavigationCallback(func(uri string) {
292 if ShouldAutoClose(uri) {300 if ShouldAutoClose(uri) {
293- requestOAuthClose(uri, "uri_changed")301+ requestOAuthClose(uri, "navigation")
294 }302 }
295 })303 })
296 304
297- // Monitor load events for URL-based detection.
298- // Wrap webkit.LoadEvent to port.LoadEvent at the boundary.
299- existingOnLoad := wkWV.OnLoadChanged
300- wkWV.OnLoadChanged = func(event webkit.LoadEvent) {
301- if existingOnLoad != nil {
302- existingOnLoad(event)
303- }
304- if port.LoadEvent(event) == port.LoadCommitted {
305- uri := wv.URI()
306- if ShouldAutoClose(uri) {
307- requestOAuthClose(uri, "load_committed")
308- }
309- }
310- }
311-
312 // Cancel safety timer on any close path.305 // Cancel safety timer on any close path.
313- wkWV.OnClose = composeOnClose(func() {306+ oauthWV.AddCloseCallback(func() {
314 cancelSafetyTimer()307 cancelSafetyTimer()
315- }, wkWV.OnClose)308+ })
316 }309 }
317 310
318 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {311 func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
M internal/ui/coordinator/content/popup.go +18 -11
@@ -8,7 +8,6 @@ import (
88 "github.com/bnema/dumber/internal/application/port"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/infrastructure/config"
11- "github.com/bnema/dumber/internal/infrastructure/webkit"
1211 "github.com/bnema/dumber/internal/logging"
1312 )
1413
@@ -153,7 +152,9 @@ func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.
153152
154153 // buildPopupCreateHandler returns the OnCreate callback for a WebView.
155154 // Returns nil if popup handling is not configured.
156-func (c *Coordinator) buildPopupCreateHandler(ctx context.Context, paneID entity.PaneID, wv port.WebView) func(port.PopupRequest) port.WebView {
155+func (c *Coordinator) buildPopupCreateHandler(
156+ ctx context.Context, paneID entity.PaneID, wv port.WebView,
157+) func(port.PopupRequest) port.WebView {
157158 if wv == nil {
158159 return nil
159160 }
@@ -304,14 +305,16 @@ func (c *Coordinator) handlePopupCreate(
304305 c.pendingPopups[popupID] = pending
305306 c.popupMu.Unlock()
306307
307- // Wire ready-to-show and close signals via webkit-specific fields
308- if wkWV, ok := popupWV.(*webkit.WebView); ok {
309- wkWV.OnReadyToShow = func() {
308+ // Wire ready-to-show and close signals via the PopupCapable interface.
309+ if pc, ok := popupWV.(port.PopupCapable); ok {
310+ pc.SetOnReadyToShow(func() {
310311 c.handlePopupReadyToShow(ctx, popupID)
311- }
312- wkWV.OnClose = composeOnClose(wkWV.OnClose, func() {
312+ })
313+ pc.SetOnClose(func() {
313314 c.handlePopupClose(ctx, popupID)
314315 })
316+ } else {
317+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support popup lifecycle callbacks (PopupCapable)")
315318 }
316319
317320 log.Info().
@@ -350,8 +353,10 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
350353
351354 // Make the WebView visible now that it's ready
352355 if pending.WebView != nil {
353- if wkWV, ok := pending.WebView.(*webkit.WebView); ok {
354- wkWV.Show()
356+ if pc, ok := pending.WebView.(port.PopupCapable); ok {
357+ pc.Show()
358+ } else {
359+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support Show() (PopupCapable)")
355360 }
356361 }
357362
@@ -384,12 +389,14 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
384389
385390 // Find pane by WebView ID
386391 var paneID entity.PaneID
392+ c.webViewsMu.RLock()
387393 for pid, wv := range c.webViews {
388394 if wv != nil && wv.ID() == popupID {
389395 paneID = pid
390396 break
391397 }
392398 }
399+ c.webViewsMu.RUnlock()
393400
394401 if paneID == "" {
395402 c.handlePopupOAuthClose(ctx, popupID)
@@ -484,8 +491,8 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
484491
485492 if err := c.onInsertPopup(ctx, popupInput); err != nil {
486493 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
487- // Clean up on failure
488- delete(c.webViews, paneID)
494+ // Clean up on failure — use the locking helper to avoid a data race.
495+ c.deleteWebViewLocked(paneID)
489496 newWV.Destroy()
490497 return false
491498 }
@@ -8,7 +8,6 @@ import (
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/infrastructure/config"10 "github.com/bnema/dumber/internal/infrastructure/config"
11- "github.com/bnema/dumber/internal/infrastructure/webkit"
12 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
13 )12 )
14 13
@@ -153,7 +152,9 @@ func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.
153 152
154 // buildPopupCreateHandler returns the OnCreate callback for a WebView.153 // buildPopupCreateHandler returns the OnCreate callback for a WebView.
155 // Returns nil if popup handling is not configured.154 // Returns nil if popup handling is not configured.
156-func (c *Coordinator) buildPopupCreateHandler(ctx context.Context, paneID entity.PaneID, wv port.WebView) func(port.PopupRequest) port.WebView {155+func (c *Coordinator) buildPopupCreateHandler(
156+ ctx context.Context, paneID entity.PaneID, wv port.WebView,
157+) func(port.PopupRequest) port.WebView {
157 if wv == nil {158 if wv == nil {
158 return nil159 return nil
159 }160 }
@@ -304,14 +305,16 @@ func (c *Coordinator) handlePopupCreate(
304 c.pendingPopups[popupID] = pending305 c.pendingPopups[popupID] = pending
305 c.popupMu.Unlock()306 c.popupMu.Unlock()
306 307
307- // Wire ready-to-show and close signals via webkit-specific fields308+ // Wire ready-to-show and close signals via the PopupCapable interface.
308- if wkWV, ok := popupWV.(*webkit.WebView); ok {309+ if pc, ok := popupWV.(port.PopupCapable); ok {
309- wkWV.OnReadyToShow = func() {310+ pc.SetOnReadyToShow(func() {
310 c.handlePopupReadyToShow(ctx, popupID)311 c.handlePopupReadyToShow(ctx, popupID)
311- }312+ })
312- wkWV.OnClose = composeOnClose(wkWV.OnClose, func() {313+ pc.SetOnClose(func() {
313 c.handlePopupClose(ctx, popupID)314 c.handlePopupClose(ctx, popupID)
314 })315 })
316+ } else {
317+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support popup lifecycle callbacks (PopupCapable)")
315 }318 }
316 319
317 log.Info().320 log.Info().
@@ -350,8 +353,10 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W
350 353
351 // Make the WebView visible now that it's ready354 // Make the WebView visible now that it's ready
352 if pending.WebView != nil {355 if pending.WebView != nil {
353- if wkWV, ok := pending.WebView.(*webkit.WebView); ok {356+ if pc, ok := pending.WebView.(port.PopupCapable); ok {
354- wkWV.Show()357+ pc.Show()
358+ } else {
359+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support Show() (PopupCapable)")
355 }360 }
356 }361 }
357 362
@@ -384,12 +389,14 @@ func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebView
384 389
385 // Find pane by WebView ID390 // Find pane by WebView ID
386 var paneID entity.PaneID391 var paneID entity.PaneID
392+ c.webViewsMu.RLock()
387 for pid, wv := range c.webViews {393 for pid, wv := range c.webViews {
388 if wv != nil && wv.ID() == popupID {394 if wv != nil && wv.ID() == popupID {
389 paneID = pid395 paneID = pid
390 break396 break
391 }397 }
392 }398 }
399+ c.webViewsMu.RUnlock()
393 400
394 if paneID == "" {401 if paneID == "" {
395 c.handlePopupOAuthClose(ctx, popupID)402 c.handlePopupOAuthClose(ctx, popupID)
@@ -484,8 +491,8 @@ func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID en
484 491
485 if err := c.onInsertPopup(ctx, popupInput); err != nil {492 if err := c.onInsertPopup(ctx, popupInput); err != nil {
486 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")493 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
487- // Clean up on failure494+ // Clean up on failure — use the locking helper to avoid a data race.
488- delete(c.webViews, paneID)495+ c.deleteWebViewLocked(paneID)
489 newWV.Destroy()496 newWV.Destroy()
490 return false497 return false
491 }498 }

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