detached 3478db3

cef: address render stack review findings

commit
3478db379a07a8b3560833b70205e40911397fbb
author
brice · · 2026-05-01 14:12 UTC
parent
3a4bc2a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +159 -334
M go.mod +2 -4
@@ -7,10 +7,10 @@ require (
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
99 github.com/bnema/purego-cef v0.12.0
10- github.com/bnema/purego-cef2gtk v0.0.0-20260501133909-bb0f269889fd
10+ github.com/bnema/purego-cef2gtk v0.0.0-20260501141125-47b4a0cc3a3c
1111 github.com/bnema/purego-pipewire v0.1.2
1212 github.com/bnema/purego-sqlite v0.1.2
13- github.com/bnema/puregotk v0.5.2-0.20260501133710-759d3a128c40
13+ github.com/bnema/puregotk v0.5.2-0.20260501141022-98dbb7db4eb0
1414 github.com/charmbracelet/bubbles v1.0.0
1515 github.com/charmbracelet/bubbletea v1.3.10
1616 github.com/charmbracelet/lipgloss v1.1.0
@@ -79,5 +79,3 @@ require (
7979 )
8080
8181 tool github.com/a-h/templ/cmd/templ
82-
83-replace github.com/bnema/purego-pipewire => ../purego-pipewire
@@ -7,10 +7,10 @@ require (
7 github.com/andybalholm/brotli v1.2.17 github.com/andybalholm/brotli v1.2.1
8 github.com/bnema/purego v0.11.0-bnema.28 github.com/bnema/purego v0.11.0-bnema.2
9 github.com/bnema/purego-cef v0.12.09 github.com/bnema/purego-cef v0.12.0
10- github.com/bnema/purego-cef2gtk v0.0.0-20260501133909-bb0f269889fd10+ github.com/bnema/purego-cef2gtk v0.0.0-20260501141125-47b4a0cc3a3c
11 github.com/bnema/purego-pipewire v0.1.211 github.com/bnema/purego-pipewire v0.1.2
12 github.com/bnema/purego-sqlite v0.1.212 github.com/bnema/purego-sqlite v0.1.2
13- github.com/bnema/puregotk v0.5.2-0.20260501133710-759d3a128c4013+ github.com/bnema/puregotk v0.5.2-0.20260501141022-98dbb7db4eb0
14 github.com/charmbracelet/bubbles v1.0.014 github.com/charmbracelet/bubbles v1.0.0
15 github.com/charmbracelet/bubbletea v1.3.1015 github.com/charmbracelet/bubbletea v1.3.10
16 github.com/charmbracelet/lipgloss v1.1.016 github.com/charmbracelet/lipgloss v1.1.0
@@ -79,5 +79,3 @@ require (
79 )79 )
80 80
81 tool github.com/a-h/templ/cmd/templ81 tool github.com/a-h/templ/cmd/templ
82-
83-replace github.com/bnema/purego-pipewire => ../purego-pipewire
M go.sum +6 -4 generated file: collapsed
M internal/infrastructure/cef/cef2gtk_adapter.go +91 -21
@@ -2,6 +2,7 @@ package cef
22
33 import (
44 "errors"
5+ "sync"
56 "sync/atomic"
67
78 purecef "github.com/bnema/purego-cef/cef"
@@ -24,6 +25,7 @@ var ErrAdapterDestroyed = errors.New("cef2gtk_adapter: adapter is destroyed")
2425 // The adapter does NOT own GL/PBO staging, dirty-rect uploads, or GTK input
2526 // controller implementation — those belong to purego-cef2gtk.
2627 type Cef2gtkAdapter struct {
28+ viewMu sync.RWMutex
2729 view *cef2gtk.View
2830 destroyed atomic.Bool
2931 destroyCnt atomic.Uint64
@@ -42,7 +44,12 @@ func NewCef2gtkAdapter() *Cef2gtkAdapter {
4244 // Widget returns the native GTK widget for embedding into containers.
4345 // The returned pointer must be used from the GTK main thread for packing.
4446 func (a *Cef2gtkAdapter) Widget() *gtk.Widget {
45- if a == nil || a.destroyed.Load() || a.view == nil {
47+ if a == nil || a.destroyed.Load() {
48+ return nil
49+ }
50+ a.viewMu.RLock()
51+ defer a.viewMu.RUnlock()
52+ if a.view == nil {
4653 return nil
4754 }
4855 return a.view.Widget()
@@ -51,7 +58,12 @@ func (a *Cef2gtkAdapter) Widget() *gtk.Widget {
5158 // GLArea returns the underlying GtkGLArea when the GLArea backend is active.
5259 // It returns nil for the GDK DMABUF backend.
5360 func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {
54- if a == nil || a.destroyed.Load() || a.view == nil {
61+ if a == nil || a.destroyed.Load() {
62+ return nil
63+ }
64+ a.viewMu.RLock()
65+ defer a.viewMu.RUnlock()
66+ if a.view == nil {
5567 return nil
5668 }
5769 return a.view.GLArea()
@@ -70,7 +82,12 @@ func (a *Cef2gtkAdapter) NativeWidget() uintptr {
7082 // Size returns the bridge's last observed positive widget size, or 1x1 before
7183 // the GTK widget has been allocated. It is safe to call off the GTK thread.
7284 func (a *Cef2gtkAdapter) Size() (int32, int32) {
73- if a == nil || a.destroyed.Load() || a.view == nil {
85+ if a == nil || a.destroyed.Load() {
86+ return 1, 1
87+ }
88+ a.viewMu.RLock()
89+ defer a.viewMu.RUnlock()
90+ if a.view == nil {
7491 return 1, 1
7592 }
7693 return a.view.Size()
@@ -79,7 +96,12 @@ func (a *Cef2gtkAdapter) Size() (int32, int32) {
7996 // DeviceScaleFactor returns the bridge's last observed GTK scale factor, or 1
8097 // before the widget has reported a scale. It is safe to call off the GTK thread.
8198 func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 {
82- if a == nil || a.destroyed.Load() || a.view == nil {
99+ if a == nil || a.destroyed.Load() {
100+ return 1
101+ }
102+ a.viewMu.RLock()
103+ defer a.viewMu.RUnlock()
104+ if a.view == nil {
83105 return 1
84106 }
85107 return a.view.DeviceScaleFactor()
@@ -89,7 +111,12 @@ func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 {
89111 // unregister from the GTK main thread; callbacks are invoked by the bridge from
90112 // GTK size notifications.
91113 func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() {
92- if a == nil || a.destroyed.Load() || a.view == nil {
114+ if a == nil || a.destroyed.Load() {
115+ return func() {}
116+ }
117+ a.viewMu.RLock()
118+ defer a.viewMu.RUnlock()
119+ if a.view == nil {
93120 return func() {}
94121 }
95122 return a.view.AddSizeObserver(fn)
@@ -98,7 +125,12 @@ func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() {
98125 // HasFocus reports whether the bridge widget currently has GTK focus. Must be
99126 // called on the GTK main thread.
100127 func (a *Cef2gtkAdapter) HasFocus() bool {
101- if a == nil || a.destroyed.Load() || a.view == nil {
128+ if a == nil || a.destroyed.Load() {
129+ return false
130+ }
131+ a.viewMu.RLock()
132+ defer a.viewMu.RUnlock()
133+ if a.view == nil {
102134 return false
103135 }
104136 return a.view.HasFocus()
@@ -107,7 +139,12 @@ func (a *Cef2gtkAdapter) HasFocus() bool {
107139 // SetCursorFromName applies a named cursor to the bridge widget. Must be called
108140 // on the GTK main thread.
109141 func (a *Cef2gtkAdapter) SetCursorFromName(name string) {
110- if a == nil || a.destroyed.Load() || a.view == nil {
142+ if a == nil || a.destroyed.Load() {
143+ return
144+ }
145+ a.viewMu.RLock()
146+ defer a.viewMu.RUnlock()
147+ if a.view == nil {
111148 return
112149 }
113150 a.view.SetCursorFromName(name)
@@ -116,7 +153,12 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) {
116153 // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk.
117154 // Must be called on the GTK main thread.
118155 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
119- if a == nil || a.destroyed.Load() || a.view == nil {
156+ if a == nil || a.destroyed.Load() {
157+ return ErrAdapterDestroyed
158+ }
159+ a.viewMu.RLock()
160+ defer a.viewMu.RUnlock()
161+ if a.view == nil {
120162 return ErrAdapterDestroyed
121163 }
122164 return a.view.PrepareOnGTKThread()
@@ -128,7 +170,12 @@ func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
128170 //
129171 // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed.
130172 func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {
131- if a == nil || a.destroyed.Load() || a.view == nil {
173+ if a == nil || a.destroyed.Load() {
174+ return nil
175+ }
176+ a.viewMu.RLock()
177+ defer a.viewMu.RUnlock()
178+ if a.view == nil {
132179 return nil
133180 }
134181 return a.view.RenderHandler(hooks)
@@ -136,7 +183,12 @@ func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandle
136183
137184 // ConfigureProfiling enables or disables purego-cef2gtk render profiling for this view.
138185 func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
139- if a == nil || a.destroyed.Load() || a.view == nil {
186+ if a == nil || a.destroyed.Load() {
187+ return ErrAdapterDestroyed
188+ }
189+ a.viewMu.RLock()
190+ defer a.viewMu.RUnlock()
191+ if a.view == nil {
140192 return ErrAdapterDestroyed
141193 }
142194 return a.view.ConfigureProfiling(opts)
@@ -147,7 +199,12 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
147199 //
148200 // opts.Scale configures HiDPI scale for pointer coordinate translation.
149201 func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.InputOptions) error {
150- if a == nil || a.destroyed.Load() || a.view == nil {
202+ if a == nil || a.destroyed.Load() {
203+ return ErrAdapterDestroyed
204+ }
205+ a.viewMu.RLock()
206+ defer a.viewMu.RUnlock()
207+ if a.view == nil {
151208 return ErrAdapterDestroyed
152209 }
153210 return a.view.AttachInput(host, opts)
@@ -157,7 +214,12 @@ func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.Inpu
157214 // Must be called on the GTK main thread. Callers must call AttachInput first or
158215 // this returns an error from the underlying bridge.
159216 func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error {
160- if a == nil || a.destroyed.Load() || a.view == nil {
217+ if a == nil || a.destroyed.Load() {
218+ return ErrAdapterDestroyed
219+ }
220+ a.viewMu.RLock()
221+ defer a.viewMu.RUnlock()
222+ if a.view == nil {
161223 return ErrAdapterDestroyed
162224 }
163225 return a.view.SetInputHost(host)
@@ -166,7 +228,12 @@ func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error {
166228 // DetachInput removes GTK input controllers attached by AttachInput.
167229 // Must be called on the GTK main thread.
168230 func (a *Cef2gtkAdapter) DetachInput() error {
169- if a == nil || a.destroyed.Load() || a.view == nil {
231+ if a == nil || a.destroyed.Load() {
232+ return ErrAdapterDestroyed
233+ }
234+ a.viewMu.RLock()
235+ defer a.viewMu.RUnlock()
236+ if a.view == nil {
170237 return ErrAdapterDestroyed
171238 }
172239 return a.view.DetachInput()
@@ -175,24 +242,27 @@ func (a *Cef2gtkAdapter) DetachInput() error {
175242 // Diagnostics returns a point-in-time snapshot of bridge rendering diagnostics
176243 // including accelerated paint counts, import/render failures, and events.
177244 func (a *Cef2gtkAdapter) Diagnostics() cef2gtk.Diagnostics {
178- if a == nil || a.destroyed.Load() || a.view == nil {
245+ if a == nil || a.destroyed.Load() {
246+ return cef2gtk.Diagnostics{}
247+ }
248+ a.viewMu.RLock()
249+ defer a.viewMu.RUnlock()
250+ if a.view == nil {
179251 return cef2gtk.Diagnostics{}
180252 }
181253 return a.view.Diagnostics()
182254 }
183255
184256 // Destroy releases renderer resources owned by purego-cef2gtk and disconnects GTK
185-// signal handlers. Must be called on the GTK main thread. The atomic destroyed
186-// flag only communicates lifecycle state; accesses to view are not otherwise
187-// synchronized, so callers must ensure no concurrent adapter method is using the
188-// view while Destroy runs. After Destroy, operations return ErrAdapterDestroyed
189-// or a zero-value result.
257+// signal handlers. Must be called on the GTK main thread. After Destroy,
258+// operations return ErrAdapterDestroyed or a zero-value result.
190259 func (a *Cef2gtkAdapter) Destroy() error {
191260 if a == nil {
192261 return ErrAdapterDestroyed
193262 }
194- a.destroyed.Store(true)
195- if a.view == nil {
263+ a.viewMu.Lock()
264+ defer a.viewMu.Unlock()
265+ if !a.destroyed.CompareAndSwap(false, true) || a.view == nil {
196266 return ErrAdapterDestroyed
197267 }
198268 err := a.view.Destroy()
@@ -2,6 +2,7 @@ package cef
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "sync"
5 "sync/atomic"6 "sync/atomic"
6 7
7 purecef "github.com/bnema/purego-cef/cef"8 purecef "github.com/bnema/purego-cef/cef"
@@ -24,6 +25,7 @@ var ErrAdapterDestroyed = errors.New("cef2gtk_adapter: adapter is destroyed")
24 // The adapter does NOT own GL/PBO staging, dirty-rect uploads, or GTK input25 // The adapter does NOT own GL/PBO staging, dirty-rect uploads, or GTK input
25 // controller implementation — those belong to purego-cef2gtk.26 // controller implementation — those belong to purego-cef2gtk.
26 type Cef2gtkAdapter struct {27 type Cef2gtkAdapter struct {
28+ viewMu sync.RWMutex
27 view *cef2gtk.View29 view *cef2gtk.View
28 destroyed atomic.Bool30 destroyed atomic.Bool
29 destroyCnt atomic.Uint6431 destroyCnt atomic.Uint64
@@ -42,7 +44,12 @@ func NewCef2gtkAdapter() *Cef2gtkAdapter {
42 // Widget returns the native GTK widget for embedding into containers.44 // Widget returns the native GTK widget for embedding into containers.
43 // The returned pointer must be used from the GTK main thread for packing.45 // The returned pointer must be used from the GTK main thread for packing.
44 func (a *Cef2gtkAdapter) Widget() *gtk.Widget {46 func (a *Cef2gtkAdapter) Widget() *gtk.Widget {
45- if a == nil || a.destroyed.Load() || a.view == nil {47+ if a == nil || a.destroyed.Load() {
48+ return nil
49+ }
50+ a.viewMu.RLock()
51+ defer a.viewMu.RUnlock()
52+ if a.view == nil {
46 return nil53 return nil
47 }54 }
48 return a.view.Widget()55 return a.view.Widget()
@@ -51,7 +58,12 @@ func (a *Cef2gtkAdapter) Widget() *gtk.Widget {
51 // GLArea returns the underlying GtkGLArea when the GLArea backend is active.58 // GLArea returns the underlying GtkGLArea when the GLArea backend is active.
52 // It returns nil for the GDK DMABUF backend.59 // It returns nil for the GDK DMABUF backend.
53 func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {60 func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea {
54- if a == nil || a.destroyed.Load() || a.view == nil {61+ if a == nil || a.destroyed.Load() {
62+ return nil
63+ }
64+ a.viewMu.RLock()
65+ defer a.viewMu.RUnlock()
66+ if a.view == nil {
55 return nil67 return nil
56 }68 }
57 return a.view.GLArea()69 return a.view.GLArea()
@@ -70,7 +82,12 @@ func (a *Cef2gtkAdapter) NativeWidget() uintptr {
70 // Size returns the bridge's last observed positive widget size, or 1x1 before82 // Size returns the bridge's last observed positive widget size, or 1x1 before
71 // the GTK widget has been allocated. It is safe to call off the GTK thread.83 // the GTK widget has been allocated. It is safe to call off the GTK thread.
72 func (a *Cef2gtkAdapter) Size() (int32, int32) {84 func (a *Cef2gtkAdapter) Size() (int32, int32) {
73- if a == nil || a.destroyed.Load() || a.view == nil {85+ if a == nil || a.destroyed.Load() {
86+ return 1, 1
87+ }
88+ a.viewMu.RLock()
89+ defer a.viewMu.RUnlock()
90+ if a.view == nil {
74 return 1, 191 return 1, 1
75 }92 }
76 return a.view.Size()93 return a.view.Size()
@@ -79,7 +96,12 @@ func (a *Cef2gtkAdapter) Size() (int32, int32) {
79 // DeviceScaleFactor returns the bridge's last observed GTK scale factor, or 196 // DeviceScaleFactor returns the bridge's last observed GTK scale factor, or 1
80 // before the widget has reported a scale. It is safe to call off the GTK thread.97 // before the widget has reported a scale. It is safe to call off the GTK thread.
81 func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 {98 func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 {
82- if a == nil || a.destroyed.Load() || a.view == nil {99+ if a == nil || a.destroyed.Load() {
100+ return 1
101+ }
102+ a.viewMu.RLock()
103+ defer a.viewMu.RUnlock()
104+ if a.view == nil {
83 return 1105 return 1
84 }106 }
85 return a.view.DeviceScaleFactor()107 return a.view.DeviceScaleFactor()
@@ -89,7 +111,12 @@ func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 {
89 // unregister from the GTK main thread; callbacks are invoked by the bridge from111 // unregister from the GTK main thread; callbacks are invoked by the bridge from
90 // GTK size notifications.112 // GTK size notifications.
91 func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() {113 func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() {
92- if a == nil || a.destroyed.Load() || a.view == nil {114+ if a == nil || a.destroyed.Load() {
115+ return func() {}
116+ }
117+ a.viewMu.RLock()
118+ defer a.viewMu.RUnlock()
119+ if a.view == nil {
93 return func() {}120 return func() {}
94 }121 }
95 return a.view.AddSizeObserver(fn)122 return a.view.AddSizeObserver(fn)
@@ -98,7 +125,12 @@ func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() {
98 // HasFocus reports whether the bridge widget currently has GTK focus. Must be125 // HasFocus reports whether the bridge widget currently has GTK focus. Must be
99 // called on the GTK main thread.126 // called on the GTK main thread.
100 func (a *Cef2gtkAdapter) HasFocus() bool {127 func (a *Cef2gtkAdapter) HasFocus() bool {
101- if a == nil || a.destroyed.Load() || a.view == nil {128+ if a == nil || a.destroyed.Load() {
129+ return false
130+ }
131+ a.viewMu.RLock()
132+ defer a.viewMu.RUnlock()
133+ if a.view == nil {
102 return false134 return false
103 }135 }
104 return a.view.HasFocus()136 return a.view.HasFocus()
@@ -107,7 +139,12 @@ func (a *Cef2gtkAdapter) HasFocus() bool {
107 // SetCursorFromName applies a named cursor to the bridge widget. Must be called139 // SetCursorFromName applies a named cursor to the bridge widget. Must be called
108 // on the GTK main thread.140 // on the GTK main thread.
109 func (a *Cef2gtkAdapter) SetCursorFromName(name string) {141 func (a *Cef2gtkAdapter) SetCursorFromName(name string) {
110- if a == nil || a.destroyed.Load() || a.view == nil {142+ if a == nil || a.destroyed.Load() {
143+ return
144+ }
145+ a.viewMu.RLock()
146+ defer a.viewMu.RUnlock()
147+ if a.view == nil {
111 return148 return
112 }149 }
113 a.view.SetCursorFromName(name)150 a.view.SetCursorFromName(name)
@@ -116,7 +153,12 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) {
116 // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk.153 // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk.
117 // Must be called on the GTK main thread.154 // Must be called on the GTK main thread.
118 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {155 func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
119- if a == nil || a.destroyed.Load() || a.view == nil {156+ if a == nil || a.destroyed.Load() {
157+ return ErrAdapterDestroyed
158+ }
159+ a.viewMu.RLock()
160+ defer a.viewMu.RUnlock()
161+ if a.view == nil {
120 return ErrAdapterDestroyed162 return ErrAdapterDestroyed
121 }163 }
122 return a.view.PrepareOnGTKThread()164 return a.view.PrepareOnGTKThread()
@@ -128,7 +170,12 @@ func (a *Cef2gtkAdapter) PrepareOnGTKThread() error {
128 //170 //
129 // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed.171 // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed.
130 func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {172 func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler {
131- if a == nil || a.destroyed.Load() || a.view == nil {173+ if a == nil || a.destroyed.Load() {
174+ return nil
175+ }
176+ a.viewMu.RLock()
177+ defer a.viewMu.RUnlock()
178+ if a.view == nil {
132 return nil179 return nil
133 }180 }
134 return a.view.RenderHandler(hooks)181 return a.view.RenderHandler(hooks)
@@ -136,7 +183,12 @@ func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandle
136 183
137 // ConfigureProfiling enables or disables purego-cef2gtk render profiling for this view.184 // ConfigureProfiling enables or disables purego-cef2gtk render profiling for this view.
138 func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {185 func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
139- if a == nil || a.destroyed.Load() || a.view == nil {186+ if a == nil || a.destroyed.Load() {
187+ return ErrAdapterDestroyed
188+ }
189+ a.viewMu.RLock()
190+ defer a.viewMu.RUnlock()
191+ if a.view == nil {
140 return ErrAdapterDestroyed192 return ErrAdapterDestroyed
141 }193 }
142 return a.view.ConfigureProfiling(opts)194 return a.view.ConfigureProfiling(opts)
@@ -147,7 +199,12 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error {
147 //199 //
148 // opts.Scale configures HiDPI scale for pointer coordinate translation.200 // opts.Scale configures HiDPI scale for pointer coordinate translation.
149 func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.InputOptions) error {201 func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.InputOptions) error {
150- if a == nil || a.destroyed.Load() || a.view == nil {202+ if a == nil || a.destroyed.Load() {
203+ return ErrAdapterDestroyed
204+ }
205+ a.viewMu.RLock()
206+ defer a.viewMu.RUnlock()
207+ if a.view == nil {
151 return ErrAdapterDestroyed208 return ErrAdapterDestroyed
152 }209 }
153 return a.view.AttachInput(host, opts)210 return a.view.AttachInput(host, opts)
@@ -157,7 +214,12 @@ func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.Inpu
157 // Must be called on the GTK main thread. Callers must call AttachInput first or214 // Must be called on the GTK main thread. Callers must call AttachInput first or
158 // this returns an error from the underlying bridge.215 // this returns an error from the underlying bridge.
159 func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error {216 func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error {
160- if a == nil || a.destroyed.Load() || a.view == nil {217+ if a == nil || a.destroyed.Load() {
218+ return ErrAdapterDestroyed
219+ }
220+ a.viewMu.RLock()
221+ defer a.viewMu.RUnlock()
222+ if a.view == nil {
161 return ErrAdapterDestroyed223 return ErrAdapterDestroyed
162 }224 }
163 return a.view.SetInputHost(host)225 return a.view.SetInputHost(host)
@@ -166,7 +228,12 @@ func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error {
166 // DetachInput removes GTK input controllers attached by AttachInput.228 // DetachInput removes GTK input controllers attached by AttachInput.
167 // Must be called on the GTK main thread.229 // Must be called on the GTK main thread.
168 func (a *Cef2gtkAdapter) DetachInput() error {230 func (a *Cef2gtkAdapter) DetachInput() error {
169- if a == nil || a.destroyed.Load() || a.view == nil {231+ if a == nil || a.destroyed.Load() {
232+ return ErrAdapterDestroyed
233+ }
234+ a.viewMu.RLock()
235+ defer a.viewMu.RUnlock()
236+ if a.view == nil {
170 return ErrAdapterDestroyed237 return ErrAdapterDestroyed
171 }238 }
172 return a.view.DetachInput()239 return a.view.DetachInput()
@@ -175,24 +242,27 @@ func (a *Cef2gtkAdapter) DetachInput() error {
175 // Diagnostics returns a point-in-time snapshot of bridge rendering diagnostics242 // Diagnostics returns a point-in-time snapshot of bridge rendering diagnostics
176 // including accelerated paint counts, import/render failures, and events.243 // including accelerated paint counts, import/render failures, and events.
177 func (a *Cef2gtkAdapter) Diagnostics() cef2gtk.Diagnostics {244 func (a *Cef2gtkAdapter) Diagnostics() cef2gtk.Diagnostics {
178- if a == nil || a.destroyed.Load() || a.view == nil {245+ if a == nil || a.destroyed.Load() {
246+ return cef2gtk.Diagnostics{}
247+ }
248+ a.viewMu.RLock()
249+ defer a.viewMu.RUnlock()
250+ if a.view == nil {
179 return cef2gtk.Diagnostics{}251 return cef2gtk.Diagnostics{}
180 }252 }
181 return a.view.Diagnostics()253 return a.view.Diagnostics()
182 }254 }
183 255
184 // Destroy releases renderer resources owned by purego-cef2gtk and disconnects GTK256 // Destroy releases renderer resources owned by purego-cef2gtk and disconnects GTK
185-// signal handlers. Must be called on the GTK main thread. The atomic destroyed257+// signal handlers. Must be called on the GTK main thread. After Destroy,
186-// flag only communicates lifecycle state; accesses to view are not otherwise258+// operations return ErrAdapterDestroyed or a zero-value result.
187-// synchronized, so callers must ensure no concurrent adapter method is using the
188-// view while Destroy runs. After Destroy, operations return ErrAdapterDestroyed
189-// or a zero-value result.
190 func (a *Cef2gtkAdapter) Destroy() error {259 func (a *Cef2gtkAdapter) Destroy() error {
191 if a == nil {260 if a == nil {
192 return ErrAdapterDestroyed261 return ErrAdapterDestroyed
193 }262 }
194- a.destroyed.Store(true)263+ a.viewMu.Lock()
195- if a.view == nil {264+ defer a.viewMu.Unlock()
265+ if !a.destroyed.CompareAndSwap(false, true) || a.view == nil {
196 return ErrAdapterDestroyed266 return ErrAdapterDestroyed
197 }267 }
198 err := a.view.Destroy()268 err := a.view.Destroy()
M internal/infrastructure/cef/engine.go +4 -0
@@ -217,6 +217,10 @@ func (e *Engine) Close() error {
217217 return nil
218218 }
219219
220+// cefShutdownWaitTimeout gives CEF browser destruction enough time to flush
221+// GTK/DMABUF texture teardown and child-process shutdown on busy media pages.
222+// It is intentionally longer than a UI-frame timeout; Close still logs if the
223+// wait expires so slow shutdown remains visible during diagnostics.
220224 const cefShutdownWaitTimeout = 10 * time.Second
221225
222226 func (e *Engine) activeWebViewCount() int {
@@ -217,6 +217,10 @@ func (e *Engine) Close() error {
217 return nil217 return nil
218 }218 }
219 219
220+// cefShutdownWaitTimeout gives CEF browser destruction enough time to flush
221+// GTK/DMABUF texture teardown and child-process shutdown on busy media pages.
222+// It is intentionally longer than a UI-frame timeout; Close still logs if the
223+// wait expires so slow shutdown remains visible during diagnostics.
220 const cefShutdownWaitTimeout = 10 * time.Second224 const cefShutdownWaitTimeout = 10 * time.Second
221 225
222 func (e *Engine) activeWebViewCount() int {226 func (e *Engine) activeWebViewCount() int {
M internal/infrastructure/cef/process_diagnostics.go +10 -4
@@ -238,11 +238,17 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio
238238 return
239239 }
240240 now := time.Now()
241- last := unixNSTime(renderStallBacktraceLastUnixNS.Load())
242- if !last.IsZero() && now.Sub(last) < renderStallBacktraceCooldown {
243- return
241+ nowNS := now.UnixNano()
242+ for {
243+ lastNS := renderStallBacktraceLastUnixNS.Load()
244+ last := unixNSTime(lastNS)
245+ if !last.IsZero() && now.Sub(last) < renderStallBacktraceCooldown {
246+ return
247+ }
248+ if renderStallBacktraceLastUnixNS.CompareAndSwap(lastNS, nowNS) {
249+ break
250+ }
244251 }
245- renderStallBacktraceLastUnixNS.Store(now.UnixNano())
246252 pid := os.Getpid()
247253 if _, err := exec.LookPath("gdb"); err != nil {
248254 logging.FromContext(wv.ctx).Warn().
@@ -238,11 +238,17 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio
238 return238 return
239 }239 }
240 now := time.Now()240 now := time.Now()
241- last := unixNSTime(renderStallBacktraceLastUnixNS.Load())241+ nowNS := now.UnixNano()
242- if !last.IsZero() && now.Sub(last) < renderStallBacktraceCooldown {242+ for {
243- return243+ lastNS := renderStallBacktraceLastUnixNS.Load()
244+ last := unixNSTime(lastNS)
245+ if !last.IsZero() && now.Sub(last) < renderStallBacktraceCooldown {
246+ return
247+ }
248+ if renderStallBacktraceLastUnixNS.CompareAndSwap(lastNS, nowNS) {
249+ break
250+ }
244 }251 }
245- renderStallBacktraceLastUnixNS.Store(now.UnixNano())
246 pid := os.Getpid()252 pid := os.Getpid()
247 if _, err := exec.LookPath("gdb"); err != nil {253 if _, err := exec.LookPath("gdb"); err != nil {
248 logging.FromContext(wv.ctx).Warn().254 logging.FromContext(wv.ctx).Warn().
M internal/infrastructure/cef/profile.go +8 -2
@@ -42,11 +42,14 @@ func (w *lockedProfileWriter) Write(p []byte) (int, error) {
4242 }
4343
4444 func (w *lockedProfileWriter) Close() error {
45- if w == nil || w.file == nil {
45+ if w == nil {
4646 return nil
4747 }
4848 w.mu.Lock()
4949 defer w.mu.Unlock()
50+ if w.file == nil {
51+ return nil
52+ }
5053 err := w.file.Close()
5154 w.file = nil
5255 return err
@@ -159,7 +162,10 @@ func (e *Engine) cef2gtkProfilePath() string {
159162 }
160163 metadata, ok := logging.SessionMetadataFromContext(e.ctx)
161164 if !ok || metadata.ID == "" {
162- return ""
165+ if e.profileLogDir == "" {
166+ return ""
167+ }
168+ return filepath.Join(e.profileLogDir, "cef2gtk_profile.jsonl")
163169 }
164170 logDir := e.profileLogDir
165171 if metadata.LogPath != "" {
@@ -42,11 +42,14 @@ func (w *lockedProfileWriter) Write(p []byte) (int, error) {
42 }42 }
43 43
44 func (w *lockedProfileWriter) Close() error {44 func (w *lockedProfileWriter) Close() error {
45- if w == nil || w.file == nil {45+ if w == nil {
46 return nil46 return nil
47 }47 }
48 w.mu.Lock()48 w.mu.Lock()
49 defer w.mu.Unlock()49 defer w.mu.Unlock()
50+ if w.file == nil {
51+ return nil
52+ }
50 err := w.file.Close()53 err := w.file.Close()
51 w.file = nil54 w.file = nil
52 return err55 return err
@@ -159,7 +162,10 @@ func (e *Engine) cef2gtkProfilePath() string {
159 }162 }
160 metadata, ok := logging.SessionMetadataFromContext(e.ctx)163 metadata, ok := logging.SessionMetadataFromContext(e.ctx)
161 if !ok || metadata.ID == "" {164 if !ok || metadata.ID == "" {
162- return ""165+ if e.profileLogDir == "" {
166+ return ""
167+ }
168+ return filepath.Join(e.profileLogDir, "cef2gtk_profile.jsonl")
163 }169 }
164 logDir := e.profileLogDir170 logDir := e.profileLogDir
165 if metadata.LogPath != "" {171 if metadata.LogPath != "" {
M internal/infrastructure/cef/profile_test.go +11 -0
@@ -23,6 +23,17 @@ func TestCEF2GTKProfilePathUsesSessionLogDirectory(t *testing.T) {
2323 }
2424 }
2525
26+func TestCEF2GTKProfilePathUsesEngineProfileLogDirFallback(t *testing.T) {
27+ profileLogDir := t.TempDir()
28+ eng := &Engine{ctx: context.Background(), profileLogDir: profileLogDir}
29+
30+ got := eng.cef2gtkProfilePath()
31+ want := filepath.Join(profileLogDir, "cef2gtk_profile.jsonl")
32+ if got != want {
33+ t.Fatalf("path = %q, want fallback output", got)
34+ }
35+}
36+
2637 func TestCEF2GTKProfilePathAllowsExplicitOutput(t *testing.T) {
2738 explicit := filepath.Join(t.TempDir(), "profile.jsonl")
2839 t.Setenv(cef2gtkProfileOutputEnv, explicit)
@@ -23,6 +23,17 @@ func TestCEF2GTKProfilePathUsesSessionLogDirectory(t *testing.T) {
23 }23 }
24 }24 }
25 25
26+func TestCEF2GTKProfilePathUsesEngineProfileLogDirFallback(t *testing.T) {
27+ profileLogDir := t.TempDir()
28+ eng := &Engine{ctx: context.Background(), profileLogDir: profileLogDir}
29+
30+ got := eng.cef2gtkProfilePath()
31+ want := filepath.Join(profileLogDir, "cef2gtk_profile.jsonl")
32+ if got != want {
33+ t.Fatalf("path = %q, want fallback output", got)
34+ }
35+}
36+
26 func TestCEF2GTKProfilePathAllowsExplicitOutput(t *testing.T) {37 func TestCEF2GTKProfilePathAllowsExplicitOutput(t *testing.T) {
27 explicit := filepath.Join(t.TempDir(), "profile.jsonl")38 explicit := filepath.Join(t.TempDir(), "profile.jsonl")
28 t.Setenv(cef2gtkProfileOutputEnv, explicit)39 t.Setenv(cef2gtkProfileOutputEnv, explicit)
M internal/infrastructure/cef/render_diagnostics.go +3 -1
@@ -132,7 +132,6 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAda
132132 wv.renderStallMu.Unlock()
133133 return
134134 }
135- wv.renderStallRecoveryLastAt = now
136135 wv.renderStallMu.Unlock()
137136
138137 wv.mu.RLock()
@@ -178,6 +177,9 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAda
178177 if result != 1 {
179178 return
180179 }
180+ wv.renderStallMu.Lock()
181+ wv.renderStallRecoveryLastAt = now
182+ wv.renderStallMu.Unlock()
181183 cefScheduleAfter(2*time.Second, func() {
182184 if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil {
183185 return
@@ -132,7 +132,6 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAda
132 wv.renderStallMu.Unlock()132 wv.renderStallMu.Unlock()
133 return133 return
134 }134 }
135- wv.renderStallRecoveryLastAt = now
136 wv.renderStallMu.Unlock()135 wv.renderStallMu.Unlock()
137 136
138 wv.mu.RLock()137 wv.mu.RLock()
@@ -178,6 +177,9 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAda
178 if result != 1 {177 if result != 1 {
179 return178 return
180 }179 }
180+ wv.renderStallMu.Lock()
181+ wv.renderStallRecoveryLastAt = now
182+ wv.renderStallMu.Unlock()
181 cefScheduleAfter(2*time.Second, func() {183 cefScheduleAfter(2*time.Second, func() {
182 if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil {184 if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil {
183 return185 return
M internal/infrastructure/cef/render_handler_adapter.go +8 -3
@@ -1,6 +1,8 @@
11 package cef
22
33 import (
4+ "sync"
5+
46 purecef "github.com/bnema/purego-cef/cef"
57 cef2gtk "github.com/bnema/purego-cef2gtk"
68
@@ -11,11 +13,14 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
1113 if wv == nil || wv.viewBridge == nil {
1214 return nil
1315 }
16+ var unsupportedPaintOnce sync.Once
1417 hooks := cef2gtk.Hooks{
1518 OnUnsupportedPaint: func() {
16- if wv.ctx != nil {
17- logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge")
18- }
19+ unsupportedPaintOnce.Do(func() {
20+ if wv.ctx != nil {
21+ logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge")
22+ }
23+ })
1924 },
2025 OnError: func(err error) {
2126 if wv.ctx != nil {
@@ -1,6 +1,8 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "sync"
5+
4 purecef "github.com/bnema/purego-cef/cef"6 purecef "github.com/bnema/purego-cef/cef"
5 cef2gtk "github.com/bnema/purego-cef2gtk"7 cef2gtk "github.com/bnema/purego-cef2gtk"
6 8
@@ -11,11 +13,14 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler {
11 if wv == nil || wv.viewBridge == nil {13 if wv == nil || wv.viewBridge == nil {
12 return nil14 return nil
13 }15 }
16+ var unsupportedPaintOnce sync.Once
14 hooks := cef2gtk.Hooks{17 hooks := cef2gtk.Hooks{
15 OnUnsupportedPaint: func() {18 OnUnsupportedPaint: func() {
16- if wv.ctx != nil {19+ unsupportedPaintOnce.Do(func() {
17- logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge")20+ if wv.ctx != nil {
18- }21+ logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge")
22+ }
23+ })
19 },24 },
20 OnError: func(err error) {25 OnError: func(err error) {
21 if wv.ctx != nil {26 if wv.ctx != nil {
M internal/infrastructure/cef/render_stall_watchdog.go +7 -1
@@ -82,7 +82,13 @@ func (wv *WebView) stopRenderStallWatchdog() {
8282 return
8383 }
8484 close(stop)
85- <-done
85+ select {
86+ case <-done:
87+ case <-time.After(cefHeartbeatStopTimeout):
88+ if wv.ctx != nil {
89+ logging.FromContext(wv.ctx).Warn().Msg("cef: render stall watchdog stop timed out")
90+ }
91+ }
8692 }
8793
8894 func (wv *WebView) renderStallWatchdogLoop(bridge *Cef2gtkAdapter, stop <-chan struct{}, done chan<- struct{}) {
@@ -82,7 +82,13 @@ func (wv *WebView) stopRenderStallWatchdog() {
82 return82 return
83 }83 }
84 close(stop)84 close(stop)
85- <-done85+ select {
86+ case <-done:
87+ case <-time.After(cefHeartbeatStopTimeout):
88+ if wv.ctx != nil {
89+ logging.FromContext(wv.ctx).Warn().Msg("cef: render stall watchdog stop timed out")
90+ }
91+ }
86 }92 }
87 93
88 func (wv *WebView) renderStallWatchdogLoop(bridge *Cef2gtkAdapter, stop <-chan struct{}, done chan<- struct{}) {94 func (wv *WebView) renderStallWatchdogLoop(bridge *Cef2gtkAdapter, stop <-chan struct{}, done chan<- struct{}) {
M internal/infrastructure/cef/webview_handlers.go +8 -2
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "strings"
7+ "sync"
78
89 purecef "github.com/bnema/purego-cef/cef"
910 cef2gtk "github.com/bnema/purego-cef2gtk"
@@ -23,7 +24,9 @@ const (
2324 // owning WebView. A single struct is used so that the Client's Get*Handler
2425 // methods can return the same receiver, avoiding extra allocations.
2526 type handlerSet struct {
26- wv *WebView
27+ wv *WebView
28+ renderHandlerOnce sync.Once
29+ renderHandler purecef.RenderHandler
2730 }
2831
2932 // Compile-time interface checks.
@@ -74,7 +77,10 @@ func (h *handlerSet) GetRenderHandler() purecef.RenderHandler {
7477 if h == nil {
7578 return nil
7679 }
77- return newDumberRenderHandler(h.wv)
80+ h.renderHandlerOnce.Do(func() {
81+ h.renderHandler = newDumberRenderHandler(h.wv)
82+ })
83+ return h.renderHandler
7884 }
7985 func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
8086
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "strings"6 "strings"
7+ "sync"
7 8
8 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
9 cef2gtk "github.com/bnema/purego-cef2gtk"10 cef2gtk "github.com/bnema/purego-cef2gtk"
@@ -23,7 +24,9 @@ const (
23 // owning WebView. A single struct is used so that the Client's Get*Handler24 // owning WebView. A single struct is used so that the Client's Get*Handler
24 // methods can return the same receiver, avoiding extra allocations.25 // methods can return the same receiver, avoiding extra allocations.
25 type handlerSet struct {26 type handlerSet struct {
26- wv *WebView27+ wv *WebView
28+ renderHandlerOnce sync.Once
29+ renderHandler purecef.RenderHandler
27 }30 }
28 31
29 // Compile-time interface checks.32 // Compile-time interface checks.
@@ -74,7 +77,10 @@ func (h *handlerSet) GetRenderHandler() purecef.RenderHandler {
74 if h == nil {77 if h == nil {
75 return nil78 return nil
76 }79 }
77- return newDumberRenderHandler(h.wv)80+ h.renderHandlerOnce.Do(func() {
81+ h.renderHandler = newDumberRenderHandler(h.wv)
82+ })
83+ return h.renderHandler
78 }84 }
79 func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }85 func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
80 86
M scripts/cef-gpu-diagnostics.sh +1 -1
@@ -71,7 +71,7 @@ if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then
7171 echo "could not find a live dumber PID; pass --pid" >&2
7272 exit 1
7373 fi
74-if ! [[ "$samples" =~ ^[0-9]+$ && "$interval_ms" =~ ^[0-9]+$ ]]; then
74+if ! [[ "$samples" =~ ^[1-9][0-9]*$ && "$interval_ms" =~ ^[1-9][0-9]*$ ]]; then
7575 echo "--samples and --interval-ms must be positive integers" >&2
7676 exit 2
7777 fi
@@ -71,7 +71,7 @@ if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then
71 echo "could not find a live dumber PID; pass --pid" >&271 echo "could not find a live dumber PID; pass --pid" >&2
72 exit 172 exit 1
73 fi73 fi
74-if ! [[ "$samples" =~ ^[0-9]+$ && "$interval_ms" =~ ^[0-9]+$ ]]; then74+if ! [[ "$samples" =~ ^[1-9][0-9]*$ && "$interval_ms" =~ ^[1-9][0-9]*$ ]]; then
75 echo "--samples and --interval-ms must be positive integers" >&275 echo "--samples and --interval-ms must be positive integers" >&2
76 exit 276 exit 2
77 fi77 fi
D scripts/generate-types.js +0 -291
@@ -1,291 +0,0 @@
1-#!/usr/bin/env node
2-
3-import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
4-import { join, dirname } from 'path';
5-
6-/**
7- * Generate TypeScript types from Go DTOs by parsing Go struct definitions
8- * Watches for changes in Go files and regenerates automatically
9- */
10-
11-const GO_MODELS_PATH = 'internal/db/';
12-const GO_PARSER_PATH = 'internal/parser/';
13-const TS_OUTPUT_PATH = 'gui/src/types/generated.ts';
14-
15-// Helper function to convert Go types to TypeScript
16-function convertGoTypeToTS(goType) {
17- const cleanType = goType.trim().replace(/^\*/, ''); // Remove pointer indicator
18-
19- // Handle Go slices: []Type -> Type[]
20- if (cleanType.startsWith('[]')) {
21- const elementType = cleanType.slice(2); // Remove []
22- return convertGoTypeToTS(elementType) + '[]';
23- }
24-
25- // Handle sql.Null types
26- if (cleanType.includes('sql.NullString')) return 'string | null';
27- if (cleanType.includes('sql.NullInt64')) return 'number | null';
28- if (cleanType.includes('sql.NullTime')) return 'string | null';
29- if (cleanType.includes('sql.NullBool')) return 'boolean | null';
30- if (cleanType.includes('sql.NullFloat64')) return 'number | null';
31-
32- // Handle Go package prefixes
33- if (cleanType.includes('*db.History')) return 'History | null';
34- if (cleanType.includes('db.History')) return 'History';
35-
36- if (cleanType.includes('time.Time')) return 'string'; // ISO date strings
37- if (cleanType.includes('time.Duration')) return 'number'; // Nanoseconds
38- if (cleanType.includes('map[string]interface{}')) return 'Record<string, unknown>';
39- if (cleanType.includes('map[string]string')) return 'Record<string, string>';
40- if (cleanType.includes('map[string]any')) return 'Record<string, unknown>';
41- if (cleanType.includes('string')) return 'string';
42- if (cleanType.includes('int') || cleanType.includes('float')) return 'number';
43- if (cleanType.includes('bool')) return 'boolean';
44- if (cleanType.match(/^[A-Z][a-zA-Z0-9]*$/)) return cleanType; // Custom struct types
45-
46- return 'unknown'; // Fallback
47-}
48-
49-// Parse Go struct to extract JSON field names and types
50-function parseGoStruct(goCode, structName) {
51- // More sophisticated regex to handle nested braces in type definitions
52- const structStart = new RegExp(`type\\s+${structName}\\s+struct\\s*{`, 's');
53- const startMatch = goCode.match(structStart);
54-
55- if (!startMatch) return null;
56-
57- // Find the matching closing brace by counting braces
58- const startIndex = startMatch.index + startMatch[0].length;
59- let braceCount = 1;
60- let endIndex = startIndex;
61-
62- for (let i = startIndex; i < goCode.length && braceCount > 0; i++) {
63- if (goCode[i] === '{') braceCount++;
64- if (goCode[i] === '}') braceCount--;
65- endIndex = i;
66- }
67-
68- if (braceCount !== 0) return null; // Unmatched braces
69-
70- const structContent = goCode.substring(startIndex, endIndex);
71- const match = [null, structContent]; // Simulate regex match format
72-
73- if (!match) return null;
74-
75- // First, reconstruct the struct content by merging lines that belong together
76- const rawContent = match[1];
77- const lines = rawContent.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//'));
78-
79- // Merge multi-line field definitions
80- const mergedFields = [];
81- let currentField = '';
82-
83- for (const line of lines) {
84- // If line starts with a field name (capital letter), it's a new field
85- if (/^\w+\s+/.test(line)) {
86- // Save previous field if exists
87- if (currentField) {
88- mergedFields.push(currentField);
89- }
90- currentField = line;
91- } else {
92- // This line continues the previous field
93- currentField += ' ' + line;
94- }
95- }
96-
97- // Don't forget the last field
98- if (currentField) {
99- mergedFields.push(currentField);
100- }
101-
102- const fields = mergedFields.map(fieldLine => {
103- // Check for embedded struct (no field name, just type)
104- const embeddedMatch = fieldLine.match(/^([A-Z][a-zA-Z0-9]*)\s*$/);
105- if (embeddedMatch) {
106- const embeddedType = embeddedMatch[1];
107- // Return a special marker for embedded structs
108- return {
109- embedded: true,
110- type: embeddedType
111- };
112- }
113-
114- // Parse: FieldName type `json:"field_name,omitempty"`
115- const fieldMatch = fieldLine.match(/^(\w+)\s+([^`]+?)\s*`json:"([^"]+)".*?`/);
116- if (!fieldMatch) return null;
117-
118- const [, , goType, jsonTag] = fieldMatch;
119- const jsonField = jsonTag.split(',')[0]; // Remove omitempty, etc.
120- const isOptional = jsonTag.includes('omitempty') || jsonTag.includes(',omitempty') || goType.trim().startsWith('*');
121-
122- // Convert Go types to TypeScript using helper function
123- const tsType = convertGoTypeToTS(goType.trim());
124-
125- return {
126- name: jsonField,
127- type: tsType,
128- optional: isOptional
129- };
130- })
131- .filter(Boolean);
132-
133- // Handle embedded structs by resolving their fields
134- const resolvedFields = [];
135- for (const field of fields) {
136- if (field.embedded) {
137- // Find the embedded struct and get its fields
138- const embeddedFields = parseGoStruct(goCode, field.type);
139- if (embeddedFields) {
140- resolvedFields.push(...embeddedFields);
141- }
142- } else {
143- resolvedFields.push(field);
144- }
145- }
146-
147- return resolvedFields;
148-}
149-
150-function generateTSInterface(structName, fields) {
151- const interfaceFields = fields.map(field =>
152- ` ${field.name}${field.optional ? '?' : ''}: ${field.type};`
153- ).join('\n');
154-
155- return `export interface ${structName} {\n${interfaceFields}\n}`;
156-}
157-
158-// Function to discover all struct names in Go code
159-function findAllStructs(goCode) {
160- const structRegex = /type\s+([A-Z][a-zA-Z0-9]*)\s+struct\s*{/g;
161- const structs = [];
162- let match;
163-
164- while ((match = structRegex.exec(goCode)) !== null) {
165- structs.push(match[1]);
166- }
167-
168- return structs;
169-}
170-
171-// Function to find Go enums (const declarations)
172-function findGoEnums(goCode) {
173- const enums = [];
174-
175- // Look for InputType specifically
176- if (goCode.includes('type InputType int')) {
177- // Find the const block that contains InputType values
178- const constRegex = /const\s*\(\s*([\s\S]*?)\s*\)/;
179- const matches = goCode.match(constRegex);
180-
181- if (matches) {
182- const constBody = matches[1];
183- // Look for any identifier that starts with InputType
184- const lines = constBody.split('\n');
185- const values = [];
186-
187- for (const line of lines) {
188- const trimmedLine = line.trim();
189-
190- // Skip comment lines and empty lines
191- if (trimmedLine.startsWith('//') || trimmedLine === '') continue;
192-
193- // Check if this line contains an InputType identifier
194- const identifierMatch = trimmedLine.match(/^\s*(InputType\w+)/);
195- if (identifierMatch) {
196- values.push(identifierMatch[1]);
197- }
198- }
199-
200- if (values.length > 0) {
201- enums.push({ name: 'InputType', values });
202- }
203- }
204- }
205-
206- return enums;
207-}
208-
209-function generateTSEnum(enumName, values) {
210- const enumValues = values.map((value, index) => ` ${value} = ${index}`).join(',\n');
211- return `export enum ${enumName} {\n${enumValues}\n}`;
212-}
213-
214-function generateTypes() {
215- console.log('🔧 Parsing Go structs and generating TypeScript types...');
216-
217- const sourceFiles = [
218- { path: GO_MODELS_PATH, files: ['models.go'] },
219- { path: GO_PARSER_PATH, files: ['types.go'] }
220- ];
221-
222- let combinedTypes = `// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
223-// Auto-generated TypeScript types from Go structs
224-// Generated on: ${new Date().toISOString()}
225-// Generator script: scripts/generate-types.js
226-// Source files: internal/db/models.go, internal/parser/types.go
227-// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
228-
229-`;
230-
231- const generatedEnums = new Set();
232-
233- for (const { path, files } of sourceFiles) {
234- for (const filename of files) {
235- const filePath = join(path, filename);
236- if (!existsSync(filePath)) continue;
237-
238- const goCode = readFileSync(filePath, 'utf-8');
239- console.log(` 📄 Processing ${filename}...`);
240-
241- // Automatically discover all structs in this file
242- const structsInFile = findAllStructs(goCode);
243- const enumsInFile = findGoEnums(goCode);
244-
245- // Generate enums first
246- console.log(` Found ${enumsInFile.length} enums in ${filename}`);
247- for (const enumDef of enumsInFile) {
248- if (generatedEnums.has(enumDef.name)) {
249- continue;
250- }
251- generatedEnums.add(enumDef.name);
252- const tsEnum = generateTSEnum(enumDef.name, enumDef.values);
253- combinedTypes += tsEnum + '\n\n';
254- console.log(` ✓ Generated enum ${enumDef.name} with values: ${enumDef.values.join(', ')}`);
255- }
256-
257- for (const structName of structsInFile) {
258- const fields = parseGoStruct(goCode, structName);
259- if (fields && fields.length > 0) {
260- const tsInterface = generateTSInterface(structName, fields);
261- combinedTypes += tsInterface + '\n\n';
262- console.log(` ✓ Generated ${structName}`);
263- }
264- }
265- }
266- }
267-
268- // Add manual types that are hard to auto-generate
269- combinedTypes += `// Manual types and aliases
270-${generatedEnums.has('InputType') ? '' : `export enum InputType {
271- InputTypeDirectURL = 0,
272- InputTypeSearchShortcut = 1,
273- InputTypeHistorySearch = 2,
274- InputTypeFallbackSearch = 3,
275-}
276-
277-`}// Type aliases for frontend usage
278-export type HistoryEntry = History;
279-export type SearchShortcut = Shortcut;
280-export type ZoomEntry = ZoomSetting;
281-`;
282-
283- // Ensure output directory exists
284- mkdirSync(dirname(TS_OUTPUT_PATH), { recursive: true });
285-
286- writeFileSync(TS_OUTPUT_PATH, combinedTypes);
287- console.log(`✅ TypeScript types generated at: ${TS_OUTPUT_PATH}`);
288-}
289-
290-// One-time generation
291-generateTypes();⏎̸
@@ -1,291 +0,0 @@
1-#!/usr/bin/env node
2-
3-import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
4-import { join, dirname } from 'path';
5-
6-/**
7- * Generate TypeScript types from Go DTOs by parsing Go struct definitions
8- * Watches for changes in Go files and regenerates automatically
9- */
10-
11-const GO_MODELS_PATH = 'internal/db/';
12-const GO_PARSER_PATH = 'internal/parser/';
13-const TS_OUTPUT_PATH = 'gui/src/types/generated.ts';
14-
15-// Helper function to convert Go types to TypeScript
16-function convertGoTypeToTS(goType) {
17- const cleanType = goType.trim().replace(/^\*/, ''); // Remove pointer indicator
18-
19- // Handle Go slices: []Type -> Type[]
20- if (cleanType.startsWith('[]')) {
21- const elementType = cleanType.slice(2); // Remove []
22- return convertGoTypeToTS(elementType) + '[]';
23- }
24-
25- // Handle sql.Null types
26- if (cleanType.includes('sql.NullString')) return 'string | null';
27- if (cleanType.includes('sql.NullInt64')) return 'number | null';
28- if (cleanType.includes('sql.NullTime')) return 'string | null';
29- if (cleanType.includes('sql.NullBool')) return 'boolean | null';
30- if (cleanType.includes('sql.NullFloat64')) return 'number | null';
31-
32- // Handle Go package prefixes
33- if (cleanType.includes('*db.History')) return 'History | null';
34- if (cleanType.includes('db.History')) return 'History';
35-
36- if (cleanType.includes('time.Time')) return 'string'; // ISO date strings
37- if (cleanType.includes('time.Duration')) return 'number'; // Nanoseconds
38- if (cleanType.includes('map[string]interface{}')) return 'Record<string, unknown>';
39- if (cleanType.includes('map[string]string')) return 'Record<string, string>';
40- if (cleanType.includes('map[string]any')) return 'Record<string, unknown>';
41- if (cleanType.includes('string')) return 'string';
42- if (cleanType.includes('int') || cleanType.includes('float')) return 'number';
43- if (cleanType.includes('bool')) return 'boolean';
44- if (cleanType.match(/^[A-Z][a-zA-Z0-9]*$/)) return cleanType; // Custom struct types
45-
46- return 'unknown'; // Fallback
47-}
48-
49-// Parse Go struct to extract JSON field names and types
50-function parseGoStruct(goCode, structName) {
51- // More sophisticated regex to handle nested braces in type definitions
52- const structStart = new RegExp(`type\\s+${structName}\\s+struct\\s*{`, 's');
53- const startMatch = goCode.match(structStart);
54-
55- if (!startMatch) return null;
56-
57- // Find the matching closing brace by counting braces
58- const startIndex = startMatch.index + startMatch[0].length;
59- let braceCount = 1;
60- let endIndex = startIndex;
61-
62- for (let i = startIndex; i < goCode.length && braceCount > 0; i++) {
63- if (goCode[i] === '{') braceCount++;
64- if (goCode[i] === '}') braceCount--;
65- endIndex = i;
66- }
67-
68- if (braceCount !== 0) return null; // Unmatched braces
69-
70- const structContent = goCode.substring(startIndex, endIndex);
71- const match = [null, structContent]; // Simulate regex match format
72-
73- if (!match) return null;
74-
75- // First, reconstruct the struct content by merging lines that belong together
76- const rawContent = match[1];
77- const lines = rawContent.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//'));
78-
79- // Merge multi-line field definitions
80- const mergedFields = [];
81- let currentField = '';
82-
83- for (const line of lines) {
84- // If line starts with a field name (capital letter), it's a new field
85- if (/^\w+\s+/.test(line)) {
86- // Save previous field if exists
87- if (currentField) {
88- mergedFields.push(currentField);
89- }
90- currentField = line;
91- } else {
92- // This line continues the previous field
93- currentField += ' ' + line;
94- }
95- }
96-
97- // Don't forget the last field
98- if (currentField) {
99- mergedFields.push(currentField);
100- }
101-
102- const fields = mergedFields.map(fieldLine => {
103- // Check for embedded struct (no field name, just type)
104- const embeddedMatch = fieldLine.match(/^([A-Z][a-zA-Z0-9]*)\s*$/);
105- if (embeddedMatch) {
106- const embeddedType = embeddedMatch[1];
107- // Return a special marker for embedded structs
108- return {
109- embedded: true,
110- type: embeddedType
111- };
112- }
113-
114- // Parse: FieldName type `json:"field_name,omitempty"`
115- const fieldMatch = fieldLine.match(/^(\w+)\s+([^`]+?)\s*`json:"([^"]+)".*?`/);
116- if (!fieldMatch) return null;
117-
118- const [, , goType, jsonTag] = fieldMatch;
119- const jsonField = jsonTag.split(',')[0]; // Remove omitempty, etc.
120- const isOptional = jsonTag.includes('omitempty') || jsonTag.includes(',omitempty') || goType.trim().startsWith('*');
121-
122- // Convert Go types to TypeScript using helper function
123- const tsType = convertGoTypeToTS(goType.trim());
124-
125- return {
126- name: jsonField,
127- type: tsType,
128- optional: isOptional
129- };
130- })
131- .filter(Boolean);
132-
133- // Handle embedded structs by resolving their fields
134- const resolvedFields = [];
135- for (const field of fields) {
136- if (field.embedded) {
137- // Find the embedded struct and get its fields
138- const embeddedFields = parseGoStruct(goCode, field.type);
139- if (embeddedFields) {
140- resolvedFields.push(...embeddedFields);
141- }
142- } else {
143- resolvedFields.push(field);
144- }
145- }
146-
147- return resolvedFields;
148-}
149-
150-function generateTSInterface(structName, fields) {
151- const interfaceFields = fields.map(field =>
152- ` ${field.name}${field.optional ? '?' : ''}: ${field.type};`
153- ).join('\n');
154-
155- return `export interface ${structName} {\n${interfaceFields}\n}`;
156-}
157-
158-// Function to discover all struct names in Go code
159-function findAllStructs(goCode) {
160- const structRegex = /type\s+([A-Z][a-zA-Z0-9]*)\s+struct\s*{/g;
161- const structs = [];
162- let match;
163-
164- while ((match = structRegex.exec(goCode)) !== null) {
165- structs.push(match[1]);
166- }
167-
168- return structs;
169-}
170-
171-// Function to find Go enums (const declarations)
172-function findGoEnums(goCode) {
173- const enums = [];
174-
175- // Look for InputType specifically
176- if (goCode.includes('type InputType int')) {
177- // Find the const block that contains InputType values
178- const constRegex = /const\s*\(\s*([\s\S]*?)\s*\)/;
179- const matches = goCode.match(constRegex);
180-
181- if (matches) {
182- const constBody = matches[1];
183- // Look for any identifier that starts with InputType
184- const lines = constBody.split('\n');
185- const values = [];
186-
187- for (const line of lines) {
188- const trimmedLine = line.trim();
189-
190- // Skip comment lines and empty lines
191- if (trimmedLine.startsWith('//') || trimmedLine === '') continue;
192-
193- // Check if this line contains an InputType identifier
194- const identifierMatch = trimmedLine.match(/^\s*(InputType\w+)/);
195- if (identifierMatch) {
196- values.push(identifierMatch[1]);
197- }
198- }
199-
200- if (values.length > 0) {
201- enums.push({ name: 'InputType', values });
202- }
203- }
204- }
205-
206- return enums;
207-}
208-
209-function generateTSEnum(enumName, values) {
210- const enumValues = values.map((value, index) => ` ${value} = ${index}`).join(',\n');
211- return `export enum ${enumName} {\n${enumValues}\n}`;
212-}
213-
214-function generateTypes() {
215- console.log('🔧 Parsing Go structs and generating TypeScript types...');
216-
217- const sourceFiles = [
218- { path: GO_MODELS_PATH, files: ['models.go'] },
219- { path: GO_PARSER_PATH, files: ['types.go'] }
220- ];
221-
222- let combinedTypes = `// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
223-// Auto-generated TypeScript types from Go structs
224-// Generated on: ${new Date().toISOString()}
225-// Generator script: scripts/generate-types.js
226-// Source files: internal/db/models.go, internal/parser/types.go
227-// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
228-
229-`;
230-
231- const generatedEnums = new Set();
232-
233- for (const { path, files } of sourceFiles) {
234- for (const filename of files) {
235- const filePath = join(path, filename);
236- if (!existsSync(filePath)) continue;
237-
238- const goCode = readFileSync(filePath, 'utf-8');
239- console.log(` 📄 Processing ${filename}...`);
240-
241- // Automatically discover all structs in this file
242- const structsInFile = findAllStructs(goCode);
243- const enumsInFile = findGoEnums(goCode);
244-
245- // Generate enums first
246- console.log(` Found ${enumsInFile.length} enums in ${filename}`);
247- for (const enumDef of enumsInFile) {
248- if (generatedEnums.has(enumDef.name)) {
249- continue;
250- }
251- generatedEnums.add(enumDef.name);
252- const tsEnum = generateTSEnum(enumDef.name, enumDef.values);
253- combinedTypes += tsEnum + '\n\n';
254- console.log(` ✓ Generated enum ${enumDef.name} with values: ${enumDef.values.join(', ')}`);
255- }
256-
257- for (const structName of structsInFile) {
258- const fields = parseGoStruct(goCode, structName);
259- if (fields && fields.length > 0) {
260- const tsInterface = generateTSInterface(structName, fields);
261- combinedTypes += tsInterface + '\n\n';
262- console.log(` ✓ Generated ${structName}`);
263- }
264- }
265- }
266- }
267-
268- // Add manual types that are hard to auto-generate
269- combinedTypes += `// Manual types and aliases
270-${generatedEnums.has('InputType') ? '' : `export enum InputType {
271- InputTypeDirectURL = 0,
272- InputTypeSearchShortcut = 1,
273- InputTypeHistorySearch = 2,
274- InputTypeFallbackSearch = 3,
275-}
276-
277-`}// Type aliases for frontend usage
278-export type HistoryEntry = History;
279-export type SearchShortcut = Shortcut;
280-export type ZoomEntry = ZoomSetting;
281-`;
282-
283- // Ensure output directory exists
284- mkdirSync(dirname(TS_OUTPUT_PATH), { recursive: true });
285-
286- writeFileSync(TS_OUTPUT_PATH, combinedTypes);
287- console.log(`✅ TypeScript types generated at: ${TS_OUTPUT_PATH}`);
288-}
289-
290-// One-time generation
291-generateTypes();⏎̸

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