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 | + | } | |||