v0.11.0 dc50343

webview.go RawHistoryFolder

1package browser
2
3import (
4	"context"
5	"fmt"
6	"log"
7	"os"
8	"path/filepath"
9	"strings"
10	"time"
11
12	"github.com/bnema/dumber/internal/app/constants"
13	"github.com/bnema/dumber/internal/app/control"
14	"github.com/bnema/dumber/internal/app/environment"
15	"github.com/bnema/dumber/internal/app/messaging"
16	"github.com/bnema/dumber/internal/config"
17	"github.com/bnema/dumber/internal/filtering"
18	"github.com/bnema/dumber/pkg/webkit"
19)
20
21// buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig
22func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {
23	mc := webkit.MemoryConfig{
24		MemoryLimitMB:           cfg.MemoryLimitMB,
25		ConservativeThreshold:   cfg.ConservativeThreshold,
26		StrictThreshold:         cfg.StrictThreshold,
27		KillThreshold:           cfg.KillThreshold,
28		PollIntervalSeconds:     cfg.PollIntervalSeconds,
29		EnableGCInterval:        cfg.EnableGCInterval,
30		ProcessRecycleThreshold: cfg.ProcessRecycleThreshold,
31		EnablePageCache:         cfg.EnablePageCache,
32		EnableMemoryMonitoring:  cfg.EnableMemoryMonitoring,
33	}
34
35	// Convert string cache model to webkit constant
36	switch strings.ToLower(cfg.CacheModel) {
37	case "document_viewer", "documentviewer", "doc":
38		mc.CacheModel = webkit.CacheModelDocumentViewer
39	case "primary_web_browser", "primary", "primarywebbrowser":
40		mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
41	default:
42		mc.CacheModel = webkit.CacheModelWebBrowser
43	}
44
45	return mc
46}
47
48func buildColorPalettes(cfg config.AppearanceConfig) webkit.ColorPalettes {
49	toPalette := func(src config.ColorPalette) webkit.ColorPalette {
50		return webkit.ColorPalette{
51			Background:     src.Background,
52			Surface:        src.Surface,
53			SurfaceVariant: src.SurfaceVariant,
54			Text:           src.Text,
55			Muted:          src.Muted,
56			Accent:         src.Accent,
57			Border:         src.Border,
58		}
59	}
60
61	return webkit.ColorPalettes{
62		Light: toPalette(cfg.LightPalette),
63		Dark:  toPalette(cfg.DarkPalette),
64	}
65}
66
67func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
68	dataDir, err := config.GetDataDir()
69	if err != nil {
70		return nil, err
71	}
72	stateDir, err := config.GetStateDir()
73	if err != nil {
74		return nil, err
75	}
76
77	webkitData := filepath.Join(dataDir, "webkit")
78	webkitCache := filepath.Join(stateDir, "webkit-cache")
79	if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
80		return nil, err
81	}
82	if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
83		return nil, err
84	}
85
86	cfg := &webkit.Config{
87		Assets:                app.assets,
88		InitialURL:            "dumb://homepage",
89		ZoomDefault:           app.config.DefaultZoom,
90		EnableDeveloperExtras: true,
91		DataDir:               webkitData,
92		CacheDir:              webkitCache,
93		APIToken:              app.config.APISecurity.Token,
94		DefaultSansFont:       app.config.Appearance.SansFont,
95		DefaultSerifFont:      app.config.Appearance.SerifFont,
96		DefaultMonospaceFont:  app.config.Appearance.MonospaceFont,
97		DefaultFontSize:       app.config.Appearance.DefaultFontSize,
98		Rendering:             webkit.RenderingConfig{Mode: string(app.config.RenderingMode)},
99		UseDomZoom:            app.config.UseDomZoom,
100		VideoAcceleration: webkit.VideoAccelerationConfig{
101			EnableVAAPI:      app.config.VideoAcceleration.EnableVAAPI,
102			AutoDetectGPU:    app.config.VideoAcceleration.AutoDetectGPU,
103			VAAPIDriverName:  app.config.VideoAcceleration.VAAPIDriverName,
104			EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers,
105			LegacyVAAPI:      app.config.VideoAcceleration.LegacyVAAPI,
106		},
107		Memory: buildMemoryConfig(app.config.WebkitMemory),
108		CodecPreferences: webkit.CodecPreferencesConfig{
109			PreferredCodecs:           strings.Split(app.config.CodecPreferences.PreferredCodecs, ","),
110			BlockedCodecs:             environment.BuildBlockedCodecsList(app.config.CodecPreferences),
111			ForceAV1:                  app.config.CodecPreferences.ForceAV1,
112			CustomUserAgent:           app.config.CodecPreferences.CustomUserAgent,
113			DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
114		},
115		Colors: buildColorPalettes(app.config.Appearance),
116	}
117
118	return cfg, nil
119}
120
121func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
122	pane := &BrowserPane{
123		webView: view,
124	}
125
126	pane.clipboardController = control.NewClipboardController(view)
127	pane.zoomController = control.NewZoomController(app.browserService, view)
128	pane.navigationController = control.NewNavigationController(
129		app.parserService,
130		app.browserService,
131		view,
132		pane.zoomController,
133	)
134	pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
135	pane.messageHandler.SetWebView(view)
136	pane.messageHandler.SetNavigationController(pane.navigationController)
137	pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
138
139	pane.zoomController.RegisterHandlers()
140	pane.shortcutHandler.RegisterShortcuts()
141
142	// Register navigation handler for workspace focus events if workspace exists
143	if app.workspace != nil {
144		app.workspace.RegisterNavigationHandler(view)
145	}
146
147	// Register favicon handlers for this webview (needed for each pane)
148	// This ensures favicons are detected and cached for all panes, not just the main webview
149	view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {
150		// Call the private handler through a public wrapper or directly via browserService
151		// We can't call handleFaviconURIChanged directly as it's private, so we need to expose it
152		// For now, duplicate the minimal logic here
153		app.browserService.ProcessFaviconURI(pageURI, faviconURI)
154	})
155
156	return pane, nil
157}
158
159func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
160	pane, err := app.buildPane(view)
161	if err != nil {
162		return nil, err
163	}
164
165	// Generate unique pane ID
166	pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
167	pane.initializeGUITracking()
168
169	// GUI manager will be loaded on-demand when needed
170	log.Printf("[webview] Created pane for webview: %s", view.ID())
171
172	app.attachPaneHandlers(pane)
173	return pane, nil
174}
175
176func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
177	if pane == nil || pane.webView == nil {
178		return
179	}
180
181	pane.webView.RegisterTitleChangedHandler(func(title string) {
182		if title == "" {
183			return
184		}
185		url := pane.webView.GetCurrentURL()
186		if url == "" {
187			return
188		}
189
190		// Update database with new title
191		go func(url, title string) {
192			ctx := context.Background()
193			if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
194				log.Printf("Warning: failed to update page title: %v", err)
195			}
196		}(url, title)
197
198		// Update stacked pane title bar if this pane is in a stack
199		if app.workspace != nil {
200			app.workspace.UpdateTitleBar(pane.webView, title)
201		}
202	})
203
204	pane.webView.RegisterScriptMessageHandler(func(payload string) {
205		if app.workspace != nil {
206			app.workspace.focusByView(pane.webView)
207		}
208		pane.messageHandler.Handle(payload)
209	})
210
211	if pane.messageHandler != nil && pane.navigationController != nil {
212		pane.messageHandler.SetNavigationController(pane.navigationController)
213	}
214
215	pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView {
216		if app.workspace != nil {
217			return app.workspace.HandlePopup(pane.webView, uri)
218		}
219
220		// No workspace manager - just navigate in current pane
221		if uri != "" && pane.navigationController != nil {
222			go func() {
223				if err := pane.navigationController.NavigateToURL(uri); err != nil {
224					log.Printf("[webview] failed to navigate to URL %s: %v", uri, err)
225				}
226			}()
227		}
228		return nil
229	})
230}
231
232// createWebView creates and configures the WebView
233func (app *BrowserApp) createWebView() error {
234	log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
235
236	cfg, err := app.buildWebkitConfig()
237	if err != nil {
238		return err
239	}
240
241	// Main window needs a top-level window
242	cfg.CreateWindow = true
243
244	view, err := webkit.NewWebView(cfg)
245
246	if err != nil {
247		return err
248	}
249
250	pane, err := app.createPaneForView(view)
251	if err != nil {
252		return err
253	}
254
255	app.webView = view
256	app.zoomController = pane.zoomController
257	app.navigationController = pane.navigationController
258	app.clipboardController = pane.clipboardController
259	app.messageHandler = pane.messageHandler
260	app.shortcutHandler = pane.shortcutHandler
261	app.panes = []*BrowserPane{pane}
262	app.activePane = pane
263
264	if app.workspace == nil {
265		app.workspace = NewWorkspaceManager(app, pane)
266	}
267
268	// Initialize window-level global shortcuts AFTER workspace is set up
269	if window := view.Window(); window != nil {
270		app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
271		if app.windowShortcutHandler != nil {
272			log.Printf("Window-level global shortcuts initialized")
273		} else {
274			log.Printf("Warning: failed to initialize window-level shortcuts")
275		}
276	}
277
278	app.setupWebViewIntegration()
279
280	// Apply initial zoom and show window
281	app.zoomController.ApplyInitialZoom()
282
283	log.Printf("Showing WebView window…")
284	if err := view.Show(); err != nil {
285		log.Printf("Warning: failed to show WebView: %v", err)
286	} else if !webkit.IsNativeAvailable() {
287		log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
288	}
289
290	return nil
291}
292
293// setupWebViewIntegration connects the WebView to browser services
294func (app *BrowserApp) setupWebViewIntegration() {
295	app.browserService.AttachWebView(app.webView)
296
297	// GUI bundle is loaded via WebKit user scripts in enableUserContentManager
298	// No need to load separately in browser service
299
300	// Use native window as title updater
301	if win := app.webView.Window(); win != nil {
302		app.browserService.SetWindowTitleUpdater(win)
303	}
304}
305
306// setupContentBlocking initializes the content blocking system with proper timing
307func (app *BrowserApp) setupContentBlocking() error {
308	log.Printf("Initializing content blocking system...")
309
310	// Enable WebKit debug logging if requested
311	webkit.SetupWebKitDebugLogging(app.config)
312
313	defer func() {
314		if r := recover(); r != nil {
315			log.Printf("Content blocking setup panic recovered: %v", r)
316		}
317	}()
318
319	// Setup filter system
320	filterManager, err := filtering.SetupFilterSystem()
321	if err != nil {
322		log.Printf("Warning: Failed to setup filter system: %v", err)
323		return nil // Don't fail browser startup, continue without filters
324	}
325
326	// Store reference to filter manager
327	app.filterManager = filterManager
328
329	// Set up callback to re-apply network filters when they become ready
330	// This callback will be called from the async filter loading process
331	filterManager.SetFiltersReadyCallback(func() {
332		// Add small delay to avoid race conditions with WebView initialization
333		go func() {
334			time.Sleep(300 * time.Millisecond)
335			if app.webView != nil {
336				if err := app.webView.UpdateContentFilters(filterManager); err != nil {
337					log.Printf("Failed to apply filters after loading: %v", err)
338				} else {
339					log.Printf("Successfully applied filters after async loading")
340				}
341			}
342		}()
343	})
344
345	// Start async filter loading early (before WebView content blocking initialization)
346	// This allows filters to be ready when the WebView needs them
347	go func() {
348		if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
349			log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
350		}
351	}()
352
353	// Initialize content blocking in WebView with delay
354	// Wait for WebView to be fully loaded before setting up content blocking
355	go func() {
356		// Wait extra time for the first navigation to avoid preconnect interference
357		log.Printf("Waiting for WebView to complete initial load before enabling content blocking...")
358		time.Sleep(3000 * time.Millisecond) // 3 seconds for first load stability
359
360		if err := app.webView.InitializeContentBlocking(filterManager); err != nil {
361			log.Printf("Warning: Failed to initialize content blocking: %v", err)
362			// Continue without content blocking rather than failing
363		}
364
365		// Register navigation handler for domain-specific filtering and GUI injection
366		// Only register after content blocking is initialized
367		app.webView.RegisterURIChangedHandler(func(uri string) {
368			// Add small delay to avoid conflicts with page load
369			go func() {
370				time.Sleep(200 * time.Millisecond) // Slightly longer delay for stability
371
372				// Apply content filtering if available
373				if app.filterManager != nil {
374					app.webView.OnNavigate(uri, app.filterManager)
375				}
376
377				// Note: GUI bundle (controls and toast) is now injected as User Script
378				// in WebKit's enableUserContentManager, so it persists across all navigations
379			}()
380		})
381	}()
382
383	log.Printf("Content blocking system initialization started")
384	return nil
385}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close