cef: address render stack review findings
- commit
- 3478db379a07a8b3560833b70205e40911397fbb
- author
- brice · · 2026-05-01 14:12 UTC
- parent
- 3a4bc2a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +159 -334
M go.mod +2 -4
| @@ -7,10 +7,10 @@ require ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 9 | 9 | 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 | |
| 11 | 11 | github.com/bnema/purego-pipewire v0.1.2 | |
| 12 | 12 | 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 | |
| 14 | 14 | github.com/charmbracelet/bubbles v1.0.0 | |
| 15 | 15 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 16 | 16 | github.com/charmbracelet/lipgloss v1.1.0 | |
| @@ -79,5 +79,3 @@ require ( | |||
| 79 | 79 | ) | |
| 80 | 80 | ||
| 81 | 81 | 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.1 | 7 | github.com/andybalholm/brotli v1.2.1 | ||
| 8 | github.com/bnema/purego v0.11.0-bnema.2 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | ||
| 9 | github.com/bnema/purego-cef v0.12.0 | 9 | 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 |
| 11 | github.com/bnema/purego-pipewire v0.1.2 | 11 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 12 | github.com/bnema/purego-sqlite v0.1.2 | 12 | 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 |
| 14 | github.com/charmbracelet/bubbles v1.0.0 | 14 | github.com/charmbracelet/bubbles v1.0.0 | ||
| 15 | github.com/charmbracelet/bubbletea v1.3.10 | 15 | github.com/charmbracelet/bubbletea v1.3.10 | ||
| 16 | github.com/charmbracelet/lipgloss v1.1.0 | 16 | github.com/charmbracelet/lipgloss v1.1.0 | ||
| @@ -79,5 +79,3 @@ require ( | |||||
| 79 | ) | 79 | ) | ||
| 80 | 80 | ||||
| 81 | tool github.com/a-h/templ/cmd/templ | 81 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "sync" | |
| 5 | 6 | "sync/atomic" | |
| 6 | 7 | ||
| 7 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -24,6 +25,7 @@ var ErrAdapterDestroyed = errors.New("cef2gtk_adapter: adapter is destroyed") | |||
| 24 | 25 | // The adapter does NOT own GL/PBO staging, dirty-rect uploads, or GTK input | |
| 25 | 26 | // controller implementation — those belong to purego-cef2gtk. | |
| 26 | 27 | type Cef2gtkAdapter struct { | |
| 28 | + | viewMu sync.RWMutex | |
| 27 | 29 | view *cef2gtk.View | |
| 28 | 30 | destroyed atomic.Bool | |
| 29 | 31 | destroyCnt atomic.Uint64 | |
| @@ -42,7 +44,12 @@ func NewCef2gtkAdapter() *Cef2gtkAdapter { | |||
| 42 | 44 | // Widget returns the native GTK widget for embedding into containers. | |
| 43 | 45 | // The returned pointer must be used from the GTK main thread for packing. | |
| 44 | 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 | 53 | return nil | |
| 47 | 54 | } | |
| 48 | 55 | return a.view.Widget() | |
| @@ -51,7 +58,12 @@ func (a *Cef2gtkAdapter) Widget() *gtk.Widget { | |||
| 51 | 58 | // GLArea returns the underlying GtkGLArea when the GLArea backend is active. | |
| 52 | 59 | // It returns nil for the GDK DMABUF backend. | |
| 53 | 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 | 67 | return nil | |
| 56 | 68 | } | |
| 57 | 69 | return a.view.GLArea() | |
| @@ -70,7 +82,12 @@ func (a *Cef2gtkAdapter) NativeWidget() uintptr { | |||
| 70 | 82 | // Size returns the bridge's last observed positive widget size, or 1x1 before | |
| 71 | 83 | // the GTK widget has been allocated. It is safe to call off the GTK thread. | |
| 72 | 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 | 91 | return 1, 1 | |
| 75 | 92 | } | |
| 76 | 93 | return a.view.Size() | |
| @@ -79,7 +96,12 @@ func (a *Cef2gtkAdapter) Size() (int32, int32) { | |||
| 79 | 96 | // DeviceScaleFactor returns the bridge's last observed GTK scale factor, or 1 | |
| 80 | 97 | // before the widget has reported a scale. It is safe to call off the GTK thread. | |
| 81 | 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 | 105 | return 1 | |
| 84 | 106 | } | |
| 85 | 107 | return a.view.DeviceScaleFactor() | |
| @@ -89,7 +111,12 @@ func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 { | |||
| 89 | 111 | // unregister from the GTK main thread; callbacks are invoked by the bridge from | |
| 90 | 112 | // GTK size notifications. | |
| 91 | 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 | 120 | return func() {} | |
| 94 | 121 | } | |
| 95 | 122 | return a.view.AddSizeObserver(fn) | |
| @@ -98,7 +125,12 @@ func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() { | |||
| 98 | 125 | // HasFocus reports whether the bridge widget currently has GTK focus. Must be | |
| 99 | 126 | // called on the GTK main thread. | |
| 100 | 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 | 134 | return false | |
| 103 | 135 | } | |
| 104 | 136 | return a.view.HasFocus() | |
| @@ -107,7 +139,12 @@ func (a *Cef2gtkAdapter) HasFocus() bool { | |||
| 107 | 139 | // SetCursorFromName applies a named cursor to the bridge widget. Must be called | |
| 108 | 140 | // on the GTK main thread. | |
| 109 | 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 | 148 | return | |
| 112 | 149 | } | |
| 113 | 150 | a.view.SetCursorFromName(name) | |
| @@ -116,7 +153,12 @@ func (a *Cef2gtkAdapter) SetCursorFromName(name string) { | |||
| 116 | 153 | // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk. | |
| 117 | 154 | // Must be called on the GTK main thread. | |
| 118 | 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 | 162 | return ErrAdapterDestroyed | |
| 121 | 163 | } | |
| 122 | 164 | return a.view.PrepareOnGTKThread() | |
| @@ -128,7 +170,12 @@ func (a *Cef2gtkAdapter) PrepareOnGTKThread() error { | |||
| 128 | 170 | // | |
| 129 | 171 | // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed. | |
| 130 | 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 | 179 | return nil | |
| 133 | 180 | } | |
| 134 | 181 | return a.view.RenderHandler(hooks) | |
| @@ -136,7 +183,12 @@ func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandle | |||
| 136 | 183 | ||
| 137 | 184 | // ConfigureProfiling enables or disables purego-cef2gtk render profiling for this view. | |
| 138 | 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 | 192 | return ErrAdapterDestroyed | |
| 141 | 193 | } | |
| 142 | 194 | return a.view.ConfigureProfiling(opts) | |
| @@ -147,7 +199,12 @@ func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error { | |||
| 147 | 199 | // | |
| 148 | 200 | // opts.Scale configures HiDPI scale for pointer coordinate translation. | |
| 149 | 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 | 208 | return ErrAdapterDestroyed | |
| 152 | 209 | } | |
| 153 | 210 | return a.view.AttachInput(host, opts) | |
| @@ -157,7 +214,12 @@ func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.Inpu | |||
| 157 | 214 | // Must be called on the GTK main thread. Callers must call AttachInput first or | |
| 158 | 215 | // this returns an error from the underlying bridge. | |
| 159 | 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 | 223 | return ErrAdapterDestroyed | |
| 162 | 224 | } | |
| 163 | 225 | return a.view.SetInputHost(host) | |
| @@ -166,7 +228,12 @@ func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error { | |||
| 166 | 228 | // DetachInput removes GTK input controllers attached by AttachInput. | |
| 167 | 229 | // Must be called on the GTK main thread. | |
| 168 | 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 | 237 | return ErrAdapterDestroyed | |
| 171 | 238 | } | |
| 172 | 239 | return a.view.DetachInput() | |
| @@ -175,24 +242,27 @@ func (a *Cef2gtkAdapter) DetachInput() error { | |||
| 175 | 242 | // Diagnostics returns a point-in-time snapshot of bridge rendering diagnostics | |
| 176 | 243 | // including accelerated paint counts, import/render failures, and events. | |
| 177 | 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 | 251 | return cef2gtk.Diagnostics{} | |
| 180 | 252 | } | |
| 181 | 253 | return a.view.Diagnostics() | |
| 182 | 254 | } | |
| 183 | 255 | ||
| 184 | 256 | // 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. | |
| 190 | 259 | func (a *Cef2gtkAdapter) Destroy() error { | |
| 191 | 260 | if a == nil { | |
| 192 | 261 | return ErrAdapterDestroyed | |
| 193 | 262 | } | |
| 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 { | |
| 196 | 266 | return ErrAdapterDestroyed | |
| 197 | 267 | } | |
| 198 | 268 | 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 input | 25 | // 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.View | 29 | view *cef2gtk.View | ||
| 28 | destroyed atomic.Bool | 30 | destroyed atomic.Bool | ||
| 29 | destroyCnt atomic.Uint64 | 31 | 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 nil | 53 | 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 nil | 67 | 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 before | 82 | // 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, 1 | 91 | 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 1 | 96 | // 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 1 | 105 | 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 from | 111 | // 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 be | 125 | // 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 false | 134 | 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 called | 139 | // 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 | return | 148 | 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 ErrAdapterDestroyed | 162 | 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 nil | 179 | 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 ErrAdapterDestroyed | 192 | 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 ErrAdapterDestroyed | 208 | 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 or | 214 | // 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 ErrAdapterDestroyed | 223 | 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 ErrAdapterDestroyed | 237 | 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 diagnostics | 242 | // 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 GTK | 256 | // 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 | 257 | + | // signal handlers. Must be called on the GTK main thread. After Destroy, |
| 186 | - | // flag only communicates lifecycle state; accesses to view are not otherwise | 258 | + | // 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 ErrAdapterDestroyed | 261 | 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 ErrAdapterDestroyed | 266 | 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 { | |||
| 217 | 217 | 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 | 224 | const cefShutdownWaitTimeout = 10 * time.Second | |
| 221 | 225 | ||
| 222 | 226 | func (e *Engine) activeWebViewCount() int { | |
| @@ -217,6 +217,10 @@ func (e *Engine) Close() error { | |||||
| 217 | return nil | 217 | 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.Second | 224 | 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 | |||
| 238 | 238 | return | |
| 239 | 239 | } | |
| 240 | 240 | 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 | + | } | |
| 244 | 251 | } | |
| 245 | - | renderStallBacktraceLastUnixNS.Store(now.UnixNano()) | |
| 246 | 252 | pid := os.Getpid() | |
| 247 | 253 | if _, err := exec.LookPath("gdb"); err != nil { | |
| 248 | 254 | logging.FromContext(wv.ctx).Warn(). | |
| @@ -238,11 +238,17 @@ func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classificatio | |||||
| 238 | return | 238 | 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 | - | return | 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 | + | } | |||
| 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) { | |||
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | 44 | func (w *lockedProfileWriter) Close() error { | |
| 45 | - | if w == nil || w.file == nil { | |
| 45 | + | if w == nil { | |
| 46 | 46 | return nil | |
| 47 | 47 | } | |
| 48 | 48 | w.mu.Lock() | |
| 49 | 49 | defer w.mu.Unlock() | |
| 50 | + | if w.file == nil { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 50 | 53 | err := w.file.Close() | |
| 51 | 54 | w.file = nil | |
| 52 | 55 | return err | |
| @@ -159,7 +162,10 @@ func (e *Engine) cef2gtkProfilePath() string { | |||
| 159 | 162 | } | |
| 160 | 163 | metadata, ok := logging.SessionMetadataFromContext(e.ctx) | |
| 161 | 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 | 170 | logDir := e.profileLogDir | |
| 165 | 171 | 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 nil | 46 | 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 = nil | 54 | w.file = nil | ||
| 52 | return err | 55 | 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.profileLogDir | 170 | 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) { | |||
| 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 | 37 | func TestCEF2GTKProfilePathAllowsExplicitOutput(t *testing.T) { | |
| 27 | 38 | explicit := filepath.Join(t.TempDir(), "profile.jsonl") | |
| 28 | 39 | 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 | |||
| 132 | 132 | wv.renderStallMu.Unlock() | |
| 133 | 133 | return | |
| 134 | 134 | } | |
| 135 | - | wv.renderStallRecoveryLastAt = now | |
| 136 | 135 | wv.renderStallMu.Unlock() | |
| 137 | 136 | ||
| 138 | 137 | wv.mu.RLock() | |
| @@ -178,6 +177,9 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAda | |||
| 178 | 177 | if result != 1 { | |
| 179 | 178 | return | |
| 180 | 179 | } | |
| 180 | + | wv.renderStallMu.Lock() | |
| 181 | + | wv.renderStallRecoveryLastAt = now | |
| 182 | + | wv.renderStallMu.Unlock() | |
| 181 | 183 | cefScheduleAfter(2*time.Second, func() { | |
| 182 | 184 | if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil { | |
| 183 | 185 | return | |
| @@ -132,7 +132,6 @@ func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, bridge *Cef2gtkAda | |||||
| 132 | wv.renderStallMu.Unlock() | 132 | wv.renderStallMu.Unlock() | ||
| 133 | return | 133 | 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 | return | 178 | 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 | return | 185 | return | ||
M internal/infrastructure/cef/render_handler_adapter.go +8 -3
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 4 | 6 | purecef "github.com/bnema/purego-cef/cef" | |
| 5 | 7 | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 6 | 8 | ||
| @@ -11,11 +13,14 @@ func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | |||
| 11 | 13 | if wv == nil || wv.viewBridge == nil { | |
| 12 | 14 | return nil | |
| 13 | 15 | } | |
| 16 | + | var unsupportedPaintOnce sync.Once | |
| 14 | 17 | hooks := cef2gtk.Hooks{ | |
| 15 | 18 | 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 | + | }) | |
| 19 | 24 | }, | |
| 20 | 25 | OnError: func(err error) { | |
| 21 | 26 | if wv.ctx != nil { | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package cef | 1 | 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 nil | 14 | 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() { | |||
| 82 | 82 | return | |
| 83 | 83 | } | |
| 84 | 84 | 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 | + | } | |
| 86 | 92 | } | |
| 87 | 93 | ||
| 88 | 94 | func (wv *WebView) renderStallWatchdogLoop(bridge *Cef2gtkAdapter, stop <-chan struct{}, done chan<- struct{}) { | |
| @@ -82,7 +82,13 @@ func (wv *WebView) stopRenderStallWatchdog() { | |||||
| 82 | return | 82 | return | ||
| 83 | } | 83 | } | ||
| 84 | close(stop) | 84 | 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 | + | } | |||
| 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "errors" | |
| 6 | 6 | "strings" | |
| 7 | + | "sync" | |
| 7 | 8 | ||
| 8 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | 10 | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| @@ -23,7 +24,9 @@ const ( | |||
| 23 | 24 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 24 | 25 | // methods can return the same receiver, avoiding extra allocations. | |
| 25 | 26 | type handlerSet struct { | |
| 26 | - | wv *WebView | |
| 27 | + | wv *WebView | |
| 28 | + | renderHandlerOnce sync.Once | |
| 29 | + | renderHandler purecef.RenderHandler | |
| 27 | 30 | } | |
| 28 | 31 | ||
| 29 | 32 | // Compile-time interface checks. | |
| @@ -74,7 +77,10 @@ func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { | |||
| 74 | 77 | if h == nil { | |
| 75 | 78 | 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 | 85 | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |
| 80 | 86 | ||
| @@ -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*Handler | 24 | // 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 *WebView | 27 | + | 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 nil | 78 | 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 | |||
| 71 | 71 | echo "could not find a live dumber PID; pass --pid" >&2 | |
| 72 | 72 | exit 1 | |
| 73 | 73 | 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 | |
| 75 | 75 | echo "--samples and --interval-ms must be positive integers" >&2 | |
| 76 | 76 | exit 2 | |
| 77 | 77 | fi | |
| @@ -71,7 +71,7 @@ if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then | |||||
| 71 | echo "could not find a live dumber PID; pass --pid" >&2 | 71 | echo "could not find a live dumber PID; pass --pid" >&2 | ||
| 72 | exit 1 | 72 | exit 1 | ||
| 73 | fi | 73 | 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 |
| 75 | echo "--samples and --interval-ms must be positive integers" >&2 | 75 | echo "--samples and --interval-ms must be positive integers" >&2 | ||
| 76 | exit 2 | 76 | exit 2 | ||
| 77 | fi | 77 | 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();⏎̸ | |||