detached 5ef919d

feat(webkit): add WebKit adapter with webview, settings, context, pool, and scheme handler

commit
5ef919d24f8e4f2c253b3b68b3a731a6c7af3f9b
author
bnema · · 2025-12-09 22:18 UTC
parent
996f88b
signature
unsigned
5 files · +1133 -0
A internal/infrastructure/webkit/context.go +175 -0
@@ -0,0 +1,175 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk-webkit/webkit"
11+ "github.com/rs/zerolog"
12+)
13+
14+// WebKitContext manages the shared WebContext and persistent NetworkSession.
15+// IMPORTANT: This MUST be initialized before creating any WebViews.
16+type WebKitContext struct {
17+ webContext *webkit.WebContext
18+ networkSession *webkit.NetworkSession
19+
20+ dataDir string
21+ cacheDir string
22+
23+ logger zerolog.Logger
24+ mu sync.RWMutex
25+ initialized bool
26+}
27+
28+// NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession.
29+// The dataDir and cacheDir are used for cookie storage, cache, and other persistent data.
30+// This MUST be called before creating any WebViews to ensure they use persistent storage.
31+func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {
32+ log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger()
33+
34+ if dataDir == "" {
35+ return nil, fmt.Errorf("data directory cannot be empty")
36+ }
37+ if cacheDir == "" {
38+ return nil, fmt.Errorf("cache directory cannot be empty")
39+ }
40+
41+ wkCtx := &WebKitContext{
42+ dataDir: dataDir,
43+ cacheDir: cacheDir,
44+ logger: log,
45+ }
46+
47+ // Create persistent network session FIRST
48+ // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"
49+ if err := wkCtx.initNetworkSession(); err != nil {
50+ return nil, fmt.Errorf("failed to init network session: %w", err)
51+ }
52+
53+ // Get or create WebContext
54+ wkCtx.webContext = webkit.WebContextGetDefault()
55+ if wkCtx.webContext == nil {
56+ wkCtx.webContext = webkit.NewWebContext()
57+ }
58+ if wkCtx.webContext == nil {
59+ return nil, fmt.Errorf("failed to get or create WebContext")
60+ }
61+
62+ // Set cache model for browser-style caching
63+ wkCtx.webContext.SetCacheModel(webkit.CacheModelWebBrowserValue)
64+
65+ wkCtx.initialized = true
66+ log.Info().
67+ Str("data_dir", dataDir).
68+ Str("cache_dir", cacheDir).
69+ Msg("webkit context initialized")
70+
71+ return wkCtx, nil
72+}
73+
74+// initNetworkSession creates and configures the persistent network session.
75+func (c *WebKitContext) initNetworkSession() error {
76+ // Create persistent network session
77+ session := webkit.NewNetworkSession(c.dataDir, c.cacheDir)
78+ if session == nil {
79+ return fmt.Errorf("failed to create network session")
80+ }
81+
82+ // Verify session is not ephemeral
83+ if session.IsEphemeral() {
84+ return fmt.Errorf("network session is ephemeral despite providing data directories")
85+ }
86+
87+ // Verify WebsiteDataManager is not ephemeral
88+ dataManager := session.GetWebsiteDataManager()
89+ if dataManager == nil {
90+ return fmt.Errorf("failed to get website data manager")
91+ }
92+ if dataManager.IsEphemeral() {
93+ return fmt.Errorf("website data manager is ephemeral")
94+ }
95+
96+ // Configure cookie storage
97+ cookieManager := session.GetCookieManager()
98+ if cookieManager == nil {
99+ return fmt.Errorf("failed to get cookie manager")
100+ }
101+
102+ cookiePath := filepath.Join(c.dataDir, "cookies.db")
103+ cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue)
104+ cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdPartyValue)
105+
106+ c.logger.Debug().
107+ Str("cookie_path", cookiePath).
108+ Msg("cookie storage configured")
109+
110+ // Enable persistent credential storage
111+ session.SetPersistentCredentialStorageEnabled(true)
112+
113+ // Configure TLS errors to emit signals (enables load-failed-with-tls-errors)
114+ session.SetTlsErrorsPolicy(webkit.TlsErrorsPolicyFailValue)
115+
116+ // Verify default session
117+ defaultSession := webkit.NetworkSessionGetDefault()
118+ if defaultSession == nil || defaultSession.IsEphemeral() {
119+ c.logger.Warn().Msg("default session may not be persistent - created session might not be used by default")
120+ }
121+
122+ c.networkSession = session
123+ c.logger.Debug().Msg("network session initialized as persistent")
124+
125+ return nil
126+}
127+
128+// Context returns the shared WebContext.
129+func (c *WebKitContext) Context() *webkit.WebContext {
130+ c.mu.RLock()
131+ defer c.mu.RUnlock()
132+ return c.webContext
133+}
134+
135+// NetworkSession returns the persistent NetworkSession.
136+func (c *WebKitContext) NetworkSession() *webkit.NetworkSession {
137+ c.mu.RLock()
138+ defer c.mu.RUnlock()
139+ return c.networkSession
140+}
141+
142+// DataDir returns the data directory path.
143+func (c *WebKitContext) DataDir() string {
144+ return c.dataDir
145+}
146+
147+// CacheDir returns the cache directory path.
148+func (c *WebKitContext) CacheDir() string {
149+ return c.cacheDir
150+}
151+
152+// IsInitialized returns true if the context has been successfully initialized.
153+func (c *WebKitContext) IsInitialized() bool {
154+ c.mu.RLock()
155+ defer c.mu.RUnlock()
156+ return c.initialized
157+}
158+
159+// PrefetchDNS prefetches DNS for the given hostname to speed up future requests.
160+func (c *WebKitContext) PrefetchDNS(hostname string) {
161+ if hostname == "" || c.networkSession == nil {
162+ return
163+ }
164+ c.networkSession.PrefetchDns(hostname)
165+}
166+
167+// Close performs cleanup. Currently a no-op as WebKit handles cleanup internally.
168+func (c *WebKitContext) Close() error {
169+ c.mu.Lock()
170+ defer c.mu.Unlock()
171+
172+ c.initialized = false
173+ c.logger.Debug().Msg("webkit context closed")
174+ return nil
175+}
@@ -0,0 +1,175 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/puregotk-webkit/webkit"
11+ "github.com/rs/zerolog"
12+)
13+
14+// WebKitContext manages the shared WebContext and persistent NetworkSession.
15+// IMPORTANT: This MUST be initialized before creating any WebViews.
16+type WebKitContext struct {
17+ webContext *webkit.WebContext
18+ networkSession *webkit.NetworkSession
19+
20+ dataDir string
21+ cacheDir string
22+
23+ logger zerolog.Logger
24+ mu sync.RWMutex
25+ initialized bool
26+}
27+
28+// NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession.
29+// The dataDir and cacheDir are used for cookie storage, cache, and other persistent data.
30+// This MUST be called before creating any WebViews to ensure they use persistent storage.
31+func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {
32+ log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger()
33+
34+ if dataDir == "" {
35+ return nil, fmt.Errorf("data directory cannot be empty")
36+ }
37+ if cacheDir == "" {
38+ return nil, fmt.Errorf("cache directory cannot be empty")
39+ }
40+
41+ wkCtx := &WebKitContext{
42+ dataDir: dataDir,
43+ cacheDir: cacheDir,
44+ logger: log,
45+ }
46+
47+ // Create persistent network session FIRST
48+ // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"
49+ if err := wkCtx.initNetworkSession(); err != nil {
50+ return nil, fmt.Errorf("failed to init network session: %w", err)
51+ }
52+
53+ // Get or create WebContext
54+ wkCtx.webContext = webkit.WebContextGetDefault()
55+ if wkCtx.webContext == nil {
56+ wkCtx.webContext = webkit.NewWebContext()
57+ }
58+ if wkCtx.webContext == nil {
59+ return nil, fmt.Errorf("failed to get or create WebContext")
60+ }
61+
62+ // Set cache model for browser-style caching
63+ wkCtx.webContext.SetCacheModel(webkit.CacheModelWebBrowserValue)
64+
65+ wkCtx.initialized = true
66+ log.Info().
67+ Str("data_dir", dataDir).
68+ Str("cache_dir", cacheDir).
69+ Msg("webkit context initialized")
70+
71+ return wkCtx, nil
72+}
73+
74+// initNetworkSession creates and configures the persistent network session.
75+func (c *WebKitContext) initNetworkSession() error {
76+ // Create persistent network session
77+ session := webkit.NewNetworkSession(c.dataDir, c.cacheDir)
78+ if session == nil {
79+ return fmt.Errorf("failed to create network session")
80+ }
81+
82+ // Verify session is not ephemeral
83+ if session.IsEphemeral() {
84+ return fmt.Errorf("network session is ephemeral despite providing data directories")
85+ }
86+
87+ // Verify WebsiteDataManager is not ephemeral
88+ dataManager := session.GetWebsiteDataManager()
89+ if dataManager == nil {
90+ return fmt.Errorf("failed to get website data manager")
91+ }
92+ if dataManager.IsEphemeral() {
93+ return fmt.Errorf("website data manager is ephemeral")
94+ }
95+
96+ // Configure cookie storage
97+ cookieManager := session.GetCookieManager()
98+ if cookieManager == nil {
99+ return fmt.Errorf("failed to get cookie manager")
100+ }
101+
102+ cookiePath := filepath.Join(c.dataDir, "cookies.db")
103+ cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqliteValue)
104+ cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdPartyValue)
105+
106+ c.logger.Debug().
107+ Str("cookie_path", cookiePath).
108+ Msg("cookie storage configured")
109+
110+ // Enable persistent credential storage
111+ session.SetPersistentCredentialStorageEnabled(true)
112+
113+ // Configure TLS errors to emit signals (enables load-failed-with-tls-errors)
114+ session.SetTlsErrorsPolicy(webkit.TlsErrorsPolicyFailValue)
115+
116+ // Verify default session
117+ defaultSession := webkit.NetworkSessionGetDefault()
118+ if defaultSession == nil || defaultSession.IsEphemeral() {
119+ c.logger.Warn().Msg("default session may not be persistent - created session might not be used by default")
120+ }
121+
122+ c.networkSession = session
123+ c.logger.Debug().Msg("network session initialized as persistent")
124+
125+ return nil
126+}
127+
128+// Context returns the shared WebContext.
129+func (c *WebKitContext) Context() *webkit.WebContext {
130+ c.mu.RLock()
131+ defer c.mu.RUnlock()
132+ return c.webContext
133+}
134+
135+// NetworkSession returns the persistent NetworkSession.
136+func (c *WebKitContext) NetworkSession() *webkit.NetworkSession {
137+ c.mu.RLock()
138+ defer c.mu.RUnlock()
139+ return c.networkSession
140+}
141+
142+// DataDir returns the data directory path.
143+func (c *WebKitContext) DataDir() string {
144+ return c.dataDir
145+}
146+
147+// CacheDir returns the cache directory path.
148+func (c *WebKitContext) CacheDir() string {
149+ return c.cacheDir
150+}
151+
152+// IsInitialized returns true if the context has been successfully initialized.
153+func (c *WebKitContext) IsInitialized() bool {
154+ c.mu.RLock()
155+ defer c.mu.RUnlock()
156+ return c.initialized
157+}
158+
159+// PrefetchDNS prefetches DNS for the given hostname to speed up future requests.
160+func (c *WebKitContext) PrefetchDNS(hostname string) {
161+ if hostname == "" || c.networkSession == nil {
162+ return
163+ }
164+ c.networkSession.PrefetchDns(hostname)
165+}
166+
167+// Close performs cleanup. Currently a no-op as WebKit handles cleanup internally.
168+func (c *WebKitContext) Close() error {
169+ c.mu.Lock()
170+ defer c.mu.Unlock()
171+
172+ c.initialized = false
173+ c.logger.Debug().Msg("webkit context closed")
174+ return nil
175+}
A internal/infrastructure/webkit/pool.go +192 -0
@@ -0,0 +1,192 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/rs/zerolog"
10+)
11+
12+// PoolConfig configures the WebView pool behavior.
13+type PoolConfig struct {
14+ // MinSize is the minimum number of warm WebViews to maintain.
15+ MinSize int
16+ // MaxSize is the maximum number of WebViews to keep in the pool.
17+ MaxSize int
18+ // PrewarmCount is the number of WebViews to pre-create on startup.
19+ PrewarmCount int
20+ // IdleTimeout is how long idle views stay in the pool before being destroyed.
21+ IdleTimeout time.Duration
22+}
23+
24+// DefaultPoolConfig returns sensible defaults for the pool.
25+func DefaultPoolConfig() PoolConfig {
26+ return PoolConfig{
27+ MinSize: 2,
28+ MaxSize: 8,
29+ PrewarmCount: 2,
30+ IdleTimeout: 5 * time.Minute,
31+ }
32+}
33+
34+// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
35+type WebViewPool struct {
36+ wkCtx *WebKitContext
37+ settings *SettingsManager
38+ config PoolConfig
39+
40+ pool chan *WebView
41+ logger zerolog.Logger
42+
43+ closed atomic.Bool
44+ wg sync.WaitGroup
45+}
46+
47+// NewWebViewPool creates a new WebView pool.
48+func NewWebViewPool(wkCtx *WebKitContext, settings *SettingsManager, cfg PoolConfig, logger zerolog.Logger) *WebViewPool {
49+ if cfg.MaxSize <= 0 {
50+ cfg.MaxSize = 8
51+ }
52+ if cfg.MinSize <= 0 {
53+ cfg.MinSize = 2
54+ }
55+ if cfg.MinSize > cfg.MaxSize {
56+ cfg.MinSize = cfg.MaxSize
57+ }
58+
59+ p := &WebViewPool{
60+ wkCtx: wkCtx,
61+ settings: settings,
62+ config: cfg,
63+ pool: make(chan *WebView, cfg.MaxSize),
64+ logger: logger.With().Str("component", "webview-pool").Logger(),
65+ }
66+
67+ return p
68+}
69+
70+// Acquire gets a WebView from the pool or creates a new one.
71+func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
72+ if p.closed.Load() {
73+ return nil, context.Canceled
74+ }
75+
76+ // Try to get from pool first (non-blocking)
77+ select {
78+ case wv := <-p.pool:
79+ if wv != nil && !wv.IsDestroyed() {
80+ p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
81+ return wv, nil
82+ }
83+ // Fall through to create new if the pooled one was destroyed
84+ default:
85+ // Pool empty
86+ }
87+
88+ // Pool empty or pooled view was destroyed, create new
89+ p.logger.Debug().Msg("pool empty, creating new webview")
90+ return NewWebView(p.wkCtx, p.settings, p.logger)
91+}
92+
93+// Release returns a WebView to the pool.
94+// The WebView is reset to about:blank before being pooled.
95+func (p *WebViewPool) Release(wv *WebView) {
96+ if wv == nil || wv.IsDestroyed() || p.closed.Load() {
97+ if wv != nil && !wv.IsDestroyed() {
98+ wv.Destroy()
99+ }
100+ return
101+ }
102+
103+ // Reset to blank state
104+ _ = wv.LoadURI("about:blank")
105+
106+ // Clear callbacks
107+ wv.OnLoadChanged = nil
108+ wv.OnTitleChanged = nil
109+ wv.OnURIChanged = nil
110+ wv.OnProgressChanged = nil
111+ wv.OnClose = nil
112+
113+ // Try to return to pool (non-blocking)
114+ select {
115+ case p.pool <- wv:
116+ p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("returned webview to pool")
117+ default:
118+ // Pool full, destroy the view
119+ p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("pool full, destroying webview")
120+ wv.Destroy()
121+ }
122+}
123+
124+// Prewarm creates WebViews in the background to populate the pool.
125+func (p *WebViewPool) Prewarm(count int) {
126+ if count <= 0 {
127+ count = p.config.PrewarmCount
128+ }
129+ if count <= 0 {
130+ return
131+ }
132+
133+ p.wg.Add(1)
134+ go func() {
135+ defer p.wg.Done()
136+ p.prewarmSync(count)
137+ }()
138+}
139+
140+// prewarmSync creates WebViews synchronously.
141+func (p *WebViewPool) prewarmSync(count int) {
142+ for i := 0; i < count && !p.closed.Load(); i++ {
143+ // Check if pool is already at capacity
144+ if len(p.pool) >= p.config.MaxSize {
145+ break
146+ }
147+
148+ wv, err := NewWebView(p.wkCtx, p.settings, p.logger)
149+ if err != nil {
150+ p.logger.Warn().Err(err).Msg("failed to prewarm webview")
151+ continue
152+ }
153+
154+ // Try to add to pool
155+ select {
156+ case p.pool <- wv:
157+ p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
158+ default:
159+ // Pool full, destroy
160+ wv.Destroy()
161+ break
162+ }
163+
164+ // Small delay between creations to avoid overwhelming the system
165+ time.Sleep(50 * time.Millisecond)
166+ }
167+}
168+
169+// Size returns the current number of WebViews in the pool.
170+func (p *WebViewPool) Size() int {
171+ return len(p.pool)
172+}
173+
174+// Close shuts down the pool and destroys all pooled WebViews.
175+func (p *WebViewPool) Close() {
176+ if p.closed.Swap(true) {
177+ return // Already closed
178+ }
179+
180+ // Wait for any background operations
181+ p.wg.Wait()
182+
183+ // Drain and destroy all pooled views
184+ close(p.pool)
185+ for wv := range p.pool {
186+ if wv != nil && !wv.IsDestroyed() {
187+ wv.Destroy()
188+ }
189+ }
190+
191+ p.logger.Debug().Msg("webview pool closed")
192+}
@@ -0,0 +1,192 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "sync"
6+ "sync/atomic"
7+ "time"
8+
9+ "github.com/rs/zerolog"
10+)
11+
12+// PoolConfig configures the WebView pool behavior.
13+type PoolConfig struct {
14+ // MinSize is the minimum number of warm WebViews to maintain.
15+ MinSize int
16+ // MaxSize is the maximum number of WebViews to keep in the pool.
17+ MaxSize int
18+ // PrewarmCount is the number of WebViews to pre-create on startup.
19+ PrewarmCount int
20+ // IdleTimeout is how long idle views stay in the pool before being destroyed.
21+ IdleTimeout time.Duration
22+}
23+
24+// DefaultPoolConfig returns sensible defaults for the pool.
25+func DefaultPoolConfig() PoolConfig {
26+ return PoolConfig{
27+ MinSize: 2,
28+ MaxSize: 8,
29+ PrewarmCount: 2,
30+ IdleTimeout: 5 * time.Minute,
31+ }
32+}
33+
34+// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
35+type WebViewPool struct {
36+ wkCtx *WebKitContext
37+ settings *SettingsManager
38+ config PoolConfig
39+
40+ pool chan *WebView
41+ logger zerolog.Logger
42+
43+ closed atomic.Bool
44+ wg sync.WaitGroup
45+}
46+
47+// NewWebViewPool creates a new WebView pool.
48+func NewWebViewPool(wkCtx *WebKitContext, settings *SettingsManager, cfg PoolConfig, logger zerolog.Logger) *WebViewPool {
49+ if cfg.MaxSize <= 0 {
50+ cfg.MaxSize = 8
51+ }
52+ if cfg.MinSize <= 0 {
53+ cfg.MinSize = 2
54+ }
55+ if cfg.MinSize > cfg.MaxSize {
56+ cfg.MinSize = cfg.MaxSize
57+ }
58+
59+ p := &WebViewPool{
60+ wkCtx: wkCtx,
61+ settings: settings,
62+ config: cfg,
63+ pool: make(chan *WebView, cfg.MaxSize),
64+ logger: logger.With().Str("component", "webview-pool").Logger(),
65+ }
66+
67+ return p
68+}
69+
70+// Acquire gets a WebView from the pool or creates a new one.
71+func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
72+ if p.closed.Load() {
73+ return nil, context.Canceled
74+ }
75+
76+ // Try to get from pool first (non-blocking)
77+ select {
78+ case wv := <-p.pool:
79+ if wv != nil && !wv.IsDestroyed() {
80+ p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
81+ return wv, nil
82+ }
83+ // Fall through to create new if the pooled one was destroyed
84+ default:
85+ // Pool empty
86+ }
87+
88+ // Pool empty or pooled view was destroyed, create new
89+ p.logger.Debug().Msg("pool empty, creating new webview")
90+ return NewWebView(p.wkCtx, p.settings, p.logger)
91+}
92+
93+// Release returns a WebView to the pool.
94+// The WebView is reset to about:blank before being pooled.
95+func (p *WebViewPool) Release(wv *WebView) {
96+ if wv == nil || wv.IsDestroyed() || p.closed.Load() {
97+ if wv != nil && !wv.IsDestroyed() {
98+ wv.Destroy()
99+ }
100+ return
101+ }
102+
103+ // Reset to blank state
104+ _ = wv.LoadURI("about:blank")
105+
106+ // Clear callbacks
107+ wv.OnLoadChanged = nil
108+ wv.OnTitleChanged = nil
109+ wv.OnURIChanged = nil
110+ wv.OnProgressChanged = nil
111+ wv.OnClose = nil
112+
113+ // Try to return to pool (non-blocking)
114+ select {
115+ case p.pool <- wv:
116+ p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("returned webview to pool")
117+ default:
118+ // Pool full, destroy the view
119+ p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("pool full, destroying webview")
120+ wv.Destroy()
121+ }
122+}
123+
124+// Prewarm creates WebViews in the background to populate the pool.
125+func (p *WebViewPool) Prewarm(count int) {
126+ if count <= 0 {
127+ count = p.config.PrewarmCount
128+ }
129+ if count <= 0 {
130+ return
131+ }
132+
133+ p.wg.Add(1)
134+ go func() {
135+ defer p.wg.Done()
136+ p.prewarmSync(count)
137+ }()
138+}
139+
140+// prewarmSync creates WebViews synchronously.
141+func (p *WebViewPool) prewarmSync(count int) {
142+ for i := 0; i < count && !p.closed.Load(); i++ {
143+ // Check if pool is already at capacity
144+ if len(p.pool) >= p.config.MaxSize {
145+ break
146+ }
147+
148+ wv, err := NewWebView(p.wkCtx, p.settings, p.logger)
149+ if err != nil {
150+ p.logger.Warn().Err(err).Msg("failed to prewarm webview")
151+ continue
152+ }
153+
154+ // Try to add to pool
155+ select {
156+ case p.pool <- wv:
157+ p.logger.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
158+ default:
159+ // Pool full, destroy
160+ wv.Destroy()
161+ break
162+ }
163+
164+ // Small delay between creations to avoid overwhelming the system
165+ time.Sleep(50 * time.Millisecond)
166+ }
167+}
168+
169+// Size returns the current number of WebViews in the pool.
170+func (p *WebViewPool) Size() int {
171+ return len(p.pool)
172+}
173+
174+// Close shuts down the pool and destroys all pooled WebViews.
175+func (p *WebViewPool) Close() {
176+ if p.closed.Swap(true) {
177+ return // Already closed
178+ }
179+
180+ // Wait for any background operations
181+ p.wg.Wait()
182+
183+ // Drain and destroy all pooled views
184+ close(p.pool)
185+ for wv := range p.pool {
186+ if wv != nil && !wv.IsDestroyed() {
187+ wv.Destroy()
188+ }
189+ }
190+
191+ p.logger.Debug().Msg("webview pool closed")
192+}
A internal/infrastructure/webkit/scheme_handler.go +295 -0
@@ -0,0 +1,295 @@
1+package webkit
2+
3+import (
4+ "net/http"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/puregotk-webkit/webkit"
9+ "github.com/jwijenbergh/puregotk/v4/gio"
10+ "github.com/jwijenbergh/puregotk/v4/glib"
11+ "github.com/rs/zerolog"
12+)
13+
14+// SchemeRequest represents a request to a custom URI scheme.
15+type SchemeRequest struct {
16+ inner *webkit.URISchemeRequest
17+ URI string
18+ Path string
19+ Method string
20+ Scheme string
21+}
22+
23+// SchemeResponse represents a response to a scheme request.
24+type SchemeResponse struct {
25+ Data []byte
26+ ContentType string
27+ StatusCode int
28+}
29+
30+// PageHandler generates content for a specific page path.
31+type PageHandler interface {
32+ Handle(req *SchemeRequest) *SchemeResponse
33+}
34+
35+// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
36+type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
37+
38+func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
39+ return f(req)
40+}
41+
42+// DumbSchemeHandler handles dumb:// URI scheme requests.
43+type DumbSchemeHandler struct {
44+ handlers map[string]PageHandler
45+ logger zerolog.Logger
46+ mu sync.RWMutex
47+}
48+
49+// NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
50+func NewDumbSchemeHandler(logger zerolog.Logger) *DumbSchemeHandler {
51+ h := &DumbSchemeHandler{
52+ handlers: make(map[string]PageHandler),
53+ logger: logger.With().Str("component", "scheme-handler").Logger(),
54+ }
55+
56+ // Register default pages
57+ h.registerDefaults()
58+
59+ return h
60+}
61+
62+// registerDefaults sets up default page handlers.
63+func (h *DumbSchemeHandler) registerDefaults() {
64+ // New tab page
65+ h.RegisterPage("/newtab", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
66+ return &SchemeResponse{
67+ Data: []byte(newTabHTML),
68+ ContentType: "text/html",
69+ StatusCode: http.StatusOK,
70+ }
71+ }))
72+
73+ // Homepage (alias for newtab)
74+ h.RegisterPage("/", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
75+ return &SchemeResponse{
76+ Data: []byte(newTabHTML),
77+ ContentType: "text/html",
78+ StatusCode: http.StatusOK,
79+ }
80+ }))
81+
82+ // Error page
83+ h.RegisterPage("/error", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
84+ return &SchemeResponse{
85+ Data: []byte(errorPageHTML),
86+ ContentType: "text/html",
87+ StatusCode: http.StatusOK,
88+ }
89+ }))
90+}
91+
92+// RegisterPage registers a handler for a specific path.
93+func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
94+ h.mu.Lock()
95+ defer h.mu.Unlock()
96+ h.handlers[path] = handler
97+ h.logger.Debug().Str("path", path).Msg("registered page handler")
98+}
99+
100+// HandleRequest processes a scheme request and sends the response.
101+func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
102+ req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
103+ if req == nil {
104+ return
105+ }
106+
107+ schemeReq := &SchemeRequest{
108+ inner: req,
109+ URI: req.GetUri(),
110+ Path: req.GetPath(),
111+ Method: req.GetHttpMethod(),
112+ Scheme: req.GetScheme(),
113+ }
114+
115+ h.logger.Debug().
116+ Str("uri", schemeReq.URI).
117+ Str("path", schemeReq.Path).
118+ Str("method", schemeReq.Method).
119+ Msg("handling scheme request")
120+
121+ // Look up handler
122+ h.mu.RLock()
123+ handler, ok := h.handlers[schemeReq.Path]
124+ if !ok {
125+ // Try without leading slash
126+ handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
127+ }
128+ h.mu.RUnlock()
129+
130+ var response *SchemeResponse
131+ if ok {
132+ response = handler.Handle(schemeReq)
133+ } else {
134+ response = &SchemeResponse{
135+ Data: []byte(notFoundHTML),
136+ ContentType: "text/html",
137+ StatusCode: http.StatusNotFound,
138+ }
139+ }
140+
141+ // Send response
142+ h.sendResponse(req, response)
143+}
144+
145+// sendResponse sends the response back to WebKit.
146+func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
147+ if response == nil {
148+ response = &SchemeResponse{
149+ Data: []byte("Internal error"),
150+ ContentType: "text/plain",
151+ StatusCode: http.StatusInternalServerError,
152+ }
153+ }
154+
155+ // Create GBytes from response data
156+ gbytes := glib.NewBytes(response.Data, uint(len(response.Data)))
157+ if gbytes == nil {
158+ h.logger.Error().Msg("failed to create GBytes for response")
159+ return
160+ }
161+
162+ // Create MemoryInputStream from GBytes
163+ stream := gio.NewMemoryInputStreamFromBytes(gbytes)
164+ if stream == nil {
165+ h.logger.Error().Msg("failed to create MemoryInputStream for response")
166+ return
167+ }
168+
169+ // Finish the request with the stream
170+ contentType := response.ContentType
171+ if contentType == "" {
172+ contentType = "text/html"
173+ }
174+
175+ req.Finish(&stream.InputStream, int64(len(response.Data)), contentType)
176+}
177+
178+// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
179+func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
180+ if wkCtx == nil || wkCtx.Context() == nil {
181+ h.logger.Error().Msg("cannot register scheme: context is nil")
182+ return
183+ }
184+
185+ callback := webkit.URISchemeRequestCallback(func(reqPtr, userData uintptr) {
186+ h.HandleRequest(reqPtr)
187+ })
188+
189+ wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
190+
191+ // Mark scheme as local and secure for proper security policies
192+ secMgr := wkCtx.Context().GetSecurityManager()
193+ if secMgr != nil {
194+ secMgr.RegisterUriSchemeAsLocal("dumb")
195+ secMgr.RegisterUriSchemeAsSecure("dumb")
196+ }
197+
198+ h.logger.Info().Msg("dumb:// scheme registered")
199+}
200+
201+// Default page templates
202+
203+const newTabHTML = `<!DOCTYPE html>
204+<html>
205+<head>
206+ <meta charset="utf-8">
207+ <title>New Tab</title>
208+ <style>
209+ body {
210+ font-family: system-ui, -apple-system, sans-serif;
211+ background: #1a1a2e;
212+ color: #eee;
213+ display: flex;
214+ align-items: center;
215+ justify-content: center;
216+ height: 100vh;
217+ margin: 0;
218+ }
219+ .container {
220+ text-align: center;
221+ }
222+ h1 {
223+ font-weight: 300;
224+ color: #888;
225+ }
226+ </style>
227+</head>
228+<body>
229+ <div class="container">
230+ <h1>dumber</h1>
231+ </div>
232+</body>
233+</html>`
234+
235+const errorPageHTML = `<!DOCTYPE html>
236+<html>
237+<head>
238+ <meta charset="utf-8">
239+ <title>Error</title>
240+ <style>
241+ body {
242+ font-family: system-ui, -apple-system, sans-serif;
243+ background: #1a1a2e;
244+ color: #eee;
245+ display: flex;
246+ align-items: center;
247+ justify-content: center;
248+ height: 100vh;
249+ margin: 0;
250+ }
251+ .container {
252+ text-align: center;
253+ }
254+ h1 { color: #e74c3c; }
255+ p { color: #888; }
256+ </style>
257+</head>
258+<body>
259+ <div class="container">
260+ <h1>Error</h1>
261+ <p>The page could not be loaded.</p>
262+ </div>
263+</body>
264+</html>`
265+
266+const notFoundHTML = `<!DOCTYPE html>
267+<html>
268+<head>
269+ <meta charset="utf-8">
270+ <title>Not Found</title>
271+ <style>
272+ body {
273+ font-family: system-ui, -apple-system, sans-serif;
274+ background: #1a1a2e;
275+ color: #eee;
276+ display: flex;
277+ align-items: center;
278+ justify-content: center;
279+ height: 100vh;
280+ margin: 0;
281+ }
282+ .container {
283+ text-align: center;
284+ }
285+ h1 { color: #f39c12; }
286+ p { color: #888; }
287+ </style>
288+</head>
289+<body>
290+ <div class="container">
291+ <h1>404</h1>
292+ <p>Page not found.</p>
293+ </div>
294+</body>
295+</html>`
@@ -0,0 +1,295 @@
1+package webkit
2+
3+import (
4+ "net/http"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/puregotk-webkit/webkit"
9+ "github.com/jwijenbergh/puregotk/v4/gio"
10+ "github.com/jwijenbergh/puregotk/v4/glib"
11+ "github.com/rs/zerolog"
12+)
13+
14+// SchemeRequest represents a request to a custom URI scheme.
15+type SchemeRequest struct {
16+ inner *webkit.URISchemeRequest
17+ URI string
18+ Path string
19+ Method string
20+ Scheme string
21+}
22+
23+// SchemeResponse represents a response to a scheme request.
24+type SchemeResponse struct {
25+ Data []byte
26+ ContentType string
27+ StatusCode int
28+}
29+
30+// PageHandler generates content for a specific page path.
31+type PageHandler interface {
32+ Handle(req *SchemeRequest) *SchemeResponse
33+}
34+
35+// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
36+type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
37+
38+func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
39+ return f(req)
40+}
41+
42+// DumbSchemeHandler handles dumb:// URI scheme requests.
43+type DumbSchemeHandler struct {
44+ handlers map[string]PageHandler
45+ logger zerolog.Logger
46+ mu sync.RWMutex
47+}
48+
49+// NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
50+func NewDumbSchemeHandler(logger zerolog.Logger) *DumbSchemeHandler {
51+ h := &DumbSchemeHandler{
52+ handlers: make(map[string]PageHandler),
53+ logger: logger.With().Str("component", "scheme-handler").Logger(),
54+ }
55+
56+ // Register default pages
57+ h.registerDefaults()
58+
59+ return h
60+}
61+
62+// registerDefaults sets up default page handlers.
63+func (h *DumbSchemeHandler) registerDefaults() {
64+ // New tab page
65+ h.RegisterPage("/newtab", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
66+ return &SchemeResponse{
67+ Data: []byte(newTabHTML),
68+ ContentType: "text/html",
69+ StatusCode: http.StatusOK,
70+ }
71+ }))
72+
73+ // Homepage (alias for newtab)
74+ h.RegisterPage("/", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
75+ return &SchemeResponse{
76+ Data: []byte(newTabHTML),
77+ ContentType: "text/html",
78+ StatusCode: http.StatusOK,
79+ }
80+ }))
81+
82+ // Error page
83+ h.RegisterPage("/error", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
84+ return &SchemeResponse{
85+ Data: []byte(errorPageHTML),
86+ ContentType: "text/html",
87+ StatusCode: http.StatusOK,
88+ }
89+ }))
90+}
91+
92+// RegisterPage registers a handler for a specific path.
93+func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
94+ h.mu.Lock()
95+ defer h.mu.Unlock()
96+ h.handlers[path] = handler
97+ h.logger.Debug().Str("path", path).Msg("registered page handler")
98+}
99+
100+// HandleRequest processes a scheme request and sends the response.
101+func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
102+ req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
103+ if req == nil {
104+ return
105+ }
106+
107+ schemeReq := &SchemeRequest{
108+ inner: req,
109+ URI: req.GetUri(),
110+ Path: req.GetPath(),
111+ Method: req.GetHttpMethod(),
112+ Scheme: req.GetScheme(),
113+ }
114+
115+ h.logger.Debug().
116+ Str("uri", schemeReq.URI).
117+ Str("path", schemeReq.Path).
118+ Str("method", schemeReq.Method).
119+ Msg("handling scheme request")
120+
121+ // Look up handler
122+ h.mu.RLock()
123+ handler, ok := h.handlers[schemeReq.Path]
124+ if !ok {
125+ // Try without leading slash
126+ handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
127+ }
128+ h.mu.RUnlock()
129+
130+ var response *SchemeResponse
131+ if ok {
132+ response = handler.Handle(schemeReq)
133+ } else {
134+ response = &SchemeResponse{
135+ Data: []byte(notFoundHTML),
136+ ContentType: "text/html",
137+ StatusCode: http.StatusNotFound,
138+ }
139+ }
140+
141+ // Send response
142+ h.sendResponse(req, response)
143+}
144+
145+// sendResponse sends the response back to WebKit.
146+func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
147+ if response == nil {
148+ response = &SchemeResponse{
149+ Data: []byte("Internal error"),
150+ ContentType: "text/plain",
151+ StatusCode: http.StatusInternalServerError,
152+ }
153+ }
154+
155+ // Create GBytes from response data
156+ gbytes := glib.NewBytes(response.Data, uint(len(response.Data)))
157+ if gbytes == nil {
158+ h.logger.Error().Msg("failed to create GBytes for response")
159+ return
160+ }
161+
162+ // Create MemoryInputStream from GBytes
163+ stream := gio.NewMemoryInputStreamFromBytes(gbytes)
164+ if stream == nil {
165+ h.logger.Error().Msg("failed to create MemoryInputStream for response")
166+ return
167+ }
168+
169+ // Finish the request with the stream
170+ contentType := response.ContentType
171+ if contentType == "" {
172+ contentType = "text/html"
173+ }
174+
175+ req.Finish(&stream.InputStream, int64(len(response.Data)), contentType)
176+}
177+
178+// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
179+func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
180+ if wkCtx == nil || wkCtx.Context() == nil {
181+ h.logger.Error().Msg("cannot register scheme: context is nil")
182+ return
183+ }
184+
185+ callback := webkit.URISchemeRequestCallback(func(reqPtr, userData uintptr) {
186+ h.HandleRequest(reqPtr)
187+ })
188+
189+ wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
190+
191+ // Mark scheme as local and secure for proper security policies
192+ secMgr := wkCtx.Context().GetSecurityManager()
193+ if secMgr != nil {
194+ secMgr.RegisterUriSchemeAsLocal("dumb")
195+ secMgr.RegisterUriSchemeAsSecure("dumb")
196+ }
197+
198+ h.logger.Info().Msg("dumb:// scheme registered")
199+}
200+
201+// Default page templates
202+
203+const newTabHTML = `<!DOCTYPE html>
204+<html>
205+<head>
206+ <meta charset="utf-8">
207+ <title>New Tab</title>
208+ <style>
209+ body {
210+ font-family: system-ui, -apple-system, sans-serif;
211+ background: #1a1a2e;
212+ color: #eee;
213+ display: flex;
214+ align-items: center;
215+ justify-content: center;
216+ height: 100vh;
217+ margin: 0;
218+ }
219+ .container {
220+ text-align: center;
221+ }
222+ h1 {
223+ font-weight: 300;
224+ color: #888;
225+ }
226+ </style>
227+</head>
228+<body>
229+ <div class="container">
230+ <h1>dumber</h1>
231+ </div>
232+</body>
233+</html>`
234+
235+const errorPageHTML = `<!DOCTYPE html>
236+<html>
237+<head>
238+ <meta charset="utf-8">
239+ <title>Error</title>
240+ <style>
241+ body {
242+ font-family: system-ui, -apple-system, sans-serif;
243+ background: #1a1a2e;
244+ color: #eee;
245+ display: flex;
246+ align-items: center;
247+ justify-content: center;
248+ height: 100vh;
249+ margin: 0;
250+ }
251+ .container {
252+ text-align: center;
253+ }
254+ h1 { color: #e74c3c; }
255+ p { color: #888; }
256+ </style>
257+</head>
258+<body>
259+ <div class="container">
260+ <h1>Error</h1>
261+ <p>The page could not be loaded.</p>
262+ </div>
263+</body>
264+</html>`
265+
266+const notFoundHTML = `<!DOCTYPE html>
267+<html>
268+<head>
269+ <meta charset="utf-8">
270+ <title>Not Found</title>
271+ <style>
272+ body {
273+ font-family: system-ui, -apple-system, sans-serif;
274+ background: #1a1a2e;
275+ color: #eee;
276+ display: flex;
277+ align-items: center;
278+ justify-content: center;
279+ height: 100vh;
280+ margin: 0;
281+ }
282+ .container {
283+ text-align: center;
284+ }
285+ h1 { color: #f39c12; }
286+ p { color: #888; }
287+ </style>
288+</head>
289+<body>
290+ <div class="container">
291+ <h1>404</h1>
292+ <p>Page not found.</p>
293+ </div>
294+</body>
295+</html>`
A internal/infrastructure/webkit/settings.go +147 -0
@@ -0,0 +1,147 @@
1+package webkit
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/puregotk-webkit/webkit"
8+ "github.com/rs/zerolog"
9+)
10+
11+// SettingsManager creates and manages WebKit Settings instances from config.
12+type SettingsManager struct {
13+ cfg *config.Config
14+ logger zerolog.Logger
15+ mu sync.RWMutex
16+}
17+
18+// NewSettingsManager creates a new SettingsManager with the given config.
19+func NewSettingsManager(cfg *config.Config, logger zerolog.Logger) *SettingsManager {
20+ return &SettingsManager{
21+ cfg: cfg,
22+ logger: logger.With().Str("component", "webkit-settings").Logger(),
23+ }
24+}
25+
26+// CreateSettings creates a new webkit.Settings instance configured from the current config.
27+func (sm *SettingsManager) CreateSettings() *webkit.Settings {
28+ sm.mu.RLock()
29+ cfg := sm.cfg
30+ sm.mu.RUnlock()
31+
32+ settings := webkit.NewSettings()
33+ if settings == nil {
34+ sm.logger.Error().Msg("failed to create webkit settings")
35+ return nil
36+ }
37+
38+ sm.applySettings(settings, cfg)
39+ return settings
40+}
41+
42+// applySettings applies configuration to a webkit.Settings instance.
43+func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config.Config) {
44+ // JavaScript settings
45+ settings.SetEnableJavascript(true)
46+ settings.SetEnableJavascriptMarkup(true)
47+
48+ // Font settings from config
49+ if cfg.Appearance.SansFont != "" {
50+ settings.SetDefaultFontFamily(cfg.Appearance.SansFont)
51+ settings.SetSansSerifFontFamily(cfg.Appearance.SansFont)
52+ }
53+ if cfg.Appearance.SerifFont != "" {
54+ settings.SetSerifFontFamily(cfg.Appearance.SerifFont)
55+ }
56+ if cfg.Appearance.MonospaceFont != "" {
57+ settings.SetMonospaceFontFamily(cfg.Appearance.MonospaceFont)
58+ }
59+ if cfg.Appearance.DefaultFontSize > 0 {
60+ settings.SetDefaultFontSize(uint32(cfg.Appearance.DefaultFontSize))
61+ }
62+
63+ // Hardware acceleration based on rendering mode
64+ switch cfg.RenderingMode {
65+ case config.RenderingModeGPU:
66+ settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue)
67+ case config.RenderingModeCPU:
68+ settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue)
69+ case config.RenderingModeAuto:
70+ // Default to always for auto mode (WebKit handles capability detection)
71+ settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue)
72+ }
73+
74+ // Debug settings
75+ settings.SetEnableDeveloperExtras(cfg.Debug.EnableWebKitDebug)
76+ settings.SetEnableWriteConsoleMessagesToStdout(cfg.Logging.CaptureConsole)
77+
78+ // Browsing experience
79+ settings.SetEnableSmoothScrolling(true)
80+ settings.SetEnableDnsPrefetching(true)
81+ settings.SetEnablePageCache(true)
82+ settings.SetEnableSiteSpecificQuirks(true)
83+
84+ // Media settings
85+ settings.SetEnableWebaudio(true)
86+ settings.SetEnableWebgl(true)
87+ settings.SetEnableMedia(true)
88+ settings.SetEnableMediasource(true)
89+ settings.SetEnableMediaCapabilities(true)
90+ settings.SetEnableMediaStream(true)
91+ settings.SetEnableEncryptedMedia(true)
92+ settings.SetMediaPlaybackRequiresUserGesture(true)
93+
94+ // HTML5 storage
95+ settings.SetEnableHtml5LocalStorage(true)
96+ settings.SetEnableHtml5Database(true)
97+
98+ // UI behavior - keyboard-driven browser doesn't need swipe gestures
99+ settings.SetEnableBackForwardNavigationGestures(false)
100+ settings.SetEnableFullscreen(true)
101+
102+ // Canvas acceleration
103+ settings.SetEnable2dCanvasAcceleration(true)
104+
105+ // WebRTC
106+ settings.SetEnableWebrtc(true)
107+
108+ sm.logger.Debug().
109+ Str("sans_font", cfg.Appearance.SansFont).
110+ Str("rendering_mode", string(cfg.RenderingMode)).
111+ Bool("developer_extras", cfg.Debug.EnableWebKitDebug).
112+ Msg("settings applied")
113+}
114+
115+// UpdateFromConfig updates the manager with a new config (for hot-reload).
116+// Note: This doesn't update already-created Settings instances.
117+// New WebViews will use the updated config.
118+func (sm *SettingsManager) UpdateFromConfig(cfg *config.Config) {
119+ sm.mu.Lock()
120+ defer sm.mu.Unlock()
121+ sm.cfg = cfg
122+ sm.logger.Debug().Msg("settings config updated")
123+}
124+
125+// ApplyToWebView applies current settings to an existing WebView.
126+// This can be used to update a WebView's settings after config hot-reload.
127+func (sm *SettingsManager) ApplyToWebView(wv *webkit.WebView) {
128+ if wv == nil {
129+ return
130+ }
131+
132+ settings := sm.CreateSettings()
133+ if settings == nil {
134+ return
135+ }
136+
137+ // WebView.GetSettings() returns the current settings - we can modify them directly
138+ // For a full replacement, we'd need webkit_web_view_set_settings which may not be available
139+ // Instead, we apply to the existing settings object
140+ existingSettings := wv.GetSettings()
141+ if existingSettings != nil {
142+ sm.mu.RLock()
143+ cfg := sm.cfg
144+ sm.mu.RUnlock()
145+ sm.applySettings(existingSettings, cfg)
146+ }
147+}
@@ -0,0 +1,147 @@
1+package webkit
2+
3+import (
4+ "sync"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/puregotk-webkit/webkit"
8+ "github.com/rs/zerolog"
9+)
10+
11+// SettingsManager creates and manages WebKit Settings instances from config.
12+type SettingsManager struct {
13+ cfg *config.Config
14+ logger zerolog.Logger
15+ mu sync.RWMutex
16+}
17+
18+// NewSettingsManager creates a new SettingsManager with the given config.
19+func NewSettingsManager(cfg *config.Config, logger zerolog.Logger) *SettingsManager {
20+ return &SettingsManager{
21+ cfg: cfg,
22+ logger: logger.With().Str("component", "webkit-settings").Logger(),
23+ }
24+}
25+
26+// CreateSettings creates a new webkit.Settings instance configured from the current config.
27+func (sm *SettingsManager) CreateSettings() *webkit.Settings {
28+ sm.mu.RLock()
29+ cfg := sm.cfg
30+ sm.mu.RUnlock()
31+
32+ settings := webkit.NewSettings()
33+ if settings == nil {
34+ sm.logger.Error().Msg("failed to create webkit settings")
35+ return nil
36+ }
37+
38+ sm.applySettings(settings, cfg)
39+ return settings
40+}
41+
42+// applySettings applies configuration to a webkit.Settings instance.
43+func (sm *SettingsManager) applySettings(settings *webkit.Settings, cfg *config.Config) {
44+ // JavaScript settings
45+ settings.SetEnableJavascript(true)
46+ settings.SetEnableJavascriptMarkup(true)
47+
48+ // Font settings from config
49+ if cfg.Appearance.SansFont != "" {
50+ settings.SetDefaultFontFamily(cfg.Appearance.SansFont)
51+ settings.SetSansSerifFontFamily(cfg.Appearance.SansFont)
52+ }
53+ if cfg.Appearance.SerifFont != "" {
54+ settings.SetSerifFontFamily(cfg.Appearance.SerifFont)
55+ }
56+ if cfg.Appearance.MonospaceFont != "" {
57+ settings.SetMonospaceFontFamily(cfg.Appearance.MonospaceFont)
58+ }
59+ if cfg.Appearance.DefaultFontSize > 0 {
60+ settings.SetDefaultFontSize(uint32(cfg.Appearance.DefaultFontSize))
61+ }
62+
63+ // Hardware acceleration based on rendering mode
64+ switch cfg.RenderingMode {
65+ case config.RenderingModeGPU:
66+ settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue)
67+ case config.RenderingModeCPU:
68+ settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue)
69+ case config.RenderingModeAuto:
70+ // Default to always for auto mode (WebKit handles capability detection)
71+ settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue)
72+ }
73+
74+ // Debug settings
75+ settings.SetEnableDeveloperExtras(cfg.Debug.EnableWebKitDebug)
76+ settings.SetEnableWriteConsoleMessagesToStdout(cfg.Logging.CaptureConsole)
77+
78+ // Browsing experience
79+ settings.SetEnableSmoothScrolling(true)
80+ settings.SetEnableDnsPrefetching(true)
81+ settings.SetEnablePageCache(true)
82+ settings.SetEnableSiteSpecificQuirks(true)
83+
84+ // Media settings
85+ settings.SetEnableWebaudio(true)
86+ settings.SetEnableWebgl(true)
87+ settings.SetEnableMedia(true)
88+ settings.SetEnableMediasource(true)
89+ settings.SetEnableMediaCapabilities(true)
90+ settings.SetEnableMediaStream(true)
91+ settings.SetEnableEncryptedMedia(true)
92+ settings.SetMediaPlaybackRequiresUserGesture(true)
93+
94+ // HTML5 storage
95+ settings.SetEnableHtml5LocalStorage(true)
96+ settings.SetEnableHtml5Database(true)
97+
98+ // UI behavior - keyboard-driven browser doesn't need swipe gestures
99+ settings.SetEnableBackForwardNavigationGestures(false)
100+ settings.SetEnableFullscreen(true)
101+
102+ // Canvas acceleration
103+ settings.SetEnable2dCanvasAcceleration(true)
104+
105+ // WebRTC
106+ settings.SetEnableWebrtc(true)
107+
108+ sm.logger.Debug().
109+ Str("sans_font", cfg.Appearance.SansFont).
110+ Str("rendering_mode", string(cfg.RenderingMode)).
111+ Bool("developer_extras", cfg.Debug.EnableWebKitDebug).
112+ Msg("settings applied")
113+}
114+
115+// UpdateFromConfig updates the manager with a new config (for hot-reload).
116+// Note: This doesn't update already-created Settings instances.
117+// New WebViews will use the updated config.
118+func (sm *SettingsManager) UpdateFromConfig(cfg *config.Config) {
119+ sm.mu.Lock()
120+ defer sm.mu.Unlock()
121+ sm.cfg = cfg
122+ sm.logger.Debug().Msg("settings config updated")
123+}
124+
125+// ApplyToWebView applies current settings to an existing WebView.
126+// This can be used to update a WebView's settings after config hot-reload.
127+func (sm *SettingsManager) ApplyToWebView(wv *webkit.WebView) {
128+ if wv == nil {
129+ return
130+ }
131+
132+ settings := sm.CreateSettings()
133+ if settings == nil {
134+ return
135+ }
136+
137+ // WebView.GetSettings() returns the current settings - we can modify them directly
138+ // For a full replacement, we'd need webkit_web_view_set_settings which may not be available
139+ // Instead, we apply to the existing settings object
140+ existingSettings := wv.GetSettings()
141+ if existingSettings != nil {
142+ sm.mu.RLock()
143+ cfg := sm.cfg
144+ sm.mu.RUnlock()
145+ sm.applySettings(existingSettings, cfg)
146+ }
147+}
A internal/infrastructure/webkit/webview.go +324 -0
@@ -0,0 +1,324 @@
1+package webkit
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "sync/atomic"
7+
8+ "github.com/bnema/puregotk-webkit/webkit"
9+ "github.com/rs/zerolog"
10+)
11+
12+// WebViewID uniquely identifies a WebView instance.
13+type WebViewID uint64
14+
15+// LoadEvent represents WebKit load events.
16+type LoadEvent int
17+
18+const (
19+ LoadStarted LoadEvent = LoadEvent(webkit.LoadStartedValue)
20+ LoadRedirected LoadEvent = LoadEvent(webkit.LoadRedirectedValue)
21+ LoadCommitted LoadEvent = LoadEvent(webkit.LoadCommittedValue)
22+ LoadFinished LoadEvent = LoadEvent(webkit.LoadFinishedValue)
23+)
24+
25+// webViewRegistry tracks all active WebViews.
26+type webViewRegistry struct {
27+ views map[WebViewID]*WebView
28+ counter atomic.Uint64
29+ mu sync.RWMutex
30+}
31+
32+var globalRegistry = &webViewRegistry{
33+ views: make(map[WebViewID]*WebView),
34+}
35+
36+func (r *webViewRegistry) register(wv *WebView) WebViewID {
37+ id := WebViewID(r.counter.Add(1))
38+ r.mu.Lock()
39+ r.views[id] = wv
40+ r.mu.Unlock()
41+ return id
42+}
43+
44+func (r *webViewRegistry) unregister(id WebViewID) {
45+ r.mu.Lock()
46+ delete(r.views, id)
47+ r.mu.Unlock()
48+}
49+
50+// Lookup returns a WebView by ID, or nil if not found.
51+func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
52+ r.mu.RLock()
53+ defer r.mu.RUnlock()
54+ return r.views[id]
55+}
56+
57+// LookupWebView returns a WebView by ID from the global registry.
58+func LookupWebView(id WebViewID) *WebView {
59+ return globalRegistry.Lookup(id)
60+}
61+
62+// WebView wraps webkit.WebView with Go-level state tracking and callbacks.
63+type WebView struct {
64+ id WebViewID
65+ inner *webkit.WebView
66+
67+ // State (protected by mutex)
68+ destroyed atomic.Bool
69+ uri string
70+ title string
71+ progress float64
72+ canGoBack bool
73+ canGoFwd bool
74+ isLoading bool
75+
76+ // Signal handler IDs for disconnection
77+ signalIDs []uint32
78+
79+ // Callbacks (set by UI layer)
80+ OnLoadChanged func(LoadEvent)
81+ OnTitleChanged func(string)
82+ OnURIChanged func(string)
83+ OnProgressChanged func(float64)
84+ OnClose func()
85+ // TODO(puregotk-webkit): OnCreate for popup support
86+ // See: /home/brice/sync/alpha/obsidian/projects/puregotk-webkit/issues/related-webview.md
87+
88+ logger zerolog.Logger
89+ mu sync.RWMutex
90+}
91+
92+// NewWebView creates a new WebView with the given context and settings.
93+func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.Logger) (*WebView, error) {
94+ if wkCtx == nil || !wkCtx.IsInitialized() {
95+ return nil, fmt.Errorf("webkit context not initialized")
96+ }
97+
98+ inner := webkit.NewWebView()
99+ if inner == nil {
100+ return nil, fmt.Errorf("failed to create webkit webview")
101+ }
102+
103+ wv := &WebView{
104+ inner: inner,
105+ logger: logger.With().Str("component", "webview").Logger(),
106+ signalIDs: make([]uint32, 0, 4),
107+ }
108+
109+ // Register in global registry
110+ wv.id = globalRegistry.register(wv)
111+
112+ // Apply settings if provided
113+ if settings != nil {
114+ settings.ApplyToWebView(inner)
115+ }
116+
117+ // Connect signals
118+ wv.connectSignals()
119+
120+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview created")
121+
122+ return wv, nil
123+}
124+
125+// connectSignals sets up signal handlers for the WebView.
126+func (wv *WebView) connectSignals() {
127+ // load-changed signal
128+ loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
129+ wv.mu.Lock()
130+ wv.uri = inner.GetUri()
131+ wv.title = inner.GetTitle()
132+ wv.canGoBack = inner.CanGoBack()
133+ wv.canGoFwd = inner.CanGoForward()
134+ wv.progress = inner.GetEstimatedLoadProgress()
135+
136+ switch event {
137+ case webkit.LoadStartedValue:
138+ wv.isLoading = true
139+ case webkit.LoadFinishedValue:
140+ wv.isLoading = false
141+ }
142+ wv.mu.Unlock()
143+
144+ if wv.OnLoadChanged != nil {
145+ wv.OnLoadChanged(LoadEvent(event))
146+ }
147+ }
148+ sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
149+ wv.signalIDs = append(wv.signalIDs, sigID)
150+
151+ // close signal
152+ closeCb := func(inner webkit.WebView) {
153+ if wv.OnClose != nil {
154+ wv.OnClose()
155+ }
156+ }
157+ sigID = wv.inner.ConnectClose(&closeCb)
158+ wv.signalIDs = append(wv.signalIDs, sigID)
159+
160+ // Note: notify::title, notify::uri, notify::estimated-load-progress
161+ // would need GObject property change notifications which may require
162+ // different API patterns in puregotk. For now, we update these in
163+ // load-changed which covers most cases.
164+}
165+
166+// ID returns the unique identifier for this WebView.
167+func (wv *WebView) ID() WebViewID {
168+ return wv.id
169+}
170+
171+// Widget returns the underlying webkit.WebView for GTK embedding.
172+func (wv *WebView) Widget() *webkit.WebView {
173+ return wv.inner
174+}
175+
176+// LoadURI loads the given URI.
177+func (wv *WebView) LoadURI(uri string) error {
178+ if wv.destroyed.Load() {
179+ return fmt.Errorf("webview %d is destroyed", wv.id)
180+ }
181+ wv.inner.LoadUri(uri)
182+ wv.logger.Debug().Str("uri", uri).Msg("loading URI")
183+ return nil
184+}
185+
186+// LoadHTML loads HTML content with an optional base URI.
187+func (wv *WebView) LoadHTML(content, baseURI string) error {
188+ if wv.destroyed.Load() {
189+ return fmt.Errorf("webview %d is destroyed", wv.id)
190+ }
191+ wv.inner.LoadHtml(content, baseURI)
192+ return nil
193+}
194+
195+// Reload reloads the current page.
196+func (wv *WebView) Reload() error {
197+ if wv.destroyed.Load() {
198+ return fmt.Errorf("webview %d is destroyed", wv.id)
199+ }
200+ wv.inner.Reload()
201+ return nil
202+}
203+
204+// ReloadBypassCache reloads the current page, bypassing the cache.
205+func (wv *WebView) ReloadBypassCache() error {
206+ if wv.destroyed.Load() {
207+ return fmt.Errorf("webview %d is destroyed", wv.id)
208+ }
209+ wv.inner.ReloadBypassCache()
210+ return nil
211+}
212+
213+// Stop stops the current load operation.
214+func (wv *WebView) Stop() error {
215+ if wv.destroyed.Load() {
216+ return fmt.Errorf("webview %d is destroyed", wv.id)
217+ }
218+ wv.inner.StopLoading()
219+ return nil
220+}
221+
222+// GoBack navigates back in history.
223+func (wv *WebView) GoBack() error {
224+ if wv.destroyed.Load() {
225+ return fmt.Errorf("webview %d is destroyed", wv.id)
226+ }
227+ if !wv.inner.CanGoBack() {
228+ return fmt.Errorf("cannot go back")
229+ }
230+ wv.inner.GoBack()
231+ return nil
232+}
233+
234+// GoForward navigates forward in history.
235+func (wv *WebView) GoForward() error {
236+ if wv.destroyed.Load() {
237+ return fmt.Errorf("webview %d is destroyed", wv.id)
238+ }
239+ if !wv.inner.CanGoForward() {
240+ return fmt.Errorf("cannot go forward")
241+ }
242+ wv.inner.GoForward()
243+ return nil
244+}
245+
246+// CanGoBack returns true if back navigation is possible.
247+func (wv *WebView) CanGoBack() bool {
248+ wv.mu.RLock()
249+ defer wv.mu.RUnlock()
250+ return wv.canGoBack
251+}
252+
253+// CanGoForward returns true if forward navigation is possible.
254+func (wv *WebView) CanGoForward() bool {
255+ wv.mu.RLock()
256+ defer wv.mu.RUnlock()
257+ return wv.canGoFwd
258+}
259+
260+// URI returns the current URI.
261+func (wv *WebView) URI() string {
262+ wv.mu.RLock()
263+ defer wv.mu.RUnlock()
264+ return wv.uri
265+}
266+
267+// Title returns the current page title.
268+func (wv *WebView) Title() string {
269+ wv.mu.RLock()
270+ defer wv.mu.RUnlock()
271+ return wv.title
272+}
273+
274+// IsLoading returns true if a page is currently loading.
275+func (wv *WebView) IsLoading() bool {
276+ wv.mu.RLock()
277+ defer wv.mu.RUnlock()
278+ return wv.isLoading
279+}
280+
281+// EstimatedProgress returns the estimated load progress (0.0 to 1.0).
282+func (wv *WebView) EstimatedProgress() float64 {
283+ wv.mu.RLock()
284+ defer wv.mu.RUnlock()
285+ return wv.progress
286+}
287+
288+// SetZoomLevel sets the zoom level (1.0 = 100%).
289+func (wv *WebView) SetZoomLevel(level float64) error {
290+ if wv.destroyed.Load() {
291+ return fmt.Errorf("webview %d is destroyed", wv.id)
292+ }
293+ wv.inner.SetZoomLevel(level)
294+ return nil
295+}
296+
297+// GetZoomLevel returns the current zoom level.
298+func (wv *WebView) GetZoomLevel() float64 {
299+ if wv.destroyed.Load() {
300+ return 1.0
301+ }
302+ return wv.inner.GetZoomLevel()
303+}
304+
305+// IsDestroyed returns true if the WebView has been destroyed.
306+func (wv *WebView) IsDestroyed() bool {
307+ return wv.destroyed.Load()
308+}
309+
310+// Destroy cleans up the WebView resources.
311+func (wv *WebView) Destroy() {
312+ if wv.destroyed.Swap(true) {
313+ return // Already destroyed
314+ }
315+
316+ // Unregister from global registry
317+ globalRegistry.unregister(wv.id)
318+
319+ // Note: Signal disconnection and GTK widget destruction
320+ // would happen here in a full implementation.
321+ // For now, we rely on GTK's reference counting.
322+
323+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed")
324+}
@@ -0,0 +1,324 @@
1+package webkit
2+
3+import (
4+ "fmt"
5+ "sync"
6+ "sync/atomic"
7+
8+ "github.com/bnema/puregotk-webkit/webkit"
9+ "github.com/rs/zerolog"
10+)
11+
12+// WebViewID uniquely identifies a WebView instance.
13+type WebViewID uint64
14+
15+// LoadEvent represents WebKit load events.
16+type LoadEvent int
17+
18+const (
19+ LoadStarted LoadEvent = LoadEvent(webkit.LoadStartedValue)
20+ LoadRedirected LoadEvent = LoadEvent(webkit.LoadRedirectedValue)
21+ LoadCommitted LoadEvent = LoadEvent(webkit.LoadCommittedValue)
22+ LoadFinished LoadEvent = LoadEvent(webkit.LoadFinishedValue)
23+)
24+
25+// webViewRegistry tracks all active WebViews.
26+type webViewRegistry struct {
27+ views map[WebViewID]*WebView
28+ counter atomic.Uint64
29+ mu sync.RWMutex
30+}
31+
32+var globalRegistry = &webViewRegistry{
33+ views: make(map[WebViewID]*WebView),
34+}
35+
36+func (r *webViewRegistry) register(wv *WebView) WebViewID {
37+ id := WebViewID(r.counter.Add(1))
38+ r.mu.Lock()
39+ r.views[id] = wv
40+ r.mu.Unlock()
41+ return id
42+}
43+
44+func (r *webViewRegistry) unregister(id WebViewID) {
45+ r.mu.Lock()
46+ delete(r.views, id)
47+ r.mu.Unlock()
48+}
49+
50+// Lookup returns a WebView by ID, or nil if not found.
51+func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
52+ r.mu.RLock()
53+ defer r.mu.RUnlock()
54+ return r.views[id]
55+}
56+
57+// LookupWebView returns a WebView by ID from the global registry.
58+func LookupWebView(id WebViewID) *WebView {
59+ return globalRegistry.Lookup(id)
60+}
61+
62+// WebView wraps webkit.WebView with Go-level state tracking and callbacks.
63+type WebView struct {
64+ id WebViewID
65+ inner *webkit.WebView
66+
67+ // State (protected by mutex)
68+ destroyed atomic.Bool
69+ uri string
70+ title string
71+ progress float64
72+ canGoBack bool
73+ canGoFwd bool
74+ isLoading bool
75+
76+ // Signal handler IDs for disconnection
77+ signalIDs []uint32
78+
79+ // Callbacks (set by UI layer)
80+ OnLoadChanged func(LoadEvent)
81+ OnTitleChanged func(string)
82+ OnURIChanged func(string)
83+ OnProgressChanged func(float64)
84+ OnClose func()
85+ // TODO(puregotk-webkit): OnCreate for popup support
86+ // See: /home/brice/sync/alpha/obsidian/projects/puregotk-webkit/issues/related-webview.md
87+
88+ logger zerolog.Logger
89+ mu sync.RWMutex
90+}
91+
92+// NewWebView creates a new WebView with the given context and settings.
93+func NewWebView(wkCtx *WebKitContext, settings *SettingsManager, logger zerolog.Logger) (*WebView, error) {
94+ if wkCtx == nil || !wkCtx.IsInitialized() {
95+ return nil, fmt.Errorf("webkit context not initialized")
96+ }
97+
98+ inner := webkit.NewWebView()
99+ if inner == nil {
100+ return nil, fmt.Errorf("failed to create webkit webview")
101+ }
102+
103+ wv := &WebView{
104+ inner: inner,
105+ logger: logger.With().Str("component", "webview").Logger(),
106+ signalIDs: make([]uint32, 0, 4),
107+ }
108+
109+ // Register in global registry
110+ wv.id = globalRegistry.register(wv)
111+
112+ // Apply settings if provided
113+ if settings != nil {
114+ settings.ApplyToWebView(inner)
115+ }
116+
117+ // Connect signals
118+ wv.connectSignals()
119+
120+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview created")
121+
122+ return wv, nil
123+}
124+
125+// connectSignals sets up signal handlers for the WebView.
126+func (wv *WebView) connectSignals() {
127+ // load-changed signal
128+ loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
129+ wv.mu.Lock()
130+ wv.uri = inner.GetUri()
131+ wv.title = inner.GetTitle()
132+ wv.canGoBack = inner.CanGoBack()
133+ wv.canGoFwd = inner.CanGoForward()
134+ wv.progress = inner.GetEstimatedLoadProgress()
135+
136+ switch event {
137+ case webkit.LoadStartedValue:
138+ wv.isLoading = true
139+ case webkit.LoadFinishedValue:
140+ wv.isLoading = false
141+ }
142+ wv.mu.Unlock()
143+
144+ if wv.OnLoadChanged != nil {
145+ wv.OnLoadChanged(LoadEvent(event))
146+ }
147+ }
148+ sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
149+ wv.signalIDs = append(wv.signalIDs, sigID)
150+
151+ // close signal
152+ closeCb := func(inner webkit.WebView) {
153+ if wv.OnClose != nil {
154+ wv.OnClose()
155+ }
156+ }
157+ sigID = wv.inner.ConnectClose(&closeCb)
158+ wv.signalIDs = append(wv.signalIDs, sigID)
159+
160+ // Note: notify::title, notify::uri, notify::estimated-load-progress
161+ // would need GObject property change notifications which may require
162+ // different API patterns in puregotk. For now, we update these in
163+ // load-changed which covers most cases.
164+}
165+
166+// ID returns the unique identifier for this WebView.
167+func (wv *WebView) ID() WebViewID {
168+ return wv.id
169+}
170+
171+// Widget returns the underlying webkit.WebView for GTK embedding.
172+func (wv *WebView) Widget() *webkit.WebView {
173+ return wv.inner
174+}
175+
176+// LoadURI loads the given URI.
177+func (wv *WebView) LoadURI(uri string) error {
178+ if wv.destroyed.Load() {
179+ return fmt.Errorf("webview %d is destroyed", wv.id)
180+ }
181+ wv.inner.LoadUri(uri)
182+ wv.logger.Debug().Str("uri", uri).Msg("loading URI")
183+ return nil
184+}
185+
186+// LoadHTML loads HTML content with an optional base URI.
187+func (wv *WebView) LoadHTML(content, baseURI string) error {
188+ if wv.destroyed.Load() {
189+ return fmt.Errorf("webview %d is destroyed", wv.id)
190+ }
191+ wv.inner.LoadHtml(content, baseURI)
192+ return nil
193+}
194+
195+// Reload reloads the current page.
196+func (wv *WebView) Reload() error {
197+ if wv.destroyed.Load() {
198+ return fmt.Errorf("webview %d is destroyed", wv.id)
199+ }
200+ wv.inner.Reload()
201+ return nil
202+}
203+
204+// ReloadBypassCache reloads the current page, bypassing the cache.
205+func (wv *WebView) ReloadBypassCache() error {
206+ if wv.destroyed.Load() {
207+ return fmt.Errorf("webview %d is destroyed", wv.id)
208+ }
209+ wv.inner.ReloadBypassCache()
210+ return nil
211+}
212+
213+// Stop stops the current load operation.
214+func (wv *WebView) Stop() error {
215+ if wv.destroyed.Load() {
216+ return fmt.Errorf("webview %d is destroyed", wv.id)
217+ }
218+ wv.inner.StopLoading()
219+ return nil
220+}
221+
222+// GoBack navigates back in history.
223+func (wv *WebView) GoBack() error {
224+ if wv.destroyed.Load() {
225+ return fmt.Errorf("webview %d is destroyed", wv.id)
226+ }
227+ if !wv.inner.CanGoBack() {
228+ return fmt.Errorf("cannot go back")
229+ }
230+ wv.inner.GoBack()
231+ return nil
232+}
233+
234+// GoForward navigates forward in history.
235+func (wv *WebView) GoForward() error {
236+ if wv.destroyed.Load() {
237+ return fmt.Errorf("webview %d is destroyed", wv.id)
238+ }
239+ if !wv.inner.CanGoForward() {
240+ return fmt.Errorf("cannot go forward")
241+ }
242+ wv.inner.GoForward()
243+ return nil
244+}
245+
246+// CanGoBack returns true if back navigation is possible.
247+func (wv *WebView) CanGoBack() bool {
248+ wv.mu.RLock()
249+ defer wv.mu.RUnlock()
250+ return wv.canGoBack
251+}
252+
253+// CanGoForward returns true if forward navigation is possible.
254+func (wv *WebView) CanGoForward() bool {
255+ wv.mu.RLock()
256+ defer wv.mu.RUnlock()
257+ return wv.canGoFwd
258+}
259+
260+// URI returns the current URI.
261+func (wv *WebView) URI() string {
262+ wv.mu.RLock()
263+ defer wv.mu.RUnlock()
264+ return wv.uri
265+}
266+
267+// Title returns the current page title.
268+func (wv *WebView) Title() string {
269+ wv.mu.RLock()
270+ defer wv.mu.RUnlock()
271+ return wv.title
272+}
273+
274+// IsLoading returns true if a page is currently loading.
275+func (wv *WebView) IsLoading() bool {
276+ wv.mu.RLock()
277+ defer wv.mu.RUnlock()
278+ return wv.isLoading
279+}
280+
281+// EstimatedProgress returns the estimated load progress (0.0 to 1.0).
282+func (wv *WebView) EstimatedProgress() float64 {
283+ wv.mu.RLock()
284+ defer wv.mu.RUnlock()
285+ return wv.progress
286+}
287+
288+// SetZoomLevel sets the zoom level (1.0 = 100%).
289+func (wv *WebView) SetZoomLevel(level float64) error {
290+ if wv.destroyed.Load() {
291+ return fmt.Errorf("webview %d is destroyed", wv.id)
292+ }
293+ wv.inner.SetZoomLevel(level)
294+ return nil
295+}
296+
297+// GetZoomLevel returns the current zoom level.
298+func (wv *WebView) GetZoomLevel() float64 {
299+ if wv.destroyed.Load() {
300+ return 1.0
301+ }
302+ return wv.inner.GetZoomLevel()
303+}
304+
305+// IsDestroyed returns true if the WebView has been destroyed.
306+func (wv *WebView) IsDestroyed() bool {
307+ return wv.destroyed.Load()
308+}
309+
310+// Destroy cleans up the WebView resources.
311+func (wv *WebView) Destroy() {
312+ if wv.destroyed.Swap(true) {
313+ return // Already destroyed
314+ }
315+
316+ // Unregister from global registry
317+ globalRegistry.unregister(wv.id)
318+
319+ // Note: Signal disconnection and GTK widget destruction
320+ // would happen here in a full implementation.
321+ // For now, we rely on GTK's reference counting.
322+
323+ wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed")
324+}

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