1package ui
2
3import (
4 "context"
5 "fmt"
6 "sync"
7
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/coordinator/content"
12 "github.com/bnema/dumber/internal/ui/layout"
13 "github.com/bnema/dumber/internal/ui/window"
14 "github.com/bnema/puregotk/v4/gtk"
15)
16
17type nativePopupShell interface {
18 DetachContent() *gtk.Widget
19 Destroy()
20 Close()
21 Show()
22}
23
24type nativePopupWindow struct {
25 popupID port.WebViewID
26 parentPaneID entity.PaneID
27 parentWindowID string
28 popupWindow nativePopupShell
29 webView port.WebView
30 closeOnce sync.Once
31}
32
33type nativePopupReleaseMode int
34
35const (
36 nativePopupReleaseDestroy nativePopupReleaseMode = iota
37 nativePopupReleaseDetach
38)
39
40var newPopupWindow = window.NewPopup
41
42func destroyFailedNativePopupSetup(popupShell nativePopupShell) {
43 if popupShell != nil {
44 popupShell.Destroy()
45 }
46}
47
48func prepareNativePopupContentWidget(widget layout.Widget) (*gtk.Widget, error) {
49 if widget == nil {
50 return nil, fmt.Errorf("failed to wrap native popup webview widget")
51 }
52 gtkWidget := widget.GtkWidget()
53 if gtkWidget == nil {
54 return nil, fmt.Errorf("failed to wrap native popup webview widget")
55 }
56 widget.SetHexpand(true)
57 widget.SetVexpand(true)
58 return gtkWidget, nil
59}
60
61func (a *App) ensureNativePopupWindows() {
62 if a.nativePopupWindows == nil {
63 a.nativePopupWindows = make(map[port.WebViewID]*nativePopupWindow)
64 }
65}
66
67func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePopupInput) error {
68 if a == nil || a.gtkApp == nil {
69 return fmt.Errorf("gtk application not available for native popup")
70 }
71 if input.PopupWebView == nil {
72 return fmt.Errorf("native popup webview is nil")
73 }
74
75 popupShell, err := newPopupWindow(ctx, a.gtkApp)
76 if err != nil {
77 return err
78 }
79 if a.contentCoord == nil {
80 destroyFailedNativePopupSetup(popupShell)
81 return fmt.Errorf("content coordinator not available for native popup")
82 }
83 widget := a.contentCoord.WrapWidget(ctx, input.PopupWebView)
84 gtkWidget, err := prepareNativePopupContentWidget(widget)
85 if err != nil {
86 destroyFailedNativePopupSetup(popupShell)
87 return err
88 }
89 popupShell.SetContent(gtkWidget)
90
91 parentWindowID := ""
92 if bw := a.browserWindowForAnyPane(input.ParentPaneID); bw != nil {
93 parentWindowID = bw.id
94 }
95
96 popupID := input.PopupWebView.ID()
97 state := &nativePopupWindow{
98 popupID: popupID,
99 parentPaneID: input.ParentPaneID,
100 parentWindowID: parentWindowID,
101 popupWindow: popupShell,
102 webView: input.PopupWebView,
103 }
104 popupShell.SetTitle("Dumber")
105
106 a.ensureNativePopupWindows()
107 a.nativePopupWindows[popupID] = state
108
109 if popupShell.Window() != nil {
110 closeRequestCb := func(_ gtk.Window) bool {
111 a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
112 return false
113 }
114 popupShell.Window().ConnectCloseRequest(&closeRequestCb)
115 }
116
117 if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {
118 aborter.SetNativePopupHostAbort(func() {
119 a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {
120 a.abortNativePopupWindow(context.Background(), popupID, input)
121 })
122 })
123 }
124 if lifecycle, ok := input.PopupWebView.(port.PopupLifecycleCapable); ok {
125 lifecycle.SetOnReadyToShow(func() {
126 a.dispatchNativePopupLifecycle("ui.native_popup.ready_to_show", popupID, func() {
127 a.showNativePopupWindow(popupID)
128 })
129 })
130 lifecycle.SetOnClose(func() {
131 a.dispatchNativePopupLifecycle("ui.native_popup.close", popupID, func() {
132 a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
133 })
134 })
135 } else {
136 popupShell.Show()
137 }
138 if oauthWV, ok := input.PopupWebView.(port.OAuthCallbackCapable); ok {
139 oauthWV.AddCloseCallback(func() {
140 a.dispatchNativePopupLifecycle("ui.native_popup.oauth_close", popupID, func() {
141 a.releaseNativePopupWindow(popupID, nativePopupReleaseDestroy)
142 })
143 })
144 }
145 if a.contentCoord != nil && input.ObserveOAuthAutoClose {
146 a.contentCoord.ObserveNativePopupAuth(ctx, input)
147 }
148
149 logging.FromContext(ctx).Info().
150 Uint64("popup_id", uint64(popupID)).
151 Str("target_uri", input.TargetURI).
152 Str("parent_pane_id", string(input.ParentPaneID)).
153 Msg("native popup host created")
154 return nil
155}
156
157func (a *App) abortNativePopupWindow(ctx context.Context, popupID port.WebViewID, input content.NativePopupInput) {
158 wv := a.releaseNativePopupWindow(popupID, nativePopupReleaseDetach)
159 if wv == nil {
160 return
161 }
162 transferred := false
163 if input.AllowBrowserWindowFallback && input.OnNativeHostAbort != nil {
164 transferred = input.OnNativeHostAbort(ctx, wv)
165 } else if input.OnNativeHostAbort != nil {
166 input.OnNativeHostAbort(ctx, wv)
167 }
168 if !transferred && !wv.IsDestroyed() {
169 wv.Destroy()
170 }
171}
172
173func (a *App) dispatchNativePopupLifecycle(label string, popupID port.WebViewID, fn func()) {
174 if a == nil || fn == nil {
175 return
176 }
177 if a.dispatchOnMainThread == nil {
178 fn()
179 return
180 }
181 result := a.dispatchOnMainThread(label, fn)
182 if result.Completed() {
183 return
184 }
185 ctx := context.Background()
186 if a.deps != nil && a.deps.Ctx != nil {
187 ctx = a.deps.Ctx
188 }
189 logging.FromContext(ctx).Warn().
190 Uint64("popup_id", uint64(popupID)).
191 Str("dispatch_label", result.Label).
192 Dur("elapsed", result.Elapsed).
193 Str("dispatch_status", string(result.Status)).
194 Msg("native popup lifecycle dispatch did not complete")
195}
196
197func (a *App) showNativePopupWindow(popupID port.WebViewID) {
198 if a == nil || a.nativePopupWindows == nil {
199 return
200 }
201 if state := a.nativePopupWindows[popupID]; state != nil && state.popupWindow != nil {
202 state.popupWindow.Show()
203 }
204}
205
206func (a *App) releaseNativePopupWindow(popupID port.WebViewID, mode nativePopupReleaseMode) port.WebView {
207 if a == nil || a.nativePopupWindows == nil {
208 return nil
209 }
210 state := a.nativePopupWindows[popupID]
211 if state == nil {
212 return nil
213 }
214 var detached port.WebView
215 state.closeOnce.Do(func() {
216 delete(a.nativePopupWindows, popupID)
217 if mode == nativePopupReleaseDetach {
218 if state.popupWindow != nil && state.popupWindow.DetachContent() != nil {
219 detached = state.webView
220 } else if state.webView != nil && !state.webView.IsDestroyed() {
221 state.webView.Destroy()
222 }
223 } else if state.webView != nil && !state.webView.IsDestroyed() {
224 state.webView.Destroy()
225 }
226 if state.popupWindow != nil {
227 state.popupWindow.Destroy()
228 }
229 })
230 return detached
231}